"""DA-12: 1단계 — Kei 실장 (꼭지 추출 + 분석).

Kei API를 통해 Kei persona가 사고하여 꼭지를 추출한다.
Kei API는 필수. fallback 없음. 성공할 때까지 무한 재시도.
"""
from __future__ import annotations

import json
import logging
import re
from typing import Any

import httpx

from src.config import settings
from src.sse_utils import stream_sse_tokens

logger = logging.getLogger(__name__)

KEI_PROMPT = (
    "다음 콘텐츠로 슬라이드 1장을 만들려고 해. 어떤 스토리로 구성할지 설계해줘.\n\n"
    "## 1단계: 핵심 메시지 파악\n"
    "- 이 콘텐츠가 전달하려는 **핵심 메시지**를 한 줄로 파악해줘.\n"
    "- 슬라이드를 본 사람이 기억해야 할 단 하나의 문장.\n"
    "- core_message 필드에 기록.\n\n"
    "## 2단계: 정보 구조 파악\n"
    "- 본문 흐름(flow)과 참조 정보(reference)로 분리되는 구조인가?\n"
    "- 독립적으로 참조되는 정보(용어 정의, 부록)가 있는가?\n"
    "- info_structure 필드에 기술.\n\n"
    "## 3단계: 슬라이드 스토리라인 설계\n"
    "핵심 메시지를 전달하기 위한 **흐름**을 설계해줘.\n"
    "각 꼭지에 purpose를 부여하고, topics 배열에 기록.\n\n"
    "## 4단계: 레이아웃 유형 선택 + 페이지 구조 판단\n"
    "먼저 콘텐츠에 맞는 **레이아웃 유형**을 선택하라:\n\n"
    "### 유형 A: 배경 + 본심 + 첨부(sidebar) + 결론\n"
    "- 참조자료(용어 정의, 부록 등)가 **별도로 존재**하는 콘텐츠\n"
    "- 좌측 body(배경+본심) + 우측 sidebar(첨부) + 하단 결론\n"
    "- page_structure 키: 배경, 본심, 첨부, 결론\n\n"
    "### 유형 B: 본심1(상단) + 본심2(하단 2분할) + 결론\n"
    "- 참조자료 없이 **본문 흐름만**으로 구성되는 콘텐츠\n"
    "- 배경/첨부가 없거나 억지로 만들어야 하면 이 유형 선택\n"
    "- 상단: 핵심 내용 전체폭 (이미지가 있으면 좌텍스트+우이미지 나란히)\n"
    "- 하단: 세부 내용 2분할 (좌/우)\n"
    "- page_structure 키: 자유 (예: 핵심목표, 프로세스변화, 기대효과, 결론)\n"
    "- 결론 키는 반드시 '결론'\n\n"
    "선택한 유형을 **layout_template** 필드에 'A' 또는 'B'로 기록하라.\n\n"
    "### 역할별 규칙 (유형 A)\n"
    "- **본심**: 이 페이지가 말하려는 핵심. 가장 큰 공간.\n"
    "- **배경**: 본심을 이해하기 위한 도입. 간결하게.\n"
    "- **첨부**: 본심을 보조하는 참조 정보. sidebar 배치. role: 'reference'.\n"
    "- **결론**: 핵심 한 줄. footer.\n\n"
    "### 역할별 규칙 (유형 B)\n"
    "- 상단 역할: 핵심 내용. 전체폭. zone: 'top'\n"
    "- 하단 좌측: zone: 'bottom_left'\n"
    "- 하단 우측: zone: 'bottom_right'\n"
    "- 결론: zone: 'footer'\n\n"
    "각 역할에 해당하는 topic_ids와 **공간 비중(weight, 합계 1.0)**을 결정하라.\n"
    "**콘텐츠에 따라 비중은 매번 달라진다. 고정값이 아니다.**\n"
    "page_structure 필드에 기록.\n\n"
    "## 원본 텍스트 보존 원칙 (절대 규칙)\n"
    "- **제목(##, ###)은 원본 그대로 사용하라. 절대 바꾸지 마라.**\n"
    "  원본이 '## 1. DX의 궁극적 목표'이면 꼭지 제목도 'DX의 궁극적 목표'.\n"
    "  임의로 '핵심 목표', '전략 방향' 등으로 바꾸지 마라.\n"
    "- **원본 텍스트(불릿, 설명)는 85% 이상 그대로 사용하라.**\n"
    "  문장을 재작성하지 마라. 원본 문장을 그대로 가져와라.\n"
    "- **결론 텍스트도 원본 그대로.** 임의로 만들지 마라.\n"
    "- 원본에 있는 내용을 임의로 제거하거나 다른 의미로 바꾸지 마라.\n"
    "- 텍스트 재구성이 허용되는 경우는 **빈 공간에 채울 요약(표, 팝업 요약)만**.\n"
    "- 각 꼭지의 source_hint에 원본의 어떤 부분이 가는지 명시.\n\n"
    "## 배치 규칙\n"
    "- 참조 정보(용어 정의 등)는 role: 'reference'로 표시 → 사이드바 배치\n"
    "- 본문 흐름은 role: 'flow' → 메인 영역 배치\n"
    "- 결론은 layer: 'conclusion' → 하단 배치\n"
    "- detail_target: true는 정말로 별도로 봐야 하는 상세 데이터에만 사용\n"
    "- 이미지/표가 있으면 images[], tables[]에 기록\n"
    "- 1페이지 적정 꼭지: 5개. 분량 적으면 1페이지로.\n"
    "- **슬라이드 제목(title)과 첫 번째 꼭지 제목은 달라야 한다.** 슬라이드 제목은 전체 주제, 꼭지 제목은 해당 위치의 구체적 내용.\n\n"
    "## 출력 형식 (JSON만)\n"
    "layout_template에 따라 page_structure가 달라진다.\n\n"
    "유형 A 예시:\n"
    "```json\n"
    '{"title": "제목", '
    '"core_message": "핵심 메시지", '
    '"total_pages": 1, '
    '"layout_template": "A", '
    '"page_structure": {'
    '"본심": {"topic_ids": [2, 3], "weight": 0.60}, '
    '"배경": {"topic_ids": [1], "weight": 0.15}, '
    '"첨부": {"topic_ids": [4], "weight": 0.15}, '
    '"결론": {"topic_ids": [5], "weight": 0.10}}, '
    '"topics": ['
    '{"id": 1, "title": "꼭지 제목", "summary": "요약", '
    '"purpose": "문제제기|근거사례|핵심전달|용어정의|결론강조|구조시각화", '
    '"source_hint": "원본에서 이 위치에 가져올 텍스트 범위 설명", '
    '"layer": "intro|core|supporting|conclusion", '
    '"role": "flow|reference", '
    '"section_title": "sidebar에 표시할 섹션 제목 (reference일 때만. 예: 용어 정의, 참고 자료)", '
    '"emphasis": true, "direction": "vertical|horizontal|flexible", '
    '"content_type": "text|image|table|mixed", '
    '"detail_target": false, "page": 1}], '
    '"images": [{"topic_id": 1, "role": "key|supporting", "has_text": false, "description": "이미지 설명"}], '
    '"tables": [{"topic_id": 2, "rows": 5, "cols": 3, "fits_single_page": true, "description": "표 설명"}]}\n'
    "```\n\n"
    "유형 B 예시:\n"
    "```json\n"
    '{"title": "제목", '
    '"core_message": "핵심 메시지", '
    '"total_pages": 1, '
    '"layout_template": "B", '
    '"page_structure": {'
    '"핵심목표": {"zone": "top", "topic_ids": [1], "weight": 0.35}, '
    '"프로세스변화": {"zone": "bottom_left", "topic_ids": [2], "weight": 0.25}, '
    '"기대효과": {"zone": "bottom_right", "topic_ids": [3], "weight": 0.25}, '
    '"결론": {"zone": "footer", "topic_ids": [4], "weight": 0.15}}, '
    '"topics": [...],'
    '"images": [...]}\n'
    "```\n\n"
    "## 콘텐츠:\n"
)


