"""ADR-0167 W2-D — LexicalClaim ratification into math lexicon sources.

This module is the explicit post-review mutation boundary for lexical math
reader evidence.  It edits only per-category source files under
``en_core_math_v1/lexicon``; it does not regenerate the compiled
``lexicon.jsonl`` and therefore does not rewrite the manifest checksum.
"""

from __future__ import annotations

import hashlib
import json
import re
import string
from dataclasses import dataclass
from datetime import date
from pathlib import Path
from typing import Final

from teaching.math_evidence import MathReaderRefusalEvidence, from_audit_row


SAFE_CATEGORIES: Final[frozenset[str]] = frozenset(
    {
        "drain_token",
    }
)

_FRAME_OPENER_CATEGORIES: Final[frozenset[str]] = frozenset(
    {
        "accumulation_verb",
        "capacity_verb",
        "copula_verb",
        "depletion_verb",
        "possession_verb",
        "transfer_verb",
    }
)
_REVIEWER_RE: Final[re.Pattern[str]] = re.compile(r"[^a-z0-9_-]+")
_TRIM_PUNCTUATION: Final[str] = string.punctuation


class RatificationError(ValueError):
    """Base class for lexical ratification rejections."""


class WrongClaimSubType(RatificationError):
    """Raised when a non-lexical evidence record reaches this handler."""


class WrongZeroViolationCandidate(RatificationError):
    """Raised when a ratification could open a wrong>0 admission path."""


class AlreadyRatified(RatificationError):
    """Raised when the target surface is already covered by the source file."""


class EvidenceTampering(RatificationError):
    """Raised when the evidence hash no longer matches canonical bytes."""


class UnknownCategory(RatificationError):
    """Raised when the requested category is not a known lexicon source."""


@dataclass(frozen=True, slots=True)
class RatificationReceipt:
    target_file: str
    lemma: str
    category: str
    provenance: str
    file_sha256_before: str
    file_sha256_after: str
    evidence_hash: str
    is_alias: bool
    aliased_to: str | None


def _repo_root() -> Path:
    here = Path(__file__).resolve()
    for parent in here.parents:
        if (parent / "pyproject.toml").exists() or (parent / "setup.cfg").exists():
            return parent
    return here.parents[1]


def _default_pack_root() -> Path:
    return _repo_root() / "language_packs" / "data" / "en_core_math_v1"


def _sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def _normalise_surface(surface: str) -> str:
    return surface.lower().strip(_TRIM_PUNCTUATION)


def _reviewer_slug(reviewer: str) -> str:
    slug = _REVIEWER_RE.sub("_", reviewer.lower()).strip("_-")
    if not slug:
        raise RatificationError("reviewer must contain at least one safe character")
    return slug


def _read_entries(path: Path) -> list[dict[str, object]]:
    entries: list[dict[str, object]] = []
    for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        record = json.loads(line)
        if not isinstance(record.get("lemma"), str):
            raise RatificationError(f"{path}: line {line_number} missing string lemma")
        if not isinstance(record.get("category"), str):
            raise RatificationError(f"{path}: line {line_number} missing string category")
        aliases = record.get("aliases", [])
        if not isinstance(aliases, list) or not all(
            isinstance(alias, str) for alias in aliases
        ):
            raise RatificationError(f"{path}: line {line_number} has invalid aliases")
        entries.append(record)
    return entries


def _write_entries(path: Path, entries: list[dict[str, object]]) -> None:
    ordered = sorted(entries, key=lambda item: str(item["lemma"]))
    lines = [json.dumps(entry, ensure_ascii=False, sort_keys=False) for entry in ordered]
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")


def _all_source_entries(pack_root: Path) -> list[dict[str, object]]:
    source_dir = pack_root / "lexicon"
    entries: list[dict[str, object]] = []
    for path in sorted(source_dir.glob("*.jsonl")):
        entries.extend(_read_entries(path))
    return entries


def _entry_surfaces(entry: dict[str, object]) -> set[str]:
    lemma = str(entry["lemma"]).lower()
    aliases = entry.get("aliases", [])
    return {lemma, *(str(alias).lower() for alias in aliases)}


def _find_frame_opener_conflict(pack_root: Path, lemma: str) -> tuple[str, str] | None:
    for entry in _all_source_entries(pack_root):
        category = str(entry["category"])
        if category not in _FRAME_OPENER_CATEGORIES:
            continue
        if lemma in _entry_surfaces(entry):
            return str(entry["lemma"]), category
    return None


def _stem_alias_parent(
    entries: list[dict[str, object]],
    lemma: str,
) -> dict[str, object] | None:
    for entry in entries:
        parent = str(entry["lemma"]).lower()
        if parent == lemma:
            return entry
        if lemma in _entry_surfaces(entry):
            return entry
        if lemma in {
            f"{parent}s",
            f"{parent}es",
            f"{parent}ed",
            f"{parent}ing",
        }:
            return entry
        if parent.endswith("y") and lemma == f"{parent[:-1]}ies":
            return entry
    return None


