"""entity_filter 모듈 단위 테스트 — 저빈도 요소 필터링."""
import json
from unittest.mock import patch, MagicMock

import pytest


# ── Fixtures ──

def _make_entities():
    """테스트용 요소 데이터 생성.

    ★2026-09-04 — 행마다 `short_id` 를 준다. 제거 판단이 **이름이 아니라
     `short_id`** 로 정해지도록 바뀌었기 때문이다(이름 글자로 뜻을 맞추면
     이름이 조금만 달라져도 어긋난다). 실제 파이프라인에서는 `entity_all`
     단계가 이 값을 채워 넘긴다.
    """
    return {
        "characters": [
            {"short_id": "C01", "name": "주인공", "description": "주요 캐릭터", "scene_appearances": [1, 2, 3, 4, 5]},
            {"short_id": "C02", "name": "조연A", "description": "조연 캐릭터", "scene_appearances": [1, 2]},
            {"short_id": "C03", "name": "단역B", "description": "단역", "scene_appearances": [3]},
        ],
        "locations": [
            {"short_id": "L01", "name": "사무실", "description": "메인 배경", "scene_appearances": [1, 2, 3, 4, 5, 6]},
            {"short_id": "L02", "name": "카페", "description": "서브 배경", "scene_appearances": [2, 3]},
            {"short_id": "L03", "name": "골목", "description": "한 번 등장", "scene_appearances": [5]},
        ],
        "props": [
            {"short_id": "P01", "name": "노트북", "description": "주요 소품", "scene_appearances": [1, 2, 3, 4]},
            {"short_id": "P02", "name": "우산", "description": "단역 소품", "scene_appearances": [2]},
        ],
    }


def _make_segments():
    return [
        {"scene_index": 1, "heading": "S#1", "start_char": 0, "end_char": 100},
        {"scene_index": 2, "heading": "S#2", "start_char": 100, "end_char": 200},
        {"scene_index": 3, "heading": "S#3", "start_char": 200, "end_char": 300},
        {"scene_index": 4, "heading": "S#4", "start_char": 300, "end_char": 400},
        {"scene_index": 5, "heading": "S#5", "start_char": 400, "end_char": 500},
        {"scene_index": 6, "heading": "S#6", "start_char": 500, "end_char": 600},
    ]


FULLTEXT = "A" * 600


# ── Tests ──