async def classify_content(content: str) -> dict[str, Any] | None:
    """1단계: Kei API를 통해 꼭지를 추출하고 분석한다.

    Kei API만 사용. fallback 없음. 실패 시 None → pipeline에서 에러.
    """
    result = await _call_kei_api(content)
    if result:
        logger.info(
            f"[Kei API] 꼭지 추출 완료: {result.get('title', '')}, "
            f"{len(result.get('topics', []))}개 꼭지"
        )
        return result

    logger.error("[Kei API] 꼭지 추출 실패. Kei API(localhost:8000) 확인 필요.")
    return None


KEI_PROMPT_B = (
    "아래는 슬라이드 스토리라인 설계 결과(1단계-A)와 원본 콘텐츠이다.\n"
    "각 꼭지의 **컨셉을 구체화**해줘. 1회 응답으로 모든 꼭지를 한꺼번에 처리.\n\n"
    "## 각 꼭지에 대해 판단할 것\n\n"
    "1. **관계 성격 (relation_type)**:\n"
    "   - sequence: 시간/단계 순서 (A→B→C)\n"
    "   - inclusion: 포함/융합 관계 (A가 B,C를 포함하거나 B,C가 합쳐져 A를 이룸)\n"
    "   - comparison: 대등 비교 (A vs B)\n"
    "   - hierarchy: 상위-하위 (A > B > C)\n"
    "   - definition: 용어 정의 (나열)\n"
    "   - cause_effect: 원인-결과\n"
    "   - none: 관계 표현 불필요 (단순 텍스트)\n\n"
    "2. **표현 성격 (expression_hint)** — 관계 성격을 설명. 구체 블록 이름은 쓰지 마라:\n"
    "   - 예: '기술 융합/포함 관계. 순서 아님. 구성요소 간 관계 표현 필요.'\n"
    "   - 예: '수단-목적 관계. 대등 비교가 아님. 역할 차이를 보여줘야 함.'\n"
    "   - 예: '용어 3개의 독립적 정의. 나열.'\n\n"
    "3. **원본 데이터 핵심 항목 (source_data)**:\n"
    "   - 이 꼭지에 해당하는 원본의 핵심 항목들을 나열하라.\n"
    "   - 항목이 여러 개면 '이름(설명), 이름(설명)' 형태로 쉼표 구분.\n"
    "   - 원본에 팝업이 참조되면 반드시 [팝업: 제목] 마커를 포함하라.\n"
    "   - 원본에 이미지가 참조되면 반드시 [이미지: 제목] 마커를 포함하라.\n"
    "   - 출처가 있으면 포함하라.\n"
    "   - '활용 필요', '구체화 필요' 같은 지시사항을 쓰지 마라. 실제 콘텐츠 항목만 쓰라.\n"
    "   - 예시: '건설산업(종합산업, 기술 통합 융합), BIM(정보관리 도구, 출처: 국토교통부 2020)'\n"
    "   - 예시: '[이미지: DX와 핵심기술간 상호관계] 다이어그램, GIS 역할(공간 분석). [팝업: DX와 BIM의 구분] 비교표'\n\n"
    "## 출력 형식 (JSON만)\n"
    "```json\n"
    '{"concepts": ['
    '{"topic_id": 1, '
    '"relation_type": "inclusion|sequence|comparison|hierarchy|definition|cause_effect|none", '
    '"expression_hint": "관계 성격 설명 (블록 이름 쓰지 말 것)", '
    '"source_data": "핵심 항목 나열 + [팝업:] [이미지:] 마커 + 출처"}]}\n'
    "```\n\n"
)


