"""R4 Phase 3+4 — tests for dispatch_cli helpers.

Phase 3 — A3 single-shot tag derivation.
Phase 4 — A5 --shots routes to r2v_multi (not silent per-shot fallback).
"""

from unittest.mock import MagicMock, patch


def test_derive_single_shot_tag_env():
    from recoil.pipeline.tools.dispatch_cli import _derive_single_shot_tag
    assert _derive_single_shot_tag({}) == "SOLO_ENV"
    assert _derive_single_shot_tag({"asset_data": {"characters": []}}) == "SOLO_ENV"


def test_derive_single_shot_tag_solo():
    from recoil.pipeline.tools.dispatch_cli import _derive_single_shot_tag
    shot = {"asset_data": {"characters": [{"char_id": "JADE"}]}}
    assert _derive_single_shot_tag(shot) == "SOLO_JADE"


def test_derive_single_shot_tag_duo_sorted():
    from recoil.pipeline.tools.dispatch_cli import _derive_single_shot_tag
    shot = {
        "asset_data": {
            "characters": [{"char_id": "WREN"}, {"char_id": "JADE"}],
        }
    }
    assert _derive_single_shot_tag(shot) == "DUO_JADE_WREN"


def test_derive_single_shot_tag_multi():
    from recoil.pipeline.tools.dispatch_cli import _derive_single_shot_tag
    shot = {
        "asset_data": {
            "characters": [
                {"char_id": "A"},
                {"char_id": "B"},
                {"char_id": "C"},
            ],
        }
    }
    assert _derive_single_shot_tag(shot) == "MULTI_CHAR"


def test_derive_single_shot_tag_string_entries():
    """Tolerate bare strings (some callers pass char_ids without role wrappers)."""
    from recoil.pipeline.tools.dispatch_cli import _derive_single_shot_tag
    shot = {"asset_data": {"characters": ["jade"]}}
    assert _derive_single_shot_tag(shot) == "SOLO_JADE"


# ─────────────────────────────────────────────────────────────────────────────
# Phase 4 — A5 routing tests
# ─────────────────────────────────────────────────────────────────────────────


def test_multi_shot_tag_shared_character():
    from recoil.pipeline.tools.dispatch_cli import _derive_multi_shot_tag
    shots = [
        {"asset_data": {"characters": [{"char_id": "JADE"}]}},
        {"asset_data": {"characters": [{"char_id": "JADE"}]}},
        {"asset_data": {"characters": [{"char_id": "JADE"}, {"char_id": "WREN"}]}},
    ]
    assert _derive_multi_shot_tag(shots) == "A_JADE"


def test_multi_shot_tag_two_shared():
    from recoil.pipeline.tools.dispatch_cli import _derive_multi_shot_tag
    shots = [
        {"asset_data": {"characters": [{"char_id": "JADE"}, {"char_id": "WREN"}]}},
        {"asset_data": {"characters": [{"char_id": "WREN"}, {"char_id": "JADE"}]}},
    ]
    assert _derive_multi_shot_tag(shots) == "A_JADE_WREN"


def test_multi_shot_tag_shared_location_no_char():
    from recoil.pipeline.tools.dispatch_cli import _derive_multi_shot_tag
    shots = [
        {"asset_data": {"characters": [], "location_id": "Int_Lower_Decks"}},
        {"asset_data": {"characters": [], "location_id": "Int_Lower_Decks"}},
    ]
    assert _derive_multi_shot_tag(shots) == "COV_INT_LOWER_DECKS"


def test_multi_shot_tag_env_fallback():
    from recoil.pipeline.tools.dispatch_cli import _derive_multi_shot_tag
    shots = [
        {"asset_data": {"characters": [{"char_id": "A"}]}},
        {"asset_data": {"characters": [{"char_id": "B"}]}},
    ]
    assert _derive_multi_shot_tag(shots) == "COV_ENV"


def test_multi_shot_tag_empty():
    from recoil.pipeline.tools.dispatch_cli import _derive_multi_shot_tag
    assert _derive_multi_shot_tag([]) == "COV_ENV"


# ── --shots routing tests (the load-bearing A5 fix) ──────────────────────────


def _make_args(**overrides):
    """Build a minimal argparse.Namespace stub for the --shots branch."""
    import argparse
    base = dict(
        project="tartarus",
        shot=None,
        shots="EP001_SH16,EP001_SH17,EP001_SH18",
        per_shot=False,
        model="seeddance-2.0",
        plan_dir="visual",
        no_validate_frames=True,
        negative_prompt=None,
        generate_audio=False,
        aspect_ratio="9:16",
        # downstream branches inspect these — keep them benign
        elements=None,
        veo_refs=None,
        wan_refs=None,
        start_frame=None,
        end_frame=None,
        seedance_refs=None,
        ref_video=None,
        audio_url=None,
        pass_id=None,
        resolution=None,
        no_audio=False,
        dry_run=False,
        shot_canonical=None,
        image_refs=None,
        image_paths=None,
        force=False,
        skip_validator=False,
        v2v_endpoint=None,
        source_video=None,
        tier="standard",
        keep_audio=False,
        segments=None,
        mode="standard",
        duration=5,
        prompt=None,
        prompt_style="balanced",
    )
    base.update(overrides)
    return argparse.Namespace(**base)


