"""아웃룩 배정은 카탈로그 id 를 **필수로** 달고 나와야 한다.

## 왜 (2026-08-07 실측)

`extract_outlooks_phase2` 는 `outlook_id` 에 enum 을 주입하면서 `required` 에는
넣지 않았다. 디스크 스키마의 필수 필드는 자유 텍스트 `outlook_name` 뿐이라,
**enum 이 걸린 필드가 선택**이 되어 모델이 채울지 말지가 실행마다 갈렸다.

* 한 실행: `outlook_id` 309/309 채움 → 아무 문제 없음
* 다른 실행: 13/309 만 채움 → `extract_outlooks_phase3` 의 미배정 검출이
  (`outlook_id` 로만 센다) 118건을 미배정으로 오인 → 아웃룩 8개 삭제 →
  하류 scene_detail 이 `id_and_outlook_required` 로 전량 거부

어느 의상인지 **판단은 LLM 이** 한다. 스키마는 그 답을 한 가지 형식으로 받을
뿐이고, 코드가 이름을 보고 대신 맞추지 않는다.
"""

import copy

import pytest

DISK_SCHEMA = {
    "type": "object",
    "properties": {
        "scene_assignments": {
            "type": "array",
            "items": {
                "type": "object",
                "properties": {
                    "scene_index": {"type": "integer"},
                    "assignments": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "properties": {
                                "character_id": {"type": "string"},
                                "outlook_name": {"type": "string"},
                            },
                            "required": ["character_id", "outlook_name"],
                            "additionalProperties": False,
                        },
                    },
                },
                "required": ["scene_index", "assignments"],
                "additionalProperties": False,
            },
        }
    },
    "required": ["scene_assignments"],
    "additionalProperties": False,
}

OUTLOOKS = [
    {"short_id": "O01", "name": "작업복", "character_id": "C01", "description": ""},
    {"short_id": "O02", "name": "외출복", "character_id": "C01", "description": ""},
]
SEGMENTS = [{"scene_index": 1, "heading": "1. 실내. 방 - 낮", "text": "인물이 앉아 있다."}]


def _row(**kw):
    # 씬 키는 불투명 토큰이다(segment_key) — 대본 본문 번호와 겹치지 않게.
    return {"scene_assignments": [
        {"segment_key": "SEG-001", "assignments": [{"character_id": "C01", **kw}]}]}


@pytest.fixture
def phase2(monkeypatch):
    from app.modules.pipeline import outlook_extractor_v2 as mod

    cap = {}

    def fake_call(**kwargs):
        cap.update(kwargs)
        payload = cap["_payload"]
        v = kwargs.get("validate_response")
        cap["_predicate_ok"] = v(payload) if v else None
        return payload

    monkeypatch.setattr(mod, "load_prompt", lambda *a, **k: "시스템")
    monkeypatch.setattr(mod, "load_schema", lambda *a, **k: copy.deepcopy(DISK_SCHEMA))
    monkeypatch.setattr(mod, "call_structured", fake_call)

    def run(payload):
        cap["_payload"] = payload
        return mod.extract_outlooks_phase2(SEGMENTS, outlooks=OUTLOOKS)

    return run, cap


class TestSchemaPinsTheReference:

    def test_outlook_id_is_required_not_optional(self, phase2):
        """enum 만 걸고 required 에 안 넣으면 모델이 건너뛴다 — 실측된 결함."""
        run, cap = phase2
        run(_row(outlook_name="작업복", outlook_id="O01"))

        item = (cap["response_schema"]["properties"]["scene_assignments"]["items"]
                ["properties"]["assignments"]["items"])
        assert item["properties"]["outlook_id"]["enum"] == ["O01", "O02"]
        assert "outlook_id" in item["required"]

    def test_no_enum_no_gate_when_catalog_empty(self, monkeypatch):
        """카탈로그가 비면 잠글 값이 없다 — enum 도 게이트도 걸지 않는다."""
        from app.modules.pipeline import outlook_extractor_v2 as mod

        cap = {}

        def fake_call(**kwargs):
            cap.update(kwargs)
            # 씬 키 파리티는 카탈로그와 무관하게 항상 지켜야 한다.
            return {"scene_assignments": [{"segment_key": "SEG-001", "assignments": []}]}

        monkeypatch.setattr(mod, "load_prompt", lambda *a, **k: "시스템")
        monkeypatch.setattr(mod, "load_schema", lambda *a, **k: copy.deepcopy(DISK_SCHEMA))
        monkeypatch.setattr(mod, "call_structured", fake_call)

        mod.extract_outlooks_phase2(SEGMENTS, outlooks=[])
        item = (cap["response_schema"]["properties"]["scene_assignments"]["items"]
                ["properties"]["assignments"]["items"])
        assert "outlook_id" not in item["properties"]


class TestGate:

    def test_missing_outlook_id_is_rejected(self, phase2):
        run, _ = phase2
        with pytest.raises(Exception) as exc:
            run(_row(outlook_name="작업복"))
        assert "outlook_id" in str(exc.value) or "카탈로그 id" in str(exc.value)

    def test_id_outside_catalog_is_rejected(self, phase2):
        run, _ = phase2
        with pytest.raises(Exception):
            run(_row(outlook_name="작업복", outlook_id="O99"))

    def test_valid_assignment_passes(self, phase2):
        run, cap = phase2
        out = run(_row(outlook_name="작업복", outlook_id="O01"))
        assert out["scene_assignments"][0]["assignments"][0]["outlook_id"] == "O01"
        assert cap["_predicate_ok"] is True

    def test_predicate_rejects_so_lower_tiers_retry(self, phase2):
        run, cap = phase2
        with pytest.raises(Exception):
            run(_row(outlook_name="작업복"))
        assert cap["_predicate_ok"] is False


class TestPhase3PruningContract:
    """phase3 는 id 로만 센다 — 그 전제가 지켜져야 삭제가 안전하다."""

    def test_assignments_with_ids_are_not_pruned(self, monkeypatch):
        from app.modules.pipeline import outlook_extractor_v2 as mod

        monkeypatch.setattr(mod, "load_prompt", lambda *a, **k: "시스템")
        monkeypatch.setattr(mod, "load_schema", lambda *a, **k: {
            "type": "object",
            "properties": {"removed": {"type": "array", "items": {
                "type": "object", "properties": {
                    "outlook_id": {"type": "string"},
                    "merge_into": {"type": "string"}}}}}})
        monkeypatch.setattr(mod, "call_structured", lambda **k: {"removed": []})

        assignments = [{"scene_index": 1, "assignments": [
            {"character_id": "C01", "outlook_name": "작업복", "outlook_id": "O01"}]}]
        out = mod.extract_outlooks_phase3(
            copy.deepcopy(OUTLOOKS), assignments, {}, {})
        kept = {o["short_id"] for o in out["cleaned_outlooks"]}
        assert "O01" in kept, "id 를 단 배정이 미배정으로 오인돼 삭제됐다"
        # 아무도 안 쓰는 O02 는 그대로 정리되어야 한다 — 기능을 죽이면 안 된다.
        assert "O02" in {r["outlook_id"] for r in out["removed"]}
