"""Learning-loop demo — CORE learning a new chain from a cold turn.

The thesis (the demo's headline claim):

  > CORE, asked a question it cannot ground, emits structured evidence
  > that a reviewed chain would have helped.  An operator authors a
  > proposal from that evidence.  The replay-equivalence gate confirms
  > the chain would not regress the cognition lane.  The operator
  > accepts.  The **same prompt now produces a deterministic
  > teaching-grounded surface** — and CORE will produce that same
  > surface for that prompt every time, replayably, with full
  > provenance back to the operator's accept.

No LLM provider has this loop.  Continuous pre-training is the
nearest analog and is fundamentally different: opaque gradient
updates over uncurated data without per-fact provenance, without
operator review, without a replay-equivalence gate, without an
audit trail that lets you ask "why did the model say this today
that it would not have said yesterday?"

Five scenes, each on a real ``ChatRuntime`` against the live active
corpus.  The active corpus file bytes are byte-identical pre/post —
the demo writes only to a transient corpus, then swaps ``_CORPUS_PATH``
to that transient for the "after" turn.  The same swap pattern the
replay-equivalence gate uses (``teaching.replay._swap_corpus_path``).

  S1.  Cold turn.  The runtime cannot ground the prompt.
  S2.  Discovery emission.  A ``DiscoveryCandidate`` is emitted to the
       attached sink — structured evidence, not a mutation.
  S3.  Operator-authored proposal.  A complete proposal is built from
       the candidate's structure plus operator-provided connective /
       object / corpus-evidence pointer.  The replay-equivalence gate
       runs (real ``teaching.replay.run_replay_equivalence``) and
       confirms no regression.
  S4.  Operator accept against a *transient* corpus.  The active corpus
       on disk is untouched; the accepted chain is written to a tmp
       file.  Audit + runtime both honour the transient corpus.
  S5.  Same prompt, now teaching-grounded.  The deterministic
       teaching-grounded surface contains the new chain's subject /
       connective / object.  Identical for any replay of the same
       prompt against the same corpus state.
"""

from __future__ import annotations

import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from chat import teaching_grounding as _tg
from chat.runtime import ChatRuntime
from teaching.discovery import DiscoveryCandidate, EvidencePointer
from teaching.proposals import (
    ProposalLog,
    accept_proposal,
    propose_from_candidate,
)


# The single prompt that drives every scene.  CAUSE intent, subject
# ``narrative`` — pack-resident lemma but no ``(narrative, cause)``
# chain in the active corpus today, guaranteeing the cold-turn path.
#
# History: the original demo used ``thought`` as the cold subject; the
# cognition saturation v2 curriculum unit (commit ``a0edbb4``) added
# ``cause_thought_reveals_meaning`` to the active corpus, so the
# (thought, cause) cell is no longer cold.  ``narrative`` is the new
# cold exemplar — same thematic shape, same connective + object.
_DEMO_PROMPT: str = "Why does narrative exist?"
_DEMO_SUBJECT: str = "narrative"

# Operator-authored proposal payload.  The (narrative, cause) cell is
# unoccupied; the operator proposes the chain
#   narrative reveals meaning
# affirming evidence is the existing corpus chain
#   cause_creation_reveals_meaning   (creation reveals meaning)
# both endpoints are pack-resident.
_OPERATOR_CONNECTIVE: str = "reveals"
_OPERATOR_OBJECT: str = "meaning"
_OPERATOR_EVIDENCE_REF: str = "cause_creation_reveals_meaning"


_VERBOSE = True


def _say(*args: Any, **kwargs: Any) -> None:
    if _VERBOSE:
        print(*args, **kwargs)


def _print_header(title: str, claim: str) -> None:
    _say()
    _say("─" * 72)
    _say(f"  {title}")
    _say("─" * 72)
    _say(f"  CLAIM: {claim}")
    _say()


# ---------------------------------------------------------------------------
# Sinks + helpers
# ---------------------------------------------------------------------------


class _BufferSink:
    """Discovery candidate sink that retains every emitted line."""

    def __init__(self) -> None:
        self.lines: list[str] = []

    def emit(self, line: str) -> None:
        self.lines.append(line)


def _active_bytes() -> bytes:
    return _tg._CORPUS_PATH.read_bytes() if _tg._CORPUS_PATH.exists() else b""


# ---------------------------------------------------------------------------
# Scene outputs
# ---------------------------------------------------------------------------


@dataclass(frozen=True, slots=True)
class SceneResult:
    scene: str
    claim: str
    detail: dict[str, Any]

    def as_dict(self) -> dict[str, Any]:
        return {"scene": self.scene, "claim": self.claim, "detail": self.detail}


