"""글로벌 3-tier fallback 단위 테스트 (Track A — Fix 1).

대상:
- `app.modules.llm.llm_client.call_structured` (3-tier 통합)
- `app.modules.llm.llm_client.call_text` (3-tier 통합)
- `app.modules.llm.llm_client.call_multiturn` (3-tier 통합)
- `app.modules.llm.safety.sanitize_for_safety`

핵심: router.completion을 mock해서 Tier1/2/3 시나리오별 동작 검증.
실제 LLM 호출 없음 (모든 호출 mock).
"""
from __future__ import annotations

import json
from typing import Any, Dict, List
from unittest.mock import MagicMock, patch

import pytest


# ── 공용 헬퍼 ────────────────────────────────────────────────

def _make_response(content: str) -> MagicMock:
    """LiteLLM Router.completion 응답을 흉내내는 mock 객체."""
    msg = MagicMock()
    msg.content = content
    choice = MagicMock()
    choice.message = msg
    resp = MagicMock()
    resp.choices = [choice]
    return resp


def _ok_json(payload: Dict[str, Any]) -> MagicMock:
    return _make_response(json.dumps(payload))


def _empty_response() -> MagicMock:
    return _make_response("")


@pytest.fixture
def mock_router():
    """`_get_router`을 patch해서 router.completion을 직접 제어."""
    # [2026-08-01 A5 후속] 봉합 지점이 `_get_router` 에서 `_get_router_binding`
    # 으로 옮겼다. Router 는 이제 **그것을 지은 슬롯**과 짝으로만 다뤄진다 —
    # 실패한 슬롯을 전역에서 다시 읽으면 stale Router 의 실패가 남의 슬롯
    # 실패로 보고되기 때문이다.
    from app.modules.llm.llm_client import _RouterBinding

    with patch("app.modules.llm.llm_client._get_router_binding") as mock_get:
        router = MagicMock()
        mock_get.return_value = _RouterBinding(slot="primary", router=router)
        yield router


_SCHEMA = {"type": "object", "properties": {"ok": {"type": "boolean"}}}


# ── call_structured ─────────────────────────────────────────

