"""요소 추출 v2 — Gemini 멀티턴 4단계 구조.

Turn 1: 중요 요소 제목만 간략 추출
Turn 2: 인물 세부 + 시각적 변형
Turn 3: 배경 세부 + 상태 변형
Turn 4: 중요 물체
"""

import json
import logging
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

from app.modules.llm.gemini_text_client import GeminiTextClient

PROMPT_DIR = (
    Path(__file__).resolve().parent.parent.parent.parent.parent
    / "prompts" / "_base" / "entity_extractor_v2"
)

def _load_system_prompt() -> str:
    """최신 버전의 시스템 프롬프트 로드."""
    versions = sorted([d.name for d in PROMPT_DIR.iterdir() if d.is_dir()], reverse=True)
    if not versions:
        raise FileNotFoundError(f"No prompt versions in {PROMPT_DIR}")
    return (PROMPT_DIR / versions[0] / "system.md").read_text(encoding="utf-8").strip()

logger = logging.getLogger(__name__)

# ── JSON 스키마 ──

_ENTITY_BRIEF_ITEM = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "appearances": {"type": "integer", "description": "시나리오 내 대략적 출현 횟수"},
    },
    "required": ["name", "appearances"],
    "additionalProperties": False,
}

TURN1_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "characters": {
            "type": "array",
            "items": _ENTITY_BRIEF_ITEM,
            "description": "중요 인물 (이름 + 출현 횟수)",
        },
        "locations": {
            "type": "array",
            "items": _ENTITY_BRIEF_ITEM,
            "description": "중요 배경/장소 (이름 + 출현 횟수)",
        },
        "props": {
            "type": "array",
            "items": _ENTITY_BRIEF_ITEM,
            "description": "중요 물체 (이름 + 출현 횟수)",
        },
    },
    "required": ["characters", "locations", "props"],
    "additionalProperties": False,
}

# 개별 요소 상세 + 타당성 판단 스키마
ENTITY_DETAIL_SCHEMA: Dict[str, Any] = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "entity_type": {"type": "string", "description": "character|location|prop"},
        "description": {"type": "string", "description": "시각적 외형 설명 (보통명사 기반)"},
        "visual_traits": {
            "type": "array",
            "items": {"type": "string"},
            "description": "핵심 시각적 특징",
        },
        "t2i_prompt": {
            "type": "string",
            "description": "이미지 생성용 T2I 프롬프트 (영어, 콘티 스타일)",
        },
    },
    "required": ["name", "entity_type", "description", "visual_traits", "t2i_prompt"],
}

# ── 시스템 지시 ──

def _get_system_instruction() -> str:
    return _load_system_prompt()