def _validate_evidence(claim: MathReaderRefusalEvidence) -> None:
    if claim.sub_type != "lexical":
        raise WrongClaimSubType(
            f"Lexical ratification requires sub_type='lexical'; got {claim.sub_type!r}"
        )
    recomputed = from_audit_row(
        claim.audit_row,
        claim.sub_type,
        claim_signature=claim.claim_signature,
    )
    if recomputed.evidence_hash != claim.evidence_hash:
        raise EvidenceTampering(
            "MathReaderRefusalEvidence hash does not match canonical audit row bytes"
        )


def apply_lexical_claim(
    *,
    claim: MathReaderRefusalEvidence,
    category: str,
    reviewer: str,
    pack_root: Path | None = None,
) -> RatificationReceipt:
    """Apply a reviewed lexical claim to a math source lexicon file.

    Preconditions:
    - ``claim.sub_type == "lexical"`` or :class:`WrongClaimSubType` is raised.
    - ``category`` is in :data:`SAFE_CATEGORIES`; frame-opener categories raise
      :class:`WrongZeroViolationCandidate`.
    - the target surface is not already a frame-opener lemma or alias.
    - ``claim.evidence_hash`` matches a recomputation from ``claim.audit_row``.

    The write target is ``{pack_root}/lexicon/{category}.jsonl``.  Entries are
    sorted alphabetically by lemma.  If the ratified surface is an inflectional
    match for an existing lemma (for example ``weight`` -> ``weights``), it is
    appended as an alias; otherwise a new lemma entry is written.

    The compiled ``lexicon.jsonl`` and ``manifest.json`` are intentionally not
    regenerated.  The loader verifies the manifest against compiled bytes but
    reads per-category source files for alias-aware entries.
    """
    _validate_evidence(claim)

    root = (pack_root if pack_root is not None else _default_pack_root()).resolve()
    source_dir = root / "lexicon"
    target_file = source_dir / f"{category}.jsonl"

    if category not in SAFE_CATEGORIES:
        if category in _FRAME_OPENER_CATEGORIES:
            raise WrongZeroViolationCandidate(
                f"category {category!r} can open reader frames; LexicalClaim "
                "ratification is restricted to drain-class categories"
            )
        if not target_file.exists():
            raise UnknownCategory(f"unknown math lexicon category: {category!r}")
        raise WrongZeroViolationCandidate(
            f"category {category!r} is outside SAFE_CATEGORIES={sorted(SAFE_CATEGORIES)!r}"
        )
    if not target_file.exists():
        raise UnknownCategory(f"missing source lexicon file for category: {category!r}")

    lemma = _normalise_surface(claim.audit_row.token_text)
    if not lemma:
        raise WrongZeroViolationCandidate("empty lexical surface cannot be ratified")

    conflict = _find_frame_opener_conflict(root, lemma)
    if conflict is not None:
        parent, conflict_category = conflict
        raise WrongZeroViolationCandidate(
            f"surface {lemma!r} already resolves through frame-opener "
            f"{conflict_category!r} lemma {parent!r}"
        )

    provenance = f"phase_2_reader_ratified_{_reviewer_slug(reviewer)}_{date.today().isoformat()}"
    before = _sha256_file(target_file)
    entries = _read_entries(target_file)
    target_relative = str(target_file.relative_to(_repo_root())) if target_file.is_relative_to(_repo_root()) else str(target_file)

    parent = _stem_alias_parent(entries, lemma)
    is_alias = False
    aliased_to: str | None = None

    if parent is not None:
        parent_lemma = str(parent["lemma"])
        aliases = [str(alias) for alias in parent.get("aliases", [])]
        surfaces = {parent_lemma.lower(), *(alias.lower() for alias in aliases)}
        if lemma in surfaces:
            raise AlreadyRatified(
                f"lexical claim {lemma!r} is already ratified in {category!r}"
            )
        aliases.append(lemma)
        parent["aliases"] = sorted(set(aliases))
        is_alias = True
        aliased_to = parent_lemma
    else:
        entries.append(
            {
                "lemma": lemma,
                "category": category,
                "aliases": [],
                "provenance": provenance,
            }
        )

    _write_entries(target_file, entries)
    after = _sha256_file(target_file)

    return RatificationReceipt(
        target_file=target_relative,
        lemma=lemma,
        category=category,
        provenance=provenance,
        file_sha256_before=before,
        file_sha256_after=after,
        evidence_hash=claim.evidence_hash,
        is_alias=is_alias,
        aliased_to=aliased_to,
    )


__all__ = [
    "AlreadyRatified",
    "EvidenceTampering",
    "RatificationError",
    "RatificationReceipt",
    "SAFE_CATEGORIES",
    "UnknownCategory",
    "WrongClaimSubType",
    "WrongZeroViolationCandidate",
    "apply_lexical_claim",
]
