"""CP-9 Phase 4 — EvalImageRunner unit tests.

Mocks the registered EvalNode (or the gemini_vision adapter when using a
real GeminiVisionEvalNode) so no live API calls fire. Exercises payload
validation, judge resolution, RunResult shape, error mapping, and the
``time.time_ns()`` id-uniqueness fix from audit § 12c R1.
"""

import sys
import pathlib

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.pipeline.core.eval import (  # noqa: E402
    EvalContext,
    EvalResult,
    register_eval_node,
)
from recoil.pipeline.core.registry import (  # noqa: E402
    MODALITY_EVAL_IMAGE_V1,
    ModalityRunner,
    RunResult,
)
from recoil.pipeline.core.runners.eval_image_runner import EvalImageRunner  # noqa: E402
from recoil.pipeline.core.runners._shared import _failure_metadata_eval as _failure_metadata  # noqa: E402


# ── Fake EvalNode ──────────────────────────────────────────────────────


class _FakeJudge:
    """Minimal EvalNode satisfying the runtime-checkable Protocol."""

    def __init__(
        self,
        *,
        judge_id: str = "eval_image_v1",
        score: float = 0.85,
        reasoning: str = "Looks coherent.",
        model_used: str = "gemini-3.1-pro-preview",
        cost_usd: float = 0.0021,
        metadata=None,
        raise_exc: BaseException = None,
    ):
        self.judge_id = judge_id
        self.model_used = model_used
        self._score = score
        self._reasoning = reasoning
        self._cost_usd = cost_usd
        self._metadata = metadata or {}
        self._raise = raise_exc
        self.calls: list[EvalContext] = []

    def evaluate(self, context: EvalContext) -> EvalResult:
        self.calls.append(context)
        if self._raise is not None:
            raise self._raise
        return EvalResult(
            score=self._score,
            reasoning=self._reasoning,
            judge_id=self.judge_id,
            model_used=self.model_used,
            cost_usd=self._cost_usd,
            metadata=self._metadata,
        )


def _payload(**overrides):
    base = {
        "shot_id": "EP001_SH02",
        "artifact_path": "/tmp/fake_image.png",
        "rubric": "Score this image's compositional quality 0-1.",
    }
    base.update(overrides)
    return base


# ── Modality / construction contract ───────────────────────────────────


def test_run_modality_attribute():
    assert EvalImageRunner.modality == MODALITY_EVAL_IMAGE_V1 == "eval_image_v1"


def test_run_zero_arg_construction():
    r = EvalImageRunner()
    assert isinstance(r, EvalImageRunner)
    assert isinstance(r, ModalityRunner)
    assert r.modality == "eval_image_v1"


# ── Happy path ─────────────────────────────────────────────────────────


def test_run_happy_path():
    judge = _FakeJudge(score=0.85, reasoning="Composed and lit well.")
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert isinstance(out, RunResult)
    assert out.success is True
    assert out.error is None
    assert out.modality == "eval_image_v1"
    assert out.metadata["final_state"] == "succeeded"
    assert out.metadata["eval_score"] == 0.85
    assert out.metadata["eval_reasoning"] == "Composed and lit well."
    assert out.metadata["judge_id"] == "eval_image_v1"
    assert out.metadata["model_used"] == "gemini-3.1-pro-preview"
    assert len(judge.calls) == 1


# ── Missing-key validation ─────────────────────────────────────────────


def test_run_missing_shot_id_returns_failure_RunResult():
    out = EvalImageRunner().run(_payload() | {"shot_id": None})
    assert out.success is False
    assert "shot_id" in out.error
    assert out.metadata == _failure_metadata()
    assert out.id.startswith("unknown_eval_image_v1_")


def test_run_missing_artifact_path_returns_failure():
    p = _payload()
    p.pop("artifact_path")
    out = EvalImageRunner().run(p)
    assert out.success is False
    assert "artifact_path" in out.error
    assert out.metadata["final_state"] == "failed"


def test_run_missing_rubric_returns_failure():
    p = _payload()
    p.pop("rubric")
    out = EvalImageRunner().run(p)
    assert out.success is False
    assert "rubric" in out.error
    assert out.metadata["final_state"] == "failed"


# ── Judge resolution / errors ──────────────────────────────────────────


def test_run_unknown_judge_id_returns_failure_with_KeyError_class():
    # No judge registered.
    out = EvalImageRunner().run(_payload() | {"judge_id": "nonexistent_judge"})
    assert out.success is False
    assert "nonexistent_judge" in out.error
    assert out.metadata["error_class"] == "KeyError"
    assert out.metadata["final_state"] == "failed"
    assert out.metadata["eval_score"] is None


def test_run_default_judge_id_used_when_omitted():
    judge = _FakeJudge(judge_id="eval_image_v1")
    register_eval_node("eval_image_v1", judge)
    p = _payload()
    p.pop("judge_id", None)
    out = EvalImageRunner().run(p)
    assert out.success is True
    assert judge.calls[0].judge_id == "eval_image_v1"


