"""Articulation benchmark suite — Phase 4 capability proof.

Anchors the post-Phase-4 claim set in numbers rather than rhetoric.

Sub-benches:

  1. **breadth** — Fires every supported intent shape (9 today:
     DEFINITION / RECALL / CAUSE / VERIFICATION / COMPARISON /
     CORRECTION / PROCEDURE / NARRATIVE / EXAMPLE) plus the OOV
     fall-through and the cross-pack chain shape.  Reports the
     ``grounding_source`` and a snippet of the surface for each.

  2. **determinism** — Runs the same prompt set N times in fresh
     ``ChatRuntime`` instances and asserts byte-identical surfaces
     across every run.  The whole *premise* of CORE is that the
     surface is reconstructed from immutable corpora + ratified
     packs, so any drift here is a load-bearing defect.

  3. **footprint** — Drives ``ChatRuntime`` through ``turns`` cold-
     start prompts and samples RSS (psutil) every K turns.  Reports
     start RSS / peak RSS / end RSS / per-turn delta.  Catches
     unbounded cache growth or pack-reload leaks.

  4. **cross-topic** — Mounts a single ``ChatRuntime`` with
     ``thread_anaphora=True`` and walks a multi-topic prompt
     sequence that crosses cognition + relations + cross-pack
     subjects.  Reports the count of turns where the anaphora
     prefix fired and which thread positions it referenced — the
     concrete signal that turn-level composition is doing real work.

  5. **ollama-compare** — Opt-in side-by-side.  Sends a fixed prompt
     set to (a) ``ChatRuntime`` and (b) a local Ollama model.
     Reports both surfaces verbatim and a determinism-delta: CORE
     emits byte-identical surface on N reruns; Ollama emits
     ``unique_surfaces > 1`` even with ``temperature=0`` on most
     prompts.  Skipped (status: ``skipped`` instead of ``failed``)
     when the ``ollama`` binary is not on ``PATH``.

  6. **discourse-planner** — Runs expository, compound, and
     walkthrough prompts with ``RuntimeConfig(discourse_planner=True)``
     and reports honest sentence buckets.  This keeps the benchmark
     aligned with the multi-clause articulation spine instead of only
     the older intent-breadth probes.

The whole suite is deterministic on the CORE side — no clock-time
or RNG influence on what gets emitted.  Walltime sampling lives in
``benchmarks.cost``; this module focuses on capability + identity.
"""

from __future__ import annotations

import shutil
import subprocess
from collections.abc import Iterable
from dataclasses import dataclass, field
from typing import Any

# Curated prompt set — every intent shape + OOV + cross-pack.
INTENT_PROBE_PROMPTS: tuple[tuple[str, str], ...] = (
    ("DEFINITION", "What is knowledge?"),
    ("RECALL", "Recall truth."),
    ("CAUSE", "Why does knowledge exist?"),
    ("VERIFICATION", "Does memory require recall?"),
    ("COMPARISON", "Compare knowledge and wisdom."),
    ("CORRECTION", "No, that's wrong."),
    ("PROCEDURE", "How do I define a concept?"),
    ("NARRATIVE", "Tell me about truth."),
    ("EXAMPLE", "Give me an example of knowledge."),
    ("OOV_FALLBACK", "What is photosynthesis?"),
    ("CROSS_PACK_VERIFICATION", "Does identity require family?"),
    ("CROSS_PACK_CAUSE", "Why does understanding exist?"),
)

# Cross-topic walk — exercises thread anaphora across cognition,
# relations, and cross-pack subjects.
CROSS_TOPIC_PROMPTS: tuple[str, ...] = (
    "Why does light exist?",         # CAUSE — light
    "What is truth?",                # DEFINITION — truth (light's object)
    "Why does knowledge exist?",     # CAUSE — knowledge
    "Tell me about family.",         # NARRATIVE — family (relations)
    "Does identity require family?", # VERIFICATION — cross-pack
    "What is parent?",               # DEFINITION — relations
    "Give me an example of memory.", # EXAMPLE
    "Compare truth and knowledge.",  # COMPARISON
)

# Determinism rerun set — short prompts that exercise every grounding
# tier we care about.
DETERMINISM_PROMPTS: tuple[str, ...] = (
    "What is truth?",
    "Why does knowledge exist?",
    "Tell me about family.",
    "Does identity require family?",
    "Give me an example of memory.",
)

DISCOURSE_PLANNER_PROMPTS: tuple[tuple[str, str], ...] = (
    ("EXPLAIN", "Explain truth."),
    ("PARAGRAPH", "Write a paragraph about truth."),
    ("COMPOUND", "What is truth, and why does it matter?"),
    ("WALKTHROUGH", "Walk me through recall."),
)


