"""G3.2 scene_detail_owned_judge wrapper tests.

`run_owned_judge()` 는 post-parse 단계가 호출하는 1-call (no retry — round 4
Q1=B / round 5 M1) judge wrapper. owned 가 빈 list 면 LLM 호출 자체 skip.

C2 v1 (W3): `run_owned_judge` signature 에 required `owned_object_usage` kwarg
추가 — producer (scene_detail v30) 가 declare 한 per-token echo 를 judge v4 가
t2i_prompt 와 cross-check 하도록 전달. 아래 TestRunOwnedJudge call-site 들은
모두 owned_object_usage fixture 를 함께 넘긴다 (signature blast radius).
"""
from __future__ import annotations

import json
from pathlib import Path
from typing import Dict, List
from unittest.mock import MagicMock

import pytest

from app.core.steps._owned_judge import run_owned_judge


PROMPT_DIR = (
    Path(__file__).resolve().parent.parent.parent.parent
    / "prompts" / "_base" / "scene_detail_owned_judge" / "1.202605032354"
)


def _usage(*tokens: str, kind: str = "anchor") -> List[Dict[str, str]]:
    """C2 v1 (W3) — owned_object_usage echo fixture.

    owned token 마다 entry `{owned_token, usage_kind, source_phrase}` 생성.
    `run_owned_judge` 는 echo coverage 를 검증하지 않으므로 (caller 의
    build_owned_sentinel 책임) test 는 owned 와 1:1 매칭만 맞춘다.
    """
    return [
        {
            "owned_token": t,
            "usage_kind": kind,
            "source_phrase": "" if kind == "absent" else f"phrase for {t}",
        }
        for t in tokens
    ]


# ---------------------------------------------------------------------------
# prompt asset presence (Task 9)
# ---------------------------------------------------------------------------


def test_prompt_directory_exists() -> None:
    assert PROMPT_DIR.is_dir()


def test_system_md_exists_and_non_empty() -> None:
    p = PROMPT_DIR / "system.md"
    assert p.exists()
    assert p.read_text(encoding="utf-8").strip()


def test_user_template_md_has_required_placeholders() -> None:
    text = (PROMPT_DIR / "user_template.md").read_text(encoding="utf-8")
    # plan Task 9 step 3 에 명시된 placeholder 3종.
    assert "{t2i_prompt}" in text
    assert "{owned_list_block}" in text
    assert "{camera_direction}" in text


def test_schema_json_shape() -> None:
    schema = json.loads((PROMPT_DIR / "schema.json").read_text(encoding="utf-8"))
    assert schema["type"] == "object"
    assert "violations" in schema["properties"]
    viol_items = schema["properties"]["violations"]["items"]
    assert viol_items["type"] == "object"
    # plan Task 9 step 4: required = owned_object/violating_phrase/reason.
    required = set(viol_items["required"])
    assert required == {"owned_object", "violating_phrase", "reason"}
    # additionalProperties: false (strict — 사용자 R4-2 영어 canonical 전제).
    assert viol_items["additionalProperties"] is False
    assert schema["additionalProperties"] is False


# ---------------------------------------------------------------------------
# run_owned_judge wrapper (Task 11)
# ---------------------------------------------------------------------------


