"""Phase T: 11-Stage 파이프라인.

Stage 0:   MDX 표준화 (코드)
Stage 1A:  Kei 꼭지 추출 (AI)
Stage 1B:  컨셉 구체화 (AI)
Stage 1.5a: 폰트 위계 + 컨테이너 비율 역산 (코드)
Stage 1.7: 참고 블록 선택 (코드)
Stage 1.5b: 디자인 예산 재계산 (코드)
Stage 2:   HTML 생성 (AI — Claude Sonnet)
Stage 3:   렌더링 조립 (코드)
Stage 4:   품질 게이트 (Selenium + Opus Vision)
Stage 5:   서빙 (코드)

이전 Phase S 파이프라인은 generate_slide_legacy()로 보존.
"""
from __future__ import annotations

import asyncio
import json
import logging
import re
import time
from pathlib import Path
from typing import Any, AsyncIterator

import anthropic

from src.pipeline_context import (
    PipelineContext, create_context, StageFailure, build_retry_feedback,
    Topic, NormalizedContent, Analysis, PageStructure,
    ContainerInfo, TextBudget, DesignBudget, FontHierarchy, BlockReference,
)
from src.kei_client import classify_content, refine_concepts, generate_structured_text
from src.design_director import LAYOUT_PRESETS, select_preset
from src.image_utils import get_image_sizes, embed_images
from src.space_allocator import calculate_container_specs
from src.slide_measurer import measure_rendered_heights, capture_slide_screenshot
from src.config import settings

logger = logging.getLogger(__name__)

# Kei API 재시도 설정 (P0 수정: 무한 루프 방지)
KEI_RETRY_INTERVAL = 10
KEI_MAX_RETRY_ATTEMPTS = 30       # 최대 30회 (5분)
KEI_MAX_RETRY_DURATION = 300      # 절대 제한 300초


# ══════════════════════════════════════
# T-0.B: run_stage() 공통 실행 패턴
# ══════════════════════════════════════

async def run_stage(
    stage_fn,
    context: PipelineContext,
    stage_name: str,
    max_retries: int = 0,
) -> PipelineContext:
    """모든 Stage의 공통 실행 패턴.

    transform(context) → validate(result) → update(context) → snapshot()

    Args:
        stage_fn: async def stage(context) -> dict.
                  성공 시 업데이트할 필드 dict 반환.
                  실패 시 dict에 "_errors" 키 포함.
        context: 현재 누적 컨텍스트
        stage_name: 스냅샷 파일명 + 로그용
        max_retries: RETRYABLE 에러 시 재시도 횟수. 코드 Stage는 0.
    """
    for attempt in range(max_retries + 1):
        result = await stage_fn(context)
        errors = result.pop("_errors", [])

        if not errors:
            # 성공: 컨텍스트에 병합 + 스냅샷
            context = context.model_copy(update=result)
            context.save_snapshot(stage_name)
            logger.info(f"[{stage_name}] 완료")
            return context

        # 에러 처리
        severity = errors[0].get("severity", "RETRYABLE") if errors else "RETRYABLE"

        if severity == "FATAL":
            context.log_error(stage_name, errors, attempt, "FATAL")
            raise StageFailure(stage_name, errors)

        if severity == "ADJUSTABLE":
            # 코드로 자동 조정 — _adjustments 키에 조정된 값이 있으면 적용
            adjustments = result.pop("_adjustments", {})
            if adjustments:
                context = context.model_copy(update=adjustments)
                context.warnings.append(f"[{stage_name}] 자동 조정: {errors}")
                context.save_snapshot(stage_name)
                logger.warning(f"[{stage_name}] ADJUSTABLE 자동 조정 적용")
                return context

        # RETRYABLE
        context.log_error(stage_name, errors, attempt, "RETRYABLE")

        if attempt < max_retries:
            # Self-Refine 피드백 생성
            context.retry_feedback = build_retry_feedback(
                stage_name, errors, context.raw_content[:500]
            )
            logger.warning(
                f"[{stage_name}] 재시도 {attempt + 1}/{max_retries}: "
                f"{[e.get('localization', '') for e in errors]}"
            )
        else:
            logger.error(f"[{stage_name}] 재시도 소진 ({max_retries}회)")

    raise StageFailure(stage_name, errors)


# ══════════════════════════════════════
# T-0.C: Phase T 파이프라인 (11 Stage)
# ══════════════════════════════════════