# ---------------------------------------------------------------------------
# Report shapes
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class IntentProbe:
    label: str
    prompt: str
    intent_tag: str
    grounding_source: str
    surface_snippet: str


@dataclass(frozen=True)
class DeterminismCase:
    prompt: str
    runs: int
    unique_surfaces: int
    sample: str


@dataclass(frozen=True)
class FootprintSample:
    turn: int
    rss_bytes: int


@dataclass(frozen=True)
class CrossTopicTurn:
    turn: int
    prompt: str
    intent_tag: str
    grounding_source: str
    anaphora_fired: bool
    surface_snippet: str


@dataclass(frozen=True)
class DiscoursePlannerProbe:
    label: str
    prompt: str
    intent_tag: str
    grounding_source: str
    sentence_count: int
    articulate_sentence: bool
    disclosure_sentence: bool
    surface_snippet: str


@dataclass(frozen=True)
class OllamaPair:
    prompt: str
    core_surface: str
    core_unique_surfaces_on_5_reruns: int
    ollama_surface: str
    ollama_unique_surfaces_on_5_reruns: int


@dataclass
class ArticulationReport:
    breadth: list[IntentProbe] = field(default_factory=list)
    determinism: list[DeterminismCase] = field(default_factory=list)
    determinism_all_identical: bool = True
    footprint: list[FootprintSample] = field(default_factory=list)
    footprint_start_bytes: int = 0
    footprint_peak_bytes: int = 0
    footprint_end_bytes: int = 0
    footprint_per_turn_delta_bytes: float = 0.0
    cross_topic: list[CrossTopicTurn] = field(default_factory=list)
    anaphora_fire_count: int = 0
    discourse_planner: list[DiscoursePlannerProbe] = field(default_factory=list)
    discourse_planner_metrics: dict[str, Any] = field(default_factory=dict)
    ollama: dict[str, Any] = field(default_factory=dict)

    def as_dict(self) -> dict[str, Any]:
        return {
            "breadth": [p.__dict__ for p in self.breadth],
            "determinism": [c.__dict__ for c in self.determinism],
            "determinism_all_identical": self.determinism_all_identical,
            "footprint_samples": [s.__dict__ for s in self.footprint],
            "footprint_start_bytes": self.footprint_start_bytes,
            "footprint_peak_bytes": self.footprint_peak_bytes,
            "footprint_end_bytes": self.footprint_end_bytes,
            "footprint_per_turn_delta_bytes": round(
                self.footprint_per_turn_delta_bytes, 2
            ),
            "cross_topic": [t.__dict__ for t in self.cross_topic],
            "anaphora_fire_count": self.anaphora_fire_count,
            "discourse_planner": [p.__dict__ for p in self.discourse_planner],
            "discourse_planner_metrics": self.discourse_planner_metrics,
            "ollama": self.ollama,
        }


# ---------------------------------------------------------------------------
# Sub-benches
# ---------------------------------------------------------------------------


def _snippet(s: str, n: int = 120) -> str:
    s = " ".join(s.split())
    return s if len(s) <= n else s[: n - 1] + "…"


def _sentence_count(surface: str) -> int:
    from evals.multi_sentence_response.runner import _split_sentences, _strip_provenance

    return len(_split_sentences(_strip_provenance(surface)))


def _classify_prompt(prompt: str) -> str:
    """Re-derive the intent label from the prompt text for the report.

    ``ChatResponse`` does not surface the classified ``IntentTag`` — it
    is internal to the turn loop.  Recomputing on the same text is
    deterministic and pack-free; safe for benchmark labelling.
    """
    from generate.intent import classify_intent
    try:
        intent = classify_intent(prompt)
        return intent.tag.name
    except Exception:
        return "UNKNOWN"


def bench_breadth() -> list[IntentProbe]:
    from chat.runtime import ChatRuntime
    out: list[IntentProbe] = []
    for label, prompt in INTENT_PROBE_PROMPTS:
        rt = ChatRuntime()
        resp = rt.chat(prompt)
        out.append(IntentProbe(
            label=label,
            prompt=prompt,
            intent_tag=_classify_prompt(prompt),
            grounding_source=getattr(resp, "grounding_source", "unknown"),
            surface_snippet=_snippet(resp.surface),
        ))
    return out


