"""chat/teaching_grounding.py — teaching-grounded surface for cold-start
CAUSE and VERIFICATION intents (ADR-0052).

ADR-0048 added pack-grounded surfaces for cold-start DEFINITION / RECALL,
and ADR-0050 extended that to COMPARISON.  Both consult the ratified
``en_core_cognition_v1`` pack as a second source of grounding alongside
the session vault.

CAUSE and VERIFICATION cannot be answered from pack ``semantic_domains``
alone — those describe a single subject, not a relation between two
subjects.  But the system already has reviewed, auditable memory for a
small, well-known set of cognition-core chains (e.g. ``knowledge requires
evidence``, ``memory requires recall``, ``light reveals truth``).  Per
the Teaching Safety discipline in CLAUDE.md, reviewed memory may
contribute grounding evidence; this module supplies that contribution as
a third grounding source.

The corpus lives at ``teaching/cognition_chains/cognition_chains_v1.jsonl``
and is treated as reviewed, immutable memory at runtime: each entry
names a subject lemma, an intent (``cause`` or ``verification``), a
fixed connective predicate already present in
``generate/semantic_templates.py:_PREDICATE_HUMANIZE``, and an object
lemma.  Both lemmas must be present in the ratified cognition pack —
every visible non-template token in the emitted surface is therefore
either one of the two lemmas, a verbatim pack ``semantic_domains``
string, or a fixed-template connective.  No LLM, no synthesis, no
inference.

Design constraints (matching ADR-0048 / ADR-0050 axioms):

- Reconstruction-over-storage: the surface is reconstructed from the
  corpus + pack at call time; both are loaded once and cached because
  the corpus is reviewed memory (immutable) and ratified packs are
  immutable.
- Dual-correction: any subject not in the corpus, any intent outside
  ``{CAUSE, VERIFICATION}``, or any chain referencing lemmas missing
  from the pack returns ``None`` and callers fall through to
  ``_UNKNOWN_DOMAIN_SURFACE`` unchanged.
- Trust boundary: every surface produced here is explicitly tagged
  ``teaching:cognition_chains_v1`` so the audit contract distinguishes
  teaching-grounded surfaces from pack-grounded surfaces from
  vault-grounded surfaces.
"""

from __future__ import annotations

import json
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path

from chat.pack_grounding import PACK_ID as COGNITION_PACK_ID, _pack_index
from chat.pack_resolver import _pack_lexicon_for
from generate.intent import IntentTag
from generate.semantic_templates import humanize_predicate
from packs.register.loader import RegisterPack, UNREGISTERED

TEACHING_CORPUS_ID: str = "cognition_chains_v1"

_VALID_INTENTS: frozenset[str] = frozenset({"cause", "verification"})

_INTENT_TAG_BY_NAME: dict[str, IntentTag] = {
    "cause": IntentTag.CAUSE,
    "verification": IntentTag.VERIFICATION,
}

_TEACHING_ROOT = Path(__file__).resolve().parent.parent / "teaching"

_CORPUS_PATH = (
    _TEACHING_ROOT
    / "cognition_chains"
    / f"{TEACHING_CORPUS_ID}.jsonl"
)


@dataclass(frozen=True, slots=True)
class TeachingCorpusSpec:
    """ADR-0064 — descriptor for one reviewed teaching corpus.

    A corpus is a JSONL file of reviewed chains plus the single lexicon
    pack whose vocabulary every chain in that corpus must reside in.  The
    1-to-1 corpus↔pack binding is the structural invariant that prevents
    cross-domain leakage during cold-start surface composition: a
    relations-domain chain cannot accidentally surface a cognition-pack
    atom (or vice versa) because the pack-consistency check at load time
    is scoped to the corpus's declared pack.

    Each registered corpus is treated as immutable, reviewed memory.
    Cross-domain triples (cognition × relations) are deliberately out of
    scope for v1 — they require a follow-up ADR that introduces a
    cross-pack chain shape, per ``docs/teaching_order.md`` §5.
    """

    corpus_id: str
    path: Path
    pack_id: str


