"""ADR-0055 Phase A — teaching corpus audit + supersession + typed provenance.

Pinned contracts:

  - ``teaching.audit.audit_corpus`` is pure, deterministic, and never
    mutates the corpus or the pack.
  - The active runtime loader (``chat.teaching_grounding._corpus_index``)
    and the audit module agree on which entries are dropped and why.
  - ``superseded_by`` retires an earlier chain from the active view but
    leaves it on disk.
  - Legacy ``"reviewed"`` provenance source token maps to
    ``"hand_authored"`` so the current corpus reports the typed enum
    without a file rewrite.
  - Pack-consistency drop is surfaced with the specific lemma name in
    the reason.
"""

from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import patch

from teaching.audit import audit_corpus
from teaching.provenance import Provenance, parse_provenance


# ---------------------------------------------------------------------------
# parse_provenance — typed shape
# ---------------------------------------------------------------------------


def test_parse_legacy_reviewed_token_maps_to_hand_authored() -> None:
    p = parse_provenance("adr-0052:reviewed:2026-05-17")
    assert p.adr_id == "adr-0052"
    assert p.source == "hand_authored"
    assert p.review_date == "2026-05-17"
    assert p.raw == "adr-0052:reviewed:2026-05-17"


def test_parse_canonical_hand_authored() -> None:
    p = parse_provenance("adr-9999:hand_authored:2099-12-31")
    assert p.source == "hand_authored"


def test_parse_discovery_promoted() -> None:
    p = parse_provenance("adr-9999:discovery_promoted:2099-12-31")
    assert p.source == "discovery_promoted"


def test_parse_imported() -> None:
    p = parse_provenance("adr-9999:imported:2099-12-31")
    assert p.source == "imported"


def test_parse_unknown_source_token_falls_back() -> None:
    p = parse_provenance("adr-9999:gibberish:2099-12-31")
    assert p.source == "unknown"
    # adr_id and review_date are still captured.
    assert p.adr_id == "adr-9999"
    assert p.review_date == "2099-12-31"


def test_parse_non_string_input_safe() -> None:
    assert parse_provenance(None).source == "unknown"
    assert parse_provenance(42).source == "unknown"
    assert parse_provenance({"adr": "x"}).source == "unknown"


def test_parse_empty_string_safe() -> None:
    p = parse_provenance("")
    assert p == Provenance(adr_id=None, source="unknown", review_date=None, raw="")


def test_parse_short_string_no_crash() -> None:
    p = parse_provenance("adr-0052")
    assert p.source == "unknown"
    assert p.raw == "adr-0052"


def test_parse_extra_trailing_colons_folded_into_date() -> None:
    p = parse_provenance("adr-9999:hand_authored:2099-12-31:extra")
    # Folding into review_date keeps drift safe — no crash, no silent loss.
    assert p.review_date == "2099-12-31:extra"


# ---------------------------------------------------------------------------
# audit_corpus — real corpus, no mutations
# ---------------------------------------------------------------------------


def test_audit_real_corpus_runs_clean() -> None:
    """The audit report's accounting invariant: every line on disk is
    either loaded or accounted for as dropped (with a typed reason).
    Hardcoding ``dropped == ()`` was the pre-supersession premise; the
    curriculum saturation v2 (commit ``a0edbb4``) introduced one
    ratified supersession (``verification_wisdom_grounds_judgment`` →
    ``verification_wisdom_requires_knowledge``).  The invariant the
    audit guarantees is line-conservation, not zero supersessions."""
    report = audit_corpus()
    assert report.lines_loaded + len(report.dropped) == report.lines_on_disk
    assert len(report.loaded) >= 10
    assert report.corpus_id == "cognition_chains_v1"
    # Every dropped line must carry a typed reason so an operator can
    # audit why it was retired without re-deriving the supersession.
    # Allowed reason prefixes (extend deliberately when a new drop class
    # is introduced — do not silently broaden):
    #
    # - ``superseded_by:<chain_id>`` — ratified curriculum supersession
    # - ``unsupported_intent:<intent>`` — chain authored with an intent
    #   the cognition corpus does not yet support (e.g. admissibility
    #   chains from ADR-0163.D.2 discovery promotion); the chain remains
    #   on disk for replay/audit but is not loaded into the active
    #   corpus.
    allowed_prefixes = ("superseded_by:", "unsupported_intent:")
    for dropped in report.dropped:
        assert dropped.reason.startswith(allowed_prefixes), (
            f"unrecognised drop reason {dropped.reason!r} for chain "
            f"{dropped.chain_id!r}; either add a new prefix to the "
            f"allowlist with a written rationale, or fix the drop site "
            f"to use an existing one."
        )