@dataclass(frozen=True, slots=True)
class DemoReport:
    prompt: str
    before_surface: str
    before_grounding_source: str
    after_surface: str
    after_grounding_source: str
    scenes: tuple[SceneResult, ...]
    learning_loop_closed: bool
    active_corpus_byte_identical: bool

    def as_dict(self) -> dict[str, Any]:
        # ``all_claims_supported`` is the canonical cross-demo success
        # field — see anti_regression/run_demo.py for the convention.
        return {
            "prompt": self.prompt,
            "before": {
                "surface": self.before_surface,
                "grounding_source": self.before_grounding_source,
            },
            "after": {
                "surface": self.after_surface,
                "grounding_source": self.after_grounding_source,
            },
            "scenes": [s.as_dict() for s in self.scenes],
            "learning_loop_closed": self.learning_loop_closed,
            "active_corpus_byte_identical": self.active_corpus_byte_identical,
            "all_claims_supported": (
                self.learning_loop_closed
                and self.active_corpus_byte_identical
            ),
        }


# ---------------------------------------------------------------------------
# Scenes
# ---------------------------------------------------------------------------


def _scene1_cold_turn(rt: ChatRuntime, sink: _BufferSink) -> tuple[SceneResult, Any]:
    _print_header(
        "S1.  Cold turn — runtime cannot ground the prompt",
        "Active corpus has no (thought, cause) chain.  The runtime "
        "falls through to the universal insufficient-grounding "
        "disclosure.  Identity / safety / ethics gates still run.",
    )
    response = rt.chat(_DEMO_PROMPT)
    _say(f"  prompt                  : {_DEMO_PROMPT}")
    _say(f"  surface                 : {response.surface}")
    _say(f"  grounding_source        : {response.grounding_source}")
    _say(f"  discovery candidates    : {len(sink.lines)}  (emitted post-turn)")
    return SceneResult(
        scene="S1_cold_turn",
        claim="No teaching chain for (thought, cause) — runtime returns the disclosure.",
        detail={
            "prompt": _DEMO_PROMPT,
            "surface": response.surface,
            "grounding_source": response.grounding_source,
            "discovery_candidates_emitted": len(sink.lines),
        },
    ), response


def _scene2_discovery_emission(sink: _BufferSink) -> tuple[SceneResult, dict[str, Any]]:
    _print_header(
        "S2.  Discovery candidate — structured evidence, not a mutation",
        "The runtime emits a DiscoveryCandidate (ADR-0055 Phase B) "
        "documenting that a reviewed (thought, cause) chain WOULD have "
        "grounded this turn.  Contemplation (ADR-0056 Phase C1) "
        "enriches with pack/corpus evidence pointers.  Active corpus "
        "is byte-identical — emission writes to the sink only.",
    )
    if not sink.lines:
        raise RuntimeError("expected at least one discovery candidate from S1")
    import json as _json
    payload = _json.loads(sink.lines[0])
    _say(f"  candidate_id            : {payload['candidate_id'][:16]}…")
    _say(f"  trigger                 : {payload['trigger']}")
    _say(f"  proposed_chain          : {payload['proposed_chain']}")
    _say(f"  polarity                : {payload['polarity']}")
    _say(f"  claim_domain            : {payload['claim_domain']}")
    _say(f"  pack_consistent         : {payload['pack_consistent']}")
    _say(f"  boundary_clean          : {payload['boundary_clean']}")
    _say(f"  evidence (pack-only)    : "
         f"{[e for e in payload['evidence']]}")
    return SceneResult(
        scene="S2_discovery_emission",
        claim=(
            "DiscoveryCandidate is structured evidence: it never mutates "
            "the active corpus.  Phase C is the only path to mutation."
        ),
        detail={
            "candidate_id": payload["candidate_id"],
            "proposed_chain": payload["proposed_chain"],
            "polarity": payload["polarity"],
            "evidence": payload["evidence"],
        },
    ), payload