# ADR-0064 — registered teaching corpora.  Order matters: chains in
# earlier corpora win on (subject, intent) collision.  Cognition is
# listed first so the cognition-lane byte-identity invariant is
# preserved when a relations chain ever shares a key (today the
# orthogonal-pack invariant prevents any such collision, but the
# resolution rule is documented).
TEACHING_CORPORA: tuple[TeachingCorpusSpec, ...] = (
    TeachingCorpusSpec(
        corpus_id="cognition_chains_v1",
        path=_TEACHING_ROOT / "cognition_chains" / "cognition_chains_v1.jsonl",
        pack_id="en_core_cognition_v1",
    ),
    TeachingCorpusSpec(
        corpus_id="relations_chains_v1",
        path=_TEACHING_ROOT / "relations_chains" / "relations_chains_v1.jsonl",
        pack_id="en_core_relations_v1",
    ),
    TeachingCorpusSpec(
        corpus_id="relations_chains_v2",
        path=_TEACHING_ROOT / "relations_chains_v2" / "relations_chains_v2.jsonl",
        pack_id="en_core_relations_v2",
    ),
)


@dataclass(frozen=True, slots=True)
class TeachingChain:
    """One reviewed teaching chain.

    Fields are copied verbatim from the JSONL line; the runtime never
    mutates them.  ``provenance`` is preserved for audit but not emitted
    in the user-facing surface.

    ADR-0064 — ``corpus_id`` records which registered teaching corpus
    the chain belongs to so the surface tag and audit trail are
    unambiguous when multiple corpora are active.
    """

    chain_id: str
    subject: str
    intent: str
    connective: str
    object: str
    domains_subject_k: int
    domains_object_k: int
    provenance: str
    corpus_id: str = "cognition_chains_v1"


def _load_corpus(spec: TeachingCorpusSpec) -> dict[tuple[str, str], TeachingChain]:
    """ADR-0064 — load one registered teaching corpus.

    Returns ``{(subject_lower, intent_lower): TeachingChain}`` keyed
    within this corpus only.  Pack-consistency is scoped to
    ``spec.pack_id``: every chain's subject AND object must reside in
    that specific pack's lexicon.  Cross-pack chain shapes (e.g. a
    relations subject with a cognition object) are out of scope for
    v1 per ``docs/teaching_order.md`` §5 and produce a drop with no
    surface impact.

    ADR-0055 Phase A: an entry whose ``chain_id`` appears as another
    entry's ``superseded_by`` is dropped from the active view.
    Append-only history on disk is preserved; the loader derives the
    active set.
    """
    if not spec.path.exists():
        return {}
    pack = _pack_lexicon_for(spec.pack_id)
    if not pack:
        return {}
    superseded_ids: set[str] = set()
    parsed_lines: list[dict] = []
    for line in spec.path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            entry = json.loads(line)
        except json.JSONDecodeError:
            continue
        if not isinstance(entry, dict):
            continue
        parsed_lines.append(entry)
        sup = entry.get("superseded_by")
        if isinstance(sup, str) and sup.strip():
            superseded_ids.add(sup.strip())

    out: dict[tuple[str, str], TeachingChain] = {}
    for entry in parsed_lines:
        subject = (entry.get("subject") or "").strip().lower()
        intent = (entry.get("intent") or "").strip().lower()
        obj = (entry.get("object") or "").strip().lower()
        connective = (entry.get("connective") or "").strip()
        if not subject or not intent or not obj or not connective:
            continue
        if intent not in _VALID_INTENTS:
            continue
        if subject not in pack or obj not in pack:
            continue
        chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
        if chain_id in superseded_ids:
            continue
        try:
            chain = TeachingChain(
                chain_id=chain_id,
                subject=subject,
                intent=intent,
                connective=connective,
                object=obj,
                domains_subject_k=int(entry.get("domains_subject_k", 2)),
                domains_object_k=int(entry.get("domains_object_k", 1)),
                provenance=str(entry.get("provenance", "")),
                corpus_id=spec.corpus_id,
            )
        except (TypeError, ValueError):
            continue
        out[(subject, intent)] = chain
    return out


