"""DA-13a + DA-13b: 2단계 — 디자인 팀장.

Step A: 레이아웃 프리셋 선택 (규칙 기반, LLM 불필요)
Step B: 프리셋 안에서 블록 매핑 + 글자 수 가이드 (Sonnet)
"""
from __future__ import annotations

import json
import logging
import re
from pathlib import Path
from typing import Any

import httpx
import yaml

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

logger = logging.getLogger(__name__)

# ──────────────────────────────────────
# 블록별 슬롯 정의
# ──────────────────────────────────────
BLOCK_SLOTS = {
    # headers/ (5개)
    "section-title-with-bg": {
        "required": ["title_ko"], "optional": ["title_en", "breadcrumb", "bg_image"],
        "slot_desc": {
            "title_ko": "한글 메인 타이틀",
            "title_en": "영문 서브 타이틀 (없으면 생략)",
            "breadcrumb": "상위 카테고리 경로 (예: 디지털전환 > BIM)",
            "bg_image": "배경 이미지 경로",
        },
    },
    "section-header-bar": {
        "required": ["title"], "optional": ["subtitle"],
        "slot_desc": {
            "title": "섹션 제목 (짧고 굵게)",
            "subtitle": "보조 설명 (한 줄)",
        },
    },
    "topic-left-right": {
        "required": ["title", "description"], "optional": [],
        "slot_desc": {
            "title": "꼭지 제목 (좌측, 굵게)",
            "description": "꼭지 설명 (우측, 2~3줄)",
        },
    },
    "topic-center": {
        "required": ["title"], "optional": ["subtitle", "description"],
        "slot_desc": {
            "title": "중앙 정렬 대제목",
            "subtitle": "부제목 (작은 글씨)",
            "description": "추가 설명 (1~2줄)",
        },
    },
    "topic-numbered": {
        "required": ["number", "title"], "optional": ["description", "color"],
        "slot_desc": {
            "number": "순번 (1, 2, 3 등)",
            "title": "단계/항목 제목",
            "description": "설명 텍스트",
            "color": "원형 번호 색상 (CSS 색상값)",
        },
    },
    # cards/ (9개)
    "card-image-3col": {
        "required": ["cards"], "optional": [],
        "slot_desc": {
            "cards": "카드 배열. 각 카드: {image: '이미지 경로', title: '제목', title_en: '영문', bullets: ['항목1', '항목2']}. 3장.",
        },
    },
    "card-dark-overlay": {
        "required": ["cards"], "optional": [],
        "slot_desc": {
            "cards": "카드 배열. 각 카드: {image: '배경 이미지', title: '키워드', description: '짧은 설명'}. 3~5장.",
        },
    },
    "card-tag-image": {
        "required": ["cards"], "optional": [],
        "slot_desc": {
            "cards": "카드 배열. 각 카드: {tag: '카테고리 라벨', tag_color: '색상', image: '이미지', title: '제목', description: '설명'}. 3장.",
        },
    },
    "card-icon-desc": {
        "required": ["cards"], "optional": [],
        "slot_desc": {
            "cards": "카드 배열. 각 카드: {icon: '이모지', title: '제목', description: '설명 (2~3줄)'}. 2~4장.",
        },
    },
    "card-compare-3col": {
        "required": ["cards"], "optional": [],
        "slot_desc": {
            "cards": "비교 카드 배열. 각 카드: {header: '카테고리명', header_color: '색상', bullets: ['항목1', '항목2']}. 3장.",
        },
    },
    "card-step-vertical": {
        "required": ["steps"], "optional": [],
        "slot_desc": {
            "steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명', image: '이미지(선택)'}. 3~5단계.",
        },
    },
    "card-image-round": {
        "required": ["cards"], "optional": [],
        "slot_desc": {
            "cards": "카드 배열. 각 카드: {image: '원형 이미지', title: '제목', description: '설명'}. 2~3장.",
        },
    },
    "card-stat-number": {
        "required": ["stats"], "optional": [],
        "slot_desc": {
            "stats": "통계 배열. 각 항목: {number: '85', unit: '%', label: '비용 절감율'}. 2~4개. 숫자는 출처 있는 것만!",
        },
    },
    "card-numbered": {
        "required": ["items"], "optional": [],
        "slot_desc": {
            "items": "항목 배열. 각 항목: {title: '항목 제목', description: '설명'}. 3~5개.",
        },
    },
    # tables/ (3개)
    "compare-3col-badge": {
        "required": ["headers", "rows"], "optional": [],
        "slot_desc": {
            "headers": "3개 열 헤더 배열: ['항목', 'A 대상', 'B 대상']",
            "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: 'A 내용', right: 'B 내용'}. 최소 3행.",
        },
    },
    "compare-2col-split": {
        "required": ["left_title", "right_title", "rows"], "optional": [],
        "slot_desc": {
            "left_title": "왼쪽 열 헤더",
            "right_title": "오른쪽 열 헤더",
            "rows": "비교 행 배열. 각 행: {criteria: '비교 기준', left: '왼쪽 내용', right: '오른쪽 내용'}. 최소 3행.",
        },
    },
    "table-simple-striped": {
        "required": ["headers", "rows"], "optional": [],
        "slot_desc": {
            "headers": "열 헤더 배열: ['열1', '열2', '열3']",
            "rows": "데이터 행 배열. 각 행: ['셀1', '셀2', '셀3']. 행 수 자유.",
        },
    },
    # visuals/ (6개)
    "venn-diagram": {
        "required": ["center_label", "items"], "optional": ["center_sub", "description"],
        "slot_desc": {
            "center_label": "중앙 교집합 라벨 (핵심 키워드)",
            "items": "원 배열. 각 원: {label: '영역명', sub: '설명'}. 2~5개.",
            "center_sub": "중앙 부가 설명",
            "description": "다이어그램 하단 설명",
        },
    },
    "circle-gradient": {
        "required": ["label"], "optional": ["sub_label"],
        "slot_desc": {
            "label": "원 중앙 메인 텍스트 (키워드, 1~2단어)",
            "sub_label": "원 아래 보조 텍스트",
        },
    },
    "compare-pill-pair": {
        "required": ["left_label", "right_label"], "optional": ["left_sub", "right_sub"],
        "slot_desc": {
            "left_label": "왼쪽 개념명 (1~2단어)",
            "right_label": "오른쪽 개념명 (1~2단어)",
            "left_sub": "왼쪽 보조 설명",
            "right_sub": "오른쪽 보조 설명",
        },
    },
    "process-horizontal": {
        "required": ["steps"], "optional": [],
        "slot_desc": {
            "steps": "단계 배열. 각 단계: {number: '01', title: '단계명', description: '설명'}. 3~5단계.",
        },
    },
    "flow-arrow-horizontal": {
        "required": ["steps"], "optional": [],
        "slot_desc": {
            "steps": "흐름 배열. 각 항목: {label: '단계명'}. 3~5개. 화살표로 연결됨.",
        },
    },
    "keyword-circle-row": {
        "required": ["keywords"], "optional": [],
        "slot_desc": {
            "keywords": "키워드 배열. 각 항목: {letter: '약어 (G)', label: '풀네임', description: '설명'}. 3~5개.",
        },
    },
    # emphasis/ (10개)
    "quote-big-mark": {
        "required": ["quote_text"], "optional": ["source"],
        "slot_desc": {
            "quote_text": "인용할 본문 텍스트 (핵심 발언, 1~3문장)",
            "source": "출처 (예: 국토교통부, 2024). 꼭지 제목이 아님!",
        },
    },
    "quote-question": {
        "required": ["question"], "optional": ["description"],
        "slot_desc": {
            "question": "독자에게 던지는 질문 (1문장, 물음표로 끝)",
            "description": "질문에 대한 부연 (1~2줄)",
        },
    },
    "comparison-2col": {
        "required": ["left_title", "left_content", "right_title", "right_content"],
        "optional": ["left_subtitle", "right_subtitle"],
        "slot_desc": {
            "left_title": "왼쪽 개념 제목 (파란색)",
            "left_content": "왼쪽 본문 (불릿 또는 문장)",
            "right_title": "오른쪽 개념 제목 (빨간색)",
            "right_content": "오른쪽 본문 (불릿 또는 문장)",
            "left_subtitle": "왼쪽 보조 제목",
            "right_subtitle": "오른쪽 보조 제목",
        },
    },
    "banner-gradient": {
        "required": ["text"], "optional": ["sub_text"],
        "slot_desc": {
            "text": "핵심 결론 한 줄 (굵은 대형 텍스트. 가장 중요한 메시지)",
            "sub_text": "부연 설명 (작은 보조 텍스트. text보다 덜 중요)",
        },
    },
    "dark-bullet-list": {
        "required": ["bullets"], "optional": ["title"],
        "slot_desc": {
            "title": "리스트 상단 제목 (파란색, 선택)",
            "bullets": "불릿 항목 배열: ['핵심 포인트 1', '핵심 포인트 2']. 3~5개.",
        },
    },
    "highlight-strip": {
        "required": ["segments"], "optional": [],
        "slot_desc": {
            "segments": "색상 구간 배열. 각 구간: {label: '카테고리명', color: '색상'}. 3~5개.",
        },
    },
    "callout-solution": {
        "required": ["title", "description"], "optional": ["icon", "source"],
        "slot_desc": {
            "title": "솔루션/방향성 제목",
            "description": "상세 설명 (2~3줄)",
            "icon": "아이콘 이모지 (예: 💡)",
            "source": "출처 (있으면)",
        },
    },
    "callout-warning": {
        "required": ["title", "description"], "optional": ["icon"],
        "slot_desc": {
            "title": "문제점/경고 제목",
            "description": "상세 설명 (2~3줄)",
            "icon": "아이콘 이모지 (예: ⚠️)",
        },
    },
    "tab-label-row": {
        "required": ["tabs"], "optional": [],
        "slot_desc": {
            "tabs": "탭 배열. 각 탭: {label: '탭 이름', active: true/false}. 3~5개. 하나만 active.",
        },
    },
    "divider-text": {
        "required": ["text"], "optional": [],
        "slot_desc": {
            "text": "구분선 중앙 텍스트 (짧은 전환 문구, 1~5단어)",
        },
    },
    # media/ (5개)
    "image-row-2col": {
        "required": ["images"], "optional": [],
        "slot_desc": {
            "images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명', caption: '캡션'}. 2장.",
        },
    },
    "image-grid-2x2": {
        "required": ["images"], "optional": [],
        "slot_desc": {
            "images": "이미지 배열. 각 항목: {src: '이미지 경로', alt: '설명'}. 4장 (2x2).",
        },
    },
    "image-side-text": {
        "required": ["image_src"], "optional": ["image_alt", "title", "description", "bullets"],
        "slot_desc": {
            "image_src": "좌측 이미지 경로",
            "image_alt": "이미지 대체 텍스트",
            "title": "우측 제목",
            "description": "우측 설명 텍스트",
            "bullets": "우측 불릿 항목 배열: ['항목1', '항목2']",
        },
    },
    "image-full-caption": {
        "required": ["src"], "optional": ["alt", "caption"],
        "slot_desc": {
            "src": "전체 너비 이미지 경로",
            "alt": "이미지 대체 텍스트",
            "caption": "이미지 하단 캡션",
        },
    },
    "image-before-after": {
        "required": ["before_src", "after_src"], "optional": ["before_label", "after_label", "caption"],
        "slot_desc": {
            "before_src": "Before 이미지 경로",
            "after_src": "After 이미지 경로",
            "before_label": "Before 라벨 (기본: Before)",
            "after_label": "After 라벨 (기본: After)",
            "caption": "비교 설명 캡션",
        },
    },
}

