"""ADR-0172 Tier 1 / W1 — MathReaderRefusalShapeProposal schema.

Mirror of :class:`teaching.proposals.TeachingChainProposal` for the
math-domain contemplation corridor.  Each proposal carries:

- a :class:`~evals.refusal_taxonomy.shape_categories.ShapeCategory` drawn
  from the refusal taxonomy;
- ≥2 :class:`~teaching.math_evidence.MathReaderRefusalEvidence` pointers as
  evidence floor;
- a ``proposed_change_kind`` discriminating the structural change class;
- a ``wrong_zero_assertion`` (≥40 chars) pinning the non-regression claim;
- a ``reasoning_trace`` (W0 :class:`ReasoningTrace`) carrying the
  contemplation chain.

Trust boundary: schema-only module.  No filesystem I/O, no teaching-store
writes, no runtime pipeline hooks.  All validation is in :func:`build_proposal`.

W0 dependency: ``ReasoningTrace`` is imported under ``TYPE_CHECKING`` until
``teaching/math_reasoning_trace.py`` (A1 branch) merges.  At runtime the
field is accepted as an opaque object; ``build_proposal`` validates it is
not ``None`` and carries a non-empty ``trace_id`` attribute.
"""

from __future__ import annotations

import hashlib
import json
from dataclasses import dataclass
from typing import Any, Literal

from evals.refusal_taxonomy.shape_categories import ShapeCategory
from generate.comprehension.audit import AuditRow
from teaching.math_evidence import MathReaderRefusalEvidence
from teaching.math_reasoning_trace import ReasoningStep, ReasoningTrace, build_trace


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

ChangeKind = Literal[
    "matcher_extension",
    "injector_sub_shape",
    "vocabulary_addition",
    "frame_reclassification",
    "composition_reclassification",
]

_VALID_CHANGE_KINDS: frozenset[str] = frozenset({
    "matcher_extension",
    "injector_sub_shape",
    "vocabulary_addition",
    "frame_reclassification",
    "composition_reclassification",
})

_WRONG_ZERO_MIN_LEN: int = 40


# ---------------------------------------------------------------------------
# Schema
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class MathReaderRefusalShapeProposal:
    """One proposed structural change for a math-reader refusal shape.

    Construct via :func:`build_proposal` — do not instantiate directly
    (proposal_id is content-derived and requires hash computation).

    Fields
    ------
    proposal_id:
        ``sha256(canonical_bytes(...)).hexdigest()`` over all other fields.
    domain:
        Always ``"math"`` — Literal pin enforced by :func:`build_proposal`.
    shape_category:
        A ratified :class:`ShapeCategory` enum member.
    structural_commonality:
        Human-readable description of the shared structural pattern among
        the evidence rows.
    evidence_pointers:
        ≥2 :class:`MathReaderRefusalEvidence` records; evidence floor mirrors
        ADR-0057's corpus-pointer requirement.
    proposed_change_kind:
        One of the four Tier-1 change classes.
    proposed_change_payload:
        JSON-serializable payload discriminated by ``proposed_change_kind``.
    wrong_zero_assertion:
        ≥40-char natural-language statement pinning the wrong=0 invariant.
    replay_equivalence_hash:
        ``sha256`` digest of the replay-equivalence gate output (mirrors
        :class:`teaching.proposals.ReplayEvidence` contract).
    reasoning_trace:
        W0 :class:`ReasoningTrace` carrying the contemplation derivation.
        Mandatory — ``None`` is rejected by :func:`build_proposal`.
    """

    proposal_id: str
    domain: Literal["math"]
    shape_category: ShapeCategory
    structural_commonality: str
    evidence_pointers: tuple[MathReaderRefusalEvidence, ...]
    proposed_change_kind: ChangeKind
    proposed_change_payload: object
    wrong_zero_assertion: str
    replay_equivalence_hash: str
    reasoning_trace: Any  # ReasoningTrace; symbolic until A1 merges


# ---------------------------------------------------------------------------
# Canonical-bytes serialization
# ---------------------------------------------------------------------------


def _serialise_evidence_pointer(ev: MathReaderRefusalEvidence) -> str:
    """Reduce an evidence record to its stable content hash."""
    return ev.evidence_hash


def _serialise_shape_category(sc: ShapeCategory) -> str:
    return sc.value


def _serialise_reasoning_trace(trace: Any) -> str:
    """Extract trace_id from any object that carries it."""
    trace_id = getattr(trace, "trace_id", None)
    if not isinstance(trace_id, str) or not trace_id:
        raise ValueError(
            "reasoning_trace must have a non-empty str trace_id attribute"
        )
    return trace_id