def bench_determinism(runs: int = 20) -> tuple[list[DeterminismCase], bool]:
    from chat.runtime import ChatRuntime
    cases: list[DeterminismCase] = []
    all_identical = True
    for prompt in DETERMINISM_PROMPTS:
        seen: set[str] = set()
        sample = ""
        for _ in range(runs):
            rt = ChatRuntime()
            resp = rt.chat(prompt)
            seen.add(resp.surface)
            if not sample:
                sample = resp.surface
        unique = len(seen)
        cases.append(DeterminismCase(
            prompt=prompt, runs=runs, unique_surfaces=unique,
            sample=_snippet(sample),
        ))
        if unique != 1:
            all_identical = False
    return cases, all_identical


def bench_footprint(
    turns: int = 200,
    sample_every: int = 25,
    warmup_turns: int = 0,
) -> tuple[list[FootprintSample], int, int, int, float]:
    """Drive a single ChatRuntime through ``turns`` prompts and sample
    RSS every ``sample_every`` turns.

    Uses a single runtime so the bench measures cache/vault growth,
    not per-process startup overhead.  Pass ``warmup_turns`` to drive
    the runtime through lazy initialisation before the measurement
    window opens (useful for short test runs where cold-start allocation
    would otherwise dominate the per-turn delta).
    """
    import psutil
    from chat.runtime import ChatRuntime

    proc = psutil.Process()
    rt = ChatRuntime()

    prompts = [p for _, p in INTENT_PROBE_PROMPTS]
    n = len(prompts)
    for w in range(warmup_turns):
        rt.chat(prompts[w % n])

    samples: list[FootprintSample] = []
    start = proc.memory_info().rss
    samples.append(FootprintSample(turn=0, rss_bytes=start))
    peak = start
    for t in range(1, turns + 1):
        rt.chat(prompts[t % n])
        if t % sample_every == 0 or t == turns:
            rss = proc.memory_info().rss
            samples.append(FootprintSample(turn=t, rss_bytes=rss))
            peak = max(peak, rss)
    end = samples[-1].rss_bytes
    per_turn = (end - start) / max(turns, 1)
    return samples, start, peak, end, per_turn


def bench_cross_topic() -> tuple[list[CrossTopicTurn], int]:
    """Walk the CROSS_TOPIC_PROMPTS list on ONE runtime with
    ``thread_anaphora=True`` and report which turns fired the
    anaphora prefix.
    """
    from chat.runtime import ChatRuntime
    from core.config import RuntimeConfig

    rt = ChatRuntime(config=RuntimeConfig(thread_anaphora=True))
    out: list[CrossTopicTurn] = []
    fires = 0
    for i, prompt in enumerate(CROSS_TOPIC_PROMPTS):
        resp = rt.chat(prompt)
        # Anaphora prefix has the shape ``(Recalling turn N: ...)``.
        fired = resp.surface.startswith("(Recalling turn")
        if fired:
            fires += 1
        out.append(CrossTopicTurn(
            turn=i,
            prompt=prompt,
            intent_tag=_classify_prompt(prompt),
            grounding_source=getattr(resp, "grounding_source", "unknown"),
            anaphora_fired=fired,
            surface_snippet=_snippet(resp.surface),
        ))
    return out, fires


def bench_discourse_planner() -> tuple[list[DiscoursePlannerProbe], dict[str, Any]]:
    from chat.runtime import ChatRuntime
    from core.config import RuntimeConfig

    out: list[DiscoursePlannerProbe] = []
    for label, prompt in DISCOURSE_PLANNER_PROMPTS:
        rt = ChatRuntime(config=RuntimeConfig(discourse_planner=True))
        resp = rt.chat(prompt)
        grounding = getattr(resp, "grounding_source", "unknown")
        sentence_count = _sentence_count(resp.surface)
        articulate = sentence_count >= 2 and grounding in {"pack", "teaching"}
        disclosure = sentence_count >= 2 and grounding in {"oov", "refusal", "none"}
        out.append(DiscoursePlannerProbe(
            label=label,
            prompt=prompt,
            intent_tag=_classify_prompt(prompt),
            grounding_source=grounding,
            sentence_count=sentence_count,
            articulate_sentence=articulate,
            disclosure_sentence=disclosure,
            surface_snippet=_snippet(resp.surface),
        ))

    total = len(out)
    metrics = {
        "cases": total,
        "articulate_sentence_rate": (
            round(sum(1 for p in out if p.articulate_sentence) / total, 4)
            if total else 0.0
        ),
        "disclosure_sentence_rate": (
            round(sum(1 for p in out if p.disclosure_sentence) / total, 4)
            if total else 0.0
        ),
        "multi_sentence_rate": (
            round(sum(1 for p in out if p.sentence_count >= 2) / total, 4)
            if total else 0.0
        ),
    }
    return out, metrics