# ──────────────────────────────────────
# 슬라이드 물리적 제약
# ──────────────────────────────────────
# 프레임: 1280×720px, 패딩 40px×4 → 가용 1200×640px
# grid gap: 20px, header ~50px, footer ~60px
# → 본문 zone 가용 높이 ≈ 490px (640 - 50 - 20 - 60 - 20)
FRAME_AVAILABLE_HEIGHT = 490

# ──────────────────────────────────────
# 레이아웃 프리셋 정의
# zone별 budget_px = 해당 zone에 넣을 수 있는 최대 높이
# width_pct = zone의 가로 비율 (블록 선택 시 참고)
# ──────────────────────────────────────
LAYOUT_PRESETS = {
    "sidebar-right": {
        "description": "좌측 본문 흐름 + 우측 참조 사이드바",
        "grid_areas": "'header header' 'body sidebar' 'footer footer'",
        "grid_columns": "65fr 35fr",
        "grid_rows": "auto 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "body": {"desc": "flow 꼭지 배치 (위→아래 순서).", "budget_px": 490, "width_pct": 65},
            "sidebar": {"desc": "reference 꼭지. 좁으므로 card-grid 1열, 시각화 블록 금지.", "budget_px": 490, "width_pct": 35},
            "footer": {"desc": "결론 꼭지. 전체 너비.", "budget_px": 60, "width_pct": 100},
        },
    },
    "two-column": {
        "description": "대등한 2단 비교",
        "grid_areas": "'header header' 'left right' 'footer footer'",
        "grid_columns": "1fr 1fr",
        "grid_rows": "auto 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "left": {"desc": "첫 번째 비교 대상.", "budget_px": 490, "width_pct": 50},
            "right": {"desc": "두 번째 비교 대상.", "budget_px": 490, "width_pct": 50},
            "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
        },
    },
    "hero-detail": {
        "description": "고강조 1개 + 보조 상세",
        "grid_areas": "'header header' 'hero hero' 'detail detail' 'footer footer'",
        "grid_columns": "1fr 1fr",
        "grid_rows": "auto 2fr 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "hero": {"desc": "고강조 꼭지 (크게).", "budget_px": 310, "width_pct": 100},
            "detail": {"desc": "나머지 보조 꼭지.", "budget_px": 155, "width_pct": 100},
            "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
        },
    },
    "single-column": {
        "description": "단일 컬럼 순차 배치",
        "grid_areas": "'header' 'body' 'footer'",
        "grid_columns": "1fr",
        "grid_rows": "auto 1fr auto",
        "zones": {
            "header": {"desc": "슬라이드 제목. 자동 크기.", "budget_px": 50, "width_pct": 100},
            "body": {"desc": "모든 꼭지 위→아래 순서.", "budget_px": 490, "width_pct": 100},
            "footer": {"desc": "결론 꼭지.", "budget_px": 60, "width_pct": 100},
        },
    },
}


