"""요소 추출 v4 — 3턴 타입별 순차 추출."""
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_extract_v4"


def extract_entities_by_type(
    fulltext: str,
    entity_type: str,
    visual_rules: str = "",
    segments_json: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> List[Dict]:
    """단일 타입 요소 추출. entity_type: character|location|prop"""
    system = load_prompt(_MODULE, "system")
    type_prompt = load_prompt(_MODULE, entity_type)
    schema = load_schema(_MODULE, f"{entity_type}_schema")

    user_prompt = (
        f"시각적 규칙:\n{visual_rules}\n\n"
        f"시나리오 전문:\n{fulltext}\n\n"
        f"{type_prompt}"
    )

    # 타입별 스텝명 — 모델 분리 가능 (character=gpt, location/prop=gemini-pro)
    step_name = f"entity_extract_{entity_type}"

    result = call_structured(
        step=step_name,
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name=f"entity_{entity_type}",
        opik_metadata=opik_metadata,
    )

    # Root key varies: characters / locations / props
    key = f"{entity_type}s" if entity_type != "prop" else "props"
    entities = result.get(key, [])

    return entities


def extract_entities_by_type_with_list(
    fulltext: str,
    entity_type: str,
    name_list: List[Dict],
    visual_rules: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
    a0_candidates: Optional[List[Dict]] = None,
    binding_out: Optional[Dict] = None,
) -> List[Dict]:
    """entity_all 리스트 기반 상세 추출.

    ★``binding_out`` — 결속 이관 결과(`lost`·`contested`·`unmatched`)를 받아
    갈 자리. 호출부가 체크포인트에 남겨야 다음 소비자가 승격을 막는다.

    name_list: [{"name": ...}, ...]
    LLM에게 이 목록의 인물/배경/소품에 대해 설명만 추가하도록 요청.

    ★``a0_candidates`` — 후보를 버리는 자리는 **한 군데가 아니다**(계획 §1.8).
    여기 붙는 ``type_prompt`` 에도 같은 부류의 제외 기준이 있어, 「제거하지
    마세요」 뒤에 「빼라」가 와서 자기모순이 된다. overlay 를 **맨 뒤**에 둔다.

    ★기본값 ``None`` 이면 legacy 와 한 글자도 안 달라진다.
    """
    system = load_prompt(_MODULE, "system")
    type_prompt = load_prompt(_MODULE, entity_type)
    schema = load_schema(_MODULE, f"{entity_type}_schema")

    # ★★**`short_id` 를 함께 싣는다** (3b 계약 4). 상세 행을 앞 단계 행에
    #  잇는 열쇠다. 이름으로 이으면 모델이 이름을 바꾼 순간 결속이 끊기고,
    #  비슷한 이름끼리 잘못 붙는다.
    _sids = [str(e.get("short_id") or "").strip() for e in name_list]
    _missing = [i for i, x in enumerate(_sids) if not x]
    if a0_candidates and _missing:
        # ★★**조용히 옛 경로로 안 내려간다** (Codex BLOCK-3). 여기서 그냥
        #  legacy 로 가면 후보가 있는데도 결속이 통째로 없고, 그것이
        #  「아무 후보도 아니었다」로 읽힌다.
        raise ValueError(
            f"entity_all 산출에 short_id 가 빈 것이 {len(_missing)}개 있다 "
            f"(자리 {_missing[:5]}) — 결속을 이을 열쇠가 없다")
    _bind = bool(a0_candidates)
    if _bind:
        from app.modules.pipeline.grounding_binding import (
            patch_schema_with_short_ids, short_id_instruction)

        schema = patch_schema_with_short_ids(schema, _sids)
        names_text = "\n".join(
            f"- [{e['short_id']}] {e['name']}" for e in name_list)
        # ★지시문은 **팩**에서 온다 — 소스에 박으면 버전도 hash 도 없다.
        names_text += "\n\n" + short_id_instruction().strip()
    else:
        names_text = "\n".join(f"- {e['name']}" for e in name_list)

    overlay = ""
    if a0_candidates:
        from app.modules.pipeline.grounding_overlay import build_overlay_lines

        _lines = build_overlay_lines(a0_candidates, entity_type)
        if _lines:
            overlay = "\n\n" + "\n".join(_lines)

    user_prompt = (
        f"시각적 규칙:\n{visual_rules}\n\n"
        f"시나리오 전문:\n{fulltext}\n\n"
        f"## 이미 확정된 목록 — 아래 목록의 요소에 대해서만 상세 설명을 작성하세요\n"
        f"새로 추가하거나 제거하지 마세요. 이름도 그대로 유지하세요.\n\n"
        f"{names_text}\n\n"
        f"{type_prompt}{overlay}"
    )

    step_name = f"entity_extract_{entity_type}"

    result = call_structured(
        step=step_name,
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name=f"entity_{entity_type}",
        opik_metadata=opik_metadata,
    )

    key = f"{entity_type}s" if entity_type != "prop" else "props"
    entities = result.get(key, [])
    if _bind:
        # ★★후보 ID 는 **코드가** 옮긴다 — 모델에게 다시 고르게 하지 않는다.
        from app.modules.pipeline.grounding_binding import rebind_by_short_id

        _r = rebind_by_short_id(entities, name_list)
        logger.info("entity_extract_%s 결속 이관: %s", entity_type, _r)
        if binding_out is not None:
            # ★★계산해 놓고 버리면 「끊겼다」가 「아무것도 아니다」로 읽힌다.
            binding_out.update(_r)
        if _r["lost"] or _r["contested"]:
            # ★붙어 있던 후보가 여기서 사라졌다. 로그만 찍고 계속 가면
            #  그 대상은 조사 대상인데 아무 엔티티에도 없는 반쪽이 된다.
            logger.warning(
                "entity_extract_%s: 결속이 끊긴 후보 %s · 다툰 short_id %s",
                entity_type, _r["lost"], _r["contested"])
    return entities


def extract_all_entities(
    fulltext: str,
    visual_rules: str = "",
    segments_json: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict[str, List[Dict]]:
    """3턴 순차: 인물 -> 배경 -> 소품."""
    characters = extract_entities_by_type(
        fulltext, "character", visual_rules, segments_json, project_config, opik_metadata,
    )
    locations = extract_entities_by_type(
        fulltext, "location", visual_rules, segments_json, project_config, opik_metadata,
    )
    props = extract_entities_by_type(
        fulltext, "prop", visual_rules, segments_json, project_config, opik_metadata,
    )
    return {"characters": characters, "locations": locations, "props": props}
