"""capture_phase_c_fixtures.py — Phase 0 behavior-preservation fixture capture.

Runs 50 representative error inputs through the THREE legacy classifiers that
Phase C is going to refactor + replace, recording every verdict to a JSON
fixture. Subsequent phases assert that the new unified classifier returns the
SAME verdict for every input (modulo deliberate changes documented in spec).

Three classifiers under test:

1. ``pipeline._lib.run_shot._extract_failure_mode(step_result) -> FailureMode``
   Reads ``step_result.error``, ``step_result.gate_verdict``,
   ``step_result.final_state`` (all via :func:`_step_attr`) and maps to a
   :class:`core.critic.FailureMode` enum.

2. ``pipeline.orchestrator.retry_dispatcher.classify_failure(step_result, shot_data)
   -> FailureCategory``
   Reads ``step_result.error``, ``step_result.final_state``,
   ``step_result.gate_verdict.details.mismatches``,
   ``step_result.gate_verdict.deferred`` and maps to a
   :class:`pipeline.orchestrator.production_types.FailureCategory` enum.

3. ``pipeline.orchestrator.strategy_registry.detect_failure_mode(pass_result, coverage_pass)
   -> tuple[FailureMode, float]``
   Reads ``pass_result.error``, ``pass_result.success``, ``pass_result.video_path``,
   ``pass_result.segment_results``, ``pass_result.expected_cuts``,
   ``pass_result.detected_cuts`` and ``coverage_pass.segments[*].duration_s``,
   ``coverage_pass.duration_s``.

Wrapping strategy: a single ``StubStepResult`` dataclass is built per input and
fed to (1) and (2). For (3) a real frozen ``PassResult`` (from
``execution.step_types``) and a real ``CoveragePass`` (from
``orchestrator.coverage_planner``) are constructed with one synthetic segment
each so ``detect_failure_mode`` can run end-to-end without touching disk or
calling Opus.

Run modes:

    python3 scripts/capture_phase_c_fixtures.py --check-only
        Validates structure (50 literal inputs, no placeholders, syntax OK)
        and exits 0.

    python3 scripts/capture_phase_c_fixtures.py
        Runs every input through every classifier and writes
        ``pipeline/core/tests/fixtures/phase_c_classifier_fixtures.json``.
"""

from __future__ import annotations

import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional

# ── Path setup so the script is runnable from any cwd ─────────────────
THIS_FILE = Path(__file__).resolve()
RECOIL_ROOT = THIS_FILE.parent.parent  # recoil/
PIPELINE_ROOT = RECOIL_ROOT / "pipeline"

# Order matters: ``recoil/core/`` (paths.py, critic.py, model_profiles.py)
# must shadow ``recoil/pipeline/core/`` (registry.py, dispatch.py) for the
# bare-name ``import core.paths`` calls inside dispatch.py to resolve. We
# install RECOIL_ROOT *last* so ``sys.path.insert(0, ...)`` puts it first
# in the search order.
for p in (str(PIPELINE_ROOT), str(RECOIL_ROOT)):
    if p not in sys.path:
        sys.path.insert(0, p)

FIXTURE_PATH = (
    PIPELINE_ROOT / "core" / "tests" / "fixtures" / "phase_c_classifier_fixtures.json"
)


# ── Stub objects for legacy classifiers ───────────────────────────────


@dataclass
class StubGateVerdict:
    """Mimics the ``GateVerdict`` shape the legacy classifiers read from
    ``step_result.gate_verdict``. ``passed`` defaults to ``True`` so that
    the gate-verdict branch in ``_extract_failure_mode`` is skipped unless
    a test explicitly opts in.
    """

    passed: bool = True
    gate_name: str = ""
    details: dict = field(default_factory=dict)
    deferred: bool = False


@dataclass
class StubStepResult:
    """Minimum surface needed by run_shot._extract_failure_mode and
    retry_dispatcher.classify_failure.
    """

    error: Optional[str] = ""
    final_state: str = ""
    gate_verdict: Optional[StubGateVerdict] = None
    model: str = "test-model"
    metadata: dict = field(default_factory=dict)


# ── Inputs ────────────────────────────────────────────────────────────