# ──────────────────────────────────────
# Step A: 프리셋 선택 (규칙 기반)
# ──────────────────────────────────────
def select_preset(analysis: dict[str, Any]) -> str:
    """실장의 role 분석을 보고 레이아웃 프리셋을 자동 선택한다.

    LLM 호출 불필요. 규칙 기반.
    """
    topics = analysis.get("topics", [])

    has_reference = any(
        t.get("role") == "reference" for t in topics
    )
    flow_topics = [t for t in topics if t.get("role", "flow") == "flow"]
    high_emphasis = [t for t in flow_topics if t.get("emphasis")]

    # reference 꼭지가 있으면 sidebar
    if has_reference:
        preset = "sidebar-right"
    # flow 꼭지가 정확히 2개이고 대등 비교이면 two-column
    elif (
        len(flow_topics) == 2
        and all(t.get("layer") == "core" for t in flow_topics)
    ):
        preset = "two-column"
    # 고강조 1개 + 나머지가 보조이면 hero
    elif (
        len(high_emphasis) == 1
        and len(flow_topics) >= 3
    ):
        preset = "hero-detail"
    # 기본: single-column
    else:
        preset = "single-column"

    logger.info(f"[Step A] 프리셋 선택: {preset}")
    return preset


# ──────────────────────────────────────
# Step B: 프리셋 내 블록 매핑 (Sonnet)
# ──────────────────────────────────────
def _get_registered_block_ids() -> set[str]:
    """catalog.yaml에 등록된 블록 ID 집합을 반환한다."""
    catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
    if not catalog_path.exists():
        return set(BLOCK_SLOTS.keys())
    try:
        with open(catalog_path, encoding="utf-8") as f:
            data = yaml.safe_load(f)
        return {
            b["id"] for b in data.get("blocks", [])
            if b.get("id") and not b.get("id", "").replace("-", "").isdigit()
        }
    except Exception:
        return set(BLOCK_SLOTS.keys())


