"""§2-4.7 — 아웃룩 phase2 자체 모순 수리.

`phase2.md` 가 서로 반대되는 두 규칙을 동시에 주고 있었다:

    :18  「해당 캐릭터에게만 그 아웃룩을 배정할 것」
    :23  「카탈로그에 해당 인물의 아웃룩이 없으면, 가장 유사한
          **다른 인물의 아웃룩**을 배정하세요」

★grounding 밖의 독립 결함이지만 **canary 선행조건**이다 —
고증한 제복이 **다른 인물에게 배정될 수 있다** (Codex).

그리고 같은 강제가 **코드에도** 있었다 (`outlook_extractor_v2.py` 의 user_prompt
「각 씬의 **모든 인물에** 카탈로그에서 의상을 배정하세요」). 프롬프트만 고치면
호출부가 반대로 시킨다.
"""
import re

from app.modules.prompt_loader import resolve_effective


def _phase2() -> str:
    return resolve_effective("outlook_extractor", "phase2", kind="prompt")["content"]


def _module_src() -> str:
    from pathlib import Path
    import app.modules.pipeline.outlook_extractor_v2 as m
    return Path(m.__file__).read_text(encoding="utf-8")


class TestPromptNoLongerContradictsItself:
    def test_the_borrow_rule_is_gone(self):
        text = _phase2()
        assert "다른 인물의 아웃룩을 배정" not in text, \
            "★남의 옷을 빌리라는 규칙이 남아 있다"
        assert "가장 유사한 다른 인물" not in text

    def test_the_owner_rule_survives(self):
        """★빌려오기를 지우면서 소유 규칙까지 지우면 안 된다."""
        assert "해당 캐릭터에게만" in _phase2()

    def test_it_says_what_to_do_instead(self):
        """★금지만 남기면 모델이 무엇을 할지 모른다 — 「비워 둔다」를 적어야 한다."""
        text = _phase2()
        assert "배정하지 마세요" in text or "배정 없이" in text
        assert "빌려 오지 마세요" in text

    def test_the_forced_assignment_is_gone(self):
        """「반드시 1개 이상」이 남으면 모델이 빌려올 수밖에 없다."""
        assert "반드시 아웃룩을 1개 이상 배정" not in _phase2()

    def test_unassigned_is_framed_as_a_fact_not_a_failure(self):
        assert "결함이 아니라" in _phase2()


class TestCallerDoesNotForceAssignment:
    """★프롬프트만 고치면 호출부가 반대로 시킨다."""

    def test_user_prompt_no_longer_demands_every_character(self):
        assert "모든 인물에 카탈로그에서 의상을 배정" not in _module_src()

    def test_user_prompt_tells_it_to_leave_empty(self):
        src = _module_src()
        assert "배정하지 말고 비워" in src
        assert "다른 인물의 옷을 빌려 오면 안" in src


class TestBothPlacesAgree:
    """★두 자리가 서로 반대로 말하면 어느 쪽이 이길지 알 수 없다."""

    def test_neither_place_asks_to_borrow(self):
        for name, text in (("프롬프트", _phase2()), ("호출부", _module_src())):
            assert not re.search(r"유사한 다른 인물|모든 인물에 (카탈로그에서 )?의상", text), \
                f"{name} 에 빌려오기/강제 배정이 남아 있다"