def test_run_custom_judge_id_used():
    custom = _FakeJudge(judge_id="custom_image_judge", score=0.42)
    default = _FakeJudge(judge_id="eval_image_v1", score=0.99)
    register_eval_node("custom_image_judge", custom)
    register_eval_node("eval_image_v1", default)
    out = EvalImageRunner().run(_payload() | {"judge_id": "custom_image_judge"})
    assert out.success is True
    assert out.metadata["eval_score"] == 0.42
    assert len(custom.calls) == 1
    assert len(default.calls) == 0


def test_run_judge_raises_provider_error_returns_failure():
    from recoil.execution.providers.gemini_vision import EvalServerError

    judge = _FakeJudge(raise_exc=EvalServerError("Gemini 503"))
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.success is False
    assert out.metadata["error_class"] == "EvalServerError"
    assert "EvalServerError" in out.error
    assert out.metadata["eval_score"] is None
    assert out.metadata["final_state"] == "failed"


def test_run_judge_raises_unknown_exception_returns_failure():
    judge = _FakeJudge(raise_exc=ValueError("boom"))
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.success is False
    assert out.metadata["error_class"] == "ValueError"
    assert "ValueError" in out.error


# ── Payload coercion ───────────────────────────────────────────────────


def test_run_payload_artifact_path_string_coerced_to_Path():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    EvalImageRunner().run(_payload() | {"artifact_path": "/tmp/img.png"})
    ctx = judge.calls[0]
    assert isinstance(ctx.target_artifact_path, pathlib.Path)
    assert str(ctx.target_artifact_path) == "/tmp/img.png"


def test_run_payload_ref_paths_coerced_to_Paths():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    EvalImageRunner().run(_payload() | {"ref_paths": ["/a.png", "/b.png"]})
    ctx = judge.calls[0]
    refs = ctx.metadata.get("ref_paths")
    assert refs is not None
    assert all(isinstance(p, pathlib.Path) for p in refs)
    assert [str(p) for p in refs] == ["/a.png", "/b.png"]


# ── Metadata content ───────────────────────────────────────────────────


def test_run_metadata_contains_eval_score_and_reasoning():
    judge = _FakeJudge(score=0.55, reasoning="Mid-tier composition.")
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.metadata["eval_score"] == 0.55
    assert out.metadata["eval_reasoning"] == "Mid-tier composition."


def test_run_metadata_contains_judge_metadata_dict():
    judge = _FakeJudge(metadata={"warnings": ["score_clipped"], "request_id": "req_x"})
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert isinstance(out.metadata["judge_metadata"], dict)
    assert out.metadata["judge_metadata"] == {
        "warnings": ["score_clipped"],
        "request_id": "req_x",
    }


# ── RunResult.id format + collision avoidance (audit § 12c R1) ─────────


def test_run_id_format():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.id.startswith("EP001_SH02_eval_image_v1_")
    # Suffix is time.time_ns() — purely digits.
    suffix = out.id.rsplit("_", 1)[-1]
    assert suffix.isdigit()
    assert int(suffix) > 10**15  # nanoseconds since epoch is huge


def test_run_id_unique_across_calls_audit_12c_R1():
    """Two evaluations on the same shot_id must NOT collide.

    Prior to audit § 12c R1, ``RunResult.id`` was ``f"{shot_id}_{modality}"``
    which produced identical ids for back-to-back evals. The fix added
    ``time.time_ns()`` to the id format. This test guards the fix.
    """
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    runner = EvalImageRunner()
    out_a = runner.run(_payload())
    out_b = runner.run(_payload())
    assert out_a.id != out_b.id


# ── Output path / url surface ──────────────────────────────────────────


def test_run_output_path_is_None():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.output_path is None


def test_run_output_url_is_None():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.output_url is None


# ── Cost propagation ───────────────────────────────────────────────────


def test_run_eval_cost_usd_propagated_from_judge():
    judge = _FakeJudge(cost_usd=0.0042)
    register_eval_node("eval_image_v1", judge)
    out = EvalImageRunner().run(_payload())
    assert out.metadata["eval_cost_usd"] == pytest.approx(0.0042)


# ── Failure metadata shape ─────────────────────────────────────────────


def test_run_failure_metadata_shape_six_keys():
    fm = _failure_metadata()
    assert set(fm.keys()) == {
        "final_state",
        "eval_score",
        "eval_reasoning",
        "judge_id",
        "model_used",
        "eval_cost_usd",
    }
    assert fm["final_state"] == "failed"
    assert fm["eval_cost_usd"] == 0.0
    assert fm["eval_score"] is None


# ── Transport injection threading ──────────────────────────────────────


def test_run_extra_passes_transport_when_provided():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    sentinel = object()
    EvalImageRunner().run(_payload() | {"_transport": sentinel})
    ctx = judge.calls[0]
    assert ctx.metadata.get("_transport") is sentinel


def test_run_extra_omits_transport_when_not_provided():
    judge = _FakeJudge()
    register_eval_node("eval_image_v1", judge)
    EvalImageRunner().run(_payload())
    ctx = judge.calls[0]
    assert "_transport" not in ctx.metadata