def _load_catalog() -> str:
    """catalog.yaml 로드."""
    catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
    if catalog_path.exists():
        return catalog_path.read_text(encoding="utf-8")

    return """사용 가능한 블록:
- quote-question: 질문형 강조. 문제 제기, 전환점.
- compare-pill-pair: 2개 키워드 시각 대비.
- comparison-2col: 2항목 비교.
- card-icon-desc: 아이콘+설명 카드.
- card-dark-overlay: 다크 배경 키워드 카드.
- venn-diagram: 벤 다이어그램. 포함/상위-하위 관계.
- process-horizontal: 단계 흐름. 절차.
- topic-left-right: 꼭지 제목+설명.
- banner-gradient: 섹션 강조 배너."""


# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.
# STEP_B_PROMPT, _fallback_layout, PURPOSE_FALLBACK, DOWNGRADE_MAP, _downgrade_fallback 삭제.
# Step B(Sonnet) 제거됨 — Phase O에서 Kei 확정 + 코드 검증으로 대체.


async def _opus_batch_recommend(
    analysis: dict[str, Any],
    faiss_candidates: dict[int, list[dict]],
    container_specs: dict | None = None,
) -> dict[int, str]:
    """Phase P: 전체 topic을 한꺼번에 보여주고 topic별 Opus 추천 1개씩 받는다.

    FAISS 후보 2개를 함께 보여주고, Opus가 도메인 지식으로 다른 1개를 추천.
    1회 Kei API 호출로 전체 topic 처리.

    Returns:
        {topic_id: block_type} — 각 topic별 Opus 추천 블록
    """
    import httpx
    from src.sse_utils import stream_sse_tokens

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

    # 각 topic의 정보 + FAISS 후보 정리
    topic_sections = []
    for topic in analysis.get("topics", []):
        tid = topic.get("id")
        faiss_blocks = faiss_candidates.get(tid, [])
        faiss_ids = [b["id"] for b in faiss_blocks]

        # 컨테이너 제약 정보
        container_info = ""
        if container_specs:
            from src.space_allocator import find_container_for_topic
            spec = find_container_for_topic(tid, container_specs)
            if spec:
                per_topic = spec.height_px // max(1, len(spec.topic_ids))
                container_info = f"컨테이너: {per_topic}px, 허용 height_cost: {spec.max_height_cost} 이하"

        topic_sections.append(
            f"- 꼭지 {tid}: {topic.get('title', '')}\n"
            f"  purpose: {topic.get('purpose', '')}\n"
            f"  relation_type: {topic.get('relation_type', '')}\n"
            f"  expression_hint: {topic.get('expression_hint', '')}\n"
            f"  FAISS 후보: {faiss_ids}\n"
            f"  {container_info}"
        )

    prompt = (
        "아래 각 꼭지에 대해 FAISS가 추천한 블록 2개를 참고하되,\n"
        "도메인 지식을 활용하여 **FAISS 후보에 없는 다른 블록 1개**를 추천해줘.\n"
        "FAISS 후보와 중복되면 안 된다.\n"
        "각 꼭지의 purpose, relation_type, expression_hint를 보고\n"
        "**콘텐츠의 의미와 목적에 가장 적합한** 블록을 추천하라.\n"
        "컨테이너 크기 제약도 반드시 고려하라.\n\n"
        f"## 꼭지 목록\n" + "\n".join(topic_sections) +
        "\n\n## 출력 (JSON만)\n"
        '{"recommendations": [{"topic_id": 1, "block_type": "...", "reason": "..."}]}'
    )

    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"[Phase P] Opus 배치 추천 HTTP {response.status_code}")
                    return {}
                full_text = await stream_sse_tokens(response)

        if not full_text:
            return {}

        result = _parse_json(full_text)
        if result and "recommendations" in result:
            mapping = {}
            for rec in result["recommendations"]:
                tid = rec.get("topic_id") or rec.get("id")
                if tid is not None:
                    mapping[tid] = rec.get("block_type", "")
            logger.info(f"[Phase P] Opus 배치 추천: {mapping}")
            return mapping

        logger.warning(f"[Phase P] Opus 배치 추천 JSON 파싱 실패: {full_text[:200]}")
        return {}

    except Exception as e:
        logger.warning(f"[Phase P] Opus 배치 추천 실패: {e}")
        return {}