async def refine_concepts(
    content: str,
    analysis: dict[str, Any],
) -> dict[str, Any]:
    """1단계-B: 각 꼭지의 컨셉을 구체화한다.

    1단계-A 결과(topics)를 받아서, 각 꼭지의 관계 성격/표현 방법/원본 데이터를 판단.
    Kei API만 사용. fallback 없음. 성공할 때까지 재시도.
    1회 호출로 모든 꼭지를 한꺼번에 처리.
    """
    import asyncio

    topics = analysis.get("topics", [])
    if not topics:
        return analysis

    # 꼭지 요약 텍스트
    topics_text = "\n".join(
        f"- 꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
        f"[purpose: {t.get('purpose', '?')}, summary: {t.get('summary', '')}]"
        for t in topics
    )

    prompt = (
        KEI_PROMPT_B
        + f"## 1단계-A 스토리라인 결과\n"
        f"핵심 메시지: {analysis.get('core_message', '?')}\n"
        f"꼭지 목록:\n{topics_text}\n\n"
        f"## 원본 콘텐츠\n{content}\n"
    )

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
    RETRY_INTERVAL = 10
    attempt = 0

    while True:
        attempt += 1
        try:
            async with httpx.AsyncClient(timeout=None) as client:
                async with client.stream(
                    "POST",
                    f"{kei_url}/api/direct",
                    json={
                        "message": prompt,
                    },
                    timeout=None,
                ) as response:
                    if response.status_code != 200:
                        logger.warning(f"[1단계-B] Kei API HTTP {response.status_code} (시도 {attempt})")
                        await asyncio.sleep(RETRY_INTERVAL)
                        continue

                    full_text = await stream_sse_tokens(response)

            if not full_text:
                logger.warning(f"[1단계-B] 응답 텍스트 없음 (시도 {attempt})")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

            result = _parse_json(full_text)
            if result and "concepts" in result:
                # topics에 concept 정보 병합
                # Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 다 체크
                concept_map = {}
                for c in result["concepts"]:
                    tid = c.get("topic_id") or c.get("id")
                    if tid is not None:
                        concept_map[tid] = c
                for topic in topics:
                    concept = concept_map.get(topic.get("id"))
                    if concept:
                        topic["relation_type"] = concept.get("relation_type", "")
                        topic["expression_hint"] = concept.get("expression_hint", "")
                        topic["source_data"] = concept.get("source_data", "")

                logger.info(f"[1단계-B] 컨셉 구체화 완료: {len(result['concepts'])}개")
                return analysis
            else:
                logger.warning(f"[1단계-B] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

        except Exception as e:
            logger.warning(f"[1단계-B] Kei API 실패 (시도 {attempt}): {e}")
            await asyncio.sleep(RETRY_INTERVAL)
            continue


KEI_STRUCTURED_TEXT_PROMPT = (
    "아래는 슬라이드 스토리라인의 꼭지 목록과 원본 콘텐츠이다.\n"
    "각 꼭지에 해당하는 원본 텍스트를 **슬라이드에 넣을 형태로 구조화**하라.\n\n"
    "## 절대 규칙\n"
    "1. **원본 문장을 그대로 가져와라. 재작성하지 마라.**\n"
    "   원본: '시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
    "   → 그대로: '• 시설물의 요구 성능을 설계·시공·운영 전 과정에서 디지털로 검증하여 안전성 확보'\n"
    "   ❌ 재작성 금지: '디지털 검증으로 안전성을 확보함'\n"
    "2. 원본 내용의 85% 이상을 보존하라. 축약하지 마라.\n"
    "3. **소제목(###)이 있으면 그대로 유지하라.** 삭제하거나 합치지 마라.\n"
    "   원본: '### 안전과 품질' → structured_text에 '안전과 품질' 소제목 유지\n"
    "4. 각 문장을 불릿(•)으로 구분하라.\n"
    "5. 하위 항목이 있으면 들여쓰기 불릿(  •)으로 구분하라.\n"
    "6. 출처가 있으면 반드시 포함하라 (출처: ...).\n"
    "7. 개조식 어미로 변환하라 (~있다→~있음, ~한다→~함, ~이다→삭제).\n"
    "8. 팝업 참조([팝업: ...])는 그대로 유지하라.\n"
    "9. 이미지 참조([이미지: ...])는 그대로 유지하라.\n\n"
    "## 출력 형식 (JSON만. 설명 없이.)\n"
    "```json\n"
    '{"structured_texts": ['
    '{"topic_id": 1, '
    '"structured_text": "• 첫 번째 문장\\n• 두 번째 문장\\n  • 하위 항목"}]}\n'
    "```\n\n"
)


async def generate_structured_text(
    content: str,
    analysis: dict[str, Any],
) -> dict[str, Any]:
    """1단계-B 보완: 각 꼭지의 structured_text를 생성.

    refine_concepts() 후 별도 호출. 원본 텍스트를 85% 보존하여 구조화.
    """
    import asyncio

    topics = analysis.get("topics", [])
    if not topics:
        return analysis

    topics_text = "\n".join(
        f"- 꼭지 {t.get('id', '?')}: {t.get('title', '?')} "
        f"[purpose: {t.get('purpose', '?')}, source_hint: {t.get('source_hint', '')}]"
        for t in topics
    )

    prompt = (
        KEI_STRUCTURED_TEXT_PROMPT
        + f"## 꼭지 목록\n{topics_text}\n\n"
        + f"## 원본 콘텐츠\n{content}\n"
    )

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
    RETRY_INTERVAL = 10
    attempt = 0

    while True:
        attempt += 1
        try:
            async with httpx.AsyncClient(timeout=None) as client:
                async with client.stream(
                    "POST",
                    f"{kei_url}/api/direct",
                    json={"message": prompt},
                    timeout=None,
                ) as response:
                    if response.status_code != 200:
                        logger.warning(f"[1단계-B-ST] Kei API HTTP {response.status_code} (시도 {attempt})")
                        await asyncio.sleep(RETRY_INTERVAL)
                        continue
                    full_text = await stream_sse_tokens(response)

            if not full_text:
                logger.warning(f"[1단계-B-ST] 응답 텍스트 없음 (시도 {attempt})")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

            result = _parse_json(full_text)
            if result and "structured_texts" in result:
                st_map = {}
                for st in result["structured_texts"]:
                    tid = st.get("topic_id") or st.get("id")
                    if tid is not None:
                        st_map[tid] = st.get("structured_text", "")

                for topic in topics:
                    text = st_map.get(topic.get("id"), "")
                    if text:
                        topic["structured_text"] = text

                filled = sum(1 for t in topics if t.get("structured_text"))
                logger.info(f"[1단계-B-ST] structured_text 생성 완료: {filled}/{len(topics)}개")
                return analysis
            else:
                logger.warning(f"[1단계-B-ST] JSON 파싱 실패 (시도 {attempt}): {full_text[:200]}")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

        except Exception as e:
            logger.warning(f"[1단계-B-ST] Kei API 실패 (시도 {attempt}): {e}")
            await asyncio.sleep(RETRY_INTERVAL)
            continue


async def _call_kei_api(content: str) -> dict[str, Any] | None:
    """Kei API를 통해 꼭지 추출. SSE 스트리밍으로 실시간 수신."""
    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                f"{kei_url}/api/direct",
                json={
                    "message": KEI_PROMPT + content,
                },
                timeout=None,
            ) as response:
                if response.status_code != 200:
                    logger.warning(f"Kei API HTTP {response.status_code}")
                    return None

                full_text = await stream_sse_tokens(response)

            if not full_text:
                logger.warning("Kei API 응답에서 텍스트 추출 실패")
                return None

            result = _parse_json(full_text)
            if result and "topics" in result:
                return result

            logger.warning(f"Kei API JSON 파싱 실패. 텍스트: {full_text[:200]}")
            return None

    except Exception as e:
        logger.warning(f"Kei API 호출 실패: {e}")
        return None




# ──────────────────────────────────────
# Phase Q-4: 제약 기반 블록 선택 (Kei 1회 호출)
# ──────────────────────────────────────

BLOCK_SELECTION_PROMPT = """당신은 11년 경력의 기획 실장이다. 각 꼭지(topic)에 가장 적합한 블록과 변형(variant)을 선택하라.

## 판단 기준 (우선순위 순)
1. 콘텐츠의 **표현 의도(expression_hint)**를 가장 잘 시각화하는 블록+변형
2. 이 꼭지의 목적(purpose)에 부합하는 표현 방식
3. 블록에 변형(variant)이 있으면, 콘텐츠에 더 적합한 변형을 선택
4. 글자수 예산 내에서 의미 전달이 가능한 블록
5. 다른 꼭지와 같은 블록 타입이 되지 않도록 다양성 확보

## 변형(variant) 선택 규칙
- 블록에 변형이 여러 개 있으면, "when" 조건과 expression_hint를 비교하여 적합한 것 선택
- 기본(default)이 적합하면 variant를 "default"로 지정
- 변형의 "when"이 expression_hint와 맞으면 해당 variant 선택

## 출력 (JSON, 꼭지 수만큼)
{"selections": [{"topic_id": 1, "block_id": "블록 id", "variant": "default 또는 변형 id", "reason": "선택 근거 1문장"}, ...]}
"""