async def generate_slide(
    content: str,
    manual_layout: dict[str, Any] | None = None,
    base_path: str = "",
) -> AsyncIterator[dict[str, str]]:
    """Phase T 파이프라인: MDX → 슬라이드 HTML.

    11 Stage를 순차 실행하며 SSE progress 이벤트를 yield.
    각 Stage는 run_stage() 패턴을 따름.
    """
    ctx = create_context(content, base_path)

    try:
        # ── Stage 0: MDX 표준화 ──
        yield {"event": "progress", "data": "0/7 MDX 표준화 중..."}

        async def stage_0(context: PipelineContext) -> dict:
            from src.mdx_normalizer import normalize_mdx_content, validate_stage0

            result = normalize_mdx_content(context.raw_content)
            errors = validate_stage0(result, context.raw_content)
            if errors:
                return {"_errors": errors}

            return {
                "normalized": NormalizedContent(
                    clean_text=result["clean_text"],
                    title=result["title"],
                    images=result["images"],
                    popups=result["popups"],
                    tables=result["tables"],
                    sections=result["sections"],
                ),
            }

        ctx = await run_stage(stage_0, ctx, "stage_0", max_retries=0)

        # ── Stage 1A: Kei 꼭지 추출 ──
        yield {"event": "progress", "data": "1/7 Kei 실장이 꼭지를 추출 중..."}

        async def stage_1a(context: PipelineContext) -> dict:
            if manual_layout:
                analysis_raw = manual_layout
            else:
                # Stage 0에서 정규화된 텍스트를 Kei에게 전달 (JSX/frontmatter 제거됨)
                input_text = context.normalized.clean_text or context.raw_content
                analysis_raw = await _retry_kei(classify_content, input_text)

            topics_raw = analysis_raw.get("topics", [])
            # Kei 응답에 있는 키만 전달, 없는 건 Pydantic 기본값 사용
            topics = [Topic(**{k: v for k, v in t.items() if k in Topic.model_fields}) for t in topics_raw]

            page_struct_raw = analysis_raw.get("page_structure", {})
            page_structure = PageStructure(roles=page_struct_raw)

            # X'-1: 제목은 원본 MDX frontmatter에서 가져옴 (Kei가 바꾸지 않음)
            original_title = context.normalized.title or analysis_raw.get("title", "")
            analysis = Analysis(
                core_message=analysis_raw.get("core_message", ""),
                title=original_title,
                total_pages=analysis_raw.get("total_pages", 1),
                layout_template=analysis_raw.get("layout_template", "A"),
            )

            # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
            if topics:
                from difflib import SequenceMatcher
                similarity = SequenceMatcher(
                    None, analysis.title, topics[0].title
                ).ratio()
                if similarity > 0.7:
                    topics[0].title = f"{topics[0].purpose}: {topics[0].summary[:30]}"
                    logger.warning(f"[제목 중복 교정] 유사도 {similarity:.0%}")

            # T-2: Stage 1A 검증 (Pydantic + 원본 대조)
            from src.validators import validate_stage_1a
            validation_errors = validate_stage_1a(
                analysis_raw,
                context.normalized.clean_text,
            )
            if validation_errors:
                return {"_errors": validation_errors}

            return {
                "analysis": analysis,
                "topics": topics,
                "page_structure": page_structure,
            }

        ctx = await run_stage(stage_1a, ctx, "stage_1a", max_retries=2)

        # ── Stage 1B: 컨셉 구체화 ──
        yield {"event": "progress", "data": "1.5/7 컨셉 구체화 중..."}

        async def stage_1b(context: PipelineContext) -> dict:
            # manual_layout에서 이미 source_data/structured_text가 있으면 스킵
            has_source = any(t.source_data for t in context.topics)
            if has_source:
                logger.info("[1단계-B] manual_layout에서 source_data 제공됨 — Kei 호출 스킵")
                return {"topics": list(context.topics)}

            # Kei에게 원본 + 1A 결과 전달
            analysis_dict = {
                "topics": [t.model_dump() for t in context.topics],
                "page_structure": context.page_structure.roles,
                "core_message": context.analysis.core_message,
                "title": context.analysis.title,
            }
            input_text = context.normalized.clean_text or context.raw_content
            refined = await refine_concepts(input_text, analysis_dict)
            if refined is None:
                return {"_errors": [{"severity": "RETRYABLE", "localization": "refine_concepts 반환 None"}]}

            # 1B 결과를 topics에 병합
            refined_topics = refined.get("topics", [])
            updated_topics = []
            for t in context.topics:
                match = next((rt for rt in refined_topics if rt.get("id") == t.id), None)
                if match:
                    updated = t.model_copy(update={
                        "relation_type": match.get("relation_type", t.relation_type),
                        "expression_hint": match.get("expression_hint", t.expression_hint),
                        "source_data": match.get("source_data", t.source_data),
                    })
                    updated_topics.append(updated)
                else:
                    updated_topics.append(t)

            # T-2: Stage 1B 검증 (모순 탐지 + 원본 대조)
            from src.validators import validate_stage_1b
            validation_errors = validate_stage_1b(
                [t.model_dump() for t in updated_topics],
                context.normalized.clean_text,
                raw_content=context.raw_content,
                layout_template=context.analysis.layout_template,
            )
            if validation_errors:
                return {"_errors": validation_errors}

            return {"topics": updated_topics}

        ctx = await run_stage(stage_1b, ctx, "stage_1b", max_retries=2)

        # ── Stage 1B 보완: structured_text 생성 (별도 Kei 호출) ──
        has_structured = any(t.structured_text for t in ctx.topics)
        if has_structured:
            logger.info("[1단계-B-ST] structured_text 이미 있음 — Kei 호출 스킵")
            analysis_for_st = {"topics": [t.model_dump() for t in ctx.topics]}
        else:
            input_text = ctx.normalized.clean_text or ctx.raw_content
            analysis_for_st = {
                "topics": [t.model_dump() for t in ctx.topics],
            }
            analysis_for_st = await generate_structured_text(input_text, analysis_for_st)
        # structured_text를 topics에 병합
        st_map = {t["id"]: t.get("structured_text", "") for t in analysis_for_st["topics"]}
        updated_topics = []
        for t in ctx.topics:
            st = st_map.get(t.id, "")
            if st:
                updated_topics.append(t.model_copy(update={"structured_text": st}))
            else:
                updated_topics.append(t)
        ctx = ctx.model_copy(update={"topics": updated_topics})
        ctx.save_snapshot("stage_1b")  # structured_text 포함하여 다시 저장
        logger.info(f"[1단계-B-ST] structured_text 병합 완료")

        # ── Stage 1.5a: 컨테이너 스펙 계산 ──
        yield {"event": "progress", "data": "2/7 컨테이너 계산 중..."}

        async def stage_1_5a(context: PipelineContext) -> dict:
            from src.space_allocator import calculate_font_hierarchy, calculate_dynamic_ratio

            # 이미지 크기 측정
            image_sizes = get_image_sizes(context.raw_content, context.base_path)

            # 역할별 텍스트 양 측정
            role_text_lengths = {}
            for role, info in context.page_structure.roles.items():
                if not isinstance(info, dict):
                    continue
                role_text = context.get_role_content(role)
                role_text_lengths[role] = len(role_text)

            # T-5: 폰트 위계 확정 (텍스트 양 기반, 역할 인식)
            font_hierarchy_dict = calculate_font_hierarchy(role_text_lengths)
            font_hierarchy = FontHierarchy(
                key_msg=font_hierarchy_dict.get("핵심", 14.0),
                core=font_hierarchy_dict.get("본심", 12.0),
                bg=font_hierarchy_dict.get("배경", 11.0),
                sidebar=font_hierarchy_dict.get("첨부", 10.0),
            )

            # 프리셋 선택 (비율 계산보다 먼저 — 프리셋의 기본 비율을 fallback으로 사용)
            analysis_dict = {
                "topics": [t.model_dump() for t in context.topics],
                "page_structure": context.page_structure.roles,
            }
            preset_name = select_preset(analysis_dict)
            preset = LAYOUT_PRESETS.get(preset_name, {})

            # T-5: 동적 비율 역산 (sidebar 텍스트 양 기반 + 프리셋 기본 비율)
            container_ratio = calculate_dynamic_ratio(
                role_text_lengths, font_hierarchy_dict,
                slide_width=settings.slide_width,
                slide_height=settings.slide_height,
                preset=preset,
            )
            logger.info(
                f"[T-5] 폰트 위계: 핵심={font_hierarchy.key_msg}, 본심={font_hierarchy.core}, "
                f"배경={font_hierarchy.bg}, 첨부={font_hierarchy.sidebar} / "
                f"비율: body:sidebar={container_ratio[0]}:{container_ratio[1]}"
            )

            # Phase X-B: 유형에 따라 컨테이너 생성 분기
            if context.analysis.layout_template == "B":
                from src.space_allocator import build_containers_type_b
                container_specs = build_containers_type_b(
                    page_structure=context.page_structure.roles,
                    slide_width=settings.slide_width,
                    slide_height=settings.slide_height,
                    image_sizes=image_sizes if isinstance(image_sizes, list) else (
                        [{**v, "key": k} for k, v in image_sizes.items()] if image_sizes else None
                    ),
                )
                logger.info(f"[X-B] 유형 B 컨테이너 생성")
            else:
                # 유형 A: 기존 코드 그대로
                container_specs = calculate_container_specs(
                    page_structure=context.page_structure.roles,
                    topics=[t.model_dump() for t in context.topics],
                    preset=preset,
                    slide_width=settings.slide_width,
                    slide_height=settings.slide_height,
                )

            # ContainerSpec → ContainerInfo 변환
            containers = {}
            for role, spec in container_specs.items():
                containers[role] = ContainerInfo(
                    role=spec.role,
                    zone=spec.zone,
                    topic_ids=spec.topic_ids,
                    weight=spec.weight,
                    height_px=spec.height_px,
                    width_px=spec.width_px,
                    max_height_cost=spec.max_height_cost,
                    block_constraints=spec.block_constraints,
                )

            # 이미지 정보 구성
            slide_images = []
            if image_sizes:
                import base64 as b64_mod
                # image_sizes가 list[dict]이면 직접 순회, dict이면 items()
                img_items = image_sizes if isinstance(image_sizes, list) else [
                    {**v, "key": k} for k, v in image_sizes.items()
                ]
                for img_info in img_items:
                    img_key = img_info.get("path", img_info.get("key", ""))
                    img_path = Path(context.base_path) / Path(img_key).name if context.base_path else Path(img_key)
                    img_b64 = ""
                    if img_path.exists():
                        img_b64 = b64_mod.b64encode(img_path.read_bytes()).decode()
                    slide_images.append({
                        "path": str(img_path),
                        "width": img_info.get("width", 0),
                        "height": img_info.get("height", 0),
                        "ratio": round(img_info.get("width", 1) / max(1, img_info.get("height", 1)), 2),
                        "topic_id": img_info.get("topic_id"),
                        "b64": img_b64,
                    })

            updated_analysis = context.analysis.model_copy(update={
                "image_sizes": image_sizes or {},
            })

            return {
                "preset_name": preset_name,
                "preset": preset,
                "containers": containers,
                "slide_images": slide_images,
                "analysis": updated_analysis,
                "font_hierarchy": font_hierarchy,
                "container_ratio": container_ratio,
            }

        ctx = await run_stage(stage_1_5a, ctx, "stage_1_5a", max_retries=0)

        # ── Stage 1.7: 참고 블록 선택 + 디자인 레퍼런스 ──
        yield {"event": "progress", "data": "2.5/7 참고 블록 선택 중..."}

        async def stage_1_7(context: PipelineContext) -> dict:
            from src.block_reference import select_and_generate_references

            references_raw = select_and_generate_references(
                topics=[t.model_dump() for t in context.topics],
                containers=context.containers,
                page_structure=context.page_structure.roles,
            )

            # V-1: dict → list[BlockReference] 모델 변환 (꼭지별)
            references = {}
            for role, ref_list in references_raw.items():
                references[role] = [
                    BlockReference(
                        block_id=rd["block_id"],
                        variant=rd["variant"],
                        visual_type=rd["visual_type"],
                        schema_info=rd["schema_info"],
                        design_reference_html=rd["design_reference_html"],
                        topic_id=rd.get("topic_id"),
                        supporting_topic_ids=rd.get("supporting_topic_ids", []),
                        is_hierarchical=rd.get("is_hierarchical", False),
                    )
                    for rd in ref_list
                ]

            return {"references": references}

        ctx = await run_stage(stage_1_7, ctx, "stage_1_7", max_retries=0)

        # ── Stage 1.8: 콘텐츠-컨테이너 적합성 검증 + 재배분 + 보강 ──
        yield {"event": "progress", "data": "2.8/7 적합성 검증 중..."}

        async def stage_1_8(context: PipelineContext) -> dict:
            from src.fit_verifier import (
                calculate_fit, redistribute, analyze_enhancements,
                apply_enhancements, build_escalation_report,
                build_enhancement_report, calculate_sub_layout,
                EnhancementAnalysis,
            )
            from src.block_assembler import assemble_slide_html
            from src.slide_measurer import measure_rendered_heights

            refs_dict = {}
            for role, ref_list in context.references.items():
                refs_dict[role] = [r.model_dump() for r in ref_list]

            containers_dict = {}
            for role, ci in context.containers.items():
                containers_dict[role] = {
                    "height_px": ci.height_px,
                    "width_px": ci.width_px,
                    "zone": ci.zone,
                }

            font_h = context.font_hierarchy.model_dump()
            normalized = context.normalized.model_dump()
            core_message = context.analysis.core_message

            # ── before: 비중대로 배정된 컨테이너 (현재 context.containers) ──
            run_dir = context.get_run_dir()
            steps_dir = run_dir / "steps"
            steps_dir.mkdir(parents=True, exist_ok=True)

            # before 시각화 저장 (Stage 1.5a의 초기 배정 상태)
            from src.step_visualizer import _gen_stage_1_8_fit_before
            _gen_stage_1_8_fit_before(context, steps_dir)

            logger.info(f"[Stage 1.8] before: " + ", ".join(
                f"{r}={ci.height_px}px" for r, ci in context.containers.items()
            ))

            # ── filled 전 sub_layouts 계산 (이미지/텍스트/keymsg 배치 결정) ──
            initial_fit = calculate_fit(
                topics=[t.model_dump() for t in context.topics],
                page_structure=context.page_structure.roles,
                containers=containers_dict,
                references=refs_dict,
                font_hierarchy=font_h,
                normalized=normalized,
                core_message=core_message,
            )
            empty_enhancements = EnhancementAnalysis()
            pre_sub_layouts = {}
            for role, rf in initial_fit.roles.items():
                ci = context.containers.get(role)
                if not ci or not rf.topic_fits:
                    continue
                layout = calculate_sub_layout(
                    role=role,
                    main_height_px=ci.height_px,
                    main_width_px=ci.width_px,
                    topic_fits=rf.topic_fits,
                    enhancements=empty_enhancements,
                    font_hierarchy=font_h,
                )
                pre_sub_layouts[role] = {
                    "main_height_px": layout.main_height_px,
                    "main_width_px": layout.main_width_px,
                    "sub_containers": [
                        {"name": sc.name, "width_px": sc.width_px, "height_px": sc.height_px, "align": sc.align}
                        for sc in layout.sub_containers
                    ],
                    "table_rows": layout.table_rows,
                }
            # context에 sub_layouts 반영 후 filled 생성
            context = context.model_copy(update={"sub_layouts": pre_sub_layouts})

            # ── filled: before 컨테이너에 블록+텍스트 채움 → Selenium 측정 ──

            filled_html = assemble_slide_html(context)
            (steps_dir / "stage_1_8_filled.html").write_text(
                filled_html.replace('</head><body>', '</head><body>\n'
                    '<div style="font-size:16px;font-weight:bold;margin-bottom:4px;">'
                    'Stage 1.8: filled (블록+텍스트 채운 상태)</div>\n'
                    '<div style="font-size:11px;color:#666;margin-bottom:8px;">'
                    'before 컨테이너에 블록+텍스트를 채움. 넘치는 영역 확인.</div>\n', 1),
                encoding="utf-8",
            )

            filled_measurement = await asyncio.to_thread(measure_rendered_heights, filled_html)
            logger.info(f"[Stage 1.8] filled 측정 완료")

            # ── 판단: 넘치는 영역 처리 ──
            updated_containers = dict(context.containers)  # 복사

            for zone_name, zone_data in filled_measurement.get("zones", {}).items():
                if zone_data.get("overflowed"):
                    excess = zone_data.get("excess_px", 0)
                    scroll_h = zone_data.get("scrollHeight", 0)

                    if zone_name == "sidebar":
                        # sidebar 예외: 세로 확장 허용
                        for role, ci in updated_containers.items():
                            if ci.zone == "sidebar":
                                new_h = max(ci.height_px, scroll_h + 10)  # 여유 10px
                                updated_containers[role] = ci.model_copy(update={"height_px": new_h})
                                logger.info(f"[Stage 1.8] sidebar 예외 확장: {role} {ci.height_px}px → {new_h}px")

                    elif zone_name == "body":
                        # body: 배경↔본심 재배분으로 처리 (후속 redistribute에서)
                        logger.info(f"[Stage 1.8] body overflow +{excess}px — 재배분 필요")

            # containers_dict 업데이트 (sidebar 확장 반영)
            for role, ci in updated_containers.items():
                containers_dict[role] = {
                    "height_px": ci.height_px,
                    "width_px": ci.width_px,
                    "zone": ci.zone,
                }

            # ── fit 계산 + 재배분 (업데이트된 컨테이너 기준) ──
            fit_analysis = calculate_fit(
                topics=[t.model_dump() for t in context.topics],
                page_structure=context.page_structure.roles,
                containers=containers_dict,
                references=refs_dict,
                font_hierarchy=font_h,
                normalized=normalized,
                core_message=core_message,
            )
            fit_analysis = redistribute(fit_analysis, containers_dict)

            # ── after: 조정된 컨테이너 ──
            for role, ci in updated_containers.items():
                new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
                updated_containers[role] = ci.model_copy(update={"height_px": int(new_h)})

            logger.info(f"[Stage 1.8] after: " + ", ".join(
                f"{r}={ci.height_px}px" for r, ci in updated_containers.items()
            ))

            # Step 3: Kei 에스컬레이션 (필요 시)
            if fit_analysis.needs_escalation:
                from src.kei_client import call_kei_fit_escalation
                report = build_escalation_report(fit_analysis)
                logger.info(f"[Stage 1.8] 에스컬레이션 필요:\n{report}")
                kei_result = await call_kei_fit_escalation(
                    fit_report=report,
                    topics=[t.model_dump() for t in context.topics],
                    content_summary=context.raw_content[:1500],
                )
                kei_decisions = []
                if kei_result:
                    kei_decisions = kei_result.get("decisions", [])
                    logger.info(f"[Stage 1.8] Kei 결정: {len(kei_decisions)}건")
                    for d in kei_decisions:
                        action = d.get("action", "")
                        target_role = d.get("role", "")
                        detail = d.get("detail", "")
                        logger.info(f"[V-4] {target_role} → {action}: {detail}")
                        if action == "restructure" and target_role in fit_analysis.roles:
                            fit_analysis = redistribute(fit_analysis, containers_dict)
            else:
                kei_decisions = []

            # Step 4: 보강 제안 분석
            enhancements = analyze_enhancements(
                topics=[t.model_dump() for t in context.topics],
                page_structure=context.page_structure.roles,
                references=refs_dict,
                analysis=fit_analysis,
                normalized=normalized,
                core_message=core_message,
            )

            # Step 5: Kei에게 보강 제안 확인 요청
            if enhancements.enhancements:
                from src.kei_client import call_kei_enhancement_review
                report = build_enhancement_report(enhancements)
                logger.info(f"[Stage 1.8] Kei 보강 검토 요청: {len(enhancements.enhancements)}건")

                kei_review = await call_kei_enhancement_review(
                    enhancement_report=report,
                    topics=[t.model_dump() for t in context.topics],
                    core_message=core_message,
                )

                # Kei 결정 반영: reject된 것 제거, modify된 것 수정
                if kei_review and kei_review.get("decisions"):
                    for decision in kei_review["decisions"]:
                        action = decision.get("action", "approve")
                        d_type = decision.get("type", "")
                        d_role = decision.get("role", "")
                        modification = decision.get("modification", "")

                        if action == "reject":
                            # 해당 enhancement 제거
                            enhancements.enhancements = [
                                e for e in enhancements.enhancements
                                if not (e.type == d_type and e.role == d_role)
                            ]
                            logger.info(f"[V-5] {d_role}/{d_type} → 거부됨")

                        elif action == "modify" and modification:
                            # 해당 enhancement 수정
                            for e in enhancements.enhancements:
                                if e.type == d_type and e.role == d_role:
                                    e.description = modification
                                    logger.info(f"[V-5] {d_role}/{d_type} → 수정: {modification[:50]}")

                        else:
                            logger.info(f"[V-5] {d_role}/{d_type} → 승인")

            # Step 6: 승인된 보강 적용 + fit 재검증
            enhancements = apply_enhancements(enhancements, fit_analysis)

            # V-10: Kei가 문맥 기반으로 bold 키워드 판단
            from src.kei_client import call_kei_bold_keywords
            kei_bold = await call_kei_bold_keywords(
                topics=[t.model_dump() for t in context.topics],
                page_structure=context.page_structure.roles,
            )
            if kei_bold:
                enhancements.bold_keywords = kei_bold
                logger.info(f"[V-10] Kei bold 키워드: {kei_bold}")

            logger.info(f"[Stage 1.8] 보강 확정: 보충블록 {len(enhancements.supplement_blocks)}개, "
                        f"강조 {len(enhancements.emphasis_blocks)}개, "
                        f"bold {len(enhancements.bold_keywords)}개 역할")

            # 재배분된 컨테이너 크기 업데이트
            updated_containers = {}
            for role, ci in context.containers.items():
                new_h = fit_analysis.redistribution.get(role, ci.height_px) if fit_analysis.redistribution else ci.height_px
                updated_containers[role] = ci.model_copy(update={
                    "height_px": int(new_h),
                })

            # Step 7: 세부 컨테이너 배치 계산
            sub_layouts = {}
            for role, rf in fit_analysis.roles.items():
                new_h = fit_analysis.redistribution.get(role, rf.allocated_px) if fit_analysis.redistribution else rf.allocated_px
                ci = context.containers.get(role)
                if not ci or not rf.topic_fits:
                    continue
                layout = calculate_sub_layout(
                    role=role,
                    main_height_px=int(new_h),
                    main_width_px=ci.width_px,
                    topic_fits=rf.topic_fits,
                    enhancements=enhancements,
                    font_hierarchy=font_h,
                )
                sub_layouts[role] = {
                    "main_height_px": layout.main_height_px,
                    "main_width_px": layout.main_width_px,
                    "sub_containers": [
                        {"name": sc.name, "width_px": sc.width_px, "height_px": sc.height_px, "align": sc.align}
                        for sc in layout.sub_containers
                    ],
                    "table_rows": layout.table_rows,
                }

            # V'-2: 팝업 원본을 Kei가 공간에 맞게 요약
            popup_summaries = {}
            popups = context.normalized.popups or []
            if popups:
                from src.kei_client import call_kei_summarize_popup
                for role, role_sub in sub_layouts.items():
                    role_scs = role_sub.get("sub_containers", [])
                    text_sc = next((sc for sc in role_scs if sc["name"] in ("text_and_table", "text")), None)
                    if not text_sc:
                        continue
                    # 텍스트 줄 수 계산
                    role_font_map = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}
                    fk = role_font_map.get(role, "core")
                    fs = font_h.get(fk, 12)
                    # structured_text에서 팝업 마커 찾기
                    ps_info = context.page_structure.roles.get(role, {})
                    tids = ps_info.get("topic_ids", []) if isinstance(ps_info, dict) else []
                    topic_map = {t.id: t for t in context.topics}
                    for tid in tids:
                        topic = topic_map.get(tid)
                        if not topic:
                            continue
                        st_text = topic.structured_text or topic.source_data or ""
                        import re as _re
                        popup_refs = _re.findall(r'\[팝업:\s*([^\]]+)\]', st_text)
                        for pr in popup_refs:
                            # 팝업 원본 찾기
                            popup = next((p for p in popups if pr in p.get("title", "")), None)
                            if not popup:
                                continue
                            # 공란 계산: V'-4 적용 후 높이 (결론 바로 위까지 채움)
                            from src.fit_verifier import _load_design_tokens as _ldt_v2
                            _v2_tokens = _ldt_v2()
                            _v2_slide_h = _v2_tokens.get("slide_height", 720)
                            _v2_pad = _v2_tokens["spacing_page"]
                            _v2_header_h = _v2_tokens.get("header_height", 66)
                            _v2_gap = _v2_tokens["spacing_block"]
                            _v2_gap_small = _v2_tokens["spacing_small"]
                            _v2_concl_ci = updated_containers.get("결론") or next((ci for r, ci in updated_containers.items() if ci.zone == "footer"), None)
                            _v2_concl_h = _v2_concl_ci.height_px if _v2_concl_ci else 53
                            _v2_ft_top = _v2_slide_h - _v2_pad - _v2_concl_h - _v2_gap
                            _v2_column_bottom = _v2_ft_top - _v2_gap
                            _v2_bg_ci = next((ci for r, ci in updated_containers.items() if ci.zone == "body" and r != role), None)
                            _v2_bg_h = _v2_bg_ci.height_px if _v2_bg_ci else 0
                            _v2_core_top = _v2_pad + _v2_header_h + _v2_gap + _v2_bg_h + _v2_gap_small
                            after_h = _v2_column_bottom - _v2_core_top  # 결론 위까지의 실제 본심 높이
                            keymsg_h = 0
                            for sc in role_scs:
                                if sc.get("name") == "keymsg":
                                    keymsg_h = float(sc.get("height_px", 0))
                            title_h = (fs + 1) * 1.5 + 4
                            # 이미지 높이: 실제 비율로 계산
                            svg_sc = next((sc for sc in role_scs if sc.get("name") == "svg"), None)
                            img_h = 0
                            if svg_sc:
                                svg_w = float(svg_sc.get("width_px", 200))
                                img_ratio = next((img.get("ratio", 1) for img in (context.slide_images or []) if img.get("b64")), 1)
                                img_h = svg_w / img_ratio if img_ratio > 0 else float(svg_sc.get("height_px", 0))
                            text_lines = len([l for l in st_text.split("\n") if l.strip() and not l.strip().startswith("[팝업:") and not l.strip().startswith("[이미지:") and not l.strip().lstrip("• ").startswith("출처:")])
                            text_h_used = text_lines * fs * 1.55
                            # 표 공간 = 전체 - 제목 - max(이미지,텍스트) - keymsg - padding
                            upper_h = max(img_h, text_h_used)
                            available_h = after_h - 16 - title_h - upper_h - keymsg_h - 8
                            available_w = float(text_sc["width_px"])
                            if available_h < fs * 3:
                                continue  # 공간 부족하면 건너뜀
                            summary = await call_kei_summarize_popup(
                                popup_title=pr,
                                popup_content=popup.get("content", ""),
                                available_width_px=available_w,
                                available_height_px=available_h,
                                font_size=fs,
                            )
                            if summary:
                                popup_summaries[pr] = summary
                                logger.info(f"[V'-2] {pr}: format={summary.get('format')}")

            # X'-6: 본문 표 요약 (유형 B — normalized.tables가 있으면)
            table_summaries = {}
            norm_tables = context.normalized.tables or []
            if norm_tables and context.analysis.layout_template == "B":
                from src.kei_client import call_kei_summarize_popup
                for ti, table_data in enumerate(norm_tables):
                    headers = table_data.get("headers", [])
                    rows = table_data.get("rows", [])
                    if not headers or not rows:
                        continue
                    # 표를 마크다운 형태로 변환하여 Kei에게 전달
                    md_table = "| " + " | ".join(headers) + " |\n"
                    md_table += "| " + " | ".join(["---"] * len(headers)) + " |\n"
                    for row in rows:
                        md_table += "| " + " | ".join(str(c) for c in row) + " |\n"

                    # 하단 우측 공간 계산
                    bottom_roles = [r for r, ci in updated_containers.items() if ci.zone in ("bottom_left", "bottom_right")]
                    if bottom_roles:
                        br_ci = next((ci for r, ci in updated_containers.items() if ci.zone == "bottom_right"), None)
                        if br_ci:
                            available_h = br_ci.height_px - 30  # 제목 + padding
                            available_w = br_ci.width_px
                            fs = font_h.get("core", 12)
                            summary = await call_kei_summarize_popup(
                                popup_title=f"본문표{ti+1}",
                                popup_content=md_table,
                                available_width_px=available_w,
                                available_height_px=available_h,
                                font_size=fs,
                            )
                            if summary:
                                table_summaries[f"table_{ti}"] = summary
                                logger.info(f"[X'-6] 본문표{ti+1}: format={summary.get('format')}")

            # 결과를 context에 저장 (Stage 2에서 사용)
            return {
                "containers": updated_containers,
                "sub_layouts": sub_layouts,
                "measurement": filled_measurement,
                "fit_result": {
                    "roles": {
                        role: {
                            "fit_status": rf.fit_status,
                            "total_required_px": rf.total_required_px,
                            "allocated_px": rf.allocated_px,
                            "shortfall_px": rf.shortfall_px,
                        }
                        for role, rf in fit_analysis.roles.items()
                    },
                    "redistribution": fit_analysis.redistribution,
                    "needs_escalation": fit_analysis.needs_escalation,
                },
                "enhancement_result": {
                    "kei_decisions": kei_decisions,
                    "subordinate_treatments": [
                        {"role": e.role, "detail": e.detail}
                        for e in enhancements.enhancements if e.type == "subordinate"
                    ],
                    "supplement_blocks": [
                        {"role": sb.role, "block_id": sb.block_id, "content_source": sb.content_source}
                        for sb in enhancements.supplement_blocks
                    ],
                    "emphasis_blocks": enhancements.emphasis_blocks,
                    "bold_keywords": enhancements.bold_keywords,
                    "popup_summaries": popup_summaries,
                    "table_summaries": table_summaries,
                },
            }

        ctx = await run_stage(stage_1_8, ctx, "stage_1_8", max_retries=0)

        # ── Stage 1.5b: 디자인 예산 재계산 (블록 선택 후) ──

        async def stage_1_5b(context: PipelineContext) -> dict:
            from src.space_allocator import calculate_design_budget

            updated_containers = {}
            for role, ci in context.containers.items():
                # V-1: 꼭지별 블록 리스트 → 첫 번째 블록의 schema를 대표로 사용
                ref_list = context.references.get(role, [])
                schema_info = ref_list[0].schema_info if ref_list else {}
                font_size = getattr(context.font_hierarchy, {
                    "본심": "core", "배경": "bg", "첨부": "sidebar", "결론": "core"
                }.get(role, "core"), 12.0)

                budget = calculate_design_budget(
                    container_height_px=ci.height_px,
                    container_width_px=ci.width_px,
                    block_schema=schema_info,
                    font_size=font_size,
                )

                updated_ci = ci.model_copy(update={
                    "design_budget": DesignBudget(
                        available_height_px=budget["available_height_px"],
                        available_width_px=budget["available_width_px"],
                        max_circle_diameter=budget["max_circle_diameter"],
                        max_img_width=budget["max_img_width"],
                        max_img_height=budget["max_img_height"],
                        fits=budget["fits"],
                    ),
                })
                updated_containers[role] = updated_ci

                if not budget["fits"]:
                    logger.warning(
                        f"[T-6] {role}: 디자인 예산 음수 — "
                        f"text={budget['text_height_px']}px > container={ci.height_px}px"
                    )

            return {"containers": updated_containers}

        ctx = await run_stage(stage_1_5b, ctx, "stage_1_5b", max_retries=0)

        # ── Stage 2: HTML 생성 (Claude Sonnet) ──
        yield {"event": "progress", "data": "3/7 슬라이드 HTML 생성 중..."}

        async def stage_2(context: PipelineContext) -> dict:
            # Phase X-BX': Type B는 code_assembled 직접 사용, Sonnet 재구성 스킵
            if context.analysis.layout_template == "B":
                from src.block_assembler import assemble_slide_html
                generated = assemble_slide_html(context)
                logger.info("[Stage 2] Type B: code_assembled 직접 사용 (Sonnet 스킵)")
                return {"generated_html": generated}

            # Type A: 기존 Sonnet 재구성 코드 그대로
            from src.content_verifier import generate_with_retry

            # PipelineContext → 기존 함수 인터페이스로 변환
            analysis_dict = {
                "topics": [t.model_dump() for t in context.topics],
                "page_structure": context.page_structure.roles,
                "core_message": context.analysis.core_message,
                "title": context.analysis.title,
                "total_pages": context.analysis.total_pages,
                "image_sizes": context.analysis.image_sizes,
            }

            container_specs_dict = {}
            for role, ci in context.containers.items():
                from src.space_allocator import ContainerSpec as LegacyContainerSpec
                container_specs_dict[role] = LegacyContainerSpec(
                    role=ci.role,
                    zone=ci.zone,
                    topic_ids=ci.topic_ids,
                    weight=ci.weight,
                    height_px=ci.height_px,
                    width_px=ci.width_px,
                    max_height_cost=ci.max_height_cost,
                    block_constraints=ci.block_constraints,
                )

            # T-7 + Phase V: context를 generate_slide_html에 전달
            phase_t_context = {
                "font_hierarchy": context.font_hierarchy.model_dump(),
                "container_ratio": context.container_ratio,
                "references": {
                    role: [r.model_dump() for r in ref_list]
                    for role, ref_list in context.references.items()
                },
                "design_budgets": {
                    role: ci.design_budget.model_dump() if ci.design_budget else {}
                    for role, ci in context.containers.items()
                },
                # Phase V: Stage 1.8 결과
                "sub_layouts": context.sub_layouts,
                "fit_result": context.fit_result,
                "enhancements": context.enhancement_result,
            }
            analysis_dict["phase_t"] = phase_t_context

            generated, verification = await generate_with_retry(
                content=context.raw_content,
                analysis=analysis_dict,
                container_specs=container_specs_dict,
                preset=context.preset,
                images=context.slide_images,
            )

            return {"generated_html": generated}

        ctx = await run_stage(stage_2, ctx, "stage_2", max_retries=0)

        # ── Stage 3: 렌더링 조립 ──
        yield {"event": "progress", "data": "4/7 슬라이드 조립 중..."}

        async def stage_3(context: PipelineContext) -> dict:
            # Phase X-BX': Type B는 Stage 2에서 이미 완전한 HTML → renderer 스킵
            if context.analysis.layout_template == "B":
                logger.info("[Stage 3] Type B: renderer 스킵 (generated_html 직접 사용)")
                return {"rendered_html": context.generated_html}

            # Type A: 기존 renderer 코드 그대로
            from src.renderer import render_slide_from_html

            analysis_dict = {
                "topics": [t.model_dump() for t in context.topics],
                "page_structure": context.page_structure.roles,
                "core_message": context.analysis.core_message,
                "title": context.analysis.title,
                "_container_ratio": list(context.container_ratio),
                "_font_hierarchy": context.font_hierarchy.model_dump(),
                "_containers": {
                    role: {"height_px": ci.height_px, "width_px": ci.width_px, "zone": ci.zone}
                    for role, ci in context.containers.items()
                },
                "_fit_redistribution": context.fit_result.get("redistribution", {}) if context.fit_result else {},
            }

            html = render_slide_from_html(context.generated_html, analysis_dict, context.preset)
            return {"rendered_html": html}

        ctx = await run_stage(stage_3, ctx, "stage_3", max_retries=0)

        # ── Stage 4: 측정 + 품질 게이트 ──
        yield {"event": "progress", "data": "5/7 품질 검증 중..."}

        async def stage_4(context: PipelineContext) -> dict:
            from src.kei_client import vision_quality_gate

            # L4: Selenium 실측
            measurement = await asyncio.to_thread(
                measure_rendered_heights, context.rendered_html
            )

            has_overflow = False
            for zone_name, zone_data in measurement.get("zones", {}).items():
                if zone_data.get("overflowed"):
                    has_overflow = True
                    excess = zone_data.get("excess_px", 0)
                    logger.warning(f"[L4] {zone_name}: overflow +{excess}px")

            if has_overflow:
                logger.warning("[L4] overflow 감지됨 — 결과물에 경고 포함하여 진행")
            else:
                logger.info("[L4] overflow 없음")

            # L5: 스크린샷 + 비전 품질 게이트
            screenshot_b64 = await asyncio.to_thread(
                capture_slide_screenshot, context.rendered_html
            )

            quality_score = 100
            if screenshot_b64:
                analysis_dict = {
                    "topics": [t.model_dump() for t in context.topics],
                    "core_message": context.analysis.core_message,
                }
                quality_result = await vision_quality_gate(screenshot_b64, analysis_dict)

                if quality_result:
                    quality_score = quality_result.get("score", 0)

                    if quality_score < 30:
                        return {"_errors": [{
                            "severity": "FATAL",
                            "localization": f"품질 {quality_score}/100 < 30",
                            "instruction": "출력 차단",
                        }]}

            return {
                "measurement": measurement,
                "quality_score": quality_score,
                "screenshot_b64": screenshot_b64 or "",
            }

        ctx = await run_stage(stage_4, ctx, "stage_4", max_retries=0)

        # ── Stage 5: 검증 통과 확인 → final.html 출력 ──
        yield {"event": "progress", "data": "6/7 최종 처리 중..."}

        # 검증 결과 확인
        measurement = ctx.measurement or {}
        has_overflow = any(
            zd.get("overflowed", False)
            for zd in measurement.get("zones", {}).values()
        )
        quality = ctx.quality_score if hasattr(ctx, "quality_score") else 100

        if has_overflow:
            overflow_zones = [
                f'{zn}(+{zd.get("excess_px", 0)}px)'
                for zn, zd in measurement.get("zones", {}).items()
                if zd.get("overflowed")
            ]
            logger.warning(f"[Stage 5] overflow 감지: {overflow_zones} — 결과물에 경고 포함")

        if quality < 30:
            logger.error(f"[Stage 5] 품질 {quality}/100 < 30 — 출력 차단")
            yield {"event": "error", "data": f"품질 검증 미달 ({quality}/100). 출력 차단."}
            return

        html = ctx.rendered_html
        if ctx.base_path:
            html = embed_images(html, ctx.base_path)

        ctx = ctx.model_copy(update={"rendered_html": html})
        ctx.save_snapshot("final")

        # final.html 저장
        run_dir = ctx.get_run_dir()
        run_dir.mkdir(parents=True, exist_ok=True)
        (run_dir / "final.html").write_text(html, encoding="utf-8")

        # Phase T: 팝업(상세 내용)을 별도 HTML로 분리 저장
        popups = ctx.normalized.popups
        if popups:
            for i, popup in enumerate(popups, 1):
                popup_title = popup.get("title", f"첨부{i}")
                popup_content = popup.get("content", "")
                # 파일명에서 특수문자 제거
                safe_title = "".join(c for c in popup_title if c.isalnum() or c in "_ -").strip()
                popup_filename = f"첨부{i}_{safe_title}.html"
                # TP-6: 첨부 HTML에 디자인 토큰 적용
                import re as _re
                # JSX style={{}} 잔여 정리
                clean_content = _re.sub(r'style=\{\{[^}]*\}\}', '', popup_content)
                clean_content = clean_content.replace('<br/>', '<br>')
                # markdown bold → HTML bold
                clean_content = _re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', clean_content)

                # 콘텐츠 유형 감지: 테이블 vs 리스트
                has_table = "<table" in clean_content
                has_list = "<ul" in clean_content or "<li" in clean_content

                # 콘텐츠 유형별 CSS
                if has_table:
                    # 3열 비교표: 양쪽 동일 너비, 중앙 맞춤, bold+br 지원
                    content_css = """
table {{ border-collapse: collapse; width: 100%; margin: 16px 0; font-size: 13px; table-layout: fixed; }}
th {{ background: var(--color-primary); color: #fff; font-weight: 700; padding: 10px 14px; text-align: center; border: 1px solid #334155; }}
th:nth-child(1), th:nth-child(3) {{ width: 42%; }}
th:nth-child(2) {{ width: 16%; }}
td {{ padding: 10px 14px; border: 1px solid var(--color-border); vertical-align: middle; text-align: center; line-height: 1.6; }}
tr:nth-child(even) {{ background: var(--color-bg-subtle); }}"""
                elif has_list:
                    # 카드형 리스트: 항목별 박스, 하위 항목은 인라인
                    content_css = """
ul {{ padding-left: 0; margin: 12px 0; list-style: none; }}
li {{ margin-bottom: 12px; font-size: 14px; background: #f8fafc; border: 1px solid var(--color-border); border-radius: 8px; padding: 14px 18px; }}
li ul {{ margin-top: 8px; margin-bottom: 0; padding-left: 0; }}
li li {{ background: transparent; border: none; border-radius: 0; padding: 2px 0; margin-bottom: 4px; font-size: 13px; color: #475569; }}
li li::before {{ content: "\\2022"; color: var(--color-accent); margin-right: 8px; }}"""
                else:
                    # 기본 (텍스트)
                    content_css = """
ul {{ padding-left: 20px; margin: 8px 0; }}
li {{ margin-bottom: 4px; font-size: 13px; }}"""

                popup_html = f"""<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<title>{popup_title} — 첨부 자료</title>
<link rel="preconnect" href="https://cdn.jsdelivr.net">
<link href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css" rel="stylesheet">
<style>
:root {{
    --color-primary: #1e293b;
    --color-accent: #2563eb;
    --color-border: #e2e8f0;
    --color-bg-subtle: #f8fafc;
}}
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
    font-family: 'Pretendard Variable', -apple-system, sans-serif;
    color: #1e293b;
    line-height: 1.7;
    padding: 40px 60px;
    max-width: 960px;
    margin: 0 auto;
    background: #ffffff;
}}
h1 {{
    font-size: 22px;
    font-weight: 700;
    color: var(--color-primary);
    border-bottom: 3px solid var(--color-accent);
    padding-bottom: 12px;
    margin-bottom: 24px;
}}
.meta {{
    font-size: 12px;
    color: #64748b;
    margin-bottom: 20px;
}}
{content_css}
strong {{ color: var(--color-primary); }}
.source {{
    font-size: 11px;
    color: #94a3b8;
    margin-top: 20px;
    padding-top: 12px;
    border-top: 1px solid var(--color-border);
}}
</style>
</head>
<body>
<h1>{popup_title}</h1>
<div class="meta">첨부 자료 {i} — 슬라이드 본문의 상세 내용</div>
{clean_content}
<div class="source">본 자료는 슬라이드 "{ctx.analysis.title}"의 첨부 자료입니다.</div>
</body>
</html>"""
                (run_dir / popup_filename).write_text(popup_html, encoding="utf-8")
                logger.info(f"[Phase T] 첨부 HTML 저장: {popup_filename}")

        yield {"event": "result", "data": html}
        logger.info(f"슬라이드 생성 완료: run={ctx.run_id}")

    except StageFailure as e:
        logger.error(f"파이프라인 중단: {e.stage_name} — {e.errors}")
        yield {"event": "error", "data": f"Stage {e.stage_name} 실패: {e.errors}"}
    except Exception as e:
        logger.exception(f"파이프라인 오류: {e}")
        yield {"event": "error", "data": str(e)}