@lru_cache(maxsize=1)
def _corpus_index() -> dict[tuple[str, str], TeachingChain]:
    """Load the cognition-chains corpus once (back-compat surface).

    Retained for discovery / replay / audit consumers whose semantics
    are scoped to the cognition corpus specifically.  Cross-corpus
    composition uses :func:`_all_chains_index` instead.

    Returns ``{(subject_lower, intent_lower): TeachingChain}``.  Entries
    with invalid schema, unsupported intents, or with subject/object
    missing from the ratified cognition pack are dropped — the corpus
    is reviewed memory but the runtime still verifies pack consistency
    on load so a pack-corpus skew cannot leak a non-pack atom into a
    surface.

    ADR-0055 Phase A: an entry whose ``chain_id`` appears as another
    entry's ``superseded_by`` is also dropped from the active view.
    Append-only history on disk is preserved; the loader derives the
    active set.
    """
    if not _CORPUS_PATH.exists():
        return {}
    pack = _pack_index()
    # First sweep: collect supersession claims.  Only well-formed
    # entries (parseable JSON object) can retire other entries — a
    # malformed line cannot supersede a good one.
    superseded_ids: set[str] = set()
    parsed_lines: list[dict] = []
    for line in _CORPUS_PATH.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            entry = json.loads(line)
        except json.JSONDecodeError:
            continue
        if not isinstance(entry, dict):
            continue
        parsed_lines.append(entry)
        sup = entry.get("superseded_by")
        if isinstance(sup, str) and sup.strip():
            superseded_ids.add(sup.strip())

    out: dict[tuple[str, str], TeachingChain] = {}
    for entry in parsed_lines:
        subject = (entry.get("subject") or "").strip().lower()
        intent = (entry.get("intent") or "").strip().lower()
        obj = (entry.get("object") or "").strip().lower()
        connective = (entry.get("connective") or "").strip()
        if not subject or not intent or not obj or not connective:
            continue
        if intent not in _VALID_INTENTS:
            continue
        # Both lemmas MUST be in the ratified pack — guarantees every
        # surface atom is pack-sourced.
        if subject not in pack or obj not in pack:
            continue
        chain_id = str(entry.get("chain_id") or f"{subject}_{intent}")
        if chain_id in superseded_ids:
            continue
        try:
            chain = TeachingChain(
                chain_id=chain_id,
                subject=subject,
                intent=intent,
                connective=connective,
                object=obj,
                domains_subject_k=int(entry.get("domains_subject_k", 2)),
                domains_object_k=int(entry.get("domains_object_k", 1)),
                provenance=str(entry.get("provenance", "")),
                corpus_id=TEACHING_CORPUS_ID,
            )
        except (TypeError, ValueError):
            continue
        out[(subject, intent)] = chain
    return out


@lru_cache(maxsize=1)
def _all_chains_index() -> dict[tuple[str, str], TeachingChain]:
    """ADR-0064 — aggregated view across every registered teaching corpus.

    Returns ``{(subject_lower, intent_lower): TeachingChain}`` keyed
    across all corpora in :data:`TEACHING_CORPORA`.  Registration order
    is the resolution order: earlier corpora win on collision.  The
    cognition corpus is registered first so the cognition-lane
    byte-identity invariant is preserved.

    The :func:`_corpus_index` back-compat loader is **not** an input to
    this aggregator — both consult the same underlying file but
    :func:`_corpus_index` is reserved for cognition-corpus-only
    consumers (audit, replay, discovery's gate).  Cross-corpus surface
    composition consults :func:`_all_chains_index`.
    """
    aggregated: dict[tuple[str, str], TeachingChain] = {}
    for spec in TEACHING_CORPORA:
        corpus = _load_corpus(spec)
        for key, chain in corpus.items():
            if key not in aggregated:
                aggregated[key] = chain
    return aggregated


