"""CP-9 Phase 4 — End-to-end dispatch integration for eval modalities.

Per BUILD_SPEC § Phase 4: ``dispatch("eval_image_v1", payload, ctx)`` yields
a RunResult (wrapped in GenerationReceipt) with eval_score/eval_reasoning in
metadata. Mocks at the ``gemini_vision.score_artifact`` boundary so no live
API calls fire.

Exercises the full chain:
    dispatch
      → registry.get_runner("eval_image_v1")
      → EvalImageRunner.run(payload)
      → get_eval_node("eval_image_v1") (registered GeminiVisionEvalNode)
      → GeminiVisionEvalNode.evaluate(ctx)
      → mocked gemini_vision.score_artifact(...)
      → EvalProviderResult → EvalResult → RunResult.metadata
      → GenerationReceipt
"""

import sys
import pathlib
from unittest.mock import patch

sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent.parent.parent))
from recoil.core.paths import ensure_pipeline_importable  # noqa: E402

ensure_pipeline_importable()

import pytest  # noqa: E402

from recoil.execution.providers import gemini_vision as _gv  # noqa: E402
from recoil.pipeline.core.dispatch import (  # noqa: E402
    dispatch,
    register_default_eval_runners,
    _reset_bootstrap_for_tests,
)
from recoil.pipeline.core.dispatch_context import DispatchContext  # noqa: E402
from recoil.pipeline.core.eval import (  # noqa: E402
    register_eval_node,
    _reset_eval_registry_for_tests,
)
from recoil.pipeline.core.receipts import GenerationReceipt  # noqa: E402
from recoil.pipeline.core.registry import _reset_for_tests  # noqa: E402
from recoil.pipeline.core.runners._gemini_vision_eval_node import (  # noqa: E402
    GeminiVisionEvalNode,
)


class _SR:
    """Minimal StepRunner stub for DispatchContext. The eval runners do
    NOT touch StepRunner — but DispatchContext requires one."""
    _dispatch_path = "test"
    def execute_keyframe(self, **kw):
        raise NotImplementedError("not used by eval runners")
    def execute_video(self, **kw):
        raise NotImplementedError("not used by eval runners")


@pytest.fixture(autouse=True)
def reset_all():
    _reset_for_tests()
    _reset_bootstrap_for_tests()
    _reset_eval_registry_for_tests()
    yield
    _reset_for_tests()
    _reset_bootstrap_for_tests()
    _reset_eval_registry_for_tests()


def _provider_result(**overrides):
    base = dict(
        score=0.83,
        reasoning="Composition strong, lighting matches brief.",
        cost_usd=0.0042,
        model_used="gemini-3.1-pro-preview",
        request_id="req_e2e_001",
        raw_metadata={"warnings": [], "finish_reason": "STOP",
                      "usage": {"promptTokenCount": 900, "candidatesTokenCount": 180}},
    )
    base.update(overrides)
    return _gv.EvalProviderResult(**base)


def test_dispatch_eval_image_v1_end_to_end_via_GeminiVisionEvalNode(tmp_path):
    """End-to-end dispatch wired to the real GeminiVisionEvalNode adapter.
    Mocks at gemini_vision.score_artifact so no HTTP calls fire."""
    register_default_eval_runners()
    register_eval_node(
        "eval_image_v1",
        GeminiVisionEvalNode(artifact_modality="image",
                             judge_id="eval_image_v1"),
    )

    captured: dict = {}

    def _fake_score_artifact(**kwargs):
        captured.update(kwargs)
        return _provider_result(score=0.83)

    ctx = DispatchContext(
        caller_id="phase4_e2e_test", step_runner=_SR(),
        project="tartarus", episode=1,
        receipts_log_path="DISABLED",
    )

    with patch.object(_gv, "score_artifact", side_effect=_fake_score_artifact):
        receipt = dispatch("eval_image_v1", {
            "shot_id": "EP001_SH02",
            "artifact_path": str(tmp_path / "x.png"),
            "rubric": "Score this image 0-1.",
        }, context=ctx)

    assert isinstance(receipt, GenerationReceipt)
    assert receipt.modality == "eval_image_v1"
    assert receipt.shot_id == "EP001_SH02"
    assert receipt.project == "tartarus"
    assert receipt.episode == 1
    assert receipt.caller_id == "phase4_e2e_test"

    rr = receipt.run_result
    assert rr.success is True
    assert rr.error is None
    assert rr.modality == "eval_image_v1"
    assert rr.output_path is None  # eval produces no artifact
    assert rr.output_url is None
    md = rr.metadata
    assert md["final_state"] == "succeeded"
    assert md["eval_score"] == pytest.approx(0.83)
    assert md["eval_reasoning"] == "Composition strong, lighting matches brief."
    assert md["judge_id"] == "eval_image_v1"
    assert md["model_used"] == "gemini-3.1-pro-preview"
    assert md["eval_cost_usd"] == pytest.approx(0.0042)

    # Adapter received the right modality.
    assert captured["artifact_modality"] == "image"
    assert captured["model_id"] == "gemini-3.1-pro-preview"