def test_shots_routes_to_r2v_multi(monkeypatch):
    """--shots without --per-shot must call get_builder(model, 'r2v_multi')
    and dispatch through the r2v_multi runner with segment cut metadata."""
    from recoil.pipeline.tools import dispatch_cli

    seen_get_builder: dict = {}
    seen_dispatch: dict = {}

    def fake_get_builder(model_id, modality):
        seen_get_builder["call"] = (model_id, modality)
        def _b(**kwargs):
            return "stub multi prompt"
        return _b

    def fake_dispatch(modality, payload, context=None):
        seen_dispatch["modality"] = modality
        seen_dispatch["payload"] = payload
        # Build a minimal receipt-ish object the success branch can read.
        result = MagicMock(
            success=True,
            output_path="/tmp/fake_multi.mp4",
            error=None,
            metadata={},
        )
        receipt = MagicMock(
            run_result=result,
            cost_usd=3.64,
            metadata={"seed": 12345},
        )
        return receipt

    def fake_get_plan_shot(project, sid, plan_dir):
        shot = {
            "shot_id": sid,
            "asset_data": {"characters": [{"char_id": "JADE"}]},
            "routing_data": {"target_editorial_duration_s": 4},
            "reference_images": [],
        }
        return shot, 1, {"bible_stub": {"characters": {"JADE": {"role": "protagonist"}}}}

    fake_runner = MagicMock()
    fake_step_runner_cls = MagicMock(return_value=fake_runner)
    fake_project_paths = MagicMock()
    fake_project_paths.for_episode = MagicMock(return_value=MagicMock())

    # Stub the prompt_engine.get_builder, dispatch, and StepRunner ctor.
    monkeypatch.setattr(
        "recoil.pipeline._lib.prompt_engine.get_builder",
        fake_get_builder,
    )
    monkeypatch.setattr(dispatch_cli, "dispatch", fake_dispatch)
    monkeypatch.setattr(dispatch_cli, "get_plan_shot", fake_get_plan_shot)
    monkeypatch.setattr(dispatch_cli, "get_store", lambda p: MagicMock())
    monkeypatch.setattr(
        "recoil.execution.step_runner.StepRunner",
        fake_step_runner_cls,
    )
    monkeypatch.setattr(
        "recoil.execution.step_types.ProjectPaths",
        fake_project_paths,
    )
    # Suppress sidecar disk I/O — the test doesn't care about FS state.
    monkeypatch.setattr(
        "recoil.pipeline._lib.sidecar.write_sidecar_dict",
        lambda path, d: None,
    )

    args = _make_args()

    # Invoke the --shots branch directly by stubbing argparse.
    with patch.object(dispatch_cli.argparse.ArgumentParser, "parse_args", return_value=args):
        # main() returns early via the r2v_multi `return` — no exit code.
        dispatch_cli.main()

    assert seen_get_builder.get("call") == ("seeddance-2.0", "r2v_multi"), (
        f"get_builder not called with r2v_multi: {seen_get_builder}"
    )
    assert seen_dispatch.get("modality") == "r2v_multi"
    payload = seen_dispatch.get("payload") or {}
    assert payload.get("shot_ids") == ["EP001_SH16", "EP001_SH17", "EP001_SH18"]
    assert payload.get("segment_shot_ids") == [
        "EP001_SH16",
        "EP001_SH17",
        "EP001_SH18",
    ]
    assert payload.get("expected_segment_timestamps") == [
        (0.0, 5.0),
        (5.0, 10.0),
        (10.0, 15.0),
    ]
    assert payload.get("model") == "seeddance-2.0"