class TestOwnerMismatchIsRejected:
    """★프롬프트만 고치고 게이트를 그대로 두면 **모델이 어겨도 아무도 모른다.**

    기존 게이트는 `outlook_id` 가 카탈로그에 있는지만 봤다 — C02 에게 C01 소유
    O01 을 준 답도 통과한다. 그것이 정확히 이 판에서 막으려던 빌려오기다.
    """

    def _call(self, rows):
        from unittest.mock import patch

        import app.modules.pipeline.outlook_extractor_v2 as m

        segments = [{"scene_index": 1, "heading": "S#1",
                     "start_char": 0, "end_char": 10}]
        outlooks = [{"short_id": "O01", "character_id": "C01", "name": "제복"},
                    {"short_id": "O02", "character_id": "C02", "name": "작업복"}]
        chars = [{"short_id": "C01", "name": "가"}, {"short_id": "C02", "name": "나"}]
        seen = {"calls": 0}

        def _fake(**kwargs):
            seen["calls"] += 1
            payload = {"scene_assignments": rows}
            v = kwargs.get("validate_response")
            seen["valid"] = v(payload) if v else None
            return payload

        with patch.object(m, "call_structured", _fake):
            try:
                m.extract_outlooks_phase2(
                    segments=segments, outlooks=outlooks, characters=chars,
                    scene_character_map={1: ["C01", "C02"]}, fulltext="가나다")
                raised = None
            except Exception as exc:                       # noqa: BLE001
                raised = exc
        return seen, raised

    def _row(self, pairs):
        # ★파리티가 요구하는 실제 키 형식(SEG-001)을 쓴다 — 안 그러면
        #  owner 검사에 닿기 전에 key_parity 가 먼저 걸린다.
        return [{"segment_key": "SEG-001", "scene_index": 1,
                 "assignments": [{"character_id": c, "outlook_id": o}
                                 for c, o in pairs]}]

    def test_owner_match_passes_validate(self):
        seen, raised = self._call(self._row([("C01", "O01"), ("C02", "O02")]))
        assert seen["valid"] is True and raised is None

    def test_borrowing_someone_elses_outlook_fails_validate(self):
        """★C02 가 C01 소유 O01 을 입은 답."""
        seen, _raised = self._call(self._row([("C02", "O01")]))
        assert seen["valid"] is False, "남의 옷을 입혔는데 통과했다"

    def test_borrowing_also_fails_the_tier3_gate(self):
        """★`validate_response` 는 Tier 3 에 안 걸린다 — 끝에서 다시 봐야 한다."""
        _seen, raised = self._call(self._row([("C02", "O01")]))
        assert raised is not None
        assert getattr(raised, "code", "") == "outlook_phase2.owner_mismatch"

    def test_empty_assignments_are_allowed(self):
        """★새 계약 — 소유 아웃룩이 없으면 배정하지 않는 것이 정상이다."""
        seen, raised = self._call([{"segment_key": "SEG-001", "scene_index": 1,
                                    "assignments": []}])
        assert seen["valid"] is True and raised is None


class TestNoPaidRetryForUnownedCharacters:
    """★새 계약이 만든 **돈 새는 자리**를 `_execute` 끝점에서 잰다.

    「소유 아웃룩이 없으면 미배정」이 정상인데, retry 가 raw `scene_char_map` 을
    기대집합으로 쓰면 그 정상 상태를 **누락으로 읽고 최대 3회 유료로 다시 부른다.**

    ★소스 문자열 검사로는 부족하다 — **죽은 `_chars_with_outlook` 만 있어도 통과**한다
    (Codex). 실제로 몇 번 불렀는지를 센다.
    """

    def _run_execute(self):
        from unittest.mock import patch

        from app.core.steps.outlook_steps import OutlookPhase2Step

        calls = {"n": 0}

        def _fake_extract(**kwargs):
            calls["n"] += 1
            # ★C01 만 배정. C02 는 소유 아웃룩이 없어 **미배정이 정상**이다.
            return {"scene_assignments": [
                {"segment_key": "SEG-001", "scene_index": 1,
                 "assignments": [{"character_id": "C01", "outlook_id": "O01"}]},
            ]}

        step = OutlookPhase2Step.__new__(OutlookPhase2Step)
        step.project_config = {"project_id": "p"}
        step._load_segments = lambda: [
            {"scene_index": 1, "heading": "S#1", "start_char": 0, "end_char": 10}]
        step._load_characters_and_scene_map = lambda: (
            [{"short_id": "C01", "name": "가"}, {"short_id": "C02", "name": "나"}],
            {1: ["C01", "C02"]},
            None,
        )
        step.build_opik_metadata = lambda: {}
        step._load_prev_checkpoint = lambda sid: (
            {"data": {"outlooks": [
                {"short_id": "O01", "character_id": "C01", "name": "제복"}],
                "null_outlook_chars": []}}
            if sid == "outlook_phase1" else None
        )

        with patch("app.modules.pipeline.outlook_extractor_v2."
                   "extract_outlooks_phase2", _fake_extract):
            step._execute()
        return calls["n"]

    def test_execute_calls_phase2_exactly_once(self):
        """★C02 는 소유 아웃룩이 없다 — 미배정이 정상이므로 재호출이 없어야 한다."""
        assert self._run_execute() == 1, "정상 미배정을 누락으로 읽고 유료로 다시 불렀다"

    def test_raw_scene_char_map_is_no_longer_the_expectation(self):
        import inspect

        from app.core.steps.outlook_steps import OutlookPhase2Step
        src = inspect.getsource(OutlookPhase2Step)
        assert "expected = set(scene_char_map.get(si, []))" not in src, \
            "★raw 인물 목록을 그대로 기대집합으로 쓴다 — 유료 재호출이 난다"