def _gpt_review_entity_list(
    turn1_result: Dict[str, Any],
    fulltext: str,
    _api_key: str | None = None,
) -> Dict[str, Any]:
    """GPT 5.4로 Turn 1 결과를 확률 기반 평가.

    [2026-08-01] 키를 Authorization 안에 인라인으로 박아 슬롯 전환이 닿지
    않았다. 이 모듈은 모듈 밖 참조가 0건인 legacy 지만, 남겨 두면 다음 사람이
    이 모양을 복제하므로 같은 계약으로 맞춘다.

    Returns:
        {
            "characters": [...], "locations": [...], "props": [...],  # 필터된 리스트
            "review": [{"name", "entity_type", "importance", "validity", "suggestion"}, ...]  # 전체 평가
        }
    """
    if _api_key is None:
        # 슬롯은 브로커가 정하고, 키 수준 실패면 다음 슬롯으로 다시 부른다.
        from app.core.openai_keys import call_with_key_failover

        return call_with_key_failover(
            lambda _k: _gpt_review_entity_list(
                turn1_result, fulltext, _api_key=_k),
            where="entity_extractor_legacy.review")

    from app.modules.pipeline.ref_image_pipeline import _load_lvm_prompt

    review_schema_path = PROMPT_DIR / sorted(
        [d.name for d in PROMPT_DIR.iterdir() if d.is_dir()], reverse=True
    )[0] / "turn1_review_schema.json"
    review_schema = json.loads(review_schema_path.read_text(encoding="utf-8"))

    # Turn 1 결과에서 이름+출현횟수 문자열 생성
    def _fmt(items):
        return ", ".join(f"{e['name']}({e['appearances']}회)" for e in items)

    prompt = _load_lvm_prompt(
        "entity_list_review",
        screenplay_summary=fulltext,
        characters=_fmt(turn1_result.get("characters", [])),
        locations=_fmt(turn1_result.get("locations", [])),
        props=_fmt(turn1_result.get("props", [])),
    )

    try:
        import urllib.request, urllib.error
        from app.core.config import settings

        body = {
            "model": settings.openai_model,
            "input": [
                {"type": "message", "role": "user", "content": [
                    {"type": "input_text", "text": prompt},
                ]},
            ],
            "text": {
                "format": {
                    "type": "json_schema",
                    "name": "entity_review",
                    "strict": True,
                    "schema": review_schema,
                }
            },
            "temperature": 0.2,
            "store": False,
        }

        req = urllib.request.Request(
            "https://api.openai.com/v1/responses",
            data=json.dumps(body).encode("utf-8"),
            headers={
                "Authorization": f"Bearer {_api_key}",
                "Content-Type": "application/json",
            },
            method="POST",
        )

        _t0 = time.time()
        with urllib.request.urlopen(req, timeout=settings.llm_timeout_text) as resp:
            payload = json.loads(resp.read().decode("utf-8"))
        _duration_ms = int((time.time() - _t0) * 1000)

        output_text = payload.get("output_text", "")
        if not output_text:
            for item in payload.get("output", []):
                if isinstance(item, dict):
                    for part in item.get("content", []):
                        if isinstance(part, dict) and part.get("type") == "output_text":
                            output_text = part.get("text", "")
                            break

        # Log the GPT call
        from app.modules.llm.llm_logger import log_llm_call
        log_llm_call(
            model_name=settings.openai_model,
            system_prompt=None,
            user_prompt=prompt,
            output_text=output_text,
            duration_ms=_duration_ms,
            status="success",
            operation_type="entity_extraction",
            step_name="turn1.5_gpt_review",
        )

        review_result = json.loads(output_text)
        entities_review = review_result.get("entities", [])

        # 매핑 생성
        importance_map = {e["name"]: e["importance"] for e in entities_review}
        gpt_appearances_map = {e["name"]: e.get("appearances", 0) for e in entities_review}

        # GPT 리뷰 결과로 low-importance 요소 필터링
        # importance="none" AND appearances < 2 → 제거
        low_importance = [
            e["name"] for e in entities_review
            if e.get("importance") == "none" and int(e.get("appearances", 0)) < 2
        ]
        if low_importance:
            logger.info("GPT review: filtering %d low-importance entities: %s", len(low_importance), low_importance)

        chars = [e["name"] for e in turn1_result.get("characters", []) if e["name"] not in low_importance]
        locs = [e["name"] for e in turn1_result.get("locations", []) if e["name"] not in low_importance]
        props_list = [e["name"] for e in turn1_result.get("props", []) if e["name"] not in low_importance]

        # 리뷰 결과 로깅 (importance 분포)
        for e in entities_review:
            logger.info("GPT review entity: %s — importance=%s, appearances=%d",
                        e["name"], e.get("importance", "?"), e.get("appearances", 0))

        return {
            "characters": chars,
            "locations": locs,
            "props": props_list,
            "review": entities_review,
            "importance_map": importance_map,
            "gpt_appearances_map": gpt_appearances_map,
        }

    except Exception as exc:
        # ★[2026-08-01] 방어적 fallback 이 키 수준 실패를 삼키면 전환이 원천
        # 봉쇄된다 — 바깥 wrapper 가 볼 예외 자체가 없어진다. urllib 은
        # provider 를 안 실으므로 표시부터 붙여 브로커가 판정하게 한다.
        import urllib.error as _urlerr

        from app.core.openai_keys import (
            is_key_level_failure,
            mark_openai_failure,
        )

        if isinstance(exc, _urlerr.HTTPError):
            # 코드는 응답 **본문**에 있다 — 읽어서 넘기지 않으면 판정기가
            # 상태·문구만 보고 지나친다.
            try:
                _body = exc.read().decode("utf-8", errors="replace")
            except Exception:  # noqa: BLE001
                _body = ""
            mark_openai_failure(
                exc, status=getattr(exc, "code", None), body=_body)
        if is_key_level_failure(exc):
            raise
        logger.warning("GPT review failed, using original list: %s", exc)
        chars = [e["name"] for e in turn1_result.get("characters", [])]
        locs = [e["name"] for e in turn1_result.get("locations", [])]
        props_list = [e["name"] for e in turn1_result.get("props", [])]
        return {"characters": chars, "locations": locs, "props": props_list, "review": [], "importance_map": {}, "gpt_appearances_map": {}}