# ══════════════════════════════════════
# 레거시 파이프라인 (Phase S) — 보존
# ══════════════════════════════════════


async def _retry_kei(fn, *args, **kwargs):
    """Kei API 호출을 성공할 때까지 재시도한다.

    Kei API는 필수 인프라. fallback 없음.
    최대 30회 또는 300초까지 재시도 후 TimeoutError.
    """
    import asyncio
    attempt = 0
    start_time = time.time()
    while attempt < KEI_MAX_RETRY_ATTEMPTS:
        attempt += 1
        elapsed = time.time() - start_time
        if elapsed > KEI_MAX_RETRY_DURATION:
            raise TimeoutError(
                f"Kei API 타임아웃: {fn.__name__} — "
                f"{elapsed:.0f}초 경과 (제한 {KEI_MAX_RETRY_DURATION}초)"
            )
        result = await fn(*args, **kwargs)
        if result is not None:
            if attempt > 1:
                logger.info(f"[Kei 재시도] {fn.__name__} 성공 ({attempt}번째 시도)")
            return result
        logger.warning(
            f"[Kei 재시도] {fn.__name__} 실패 (시도 {attempt}/{KEI_MAX_RETRY_ATTEMPTS}). "
            f"{KEI_RETRY_INTERVAL}초 후 재시도..."
        )
        await asyncio.sleep(KEI_RETRY_INTERVAL)

    raise RuntimeError(
        f"Kei API 최대 재시도 초과: {fn.__name__} — {attempt}회 시도"
    )