def test_audit_loaded_entries_have_typed_provenance() -> None:
    report = audit_corpus()
    for entry in report.loaded:
        assert entry.provenance.source in {
            "hand_authored", "discovery_promoted", "imported", "unknown",
        }


def test_audit_is_deterministic() -> None:
    a = audit_corpus()
    b = audit_corpus()
    assert a.as_dict() == b.as_dict()


def test_audit_as_dict_is_json_serialisable() -> None:
    report = audit_corpus()
    blob = json.dumps(report.as_dict(), sort_keys=True)
    assert "cognition_chains_v1" in blob


# ---------------------------------------------------------------------------
# audit_corpus — synthetic corpora exercising drop reasons
# ---------------------------------------------------------------------------


def _write_corpus(tmp_path: Path, lines: list[str]) -> Path:
    p = tmp_path / "cognition_chains_v1.jsonl"
    p.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return p


def test_audit_surfaces_invalid_json(tmp_path: Path) -> None:
    path = _write_corpus(tmp_path, ["not json at all"])
    report = audit_corpus(path)
    assert report.lines_on_disk == 1
    assert report.lines_loaded == 0
    assert len(report.dropped) == 1
    assert report.dropped[0].reason == "invalid_json"


def test_audit_surfaces_unsupported_intent(tmp_path: Path) -> None:
    path = _write_corpus(tmp_path, [json.dumps({
        "chain_id": "x", "subject": "light", "intent": "definition",
        "connective": "is", "object": "truth",
        "provenance": "adr-test:hand_authored:2026-05-18",
    })])
    report = audit_corpus(path)
    assert len(report.dropped) == 1
    assert report.dropped[0].reason == "unsupported_intent:definition"


def test_audit_surfaces_pack_missing_subject(tmp_path: Path) -> None:
    path = _write_corpus(tmp_path, [json.dumps({
        "chain_id": "x", "subject": "zzznotalemma", "intent": "cause",
        "connective": "reveals", "object": "truth",
        "provenance": "adr-test:hand_authored:2026-05-18",
    })])
    report = audit_corpus(path)
    assert len(report.dropped) == 1
    assert report.dropped[0].reason == "pack_missing_subject:zzznotalemma"


def test_audit_surfaces_pack_missing_object(tmp_path: Path) -> None:
    path = _write_corpus(tmp_path, [json.dumps({
        "chain_id": "x", "subject": "light", "intent": "cause",
        "connective": "reveals", "object": "zzznotalemma",
        "provenance": "adr-test:hand_authored:2026-05-18",
    })])
    report = audit_corpus(path)
    assert len(report.dropped) == 1
    assert report.dropped[0].reason == "pack_missing_object:zzznotalemma"


def test_audit_surfaces_missing_required_field(tmp_path: Path) -> None:
    path = _write_corpus(tmp_path, [json.dumps({
        "chain_id": "x", "subject": "", "intent": "cause",
        "connective": "reveals", "object": "truth",
    })])
    report = audit_corpus(path)
    assert len(report.dropped) == 1
    assert report.dropped[0].reason == "missing_required_field:subject"


# ---------------------------------------------------------------------------
# Supersession — disk preserves history, active view drops superseded
# ---------------------------------------------------------------------------