@lru_cache(maxsize=8)
def _pack_for_corpus(corpus_id: str) -> dict[str, tuple[str, ...]]:
    """Return the lexicon for the pack bound to *corpus_id*, cached.

    ADR-0064 — each registered teaching corpus is bound to exactly
    one lexicon pack via :data:`TEACHING_CORPORA`.  Returns an empty
    dict if *corpus_id* is unknown — callers see this as "chain
    cannot be surfaced" and fall through to the universal disclosure.
    """
    for spec in TEACHING_CORPORA:
        if spec.corpus_id == corpus_id:
            return _pack_lexicon_for(spec.pack_id)
    return {}


def _intent_name(intent_tag: IntentTag) -> str | None:
    """Return the lower-case intent key for the corpus, or ``None``."""
    if intent_tag is IntentTag.CAUSE:
        return "cause"
    if intent_tag is IntentTag.VERIFICATION:
        return "verification"
    return None


def teaching_grounded_surface(
    subject_lemma: str,
    intent_tag: IntentTag,
    *,
    register: RegisterPack = UNREGISTERED,
) -> str | None:
    """Return a deterministic teaching-grounded surface, or ``None``.

    The surface format is fixed:

        "{subject} — teaching-grounded ({corpus_id}): {ds1}; {ds2}.
         {subject} {connective} {object} ({do1}). No session evidence yet."

    Every visible non-template token is either one of the two lemmas, a
    verbatim ``semantic_domains`` string from the ratified cognition
    pack, or the connective predicate already humanised by
    ``generate.semantic_templates.humanize_predicate``.  The trailing
    disclosure (``No session evidence yet.``) is the constant
    trust-boundary label that distinguishes teaching-grounded surfaces
    from vault-grounded surfaces.

    Returns ``None`` when:
      - the lemma is empty or not a string,
      - the intent tag is not ``CAUSE`` or ``VERIFICATION``,
      - the (subject, intent) pair is not in the teaching corpus.
    """
    if not subject_lemma or not isinstance(subject_lemma, str):
        return None
    key = subject_lemma.strip().lower()
    if not key:
        return None
    intent_name = _intent_name(intent_tag)
    if intent_name is None:
        return None
    chain = _all_chains_index().get((key, intent_name))
    if chain is None:
        return None
    # ADR-0064 — pack-residency is scoped to the chain's resolving
    # corpus.  Each registered corpus is bound to exactly one pack.
    pack = _pack_for_corpus(chain.corpus_id)
    subject_domains = pack.get(chain.subject, ())
    object_domains = pack.get(chain.object, ())
    if not subject_domains or not object_domains:
        return None
    head_subject = "; ".join(
        subject_domains[: max(1, chain.domains_subject_k)]
    )
    head_object = "; ".join(
        object_domains[: max(1, chain.domains_object_k)]
    )
    connective = humanize_predicate(chain.connective)
    return (
        f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
        f"{head_subject}. {chain.subject} {connective} {chain.object} "
        f"({head_object}). No session evidence yet."
    )


def _resolve_followup(
    *,
    current: TeachingChain,
    corpus: dict[tuple[str, str], TeachingChain],
    visited: frozenset[str],
    same_corpus_id: str,
) -> TeachingChain | None:
    """ADR-0083 shared resolver — return the next chain that survives
    the cycle / pack-residency / single-corpus guards, else ``None``.

    Used by both the ADR-0062 depth-1 composer and the ADR-0083
    transitive composer.  Per-hop rules:

    1. Prefer ``(current.object, "cause")``; fall back to
       ``(current.object, "verification")``.
    2. Refuse candidates whose ``object`` is in *visited* (covers
       ADR-0062's 1-step cycle guard as the depth-2 case).
    3. Refuse candidates whose ``object`` is not pack-resident with
       ``semantic_domains`` in the candidate's resolving corpus's pack.
    4. Refuse candidates from a different corpus than *same_corpus_id*
       (single-corpus traversal in v1; cross-corpus transitive is
       deferred to a follow-up ADR).
    """
    for next_intent in ("cause", "verification"):
        candidate = corpus.get((current.object, next_intent))
        if candidate is None:
            continue
        if candidate.object in visited:
            continue
        if candidate.corpus_id != same_corpus_id:
            continue
        pack = _pack_for_corpus(candidate.corpus_id)
        if not pack.get(candidate.object, ()):
            continue
        return candidate
    return None