def _save_step(run_dir: Path, filename: str, data: Any) -> None:
    """스텝 결과를 JSON 또는 HTML로 저장한다. (K-1)"""
    run_dir.mkdir(parents=True, exist_ok=True)
    filepath = run_dir / filename
    if filename.endswith(".html"):
        filepath.write_text(data, encoding="utf-8")
    else:
        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
    logger.info(f"[중간 산출물] {filename} 저장 → {run_dir.name}/")


async def generate_slide_legacy(
    content: str,
    manual_layout: dict[str, Any] | None = None,
    base_path: str = "",
) -> AsyncIterator[dict[str, str]]:
    """Phase S 레거시 파이프라인. Phase T 전환 전 보존용.

    Yields:
        SSE 이벤트: progress / result / error
    """
    # K-1: 중간 산출물 저장용 디렉토리
    run_id = str(int(time.time() * 1000))
    run_dir = Path("data/runs") / run_id

    try:
        # 1단계: Kei 실장 — 꼭지 추출 + 분석
        yield {"event": "progress", "data": "1/5 Kei 실장이 꼭지를 추출 중..."}

        if manual_layout:
            analysis = manual_layout
        else:
            analysis = await _retry_kei(classify_content, content)
            # _retry_kei는 무한 재시도. None이 올 수 없다.

        topic_count = len(analysis.get("topics", []))
        page_count = analysis.get("total_pages", 1)
        logger.info(f"1단계-A 완료: {topic_count}개 꼭지, {page_count}페이지")
        _save_step(run_dir, "step1_analysis.json", analysis)

        # 1단계-B: 각 꼭지 컨셉 구체화
        yield {"event": "progress", "data": "1.5/5 Kei 실장이 각 꼭지의 컨셉을 구체화 중..."}
        analysis = await refine_concepts(content, analysis)
        logger.info("1단계-B 완료: 컨셉 구체화")
        _save_step(run_dir, "step1b_concepts.json", {
            "concepts": [
                {k: t.get(k) for k in ("id", "title", "purpose", "relation_type", "expression_hint", "source_data")}
                for t in analysis.get("topics", [])
            ]
        })

        # I-6: 슬라이드 제목 ↔ 첫 꼭지 제목 중복 검증
        from difflib import SequenceMatcher
        title = analysis.get("title", "")
        topics = analysis.get("topics", [])
        if topics:
            first_title = topics[0].get("title", "")
            similarity = SequenceMatcher(None, title, first_title).ratio()
            if similarity > 0.7:
                purpose = topics[0].get("purpose", "문제제기")
                topics[0]["title"] = f"{purpose}: {topics[0].get('summary', '')[:30]}"
                logger.warning(
                    f"[제목 중복 교정] 유사도 {similarity:.0%} → 첫 꼭지 제목 변경"
                )

        # 이미지 크기 측정 (base_path 있을 때만)
        image_sizes = get_image_sizes(content, base_path)
        if image_sizes:
            analysis["image_sizes"] = image_sizes
            logger.info(f"이미지 측정: {len(image_sizes)}개")

        # ★ Phase O-1: 컨테이너 스펙 계산 (Kei 비중 → px 확정)
        preset_name = select_preset(analysis)
        preset = LAYOUT_PRESETS.get(preset_name, {})
        page_struct = analysis.get("page_structure", {})

        container_specs = calculate_container_specs(
            page_structure=page_struct,
            topics=analysis.get("topics", []),
            preset=preset,
            slide_width=settings.slide_width,
            slide_height=settings.slide_height,
        )
        _save_step(run_dir, "step1c_containers.json", {
            role: {
                "height_px": spec.height_px,
                "width_px": spec.width_px,
                "max_height_cost": spec.max_height_cost,
                "topic_ids": spec.topic_ids,
                "weight": spec.weight,
                "block_constraints": spec.block_constraints,
            }
            for role, spec in container_specs.items()
        })

        # ★ Phase S: Claude Sonnet이 HTML 직접 생성
        # 블록 선택 없음. 슬롯 채우기 없음. AI가 콘텐츠에 맞는 HTML 구조를 직접 만든다.
        yield {"event": "progress", "data": "2/4 슬라이드 HTML 생성 중..."}

        from src.content_verifier import generate_with_retry
        from src.renderer import render_slide_from_html
        from src.kei_client import vision_quality_gate
        import asyncio

        topics = analysis.get("topics", [])

        # 이미지 정보 구성 (base64 포함)
        slide_images = []
        if image_sizes:
            import base64 as b64_mod
            for img_key, img_info in image_sizes.items():
                img_path = Path(base_path) / img_key if base_path else Path(img_key)
                img_b64 = ""
                if img_path.exists():
                    img_b64 = b64_mod.b64encode(img_path.read_bytes()).decode()
                slide_images.append({
                    "path": str(img_path),
                    "width": img_info.get("width", 0),
                    "height": img_info.get("height", 0),
                    "ratio": round(img_info.get("width", 1) / max(1, img_info.get("height", 1)), 2),
                    "topic_id": img_info.get("topic_id"),
                    "b64": img_b64,
                })

        # Claude Sonnet HTML 생성 + 독립 검증 + 재시도 루프
        generated, verification = await generate_with_retry(
            content=content,
            analysis=analysis,
            container_specs=container_specs,
            preset=preset,
            images=slide_images,
        )

        _save_step(run_dir, "step2b_verification.json", {
            area: {"passed": r.passed, "score": r.score, "errors": r.errors}
            for area, r in verification.items()
        })

        _save_step(run_dir, "step2_generated.json", {
            "body_html_length": len(generated.get("body_html", "")),
            "sidebar_html_length": len(generated.get("sidebar_html", "")),
            "footer_html_length": len(generated.get("footer_html", "")),
            "reasoning": generated.get("reasoning", ""),
        })
        logger.info(
            f"[Phase S] HTML 생성 완료: body={len(generated.get('body_html', ''))}자, "
            f"sidebar={len(generated.get('sidebar_html', ''))}자, "
            f"footer={len(generated.get('footer_html', ''))}자"
        )

        # 3단계: 렌더링 — AI 생성 HTML을 슬라이드 프레임에 삽입
        yield {"event": "progress", "data": "3/4 슬라이드 조립 중..."}

        html = render_slide_from_html(generated, analysis, preset)
        logger.info("[Phase S] 슬라이드 조립 완료")
        _save_step(run_dir, "step3_rendered.html", html)

        # ★ Phase Q: 검증 렌더링 + 수학적 조정 + 비전 품질 게이트
        measurement = await asyncio.to_thread(measure_rendered_heights, html)
        _save_step(run_dir, "step4_measurement.json", measurement)

        # Phase S: overflow 감지
        has_overflow = False
        for zone_name, zone_data in measurement.get("zones", {}).items():
            if zone_data.get("overflowed"):
                has_overflow = True
                logger.warning(f"[Phase S] {zone_name}: overflow +{zone_data.get('excess_px', 0)}px")

        if has_overflow:
            logger.warning("[Phase S] overflow 감지 — 결과물에 반영 (후속 품질 게이트에서 평가)")
        else:
            logger.info("[Phase S] overflow 없음")

        # Phase S: 비전 모델 품질 게이트
        yield {"event": "progress", "data": "4/4 품질 검증 중..."}

        screenshot_b64 = await asyncio.to_thread(capture_slide_screenshot, html)
        quality_result = None

        if screenshot_b64:
            _save_step(run_dir, "step5_screenshot.txt", f"base64 PNG, {len(screenshot_b64)} chars")
            quality_result = await vision_quality_gate(screenshot_b64, analysis)

            if quality_result:
                _save_step(run_dir, "step5_quality_gate.json", quality_result)

                if not quality_result.get("passed", True):
                    score = quality_result.get("score", 0)
                    issues = quality_result.get("issues", [])
                    logger.warning(f"[Q-6] 품질 게이트 FAIL: {score}/100 — {issues}")

                    # Q-8: 심각한 품질 문제 시 출력 차단
                    if score < 30:
                        logger.error(f"[Q-8] 출력 차단: 품질 {score}/100 < 30 최소 기준")
                        yield {"event": "error", "data": f"슬라이드 품질 미달 ({score}/100). 재시도해 주세요."}
                        return
                else:
                    logger.info(f"[Q-6] 품질 게이트 PASS: {quality_result.get('score', 0)}/100")
        else:
            logger.warning("[Q-6] 스크린샷 캡처 실패 — 품질 게이트 스킵")

        # D-5: 이미지를 base64로 삽입 (다운로드 HTML에서도 보이도록)
        if base_path:
            html = embed_images(html, base_path)
            logger.info("이미지 base64 삽입 완료")

        _save_step(run_dir, "final.html", html)
        yield {"event": "result", "data": html}
        logger.info(f"슬라이드 생성 완료: run={run_id}")

    except Exception as e:
        logger.exception(f"파이프라인 오류: {e}")
        yield {"event": "error", "data": str(e)}


