# -*- coding: utf-8 -*-
"""anu_v3.enactor_idempotency — deterministic enact-id + idempotent-skip ledger
for the bounded runtime-event enactor (task-2553+55 §2.9).

회장 §2.9 verbatim: "같은 proposal 재처리 시 idempotent." The bounded enactor
must be a pure deterministic function of the proposal candidate: re-processing
the *same* proposal must NOT produce a second additive artifact, a duplicate
decision, or a re-write. This module supplies:

  * ``enact_id(...)`` — a deterministic content hash over the *normative*
    fields of a proposal (proposal_type · source_id · settle identity ·
    additive artifact target). Free-form note text is deliberately excluded
    so a cosmetic note change never forces a duplicate enact.
  * ``already_enacted(...)`` — reads an existing additive artifact (if any)
    READ-ONLY and reports whether it already carries this exact ``enact_id``
    (idempotent skip) or a *different* one at the same path (a binding
    conflict — never silently overwritten).

The module performs ZERO writes itself — it only *decides* idempotency. The
caller (the enactor) performs the allowlisted additive write only when this
module reports ``should_write is True``.

Layer A / NO-CRON: pure. ZERO cron / dispatch / subprocess / cokacdir /
merge / PR / branch / credential.
"""
from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional, Sequence

IDEMPOTENCY_SCHEMA = "anu_v3.enactor_idempotency.v1"

IDEMPOTENT_FIRST_WRITE = "FIRST_WRITE"
IDEMPOTENT_SKIP = "IDEMPOTENT_SKIP"
IDEMPOTENT_BINDING_CONFLICT = "ENACT_BINDING_CONFLICT"


def _canonical(value: object) -> str:
    """Stable JSON canonicalisation (sorted keys, no whitespace drift)."""
    return json.dumps(
        value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
    )


def enact_id(
    *,
    proposal_type: str,
    source_id: str,
    settle_identity: Sequence[object],
    artifact_target: str,
) -> str:
    """Deterministic enact identity.

    ``settle_identity`` = the normative tuple that makes this enact unique
    (e.g. batch_id + sorted track task_ids + all_authoritative_pass). It is
    canonicalised so element ordering / dict key ordering never perturbs the
    hash. The same proposal -> the same id -> an idempotent skip.
    """
    payload = _canonical(
        {
            "proposal_type": proposal_type,
            "source_id": source_id,
            "settle_identity": list(settle_identity),
            "artifact_target": artifact_target,
        }
    )
    return hashlib.sha256(payload.encode("utf-8")).hexdigest()


@dataclass
class IdempotencyDecision:
    schema: str
    enact_id: str
    artifact_target: str
    classification: str  # FIRST_WRITE | IDEMPOTENT_SKIP | ENACT_BINDING_CONFLICT
    should_write: bool
    existing_enact_id: Optional[str]
    reasons: List[str] = field(default_factory=list)

    @property
    def idempotent_skip(self) -> bool:
        return self.classification == IDEMPOTENT_SKIP

    def to_json(self) -> Dict[str, object]:
        return {
            "schema": self.schema,
            "enact_id": self.enact_id,
            "artifact_target": self.artifact_target,
            "classification": self.classification,
            "should_write": self.should_write,
            "existing_enact_id": self.existing_enact_id,
            "reasons": list(self.reasons),
        }


def already_enacted(
    *,
    eid: str,
    artifact_target: str,
    workspace_root: object,
) -> IdempotencyDecision:
    """READ-ONLY idempotency probe over an existing additive artifact.

    * absent target               -> FIRST_WRITE  (should_write=True)
    * present & embedded id == eid -> IDEMPOTENT_SKIP (should_write=False,
                                      byte-0 — never re-written)
    * present & embedded id != eid -> ENACT_BINDING_CONFLICT (should_write
                                      =False — a different enact already
                                      owns this path; never silently
                                      overwritten, escalated by the caller)
    * present but unreadable/no id -> ENACT_BINDING_CONFLICT (fail-closed —
                                      an opaque pre-existing file at the
                                      additive path is never clobbered)
    """
    path = Path(str(workspace_root)) / artifact_target
    if not path.is_file():
        return IdempotencyDecision(
            schema=IDEMPOTENCY_SCHEMA,
            enact_id=eid,
            artifact_target=artifact_target,
            classification=IDEMPOTENT_FIRST_WRITE,
            should_write=True,
            existing_enact_id=None,
            reasons=[
                "additive artifact absent — first deterministic enact "
                "(additive create only; no existing file touched)."
            ],
        )
    try:
        existing = json.loads(path.read_text(encoding="utf-8"))
        existing_id = (
            str(existing.get("enact_id"))
            if isinstance(existing, dict) and existing.get("enact_id")
            else None
        )
    except (OSError, ValueError):
        existing_id = None
    if existing_id == eid:
        return IdempotencyDecision(
            schema=IDEMPOTENCY_SCHEMA,
            enact_id=eid,
            artifact_target=artifact_target,
            classification=IDEMPOTENT_SKIP,
            should_write=False,
            existing_enact_id=existing_id,
            reasons=[
                "additive artifact already carries this exact enact_id — "
                "idempotent skip; the file is left byte-0 (no re-write, no "
                "duplicate decision; 회장 §2.9)."
            ],
        )
    return IdempotencyDecision(
        schema=IDEMPOTENCY_SCHEMA,
        enact_id=eid,
        artifact_target=artifact_target,
        classification=IDEMPOTENT_BINDING_CONFLICT,
        should_write=False,
        existing_enact_id=existing_id,
        reasons=[
            "a DIFFERENT enact already owns this additive path "
            f"(existing={existing_id!r} != {eid!r}) — never silently "
            "overwritten; the enactor escalates instead of clobbering "
            "(additive-only invariant, 회장 §2.5/§6)."
        ],
    )


__all__ = [
    "IDEMPOTENCY_SCHEMA",
    "IDEMPOTENT_FIRST_WRITE",
    "IDEMPOTENT_SKIP",
    "IDEMPOTENT_BINDING_CONFLICT",
    "enact_id",
    "IdempotencyDecision",
    "already_enacted",
]