def _load_turn_prompt(turn_name: str, **kwargs) -> str:
    """턴 프롬프트 파일 로드 + 변수 치환."""
    versions = sorted([d.name for d in PROMPT_DIR.iterdir() if d.is_dir()], reverse=True)
    if not versions:
        raise FileNotFoundError(f"No prompt versions in {PROMPT_DIR}")
    text = (PROMPT_DIR / versions[0] / f"{turn_name}.md").read_text(encoding="utf-8").strip()
    return text.format(**kwargs) if kwargs else text


def extract_entities_multiturn(
    gemini_client: GeminiTextClient,
    fulltext: str,
    prior_entities: Optional[List[Dict[str, Any]]] = None,
    checkpoint_dir: Optional[str] = None,
) -> Dict[str, Any]:
    """4턴 멀티턴으로 요소를 단계별 추출. 체크포인트로 중단 시 이어서.

    Args:
        gemini_client: Gemini 멀티턴 클라이언트 (이력 유지)
        fulltext: 시나리오 전문
        prior_entities: 이전 에피소드의 기존 요소 목록 (있으면 재사용)

    Returns:
        {
            "characters": [{"name", "description", "visual_traits", "variants": [...]}],
            "locations": [{"name", "description", "variants": [...]}],
            "props": [{"name", "description"}],
        }
    """
    gemini_client.reset_history()

    # 체크포인트 경로
    import hashlib, os
    cp_path = None
    cp_data = None
    if checkpoint_dir:
        os.makedirs(checkpoint_dir, exist_ok=True)
        ep_hash = hashlib.md5(fulltext.encode("utf-8")).hexdigest()[:8]
        cp_path = os.path.join(checkpoint_dir, f"entity_extraction_{ep_hash}.json")
        if os.path.exists(cp_path):
            try:
                cp_data = json.loads(open(cp_path, encoding="utf-8").read())
                logger.info("Entity checkpoint loaded: completed_turn=%s", cp_data.get("completed_turn"))
            except Exception:
                cp_data = None

    def _save_cp(turn_name: str, data: dict):
        if not cp_path:
            return
        data["completed_turn"] = turn_name
        open(cp_path, "w", encoding="utf-8").write(json.dumps(data, ensure_ascii=False, indent=2))

    def _delete_cp():
        if cp_path and os.path.exists(cp_path):
            os.unlink(cp_path)
            logger.info("Entity checkpoint deleted")

    # 이전 에피소드 요소가 있으면 컨텍스트로 추가
    prior_block = ""
    if prior_entities:
        prior_block = (
            "\n\n[이전 에피소드에서 추출된 기존 요소]\n"
            + json.dumps(prior_entities, ensure_ascii=False, indent=1)
            + "\n기존 요소가 이번 에피소드에도 등장하면 이름을 동일하게 유지하세요.\n"
        )

    # ── Turn 0: 세계관 + 시각 스타일 확정 ──
    completed_turn = cp_data.get("completed_turn") if cp_data else None

    if completed_turn and completed_turn >= "turn0":
        style_result = cp_data["style_result"]
        logger.info("Turn 0: restored from checkpoint")
    else:
        logger.info("Entity extraction Turn 0: style analysis")
        turn0_schema_path = PROMPT_DIR / sorted(
            [d.name for d in PROMPT_DIR.iterdir() if d.is_dir()], reverse=True
        )[0] / "turn0_style_schema.json"
        turn0_schema = json.loads(turn0_schema_path.read_text(encoding="utf-8"))

        turn0_msg = _load_turn_prompt("turn0_style", prior_block=prior_block, fulltext=fulltext)
        style_result = gemini_client.send_structured(
            user_message=turn0_msg,
            response_schema=turn0_schema,
            system_instruction=_get_system_instruction(),
        )
        _save_cp("turn0", {"style_result": style_result})
    logger.info("Turn 0 style: era=%s, region=%s, genre=%s",
                style_result.get("era"), style_result.get("region"), style_result.get("genre"))

    # ── Turn 1: 요소 제목만 간략 추출 ──
    if completed_turn and completed_turn >= "turn1":
        turn1_result = cp_data["turn1_result"]
        logger.info("Turn 1: restored from checkpoint")
    else:
        logger.info("Entity extraction Turn 1: brief entity names")
        turn1_msg = _load_turn_prompt("turn1", prior_block=prior_block, fulltext=fulltext)
        turn1_result = gemini_client.send_structured(
            user_message=turn1_msg,
            response_schema=TURN1_SCHEMA,
            system_instruction=_get_system_instruction(),
        )
        _save_cp("turn1", {"style_result": style_result, "turn1_result": turn1_result})
    logger.info("Turn 1 result: %d characters, %d locations, %d props",
                len(turn1_result.get("characters", [])),
                len(turn1_result.get("locations", [])),
                len(turn1_result.get("props", [])))

    # ── Turn 1.5: GPT 5.4 검증 — 리스트가 적절한지 확인 ──
    if completed_turn and completed_turn >= "turn1.5":
        turn1_result = cp_data["turn1_result"]
        logger.info("Turn 1.5: restored from checkpoint")
    else:
        logger.info("Entity extraction Turn 1.5: GPT 5.4 review")
        turn1_result = _gpt_review_entity_list(turn1_result, fulltext)
        _save_cp("turn1.5", {"style_result": style_result, "turn1_result": turn1_result})

    # ── Turn 1.7: GPT 5.4로 전체 시나리오 기반 요소 상세 일괄 추출 ──
    entity_queue: List[Tuple[str, str]] = []
    for name in turn1_result.get("characters", []):
        entity_queue.append((name, "character"))
    for name in turn1_result.get("locations", []):
        entity_queue.append((name, "location"))
    for name in turn1_result.get("props", []):
        entity_queue.append((name, "prop"))

    if completed_turn and completed_turn >= "turn1.7" and cp_data.get("gpt_entity_details"):
        gpt_entity_details = cp_data["gpt_entity_details"]
        logger.info("Turn 1.7: restored from checkpoint (%d entities)", len(gpt_entity_details))
    else:
        gpt_entity_details: Dict[str, Dict[str, Any]] = {}
        try:
            entity_list_text = "\n".join(f"- {name} ({etype})" for name, etype in entity_queue)
            turn17_prompt = _load_turn_prompt(
                "turn1_7_detail_batch",
                entity_list=entity_list_text,
                fulltext=fulltext,
            )
            turn17_schema_path = PROMPT_DIR / sorted(
                [d.name for d in PROMPT_DIR.iterdir() if d.is_dir()], reverse=True
            )[0] / "turn1_7_detail_batch_schema.json"
            turn17_schema = json.loads(turn17_schema_path.read_text(encoding="utf-8"))

            from app.modules.llm.openai_client import OpenAIClient
            gpt = OpenAIClient()
            batch_result = gpt.generate_structured(
                system_prompt="시나리오 분석 전문가. 요소별 시각적 상세 정보(즉, 외모 외형 보이는 부분 중심)를 관련성 있는 부분을 최대한 많이 추출한다.",
                user_prompt=turn17_prompt,
                response_schema=turn17_schema,
                schema_name="entity_detail_batch",
            )
            for ent in batch_result.get("entities", []):
                gpt_entity_details[ent["name"]] = {
                    "description": ent.get("description", ""),
                    "visual_traits": ent.get("visual_traits", []),
                }
            logger.info("Turn 1.7: GPT returned details for %d/%d entities", len(gpt_entity_details), len(entity_queue))

            # 누락된 요소 확인 → 재시도
            missing = [(name, etype) for name, etype in entity_queue if name not in gpt_entity_details]
            if missing:
                logger.warning("Turn 1.7: %d entities missing, retrying", len(missing))
                missing_list_text = "\n".join(f"- {name} ({etype})" for name, etype in missing)
                retry_prompt = _load_turn_prompt(
                    "turn1_7_detail_batch",
                    entity_list=missing_list_text,
                    fulltext=fulltext,
                )
                try:
                    retry_result = gpt.generate_structured(
                        system_prompt="시나리오 분석 전문가. 요소별 시각적 상세 정보(즉, 외모 외형 보이는 부분 중심)를 관련성 있는 부분을 최대한 많이 추출한다.",
                        user_prompt=retry_prompt,
                        response_schema=turn17_schema,
                        schema_name="entity_detail_batch_retry",
                    )
                    for ent in retry_result.get("entities", []):
                        gpt_entity_details[ent["name"]] = {
                            "description": ent.get("description", ""),
                            "visual_traits": ent.get("visual_traits", []),
                        }
                    still_missing = [name for name, _ in missing if name not in gpt_entity_details]
                    if still_missing:
                        logger.warning("Turn 1.7 retry: still missing %d: %s", len(still_missing), still_missing)
                    else:
                        logger.info("Turn 1.7 retry: all missing entities recovered")
                except Exception as retry_exc:
                    logger.warning("Turn 1.7 retry failed: %s", retry_exc)

        except Exception as exc:
            logger.warning("Turn 1.7 GPT batch detail failed: %s — Turn 2+ will proceed without", exc)

    _save_cp("turn1.7", {
        "style_result": style_result,
        "turn1_result": turn1_result,
        "gpt_entity_details": gpt_entity_details,
    })

    # ── Turn 2+: 요소별 T2I 프롬프트 생성 (병렬, GPT 상세 정보 활용) ──
    characters: List[Dict[str, Any]] = []
    locations: List[Dict[str, Any]] = []
    props: List[Dict[str, Any]] = []

    total = len(entity_queue)
    if total == 0:
        logger.info("No entities to extract details for")
    else:
        system_prompt = _get_system_instruction()

        def _extract_single_entity(
            idx: int,
            ename: str,
            etype: str,
        ) -> Tuple[int, str, str, Dict[str, Any]]:
            """단일 요소 T2I 생성 (독립 GeminiTextClient, GPT 상세 정보 포함, 3회 재시도)."""
            logger.info("Entity detail %d/%d: %s (%s)", idx + 1, total, ename, etype)

            # GPT Turn 1.7에서 받은 상세 정보를 프롬프트에 포함
            gpt_detail = gpt_entity_details.get(ename, {})
            extra_context = ""
            if gpt_detail:
                desc = gpt_detail.get("description", "")
                traits = ", ".join(gpt_detail.get("visual_traits", []))
                extra_context = f"\n\n[시나리오 기반 상세 정보]\n설명: {desc}\n시각적 특징: {traits}"

            turn_msg = _load_turn_prompt(
                "turn_entity_detail",
                entity_name=ename,
                entity_type=etype,
            ) + extra_context

            max_entity_retries = 3
            last_exc: Optional[Exception] = None

            for attempt in range(1, max_entity_retries + 1):
                try:
                    # 독립 클라이언트 — 멀티턴 이력 불필요
                    client = GeminiTextClient()
                    detail = client.send_structured(
                        user_message=turn_msg,
                        response_schema=ENTITY_DETAIL_SCHEMA,
                        system_instruction=system_prompt,
                    )
                    entity_data = {
                        "name": detail.get("name", ename),
                        "description": detail.get("description", ""),
                        "visual_traits": detail.get("visual_traits", []),
                        "t2i_prompt": detail.get("t2i_prompt", ""),
                    }
                    return (idx, ename, etype, entity_data)
                except Exception as exc:
                    last_exc = exc
                    logger.warning(
                        "Entity detail attempt %d/%d failed for %s: %s",
                        attempt, max_entity_retries, ename, exc,
                    )
                    if attempt < max_entity_retries:
                        time.sleep(2 * attempt)

            # 모든 재시도 실패 — 기본값 반환
            logger.warning("Entity detail failed after %d retries for %s: %s",
                           max_entity_retries, ename, last_exc)
            basic = {"name": ename, "description": "", "visual_traits": [], "t2i_prompt": ""}
            return (idx, ename, etype, basic)

        # 병렬 실행 — 2초 간격 스태거, RPM 보호용 1초 sleep은 worker 내부 retry에 포함
        max_workers = min(10, total)
        logger.info("Launching %d entity detail workers (total=%d)", max_workers, total)
        results: List[Tuple[int, str, str, Dict[str, Any]]] = []

        with ThreadPoolExecutor(max_workers=max_workers) as executor:
            futures = {}
            for i, (ename, etype) in enumerate(entity_queue):
                if i > 0:
                    time.sleep(2)  # 2초 스태거 — RPM 제한 회피
                future = executor.submit(_extract_single_entity, i, ename, etype)
                futures[future] = (i, ename, etype)

            for future in as_completed(futures):
                result = future.result()
                results.append(result)

        # 원래 순서대로 정렬 후 분류
        results.sort(key=lambda r: r[0])
        for _idx, _ename, etype, entity_data in results:
            if etype == "character":
                characters.append(entity_data)
            elif etype == "location":
                locations.append(entity_data)
            else:
                props.append(entity_data)

    logger.info("Entity extraction complete: %d characters, %d locations, %d props",
                len(characters), len(locations), len(props))

    _delete_cp()

    return {
        "characters": characters,
        "locations": locations,
        "props": props,
        "brief": turn1_result,
        "style": style_result,
    }

def _active_openai_key() -> str:
    """활성 키 슬롯 — 1차 필드를 직접 보면 보조 키가 안 쓰인다."""
    from app.core.openai_keys import active_key

    return active_key()
