"""Build six eval bundles for chapter 12.

These fixtures mirror the event shape the chapter-6 streaming agent
writes (``stt.final``, ``agent.first_token``, ``tts.first_audio``,
``stage.tts.execute``, ``turn.gap``, and, where relevant,
``interruption.start``). The numbers are invented but internally
consistent: ``turn.gap.total_gap_ms`` ends at first accepted audio.

The checked-in bundles and ``ground_truth.csv`` are the artifacts the reader
actually uses. Generate into an ignored output root when experimenting:

    uv run python docs/teaching/12-evals-and-latency/generate_bundles.py \
        --output-root .easycat/teaching/12-evals-and-latency

Maintainers intentionally refreshing the checked-in fixtures can omit
``--output-root``.
"""

from __future__ import annotations

import argparse
import csv
import types
from pathlib import Path

from easycat.debug.export import export_debug_bundle
from easycat.runtime import InMemoryRingBuffer, JournalRecordKind

HERE = Path(__file__).parent


def _emit(
    journal: InMemoryRingBuffer,
    name: str,
    session_id: str,
    data: dict[str, object],
) -> None:
    journal.append(
        kind=JournalRecordKind.EVENT,
        name=name,
        session_id=session_id,
        data=data,
    )


def _save(journal: InMemoryRingBuffer, session_id: str, filename: str, into: Path) -> None:
    into.mkdir(parents=True, exist_ok=True)
    path = into / filename
    export_debug_bundle(types.SimpleNamespace(journal=journal), path, overwrite=True)
    print(f"  wrote {path}")


def _turn(
    journal: InMemoryRingBuffer,
    sid: str,
    t_start: float,
    stt_text: str,
    agent_first_token_delay_ms: float = 350,
    tts_spans_ms: list[float] | None = None,
    interruption_t_ms: float | None = None,
    tool_calls: list[dict[str, object]] | None = None,
) -> None:
    """Emit a canonical turn's worth of records.

    ``tool_calls`` is an optional list of ``{"name", "args", "result",
    "elapsed_ms"}`` dicts; each becomes a paired
    ``tool.call.started`` / ``tool.call.result`` following the same
    shape chapter 7 emits.
    """
    if tts_spans_ms is None:
        tts_spans_ms = [300]
    # The first TTS sentence is the first audible reply. On a tool turn
    # it serves as chapter-7-style filler, so tool execution continues
    # after the user has heard the bot respond.
    first_audio_gap_ms = agent_first_token_delay_ms + tts_spans_ms[0]
    stt_final_t = t_start + 1000
    first_token_t = stt_final_t + agent_first_token_delay_ms
    _emit(journal, "turn.started", sid, {"stage": "turn", "t_ms": t_start})
    _emit(
        journal,
        "stt.final",
        sid,
        {"stage": "stt", "text": stt_text, "t_ms": stt_final_t},
    )
    _emit(
        journal,
        "agent.first_token",
        sid,
        {"stage": "agent", "t_ms": first_token_t},
    )
    _emit(
        journal,
        "tts.first_audio",
        sid,
        {"stage": "tts", "t_ms": stt_final_t + first_audio_gap_ms},
    )
    _emit(
        journal,
        "stage.tts.execute",
        sid,
        {"stage": "tts", "text": "sentence 1", "elapsed_ms": tts_spans_ms[0]},
    )
    for tc in tool_calls or ():
        _emit(
            journal,
            "tool.call.started",
            sid,
            {"stage": "tool", "name": tc["name"], "args": tc.get("args", {})},
        )
        _emit(
            journal,
            "tool.call.result",
            sid,
            {
                "stage": "tool",
                "name": tc["name"],
                "elapsed_ms": tc["elapsed_ms"],
                "result": tc["result"],
            },
        )
    for i, ms in enumerate(tts_spans_ms[1:], start=2):
        _emit(
            journal,
            "stage.tts.execute",
            sid,
            {"stage": "tts", "text": f"sentence {i}", "elapsed_ms": ms},
        )
    if interruption_t_ms is not None:
        _emit(
            journal,
            "interruption.start",
            sid,
            {"stage": "vad", "t_ms": interruption_t_ms},
        )
    _emit(
        journal,
        "turn.gap",
        sid,
        {
            "stage": "turn",
            "total_gap_ms": first_audio_gap_ms,
            "text": stt_text,
        },
    )