async def select_block_for_topics(
    topics: list[dict[str, Any]],
    candidates_per_topic: dict[int, list[dict]],
    budgets_per_topic: dict[int, dict[str, dict]],
    container_specs: dict[str, Any],
    analysis: dict[str, Any],
) -> dict[int, dict] | None:
    """Phase Q-4: 필터링된 후보 목록에서 Kei가 topic별 블록을 1개씩 선택.

    AI 1회 호출로 모든 topic의 블록을 동시 선택한다.

    Args:
        topics: 1단계 분석의 topic 리스트
        candidates_per_topic: {topic_id: [후보 블록 리스트]}
        budgets_per_topic: {topic_id: {block_id: budget_dict}}
        container_specs: 역할별 ContainerSpec
        analysis: 1단계 분석 결과

    Returns:
        {topic_id: {"block_id": "...", "reason": "..."}} 또는 None (실패)
    """
    from src.block_selector import format_candidates_for_prompt
    from src.space_allocator import find_container_for_topic

    core_message = analysis.get("core_message", "")

    # 프롬프트 구성
    prompt_parts = [
        BLOCK_SELECTION_PROMPT,
        f"\n## 슬라이드 핵심 메시지\n{core_message}\n",
    ]

    for topic in topics:
        tid = topic.get("id")
        candidates = candidates_per_topic.get(tid, [])
        if not candidates:
            continue

        spec = find_container_for_topic(tid, container_specs)
        per_topic_px = spec.height_px // max(1, len(spec.topic_ids)) if spec else 0
        budget = budgets_per_topic.get(tid, {})

        expression_hint = topic.get("expression_hint", "")
        prompt_parts.append(
            f"\n### 꼭지 {tid}: {topic.get('title', '')}\n"
            f"- 목적: {topic.get('purpose', '')}\n"
            f"- 관계 유형: {topic.get('relation_type', 'none')}\n"
            f"- ★ 표현 의도: {expression_hint}\n"
            f"- 컨테이너: {per_topic_px}px × {spec.width_px if spec else 700}px "
            f"({spec.role if spec else '?'} {spec.zone if spec else '?'})\n"
            f"- 후보 블록:\n"
            f"{format_candidates_for_prompt(candidates, budget)}\n"
        )

    full_prompt = "\n".join(prompt_parts)

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                f"{kei_url}/api/direct",
                json={
                    "message": full_prompt,
                },
                timeout=None,
            ) as response:
                if response.status_code != 200:
                    logger.warning(f"[Q-4] Kei API HTTP {response.status_code}")
                    return None

                full_text = await stream_sse_tokens(response)

        if not full_text:
            logger.warning("[Q-4] Kei API 응답 비어있음")
            return None

        result = _parse_json(full_text)
        if not result or "selections" not in result:
            logger.warning(f"[Q-4] JSON 파싱 실패: {full_text[:200]}")
            return None

        # 결과 → {topic_id: {"block_id": ..., "reason": ...}}
        selections = {}
        for sel in result["selections"]:
            tid = sel.get("topic_id")
            block_id = sel.get("block_id", "")

            # catalog 존재 검증 (유령 블록 최종 차단)
            candidates = candidates_per_topic.get(tid, [])
            valid_ids = {c.get("id") for c in candidates}
            if block_id not in valid_ids:
                logger.warning(
                    f"[Q-4] topic {tid}: Kei가 '{block_id}' 선택했으나 후보에 없음. "
                    f"첫 번째 후보로 대체."
                )
                block_id = candidates[0]["id"] if candidates else block_id

            selections[tid] = {
                "block_id": block_id,
                "variant": sel.get("variant", "default"),
                "reason": sel.get("reason", ""),
            }

        logger.info(
            f"[Q-4] 블록 선택 완료: "
            + ", ".join(f"t{tid}={s['block_id']}" for tid, s in selections.items())
        )
        return selections

    except Exception as e:
        logger.error(f"[Q-4] Kei 블록 선택 실패: {e}")
        return None


# ──────────────────────────────────────
# Phase Q-6: 비전 모델 품질 게이트 (VASCAR식)
# ──────────────────────────────────────

VISION_QUALITY_PROMPT = """슬라이드 스크린샷을 평가하라.

## 체크리스트 (각 항목 1-5점)
1. 콘텐츠 겹침/잘림: 모든 텍스트가 컨테이너 안에 있는가?
2. 시각적 위계: 본심 영역이 가장 두드러지는가?
3. 가독성: 모든 폰트가 읽을 수 있는 크기인가? (10px 이상)
4. 블록 다양성: 서로 다른 블록 유형을 사용하고 있는가?
5. 전문성: 한국어 비즈니스 프레젠테이션으로 적절한가?

## 출력 (JSON)
{
    "passed": true/false,
    "score": 0-100,
    "checks": {"겹침": 5, "위계": 4, "가독성": 5, "다양성": 3, "전문성": 4},
    "issues": ["문제 설명 (있으면)"],
    "fix_targets": [{"area": "body", "topic_id": 3, "action": "shrink|replace|rewrite", "detail": "구체적 지시"}]
}
"""


async def vision_quality_gate(
    screenshot_b64: str,
    analysis: dict[str, Any],
) -> dict[str, Any] | None:
    """Phase Q-6: 스크린샷 기반 시각 품질 평가.

    VASCAR 논문 기반 — 렌더링 → 비전 모델 평가 → 교정 여부 결정.

    Returns:
        {"passed": bool, "score": int, "issues": [...], "fix_targets": [...]}
    """
    import anthropic
    import base64

    core_message = analysis.get("core_message", "")
    topic_summary = ", ".join(
        f"{t.get('id')}:{t.get('title','')}" for t in analysis.get("topics", [])
    )

    try:
        client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)

        response = await client.messages.create(
            model="claude-opus-4-6-20250415",
            max_tokens=2048,
            messages=[{
                "role": "user",
                "content": [
                    {
                        "type": "image",
                        "source": {
                            "type": "base64",
                            "media_type": "image/png",
                            "data": screenshot_b64,
                        },
                    },
                    {
                        "type": "text",
                        "text": (
                            VISION_QUALITY_PROMPT +
                            f"\n\n## 컨텍스트\n"
                            f"핵심 메시지: {core_message}\n"
                            f"꼭지 구성: {topic_summary}\n"
                        ),
                    },
                ],
            }],
        )

        result_text = response.content[0].text if response.content else ""
        result = _parse_json(result_text)

        if result:
            score = result.get("score", 0)
            passed = result.get("passed", score >= 50)
            result["passed"] = passed
            logger.info(
                f"[Q-6] 품질 게이트: {score}/100 → {'PASS' if passed else 'FAIL'}"
            )
            return result

        logger.warning(f"[Q-6] JSON 파싱 실패: {result_text[:200]}")
        return None

    except Exception as e:
        logger.error(f"[Q-6] 비전 품질 평가 실패: {e}")
        return None


# ──────────────────────────────────────
# J-7: Kei 최종 검수
# ──────────────────────────────────────

