"""저빈도 요소 필터링 — shot_count 기반 + LLM 제거 판단."""
import logging
from typing import Dict, List, Optional
from app.modules.llm.llm_client import call_structured
from app.modules.prompt_loader import load_prompt, load_schema

logger = logging.getLogger(__name__)
_MODULE = "entity_filter"


def _appearance_count(entity: Dict) -> int:
    """등장 횟수 — shot_count 우선, 그 다음 scene_count, 마지막이 scene_appearances.

    ★키가 **있으면 0 도 authoritative** 다. 예전에는 ``or`` 사슬이라
    ``shot_count == 0`` 이 falsy 라서 조용히 다음 fallback 으로 넘어갔다.
    GROUNDING-V2 의 A0 후보는 **정의상 샷 구조에 없어서 shot_count 가 0** 이므로
    이 구분이 필요하다 (설계: docs/design/2026-08-29-grounding-v2-plan.md §1.6).
    """
    shot_count = entity.get("shot_count")
    if shot_count is not None:
        return int(shot_count)
    scene_count = entity.get("scene_count")
    if scene_count is not None:
        return int(scene_count)
    return len(set(entity.get("scene_appearances") or []))


def filter_low_frequency_entities(
    entities: Dict[str, List[Dict]],
    segments: List[Dict],
    fulltext: str,
    max_scenes: int = 3,
    protected_short_ids: Optional[set] = None,
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict:
    """저빈도 등장 요소를 LLM에게 필터링 요청.

    shot_count (리스팅 단계에서 LLM이 산출한 등장 횟수) 기반.
    """
    # Collect low-frequency entities (변형 관계가 있는 요소는 보호)
    _protected = protected_short_ids or set()
    low_freq = []
    for etype in ["characters", "locations", "props"]:
        for e in entities.get(etype, []):
            sid = e.get("short_id", "")
            if sid and sid in _protected:
                continue  # 변형 관계 보호 — 필터 대상에서 제외
            count = _appearance_count(e)
            if count < max_scenes:
                low_freq.append({
                    "name": e["name"],
                    "entity_type": etype[:-1] if etype != "props" else "prop",
                    "description": e.get("description", ""),
                    "count": count,
                    "short_id": e.get("short_id", ""),
                })

    if not low_freq:
        logger.info("No low-frequency entities to filter")
        # ★키 모양을 아래 갈래와 **같게** 낸다. 한쪽만 `removed_entities` 를
        #  빼면 소비자가 「없다」와 「이 판에는 그 칸이 없다」를 못 가른다.
        return {"decisions": [], "filtered_entities": entities,
                "removed_entities": [], "removed_count": 0,
                "kept_count": sum(len(v) for v in entities.values())}

    system = load_prompt(_MODULE, "system")
    schema = load_schema(_MODULE, "filter_schema")

    entity_block = "\n".join(
        f"- [{e['entity_type']}] {e['short_id']} {e['name']} ({e['count']}회): {e['description']}"
        for e in low_freq
    )

    user_prompt = (
        f"아래 요소들은 등장 횟수가 적습니다. 시각적으로 중요한지 판단하세요.\n\n"
        f"{entity_block}"
    )

    result = call_structured(
        step="entity_filter",
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name="entity_filter",
        opik_metadata=opik_metadata,
    )

    # ★★★제거는 **`short_id` 로** 정한다 (2026-09-04).
    #
    #  종전에는 LLM 이 돌려준 `name` 에서 정규식으로 접두를 떼고 이름으로
    #  맞췄다. 이름이 조금만 달라져도 어긋나고, 뜻을 글자로 판단하는 자리다.
    #  판 4 팩부터 `short_id` 를 함께 받는다.
    #
    #  ★허용 목록은 **이 호출에 실어 보낸 저빈도 대상**뿐이다. 모델이 그 밖의
    #   ID 를 내면 안 지운다 — 지우는 쪽으로 틀리면 되돌릴 수 없다.
    _sent = {e["short_id"] for e in low_freq if e.get("short_id")}
    remove_sids: set = set()
    no_sid = 0
    for d in result.get("decisions", []):
        if d.get("decision") != "remove":
            continue
        sid = str(d.get("short_id") or "").strip()
        if not sid:
            # ★이름으로 짐작하지 않는다. 옛 팩(판 3 이하)의 산출이면 여기 온다.
            no_sid += 1
            continue
        if sid not in _sent:
            logger.warning(
                "entity_filter: 보내지 않은 대상 %s 를 지우라고 했다 — 무시", sid)
            continue
        remove_sids.add(sid)
    if no_sid:
        logger.warning(
            "entity_filter: `short_id` 없는 제거 판단 %d개 — 이름으로 짐작하지 "
            "않고 **살린다**. 옛 팩 산출이면 entity_filter 를 다시 돌려야 한다.",
            no_sid)

    filtered: Dict[str, List[Dict]] = {}
    # ★★제거분을 **행 통째로** 남긴다. 종전에는 `decisions` 에 이름과 사유
    #  한 줄만 남고 설명·특징·등장 정보가 사라졌다. 그러면 뒤 화에서 같은
    #  것이 다시 나와도 앞 화의 그것과 이을 근거가 없다 — 사용자가 지적한
    #  바로 그 상황이다(실측: 1화 공구상자·렌치·지팡이·담요 4개).
    removed_entities: List[Dict] = []
    _reason = {str(d.get("short_id") or ""): d.get("reason", "")
               for d in result.get("decisions", [])}
    for etype in ["characters", "locations", "props"]:
        owner = "prop" if etype == "props" else etype[:-1]
        keep, drop = [], []
        for e in entities.get(etype, []):
            sid = str(e.get("short_id") or "")
            if sid and sid in remove_sids:
                row = dict(e)
                # ★갈래를 **여기서 찍는다.** 소비자가 목록 밖에서 이 행을 볼 때
                #  어느 갈래인지 짐작하게 두면 한 갈래가 조용히 빠진다.
                row["entity_type"] = owner
                row["shelved_reason"] = _reason.get(sid, "")
                drop.append(row)
            else:
                keep.append(e)
        filtered[etype] = keep
        removed_entities.extend(drop)

    removed_count = len(removed_entities)
    logger.info("Filtered %d low-freq entities → shelved %d",
                len(low_freq), removed_count)

    return {
        "decisions": result.get("decisions", []),
        "filtered_entities": filtered,
        # ★★행 통째로. 이 칸이 다음 화의 이어 붙이기 재료다.
        "removed_entities": removed_entities,
        "removed_count": removed_count,
        "kept_count": sum(len(v) for v in filtered.values()),
    }