async def _adjust_design(
    layout_concept: dict[str, Any],
    analysis: dict[str, Any],
) -> dict[str, Any]:
    """4단계 전반: 디자인 실무자가 텍스트 양에 맞게 CSS를 조정한다.

    각 area별 블록 수, 텍스트 총량, zone 예산을 계산하고,
    Sonnet이 area별 CSS 변수 override를 결정한다.
    블록 템플릿이 이미 CSS 변수(var(--font-body) 등)를 사용하므로,
    area div에서 변수를 override하면 내부 블록이 자동 조정된다.
    """
    try:
        client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)

        # 프리셋 정보 가져오기
        preset_name = select_preset(analysis)
        preset = LAYOUT_PRESETS.get(preset_name, {})
        zones = preset.get("zones", {})

        for page in layout_concept.get("pages", []):
            # area별 블록 수 + 텍스트 총량 집계
            area_info = {}
            for block in page.get("blocks", []):
                area = block.get("area", "body")
                if area not in area_info:
                    zone = zones.get(area, {})
                    area_info[area] = {
                        "block_count": 0,
                        "total_chars": 0,
                        "budget_px": zone.get("budget_px", 0),
                        "width_pct": zone.get("width_pct", 100),
                        "block_types": [],
                    }
                data = block.get("data", {})
                text_len = len(json.dumps(data, ensure_ascii=False))
                area_info[area]["block_count"] += 1
                area_info[area]["total_chars"] += text_len
                area_info[area]["block_types"].append(block.get("type", ""))

            # area 정보 텍스트 구성
            area_lines = []
            for area_name, info in area_info.items():
                area_lines.append(
                    f"- {area_name} (예산 {info['budget_px']}px, 너비 {info['width_pct']}%): "
                    f"{info['block_count']}개 블록, 총 {info['total_chars']}자\n"
                    f"  블록 타입: {', '.join(info['block_types'])}"
                )

            system = (
                "당신은 디자인 실무자이다. 편집자가 정리한 텍스트가 각 영역에 잘 들어가도록 CSS를 조정한다.\n\n"
                "## 원칙\n"
                "- 텍스트를 자르지 않는다. 디자인이 텍스트에 맞춘다.\n"
                "- 빈 공간을 방치하지 않는다.\n"
                "- 텍스트가 많으면: 폰트/여백을 줄여서 맞춘다.\n"
                "- 텍스트가 적으면: 폰트/여백을 늘려서 채운다.\n\n"
                "## 조정 가능한 CSS 변수\n"
                "- --font-body (기본 0.95rem): 본문 폰트 크기\n"
                "- --font-subtitle (기본 1.25rem): 소제목 폰트 크기\n"
                "- --font-caption (기본 0.8rem): 캡션 폰트 크기\n"
                "- --spacing-inner (기본 16px): 블록 내부 여백\n"
                "- --spacing-block (기본 20px): 블록 간 간격\n"
                "- --spacing-small (기본 8px): 작은 여백\n\n"
                "## 출력 형식 (JSON만. 설명 없이.)\n"
                "각 area에 적용할 CSS 변수 override를 inline style 문자열로 반환.\n"
                "조정 불필요한 area는 빈 문자열.\n"
                '{"area_styles": {"body": "--font-body: 0.85rem; --spacing-inner: 10px;", "sidebar": "", "footer": ""}}'
            )

            user_prompt = (
                f"## 각 영역 현황\n" + "\n".join(area_lines) +
                f"\n\n위 영역별로 CSS 변수 조정이 필요한지 판단하여 JSON으로 반환해줘."
            )

            response = await client.messages.create(
                model="claude-sonnet-4-20250514",
                max_tokens=1024,
                system=system,
                messages=[{"role": "user", "content": user_prompt}],
            )

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

            if result and "area_styles" in result:
                page["area_styles"] = result["area_styles"]
                logger.info(
                    f"디자인 조정: {', '.join(f'{k}={bool(v)}' for k, v in result['area_styles'].items())}"
                )
            else:
                page["area_styles"] = {}
                logger.info("디자인 조정: 조정 불필요 또는 파싱 실패")

    except Exception as e:
        logger.warning(f"디자인 조정 실패 (기존 스타일로 렌더링): {e}")
        # 실패 시 area_styles 없음 → 기존과 동일하게 렌더링
        for page in layout_concept.get("pages", []):
            if "area_styles" not in page:
                page["area_styles"] = {}

    return layout_concept