async def _opus_block_recommendation(
    analysis: dict[str, Any],
    block_candidates: str,
    preset_name: str,
    preset: dict[str, Any],
    container_specs: dict | None = None,
) -> dict[str, Any] | None:
    """Phase O: Kei(Opus)가 컨테이너 제약을 보고 블록을 확정한다.

    Kei API를 통해 Opus가 사고하여:
    - 컨테이너 크기(px)에 맞는 블록 선정
    - height_cost가 컨테이너보다 큰 블록은 선택 금지
    - 도메인 지식 기반 판단

    반드시 Kei API 경유. Anthropic 직접 호출 절대 금지.
    """
    import httpx

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

    zone_desc = "\n".join(
        f"- {name}: {z['desc']} [높이: ~{z['budget_px']}px, 너비: {z['width_pct']}%]"
        for name, z in preset["zones"].items()
    )

    topics_text = "\n".join(
        f"- 꼭지 {t.get('id', '?')}: {t.get('title', '')} "
        f"[{t.get('layer', '?')}, {t.get('role', 'flow')}, 강조:{t.get('emphasis', False)}]"
        for t in analysis.get("topics", [])
    )

    # Phase O: 컨테이너 제약 텍스트
    container_text = ""
    if container_specs:
        from src.space_allocator import ContainerSpec
        lines = ["## 컨테이너 제약 (반드시 준수)\n각 꼭지는 아래 컨테이너 안에 들어가야 한다. height_cost가 허용 범위를 초과하면 선택 금지.\n"]
        for role, spec in container_specs.items():
            for tid in spec.topic_ids:
                lines.append(
                    f"- 꼭지 {tid}: 컨테이너 {spec.height_px}px × {spec.width_px}px, "
                    f"허용 height_cost: **{spec.max_height_cost} 이하**, "
                    f"최대 항목 수: {spec.block_constraints.get('max_items', '?')}개"
                )
        container_text = "\n".join(lines) + "\n\n"

    prompt = (
        f"슬라이드 디자인 블록 추천을 해줘.\n\n"
        f"## 프리셋: {preset_name}\n{preset['description']}\n\n"
        f"## Zone 구조 (반드시 이 zone에 배정하라)\n{zone_desc}\n\n"
        f"## Zone 배정 규칙 (절대 규칙)\n"
        f"- flow 꼭지 → body / left / hero zone\n"
        f"- reference 꼭지 → sidebar zone\n"
        f"- conclusion 꼭지 → **반드시 footer zone** (banner-gradient 권장)\n"
        f"- sidebar(35%)에는 시각화 블록 금지\n\n"
        f"{container_text}"
        f"## 꼭지 목록\n{topics_text}\n\n"
        f"## 블록 후보 (FAISS 검색 결과)\n{block_candidates}\n\n"
        f"## 요청\n"
        f"각 꼭지에 가장 적합한 블록을 추천해줘.\n"
        f"컨테이너 높이(px)와 허용 height_cost를 반드시 확인하고,\n"
        f"도메인 지식을 활용하여 콘텐츠 성격에 맞는 블록을 선택해.\n\n"
        f"## 출력 형식 (JSON만)\n"
        f'{{"recommendations": ['
        f'{{"topic_id": 1, "block_type": "...", "area": "...", '
        f'"reason": "도메인 관점에서 이 블록이 적합한 이유", '
        f'"size_guide": "compact|medium|large"}}]}}'
    )

    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"[Step A-2] Kei API HTTP {response.status_code}")
                    return None

                full_text = await stream_sse_tokens(response)

            if not full_text:
                logger.warning("[Step A-2] Kei API 응답 텍스트 없음")
                return None

            result = _parse_json(full_text)
            if result and "recommendations" in result:
                logger.info(
                    f"[Step A-2] Opus 블록 추천 완료: "
                    f"{len(result['recommendations'])}개"
                )
                return result

            logger.warning(f"[Step A-2] JSON 파싱 실패: {full_text[:200]}")
            return None

    except Exception as e:
        logger.warning(f"[Step A-2] Kei API 호출 실패: {e}")
        return None