def test_supersession_drops_earlier_chain_from_active_view(tmp_path: Path) -> None:
    older = {
        "chain_id": "cause_light_reveals_truth",
        "subject": "light", "intent": "cause", "connective": "reveals",
        "object": "truth", "domains_subject_k": 2, "domains_object_k": 1,
        "provenance": "adr-test:hand_authored:2026-05-18",
    }
    newer = {
        "chain_id": "cause_light_grounds_truth",
        "subject": "light", "intent": "cause", "connective": "grounds",
        "object": "truth", "domains_subject_k": 2, "domains_object_k": 1,
        "provenance": "adr-test:hand_authored:2026-05-19",
        "superseded_by": "cause_light_reveals_truth",
    }
    path = _write_corpus(tmp_path, [json.dumps(older), json.dumps(newer)])
    report = audit_corpus(path)
    assert report.lines_on_disk == 2
    assert report.lines_loaded == 1
    # The newer entry retires the older one.
    assert report.dropped[0].chain_id == "cause_light_reveals_truth"
    assert report.dropped[0].reason == "superseded_by:cause_light_reveals_truth"
    # Newer entry is active.
    assert report.loaded[0].chain_id == "cause_light_grounds_truth"


def test_loaded_entries_with_supersession_point_to_dropped_chains() -> None:
    """The active-set invariant: a loaded entry may carry a non-null
    ``superseded_by`` (it is the replacement, pointing back at what it
    retired), but the chain id it points to MUST itself be dropped
    from the active set — otherwise the corpus would contain two
    live versions of the same lemma's chain.

    Was ``test_default_superseded_by_is_null_in_loaded_entries`` (an
    overstrict premise written before any supersession existed); the
    weaker invariant here is the one ADR-0055 Phase A actually
    guarantees."""
    report = audit_corpus()
    dropped_ids = {entry.chain_id for entry in report.dropped}
    live_ids = {entry.chain_id for entry in report.loaded}
    for entry in report.loaded:
        if entry.superseded_by is None:
            continue
        # Replacement entry — its back-pointer must reference a chain
        # id that is dropped (retired), never a live one.
        assert entry.superseded_by in dropped_ids, (
            f"loaded entry {entry.chain_id!r} points to "
            f"{entry.superseded_by!r} which is not in the dropped set "
            f"— supersession invariant broken"
        )
        assert entry.superseded_by not in live_ids, (
            f"loaded entry {entry.chain_id!r} supersedes a still-live "
            f"chain {entry.superseded_by!r} — double-live state"
        )


# ---------------------------------------------------------------------------
# Runtime parity — runtime loader and audit agree
# ---------------------------------------------------------------------------


def test_runtime_loader_and_audit_agree_on_active_chain_ids() -> None:
    """Whatever audit_corpus says is loaded must also be what the
    runtime loader has indexed (modulo the keying — runtime keys by
    (subject, intent); audit lists chain_ids)."""
    from chat.teaching_grounding import _corpus_index

    _corpus_index.cache_clear()
    runtime_chains = {c.chain_id for c in _corpus_index().values()}
    audit_chains = {c.chain_id for c in audit_corpus().loaded}
    assert runtime_chains == audit_chains


def test_runtime_loader_honors_superseded_by(tmp_path: Path) -> None:
    """If a corpus on disk has supersession, the runtime loader's
    active set must match the audit report."""
    older = {
        "chain_id": "cause_light_reveals_truth",
        "subject": "light", "intent": "cause", "connective": "reveals",
        "object": "truth",
        "provenance": "adr-test:hand_authored:2026-05-18",
    }
    newer = {
        "chain_id": "cause_light_grounds_truth",
        "subject": "light", "intent": "cause", "connective": "grounds",
        "object": "truth",
        "provenance": "adr-test:hand_authored:2026-05-19",
        "superseded_by": "cause_light_reveals_truth",
    }
    path = _write_corpus(tmp_path, [json.dumps(older), json.dumps(newer)])

    from chat import teaching_grounding as tg

    with patch.object(tg, "_CORPUS_PATH", path):
        tg._corpus_index.cache_clear()
        try:
            index = tg._corpus_index()
            active = {c.chain_id for c in index.values()}
            assert active == {"cause_light_grounds_truth"}
        finally:
            tg._corpus_index.cache_clear()


# ---------------------------------------------------------------------------
# Audit is read-only (trust boundary)
# ---------------------------------------------------------------------------


def test_audit_does_not_mutate_corpus_on_disk() -> None:
    from chat.teaching_grounding import _CORPUS_PATH

    before = _CORPUS_PATH.read_bytes()
    audit_corpus()
    after = _CORPUS_PATH.read_bytes()
    assert before == after