class TestAnOwnerlessOutlookIsRefused:
    """★★★**주인 없는 옷은 아무에게도 안 준다** (Codex).

    게이트가 `if owner and cid` 로 걸러서, `character_id` 가 빈 아웃룩은 검사를
    통째로 건너뛰고 **아무에게나** 배정됐다 — 주인이 없으면 「남의 것인지」를
    물을 수가 없다. 그것도 빌려오기다.

    ★저장 `outlook_phase1` manifest **58개**에 주인 없는 행이 **0건**이라
    기존 데이터가 이 구멍에 기대는 근거도 없다.
    """

    def _call(self, rows):
        """★기존 헬퍼와 **같은 방식**으로 태우되 카탈로그에 주인 없는 옷을
        하나 더 둔다."""
        from unittest.mock import patch

        import app.modules.pipeline.outlook_extractor_v2 as m

        outlooks = [{"short_id": "O01", "character_id": "C01", "name": "제복"},
                    {"short_id": "O02", "character_id": "C02", "name": "작업복"},
                    {"short_id": "O07", "character_id": "", "name": "주인 없는 옷"}]
        seen = {}

        def _fake(**kwargs):
            payload = {"scene_assignments": rows}
            v = kwargs.get("validate_response")
            seen["valid"] = v(payload) if v else None
            return payload

        with patch.object(m, "call_structured", _fake):
            try:
                m.extract_outlooks_phase2(
                    segments=[{"scene_index": 1, "heading": "S#1",
                               "start_char": 0, "end_char": 10}],
                    outlooks=outlooks,
                    characters=[{"short_id": "C01"}, {"short_id": "C02"}],
                    scene_character_map={1: ["C01", "C02"]}, fulltext="가나다")
                raised = None
            except Exception as exc:                       # noqa: BLE001
                raised = exc
        return seen, raised

    def _row(self, pairs):
        return [{"segment_key": "SEG-001", "scene_index": 1,
                 "assignments": [{"character_id": c, "outlook_id": o}
                                 for c, o in pairs]}]

    def test_assigning_an_ownerless_outlook_is_refused(self):
        seen, raised = self._call(self._row([("C02", "O07")]))
        assert raised is not None, "주인 없는 옷이 그냥 배정됐다"
        assert getattr(raised, "code", "") == "outlook_phase2.owner_mismatch"
        assert "소유자 없음" in str(raised)

    def test_the_retry_guard_refuses_it_too(self):
        """★재시도 검사도 봐야 한다 — 사후 게이트만 있으면 값을 낭비한다."""
        seen, _raised = self._call(self._row([("C02", "O07")]))
        assert seen["valid"] is False

    def test_leaving_it_unassigned_is_normal(self):
        """★카탈로그에 있는 것 자체는 문제가 아니다 — 막는 것은 **그 id 를
        누구에게든 주는 것**이다."""
        seen, raised = self._call(self._row([("C01", "O01")]))
        assert raised is None and seen["valid"] is True