async def create_layout_concept(
    content: str,
    analysis: dict[str, Any],
    container_specs: dict | None = None,
) -> dict[str, Any]:
    """2단계: Step A(프리셋) + Step B(블록 매핑).

    Args:
        content: 원본 텍스트
        analysis: 1단계 실장의 꼭지 분석 결과

    Returns:
        레이아웃 컨셉 JSON
    """
    # Step A: 프리셋 선택 (규칙 기반)
    preset_name = select_preset(analysis)
    preset = LAYOUT_PRESETS[preset_name]

    # P2-A: FAISS 검색으로 관련 블록만 추출
    from src.block_search import search_blocks_for_topics
    topics = analysis.get("topics", [])
    catalog_text = search_blocks_for_topics(topics, top_k_per_topic=3, total_max=10)
    logger.info(f"[Step A] 블록 후보 검색 완료 (FAISS)")

    # Phase N-1: Step A-2 — Kei(Opus)가 블록 확정. Sonnet은 zone + char_guide만.
    opus_recommendation = await _opus_block_recommendation(
        analysis, catalog_text, preset_name, preset,
        container_specs=container_specs,
    )

    # Kei 확정 블록 매핑 (topic_id → block_type)
    kei_confirmed_blocks: dict[int, str] = {}
    kei_confirmed_areas: dict[int, str] = {}
    if opus_recommendation and opus_recommendation.get("recommendations"):
        recs = opus_recommendation["recommendations"]
        for rec in recs:
            # Kei가 topic_id 또는 id로 응답할 수 있으므로 양쪽 체크
            tid = rec.get("topic_id") or rec.get("id")
            if tid is not None:
                kei_confirmed_blocks[tid] = rec.get("block_type", "")
                kei_confirmed_areas[tid] = rec.get("area", "")
        logger.info(f"[Step A-2] Kei 블록 확정: {kei_confirmed_blocks}")
    else:
        # Kei API 필수. 응답 없으면 성공할 때까지 무한 재시도.
        import asyncio
        RETRY_INTERVAL = 10
        attempt = 0
        while not opus_recommendation or not opus_recommendation.get("recommendations"):
            attempt += 1
            logger.warning(f"[Step A-2] Kei API 응답 없음 (시도 {attempt}). {RETRY_INTERVAL}초 후 재시도...")
            await asyncio.sleep(RETRY_INTERVAL)
            opus_recommendation = await _opus_block_recommendation(
                analysis, catalog_text, preset_name, preset
            )
        # 재시도 성공 → 확정 블록 매핑
        for rec in opus_recommendation["recommendations"]:
            tid = rec.get("topic_id") or rec.get("id")
            if tid is not None:
                kei_confirmed_blocks[tid] = rec.get("block_type", "")
                kei_confirmed_areas[tid] = rec.get("area", "")
        logger.info(f"[Step A-2] Kei 블록 확정 (재시도 후): {kei_confirmed_blocks}")

    # Phase O: Kei 확정 블록 + 코드 검증으로 직접 layout_concept 생성
    # Step B(Sonnet) 제거됨 — Kei가 블록/zone을 확정, 코드가 스펙 계산

    blocks = []
    registered_ids = _get_registered_block_ids()
    valid_zones = {z for z in preset["zones"] if z != "header"}
    default_zone = "body" if "body" in valid_zones else next(iter(valid_zones))

    for topic in topics:
        tid = topic.get("id")
        role = topic.get("role", "flow")

        # 블록 타입: Kei 확정값
        block_type = kei_confirmed_blocks.get(tid, "topic-left-right")

        # 블록 ID 검증: catalog에 없으면 에러 로그 (fallback 없음)
        if block_type not in registered_ids:
            logger.error(f"[블록 검증] Kei 확정 블록 '{block_type}'이 catalog에 없음. topic {tid}")
            block_type = "topic-left-right"  # 최소 안전 블록

        # zone 배치: Kei 확정값 → 검증
        area = kei_confirmed_areas.get(tid, "")
        if not area or area not in valid_zones:
            # Kei가 area를 안 줬으면 role에서 결정
            if role == "reference" and "sidebar" in valid_zones:
                area = "sidebar"
            elif topic.get("layer") == "conclusion" and "footer" in valid_zones:
                area = "footer"
            else:
                area = default_zone

        # conclusion 꼭지 → footer 강제
        if topic.get("layer") == "conclusion" and "footer" in valid_zones:
            area = "footer"

        # body/sidebar 금지 블록 검증
        if area in ("body", "left", "right", "hero", "detail") and block_type in BODY_FORBIDDEN_MAP:
            replacement = BODY_FORBIDDEN_MAP[block_type]
            if replacement:
                logger.warning(f"[블록 검증] body 금지 '{block_type}' → '{replacement}'")
                block_type = replacement
            else:
                continue  # None이면 삭제

        if area == "sidebar" and block_type in SIDEBAR_FORBIDDEN_BLOCKS:
            replacement = SIDEBAR_FORBIDDEN_BLOCKS[block_type]
            if replacement:
                logger.warning(f"[블록 검증] sidebar 금지 '{block_type}' → '{replacement}'")
                block_type = replacement
            else:
                continue

        blocks.append({
            "area": area,
            "type": block_type,
            "topic_id": tid,
            "purpose": topic.get("purpose", ""),
            "reason": kei_confirmed_blocks.get(tid, ""),
            "size": "medium",
        })

    # Phase N-2: sidebar에 reference 블록이 있으면 section label 자동 삽입
    sidebar_blocks = [b for b in blocks if b.get("area") == "sidebar"]
    if sidebar_blocks:
        first_sidebar = sidebar_blocks[0]
        sidebar_topic = next(
            (t for t in topics if t.get("id") == first_sidebar.get("topic_id")),
            None,
        )
        section_title = ""
        if sidebar_topic:
            section_title = sidebar_topic.get("section_title", "")
        if not section_title:
            purpose = first_sidebar.get("purpose", "")
            section_title = {
                "용어정의": "용어 정의",
                "근거사례": "참고 자료",
            }.get(purpose, "")

        if section_title:
            first_sidebar_idx = next(
                i for i, b in enumerate(blocks) if b.get("area") == "sidebar"
            )
            blocks.insert(first_sidebar_idx, {
                "area": "sidebar",
                "type": "divider-text",
                "topic_id": None,
                "purpose": "_label",
                "data": {"text": section_title},
                "size": "compact",
                "_is_label": True,
            })
            logger.info(f"[N-2] sidebar 섹션 제목 삽입: '{section_title}'")

    # zone별 height_cost 합산 검증
    overflows = _validate_height_budget(blocks, preset)

    logger.info(
        f"[레이아웃] 블록 배치 완료: {preset_name}, {len(blocks)}개 블록"
        + (f", overflow {len(overflows)}건" if overflows else "")
    )

    result = {
        "title": analysis.get("title", "슬라이드"),
        "pages": [{
            "grid_areas": preset["grid_areas"],
            "grid_columns": preset["grid_columns"],
            "grid_rows": preset["grid_rows"],
            "blocks": blocks,
        }],
    }
    if overflows:
        result["overflow"] = overflows
    return result