def teaching_grounded_surface_composed(
    subject_lemma: str,
    intent_tag: IntentTag,
    *,
    register: RegisterPack = UNREGISTERED,
) -> str | None:
    """ADR-0062 — chain-of-chains teaching-grounded surface.

    When a chain ``(A, intent_A, conn_A, B)`` exists AND a follow-up
    chain ``(B, ?, conn_B, C)`` exists for either intent, compose a
    two-clause surface:

        "{A} — teaching-grounded ({corpus_id}): {dA1}; {dA2}.
         {A} {conn_A} {B} ({dB1}), which {conn_B} {C} ({dC1}).
         No session evidence yet."

    Cycle-safe: if ``C == A`` or ``C == B``, the composer falls back
    to the single-chain surface (no follow-up clause).  Bounded depth:
    v1 follows exactly one hop; deeper chains require a future ADR.

    Follow-up intent preference: prefer ``cause`` when both exist
    (causal continuation reads more naturally than a verification
    detour).  This preference is deterministic and pack-agnostic.

    Returns ``None`` under the same conditions as
    ``teaching_grounded_surface``.  When the initial chain exists
    but no follow-up does, the composer degrades to the single-chain
    surface byte-identically — drop-in replacement.
    """
    if not subject_lemma or not isinstance(subject_lemma, str):
        return None
    key = subject_lemma.strip().lower()
    if not key:
        return None
    intent_name = _intent_name(intent_tag)
    if intent_name is None:
        return None
    corpus = _all_chains_index()
    chain = corpus.get((key, intent_name))
    if chain is None:
        return None
    # ADR-0064 — pack lookups follow each chain's resolving corpus.
    pack = _pack_for_corpus(chain.corpus_id)
    subject_domains = pack.get(chain.subject, ())
    object_domains = pack.get(chain.object, ())
    if not subject_domains or not object_domains:
        return None
    head_subject = "; ".join(
        subject_domains[: max(1, chain.domains_subject_k)]
    )
    head_object_short = "; ".join(
        object_domains[: max(1, chain.domains_object_k)]
    )
    connective = humanize_predicate(chain.connective)

    # ADR-0083 — shared resolver enforces ADR-0062's cycle guard
    # (visited = {subject, object}) plus pack-residency + single-
    # corpus guards.  Behaviour at depth-1 is unchanged.
    follow_up = _resolve_followup(
        current=chain,
        corpus=corpus,
        visited=frozenset({chain.subject, chain.object}),
        same_corpus_id=chain.corpus_id,
    )

    if follow_up is None:
        # No follow-up available — degrade to single-chain surface
        # byte-identically with ``teaching_grounded_surface``.
        return (
            f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
            f"{head_subject}. {chain.subject} {connective} {chain.object} "
            f"({head_object_short}). No session evidence yet."
        )

    # ADR-0083 — _resolve_followup already enforced pack-residency
    # on follow_up.object, so a non-None result is safe to render.
    follow_pack = _pack_for_corpus(follow_up.corpus_id)
    follow_object_domains = follow_pack.get(follow_up.object, ())
    follow_head = "; ".join(
        follow_object_domains[: max(1, follow_up.domains_object_k)]
    )
    follow_connective = humanize_predicate(follow_up.connective)
    return (
        f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
        f"{head_subject}. {chain.subject} {connective} {chain.object} "
        f"({head_object_short}), which {follow_connective} {follow_up.object} "
        f"({follow_head}). No session evidence yet."
    )