def canonical_bytes(proposal: MathReaderRefusalShapeProposal) -> bytes:
    """Return deterministic canonical bytes over all fields except proposal_id.

    Stable JSON (sorted keys, no whitespace, UTF-8).  evidence_pointers are
    reduced to their evidence_hash digests; reasoning_trace is reduced to its
    trace_id.  proposed_change_payload must be JSON-serializable (validated
    by :func:`build_proposal` before this function is called).
    """
    payload: dict[str, Any] = {
        "domain": proposal.domain,
        "evidence_pointers": sorted(
            _serialise_evidence_pointer(ev) for ev in proposal.evidence_pointers
        ),
        "proposed_change_kind": proposal.proposed_change_kind,
        "proposed_change_payload": proposal.proposed_change_payload,
        "reasoning_trace_id": _serialise_reasoning_trace(proposal.reasoning_trace),
        "replay_equivalence_hash": proposal.replay_equivalence_hash,
        "shape_category": _serialise_shape_category(proposal.shape_category),
        "structural_commonality": proposal.structural_commonality,
        "wrong_zero_assertion": proposal.wrong_zero_assertion,
    }
    return json.dumps(
        payload,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
    ).encode("utf-8")


def compute_proposal_id(
    *,
    domain: Literal["math"],
    shape_category: ShapeCategory,
    structural_commonality: str,
    evidence_pointers: tuple[MathReaderRefusalEvidence, ...],
    proposed_change_kind: ChangeKind,
    proposed_change_payload: object,
    wrong_zero_assertion: str,
    replay_equivalence_hash: str,
    reasoning_trace: Any,
) -> str:
    """Hash all content fields to produce a stable proposal_id.

    Uses a temporary placeholder proposal_id so that :func:`canonical_bytes`
    can operate on a fully-formed dataclass without the chicken-and-egg
    problem of hashing the id field itself.
    """
    placeholder = MathReaderRefusalShapeProposal(
        proposal_id="",
        domain=domain,
        shape_category=shape_category,
        structural_commonality=structural_commonality,
        evidence_pointers=evidence_pointers,
        proposed_change_kind=proposed_change_kind,
        proposed_change_payload=proposed_change_payload,
        wrong_zero_assertion=wrong_zero_assertion,
        replay_equivalence_hash=replay_equivalence_hash,
        reasoning_trace=reasoning_trace,
    )
    raw = canonical_bytes(placeholder)
    return hashlib.sha256(raw).hexdigest()


# ---------------------------------------------------------------------------
# Factory
# ---------------------------------------------------------------------------