# height_cost → px 변환 (결정론적)
HEIGHT_COST_PX = {
    "compact": 70,
    "medium": 150,
    "large": 250,
    "xlarge": 400,
}

# body/sidebar/footer zone에서 사용 금지인 블록 → 교체
BODY_FORBIDDEN_MAP = {
    "section-title-with-bg": "topic-center",  # 500px 블록 → compact 헤더로
    "section-header-bar": None,  # body에서 제거 — header에 이미 slide-title 있음 (J-2)
}

# Phase M: 블록-zone 적합성 맵
# sidebar(35% 너비)에서 사용 불가한 블록 → 대체 블록
SIDEBAR_FORBIDDEN_BLOCKS = {
    "card-compare-3col": "card-numbered",
    "card-dark-overlay": "card-numbered",
    "card-icon-desc": "card-numbered",
    "card-image-3col": "card-numbered",
    "card-image-round": "card-numbered",
    "card-stat-number": "card-numbered",
    "card-tag-image": "card-numbered",
    "comparison-2col": "dark-bullet-list",
    "compare-2col-split": "dark-bullet-list",
    "compare-pill-pair": "dark-bullet-list",
    "section-title-with-bg": None,
    "section-header-bar": None,
    "topic-center": "topic-left-right",
    "quote-big-mark": "quote-question",
    "image-full-caption": "image-row-2col",
}


def _get_block_height(block_type: str) -> int:
    """블록 타입의 height_cost를 px로 반환."""
    catalog_map = _load_catalog_map_for_height()
    cost_label = catalog_map.get(block_type, "medium")
    return HEIGHT_COST_PX.get(cost_label, 150)


def _load_catalog_map_for_height() -> dict[str, str]:
    """catalog.yaml에서 id → height_cost 매핑을 로드."""
    catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
    if not catalog_path.exists():
        return {}
    try:
        with open(catalog_path, encoding="utf-8") as f:
            data = yaml.safe_load(f)
        return {b["id"]: b.get("height_cost", "medium") for b in data.get("blocks", [])}
    except Exception:
        return {}


def _load_catalog_purpose_fit() -> dict[str, list[str]]:
    """catalog.yaml에서 id → purpose_fit 매핑을 로드."""
    catalog_path = Path(__file__).parent.parent / "templates" / "catalog.yaml"
    if not catalog_path.exists():
        return {}
    try:
        with open(catalog_path, encoding="utf-8") as f:
            data = yaml.safe_load(f)
        return {
            b["id"]: b.get("purpose_fit", [])
            for b in data.get("blocks", [])
        }
    except Exception:
        return {}


def _validate_purpose_fit(blocks: list[dict]) -> int:
    """각 블록의 purpose_fit을 검증하고, 불일치 시 대체한다.

    Returns:
        교체된 블록 수.
    """
    purpose_fit_map = _load_catalog_purpose_fit()
    replaced = 0

    for block in blocks:
        block_type = block.get("type", "")
        purpose = block.get("purpose", "")
        if not block_type or not purpose:
            continue

        allowed_purposes = purpose_fit_map.get(block_type, [])
        # purpose_fit이 빈 리스트면 범용 블록 → 검증 스킵
        if not allowed_purposes:
            continue

        if purpose not in allowed_purposes:
            # Kei가 확정한 블록이므로 경고만 출력. 강제 교체 안 함.
            logger.warning(
                f"[purpose_fit 검증] '{block_type}'의 purpose_fit={allowed_purposes}에 "
                f"'{purpose}' 없음 — Kei 확정이므로 유지"
            )

    return replaced