class TestCallStructuredFallback:
    def test_tier1_success_no_fallback(self, mock_router):
        """1차 호출 성공 — sanitize/gpt 단계 안 거침."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.return_value = _ok_json({"ok": True})

        result = call_structured(
            step="scene_consistency",
            system_prompt="sys",
            user_prompt="hello",
            response_schema=_SCHEMA,
        )

        assert result == {"ok": True}
        assert mock_router.completion.call_count == 1
        # 메시지가 sanitize 안 된 원본 그대로 전달됨
        msgs = mock_router.completion.call_args.kwargs["messages"]
        assert msgs[1]["content"] == "hello"
        # sanitize 태그 없음 (Tier 1에서 끝)
        meta = mock_router.completion.call_args.kwargs.get("metadata") or {}
        opik_meta = meta.get("opik") or {}
        assert "sanitized" not in (opik_meta.get("tags") or [])
        assert "gpt_fallback" not in (opik_meta.get("tags") or [])

    def test_tier1_empty_response_tier2_success(self, mock_router):
        """1차 empty → Tier 2 sanitize 통과."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            _empty_response(),
            _ok_json({"ok": True}),
        ]

        result = call_structured(
            step="scene_consistency",
            system_prompt="sys",
            user_prompt="피웅덩이 텍스트",
            response_schema=_SCHEMA,
        )

        assert result == {"ok": True}
        assert mock_router.completion.call_count == 2
        # Tier 2 호출에서 user_prompt sanitize 됨
        second_call = mock_router.completion.call_args_list[1]
        msgs = second_call.kwargs["messages"]
        assert "피웅덩이" not in msgs[1]["content"]  # sanitize 적용
        assert "붉은 액체 웅덩이" in msgs[1]["content"]
        # system에 SAFETY_SYSTEM_SUFFIX 추가
        assert "[콘텐츠 안전 참고]" in msgs[0]["content"]
        # 태그에 sanitized
        meta = second_call.kwargs.get("metadata") or {}
        assert "sanitized" in (meta.get("opik") or {}).get("tags", [])

    def test_tier1_prohibited_tier2_success(self, mock_router):
        """1차 PROHIBITED-style 예외 → Tier 2 통과."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            Exception("BLOCKED: PROHIBITED_CONTENT"),
            _ok_json({"ok": True}),
        ]

        result = call_structured(
            step="scene_detail",
            system_prompt="sys",
            user_prompt="dead body in the room",
            response_schema=_SCHEMA,
        )

        assert result == {"ok": True}
        assert mock_router.completion.call_count == 2

    def test_tier1_2_fail_tier3_gpt_success(self, mock_router):
        """Tier 1/2 fail → Tier 3 GPT fallback 통과."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            Exception("PROHIBITED"),
            Exception("PROHIBITED"),
            _ok_json({"ok": True, "final": "gpt"}),
        ]

        result = call_structured(
            step="scene_detail",
            system_prompt="sys",
            user_prompt="시신",
            response_schema=_SCHEMA,
        )

        assert result == {"ok": True, "final": "gpt"}
        assert mock_router.completion.call_count == 3
        third = mock_router.completion.call_args_list[2]
        # Tier 3은 model="gpt" 강제
        assert third.kwargs["model"] == "gpt"
        # 태그 gpt_fallback
        meta = third.kwargs.get("metadata") or {}
        assert "gpt_fallback" in (meta.get("opik") or {}).get("tags", [])

    def test_all_tiers_fail_raises(self, mock_router):
        """3차도 실패 → 마지막 예외 raise."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            Exception("e1"),
            Exception("e2"),
            Exception("e3-final"),
        ]

        with pytest.raises(Exception, match="e3-final"):
            call_structured(
                step="scene_detail",
                system_prompt="sys",
                user_prompt="text",
                response_schema=_SCHEMA,
            )
        assert mock_router.completion.call_count == 3

    def test_disable_fallback_legacy(self, mock_router):
        """enable_fallback=False — 1차 fail 시 즉시 raise (sanitize 시도 안 함)."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [Exception("first failure")]

        with pytest.raises(Exception, match="first failure"):
            call_structured(
                step="scene_detail",
                system_prompt="sys",
                user_prompt="text",
                response_schema=_SCHEMA,
                enable_fallback=False,
            )
        assert mock_router.completion.call_count == 1

    def test_tier3_uses_gpt_when_initial_step_was_gemini(self, mock_router):
        """기본 모델이 gemini-pro인 step도 Tier 3에서 model=gpt로 강제됨."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            Exception("e1"),
            Exception("e2"),
            _ok_json({"ok": True}),
        ]

        # location_consistency 기본 = gemini-pro (PIPELINE_STEPS extension).
        # GPT-5.6 이관(2026-07-10)으로 scene_director 는 gpt(Sol) 기본이 되어
        # gemini 시작 fixture 로 부적합 — gemini-pro 기본이 남아 있는 step 사용.
        call_structured(
            step="location_consistency",
            system_prompt="sys",
            user_prompt="text",
            response_schema=_SCHEMA,
        )

        first_model = mock_router.completion.call_args_list[0].kwargs["model"]
        third_model = mock_router.completion.call_args_list[2].kwargs["model"]
        assert first_model == "gemini-pro"
        assert third_model == "gpt"


# ── call_text ─────────────────────────────────────────

class TestCallTextFallback:
    def test_tier1_success(self, mock_router):
        from app.modules.llm.llm_client import call_text

        mock_router.completion.return_value = _make_response("안녕")

        result = call_text(
            step="text_cleanup",
            system_prompt="sys",
            user_prompt="hi",
        )
        assert result == "안녕"
        assert mock_router.completion.call_count == 1

    def test_tier1_empty_tier2_success(self, mock_router):
        from app.modules.llm.llm_client import call_text

        mock_router.completion.side_effect = [
            _empty_response(),
            _make_response("ok"),
        ]
        result = call_text(
            step="text_cleanup",
            system_prompt="sys",
            user_prompt="피 묻은 옷",
        )
        assert result == "ok"
        assert mock_router.completion.call_count == 2
        second = mock_router.completion.call_args_list[1]
        assert "붉은 물감이 묻은" in second.kwargs["messages"][1]["content"]

    def test_disable_fallback_text(self, mock_router):
        from app.modules.llm.llm_client import call_text

        mock_router.completion.side_effect = [Exception("boom")]
        with pytest.raises(Exception, match="boom"):
            call_text(
                step="text_cleanup",
                system_prompt="s",
                user_prompt="u",
                enable_fallback=False,
            )
        assert mock_router.completion.call_count == 1


# ── call_multiturn ─────────────────────────────────────────

class TestCallMultiturnFallback:
    def test_tier1_success(self, mock_router):
        from app.modules.llm.llm_client import call_multiturn

        mock_router.completion.return_value = _ok_json({"ok": True})
        result = call_multiturn(
            step="entity_extract",
            messages=[
                {"role": "system", "content": "sys"},
                {"role": "user", "content": "u"},
            ],
            response_schema=_SCHEMA,
        )
        assert result == {"ok": True}
        assert mock_router.completion.call_count == 1

    def test_tier2_sanitizes_messages_and_appends_suffix(self, mock_router):
        from app.modules.llm.llm_client import call_multiturn

        mock_router.completion.side_effect = [
            Exception("PROHIBITED"),
            _ok_json({"ok": True}),
        ]
        call_multiturn(
            step="entity_extract",
            messages=[
                {"role": "system", "content": "you are extractor"},
                {"role": "user", "content": "시신을 묘사해줘"},
            ],
            response_schema=_SCHEMA,
        )
        assert mock_router.completion.call_count == 2
        second = mock_router.completion.call_args_list[1]
        msgs = second.kwargs["messages"]
        # system 메시지에 SAFETY_SYSTEM_SUFFIX append
        assert "[콘텐츠 안전 참고]" in msgs[0]["content"]
        assert "you are extractor" in msgs[0]["content"]
        # user 메시지 sanitize
        assert "시신" not in msgs[1]["content"]
        assert "쓰러진 인물" in msgs[1]["content"]

    def test_tier3_gpt_fallback_multiturn(self, mock_router):
        from app.modules.llm.llm_client import call_multiturn

        mock_router.completion.side_effect = [
            Exception("e1"),
            Exception("e2"),
            _make_response("done"),
        ]
        result = call_multiturn(
            step="entity_extract",
            messages=[
                {"role": "system", "content": "s"},
                {"role": "user", "content": "u"},
            ],
            response_schema=None,  # free-text path
        )
        assert result == "done"
        assert mock_router.completion.call_count == 3
        assert mock_router.completion.call_args_list[2].kwargs["model"] == "gpt"


# ── safety.py 단위 ─────────────────────────────────────────

class TestSanitizeForSafety:
    def test_replaces_korean_graphic_words(self):
        from app.modules.llm.safety import sanitize_for_safety

        out = sanitize_for_safety("죽은 시신이 피웅덩이에 있다")
        assert "시신" not in out
        assert "피웅덩이" not in out
        assert "쓰러진 인물" in out
        assert "붉은 액체 웅덩이" in out

    def test_replaces_english_graphic_words(self):
        from app.modules.llm.safety import sanitize_for_safety

        out = sanitize_for_safety("a bloody dead corpse on the floor")
        # 단어 단위 치환 — 어휘가 바뀌는지 확인
        assert "blood" not in out.lower() or "dark red paint" in out.lower()
        assert "corpse" not in out.lower()
        assert "motionless" in out.lower()

    def test_idempotent_on_clean_text(self):
        from app.modules.llm.safety import sanitize_for_safety

        clean = "그는 평범한 옷을 입고 있다."
        assert sanitize_for_safety(clean) == clean

    def test_empty_string(self):
        from app.modules.llm.safety import sanitize_for_safety

        assert sanitize_for_safety("") == ""

    def test_non_string_passthrough(self):
        """multimodal content list 등 비-str 입력은 그대로 통과."""
        from app.modules.llm.safety import sanitize_for_safety

        payload: List[Dict[str, str]] = [{"type": "text", "text": "시신"}]
        # list 입력은 변경 없이 그대로 반환
        assert sanitize_for_safety(payload) is payload

    def test_does_not_truncate(self):
        """CLAUDE.md 절대 규칙: 길이 자르지 않는다."""
        from app.modules.llm.safety import sanitize_for_safety

        long_text = "평범한 텍스트 " * 1000
        out = sanitize_for_safety(long_text)
        # 단어 치환 안 일어나는 텍스트 → 길이 동일
        assert len(out) == len(long_text)


class TestSanitizeMessages:
    def test_preserves_role_structure(self):
        from app.modules.llm.safety import sanitize_messages

        msgs = [
            {"role": "system", "content": "sys"},
            {"role": "user", "content": "시신"},
            {"role": "assistant", "content": "응답"},
        ]
        out = sanitize_messages(msgs)
        assert len(out) == 3
        assert out[0]["role"] == "system"
        assert out[1]["role"] == "user"
        assert out[1]["content"] == "쓰러진 인물"
        assert out[2]["role"] == "assistant"

    def test_does_not_mutate_input(self):
        from app.modules.llm.safety import sanitize_messages

        original = [{"role": "user", "content": "시신"}]
        sanitize_messages(original)
        assert original[0]["content"] == "시신"  # 원본 보존

    def test_non_str_content_preserved(self):
        from app.modules.llm.safety import sanitize_messages

        # multimodal content list
        msgs = [
            {"role": "user", "content": [{"type": "text", "text": "시신"}]},
        ]
        out = sanitize_messages(msgs)
        # list content는 sanitize 안 함 (multimodal 보호)
        assert out[0]["content"] == [{"type": "text", "text": "시신"}]


# ── F1-1, F1-2, P2-3 추가 시나리오 ─────────────────────────────────────────


def _make_named_exc(class_name: str) -> type:
    """동적으로 class name을 가진 Exception 서브클래스 생성.

    is_safety_related_error는 type(exc).__name__으로 transient 분류.
    실제 RateLimitError/Timeout 등을 import하지 않고 이름만 매칭 검증.
    """
    return type(class_name, (Exception,), {})


class TestF1_1_CallTextLegacyEmptyResponse:
    """F1-1: enable_fallback=False일 때 빈 응답은 ""로 반환 (legacy 보존).

    enable_fallback=True일 때는 기존대로 Tier 2 진행.
    """

    def test_call_text_empty_response_legacy_fallback_disabled(self, mock_router):
        from app.modules.llm.llm_client import call_text

        mock_router.completion.side_effect = [_empty_response()]

        result = call_text(
            step="text_cleanup",
            system_prompt="sys",
            user_prompt="u",
            enable_fallback=False,
        )

        # legacy: 빈 응답을 그대로 "" 반환 — RuntimeError raise 안 함
        assert result == ""
        assert mock_router.completion.call_count == 1

    def test_call_text_empty_response_fallback_enabled_triggers_tier2(self, mock_router):
        from app.modules.llm.llm_client import call_text

        mock_router.completion.side_effect = [
            _empty_response(),  # Tier 1
            _make_response("recovered"),  # Tier 2 sanitize 통과
        ]

        result = call_text(
            step="text_cleanup",
            system_prompt="sys",
            user_prompt="피 묻은",
        )

        assert result == "recovered"
        assert mock_router.completion.call_count == 2
        # Tier 2에서 sanitize 적용 확인
        second = mock_router.completion.call_args_list[1]
        assert "붉은 물감이 묻은" in second.kwargs["messages"][1]["content"]

    def test_call_text_all_tiers_empty_raises(self, mock_router):
        """모든 tier가 빈 응답이면 마지막에 명시적으로 RuntimeError raise."""
        from app.modules.llm.llm_client import call_text

        mock_router.completion.side_effect = [
            _empty_response(),
            _empty_response(),
            _empty_response(),
        ]

        with pytest.raises(RuntimeError, match="empty text response"):
            call_text(
                step="text_cleanup",
                system_prompt="sys",
                user_prompt="u",
            )
        assert mock_router.completion.call_count == 3


class TestF1_2_SafetyFilterClassification:
    """F1-2: Tier 1 예외가 transient면 즉시 raise (Tier 2/3 skip)."""

    def test_tier1_rate_limit_no_fallback(self, mock_router):
        """RateLimitError → 즉시 raise, Tier 2/3 호출 안 함."""
        from app.modules.llm.llm_client import call_structured

        RateLimitError = _make_named_exc("RateLimitError")
        mock_router.completion.side_effect = [
            RateLimitError("Rate limit exceeded"),
            _ok_json({"ok": True}),  # Tier 2 — 도달하면 안 됨
        ]

        with pytest.raises(RateLimitError):
            call_structured(
                step="scene_detail",
                system_prompt="sys",
                user_prompt="u",
                response_schema=_SCHEMA,
            )
        assert mock_router.completion.call_count == 1

    def test_tier1_timeout_no_fallback(self, mock_router):
        """Timeout → 즉시 raise."""
        from app.modules.llm.llm_client import call_structured

        Timeout = _make_named_exc("Timeout")
        mock_router.completion.side_effect = [
            Timeout("Request timeout"),
            _ok_json({"ok": True}),
        ]

        with pytest.raises(Timeout):
            call_structured(
                step="scene_detail",
                system_prompt="sys",
                user_prompt="u",
                response_schema=_SCHEMA,
            )
        assert mock_router.completion.call_count == 1

    def test_tier1_connection_error_no_fallback(self, mock_router):
        from app.modules.llm.llm_client import call_text

        ConnectionError_ = _make_named_exc("ConnectionError")
        mock_router.completion.side_effect = [
            ConnectionError_("connection reset"),
            _make_response("recovered"),
        ]

        with pytest.raises(ConnectionError_):
            call_text(step="text_cleanup", system_prompt="s", user_prompt="u")
        assert mock_router.completion.call_count == 1

    def test_tier1_api_connection_error_no_fallback_multiturn(self, mock_router):
        from app.modules.llm.llm_client import call_multiturn

        APIConnectionError = _make_named_exc("APIConnectionError")
        mock_router.completion.side_effect = [
            APIConnectionError("network failed"),
            _ok_json({"ok": True}),
        ]

        with pytest.raises(APIConnectionError):
            call_multiturn(
                step="entity_extract",
                messages=[
                    {"role": "system", "content": "sys"},
                    {"role": "user", "content": "u"},
                ],
                response_schema=_SCHEMA,
            )
        assert mock_router.completion.call_count == 1

    def test_tier1_prohibited_triggers_tier2(self, mock_router):
        """PROHIBITED_CONTENT 메시지 → Tier 2 진행 (safety 분류)."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            Exception("BLOCKED: PROHIBITED_CONTENT"),
            _ok_json({"ok": True}),
        ]

        result = call_structured(
            step="scene_detail",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
        )

        assert result == {"ok": True}
        assert mock_router.completion.call_count == 2

    def test_tier1_content_filter_triggers_tier2(self, mock_router):
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            Exception("finish_reason=content_filter"),
            _ok_json({"ok": True}),
        ]
        call_structured(
            step="scene_detail",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
        )
        assert mock_router.completion.call_count == 2

    def test_tier1_empty_response_safety_classified(self, mock_router):
        """RuntimeError 'empty response'는 safety 분류 → Tier 2 진행."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            _empty_response(),  # 내부에서 'empty response' RuntimeError로 변환
            _ok_json({"ok": True}),
        ]
        result = call_structured(
            step="scene_detail",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
        )
        assert result == {"ok": True}
        assert mock_router.completion.call_count == 2


class TestIsSafetyRelatedError:
    """is_safety_related_error helper 단위."""

    def test_known_safety_signals(self):
        from app.modules.llm.safety import is_safety_related_error

        assert is_safety_related_error(Exception("PROHIBITED_CONTENT"))
        assert is_safety_related_error(Exception("content_filter triggered"))
        assert is_safety_related_error(Exception("blocked_reason=SAFETY"))
        assert is_safety_related_error(Exception("HARM_CATEGORY_HARASSMENT"))
        assert is_safety_related_error(RuntimeError("empty response from gemini"))
        assert is_safety_related_error(RuntimeError("OpenAI moderation flagged"))

    def test_excludes_transient_class_names(self):
        from app.modules.llm.safety import is_safety_related_error

        for cls_name in (
            "Timeout", "TimeoutError", "ReadTimeout", "ConnectTimeout",
            "RateLimitError", "ServiceUnavailableError", "InternalServerError",
            "ConnectionError", "ConnectionResetError",
            "APIConnectionError", "APIError",
            "BadRequestError", "AuthenticationError",
        ):
            DummyExc = type(cls_name, (Exception,), {})
            assert not is_safety_related_error(DummyExc("anything"))

    def test_empty_semantic_response_error_is_safety(self):
        from app.modules.llm.safety import (
            EmptySemanticResponseError,
            is_safety_related_error,
        )

        exc = EmptySemanticResponseError("validate_response failed for step=foo")
        assert is_safety_related_error(exc)

    def test_unknown_exception_defaults_to_safety(self):
        """보수적 default = True. transient list 외 모든 unknown은 fallback 시도."""
        from app.modules.llm.safety import is_safety_related_error

        class _UnknownError(Exception):
            pass

        assert is_safety_related_error(_UnknownError("mystery"))
        assert is_safety_related_error(ValueError("schema mismatch"))


class TestP2_3_ValidateResponseCallback:
    """P2-3: validate_response가 False 반환 시 Tier 2/3 강제 진행."""

    def test_validate_response_passes_through_when_not_provided(self, mock_router):
        """validate_response=None — 기존 동작 (1차 결과 그대로 반환)."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.return_value = _ok_json({"locations": []})

        result = call_structured(
            step="location_consistency",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
        )
        # validator 없으면 빈 list여도 그대로 반환
        assert result == {"locations": []}
        assert mock_router.completion.call_count == 1

    def test_validate_response_false_triggers_tier2(self, mock_router):
        """Tier 1 응답 valid JSON이지만 validator False → Tier 2 진행."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            _ok_json({"locations": []}),  # Tier 1 — semantic empty
            _ok_json({"locations": [{"id": "L01", "desc": "OK"}]}),  # Tier 2 — 정상
        ]

        result = call_structured(
            step="location_consistency",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
            validate_response=lambda r: bool(r.get("locations")),
        )

        assert result == {"locations": [{"id": "L01", "desc": "OK"}]}
        assert mock_router.completion.call_count == 2

    def test_validate_response_false_in_tier1_and_tier2_triggers_tier3(self, mock_router):
        """Tier 1/2 둘 다 semantic empty → Tier 3 GPT fallback."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            _ok_json({"locations": []}),  # Tier 1 — empty
            _ok_json({"locations": []}),  # Tier 2 — empty
            _ok_json({"locations": [{"id": "L01"}]}),  # Tier 3 GPT
        ]

        result = call_structured(
            step="location_consistency",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
            validate_response=lambda r: bool(r.get("locations")),
        )

        assert result == {"locations": [{"id": "L01"}]}
        assert mock_router.completion.call_count == 3
        third = mock_router.completion.call_args_list[2]
        assert third.kwargs["model"] == "gpt"

    def test_validate_response_not_applied_to_tier3(self, mock_router):
        """Tier 3은 validate 적용 안 함 — 마지막 결과 보존."""
        from app.modules.llm.llm_client import call_structured

        mock_router.completion.side_effect = [
            _ok_json({"locations": []}),
            _ok_json({"locations": []}),
            _ok_json({"locations": []}),  # Tier 3도 empty지만 validator 미적용
        ]

        result = call_structured(
            step="location_consistency",
            system_prompt="sys",
            user_prompt="u",
            response_schema=_SCHEMA,
            validate_response=lambda r: bool(r.get("locations")),
        )

        # Tier 3 결과 그대로 (validator 적용 안 됨)
        assert result == {"locations": []}
        assert mock_router.completion.call_count == 3

    def test_empty_semantic_response_error_message(self):
        """EmptySemanticResponseError 메시지에 step + tier 포함."""
        from app.modules.llm.safety import EmptySemanticResponseError

        exc = EmptySemanticResponseError(
            "validate_response failed for step=foo tier=tier1 — semantic empty result"
        )
        assert "validate_response" in str(exc)
        assert isinstance(exc, RuntimeError)