INPUTS: list[dict[str, Any]] = [
    {"error": "429 rate limit", "expect_transient": True},
    {"error": "ECONNRESET", "expect_transient": True},
    {"error": "504 gateway timeout", "expect_transient": True},
    {"error": "500 internal", "expect_transient": True},
    {"error": "503 unavailable", "expect_transient": True},
    {"error": "502 bad gateway", "expect_transient": True},
    {"error": "501 not implemented", "expect_transient": True},
    {"error": "timeout reading from upstream", "expect_transient": True},
    {"error": "connection refused", "expect_transient": True},
    {"error": "content policy violation", "expect_content_filter": True},
    {"error": "safety blocked the request", "expect_content_filter": True},
    {"error": "moderation refused this prompt", "expect_content_filter": True},
    {"error": "unsafe content detected", "expect_content_filter": True},
    {"error": "budget exceeded for this run", "expect_budget": True},
    {"error": "402 insufficient balance", "expect_budget": True},
    {"error": "422 input should be valid", "expect_schema": True},
    {"error": "unprocessable entity (422)", "expect_schema": True},
    {"error": "ValidationError: field 'duration' missing", "expect_schema": True},
    {"error": "wardrobe phase mismatch detected", "expect_wardrobe": True},
    {"error": "identity drift detected on subject", "expect_identity": True},
    {
        "error": "background contamination — unwanted character",
        "expect_background": True,
    },
    {"error": "anatomy: limb miscount", "expect_anatomy": True},
    {"error": "anatomy: face merge", "expect_anatomy": True},
    {"error": "lighting mismatch with bible", "expect_lighting": True},
    {"error": "grid influence detected", "expect_grid": True},
    {"error": "ref bleed — character A wearing B's wardrobe", "expect_ref_bleed": True},
    {"error": "audio sync drift > 200ms", "expect_audio_sync": True},
    {"error": "coverage geometry broken", "expect_coverage": True},
    {"error": "composition wrong — main subject off-frame", "expect_composition": True},
    {"error": "style drift from bible palette", "expect_style": True},
    {"error": "cuts too soft — no clear shot change", "expect_cuts": True},
    {
        "error": "prompt duration mismatch — 8s prompt for 5s shot",
        "expect_prompt_duration": True,
    },
    {"error": "cost overrun: 250% of budget", "expect_cost_overrun": True},
    {"error": "motion failure — subject frozen", "expect_motion": True},
    {"error": "end frame drift detected", "expect_end_frame": True},
    {"error": "GATE_MECHANICAL: artifact missing", "expect_gate_mechanical": True},
    {"error": "GATE_MECHANICAL: file not found", "expect_gate_mechanical": True},
    {"error": "fal.ai job timed out at 120s", "expect_transient": True},
    {"error": "kling api returned 502 bad gateway", "expect_transient": True},
    {"error": "google gemini quota exceeded", "expect_budget": True},
    {"error": "elevenlabs 401 unauthorized", "expect_permanent": True},
    {"error": "sync.so 422 invalid audio format", "expect_schema": True},
    {"error": "ECONNRESET during sync.so streaming", "expect_transient": True},
    {"error": "rate limit reached, retry after 60s", "expect_transient": True},
    {"error": "the upstream provider returned a 500", "expect_transient": True},
    {"error": "garbage random text that no list captures", "expect_unknown": True},
    {"error": "weird-error-shape-not-in-any-list", "expect_unknown": True},
    {"error": "", "expect_unknown": True},
    {"error": "None", "expect_unknown": True},
    {"error": "TypeError: 'NoneType' is not subscriptable", "expect_unknown": True},
]
assert len(INPUTS) == 50, f"Expected 50 inputs, got {len(INPUTS)}"


# ── Self-validation (used by --check-only) ────────────────────────────


def _self_check_inputs() -> None:
    """Walk INPUTS once and refuse any placeholder / non-string error."""
    if len(INPUTS) != 50:
        raise AssertionError(f"Expected 50 inputs, got {len(INPUTS)}")
    for idx, item in enumerate(INPUTS):
        if not isinstance(item, dict):
            raise AssertionError(f"INPUTS[{idx}] is not a dict: {item!r}")
        if "error" not in item:
            raise AssertionError(f"INPUTS[{idx}] missing 'error' key: {item!r}")
        err = item["error"]
        if not isinstance(err, str):
            raise AssertionError(
                f"INPUTS[{idx}].error is not a literal string (type={type(err).__name__}): {err!r}"
            )
        # Reject obvious placeholder tokens.
        for placeholder in ("<example>", "<TODO>", "<PLACEHOLDER>", "TODO_REPLACE"):
            if placeholder in err:
                raise AssertionError(
                    f"INPUTS[{idx}].error contains placeholder {placeholder!r}: {err!r}"
                )


# ── Classifier wrappers ───────────────────────────────────────────────


def _run_extract_failure_mode(error_text: str) -> dict[str, Any]:
    """Wrap pipeline._lib.run_shot.extract_failure_mode."""
    from recoil.pipeline._lib.run_shot import extract_failure_mode  # type: ignore

    stub = StubStepResult(error=error_text)
    try:
        result = extract_failure_mode(stub)
        # FailureMode is a str-Enum; capture its .value for stable JSON.
        return {"value": getattr(result, "value", str(result))}
    except Exception as exc:  # noqa: BLE001
        return {"error": f"{type(exc).__name__}: {exc}"}