KEI_REVIEW_PROMPT = """당신은 11년 경력의 기획 실장이다. 디자인 팀장이 조립한 슬라이드를 최종 검수한다.

## 검수 관점
1. 핵심 메시지(core_message)가 시각적으로 명확히 전달되는가?
2. 콘텐츠 흐름이 블록 배치와 일치하는가?
3. 각 블록이 해당 꼭지의 purpose에 적합한가?
4. 중요한 내용이 빠지거나 과도하게 축소되지 않았는가?
5. 높이 초과: 각 zone의 블록+텍스트가 예산을 초과하는가?
   - 텍스트 축약으로 해결 가능 → shrink
   - 콘텐츠가 본질적으로 큼 → overflow_detected
6. **핵심전달이 body에서 가장 큰 시각적 비중을 차지하는가?**
   - 핵심전달 블록이 도입부(문제제기+근거사례)보다 작으면 → rewrite로 비중 조정
7. **문제제기가 간결한가? (100자 이내)**
   - 초과 시 → shrink (target_ratio: 0.5)
8. **용어정의가 sidebar에 있는가?**
   - body에 있으면 → 구조 문제 지적 (issues에 명시)
9. **핵심전달 블록이 화면 안에 보이는가?**
   - 잘리면 → overflow_detected

## 조정 action
- expand: 텍스트 늘림 (target_ratio, 예: 1.3)
- shrink: 텍스트 줄임 (target_ratio, 예: 0.7)
- rewrite: 텍스트 재작성 (detail에 방향)
- overflow_detected: 높이 초과, 콘텐츠 판단 필요 (zone과 블록 명시)

## 출력 (JSON만. 설명 없이.)
{"needs_adjustment": true/false, "issues": ["이슈1"], "adjustments": [{"block_area": "...", "action": "expand|shrink|rewrite|overflow_detected", "target_ratio": 1.3, "detail": "..."}]}
"""


async def call_kei_final_review(
    html: str,
    block_summary: list[str],
    zone_budget_text: str,
    overflow_hint_text: str,
    analysis: dict[str, Any] | None = None,
    screenshot_b64: str | None = None,
) -> dict[str, Any] | None:
    """Phase N-4: Kei(Opus)가 스크린샷을 보고 최종 검수한다.

    스크린샷이 있으면: Anthropic API 직접 호출 (Opus + 멀티모달)
    스크린샷이 없으면: Kei API 경유 (텍스트 기반)
    어느 경로든 Kei(Opus)가 판단. Sonnet 절대 금지.
    """
    import anthropic

    core_message = analysis.get("core_message", "") if analysis else ""
    topics_summary = ""
    if analysis:
        topics_summary = "\n".join(
            f"- 꼭지 {t.get('id')}: {t.get('title', '')} [{t.get('purpose', '')}]"
            for t in analysis.get("topics", [])
        )

    review_text = (
        f"## 핵심 메시지\n{core_message}\n\n"
        f"## 꼭지 목록\n{topics_summary}\n\n"
        f"## 블록별 데이터 양\n" + "\n".join(block_summary) +
        zone_budget_text +
        overflow_hint_text +
        f"\n\n위 슬라이드를 검수하고 조정이 필요한지 판단해. JSON만."
    )

    # 스크린샷이 있으면: Opus 직접 호출 + 이미지 전달
    if screenshot_b64:
        try:
            client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
            response = await client.messages.create(
                model="claude-opus-4-6-20250415",
                max_tokens=4096,
                system=KEI_REVIEW_PROMPT,
                messages=[{
                    "role": "user",
                    "content": [
                        {
                            "type": "image",
                            "source": {
                                "type": "base64",
                                "media_type": "image/png",
                                "data": screenshot_b64,
                            },
                        },
                        {
                            "type": "text",
                            "text": review_text,
                        },
                    ],
                }],
            )

            result_text = response.content[0].text
            result = _parse_json(result_text)
            if result and "needs_adjustment" in result:
                logger.info(
                    f"[Kei 최종 검수] 스크린샷 기반, needs_adjustment={result['needs_adjustment']}"
                )
                return result
            logger.warning("[Kei 최종 검수] 스크린샷 기반 JSON 파싱 실패")
            return None

        except Exception as e:
            logger.warning(f"Kei 최종 검수 (스크린샷) 실패: {e}")
            return None

    # 스크린샷 없으면: Kei API 경유 (텍스트 기반)
    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
    prompt = (
        KEI_REVIEW_PROMPT + "\n\n" + review_text +
        f"\n\n## 조립 HTML (요약)\n{html[:3000]}"
    )

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                f"{kei_url}/api/direct",
                json={
                    "message": prompt,
                },
                timeout=None,
            ) as response:
                if response.status_code != 200:
                    logger.warning(f"Kei 최종 검수 HTTP {response.status_code}")
                    return None
                full_text = await stream_sse_tokens(response)

        if full_text:
            result = _parse_json(full_text)
            if result and "needs_adjustment" in result:
                logger.info(
                    f"[Kei 최종 검수] 텍스트 기반, needs_adjustment={result['needs_adjustment']}"
                )
                return result
            logger.warning("[Kei 최종 검수] JSON 파싱 실패")
            return None

        logger.warning("Kei 최종 검수 텍스트 추출 실패")
        return None

    except Exception as e:
        logger.warning(f"Kei 최종 검수 실패: {e}")
        return None


# ──────────────────────────────────────
# I-9: Kei 넘침 판단 호출
# ──────────────────────────────────────

KEI_OVERFLOW_PROMPT = """당신은 슬라이드 콘텐츠 전문가이다.
디자인 팀장이 배치한 블록들이 컨테이너(zone)의 높이 예산을 초과했다.
콘텐츠의 중요도와 전달 메시지를 기준으로 어떻게 처리할지 판단하라.

## 판단 기준
- 텍스트 분량만 줄이면 현재 블록 구조 안에서 해결되는가? → "trim"
- 콘텐츠 자체가 컨테이너에 담기엔 본질적으로 많은가? → "restructure"
- 중요도가 높은 콘텐츠를 무리하게 축소하면 안 된다
- 부가/상세 정보는 팝업(detail page)으로 분리할 수 있다

## 출력 (JSON만. 설명 없이.)

Option 1 (텍스트 축약):
{"decision": "trim", "trim_targets": [{"topic_id": 1, "max_chars": 200, "reason": "부연 설명 축약 가능"}]}

Option 2 (핵심 재구성 + 팝업 분리):
{"decision": "restructure", "core_topics": [1, 2], "detail_topics": [3], "reason": "12행 비교표는 팝업으로 분리"}
"""


async def call_kei_overflow_judgment(
    overflows: list[dict],
    content: str,
    analysis: dict[str, Any],
) -> dict[str, Any] | None:
    """Kei API에 넘침 상황을 전달하고 판단을 받는다.

    반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
    실패 시 None → pipeline에서 무한 재시도.
    """
    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")

    overflow_desc = json.dumps(overflows, ensure_ascii=False, indent=2)
    topics_desc = json.dumps(
        [
            {
                "id": t.get("id"),
                "title": t.get("title", ""),
                "purpose": t.get("purpose", ""),
                "summary": t.get("summary", "")[:100],
            }
            for t in analysis.get("topics", [])
        ],
        ensure_ascii=False,
    )

    # I-8: 대형 콘텐츠 정보 포함
    extra_info = ""
    tables = analysis.get("tables", [])
    if tables:
        extra_info += f"\n\n## 테이블 정보\n{json.dumps(tables, ensure_ascii=False)}"
    images = analysis.get("images", [])
    if images:
        extra_info += f"\n\n## 이미지 정보\n{json.dumps(images, ensure_ascii=False)}"

    prompt = (
        KEI_OVERFLOW_PROMPT + "\n\n"
        f"## 넘침 현황\n{overflow_desc}\n\n"
        f"## 꼭지 목록\n{topics_desc}"
        f"{extra_info}\n\n"
        f"## 원본 콘텐츠 요약\n{content[:2000]}"
    )

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                f"{kei_url}/api/direct",
                json={
                    "message": prompt,
                },
                timeout=None,
            ) as response:
                if response.status_code != 200:
                    logger.warning(f"Kei API (overflow) HTTP {response.status_code}")
                    return None
                full_text = await stream_sse_tokens(response)

        if full_text:
            result = _parse_json(full_text)
            if result and "decision" in result:
                logger.info(f"[Kei 넘침 판단] decision={result['decision']}")
                return result
            logger.warning("[Kei 넘침 판단] JSON 파싱 실패 또는 decision 없음")
            return None

        logger.warning("Kei API (overflow) 텍스트 추출 실패")
        return None

    except Exception as e:
        logger.warning(f"Kei API (overflow) 호출 실패: {e}")
        return None


