"""Tests for Build A Phase 2 — dispatch injects aspect_ratio from Project SSOT.

Coverage:
  1. Payload missing aspect_ratio + project context → injected from Project
  2. Payload has aspect_ratio explicitly → NOT overridden by dispatch
  3. MissingAspectRatioError raised by runner when payload has no aspect_ratio
     and context has no project (dispatch cannot inject)
  4. _require_aspect_ratio helper raises correctly on missing value
  5. _require_aspect_ratio helper returns value when present
"""
from __future__ import annotations

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 unittest.mock import patch, MagicMock  # noqa: E402

from recoil.pipeline.core.exceptions import MissingAspectRatioError  # noqa: E402


# ── helpers ──────────────────────────────────────────────────────────────────


class _StubStepRunner:
    """Minimal StepRunner stub that satisfies DispatchContext requirements."""

    _dispatch_path = None

    def execute_keyframe(self, **kw):
        return None

    def execute_video(self, **kw):
        return None


# ── _require_aspect_ratio unit tests ─────────────────────────────────────────


class TestRequireAspectRatio:
    """Unit tests for the _require_aspect_ratio helper in image_runner and video_runner."""

    def test_image_runner_raises_on_missing(self):
        from recoil.pipeline.core.runners.image_runner import _require_aspect_ratio
        with pytest.raises(MissingAspectRatioError, match="shot_id=MY_SHOT"):
            _require_aspect_ratio({}, "MY_SHOT")

    def test_image_runner_returns_value_when_present(self):
        from recoil.pipeline.core.runners.image_runner import _require_aspect_ratio
        # underscore → colon normalization (internal AspectRatio enum → API format)
        result = _require_aspect_ratio({"aspect_ratio": "16_9"}, "TEST_SHOT")
        assert result == "16:9"

    def test_image_runner_raises_on_empty_string(self):
        from recoil.pipeline.core.runners.image_runner import _require_aspect_ratio
        with pytest.raises(MissingAspectRatioError):
            _require_aspect_ratio({"aspect_ratio": ""}, "TEST_SHOT")

    def test_video_runner_raises_on_missing(self):
        from recoil.pipeline.core.runners.video_runner import _require_aspect_ratio
        with pytest.raises(MissingAspectRatioError, match="shot_id=MY_SHOT"):
            _require_aspect_ratio({}, "MY_SHOT")

    def test_video_runner_returns_value_when_present(self):
        from recoil.pipeline.core.runners.video_runner import _require_aspect_ratio
        # underscore → colon normalization (internal AspectRatio enum → API format)
        result = _require_aspect_ratio({"aspect_ratio": "9_16"}, "TEST_SHOT")
        assert result == "9:16"


# ── dispatch aspect injection tests ──────────────────────────────────────────


class TestDispatchAspectInjection:
    """Integration tests for aspect_ratio injection in dispatch()."""

    def _make_ctx(self, project=None):
        from recoil.pipeline.core.dispatch_context import DispatchContext
        return DispatchContext(
            caller_id="test_aspect_injection",
            step_runner=_StubStepRunner(),
            project=project,
            receipts_log_path="DISABLED",
        )

    def test_explicit_aspect_not_overridden_by_dispatch(self):
        """When payload already has aspect_ratio, dispatch must NOT touch it."""
        from recoil.pipeline.core.dispatch import dispatch, _reset_bootstrap_for_tests
        _reset_bootstrap_for_tests()

        original_ar = "1_1"
        payload = {
            "shot_id": "TEST_NO_OVERRIDE",
            "prompt": "test",
            "model": "test-model",
            "aspect_ratio": original_ar,
        }

        # Capture the payload that reaches runner.run
        captured_payloads: list[dict] = []

        class _CapturingRunner:
            def run(self, p):
                captured_payloads.append(dict(p))
                from recoil.pipeline.core.registry import RunResult
                import time
                return RunResult(
                    id="test_id",
                    modality="image_t2i",
                    success=True,
                    output_path="/tmp/fake.jpg",
                    metadata={"cost_usd": 0.0, "final_state": "ok",
                              "gate_verdict": None, "take_index": None,
                              "model": "test-model", "pipeline": None},
                )

        ctx = self._make_ctx(project="driver-beware")
        with patch("recoil.pipeline.core.dispatch.get_runner",
                   return_value=_CapturingRunner()):
            mock_project = MagicMock()
            mock_project.aspect_ratio = "16_9"
            with patch("recoil.core.project.get_project", return_value=mock_project):
                dispatch("image_t2i", payload, context=ctx)

        assert len(captured_payloads) == 1, "runner.run should be called exactly once"
        # The original aspect_ratio must be preserved
        assert captured_payloads[0]["aspect_ratio"] == original_ar, (
            f"Expected aspect_ratio={original_ar!r}, "
            f"got {captured_payloads[0].get('aspect_ratio')!r}"
        )

    def test_caller_dict_not_mutated_on_injection(self):
        """dispatch() must copy payload before injection — never mutate caller's dict."""
        from recoil.pipeline.core.dispatch import dispatch, _reset_bootstrap_for_tests
        _reset_bootstrap_for_tests()

        caller_payload = {
            "shot_id": "TEST_NO_MUTATE",
            "prompt": "test",
            "model": "test-model",
            # no aspect_ratio
        }
        original_keys = set(caller_payload.keys())

        class _NullRunner:
            def run(self, p):
                from recoil.pipeline.core.registry import RunResult
                return RunResult(
                    id="x", modality="image_t2i", success=False,
                    output_path=None, metadata={},
                )

        ctx = self._make_ctx(project="driver-beware")
        mock_project = MagicMock()
        mock_project.aspect_ratio = "9_16"
        with patch("recoil.pipeline.core.dispatch.get_runner",
                   return_value=_NullRunner()):
            with patch("recoil.core.project.get_project", return_value=mock_project):
                try:
                    dispatch("image_t2i", caller_payload, context=ctx)
                except Exception:
                    pass  # runner may fail; we only care about dict mutation

        # Caller's original dict must be unmodified
        assert set(caller_payload.keys()) == original_keys, (
            "dispatch() must not mutate the caller's payload dict"
        )

    def test_missing_aspect_no_project_context_propagates_error(self):
        """When no aspect_ratio and no project context, MissingAspectRatioError propagates."""
        from recoil.pipeline.core.dispatch import dispatch, _reset_bootstrap_for_tests
        _reset_bootstrap_for_tests()

        payload = {
            "shot_id": "TEST_FAIL",
            "prompt": "test",
            "model": "test-model",
            # no aspect_ratio
        }
        # context with no project — dispatch cannot inject
        ctx = self._make_ctx(project=None)

        class _RaisingRunner:
            def run(self, p):
                from recoil.pipeline.core.runners.image_runner import _require_aspect_ratio
                _require_aspect_ratio(p, p.get("shot_id", "unknown"))

        with patch("recoil.pipeline.core.dispatch.get_runner",
                   return_value=_RaisingRunner()):
            # MissingAspectRatioError inherits from ValueError; the runner raises it,
            # and dispatch() only catches NotImplementedError — so it should propagate.
            with pytest.raises(MissingAspectRatioError):
                dispatch("image_t2i", payload, context=ctx)