class TestRunOwnedJudge:
    def test_calls_call_structured_with_correct_step(self) -> None:
        fake_call = MagicMock(return_value={"violations": []})
        result = run_owned_judge(
            t2i_prompt="A figure stands by a wall.",
            owned=["door", "window"],
            owned_object_usage=_usage("door", "window"),
            camera_direction="eye-level wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        kwargs = fake_call.call_args.kwargs
        # round 3 #1 / round 4 MINOR 1: step 이름만 전달, default_model 인자 X.
        assert kwargs["step"] == "scene_detail_owned_judge"
        assert "default_model" not in kwargs
        # 결과는 빈 violations.
        assert result == []

    def test_returns_violations_list(self) -> None:
        viols = [
            {
                "owned_object": "TV",
                "violating_phrase": "a TV in the corner",
                "reason": "redraw without anchor",
            }
        ]
        fake_call = MagicMock(return_value={"violations": viols})
        result = run_owned_judge(
            t2i_prompt="A TV in the corner.",
            owned=["TV"],
            owned_object_usage=_usage("TV"),
            camera_direction="medium",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        assert result == viols

    def test_empty_owned_returns_empty_no_call(self) -> None:
        # owned 빈 list → judge 호출 자체 skip (불필요한 LLM 비용 절감).
        # owned 가 비면 owned_object_usage 도 [] (v30 full-coverage invariant).
        fake_call = MagicMock()
        result = run_owned_judge(
            t2i_prompt="anything",
            owned=[],
            owned_object_usage=[],
            camera_direction="wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        assert result == []
        fake_call.assert_not_called()

    def test_user_prompt_inlines_t2i_owned_camera(self) -> None:
        captured: dict = {}

        def fake_call(**kwargs):
            captured.update(kwargs)
            return {"violations": []}

        run_owned_judge(
            t2i_prompt="Hero stands near doorway.",
            owned=["door", "window"],
            owned_object_usage=_usage("door", "window"),
            camera_direction="eye-level wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        user_p = captured["user_prompt"]
        assert "Hero stands near doorway." in user_p
        assert "- door" in user_p
        assert "- window" in user_p
        assert "eye-level wide" in user_p
        # C2 v1 (W3): owned_object_usage echo 가 JSON 으로 inline.
        assert '"owned_token": "door"' in user_p
        assert '"usage_kind"' in user_p
        assert "phrase for window" in user_p
        # placeholder 가 그대로 남아 있으면 안 됨.
        assert "{t2i_prompt}" not in user_p
        assert "{owned_list_block}" not in user_p
        assert "{owned_object_usage}" not in user_p
        assert "{camera_direction}" not in user_p

    def test_passes_schema_and_schema_name(self) -> None:
        captured: dict = {}

        def fake_call(**kwargs):
            captured.update(kwargs)
            return {"violations": []}

        run_owned_judge(
            t2i_prompt="x",
            owned=["door"],
            owned_object_usage=_usage("door"),
            camera_direction="wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        assert captured["schema_name"] == "scene_detail_owned_judge"
        assert isinstance(captured["response_schema"], dict)
        assert "violations" in captured["response_schema"]["properties"]

    def test_passes_project_config_and_opik_metadata(self) -> None:
        captured: dict = {}

        def fake_call(**kwargs):
            captured.update(kwargs)
            return {"violations": []}

        cfg = {"scene_detail_owned_judge": {"model": "gpt-mini"}}
        meta = {"opik": {"tags": ["test"]}}
        run_owned_judge(
            t2i_prompt="x",
            owned=["door"],
            owned_object_usage=_usage("door"),
            camera_direction="wide",
            call_structured_fn=fake_call,
            project_config=cfg,
            opik_metadata=meta,
        )
        assert captured["project_config"] is cfg
        assert captured["opik_metadata"] is meta

    def test_no_retry_returns_violations_immediately(self) -> None:
        # round 4 Q1=B / round 5 M1: 1-call only, no retry on contract violation.
        fake_call = MagicMock(
            return_value={
                "violations": [
                    {
                        "owned_object": "door",
                        "violating_phrase": "a new door appears",
                        "reason": "explicit redraw",
                    }
                ]
            }
        )
        result = run_owned_judge(
            t2i_prompt="A new door appears.",
            owned=["door"],
            owned_object_usage=_usage("door", kind="redraw"),
            camera_direction="wide",
            call_structured_fn=fake_call,
            project_config=None,
            opik_metadata=None,
        )
        # 정확히 1회 호출.
        assert fake_call.call_count == 1
        assert len(result) == 1
        assert result[0]["owned_object"] == "door"

    def test_missing_violations_key_raises_contract_violation(self) -> None:
        # Wave 6 BLOCKING fix: schema 가 violations 를 required 로 선언 — 응답에
        # 키가 없으면 silent [] 가 아니라 AppError(step.contract_violation).
        # feedback_no_silent_fallback.md 준수.
        from app.core.errors import AppError

        fake_call = MagicMock(return_value={})
        with pytest.raises(AppError) as exc_info:
            run_owned_judge(
                t2i_prompt="x",
                owned=["door"],
                owned_object_usage=_usage("door"),
                camera_direction="wide",
                call_structured_fn=fake_call,
                project_config=None,
                opik_metadata=None,
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "violations" in exc_info.value.message

    def test_violations_non_list_raises_contract_violation(self) -> None:
        # Wave 6 BLOCKING fix: violations 가 list 아닌 경우도 surface.
        from app.core.errors import AppError

        fake_call = MagicMock(return_value={"violations": "oops"})
        with pytest.raises(AppError) as exc_info:
            run_owned_judge(
                t2i_prompt="x",
                owned=["door"],
                owned_object_usage=_usage("door"),
                camera_direction="wide",
                call_structured_fn=fake_call,
                project_config=None,
                opik_metadata=None,
            )
        assert exc_info.value.code == "step.contract_violation"
        assert "must be list" in exc_info.value.message
