"""location_consistency P2-3 — validate_response callback 통합 검증.

목적: location_consistency_step이 `call_structured(validate_response=...)`로
빈 locations 배열을 semantic empty로 분류 → Tier 2/3 fallback이 강제 trigger.

이전 버그: Gemini가 valid JSON `{"locations": []}` 반환 시 Tier 1 통과로 간주되어
글로벌 fallback이 trigger 안 됐다. caller가 ValueError로 catch하면 즉시
base_description으로 fallback (ad-hoc).
"""
from __future__ import annotations

import json
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from app.core.steps.location_consistency_step import LocationConsistencyStep
# [2026-08-01 A5 후속] Router 는 그것을 지은 슬롯과 짝으로만 다뤄진다 —
# 실패한 슬롯을 전역에서 다시 읽으면 stale Router 의 실패가 남의 슬롯
# 실패로 보고되어 보조 키를 못 써 보고 죽는다.
from app.modules.llm.llm_client import _RouterBinding


def _write_cp(tmp_path: Path, pid: str, eid: str, step_id: str, payload: dict):
    cp_dir = tmp_path / pid / "checkpoints" / "episodes" / eid / step_id
    cp_dir.mkdir(parents=True, exist_ok=True)
    (cp_dir / "manifest.json").write_text(json.dumps(payload), encoding="utf-8")


def _make_response(content: str) -> MagicMock:
    msg = MagicMock()
    msg.content = content
    choice = MagicMock()
    choice.message = msg
    resp = MagicMock()
    resp.choices = [choice]
    return resp


def _ok_locations(loc_id: str, desc: str) -> MagicMock:
    return _make_response(json.dumps({
        "locations": [{
            "location_id": loc_id,
            "name": "Test",
            "fixed_visual_description": desc,
            "analysis_summary": "ok",
        }],
    }))


def _empty_locations() -> MagicMock:
    return _make_response(json.dumps({"locations": []}))


@pytest.fixture
def step_fixture(tmp_path: Path, monkeypatch):
    monkeypatch.setattr("app.core.config.settings.projects_dir", str(tmp_path))
    pid, eid = "p1", "e1"
    step = LocationConsistencyStep.__new__(LocationConsistencyStep)
    step.project_id = pid
    step.episode_id = eid
    step.db = MagicMock()
    step.project_config = {}
    step.build_opik_metadata = lambda extra_metadata=None: {"opik": {"tags": []}}
    return step, tmp_path, pid, eid


def _setup_minimal_checkpoints(tmp_path: Path, pid: str, eid: str):
    """entity_merge / entity_detail / scene_director / scene_save 최소 셋업."""
    _write_cp(tmp_path, pid, eid, "entity_merge", {"data": {
        "locations": [{"short_id": "L01", "name": "Test"}],
    }})
    _write_cp(tmp_path, pid, eid, "entity_detail", {"data": {
        "entity_details": {"Test:location": {
            "description": "fallback desc from entity_detail",
            "visual_traits": ["roof", "small"],
        }},
    }})
    _write_cp(tmp_path, pid, eid, "scene_director", {"data": {
        "scenes": [{"scene_index": 1, "present_entity_ids": ["L01"]}],
    }})
    _write_cp(tmp_path, pid, eid, "scene_save", {"data": {
        "segments": [{"scene_index": 1, "heading": "L01. Test - Day", "text": "scene text"}],
    }})