def build_all(output_root: Path = HERE) -> list[dict[str, str]]:
    bundles = output_root / "bundles"
    ground_truth = output_root / "ground_truth.csv"
    specs: list[tuple[str, dict]] = [
        # Fast clean turn.
        (
            "turn_01_fast.bundle",
            {
                "t_start": 1_000_000.0,
                "stt_text": "what time is it",
                "agent_first_token_delay_ms": 350,
                "tts_spans_ms": [300, 280, 310],
            },
        ),
        # Slow agent turn (P95 spike).
        (
            "turn_02_slow_agent.bundle",
            {
                "t_start": 1_100_000.0,
                "stt_text": "tell me a joke",
                "agent_first_token_delay_ms": 2100,
                "tts_spans_ms": [320, 290],
            },
        ),
        # Normal turn with a ghost interruption (like ch11 bug 3).
        (
            "turn_03_ghost_interrupt.bundle",
            {
                "t_start": 1_200_000.0,
                "stt_text": "whats the weather",
                "agent_first_token_delay_ms": 400,
                "tts_spans_ms": [310],
                "interruption_t_ms": 1_200_001_450.0,
            },
        ),
        # Normal turn, correctly interrupted by a real user.
        (
            "turn_04_real_interrupt.bundle",
            {
                "t_start": 1_300_000.0,
                "stt_text": "stop",
                "agent_first_token_delay_ms": 380,
                "tts_spans_ms": [290, 310],
                "interruption_t_ms": 1_300_001_400.0,
            },
        ),
        # Medium-clean turn.
        (
            "turn_05_medium.bundle",
            {
                "t_start": 1_400_000.0,
                "stt_text": "remind me to buy milk",
                "agent_first_token_delay_ms": 600,
                "tts_spans_ms": [310, 280, 300, 320],
            },
        ),
        # Turn with two tool calls — exercises the chapter-7 tool
        # shape so exercise 2's "run on tool-bearing bundles" has
        # something to consume.
        (
            "tools_01_weather.bundle",
            {
                "t_start": 1_500_000.0,
                "stt_text": "whats the weather in paris and set a timer for five minutes",
                "agent_first_token_delay_ms": 820,
                "tts_spans_ms": [340, 310, 290],
                "tool_calls": [
                    {
                        "name": "get_weather",
                        "args": {"city": "paris"},
                        "result": "18C, light rain",
                        "elapsed_ms": 1480,
                    },
                    {
                        "name": "set_timer",
                        "args": {"seconds": 300},
                        "result": "ok",
                        "elapsed_ms": 42,
                    },
                ],
            },
        ),
    ]

    rows = []
    for filename, kwargs in specs:
        sid = filename.removesuffix(".bundle")
        j = InMemoryRingBuffer(capacity=1_000)
        _turn(j, sid, **kwargs)
        _save(j, sid, filename, into=bundles)
        rows.append(
            {
                "bundle": filename,
                "reference_transcript": kwargs["stt_text"],
                "had_real_barge_in": "1" if filename == "turn_04_real_interrupt.bundle" else "0",
                "had_tool_call": "1" if kwargs.get("tool_calls") else "0",
            }
        )

    output_root.mkdir(parents=True, exist_ok=True)
    with ground_truth.open("w", newline="") as f:
        w = csv.DictWriter(
            f,
            fieldnames=[
                "bundle",
                "reference_transcript",
                "had_real_barge_in",
                "had_tool_call",
            ],
        )
        w.writeheader()
        w.writerows(rows)
    print(f"  wrote {ground_truth}")
    return rows