class TestFilterLowFrequencyEntities:
    """filter_low_frequency_entities 함수 테스트."""

    @patch("app.modules.pipeline.entity_filter.call_structured")
    @patch("app.modules.pipeline.entity_filter.load_schema")
    @patch("app.modules.pipeline.entity_filter.load_prompt")
    def test_collects_only_low_freq_entities(self, mock_prompt, mock_schema, mock_call):
        """3씬 이하 등장 요소만 LLM user_prompt에 포함된다.

        W3-3 Codex Medium: entity_filter는 lifecycle=active step이며 이 테스트가 prompt
        selection 계약을 검증하는 유일한 단위 테스트. skip 해제 + 현재 call_structured
        keyword 시그니처에 맞춰 단순화.
        """
        mock_prompt.return_value = "system prompt"
        mock_schema.return_value = {"type": "object", "properties": {}}
        mock_call.return_value = {"decisions": [
            {"name": "조연A", "entity_type": "character", "decision": "keep", "reason": "중요"},
            {"name": "단역B", "entity_type": "character", "decision": "remove", "reason": "불필요"},
            {"name": "카페", "entity_type": "location", "decision": "keep", "reason": "중요"},
            {"name": "골목", "entity_type": "location", "decision": "remove", "reason": "불필요"},
            {"name": "우산", "entity_type": "prop", "decision": "remove", "reason": "불필요"},
        ]}

        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        entities = _make_entities()
        filter_low_frequency_entities(
            entities=entities,
            segments=_make_segments(),
            fulltext=FULLTEXT,
            max_scenes=3,
        )

        # call_structured(step=..., system_prompt=..., user_prompt=..., ...)
        mock_call.assert_called_once()
        user_prompt = mock_call.call_args.kwargs["user_prompt"]

        # 저빈도 요소 (count < 3): 조연A(2), 단역B(1), 카페(2), 골목(1), 우산(1)
        assert "조연A" in user_prompt
        assert "단역B" in user_prompt
        assert "카페" in user_prompt
        assert "골목" in user_prompt
        assert "우산" in user_prompt
        # 고빈도 요소는 제외: 주인공(5), 사무실(6), 노트북(4)
        assert "주인공" not in user_prompt
        assert "사무실" not in user_prompt
        assert "노트북" not in user_prompt

    @patch("app.modules.pipeline.entity_filter.call_structured")
    @patch("app.modules.pipeline.entity_filter.load_schema")
    @patch("app.modules.pipeline.entity_filter.load_prompt")
    def test_high_freq_entities_not_sent_to_llm(self, mock_prompt, mock_schema, mock_call):
        """4씬 이상 등장 요소는 LLM에 전송하지 않음."""
        mock_prompt.return_value = "system prompt"
        mock_schema.return_value = {"type": "object", "properties": {}}
        mock_call.return_value = {"decisions": [
            {"name": "조연A", "entity_type": "character", "decision": "keep", "reason": "중요"},
            {"name": "단역B", "entity_type": "character", "decision": "keep", "reason": "중요"},
            {"name": "카페", "entity_type": "location", "decision": "keep", "reason": "중요"},
            {"name": "골목", "entity_type": "location", "decision": "keep", "reason": "중요"},
            {"name": "우산", "entity_type": "prop", "decision": "keep", "reason": "중요"},
        ]}

        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        entities = _make_entities()
        result = filter_low_frequency_entities(
            entities=entities,
            segments=_make_segments(),
            fulltext=FULLTEXT,
            max_scenes=3,
        )

        # High-freq entities should remain
        filtered = result["filtered_entities"]
        char_names = [c["name"] for c in filtered["characters"]]
        loc_names = [l["name"] for l in filtered["locations"]]
        prop_names = [p["name"] for p in filtered["props"]]

        assert "주인공" in char_names  # 5 scenes - NOT filtered
        assert "사무실" in loc_names   # 6 scenes - NOT filtered
        assert "노트북" in prop_names  # 4 scenes - NOT filtered

    @patch("app.modules.pipeline.entity_filter.call_structured")
    @patch("app.modules.pipeline.entity_filter.load_schema")
    @patch("app.modules.pipeline.entity_filter.load_prompt")
    def test_remove_decisions_applied(self, mock_prompt, mock_schema, mock_call):
        """LLM의 remove 결정이 반영됨."""
        mock_prompt.return_value = "system prompt"
        mock_schema.return_value = {"type": "object", "properties": {}}
        # ★판 4 팩부터 판단에 `short_id` 가 실린다. 이름으로 맞추던 종전
        #  경로는 이름이 달라지면 어긋났다.
        mock_call.return_value = {"decisions": [
            {"short_id": "C02", "name": "조연A", "entity_type": "character", "decision": "keep", "reason": "중요"},
            {"short_id": "C03", "name": "단역B", "entity_type": "character", "decision": "remove", "reason": "일시적 언급"},
            {"short_id": "L02", "name": "카페", "entity_type": "location", "decision": "keep", "reason": "반복 배경"},
            {"short_id": "L03", "name": "골목", "entity_type": "location", "decision": "remove", "reason": "배경의 일부"},
            {"short_id": "P02", "name": "우산", "entity_type": "prop", "decision": "remove", "reason": "식별 불가"},
        ]}

        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        entities = _make_entities()
        result = filter_low_frequency_entities(
            entities=entities,
            segments=_make_segments(),
            fulltext=FULLTEXT,
            max_scenes=3,
        )

        filtered = result["filtered_entities"]

        # Removed entities should NOT be in filtered
        char_names = [c["name"] for c in filtered["characters"]]
        assert "단역B" not in char_names

        loc_names = [l["name"] for l in filtered["locations"]]
        assert "골목" not in loc_names

        prop_names = [p["name"] for p in filtered["props"]]
        assert "우산" not in prop_names

        # ★★지운 것은 **행 통째로** 남아야 한다 (2026-09-04). 종전에는
        #  `decisions` 에 이름과 사유 한 줄만 남아, 뒤 화에서 같은 것이 다시
        #  나와도 앞 화의 그것과 이을 근거가 없었다.
        shelved = {r["short_id"]: r for r in result["removed_entities"]}
        assert set(shelved) == {"C03", "L03", "P02"}, shelved
        assert shelved["P02"]["description"] == "단역 소품", "설명이 사라졌다"
        assert shelved["P02"]["entity_type"] == "prop", "갈래를 안 찍었다"
        assert shelved["L03"]["shelved_reason"] == "배경의 일부"

        assert result["removed_count"] == 3

    @patch("app.modules.pipeline.entity_filter.call_structured")
    @patch("app.modules.pipeline.entity_filter.load_schema")
    @patch("app.modules.pipeline.entity_filter.load_prompt")
    def test_keep_decisions_preserved(self, mock_prompt, mock_schema, mock_call):
        """LLM의 keep 결정이 유지됨."""
        mock_prompt.return_value = "system prompt"
        mock_schema.return_value = {"type": "object", "properties": {}}
        mock_call.return_value = {"decisions": [
            {"name": "조연A", "entity_type": "character", "decision": "keep", "reason": "핵심 조연"},
            {"name": "단역B", "entity_type": "character", "decision": "keep", "reason": "시각적으로 중요"},
            {"name": "카페", "entity_type": "location", "decision": "keep", "reason": "반복 배경"},
            {"name": "골목", "entity_type": "location", "decision": "keep", "reason": "중요한 장면"},
            {"name": "우산", "entity_type": "prop", "decision": "keep", "reason": "핵심 도구"},
        ]}

        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        entities = _make_entities()
        result = filter_low_frequency_entities(
            entities=entities,
            segments=_make_segments(),
            fulltext=FULLTEXT,
            max_scenes=3,
        )

        filtered = result["filtered_entities"]

        # All entities should be preserved (nothing removed)
        assert len(filtered["characters"]) == 3
        assert len(filtered["locations"]) == 3
        assert len(filtered["props"]) == 2
        assert result["removed_count"] == 0

    def test_empty_low_freq_returns_unchanged(self):
        """저빈도 요소가 없으면 LLM 호출 없이 원본 반환."""
        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        # All entities appear in 4+ scenes
        entities = {
            "characters": [
                {"name": "주인공", "description": "주요", "scene_appearances": [1, 2, 3, 4]},
            ],
            "locations": [
                {"name": "사무실", "description": "메인", "scene_appearances": [1, 2, 3, 4, 5]},
            ],
            "props": [
                {"name": "노트북", "description": "주요", "scene_appearances": [1, 2, 3, 4]},
            ],
        }

        result = filter_low_frequency_entities(
            entities=entities,
            segments=_make_segments(),
            fulltext=FULLTEXT,
            max_scenes=3,
        )

        # No LLM call should be made, entities returned as-is
        assert result["decisions"] == []
        assert result["filtered_entities"] == entities