def build_proposal(
    *,
    domain: Literal["math"] = "math",
    shape_category: ShapeCategory,
    structural_commonality: str,
    evidence_pointers: tuple[MathReaderRefusalEvidence, ...],
    proposed_change_kind: str,
    proposed_change_payload: object,
    wrong_zero_assertion: str,
    replay_equivalence_hash: str,
    reasoning_trace: Any,
) -> MathReaderRefusalShapeProposal:
    """Build a :class:`MathReaderRefusalShapeProposal` with all invariants enforced.

    Raises ``ValueError`` on any violation; the caller must fix the inputs.

    Invariants
    ----------
    1. ``domain`` must be ``"math"``.
    2. ``shape_category`` must be a :class:`ShapeCategory` enum member.
    3. ``len(evidence_pointers) >= 2`` (evidence floor).
    4. ``proposed_change_kind`` in :data:`_VALID_CHANGE_KINDS`.
    5. ``proposed_change_payload`` must be JSON-serializable.
    6. ``len(wrong_zero_assertion.strip()) >= _WRONG_ZERO_MIN_LEN``.
    7. ``reasoning_trace`` is not ``None`` and carries a non-empty ``trace_id``.
    """
    if domain != "math":
        raise ValueError(f"domain must be 'math'; got {domain!r}")

    if not isinstance(shape_category, ShapeCategory):
        raise ValueError(
            f"shape_category must be a ShapeCategory enum member; got {shape_category!r}"
        )

    if len(evidence_pointers) < 2:
        raise ValueError(
            f"evidence_pointers requires ≥2 entries; got {len(evidence_pointers)}"
        )

    if proposed_change_kind not in _VALID_CHANGE_KINDS:
        raise ValueError(
            f"proposed_change_kind {proposed_change_kind!r} is not a valid ChangeKind; "
            f"allowed: {sorted(_VALID_CHANGE_KINDS)}"
        )

    try:
        json.dumps(proposed_change_payload, ensure_ascii=False, separators=(",", ":"))
    except (TypeError, ValueError) as exc:
        raise ValueError(
            f"proposed_change_payload is not JSON-serializable: {exc}"
        ) from exc

    if not wrong_zero_assertion or len(wrong_zero_assertion.strip()) < _WRONG_ZERO_MIN_LEN:
        raise ValueError(
            f"wrong_zero_assertion must be ≥{_WRONG_ZERO_MIN_LEN} chars (non-empty); "
            f"got {len(wrong_zero_assertion)!r}"
        )

    if reasoning_trace is None:
        raise ValueError("reasoning_trace is required and must not be None")

    # Validate trace_id accessibility (also validates the serialisation path).
    trace_id = getattr(reasoning_trace, "trace_id", None)
    if not isinstance(trace_id, str) or not trace_id:
        raise ValueError(
            "reasoning_trace must carry a non-empty str trace_id attribute"
        )

    resolved_kind: ChangeKind = proposed_change_kind  # type: ignore[assignment]

    pid = compute_proposal_id(
        domain=domain,
        shape_category=shape_category,
        structural_commonality=structural_commonality,
        evidence_pointers=evidence_pointers,
        proposed_change_kind=resolved_kind,
        proposed_change_payload=proposed_change_payload,
        wrong_zero_assertion=wrong_zero_assertion,
        replay_equivalence_hash=replay_equivalence_hash,
        reasoning_trace=reasoning_trace,
    )

    return MathReaderRefusalShapeProposal(
        proposal_id=pid,
        domain=domain,
        shape_category=shape_category,
        structural_commonality=structural_commonality,
        evidence_pointers=tuple(evidence_pointers),
        proposed_change_kind=resolved_kind,
        proposed_change_payload=proposed_change_payload,
        wrong_zero_assertion=wrong_zero_assertion,
        replay_equivalence_hash=replay_equivalence_hash,
        reasoning_trace=reasoning_trace,
    )


# ---------------------------------------------------------------------------
# Self-contained JSONL persistence serializer (ADR-0172 tightening follow-up #1)
# ---------------------------------------------------------------------------
#
# canonical_bytes() above is the content-hash function: it reduces
# evidence_pointers to evidence_hashes and reasoning_trace to its trace_id.
# That's the right shape for the proposal_id derivation but NOT for
# round-tripping through disk — a reader cannot reconstruct the full
# proposal from canonical bytes without re-running the decomposer.
#
# to_jsonl_record() / from_jsonl_record() are a SEPARATE persistence
# serializer that emits a self-contained record (proposal_id, full
# evidence_pointers, full reasoning_trace.steps) so the workbench can
# read proposals.jsonl without re-running decompose_audit().
#
# Determinism contract preserved: sort_keys=True, compact separators,
# no floats (rejected by reasoning_trace validators).


def _audit_row_to_dict(row: AuditRow) -> dict[str, Any]:
    return {
        "case_id": row.case_id,
        "sentence_index": row.sentence_index,
        "token_index": row.token_index,
        "token_text": row.token_text,
        "recognized_terms": list(row.recognized_terms),
        "skipped_frame": row.skipped_frame,
        "missing_operator": row.missing_operator,
        "refusal_reason": row.refusal_reason,
        "refusal_detail": row.refusal_detail,
    }


def _audit_row_from_dict(data: dict[str, Any]) -> AuditRow:
    return AuditRow(
        case_id=str(data["case_id"]),
        sentence_index=int(data["sentence_index"]),
        token_index=int(data["token_index"]),
        token_text=str(data["token_text"]),
        recognized_terms=tuple(data.get("recognized_terms") or ()),
        skipped_frame=data.get("skipped_frame"),
        missing_operator=data.get("missing_operator"),
        refusal_reason=str(data.get("refusal_reason", "")),
        refusal_detail=str(data.get("refusal_detail", "")),
    )


def _evidence_to_dict(ev: MathReaderRefusalEvidence) -> dict[str, Any]:
    return {
        "case_id": ev.case_id,
        "sentence_index": ev.sentence_index,
        "token_index": ev.token_index,
        "refusal_reason": ev.refusal_reason,
        "missing_operator": ev.missing_operator,
        "claim_signature": ev.claim_signature,
        "evidence_hash": ev.evidence_hash,
        "sub_type": ev.sub_type,
        "audit_row": _audit_row_to_dict(ev.audit_row),
    }