async def _review_balance(
    html: str,
    layout_concept: dict[str, Any],
    content: str,
    analysis: dict[str, Any] | None = None,
    measurement_text: str = "",
    screenshot_b64: str | None = None,
) -> dict[str, Any] | None:
    """5단계: Kei 실장이 조립 결과를 최종 검수한다. (J-7 + Phase L)

    Kei가 콘텐츠 관점 + 실제 렌더링 측정 결과 기반으로 검수:
    - 핵심 메시지 전달 여부
    - 콘텐츠 흐름 ↔ 블록 배치 일치
    - 실제 px 기반 높이/비중 검증 (Phase L)
    - 중요 내용 누락/축소 여부
    """
    try:
        # 블록별 텍스트 양 요약
        block_summary = []
        for page in layout_concept.get("pages", []):
            for block in page.get("blocks", []):
                data = block.get("data", {})
                text_len = len(json.dumps(data, ensure_ascii=False))
                block_summary.append(
                    f"  {block.get('area')}/{block.get('type')}: "
                    f"데이터 {text_len}자"
                )

        # zone 예산 정보 (analysis에서 프리셋 추출)
        zone_budget_text = ""
        overflow_hint_text = ""
        if analysis:
            preset_name = select_preset(analysis)
            preset = LAYOUT_PRESETS.get(preset_name, {})
            zone_lines = [
                f"- {name}: ~{z['budget_px']}px (너비 {z['width_pct']}%)"
                for name, z in preset.get("zones", {}).items()
            ]
            zone_budget_text = (
                "\n\n## zone별 높이 예산\n" + "\n".join(zone_lines)
            )

            # Stage 2에서 감지한 예상 overflow 힌트
            overflow_hint = layout_concept.get("overflow", [])
            if overflow_hint:
                hint_lines = [
                    f"- {o['area']}: 예상 {o['total_px']}px > 예산 {o['budget_px']}px "
                    f"(+{o['overflow_px']}px 초과)"
                    for o in overflow_hint
                ]
                overflow_hint_text = (
                    "\n\n## 높이 초과 힌트 (2단계 예상치, 참고용)\n"
                    + "\n".join(hint_lines)
                )

        # Phase L: 렌더링 측정 결과를 overflow_hint에 추가 (실제 px 기반)
        if measurement_text:
            overflow_hint_text += f"\n\n{measurement_text}"

        # Kei로 최종 검수 (레거시 — Phase S에서는 메인 흐름에서 미사용)
        from src.kei_client import call_kei_final_review
        return await call_kei_final_review(
            html, block_summary, zone_budget_text, overflow_hint_text, analysis,
            screenshot_b64=screenshot_b64,
        )

    except Exception as e:
        logger.warning(f"재검토 실패: {e}")
        return None