def _scene3_propose(log_path: Path, candidate_id: str) -> tuple[SceneResult, Any]:
    _print_header(
        "S3.  Operator-authored proposal — replay-equivalence gate runs",
        "From the discovery candidate's evidence, the operator authors "
        "a complete chain: narrative reveals meaning.  Affirming evidence "
        "is the existing corpus chain cause_creation_reveals_meaning. "
        "The real replay gate (teaching.replay.run_replay_equivalence) "
        "runs the cognition public split twice — active corpus vs. "
        "transient-with-appended-chain — and reports no regression.",
    )
    # Construct the operator-augmented candidate.  This is the operator
    # contribution: connective, object, and an affirming-source evidence
    # pointer to a corpus chain that already encodes the relevant
    # semantic shape.
    augmented = DiscoveryCandidate(
        candidate_id=candidate_id,
        proposed_chain={
            "subject": _DEMO_SUBJECT, "intent": "cause",
            "connective": _OPERATOR_CONNECTIVE,
            "object": _OPERATOR_OBJECT,
        },
        trigger="would_have_grounded",
        source_turn_trace="",
        pack_consistent=True,
        boundary_clean=True,
        polarity="affirms",
        claim_domain="factual",
        evidence=(
            EvidencePointer(
                source="corpus",
                ref=_OPERATOR_EVIDENCE_REF,
                polarity="affirms",
                epistemic_status="coherent",
            ),
        ),
    )
    log = ProposalLog(log_path)
    proposal = propose_from_candidate(augmented, log=log)
    rec = log.find(proposal.proposal_id) or {}
    ev = rec.get("replay_evidence") or {}
    _say(f"  proposal_id             : {proposal.proposal_id}")
    _say(f"  proposed_chain          : {proposal.proposed_chain}")
    _say(f"  evidence (corpus ref)   : {_OPERATOR_EVIDENCE_REF}")
    _say(f"  replay baseline         : {ev.get('baseline')}")
    _say(f"  replay candidate        : {ev.get('candidate')}")
    _say(f"  regressed_metrics       : {ev.get('regressed_metrics')}")
    _say(f"  replay_equivalent       : {ev.get('replay_equivalent')}")
    _say(f"  state                   : {rec.get('state')}")
    if rec.get("state") != "pending":
        raise RuntimeError(
            f"expected pending state but got {rec.get('state')!r}; "
            f"regressed metrics: {ev.get('regressed_metrics')}"
        )
    return SceneResult(
        scene="S3_propose_replay_pass",
        claim=(
            "Real replay gate confirms no metric regression — the "
            "proposal moves to pending.  Operator --accept still required."
        ),
        detail={
            "proposal_id": proposal.proposal_id,
            "proposed_chain": proposal.proposed_chain,
            "replay_evidence": ev,
            "state": rec.get("state"),
        },
    ), proposal


def _scene4_accept_against_transient(
    log_path: Path,
    proposal_id: str,
) -> tuple[SceneResult, Path]:
    _print_header(
        "S4.  Operator accept — transient corpus, active corpus untouched",
        "accept_proposal writes one JSONL line to a TRANSIENT corpus "
        "(copy of active + new chain).  The active corpus file bytes "
        "are byte-identical pre/post.  Provenance on the new entry: "
        "adr-0057:discovery_promoted:<review_date>.",
    )
    log = ProposalLog(log_path)
    tmp_dir = Path(tempfile.mkdtemp(prefix="learning_loop_demo_"))
    transient = tmp_dir / "cognition_chains_v1.jsonl"
    if _tg._CORPUS_PATH.exists():
        shutil.copyfile(_tg._CORPUS_PATH, transient)
    else:
        transient.write_text("", encoding="utf-8")

    active_before = _active_bytes()
    transient_lines_before = len(transient.read_text(encoding="utf-8").splitlines())

    chain_id = accept_proposal(
        proposal_id,
        log=log,
        corpus_path=transient,
        review_date="2026-05-18",
        operator_note="learning-loop demo (transient corpus only)",
    )
    active_after = _active_bytes()
    transient_lines_after = len(transient.read_text(encoding="utf-8").splitlines())

    _say(f"  appended chain_id       : {chain_id}")
    _say(f"  transient corpus path   : {transient}")
    _say(f"  transient lines  before : {transient_lines_before}")
    _say(f"  transient lines  after  : {transient_lines_after}")
    _say(f"  active corpus byte-eq   : {active_before == active_after}")
    if active_before != active_after:
        raise RuntimeError(
            "demo invariant broken: accept_proposal mutated the active corpus"
        )
    return SceneResult(
        scene="S4_accept_against_transient",
        claim=(
            "accept_proposal is the sole corpus-write surface.  Pointing "
            "it at a transient path leaves the active corpus byte-identical."
        ),
        detail={
            "chain_id": chain_id,
            "transient_corpus": str(transient),
            "transient_lines_before": transient_lines_before,
            "transient_lines_after": transient_lines_after,
            "active_corpus_byte_identical": active_before == active_after,
        },
    ), transient