def teaching_grounded_surface_transitive(
    subject_lemma: str,
    intent_tag: IntentTag,
    *,
    register: RegisterPack = UNREGISTERED,
    max_depth: int = 2,
) -> str | None:
    """ADR-0083 — bounded multi-hop teaching-grounded surface.

    Strict superset of :func:`teaching_grounded_surface_composed`.
    Iterates the shared :func:`_resolve_followup` helper under a
    visited-set guard, appending one ``", which {conn} {obj} ({dom})"``
    clause per surviving hop, up to *max_depth - 1* follow-ups beyond
    the initial chain.

    ``max_depth`` is the maximum number of follow-up hops to append
    beyond the initial chain.  At ``max_depth=0`` byte-identical to
    :func:`teaching_grounded_surface` (no hops).  At ``max_depth=1``
    byte-identical to :func:`teaching_grounded_surface_composed`
    (one follow-up).  At ``max_depth=2`` byte-identical to ADR-0062
    when no second hop survives, strict superset when one does.

    Single-corpus traversal in v1 — every hop must resolve in the
    initial chain's corpus.  Cross-corpus transitive is deferred to a
    follow-up ADR.  Returns ``None`` under the same conditions as
    :func:`teaching_grounded_surface`.
    """
    if not subject_lemma or not isinstance(subject_lemma, str):
        return None
    key = subject_lemma.strip().lower()
    if not key:
        return None
    intent_name = _intent_name(intent_tag)
    if intent_name is None:
        return None
    corpus = _all_chains_index()
    chain = corpus.get((key, intent_name))
    if chain is None:
        return None
    pack = _pack_for_corpus(chain.corpus_id)
    subject_domains = pack.get(chain.subject, ())
    object_domains = pack.get(chain.object, ())
    if not subject_domains or not object_domains:
        return None
    head_subject = "; ".join(
        subject_domains[: max(1, chain.domains_subject_k)]
    )
    head_object_short = "; ".join(
        object_domains[: max(1, chain.domains_object_k)]
    )
    connective = humanize_predicate(chain.connective)

    depth_cap = max(0, int(max_depth))
    hops: list[TeachingChain] = []
    visited = {chain.subject, chain.object}
    current = chain
    while len(hops) < depth_cap:
        nxt = _resolve_followup(
            current=current,
            corpus=corpus,
            visited=frozenset(visited),
            same_corpus_id=chain.corpus_id,
        )
        if nxt is None:
            break
        hops.append(nxt)
        visited.add(nxt.object)
        current = nxt

    body = (
        f"{chain.subject} — teaching-grounded ({chain.corpus_id}): "
        f"{head_subject}. {chain.subject} {connective} {chain.object} "
        f"({head_object_short})"
    )
    for hop in hops:
        hop_pack = _pack_for_corpus(hop.corpus_id)
        hop_domains = hop_pack.get(hop.object, ())
        hop_head = "; ".join(hop_domains[: max(1, hop.domains_object_k)])
        hop_connective = humanize_predicate(hop.connective)
        body += f", which {hop_connective} {hop.object} ({hop_head})"
    body += ". No session evidence yet."
    return body


def has_teaching_chain(subject_lemma: str, intent_tag: IntentTag) -> bool:
    """Return True iff a reviewed chain exists for (subject, intent)
    in any registered teaching corpus (ADR-0064 cross-corpus view)."""
    if not subject_lemma or not isinstance(subject_lemma, str):
        return False
    intent_name = _intent_name(intent_tag)
    if intent_name is None:
        return False
    return (subject_lemma.strip().lower(), intent_name) in _all_chains_index()


def clear_teaching_caches() -> None:
    """Drop every teaching-grounding lru_cache.

    ADR-0064 — the replay-equivalence gate swaps ``_CORPUS_PATH`` to
    a transient corpus and clears ``_corpus_index``; when multiple
    corpora are registered the aggregated index must also reset so
    the swap takes effect.  Test-only and replay-only escape hatch;
    production code never calls this on the hot path.
    """
    _corpus_index.cache_clear()
    _all_chains_index.cache_clear()
    _pack_for_corpus.cache_clear()