def test_shots_per_shot_opt_in_falls_to_legacy_loop(monkeypatch):
    """--shots --per-shot must NOT call r2v_multi builder; must hit
    execute_multi_shot (which sequential-falls for non-kling-rest models)."""
    from recoil.pipeline.tools import dispatch_cli

    seen_get_builder: dict = {}

    def fake_get_builder(model_id, modality):
        seen_get_builder.setdefault("calls", []).append((model_id, modality))
        def _b(**kwargs):
            return "should not be called"
        return _b

    def fake_get_plan_shot(project, sid, plan_dir):
        shot = {
            "shot_id": sid,
            "asset_data": {"characters": [{"char_id": "JADE"}]},
            "routing_data": {"target_editorial_duration_s": 4},
            "reference_images": [],
        }
        return shot, 1, {"bible_stub": {}}

    # Stub StepRunner so execute_multi_shot returns synthetic per-shot results.
    fake_runner = MagicMock()
    fake_runner.execute_multi_shot = MagicMock(
        return_value=[
            MagicMock(success=True, output_path=f"/tmp/{sid}.mp4",
                      shot_id=sid, cost_usd=1.21, error=None)
            for sid in ("EP001_SH16", "EP001_SH17", "EP001_SH18")
        ]
    )
    fake_step_runner_cls = MagicMock(return_value=fake_runner)
    fake_project_paths = MagicMock()
    fake_project_paths.for_episode = MagicMock(return_value=MagicMock())

    monkeypatch.setattr(
        "recoil.pipeline._lib.prompt_engine.get_builder",
        fake_get_builder,
    )
    monkeypatch.setattr(dispatch_cli, "get_plan_shot", fake_get_plan_shot)
    monkeypatch.setattr(dispatch_cli, "get_store", lambda p: MagicMock())
    monkeypatch.setattr(
        "recoil.execution.step_runner.StepRunner",
        fake_step_runner_cls,
    )
    monkeypatch.setattr(
        "recoil.execution.step_types.ProjectPaths",
        fake_project_paths,
    )
    monkeypatch.setattr(dispatch_cli, "find_hero_frame", lambda *a, **k: None)
    # Capture sidecar writes — we assert at least one is attempted per shot.
    sidecar_writes: list = []
    monkeypatch.setattr(
        "recoil.pipeline._lib.sidecar.write_sidecar_dict",
        lambda path, d: sidecar_writes.append((str(path), d)),
    )
    # The legacy path also calls build_multi_prompt_sequence — give it a stub.
    monkeypatch.setattr(
        "recoil.pipeline._lib.prompt_engine.build_multi_prompt_sequence",
        lambda batch, **kw: [
            {"index": i + 1, "prompt": "p", "duration": 4}
            for i, _ in enumerate(batch)
        ],
    )

    args = _make_args(per_shot=True)
    with patch.object(dispatch_cli.argparse.ArgumentParser, "parse_args", return_value=args):
        dispatch_cli.main()

    # r2v_multi builder must NOT have been called.
    r2v_multi_calls = [
        c for c in seen_get_builder.get("calls", []) if c[1] == "r2v_multi"
    ]
    assert r2v_multi_calls == [], (
        f"--per-shot must skip r2v_multi; saw: {r2v_multi_calls}"
    )
    fake_runner.execute_multi_shot.assert_called_once()
    # Each successful per-shot result wrote a sidecar.
    assert len(sidecar_writes) == 3, (
        f"--per-shot must write a sidecar per shot, got {len(sidecar_writes)}"
    )


def test_shots_missing_r2v_multi_builder_exits(monkeypatch, capsys):
    """If the model has no r2v_multi builder, --shots (default) must raise
    via sys.exit(1) — NOT silently fall through to per-shot."""
    from recoil.pipeline.tools import dispatch_cli

    def fake_get_builder(model_id, modality):
        # Mirror prompt_engine.get_builder contract on miss.
        raise KeyError(f"No builder for ({model_id!r}, {modality!r}).")

    def fake_get_plan_shot(project, sid, plan_dir):
        return (
            {"shot_id": sid, "asset_data": {}, "routing_data": {}},
            1,
            {"bible_stub": {}},
        )

    fake_runner = MagicMock()
    fake_step_runner_cls = MagicMock(return_value=fake_runner)
    fake_project_paths = MagicMock()
    fake_project_paths.for_episode = MagicMock(return_value=MagicMock())

    monkeypatch.setattr(
        "recoil.pipeline._lib.prompt_engine.get_builder",
        fake_get_builder,
    )
    monkeypatch.setattr(dispatch_cli, "get_plan_shot", fake_get_plan_shot)
    monkeypatch.setattr(dispatch_cli, "get_store", lambda p: MagicMock())
    monkeypatch.setattr(
        "recoil.execution.step_runner.StepRunner",
        fake_step_runner_cls,
    )
    monkeypatch.setattr(
        "recoil.execution.step_types.ProjectPaths",
        fake_project_paths,
    )

    args = _make_args(model="kling-v3")  # kling-v3 has no r2v_multi builder
    import pytest

    with patch.object(dispatch_cli.argparse.ArgumentParser, "parse_args", return_value=args):
        with pytest.raises(SystemExit) as exc_info:
            dispatch_cli.main()
    assert exc_info.value.code == 1
    captured = capsys.readouterr()
    assert "r2v_multi" in captured.out or "r2v_multi" in captured.err