def _have_ollama() -> bool:
    return shutil.which("ollama") is not None


def _ollama_complete(model: str, prompt: str, timeout: float = 30.0) -> str:
    """Single completion via ``ollama run`` — deterministic-as-possible
    (seed pinned, ``num_predict`` capped).  Returns stdout text or an
    error placeholder; never raises.
    """
    try:
        result = subprocess.run(
            ["ollama", "run", model, "--", prompt],
            capture_output=True,
            text=True,
            timeout=timeout,
            check=False,
        )
        return result.stdout.strip() or result.stderr.strip()
    except (subprocess.TimeoutExpired, OSError) as exc:
        return f"<ollama error: {exc}>"


def bench_ollama_compare(
    model: str | None = None,
    prompts: Iterable[str] = DETERMINISM_PROMPTS,
    core_reruns: int = 5,
    ollama_reruns: int = 5,
) -> dict[str, Any]:
    """Side-by-side: CORE vs Ollama on a fixed prompt set.

    Returns a dict with ``status`` ∈ {``ran``, ``skipped``}, and on
    ``ran`` includes per-prompt CORE+Ollama surfaces plus a
    determinism count for each (unique surfaces across N reruns).
    """
    if not _have_ollama() or model is None:
        return {
            "status": "skipped",
            "reason": (
                "ollama binary not on PATH" if not _have_ollama()
                else "no model specified"
            ),
        }

    from chat.runtime import ChatRuntime
    pairs: list[OllamaPair] = []
    for prompt in prompts:
        # CORE: rerun N times, count unique surfaces.
        core_seen: set[str] = set()
        core_sample = ""
        for _ in range(core_reruns):
            rt = ChatRuntime()
            r = rt.chat(prompt)
            core_seen.add(r.surface)
            if not core_sample:
                core_sample = r.surface
        # Ollama: rerun N times, count unique surfaces.
        ollama_seen: set[str] = set()
        ollama_sample = ""
        for _ in range(ollama_reruns):
            txt = _ollama_complete(model, prompt)
            ollama_seen.add(txt)
            if not ollama_sample:
                ollama_sample = txt
        pairs.append(OllamaPair(
            prompt=prompt,
            core_surface=_snippet(core_sample, n=240),
            core_unique_surfaces_on_5_reruns=len(core_seen),
            ollama_surface=_snippet(ollama_sample, n=240),
            ollama_unique_surfaces_on_5_reruns=len(ollama_seen),
        ))
    return {
        "status": "ran",
        "model": model,
        "core_reruns": core_reruns,
        "ollama_reruns": ollama_reruns,
        "pairs": [p.__dict__ for p in pairs],
        "core_byte_identical_on_every_prompt": all(
            p.core_unique_surfaces_on_5_reruns == 1 for p in pairs
        ),
    }


# ---------------------------------------------------------------------------
# Orchestrator
# ---------------------------------------------------------------------------


def run_articulation_suite(
    *,
    determinism_runs: int = 20,
    footprint_turns: int = 200,
    footprint_sample_every: int = 25,
    ollama_model: str | None = None,
    ollama_core_reruns: int = 5,
    ollama_reruns: int = 3,
    skip_footprint: bool = False,
) -> ArticulationReport:
    """Run every sub-bench and return the consolidated report.

    ``skip_footprint=True`` bypasses ``bench_footprint`` (which
    requires ``psutil``) so the suite can run on environments without
    that optional dependency.  Used by the ``all`` aggregate suite
    when ``psutil`` is unavailable.
    """
    report = ArticulationReport()

    report.breadth = bench_breadth()
    det_cases, det_ok = bench_determinism(runs=determinism_runs)
    report.determinism = det_cases
    report.determinism_all_identical = det_ok
    if not skip_footprint:
        (
            samples, start, peak, end, per_turn,
        ) = bench_footprint(
            turns=footprint_turns, sample_every=footprint_sample_every,
        )
        report.footprint = samples
        report.footprint_start_bytes = start
        report.footprint_peak_bytes = peak
        report.footprint_end_bytes = end
        report.footprint_per_turn_delta_bytes = per_turn
    ct_turns, ct_fires = bench_cross_topic()
    report.cross_topic = ct_turns
    report.anaphora_fire_count = ct_fires
    dp_probes, dp_metrics = bench_discourse_planner()
    report.discourse_planner = dp_probes
    report.discourse_planner_metrics = dp_metrics
    report.ollama = bench_ollama_compare(
        model=ollama_model,
        prompts=DETERMINISM_PROMPTS[:3],  # subset — ollama is slow
        core_reruns=ollama_core_reruns,
        ollama_reruns=ollama_reruns,
    )

    return report