class TestAppearanceCountAuthoritative:
    """★shot_count 키가 **있으면 0 도 authoritative** — or 사슬이 삼키던 자리.

    설계: docs/design/2026-08-29-grounding-v2-plan.md §1.6.
    GROUNDING-V2 의 A0 후보는 정의상 샷 구조에 없어 shot_count 가 0 이라
    「0 이면 fallback 으로 넘어간다」가 후보를 조용히 고빈도로 만든다.
    """

    def test_zero_shot_count_wins_over_fallbacks(self):
        from app.modules.pipeline.entity_filter import _appearance_count
        e = {"shot_count": 0, "scene_count": 9, "scene_appearances": [1, 2, 3, 4, 5]}
        assert _appearance_count(e) == 0

    def test_none_shot_count_falls_through_to_scene_count(self):
        from app.modules.pipeline.entity_filter import _appearance_count
        assert _appearance_count(
            {"shot_count": None, "scene_count": 4, "scene_appearances": [1]}
        ) == 4

    def test_zero_scene_count_wins_over_appearances(self):
        from app.modules.pipeline.entity_filter import _appearance_count
        assert _appearance_count({"scene_count": 0, "scene_appearances": [1, 2, 3]}) == 0

    def test_missing_both_uses_deduped_appearances(self):
        from app.modules.pipeline.entity_filter import _appearance_count
        assert _appearance_count({"scene_appearances": [2, 2, 5]}) == 2

    def test_missing_everything_is_zero(self):
        from app.modules.pipeline.entity_filter import _appearance_count
        assert _appearance_count({"name": "이름만"}) == 0

    @patch("app.modules.pipeline.entity_filter.call_structured")
    @patch("app.modules.pipeline.entity_filter.load_schema")
    @patch("app.modules.pipeline.entity_filter.load_prompt")
    def test_zero_shot_count_entity_reaches_the_filter_prompt(
        self, mock_prompt, mock_schema, mock_call
    ):
        """★끝점 시험 — 조립부가 아니라 실제로 LLM 에 나가는 user_prompt 로 잰다.

        shot_count=0 인데 scene_appearances 가 5씬이면 예전 or 사찰에서는
        count=5 가 되어 **저빈도 목록에 아예 안 실렸다**.
        """
        mock_prompt.return_value = "system prompt"
        mock_schema.return_value = {"type": "object", "properties": {}}
        mock_call.return_value = {"decisions": []}

        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        entities = {
            "characters": [],
            "locations": [],
            "props": [
                # A0 후보 모양 — 샷 구조에 없어 shot_count 0, 원문에는 여러 번 언급
                {"name": "회수권", "short_id": "P07", "description": "종이 승차권",
                 "shot_count": 0, "scene_appearances": [1, 2, 3, 4, 5]},
                # 대조 — 고빈도라 실리면 안 된다
                {"name": "가방", "short_id": "P08", "description": "가방",
                 "shot_count": 7, "scene_appearances": [1]},
            ],
        }
        filter_low_frequency_entities(
            entities=entities, segments=_make_segments(), fulltext=FULLTEXT, max_scenes=3
        )

        user_prompt = mock_call.call_args.kwargs["user_prompt"]
        assert "회수권" in user_prompt, "shot_count=0 후보가 저빈도 판단 대상에서 빠졌다"
        assert "(0회)" in user_prompt, "0 이 fallback 값으로 바뀌어 나갔다"
        assert "가방" not in user_prompt

    @patch("app.modules.pipeline.entity_filter.call_structured")
    @patch("app.modules.pipeline.entity_filter.load_schema")
    @patch("app.modules.pipeline.entity_filter.load_prompt")
    def test_protected_short_id_still_bypasses(self, mock_prompt, mock_schema, mock_call):
        """보호 통로는 그대로 — GROUNDING-V2 가 research 후보를 여기에 union 한다."""
        mock_prompt.return_value = "system prompt"
        mock_schema.return_value = {"type": "object", "properties": {}}
        mock_call.return_value = {"decisions": []}

        from app.modules.pipeline.entity_filter import filter_low_frequency_entities

        entities = {"characters": [], "locations": [], "props": [
            {"name": "회수권", "short_id": "P07", "description": "종이 승차권", "shot_count": 0},
        ]}
        out = filter_low_frequency_entities(
            entities=entities, segments=_make_segments(), fulltext=FULLTEXT,
            max_scenes=3, protected_short_ids={"P07"},
        )
        mock_call.assert_not_called()
        assert out["filtered_entities"] == entities