async def _apply_adjustments(
    layout_concept: dict[str, Any],
    review: dict[str, Any],
    content: str,
) -> dict[str, Any]:
    """재검토 결과에 따라 텍스트를 재편집한다."""
    adjustments = review.get("adjustments", [])
    if not adjustments:
        return layout_concept

    # 조정이 필요한 블록만 재편집
    for adj in adjustments:
        area = adj.get("block_area", "")
        action = adj.get("action", "")
        ratio = adj.get("target_ratio")
        detail = adj.get("detail", "")

        for page in layout_concept.get("pages", []):
            for block in page.get("blocks", []):
                if block.get("area") != area:
                    continue

                if action == "expand" and ratio:
                    for key in block.get("char_guide", {}):
                        block["char_guide"][key] = int(
                            block["char_guide"][key] * ratio
                        )
                    logger.info(f"조정: {area} → expand ×{ratio} ({detail})")

                elif action == "shrink" and ratio:
                    for key in block.get("char_guide", {}):
                        block["char_guide"][key] = int(
                            block["char_guide"][key] * ratio
                        )
                    logger.info(f"조정: {area} → shrink ×{ratio} ({detail})")

                elif action == "rewrite":
                    if "data" in block:
                        del block["data"]
                    block["reason"] = f"재작성: {detail}"
                    logger.info(f"조정: {area} → rewrite ({detail})")

                elif action == "kei_trim":
                    max_chars = adj.get("max_chars", 200)
                    if "char_guide" not in block:
                        block["char_guide"] = {}
                    for key in block.get("char_guide", {}):
                        block["char_guide"][key] = min(
                            block["char_guide"][key], max_chars
                        )
                    if not block["char_guide"]:
                        block["char_guide"] = {"text": max_chars}
                    logger.info(
                        f"조정: {area} → kei_trim max_chars={max_chars} "
                        f"({detail})"
                    )

                elif action == "kei_restructure":
                    block["detail_target"] = True
                    if "data" in block:
                        del block["data"]
                    block["reason"] = f"재구성: {detail}"
                    logger.info(
                        f"조정: {area} → kei_restructure (detail_target)"
                    )

    # 조정된 가이드로 재편집 (레거시 — Phase S에서는 미사용)
    from src.content_editor import fill_content
    layout_concept = await fill_content(content, layout_concept)
    return layout_concept