def _scene5_replay_now_grounded(transient: Path) -> SceneResult:
    _print_header(
        "S5.  Same prompt — now deterministically teaching-grounded",
        "With the runtime's corpus path swapped to the transient corpus, "
        "the same prompt now returns a teaching-grounded surface "
        "containing the operator-accepted chain: "
        "narrative reveals meaning.  Identical bytes for any replay of "
        "the same prompt against this corpus state.",
    )
    # ADR-0064 — the cognition corpus is one of several registered
    # teaching corpora; surface composers now consult
    # ``_all_chains_index`` instead of ``_corpus_index`` alone.  We
    # rewrite the registry entry's path for the duration of the swap
    # and clear every teaching cache so the aggregator re-reads the
    # transient corpus.
    real_path = _tg._CORPUS_PATH
    original_specs = _tg.TEACHING_CORPORA
    swapped_specs = tuple(
        _tg.TeachingCorpusSpec(
            corpus_id=s.corpus_id,
            path=transient if s.corpus_id == _tg.TEACHING_CORPUS_ID else s.path,
            pack_id=s.pack_id,
        )
        for s in original_specs
    )
    try:
        _tg._CORPUS_PATH = transient  # type: ignore[assignment]
        _tg.TEACHING_CORPORA = swapped_specs  # type: ignore[misc]
        _tg.clear_teaching_caches()
        rt2 = ChatRuntime()
        response = rt2.chat(_DEMO_PROMPT)
    finally:
        _tg._CORPUS_PATH = real_path  # type: ignore[assignment]
        _tg.TEACHING_CORPORA = original_specs  # type: ignore[misc]
        _tg.clear_teaching_caches()

    surface = response.surface
    grounding = response.grounding_source
    _say(f"  prompt                  : {_DEMO_PROMPT}")
    _say(f"  surface                 : {surface}")
    _say(f"  grounding_source        : {grounding}")

    # Falsifiable assertions for the demo's headline claim.
    contains_subject = _DEMO_SUBJECT in surface.lower()
    contains_connective = "reveal" in surface.lower()  # humanised
    contains_object = "meaning" in surface.lower()
    is_teaching_grounded = grounding == "teaching"

    if not (contains_subject and contains_connective and contains_object and is_teaching_grounded):
        raise RuntimeError(
            f"demo invariant broken: same-prompt surface did not become "
            f"teaching-grounded (surface={surface!r}, grounding={grounding!r})"
        )

    return SceneResult(
        scene="S5_replay_now_grounded",
        claim=(
            "The same prompt now produces a deterministic teaching-"
            "grounded surface containing the accepted chain's "
            "subject / connective / object."
        ),
        detail={
            "surface": surface,
            "grounding_source": grounding,
            "contains_subject": contains_subject,
            "contains_connective_reveals": contains_connective,
            "contains_object_meaning": contains_object,
        },
    )


# ---------------------------------------------------------------------------
# Public entry point
# ---------------------------------------------------------------------------


def run_demo(*, emit_json: bool = False) -> dict[str, Any]:
    """Run all five scenes and return a structured report."""
    global _VERBOSE
    _VERBOSE = not emit_json

    active_bytes_before = _active_bytes()
    rt = ChatRuntime()
    sink = _BufferSink()
    rt.attach_discovery_sink(sink)
    rt.attach_contemplation(enabled=True)

    with tempfile.TemporaryDirectory() as tmpdir:
        log_path = Path(tmpdir) / "demo_proposals.jsonl"
        s1, _before_response = _scene1_cold_turn(rt, sink)
        s2, candidate_payload = _scene2_discovery_emission(sink)
        s3, proposal = _scene3_propose(log_path, candidate_payload["candidate_id"])
        s4, transient = _scene4_accept_against_transient(log_path, proposal.proposal_id)
        s5 = _scene5_replay_now_grounded(transient)

    active_bytes_after = _active_bytes()
    report = DemoReport(
        prompt=_DEMO_PROMPT,
        before_surface=s1.detail["surface"],
        before_grounding_source=s1.detail["grounding_source"],
        after_surface=s5.detail["surface"],
        after_grounding_source=s5.detail["grounding_source"],
        scenes=(s1, s2, s3, s4, s5),
        learning_loop_closed=(
            s1.detail["grounding_source"] == "none"
            and s5.detail["grounding_source"] == "teaching"
        ),
        active_corpus_byte_identical=(active_bytes_before == active_bytes_after),
    )

    if _VERBOSE:
        _say()
        _say("═" * 72)
        _say("  BEFORE / AFTER  (single deterministic prompt, one accept between)")
        _say("═" * 72)
        _say(f"  prompt   : {report.prompt}")
        _say(f"  before   : [{report.before_grounding_source}] {report.before_surface}")
        _say(f"  after    : [{report.after_grounding_source}] {report.after_surface}")
        _say()
        _say(f"  learning_loop_closed         : {report.learning_loop_closed}")
        _say(f"  active corpus byte-identical : {report.active_corpus_byte_identical}")
        _say()

    return report.as_dict()


__all__ = ["run_demo"]