def test_dispatch_eval_video_v1_end_to_end_via_GeminiVisionEvalNode(tmp_path):
    register_default_eval_runners()
    register_eval_node(
        "eval_video_v1",
        GeminiVisionEvalNode(artifact_modality="video",
                             judge_id="eval_video_v1"),
    )

    captured: dict = {}

    def _fake(**kwargs):
        captured.update(kwargs)
        return _provider_result(score=0.71, reasoning="Motion plausible.")

    ctx = DispatchContext(
        caller_id="phase4_e2e_test", step_runner=_SR(),
        receipts_log_path="DISABLED",
    )
    with patch.object(_gv, "score_artifact", side_effect=_fake):
        receipt = dispatch("eval_video_v1", {
            "shot_id": "EP001_SH02",
            "artifact_path": str(tmp_path / "v.mp4"),
            "rubric": "Score this video 0-1.",
        }, context=ctx)

    assert receipt.run_result.success is True
    assert receipt.run_result.metadata["eval_score"] == pytest.approx(0.71)
    assert captured["artifact_modality"] == "video"


def test_dispatch_eval_audio_v1_end_to_end_via_GeminiVisionEvalNode(tmp_path):
    register_default_eval_runners()
    register_eval_node(
        "eval_audio_v1",
        GeminiVisionEvalNode(artifact_modality="audio",
                             judge_id="eval_audio_v1"),
    )

    captured: dict = {}

    def _fake(**kwargs):
        captured.update(kwargs)
        return _provider_result(score=0.49, reasoning="Voice match thin.")

    ctx = DispatchContext(
        caller_id="phase4_e2e_test", step_runner=_SR(),
        receipts_log_path="DISABLED",
    )
    with patch.object(_gv, "score_artifact", side_effect=_fake):
        receipt = dispatch("eval_audio_v1", {
            "shot_id": "EP001_VO01",
            "artifact_path": str(tmp_path / "a.mp3"),
            "rubric": "Score this audio 0-1.",
        }, context=ctx)

    assert receipt.run_result.success is True
    assert receipt.run_result.metadata["eval_score"] == pytest.approx(0.49)
    assert captured["artifact_modality"] == "audio"


def test_dispatch_eval_image_v1_provider_error_yields_failure_receipt(tmp_path):
    """When gemini_vision raises EvalServerError, the runner translates to
    a failure-RunResult (success=False) — never propagates."""
    register_default_eval_runners()
    register_eval_node(
        "eval_image_v1",
        GeminiVisionEvalNode(artifact_modality="image",
                             judge_id="eval_image_v1"),
    )

    ctx = DispatchContext(
        caller_id="phase4_e2e_test", step_runner=_SR(),
        receipts_log_path="DISABLED",
    )

    with patch.object(_gv, "score_artifact",
                      side_effect=_gv.EvalServerError("Gemini 503 retries exhausted")):
        receipt = dispatch("eval_image_v1", {
            "shot_id": "EP001_SH02",
            "artifact_path": str(tmp_path / "x.png"),
            "rubric": "Score 0-1.",
        }, context=ctx)

    rr = receipt.run_result
    assert rr.success is False
    assert "EvalServerError" in rr.error
    assert rr.metadata["error_class"] == "EvalServerError"
    assert rr.metadata["final_state"] == "failed"
    assert rr.metadata["eval_score"] is None
    assert rr.metadata["eval_cost_usd"] == 0.0