def _parse_json(text: str) -> dict[str, Any] | None:
    """텍스트에서 JSON을 추출한다.

    Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
    """
    # 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
    # Kei API가 JSON을 마크다운 리스트로 감싸서 응답하는 경우 대응
    lines = text.split("\n")
    cleaned_lines = []
    for line in lines:
        stripped = line.lstrip()
        if stripped.startswith("- "):
            cleaned_lines.append(stripped[2:])
        elif stripped.startswith("* "):
            cleaned_lines.append(stripped[2:])
        else:
            cleaned_lines.append(stripped)
    cleaned = "\n".join(cleaned_lines)

    # 원본 + 클린 버전 둘 다 시도
    for target in [text, cleaned]:
        patterns = [
            r"```json\s*(.*?)```",
            r"```\s*(.*?)```",
            r"(\{.*\})",
        ]
        for pattern in patterns:
            match = re.search(pattern, target, re.DOTALL)
            if match:
                try:
                    return json.loads(match.group(1).strip())
                except json.JSONDecodeError:
                    continue
    return None


async def select_best_candidate(
    topic_results: list[dict[str, Any]],
    analysis: dict[str, Any],
) -> dict[str, Any]:
    """Phase P: Kei가 스크린샷을 보고 topic별 최적 블록을 선택한다.

    여러 topic을 묶어서 1회 호출. 각 topic별 후보 스크린샷을 Opus 멀티모달로 비교.

    Args:
        topic_results: [{
            "topic_id": 1,
            "topic_title": "...",
            "purpose": "문제제기",
            "candidates": [
                {"index": 0, "type": "callout-warning", "screenshot_b64": "...", "overflowed": False},
                {"index": 1, "type": "dark-bullet-list", "screenshot_b64": "...", "overflowed": False},
                {"index": 2, "type": "quote-big-mark", "screenshot_b64": "...", "overflowed": True},
            ]
        }, ...]
        analysis: 1단계 분석 결과 (core_message 등)

    Returns:
        {"selections": [{"topic_id": 1, "selected_index": 0, "reason": "..."}]}
    """
    import anthropic

    core_message = analysis.get("core_message", "")

    # 메시지 content 블록 구성 (텍스트 + 이미지들)
    content_blocks = []

    # 지시문
    instruction = (
        f"슬라이드의 핵심 메시지: {core_message}\n\n"
        "아래 각 꼭지(topic)별로 후보 블록 스크린샷을 보여준다.\n"
        "각 꼭지마다 **당초 목적에 가장 적합한 1개**를 선택하라.\n\n"
        "판단 기준:\n"
        "1. 당초 목적(purpose)에 적합한가? (문제 제기인데 비교 블록이면 부적합)\n"
        "2. 콘텐츠 의미가 왜곡되지 않는가?\n"
        "3. 컨테이너에 넘치지 않는가? (overflow 표시된 것은 감점)\n"
        "4. 같은 블록이 다른 topic과 중복되면 피하라.\n\n"
        "전부 안 맞으면 그나마 가장 나은 것을 선택하라.\n\n"
    )

    # 각 topic의 후보 스크린샷 추가
    for tr in topic_results:
        tid = tr["topic_id"]
        purpose = tr.get("purpose", "")
        instruction += f"## 꼭지 {tid}: {tr.get('topic_title', '')} (목적: {purpose})\n"

        for cand in tr.get("candidates", []):
            idx = cand["index"]
            block_type = cand.get("type", "")
            overflowed = cand.get("overflowed", False)
            overflow_note = " ⚠️ OVERFLOW" if overflowed else ""
            instruction += f"  후보 {idx}: {block_type}{overflow_note}\n"

    content_blocks.append({"type": "text", "text": instruction})

    # 스크린샷 이미지 추가
    for tr in topic_results:
        for cand in tr.get("candidates", []):
            b64 = cand.get("screenshot_b64")
            if b64:
                content_blocks.append({
                    "type": "text",
                    "text": f"[꼭지 {tr['topic_id']} 후보 {cand['index']}: {cand.get('type', '')}]",
                })
                content_blocks.append({
                    "type": "image",
                    "source": {
                        "type": "base64",
                        "media_type": "image/png",
                        "data": b64,
                    },
                })

    content_blocks.append({
        "type": "text",
        "text": (
            "\n## 출력 (JSON만)\n"
            '{"selections": [{"topic_id": 1, "selected_index": 0, "reason": "선택 이유"}]}'
        ),
    })

    try:
        client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
        response = await client.messages.create(
            model="claude-opus-4-6-20250415",
            max_tokens=2048,
            messages=[{"role": "user", "content": content_blocks}],
        )

        result_text = response.content[0].text
        result = _parse_json(result_text)
        if result and "selections" in result:
            logger.info(
                f"[Phase P] Kei 최종 선택: "
                + ", ".join(f"t{s['topic_id']}→후보{s['selected_index']}" for s in result["selections"])
            )
            return result

        logger.warning(f"[Phase P] 선택 JSON 파싱 실패: {result_text[:200]}")
        return {"selections": []}

    except Exception as e:
        logger.warning(f"[Phase P] Kei 최종 선택 실패: {e}")
        return {"selections": []}


# ──────────────────────────────────────
# Phase V: 콘텐츠-컨테이너 적합성 에스컬레이션
# ──────────────────────────────────────

KEI_ENHANCEMENT_REVIEW_PROMPT = """당신은 슬라이드 설계 전문가이다.
아래는 슬라이드 콘텐츠 품질 강화를 위한 제안 목록이다.
각 제안을 검토하고, 승인/수정/거부를 결정하라.

## 판단 기준
- 핵심 메시지가 시각적으로 강조되는가?
- 빈 공간에 유의미한 콘텐츠를 추가할 수 있는가?
- 종속 꼭지의 처리 방식(인라인/하위블록)이 적절한가?
- bold 키워드가 핵심 용어인가?

## 출력 (JSON만. 설명 없이.)

```json
{
  "decisions": [
    {
      "type": "subordinate|fill_space|emphasis|bold_keywords",
      "role": "배경|본심|첨부|결론",
      "action": "approve|modify|reject",
      "modification": "수정 시 구체적 내용 (approve/reject면 빈 문자열)"
    }
  ]
}
```
"""