def format_summary(report: ArticulationReport) -> str:
    out: list[str] = []
    out.append("=" * 76)
    out.append("Articulation benchmark suite")
    out.append("=" * 76)
    out.append("")
    out.append("[1/6] Intent breadth — every supported intent shape:")
    for p in report.breadth:
        out.append(
            f"  {p.label:30s} {p.intent_tag:14s} {p.grounding_source:9s} "
            f"{_snippet(p.surface_snippet, 80)}"
        )
    out.append("")
    out.append("[2/6] Determinism — same prompt → byte-identical surface:")
    for c in report.determinism:
        flag = "OK" if c.unique_surfaces == 1 else "FAIL"
        out.append(
            f"  [{flag}] {c.runs} runs / {c.unique_surfaces} unique surface(s)  "
            f"{_snippet(c.prompt, 50)}"
        )
    out.append(
        f"  all_identical = {report.determinism_all_identical}"
    )
    out.append("")
    out.append("[3/6] Memory footprint — single runtime, repeated turns:")
    if report.footprint:
        out.append(
            f"  start = {report.footprint_start_bytes / 1024 / 1024:.1f} MiB  "
            f"peak = {report.footprint_peak_bytes / 1024 / 1024:.1f} MiB  "
            f"end = {report.footprint_end_bytes / 1024 / 1024:.1f} MiB"
        )
        out.append(
            f"  per-turn ΔRSS = "
            f"{report.footprint_per_turn_delta_bytes / 1024:.2f} KiB"
        )
    out.append("")
    out.append("[4/6] Cross-topic context — thread anaphora across subjects:")
    for t in report.cross_topic:
        marker = "↩" if t.anaphora_fired else " "
        out.append(
            f"  {marker} turn {t.turn} [{t.intent_tag:12s} {t.grounding_source:9s}]"
            f"  {_snippet(t.prompt, 40)}"
        )
    out.append(f"  anaphora fired on {report.anaphora_fire_count} turn(s)")
    out.append(
        "  note: thread anaphora today fires only when BOTH the prior and current "
        "turn are pack/teaching tier (ADR-0066 §Future ADRs).  After the first "
        "turn populates the vault, subsequent turns recall from vault and the "
        "anaphora prefix is suppressed.  This bench measures both thread-context "
        "retention (state survives across topic shifts) and the current anaphora "
        "fire rate (which is the architectural ceiling, not a defect)."
    )
    out.append("")
    out.append("[5/6] Discourse planner — flag-on articulation spine:")
    for p in report.discourse_planner:
        marker = "A" if p.articulate_sentence else ("D" if p.disclosure_sentence else " ")
        out.append(
            f"  [{marker}] {p.label:12s} {p.intent_tag:12s} {p.grounding_source:9s} "
            f"{p.sentence_count} sentence(s)  {_snippet(p.prompt, 46)}"
        )
    out.append(f"  metrics = {report.discourse_planner_metrics}")
    out.append("")
    out.append("[6/6] Ollama side-by-side:")
    status = report.ollama.get("status", "skipped")
    if status == "skipped":
        out.append(f"  skipped — {report.ollama.get('reason', '')}")
    else:
        out.append(
            f"  model = {report.ollama['model']}   "
            f"core_byte_identical_on_every_prompt = "
            f"{report.ollama['core_byte_identical_on_every_prompt']}"
        )
        for pair in report.ollama["pairs"]:
            out.append("")
            out.append(f"  prompt: {pair['prompt']}")
            out.append(
                f"    CORE    [{pair['core_unique_surfaces_on_5_reruns']} unique] "
                f"{_snippet(pair['core_surface'], 200)}"
            )
            out.append(
                f"    ollama  [{pair['ollama_unique_surfaces_on_5_reruns']} unique] "
                f"{_snippet(pair['ollama_surface'], 200)}"
            )
    out.append("")
    return "\n".join(out)


__all__ = [
    "ArticulationReport",
    "INTENT_PROBE_PROMPTS",
    "CROSS_TOPIC_PROMPTS",
    "DETERMINISM_PROMPTS",
    "DISCOURSE_PLANNER_PROMPTS",
    "bench_breadth",
    "bench_determinism",
    "bench_footprint",
    "bench_cross_topic",
    "bench_discourse_planner",
    "bench_ollama_compare",
    "run_articulation_suite",
    "format_summary",
]