def build_golden(output_root: Path = HERE) -> None:
    """Build the golden WER fixtures.

    Each bundle has a hand-tuned mismatch between the STT hypothesis
    (the text in ``stt.final``) and the reference transcript (in
    ``ground_truth.csv``), producing a known, reproducible WER value.
    Use these to verify the WER pipeline before pointing it at real
    recordings — if the numbers below don't match, your pipeline has
    drifted.

    Three fixtures:

    | bundle                       | ref words | edits | expected WER |
    |------------------------------|-----------|-------|--------------|
    | golden_01_wer_5pct.bundle    | 20        | 1 sub | 5.0%         |
    | golden_02_wer_10pct.bundle   | 10        | 1 sub | 10.0%        |
    | golden_03_wer_25pct.bundle   |  8        | 2     | 25.0%        |

    All edits are at the word level (the same shape ``_wer_words``
    in ``evals.py`` measures). Punctuation matters — neither side
    includes any.
    """
    golden = output_root / "bundles" / "golden"
    golden_ground_truth = golden / "ground_truth.csv"
    cases = [
        {
            "filename": "golden_01_wer_5pct.bundle",
            "reference": (
                "today seems like a really good day to go for a walk "
                "in the park with my dog and friends"
            ),
            "hypothesis": (
                # 1 substitution: "great" instead of "good" (word 6 of 20)
                "today seems like a really great day to go for a walk "
                "in the park with my dog and friends"
            ),
            "expected_wer_pct": 5.0,
            "note": "1 substitution, 20-word reference",
        },
        {
            "filename": "golden_02_wer_10pct.bundle",
            "reference": "please set a quick timer for ten minutes starting now",
            "hypothesis": (
                # 1 substitution: "eleven" instead of "ten" (word 7 of 10)
                "please set a quick timer for eleven minutes starting now"
            ),
            "expected_wer_pct": 10.0,
            "note": "1 substitution, 10-word reference",
        },
        {
            "filename": "golden_03_wer_25pct.bundle",
            "reference": "the meeting starts at three in the afternoon",
            "hypothesis": (
                # 1 deletion ("the") + 1 substitution ("four" for "three") = 2 / 8 = 25%
                "meeting starts at four in the afternoon"
            ),
            "expected_wer_pct": 25.0,
            "note": "1 substitution + 1 deletion, 8-word reference",
        },
    ]

    rows: list[dict[str, str]] = []
    t = 9_000_000.0
    for case in cases:
        sid = case["filename"].removesuffix(".bundle")
        j = InMemoryRingBuffer(capacity=1_000)
        _turn(
            j,
            sid,
            t_start=t,
            stt_text=case["hypothesis"],
        )
        _save(j, sid, case["filename"], into=golden)
        rows.append(
            {
                "bundle": case["filename"],
                "reference_transcript": case["reference"],
                "expected_wer_pct": f"{case['expected_wer_pct']:.1f}",
                "had_real_barge_in": "0",
                "had_tool_call": "0",
                "note": case["note"],
            }
        )
        t += 100_000

    golden.mkdir(parents=True, exist_ok=True)
    with golden_ground_truth.open("w", newline="") as f:
        w = csv.DictWriter(
            f,
            fieldnames=[
                "bundle",
                "reference_transcript",
                "expected_wer_pct",
                "had_real_barge_in",
                "had_tool_call",
                "note",
            ],
        )
        w.writeheader()
        w.writerows(rows)
    print(f"  wrote {golden_ground_truth}")


def main(argv: list[str] | None = None) -> None:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--output-root",
        type=Path,
        default=HERE,
        help="Chapter-shaped output root containing bundles/ and ground_truth.csv.",
    )
    args = parser.parse_args(argv)
    golden = args.output_root / "bundles" / "golden"
    golden_ground_truth = golden / "ground_truth.csv"

    print("Building evaluation bundles...")
    build_all(args.output_root)
    print("\nBuilding golden WER bundles (reproducible non-zero WER)...")
    build_golden(args.output_root)
    print(
        "\nRun evals against the golden set with:\n"
        f"  uv run python {HERE / 'evals.py'} "
        f"{golden} {golden_ground_truth}\n"
        "Expected WERs: 5.0%, 10.0%, 25.0%, aggregate 10.5%."
    )


if __name__ == "__main__":
    main()