class TestLocationConsistencyValidateResponse:
    """P2-3: empty locations response triggers global fallback."""

    @patch("app.core.steps.location_consistency_step.load_schema")
    @patch("app.core.steps.location_consistency_step.load_prompt")
    @patch("app.modules.llm.llm_client._get_router_binding")
    def test_empty_locations_triggers_fallback(
        self, mock_get_router, mock_load_prompt, mock_load_schema, step_fixture
    ):
        """Tier 1 응답 `{"locations": []}` → validate_response False → Tier 2 sanitize 진행."""
        step, tmp_path, pid, eid = step_fixture
        _setup_minimal_checkpoints(tmp_path, pid, eid)

        mock_load_prompt.return_value = "system prompt"
        mock_load_schema.return_value = {"type": "object"}

        router = MagicMock()
        mock_get_router.return_value = _RouterBinding(
            slot="primary", router=router)
        # Tier 1: empty locations (semantic empty)
        # Tier 2 sanitize: 정상 응답
        router.completion.side_effect = [
            _empty_locations(),
            _ok_locations("L01", "fixed desc from sanitized"),
        ]

        result = step._execute()

        # Tier 2 결과가 반영되어야 함 (entity_detail fallback이 아닌 LLM 결과)
        assert result["completed_count"] == 1
        assert result["failed_count"] == 0
        loc = result["data"]["locations"][0]
        assert loc["location_id"] == "L01"
        assert loc["fixed_visual_description"] == "fixed desc from sanitized"
        # router.completion 두 번 호출: Tier 1 + Tier 2
        assert router.completion.call_count == 2

    @patch("app.core.steps.location_consistency_step.load_schema")
    @patch("app.core.steps.location_consistency_step.load_prompt")
    @patch("app.modules.llm.llm_client._get_router_binding")
    def test_non_empty_locations_returns_directly(
        self, mock_get_router, mock_load_prompt, mock_load_schema, step_fixture
    ):
        """Tier 1에 데이터 있으면 그대로 반환 (Tier 2 안 거침)."""
        step, tmp_path, pid, eid = step_fixture
        _setup_minimal_checkpoints(tmp_path, pid, eid)

        mock_load_prompt.return_value = "system prompt"
        mock_load_schema.return_value = {"type": "object"}

        router = MagicMock()
        mock_get_router.return_value = _RouterBinding(
            slot="primary", router=router)
        router.completion.return_value = _ok_locations("L01", "first attempt OK")

        result = step._execute()

        assert result["completed_count"] == 1
        loc = result["data"]["locations"][0]
        assert loc["fixed_visual_description"] == "first attempt OK"
        # Tier 1만 호출 — fallback 없음
        assert router.completion.call_count == 1

    @patch("app.core.steps.location_consistency_step.load_schema")
    @patch("app.core.steps.location_consistency_step.load_prompt")
    @patch("app.modules.llm.llm_client._get_router_binding")
    def test_all_tiers_empty_falls_back_to_entity_detail(
        self, mock_get_router, mock_load_prompt, mock_load_schema, step_fixture
    ):
        """Tier 1/2/3 모두 empty → 마지막 결과(empty) → caller가 ValueError catch → entity_detail desc."""
        step, tmp_path, pid, eid = step_fixture
        _setup_minimal_checkpoints(tmp_path, pid, eid)

        mock_load_prompt.return_value = "system prompt"
        mock_load_schema.return_value = {"type": "object"}

        router = MagicMock()
        mock_get_router.return_value = _RouterBinding(
            slot="primary", router=router)
        # 모든 tier가 empty list. Tier 3은 validator 미적용 — 빈 list 그대로 반환.
        # caller(_process_location)가 빈 list 보면 ValueError → except → fallback.
        router.completion.side_effect = [
            _empty_locations(),  # Tier 1
            _empty_locations(),  # Tier 2
            _empty_locations(),  # Tier 3
        ]

        result = step._execute()

        # 모두 실패 → entity_detail description으로 fallback
        assert result["failed_count"] == 1
        loc = result["data"]["locations"][0]
        assert loc["fixed_visual_description"] == "fallback desc from entity_detail"
        assert "실패" in loc["analysis_summary"]
        assert router.completion.call_count == 3

    @patch("app.core.steps.location_consistency_step.load_schema")
    @patch("app.core.steps.location_consistency_step.load_prompt")
    @patch("app.modules.llm.llm_client._get_router_binding")
    def test_validate_callback_passed_to_call_structured(
        self, mock_get_router, mock_load_prompt, mock_load_schema, step_fixture
    ):
        """call_structured에 validate_response callback이 실제로 전달되는지 직접 확인."""
        step, tmp_path, pid, eid = step_fixture
        _setup_minimal_checkpoints(tmp_path, pid, eid)

        mock_load_prompt.return_value = "system prompt"
        mock_load_schema.return_value = {"type": "object"}

        router = MagicMock()
        mock_get_router.return_value = _RouterBinding(
            slot="primary", router=router)
        router.completion.return_value = _ok_locations("L01", "ok")

        with patch(
            "app.core.steps.location_consistency_step.call_structured",
            wraps=__import__(
                "app.core.steps.location_consistency_step", fromlist=["call_structured"]
            ).call_structured,
        ) as mock_cs:
            step._execute()

        # call_structured가 validate_response 키워드를 받음
        assert mock_cs.called
        kwargs = mock_cs.call_args.kwargs
        assert "validate_response" in kwargs
        validator = kwargs["validate_response"]
        # validator는 빈 list/dict에 False, 정상 데이터에 True
        assert validator({"locations": []}) is False
        assert validator({"locations": [{"fixed_visual_description": ""}]}) is False
        assert validator({"locations": [{"fixed_visual_description": "real desc"}]}) is True
        assert validator({}) is False
