"""아웃룩 추출 v2 — 3단계: 목록 추출 -> 씬별 매핑 -> 의상 정리."""
import logging
from typing import Dict, List, Optional

from app.modules.llm.llm_client import call_structured
from app.modules.pipeline import segment_key as segkey
from app.modules.prompt_loader import load_prompt, load_schema

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


def extract_outlooks_phase1(
    segments: List[Dict],
    characters: List[Dict] = None,
    scene_character_map: Optional[Dict[int, List[str]]] = None,
    visual_rules: str = "",
    fulltext: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict:
    """1단계: 전체 씬에서 아웃룩 목록 추출 (이름 + 설명)."""
    system = load_prompt(_MODULE, "phase1")
    schema = load_schema(_MODULE, "phase1_schema")

    # Build character block with appearance counts
    appearance_counts: Dict[str, int] = {}
    if scene_character_map:
        for _si, char_ids in scene_character_map.items():
            for cid in char_ids:
                appearance_counts[cid] = appearance_counts.get(cid, 0) + 1

    char_lines = []
    for c in characters:
        csid = c.get("short_id", c["name"])
        count = appearance_counts.get(csid, 0)
        if count > 0:
            char_lines.append(f"- {csid}: {c['name']} (등장 {count}씬)")
        else:
            char_lines.append(f"- {csid}: {c['name']}")
    char_block = "\n".join(char_lines)

    scene_block = _build_scene_block(segments, fulltext)

    rules_block = f"세계관:\n{visual_rules}\n\n" if visual_rules else ""
    user_prompt = f"{rules_block}캐릭터:\n{char_block}\n\n{scene_block}"

    return call_structured(
        step="outlook_extraction",
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name="outlook_phase1",
        opik_metadata=opik_metadata,
    )


def extract_outlooks_phase2(
    segments: List[Dict],
    outlooks: List[Dict] = None,
    characters: List[Dict] = None,
    scene_character_map: Optional[Dict[int, List[str]]] = None,
    fulltext: str = "",
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict:
    """2단계: 의상 카탈로그(ID) + 씬별 인물 → 씬별 매핑. enum 기반."""
    import copy as _copy
    system = load_prompt(_MODULE, "phase2")
    schema = _copy.deepcopy(load_schema(_MODULE, "phase2_schema"))

    # 의상 카탈로그 (O01, O02, ...)
    catalog_lines = []
    outlook_ids = []
    for ol in outlooks:
        osid = ol.get("short_id", "")
        outlook_ids.append(osid)
        cid = ol.get("character_id", "")
        cid_tag = f" ({cid})" if cid else ""
        catalog_lines.append(f"- {osid}: {ol['name']}{cid_tag} — {ol.get('description', '')}")

    # schema에 enum 주입 — ★`required` 에도 넣는다.
    #
    # 2026-08-07 실측: enum 만 주입하고 required 에 안 넣었더니, 필수 필드는
    # 자유 텍스트 `outlook_name` 뿐이라 **모델이 선택 필드인 outlook_id 를
    # 채울지 말지가 실행마다 갈렸다**. 같은 코드·같은 스키마로 한 실행은
    # 309/309 를 채웠고 다른 실행은 13/309 만 채웠다. 후자에서 phase3 의
    # 미배정 검출(outlook_id 로만 센다)이 118건을 미배정으로 오인해 아웃룩
    # 8개를 삭제했고, 하류 scene_detail 이 전량 거부됐다.
    #
    # 어느 아웃룩인지 **판단은 LLM 이** 한다. 스키마는 그 답을 카탈로그 id 라는
    # 한 가지 형식으로 받을 뿐이다 — 코드가 이름을 보고 대신 맞추지 않는다.
    if outlook_ids:
        _item = (schema["properties"]["scene_assignments"]["items"]
                 ["properties"]["assignments"]["items"])
        _item["properties"]["outlook_id"] = {
            "type": "string",
            "enum": outlook_ids,
            "description": "카탈로그에서 고른 의상의 id — 반드시 채운다",
        }
        _req = _item.get("required")
        if isinstance(_req, list) and "outlook_id" not in _req:
            _item["required"] = _req + ["outlook_id"]

    # 씬별 인물 목록
    scene_chars_block = ""
    if scene_character_map:
        lines = []
        for si in sorted(scene_character_map.keys()):
            chars = scene_character_map[si]
            if chars:
                lines.append(f"씬 {si}: {', '.join(chars)}")
        scene_chars_block = (
            "씬별 인물 (모든 인물에 의상 배정 필수):\n"
            + "\n".join(lines) + "\n\n"
        )

    # 씬 키를 불투명 토큰으로 잠근다 — 본문의 번호 헤딩과 충돌하지 않게.
    # 2026-08-07 실측: 이 잠금이 없어 한 실행이 씬 인덱스를 대본 번호(1~116,
    # 114행)로 되돌려줬고 아웃룩이 한 칸 밀려 배정됐다. scene_director 와 같은
    # 결함이라 같은 헬퍼로 잠근다.
    seg_keys = segkey.segment_keys(len(segments))
    key_to_index = segkey.key_index_map(seg_keys, segments)
    segkey.pin_key_field(schema["properties"]["scene_assignments"]["items"], seg_keys)

    # 회신 의무는 **인물이 배정될 씬**에만 있다. 블록은 씬 전체를 보내되
    # (앞뒤 맥락이 배정 판단의 재료다), 인물 0명 씬은 배정할 대상이 없어
    # 행이 없는 것이 정상이라 '누락' 판정에서 뺀다 — 회신은 허용한다.
    # 인물 맵이 아예 비어 오면 좁힐 근거가 없으니 기존대로 전 키를 요구한다.
    _char_map = scene_character_map or {}
    required_keys = (
        [k for k in seg_keys if _char_map.get(key_to_index[k])]
        if _char_map else list(seg_keys)
    )
    # 좁히기가 '전부 빗나가' 빈 목록이 되면 파리티가 아무것도 안 지킨다 —
    # 빈 회신도, 전 키에 빈 배정을 채운 회신도 통과한다. 그 상태는 인물 맵의
    # 씬 번호 공간이 세그먼트 번호 공간과 어긋난 것뿐이고(서수 대체 경로),
    # 그때 받아 낸 배정은 근거가 없다. 부르기 전에 멈춘다.
    # ★조건이 좁은 것이 중요하다: 값이 전부 빈 맵(배정할 인물이 아예 없음)
    # 이나, 번호가 조금이라도 겹치는 경우(08-07 형태의 한 칸 밀림)에는 안
    # 터진다 — 지금 도는 실행을 새로 깨뜨리지 않는다.
    if any(_char_map.values()) and not required_keys:
        from app.core.errors import AppError

        raise AppError(
            code="outlook_phase2.scene_key_space_mismatch",
            message=(
                f"인물 맵의 씬 번호 {sorted(map(str, _char_map))[:5]} 가 "
                f"세그먼트 번호 {sorted(set(key_to_index.values()))[:5]} 와 "
                f"하나도 겹치지 않는다 — 배정 기준이 없어 중단한다"),
            status_code=502,
        )

    scene_block = segkey.build_blocks(seg_keys, segments, fulltext)
    user_prompt = (
        f"의상 카탈로그:\n"
        f"{chr(10).join(catalog_lines)}\n\n"
        f"{scene_chars_block}"
        f"{scene_block}\n\n"
        f"각 씬의 모든 인물에 카탈로그에서 의상을 배정하세요."
    )

    allowed = set(outlook_ids)

    def _valid(payload: Dict) -> bool:
        """씬 키 파리티 + 배정마다 카탈로그 id — 어느 하나라도 깨지면 재시도."""
        rows = (payload or {}).get("scene_assignments") or []
        if not segkey.parity_ok(rows, required_keys, step="outlook_phase2",
                                allowed=seg_keys):
            return False
        if not allowed:
            return True
        for row in rows:
            for a in row.get("assignments") or []:
                if (a.get("outlook_id") or "") not in allowed:
                    return False
        return True

    result = call_structured(
        step="outlook_extraction",
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name="outlook_phase2",
        opik_metadata=opik_metadata,
        validate_response=_valid,
    )

    # 게이트 — validate_response 는 Tier 3 에 적용되지 않으므로 여기서 한 번 더.
    # 조용히 통과시키면 아웃룩이 한 칸 밀려 배정되고(씬 키), phase3 가 미배정으로
    # 오인해 아웃룩을 지운다(outlook_id). 둘 다 실측된 경로다.
    segkey.assert_parity(
        (result or {}).get("scene_assignments") or [], required_keys,
        step="outlook_phase2", allowed=seg_keys)
    if allowed:
        bad = [
            f"씬{row.get('scene_index')}:{a.get('character_id')}"
            f"→{a.get('outlook_id') or a.get('outlook_name') or '(없음)'}"
            for row in (result or {}).get("scene_assignments") or []
            for a in row.get("assignments") or []
            if (a.get("outlook_id") or "") not in allowed
        ]
        if bad:
            from app.core.errors import AppError

            raise AppError(
                code="outlook_phase2.assignment_id_missing",
                message=(
                    f"아웃룩 배정 {len(bad)}건이 카탈로그 id 를 갖지 않는다 "
                    f"(outlook_id enum 밖이거나 비었다) — 예: {bad[:5]}"
                ),
                status_code=502,
            )
    # 키를 벗기고 세그먼트 인덱스를 달아 준다 — 하류 계약은 그대로.
    result["scene_assignments"] = segkey.map_back(
        (result or {}).get("scene_assignments") or [], key_to_index)
    return result


def extract_outlooks_phase3(
    outlooks: List[Dict],
    scene_assignments: List[Dict],
    scene_summaries: Dict[int, str],
    character_routes: Dict[str, List[int]],  # C01 -> [1,3,5,7]
    project_config: Optional[Dict] = None,
    opik_metadata: Optional[Dict] = None,
) -> Dict:
    """3단계: 아웃룩 병합 판별 (LLM) + 코드에서 정리.

    LLM은 removed(병합 대상)만 반환. 코드에서:
    1. 병합 적용 (merge_into로 배정 교체)
    2. 배정 0건 아웃룩 제거
    3. 인물 유일 아웃룩 보호
    """
    import copy as _copy
    system = load_prompt(_MODULE, "phase3")
    schema = _copy.deepcopy(load_schema(_MODULE, "phase3_schema"))

    # enum 주입 — removed에서 사용 가능한 ID
    outlook_ids = [o.get("short_id", "") for o in outlooks if o.get("short_id")]
    if outlook_ids:
        schema["properties"]["removed"]["items"]["properties"]["outlook_id"] = {
            "type": "string", "enum": outlook_ids,
        }
        schema["properties"]["removed"]["items"]["properties"]["merge_into"] = {
            "type": "string", "enum": outlook_ids,
        }

    outlook_block = "\n".join(
        f"- {o.get('short_id','?')} ({o.get('character_id','?')}): {o.get('name', '')} — {o.get('description', '')}"
        for o in outlooks
    )

    user_prompt = (
        f"아웃룩 카탈로그 ({len(outlooks)}개):\n{outlook_block}\n\n"
        "내용이 매우 동일한 아웃룩이 있으면 병합 대상을 알려주세요."
    )

    llm_result = call_structured(
        step="outlook_extraction",
        system_prompt=system,
        user_prompt=user_prompt,
        response_schema=schema,
        project_config=project_config,
        schema_name="outlook_phase3",
        opik_metadata=opik_metadata,
    )

    # ── 코드에서 정리 ──
    removed_list = llm_result.get("removed", [])
    merge_map = {}  # old_id → new_id
    remove_ids = set()
    for r in removed_list:
        old_id = r.get("outlook_id", "")
        new_id = r.get("merge_into", "")
        if not (old_id and new_id and old_id != new_id):
            continue
        # 인물 유일 아웃룩 보호: 제거 후 남는 아웃룩이 0개면 스킵
        cid_for_old = next((o.get("character_id", "") for o in outlooks if o.get("short_id") == old_id), "")
        surviving = sum(1 for o in outlooks
                        if o.get("character_id") == cid_for_old
                        and o.get("short_id") not in remove_ids
                        and o.get("short_id") != old_id)
        if surviving == 0:
            logger.warning("Phase3: skipping merge %s→%s, would leave char %s with 0 outlooks",
                           old_id, new_id, cid_for_old)
            continue
        merge_map[old_id] = new_id
        remove_ids.add(old_id)

    # 체인 병합 해소: O03→O02→O01 → O03→O01, O02→O01
    def _resolve_chain(mm, oid):
        seen = set()
        while oid in mm and oid not in seen:
            seen.add(oid)
            oid = mm[oid]
        return oid
    merge_map = {k: _resolve_chain(merge_map, v) for k, v in merge_map.items()}

    # 1) 배정에서 merge 적용
    for sa in scene_assignments:
        for a in sa.get("assignments", []):
            oid = a.get("outlook_id", "")
            if oid in merge_map:
                a["outlook_id"] = merge_map[oid]

    # 2) 배정 0건 아웃룩 검출
    #
    # ★여기는 `outlook_id` 만 읽는다 — 그게 맞다. 배정이 어느 아웃룩을 가리키는지
    # **판단은 phase2 의 LLM 이** 하고, 그 답은 outlook_id enum 으로 잠겨 있다.
    # 코드가 이름을 보고 대신 맞춰 주면 그 판단을 코드가 뺏는 것이고, 잠금이
    # 풀려 있어도 드러나지 않는다. 2026-08-07 실측: enum 이 없던 시절 한 실행이
    # 308건 중 13건만 id 를 채웠고, 여기서 미배정으로 오인돼 **118건이 참조 중인
    # 아웃룩 8개가 삭제**됐다(하류 scene_detail 전량 거부). 잠금은 phase2 스키마에
    # 있고, 여기 도달했는데 id 가 비어 있으면 그건 상류 결함이므로 아래에서
    # 조용히 넘기지 않고 드러낸다.
    assigned_oids = set()
    missing_ref = 0
    for sa in scene_assignments:
        for a in sa.get("assignments", []):
            oid = a.get("outlook_id", "")
            if not oid:
                missing_ref += 1
            assigned_oids.add(oid)
    if missing_ref:
        logger.error(
            "Phase3: 배정 %d건에 outlook_id 가 없다 — phase2 의 enum 잠금이 "
            "풀렸거나 상류 데이터가 낡았다. 이대로면 미배정 오인 삭제가 난다.",
            missing_ref,
        )

    # 3) 인물별 아웃룩 수 계산 (보호용)
    char_outlook_count = {}
    for ol in outlooks:
        cid = ol.get("character_id", "")
        oid = ol.get("short_id", "")
        if oid not in remove_ids:
            char_outlook_count[cid] = char_outlook_count.get(cid, 0) + 1

    # 4) 최종 정리 — 배정 0건 + merge 대상 제거 (인물 유일 아웃룩 보호)
    cleaned_outlooks = []
    final_removed = []
    for ol in outlooks:
        oid = ol.get("short_id", "")
        cid = ol.get("character_id", "")
        if oid in remove_ids:
            final_removed.append({"outlook_id": oid, "merge_into": merge_map[oid]})
            continue
        if oid not in assigned_oids and char_outlook_count.get(cid, 0) > 1:
            final_removed.append({"outlook_id": oid, "reason": "unassigned"})
            char_outlook_count[cid] -= 1
            continue
        cleaned_outlooks.append(ol)

    logger.info("Phase3 code cleanup: %d→%d outlooks (removed %d, merged %d)",
                len(outlooks), len(cleaned_outlooks), len(final_removed), len(merge_map))

    return {
        "cleaned_outlooks": cleaned_outlooks,
        "cleaned_assignments": scene_assignments,
        "removed": final_removed,
    }


def _build_scene_block(segments: List[Dict], fulltext: str = "") -> str:
    lines = []
    for seg in segments:
        si = seg.get("scene_index", 0)
        heading = seg.get("heading", "")
        text = seg.get("text") or fulltext[seg.get("start_char", 0):seg.get("end_char", 0)]
        lines.append(f"## 씬 {si}: {heading}\n{text}")
    return "\n\n".join(lines)
