"""A2 Phase 2: board-model OVERRIDE seam + NB2 fallback + flora is2i registry +
fallback builder registration. No live gen - policy/registry/seam units only."""
from recoil.pipeline._lib.board_provider import (
    select_board_model, board_fallback_model, is_board_refusal, BOARD_FALLBACK_MODEL)


class _Beat:
    beat_metadata = None


class _Prim:
    pass


def test_default_routes_to_config_storyboard_model():
    # config default is gpt-image-2 (REC-182) - Image2 STAYS PRIMARY.
    assert select_board_model(beat=_Beat(), primitive=_Prim()) == "gpt-image-2"


def test_override_param_wins():
    assert select_board_model(beat=_Beat(), primitive=_Prim(),
                              override="seedream-v4.5") == "seedream-v4.5"


def test_beat_level_board_model_wins_over_default():
    class _B:
        beat_metadata = {"board_model": "gemini-3-pro-image-preview"}
    assert select_board_model(beat=_B(), primitive=_Prim()) == "gemini-3-pro-image-preview"


def test_fallback_is_nb2():
    assert board_fallback_model("gpt-image-2") == BOARD_FALLBACK_MODEL
    assert BOARD_FALLBACK_MODEL == "gemini-3.1-flash-image-preview"


def test_is_board_refusal_detects_422_and_content_refusal():
    class _RR:
        def __init__(self, success, error):
            self.success, self.error = success, error
    assert is_board_refusal(_RR(False, "Flora 422: content refused")) is True
    assert is_board_refusal(_RR(False, "Image content policy refusal")) is True
    assert is_board_refusal(_RR(False, "network timeout")) is False  # generic fail, not refusal
    assert is_board_refusal(_RR(True, None)) is False                # success
    assert is_board_refusal(None) is False


def test_flora_registry_resolves_is2i_primary_and_fallback():
    from recoil.execution.providers.flora import _FLORA_MODEL_IDS
    assert _FLORA_MODEL_IDS[("gpt-image-2", "is2i")] == "is2i-gpt-image-2"
    assert (_FLORA_MODEL_IDS[("gemini-3.1-flash-image-preview", "is2i")]
            == "is2i-gemini-3.1-flash-image")


def test_fallback_model_storyboard_builders_registered():
    """The fallback model must resolve a storyboard AND storyboard_finish builder
    so the Phase-2 retry does not KeyError on get_builder."""
    from recoil.pipeline._lib.prompt_engine import get_builder
    assert get_builder("gemini-3.1-flash-image-preview", "storyboard") is not None
    assert get_builder("gemini-3.1-flash-image-preview", "storyboard_finish") is not None