def _evidence_from_dict(data: dict[str, Any]) -> MathReaderRefusalEvidence:
    return MathReaderRefusalEvidence(
        case_id=str(data["case_id"]),
        sentence_index=int(data["sentence_index"]),
        token_index=int(data["token_index"]),
        refusal_reason=str(data["refusal_reason"]),
        missing_operator=data.get("missing_operator"),
        claim_signature=str(data.get("claim_signature", "")),
        evidence_hash=str(data["evidence_hash"]),
        audit_row=_audit_row_from_dict(data["audit_row"]),
        sub_type=data["sub_type"],
    )


def _step_to_dict(step: ReasoningStep) -> dict[str, Any]:
    return {
        "step_index": step.step_index,
        "step_kind": step.step_kind,
        "input_pointers": list(step.input_pointers),
        "claim": step.claim,
        "justification": step.justification,
        "output_payload": step.output_payload,
    }


def _step_from_dict(data: dict[str, Any]) -> ReasoningStep:
    return ReasoningStep(
        step_index=int(data["step_index"]),
        step_kind=data["step_kind"],
        input_pointers=tuple(str(p) for p in data.get("input_pointers", ())),
        claim=str(data.get("claim", "")),
        justification=str(data.get("justification", "")),
        output_payload=data.get("output_payload"),
    )


def to_jsonl_record(proposal: MathReaderRefusalShapeProposal) -> dict[str, Any]:
    """Return a self-contained dict representation suitable for JSONL persistence.

    Unlike :func:`canonical_bytes`, this record includes:
    - ``proposal_id`` (so consumers don't need to recompute it)
    - full ``evidence_pointers`` (nested dicts — not just hashes)
    - full ``reasoning_trace.steps`` (inline — not just trace_id)

    The output is JSON-serializable.  Encoding to bytes via
    ``json.dumps(record, sort_keys=True, separators=(",", ":"),
    ensure_ascii=False)`` produces deterministic byte-identical output
    across reruns.
    """
    trace = proposal.reasoning_trace
    return {
        "proposal_id": proposal.proposal_id,
        "domain": proposal.domain,
        "shape_category": proposal.shape_category.value,
        "structural_commonality": proposal.structural_commonality,
        "evidence_pointers": [
            _evidence_to_dict(ev) for ev in proposal.evidence_pointers
        ],
        "proposed_change_kind": proposal.proposed_change_kind,
        "proposed_change_payload": proposal.proposed_change_payload,
        "wrong_zero_assertion": proposal.wrong_zero_assertion,
        "replay_equivalence_hash": proposal.replay_equivalence_hash,
        "reasoning_trace": {
            "trace_id": trace.trace_id,
            "steps": [_step_to_dict(s) for s in trace.steps],
        },
    }


def from_jsonl_record(record: dict[str, Any]) -> MathReaderRefusalShapeProposal:
    """Reconstruct a proposal from a :func:`to_jsonl_record` dict.

    Goes through :func:`build_proposal` so all invariants are re-validated
    (evidence floor, change_kind allowlist, wrong_zero min-length, trace_id
    presence, JSON-serializable payload).  The reconstructed ``proposal_id``
    must match the persisted one — mismatch indicates tampering or schema
    drift and raises :class:`ValueError`.
    """
    evidence_records = tuple(
        _evidence_from_dict(d) for d in record.get("evidence_pointers", ())
    )
    steps = tuple(_step_from_dict(d) for d in record["reasoning_trace"]["steps"])
    trace = build_trace(steps)

    shape_category = ShapeCategory(record["shape_category"])

    proposal = build_proposal(
        domain=record.get("domain", "math"),
        shape_category=shape_category,
        structural_commonality=str(record["structural_commonality"]),
        evidence_pointers=evidence_records,
        proposed_change_kind=str(record["proposed_change_kind"]),
        proposed_change_payload=record.get("proposed_change_payload"),
        wrong_zero_assertion=str(record["wrong_zero_assertion"]),
        replay_equivalence_hash=str(record["replay_equivalence_hash"]),
        reasoning_trace=trace,
    )

    persisted_id = str(record.get("proposal_id", ""))
    if persisted_id and persisted_id != proposal.proposal_id:
        raise ValueError(
            "proposal_id mismatch on JSONL round-trip: persisted "
            f"{persisted_id!r} != recomputed {proposal.proposal_id!r}"
        )
    return proposal


__all__ = [
    "ChangeKind",
    "MathReaderRefusalShapeProposal",
    "build_proposal",
    "canonical_bytes",
    "compute_proposal_id",
    "from_jsonl_record",
    "to_jsonl_record",
]