async def call_kei_enhancement_review(
    enhancement_report: str,
    topics: list[dict],
    core_message: str,
) -> dict[str, Any] | None:
    """Stage 1.8 Step 5: Kei에게 보강 제안을 보여주고 승인/수정/거부 결정을 받는다.

    Kei API(/api/direct)만 사용.
    """
    import asyncio

    topics_text = "\n".join(
        f"- 꼭지{t.get('id', '?')}: {t.get('title', '')} [{t.get('purpose', '')}]"
        for t in topics
    )

    prompt = (
        KEI_ENHANCEMENT_REVIEW_PROMPT + "\n\n"
        f"## 핵심 메시지\n{core_message}\n\n"
        f"## 꼭지 목록\n{topics_text}\n\n"
        f"## 보강 제안\n{enhancement_report}\n"
    )

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
    RETRY_INTERVAL = 10
    attempt = 0

    while True:
        attempt += 1
        try:
            async with httpx.AsyncClient(timeout=None) as client:
                async with client.stream(
                    "POST",
                    f"{kei_url}/api/direct",
                    json={"message": prompt},
                    timeout=None,
                ) as response:
                    if response.status_code != 200:
                        logger.warning(f"[V-5 Kei 보강 검토] HTTP {response.status_code} (시도 {attempt})")
                        await asyncio.sleep(RETRY_INTERVAL)
                        continue
                    full_text = await stream_sse_tokens(response)

            if not full_text:
                logger.warning(f"[V-5 Kei 보강 검토] 응답 없음 (시도 {attempt})")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

            result = _parse_json(full_text)
            if result and "decisions" in result:
                approved = sum(1 for d in result["decisions"] if d.get("action") == "approve")
                total = len(result["decisions"])
                logger.info(f"[V-5 Kei 보강 검토] {approved}/{total} 승인")
                return result
            else:
                logger.warning(f"[V-5 Kei 보강 검토] JSON 파싱 실패 (시도 {attempt})")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

        except Exception as e:
            logger.warning(f"[V-5 Kei 보강 검토] 실패 (시도 {attempt}): {e}")
            await asyncio.sleep(RETRY_INTERVAL)
            continue


async def call_kei_summarize_popup(
    popup_title: str,
    popup_content: str,
    available_width_px: float,
    available_height_px: float,
    font_size: float,
) -> dict[str, Any] | None:
    """V'-2: 코드가 형태+크기를 결정하고, Kei가 텍스트만 채운다.

    1. 코드: 팝업 원본에 표(|)가 있으면 table, 불릿(•)이면 bullets, 그 외 text
    2. 코드: 공간 크기와 폰트를 고려하여 행/열 수 계산
    3. Kei: 결정된 형태+크기에 맞게 원본 내용을 요약

    Returns:
        {
            "format": "table" | "bullets" | "text",
            "columns": [...], "data": [["셀", ...], ...],  # table
            "items": ["...", ...],  # bullets
            "summary": "...",  # text
        }
    """
    import asyncio

    header_h = font_size * 1.5 + font_size * 0.6
    row_h = font_size * 1.5 + font_size * 0.6
    bullet_h = font_size * 1.55
    chars_per_col = int(available_width_px / (font_size * 0.6))

    # 코드가 형태 판단
    import re
    has_table = popup_content.count("|") > 6 or "<table" in popup_content
    has_bullets = popup_content.count("•") > 2

    if has_table:
        # 원본 표에서 열 수 추출 — <th> 태그 우선, 없으면 | 파싱
        th_headers = re.findall(r'<th[^>]*>(.*?)</th>', popup_content)
        # <strong> 등 태그 제거
        th_headers = [re.sub(r'<[^>]+>', '', h).strip() for h in th_headers]
        if th_headers:
            # 첫 번째 <thead>의 열만 사용 (중복 테이블 헤더 제거)
            orig_cols = th_headers[:3] if len(th_headers) > 3 else th_headers
            col_count = len(orig_cols)
        else:
            table_lines = [l.strip() for l in popup_content.split("\n") if l.strip().startswith("|")]
            if len(table_lines) >= 2:
                orig_cols = [c.strip() for c in table_lines[0].split("|") if c.strip()]
                col_count = len(orig_cols)
            else:
                orig_cols = []
                col_count = 3
        # 행 수: 공간에 맞게 계산 (제목행 제외, 데이터 행만)
        space_rows = int((available_height_px - header_h) / row_h) if available_height_px > header_h else 1
        # 원본 표 데이터 행 수
        orig_data_rows = len(re.findall(r'<tr>', popup_content)) or len([l for l in popup_content.split("\n") if l.strip().startswith("|") and not l.strip().startswith("|--")]) - 1
        orig_data_rows = max(1, orig_data_rows)
        # 공간과 원본 중 작은 쪽, 최소 1 최대 5
        max_rows = min(space_rows, orig_data_rows, 5)
        max_rows = max(1, max_rows)
        chars_per_col = int(available_width_px / col_count / (font_size * 0.6))
        fmt = "table"
        prompt_task = (
            f"원본 표를 정확히 {col_count}열 × {max_rows}행으로 요약하라.\n"
            f"열 이름: {orig_cols}\n"
            f"data 배열에 정확히 {max_rows}개의 행을 넣어라. 1행만 넣지 마라.\n"
            f"원본에서 가장 핵심적인 {max_rows}개 비교 항목(범위, S/W, 프로세스, 성과품, 활용 등)을 골라라.\n"
            f"각 셀은 {chars_per_col}자 이내의 짧은 핵심 요약으로.\n"
            f"JSON: {{\"columns\": [\"{orig_cols[0] if orig_cols else '열1'}\", ...], \"data\": [[\"셀\", ...], ...]}}"
        )
    elif has_bullets:
        max_items = int(available_height_px / bullet_h)
        max_items = max(1, max_items)
        fmt = "bullets"
        prompt_task = (
            f"원본 불릿을 {max_items}개 이내로 요약하라.\n"
            f"각 항목은 {chars_per_col}자 이내로.\n"
            f"JSON: {{\"items\": [\"항목1\", ...]}}"
        )
    else:
        max_lines = int(available_height_px / bullet_h)
        fmt = "text"
        prompt_task = (
            f"원본을 {max_lines}줄 이내로 요약하라.\n"
            f"JSON: {{\"summary\": \"요약 텍스트\"}}"
        )

    prompt = f"""당신은 슬라이드 콘텐츠 요약 전문가이다.

## 팝업 제목: {popup_title}

## 팝업 원본:
{popup_content}

## 요청
{prompt_task}

핵심만 남기되, 원본의 의미가 왜곡되지 않도록 하라. JSON만 응답하라."""

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
    attempt = 0

    while attempt < 5:
        attempt += 1
        try:
            async with httpx.AsyncClient(timeout=None) as client:
                async with client.stream(
                    "POST",
                    f"{kei_url}/api/direct",
                    json={"message": prompt},
                    timeout=None,
                ) as response:
                    if response.status_code != 200:
                        logger.warning(f"[V'-2 Kei 요약] HTTP {response.status_code} (시도 {attempt})")
                        await asyncio.sleep(10)
                        continue
                    full_text = await stream_sse_tokens(response)

            if not full_text:
                await asyncio.sleep(10)
                continue

            result = _parse_json(full_text)
            if result and isinstance(result, dict):
                # 코드가 결정한 format을 주입 (Kei는 텍스트만 채움)
                result["format"] = fmt
                logger.info(f"[V'-2 Kei 요약] {popup_title}: format={fmt}")
                return result
            else:
                logger.warning(f"[V'-2 Kei 요약] 파싱 실패 (시도 {attempt})")
                await asyncio.sleep(10)
                continue

        except Exception as e:
            logger.warning(f"[V'-2 Kei 요약] 실패 (시도 {attempt}): {e}")
            await asyncio.sleep(10)
            continue

    return None