def _run_classify_failure(error_text: str) -> dict[str, Any]:
    """Wrap pipeline.orchestrator.retry_dispatcher.classify_failure."""
    from recoil.pipeline.orchestrator.retry_dispatcher import classify_failure  # type: ignore

    stub = StubStepResult(error=error_text, gate_verdict=StubGateVerdict())
    try:
        result = classify_failure(stub, shot_data=None)
        return {"value": getattr(result, "value", str(result))}
    except Exception as exc:  # noqa: BLE001
        return {"error": f"{type(exc).__name__}: {exc}"}


def _build_pass_pair(error_text: str):
    """Build a real (PassResult, CoveragePass) pair so detect_failure_mode
    can run without monkey-patching. Both classes are
    @dataclass(frozen=True)/@dataclass — we instantiate the minimum viable
    fields the function reads and leave the rest at defaults.
    """
    from recoil.execution.step_types import PassResult  # type: ignore
    from orchestrator.coverage_planner import (  # type: ignore
        CoveragePass,
        CoverageSegment,
    )

    seg = CoverageSegment(
        segment_index=0,
        source_shot_id="EP001_SH01",
        shot_type="MS",
        duration_s=5,
        prompt="placeholder",
    )
    coverage_pass = CoveragePass(
        pass_id="phase_c_fixture_pass",
        episode_id="ep001",
        shot_range=("EP001_SH01", "EP001_SH01"),
        camera_side="A",
        label="fixture",
        focus_character="char_A",
        pass_type="character",
        segments=[seg],
    )
    pass_result = PassResult(
        pass_id="phase_c_fixture_pass",
        success=False,
        video_path=None,
        cost_usd=0.0,
        segment_results=(),
        model="test-model",
        pipeline="coverage_pass",
        error=error_text,
        take_index=0,
        expected_cuts=0,
        detected_cuts=0,
    )
    return pass_result, coverage_pass


def _run_detect_failure_mode(error_text: str) -> dict[str, Any]:
    """Wrap pipeline.orchestrator.strategy_registry.detect_failure_mode."""
    from recoil.pipeline.orchestrator.strategy_registry import detect_failure_mode  # type: ignore

    try:
        pass_result, coverage_pass = _build_pass_pair(error_text)
        mode, conf = detect_failure_mode(pass_result, coverage_pass)
        return {
            "value": getattr(mode, "value", str(mode)),
            "confidence": float(conf),
        }
    except Exception as exc:  # noqa: BLE001
        return {"error": f"{type(exc).__name__}: {exc}"}


# ── Capture orchestration ─────────────────────────────────────────────


def capture_fixtures() -> dict[str, Any]:
    """Run every INPUT through every classifier and assemble the fixture dict."""
    entries: list[dict[str, Any]] = []
    for idx, item in enumerate(INPUTS):
        err_text = item["error"]
        entry = {
            "index": idx,
            "input": dict(item),
            "extract_failure_mode": _run_extract_failure_mode(err_text),
            "classify_failure": _run_classify_failure(err_text),
            "detect_failure_mode": _run_detect_failure_mode(err_text),
        }
        entries.append(entry)
    return {
        "schema_version": 1,
        "captured_for": "engine-fix-phase-C",
        "classifiers": [
            "recoil.pipeline._lib.run_shot._extract_failure_mode",
            "pipeline.orchestrator.retry_dispatcher.classify_failure",
            "pipeline.orchestrator.strategy_registry.detect_failure_mode",
        ],
        "input_count": len(entries),
        "entries": entries,
    }


def main(argv: list[str]) -> int:
    if "--check-only" in argv:
        # Compile-self check (catches accidental syntax errors when the file
        # is rewritten by future phases) and INPUTS-shape check.
        with open(THIS_FILE, "rb") as fh:
            compile(fh.read(), str(THIS_FILE), "exec")
        _self_check_inputs()
        print("OK: 50 literal inputs, no placeholders, syntax compiles.")
        return 0

    fixtures = capture_fixtures()
    FIXTURE_PATH.parent.mkdir(parents=True, exist_ok=True)
    with open(FIXTURE_PATH, "w") as fh:
        json.dump(fixtures, fh, indent=2, sort_keys=False)
        fh.write("\n")
    print(
        f"Wrote {len(fixtures['entries'])} fixtures → "
        f"{FIXTURE_PATH.relative_to(RECOIL_ROOT)}"
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main(sys.argv[1:]))