def _build_overflow_context(
    layout_concept: dict[str, Any],
    overflow_adjs: list[dict],
) -> list[dict]:
    """Sonnet이 감지한 overflow_detected를 Kei에게 전달할 형태로 변환한다.

    실제 채워진 블록 데이터(텍스트)를 포함하여 Kei가 판단할 수 있도록 한다.
    """
    overflows = []
    for adj in overflow_adjs:
        area = adj.get("block_area", "")
        # 해당 zone의 블록 정보 + 실제 텍스트 추출
        area_blocks = []
        for page in layout_concept.get("pages", []):
            for block in page.get("blocks", []):
                if block.get("area") == area:
                    data = block.get("data", {})
                    text_preview = json.dumps(data, ensure_ascii=False)[:300]
                    area_blocks.append({
                        "type": block.get("type", ""),
                        "purpose": block.get("purpose", ""),
                        "topic_id": block.get("topic_id"),
                        "text_preview": text_preview,
                    })
        overflows.append({
            "area": area,
            "detail": adj.get("detail", ""),
            "blocks": area_blocks,
        })
    return overflows


def _convert_kei_judgment(
    review_result: dict[str, Any],
    kei_judgment: dict[str, Any],
) -> None:
    """Kei의 trim/restructure 판단을 review_result.adjustments에 반영한다.

    기존 overflow_detected 항목을 kei_trim 또는 kei_restructure로 교체.
    """
    decision = kei_judgment.get("decision", "")
    new_adjs = []

    for adj in review_result.get("adjustments", []):
        if adj.get("action") == "overflow_detected":
            # overflow_detected → Kei 판단으로 교체
            if decision == "trim":
                for target in kei_judgment.get("trim_targets", []):
                    new_adjs.append({
                        "block_area": adj.get("block_area", ""),
                        "action": "kei_trim",
                        "max_chars": target.get("max_chars", 200),
                        "topic_id": target.get("topic_id"),
                        "detail": target.get("reason", ""),
                    })
            elif decision == "restructure":
                for tid in kei_judgment.get("detail_topics", []):
                    new_adjs.append({
                        "block_area": adj.get("block_area", ""),
                        "action": "kei_restructure",
                        "topic_id": tid,
                        "detail": kei_judgment.get("reason", ""),
                    })
        else:
            # 기존 expand/shrink/rewrite는 그대로 유지
            new_adjs.append(adj)

    review_result["adjustments"] = new_adjs


def _parse_json(text: str) -> dict[str, Any] | None:
    """텍스트에서 JSON을 추출한다."""
    patterns = [
        r"```json\s*(.*?)```",
        r"```\s*(.*?)```",
        r"(\{.*\})",
    ]
    for pattern in patterns:
        match = re.search(pattern, text, re.DOTALL)
        if match:
            try:
                return json.loads(match.group(1).strip())
            except json.JSONDecodeError:
                continue
    return None