def _validate_height_budget(blocks: list[dict], preset: dict) -> list[dict]:
    """zone별 height_cost 합산을 검증한다. (I-9 개정)

    금지 블록 교체, pill-pair 단독 검증은 수행하되,
    높이 초과 시 블록을 자동 교체하지 않는다.
    대신 overflow 정보를 수집하여 반환 → pipeline에서 Kei에게 판단 요청.
    Returns:
        overflow 정보 리스트. 초과 없으면 빈 리스트.
    """
    zones = preset.get("zones", {})
    gap_px = 20  # --spacing-block

    # zone별 블록 그룹핑
    zone_blocks: dict[str, list[dict]] = {}
    for block in blocks:
        area = block.get("area", "body")
        if area not in zone_blocks:
            zone_blocks[area] = []
        zone_blocks[area].append(block)

    # 금지 블록 처리: 교체 또는 삭제 (J-2: None이면 삭제)
    blocks_to_remove = []
    for block in blocks:
        area = block.get("area", "body")
        block_type = block.get("type", "")
        if area != "header" and block_type in BODY_FORBIDDEN_MAP:
            replacement = BODY_FORBIDDEN_MAP[block_type]
            if replacement is None:
                blocks_to_remove.append(block)
                logger.warning(
                    f"[금지 블록 삭제] {block_type} (area={area})"
                )
            else:
                block["type"] = replacement
                logger.warning(
                    f"[금지 블록 교체] {block_type} → {replacement} (area={area})"
                )
    for block in blocks_to_remove:
        blocks.remove(block)

    # 삭제 후 zone_blocks 재구성 (후속 pill-pair/높이 체크에 반영)
    zone_blocks.clear()
    for block in blocks:
        area = block.get("area", "body")
        if area not in zone_blocks:
            zone_blocks[area] = []
        zone_blocks[area].append(block)

    # Phase M: sidebar 블록-zone 적합성 검증 (P-6)
    for block in blocks:
        if block.get("area") == "sidebar" and block.get("type") in SIDEBAR_FORBIDDEN_BLOCKS:
            replacement = SIDEBAR_FORBIDDEN_BLOCKS[block["type"]]
            if replacement is None:
                logger.warning(f"[zone 적합성] sidebar에서 {block['type']} 삭제")
            else:
                logger.warning(f"[zone 적합성] sidebar: {block['type']} → {replacement}")
                block["type"] = replacement

    # sidebar 카드 블록 1열 강제 (J-6)
    CARD_BLOCKS = {
        "card-tag-image", "card-icon-desc", "card-image-3col",
        "card-dark-overlay", "card-compare-3col", "card-image-round",
        "card-stat-number",
    }
    for block in blocks:
        if block.get("area") == "sidebar" and block.get("type") in CARD_BLOCKS:
            if "data" not in block:
                block["data"] = {}
            block["data"]["column_override"] = 1

    # compare-pill-pair 단독 사용 금지 (I-7)
    COMPARISON_BLOCKS = {"compare-2col-split", "compare-3col-badge", "comparison-2col"}
    for area, area_blocks in zone_blocks.items():
        types = {b.get("type") for b in area_blocks}
        if "compare-pill-pair" in types and not types & COMPARISON_BLOCKS:
            for block in area_blocks:
                if block.get("type") == "compare-pill-pair":
                    block["type"] = "comparison-2col"
                    logger.warning(
                        "[pill-pair 단독 금지] compare-pill-pair → comparison-2col"
                    )

    # 높이 예산 검증 — 초과 시 자동 조치 + overflow 정보 수집
    overflows: list[dict] = []
    for area, area_blocks in zone_blocks.items():
        zone_info = zones.get(area, {})
        budget = zone_info.get("budget_px", 490)

        total = sum(_get_block_height(b.get("type", "")) for b in area_blocks)
        total += gap_px * max(0, len(area_blocks) - 1)

        if total <= budget:
            continue

        overflow_px = total - budget

        # footer 초과 자동 조치: banner-gradient의 sub_text 제거로 높이 축소
        if area == "footer" and overflow_px <= 30:
            for block in area_blocks:
                if block.get("type") == "banner-gradient":
                    if "data" not in block:
                        block["data"] = {}
                    block["data"]["_strip_sub_text"] = True
                    logger.info(
                        f"[높이 자동 조치] footer 초과 {overflow_px}px → "
                        f"banner-gradient sub_text 제거"
                    )
            # sub_text 제거 시 compact(50px)로 줄어들므로 재계산
            total_after = sum(
                50 if (b.get("type") == "banner-gradient" and b.get("data", {}).get("_strip_sub_text"))
                else _get_block_height(b.get("type", ""))
                for b in area_blocks
            )
            total_after += gap_px * max(0, len(area_blocks) - 1)
            if total_after <= budget:
                continue  # 조치 후 예산 이내 → overflow 아님

        logger.warning(
            f"[높이 예산 초과] {area}: {total}px > {budget}px. "
            f"블록: {[b.get('type') for b in area_blocks]}"
        )

        overflows.append({
            "area": area,
            "overflow_px": total - budget,
            "budget_px": budget,
            "total_px": total,
            "blocks": [
                {
                    "type": b.get("type", ""),
                    "purpose": b.get("purpose", ""),
                    "topic_id": b.get("topic_id"),
                    "height_px": _get_block_height(b.get("type", "")),
                }
                for b in area_blocks
            ],
        })

    return overflows


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

    Kei API가 마크다운 리스트 접두사(- )를 붙여 응답하는 경우에도 처리.
    """
    # 전처리: 각 줄 앞의 마크다운 리스트 접두사(- ) 제거
    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