def test_fallback_recorded_in_derivation(tmp_path, monkeypatch):
    """Inject a primary dispatch that 422-REFUSES (RunResult(success=False,
    error='...422...')) on call #1 and succeeds on call #2; assert the retry seam
    selects board_fallback_model, the second dispatch carries the NB2 model id, and
    the persisted sidecar records provider == NB2 + 'fallback_from' == primary.
    NO live gen - the dispatch fn is a stub.

    Mirrors the live failure surface: board_builder reads receipt.run_result and
    returns an error dict on `not run_result.success` (board_builder.py:448-453),
    NOT a raised exception. The asserted keys (board['provider'], board['fallback_from'])
    are the SAME sidecar_extra dict the builder stamps at board_builder.py:331-351
    (model at :342) and persists onto beat.board - the fake_dispatch below writes
    payload['sidecar_extra'] to the sidecar JSON, which is the dict the builder
    flowed in; do NOT assert against a parallel sidecar. Fixture helpers are lifted from
    recoil/pipeline/tests/test_board_builder.py (project_paths fixture, _shot,
    _write_batch_scene, _settings_passthrough, _receipt). Build the scene + a JADE
    hero + sublocation ref exactly as
    test_dispatch_success_payload_sidecar_fingerprint_and_board_persisted does.
    If the live board record key path differs at build time, read the persisted
    record once with load_scene and adapt the key path - do NOT weaken the
    assertion to a no-op."""
    import json
    from pathlib import Path
    from PIL import Image
    from recoil.core.paths import ProjectPaths
    from recoil.core.ref_types import RefAsset, ReferenceBundle
    from recoil.pipeline._lib import board_builder as bb
    from recoil.pipeline.core.registry import MODALITY_STORYBOARD, RunResult
    from recoil.pipeline.core.take import Beat, Scene
    from recoil.pipeline.core.persistence import scene_path, save_scene, load_scene
    from recoil.pipeline._lib.board_provider import BOARD_FALLBACK_MODEL

    paths = ProjectPaths(project_root=tmp_path / "fixture_project")
    paths.project_root.mkdir(parents=True)
    monkeypatch.setattr(ProjectPaths, "for_project",
                        classmethod(lambda cls, project=None: paths))
    shots = [
        {"shot_id": "EP001_SH10", "scene_index": 1, "duration_s": 1.0,
         "intent": "Beat.", "asset_data": {"characters": [{"char_id": "JADE",
         "wardrobe_phase_id": "p1"}], "location_id": "int_lab"},
         "spatial_data": {"sublocation": "pod_platform"}},
    ]
    # Persisted scene id/file stays BATCH_004 (test_board_builder.py:50-72);
    # the PUBLIC selector passed to build_and_dispatch_board is EP001_CONT_004
    # (test_board_builder.py:587) - parse_batch_selector only accepts
    # EP###_(CONT|ONER)_### (batch_selector.py:37-41).
    beat = Beat(beat_id="BATCH_004", beat_metadata={"scene_id": "BATCH_004",
                "modality": "r2v_multi", "shot": shots[0], "batch_shots": shots,
                "batch_summary": {"shared_characters": [],
                                  "shared_location_id": "int_lab"}}, board=None)
    scene = Scene(scene_id="BATCH_004", beats=[beat],
                  scene_metadata={"episode": "ep_001", "project": "fixture_project"})
    sfile = scene_path("fixture_project", "ep_001", "BATCH_004")
    save_scene(scene, sfile)  # live signature is save_scene(scene, path) (persistence.py:84)
    paths.global_bible_path.parent.mkdir(parents=True)
    paths.global_bible_path.write_text(
        '{"characters":{"JADE":{"visual_description":"Lean. Second."}}}', encoding="utf-8")
    # _collect_board_refs requires BOTH front + profile; create real PNGs and
    # return both from the bundle monkeypatch
    # (mirror test_board_builder.py:552-572).
    char_dir = paths.project_root / "assets" / "char" / "jade" / "base"
    char_dir.mkdir(parents=True)
    front = char_dir / "jade_front_base_v01.png"
    profile = char_dir / "jade_profile_base_v01.png"
    Image.new("RGB", (8, 8)).save(front)
    Image.new("RGB", (8, 8)).save(profile)
    subloc = (paths.asset_look_dir("loc", "int_lab", "base")
              / "sublocations" / "sublocation_pod_platform_v01.png")
    subloc.parent.mkdir(parents=True)
    Image.new("RGB", (8, 8)).save(subloc)
    # Non-empty segment passthrough (test_board_builder.py:75-76,567); an empty
    # return makes n=len(segments)<1 raise at board_builder.py:259-262.
    monkeypatch.setattr(bb, "derive_settings",
                        lambda segments, **_k: [dict(seg, setting=f"Setting {i}")
                                                for i, seg in enumerate(segments, start=1)])
    monkeypatch.setattr(bb, "resolve_character_bundle",
                        lambda paths, char_id, phase=None: ReferenceBundle((
                            RefAsset(path=front, role="identity", subject=char_id, kind="turn", view="front"),
                            RefAsset(path=profile, role="identity", subject=char_id, kind="turn", view="profile"),
                        )))
    monkeypatch.setattr(bb, "sublocation_ref", lambda paths, lid, name: subloc)
    monkeypatch.setattr(bb, "validate_ref_file", lambda path: None)

    calls = {"n": 0, "models": []}

    def _receipt(success, error=None):
        from recoil.pipeline.core.receipts import GenerationReceipt
        # GenerationReceipt requires receipt_id + modality BEFORE caller_id
        # (receipts.py:59-66); modality must equal run_result.modality
        # (mirror test_board_builder.py:79-96).
        return GenerationReceipt(
            receipt_id="rcpt_test", modality=MODALITY_STORYBOARD,
            caller_id="board_builder", project="fixture_project", episode=1,
            shot_id="EP001_CONT_004", timestamp_utc="2026-06-11T00:00:00Z",
            run_result=RunResult(id="r", modality=MODALITY_STORYBOARD,
                output_path=("/tmp/b.png" if success else None), metadata={},
                success=success, error=error))

    def fake_dispatch(modality, payload, *, context):
        calls["n"] += 1
        calls["models"].append(payload.get("model"))
        if calls["n"] == 1:
            return _receipt(False, error="Flora 422: content refused")
        sd = Path(payload["save_dir"])
        sd.mkdir(parents=True, exist_ok=True)
        png = sd / f"{payload['filename_stem']}.png"
        Image.new("RGB", (20, 20)).save(png)
        Path(f"{png}.json").write_text(json.dumps(payload["sidecar_extra"]), encoding="utf-8")
        return _receipt(True)

    monkeypatch.setattr(bb, "dispatch", fake_dispatch)

    result = bb.build_and_dispatch_board("fixture_project", 1, "EP001_CONT_004",
                                         step_runner=object())

    assert result["success"] is True
    assert calls["n"] == 2, "primary refused -> retried ONCE on the fallback"
    assert calls["models"][1] == BOARD_FALLBACK_MODEL
    board = load_scene(sfile).beats[0].board
    assert board["provider"] == BOARD_FALLBACK_MODEL
    assert board["fallback_from"] == "gpt-image-2"


def test_board_builder_routes_both_sites_through_select_board_model():
    """POST-SEAM grounding (the fact a Phase-1 snapshot would have asserted, moved
    here so it reflects the state AFTER Phase 2 rewrites both board sites). Both
    former hardwired get_model('storyboard','image') call sites in board_builder.py
    now route through select_board_model; ZERO hardwired get_model('storyboard',
    'image') calls remain in that module (the lone surviving one lives in
    board_provider.py, a different file)."""
    import pathlib
    repo = pathlib.Path(__file__).resolve().parents[4]  # _lib/tests -> repo root
    text = (repo / "recoil/pipeline/_lib/board_builder.py").read_text(encoding="utf-8")
    assert text.count('get_model("storyboard", "image")') == 0
    # ".count('select_board_model(')" counts CALL sites only; the import line has no paren.
    assert text.count("select_board_model(") == 2