async def call_kei_bold_keywords(
    topics: list[dict],
    page_structure: dict,
) -> dict[str, list[str]]:
    """V-10: Kei가 문맥 기반으로 각 역할의 bold 키워드를 판단한다.

    Returns:
        {"배경": ["키워드1", ...], "본심": [...], ...}
    """
    import asyncio

    # 역할별 structured_text 정리
    topic_map = {t.get("id"): t for t in topics}
    role_texts = {}
    for role, info in page_structure.items():
        if not isinstance(info, dict):
            continue
        tids = info.get("topic_ids", [])
        texts = []
        for tid in tids:
            topic = topic_map.get(tid, {})
            st = topic.get("structured_text", "") or topic.get("source_data", "")
            if st:
                texts.append(f"[{topic.get('title', '')}]\n{st}")
        if texts:
            role_texts[role] = "\n".join(texts)

    if not role_texts:
        return {}

    role_section = "\n\n".join(
        f"## {role}\n{text}" for role, text in role_texts.items()
    )

    prompt = f"""당신은 슬라이드 디자인 전문가이다.

아래는 슬라이드의 각 영역별 콘텐츠이다. 각 영역에서 **문맥상 정말 강조되어야 할 키워드**를 골라라.

규칙:
- 개수를 정하지 마라. 문맥에 맞게 필요한 만큼만 골라라.
- 단순 명사 나열이 아니라, 읽는 사람이 "이것이 핵심이구나"라고 느낄 키워드여야 한다.
- 일반적인 단어(역할, 기술, 정의 등)는 강조 대상이 아니다.
- 고유명사, 핵심 개념명, 대비되는 용어 등이 강조 대상이다.

JSON으로 응답하라:
{{"배경": ["키워드1", ...], "본심": [...], "첨부": [...], "결론": [...]}}
빈 역할은 빈 리스트로.

{role_section}"""

    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")
    RETRY_INTERVAL = 10
    attempt = 0

    while attempt < 5:
        attempt += 1
        try:
            async with httpx.AsyncClient(timeout=None) as client:
                async with client.stream(
                    "POST",
                    f"{kei_url}/api/direct",
                    json={"message": prompt},
                    timeout=None,
                ) as response:
                    if response.status_code != 200:
                        logger.warning(f"[V-10 Kei bold] HTTP {response.status_code} (시도 {attempt})")
                        await asyncio.sleep(RETRY_INTERVAL)
                        continue
                    full_text = await stream_sse_tokens(response)

            if not full_text:
                await asyncio.sleep(RETRY_INTERVAL)
                continue

            result = _parse_json(full_text)
            if result and isinstance(result, dict):
                logger.info(f"[V-10 Kei bold] 결과: {result}")
                return result
            else:
                logger.warning(f"[V-10 Kei bold] 파싱 실패 (시도 {attempt})")
                await asyncio.sleep(RETRY_INTERVAL)
                continue

        except Exception as e:
            logger.warning(f"[V-10 Kei bold] 실패 (시도 {attempt}): {e}")
            await asyncio.sleep(RETRY_INTERVAL)
            continue

    logger.warning("[V-10 Kei bold] 최대 재시도 초과, 빈 결과 반환")
    return {}


KEI_FIT_ESCALATION_PROMPT = """당신은 슬라이드 설계 전문가이다.
콘텐츠를 컨테이너에 배치하려 했으나, 일부 영역의 콘텐츠가 공간을 초과한다.
재배분을 시도했지만 해결되지 않은 영역이 있다.

콘텐츠의 중요도와 전달 메시지를 기준으로, 어떻게 처리할지 결정하라.

## 판단 기준
- 핵심 메시지(본심)의 공간은 최대한 보장
- 배경은 보조 역할 — 간결화 가능
- 사례/근거는 인라인 축약 또는 팝업 분리 가능
- 용어 정의는 sidebar에 맞게 조정 가능

## 출력 (JSON만. 설명 없이.)

```json
{
  "decisions": [
    {
      "role": "배경",
      "action": "merge|inline|popup|trim|restructure",
      "detail": "구체적 지시 (어떤 꼭지를 어떻게)",
      "reason": "판단 근거 1문장"
    }
  ]
}
```

action 종류:
- merge: 여러 꼭지를 하나의 블록 안에서 흐름으로 합침
- inline: 사례/근거를 괄호 한 줄로 축약하여 인라인
- popup: 상세 내용을 팝업으로 분리하고 링크만 남김
- trim: 텍스트 분량을 줄임 (max_chars 지정)
- restructure: 컨테이너 구조 자체를 변경 (배경 전체폭 등)
"""


async def call_kei_fit_escalation(
    fit_report: str,
    topics: list[dict],
    content_summary: str,
) -> dict[str, Any] | None:
    """Phase V: 적합성 검증 실패 시 Kei에게 판단 요청.

    Kei API만 사용. Anthropic 직접 호출 절대 금지.
    """
    kei_url = getattr(settings, "kei_api_url", "http://localhost:8000")

    topics_desc = json.dumps(
        [
            {
                "id": t.get("id"),
                "title": t.get("title", ""),
                "purpose": t.get("purpose", ""),
                "source_data": t.get("source_data", "")[:200],
            }
            for t in topics
        ],
        ensure_ascii=False,
        indent=2,
    )

    prompt = (
        KEI_FIT_ESCALATION_PROMPT + "\n\n"
        f"## 적합성 검증 결과\n{fit_report}\n\n"
        f"## 꼭지 목록\n{topics_desc}\n\n"
        f"## 원본 콘텐츠 요약\n{content_summary[:1500]}"
    )

    try:
        async with httpx.AsyncClient(timeout=None) as client:
            async with client.stream(
                "POST",
                f"{kei_url}/api/direct",
                json={
                    "message": prompt,
                },
                timeout=None,
            ) as response:
                if response.status_code != 200:
                    logger.warning(f"Kei API (fit) HTTP {response.status_code}")
                    return None
                full_text = await stream_sse_tokens(response)

        if full_text:
            result = _parse_json(full_text)
            if result and "decisions" in result:
                logger.info(
                    f"[V-4] Kei 적합성 판단: "
                    + ", ".join(
                        f"{d['role']}→{d['action']}"
                        for d in result["decisions"]
                    )
                )
                return result
            logger.warning("[V-4] Kei 적합성 판단 JSON 파싱 실패")
            return None

        logger.warning("Kei API (fit) 텍스트 추출 실패")
        return None

    except Exception as e:
        logger.warning(f"Kei API (fit) 호출 실패: {e}")
        return None
