"""블록 조립 공통 모듈.

filled, assembled, Stage 2 모두 이 모듈의 함수를 사용.
조립 로직이 한 곳에만 존재하여 수정 사항이 전체에 반영됨.

입력: PipelineContext (또는 동등한 dict)
출력: 역할별 HTML dict + 슬라이드 전체 HTML

하드코딩 없음. font_hierarchy, sub_layouts, design_reference_html, structured_text에서 동적으로.
"""
from __future__ import annotations

import re
import logging
from typing import Any, TYPE_CHECKING

if TYPE_CHECKING:
    from src.pipeline_context import PipelineContext

logger = logging.getLogger(__name__)

COLORS = {"배경": "#dc2626", "본심": "#2563eb", "첨부": "#16a34a", "결론": "#7c3aed"}
FONT_MAP = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}


def assemble_role_html(
    role: str,
    ctx: "PipelineContext",
) -> tuple[str, set[str]]:
    """하나의 역할(배경/본심/첨부/결론)에 대해 블록 디자인 + 텍스트를 조립.

    Returns:
        (조립된 HTML, 사용된 CSS set)
    """
    ps = ctx.page_structure.roles
    info = ps.get(role, {})
    if not isinstance(info, dict):
        return "", set()
    tids = info.get("topic_ids", [])
    if not tids:
        return "", set()

    topic_map = {t.id: t for t in ctx.topics}
    ref_list = ctx.references.get(role, [])
    if not ref_list:
        return "", set()

    r0 = ref_list[0]
    primary_tid = r0.topic_id if hasattr(r0, 'topic_id') and r0.topic_id else (tids[0] if tids else None)
    primary_topic = topic_map.get(primary_tid)
    if not primary_topic:
        return "", set()

    font_key = FONT_MAP.get(role, "core")
    font_size = getattr(ctx.font_hierarchy, font_key, 12)
    sub_layouts = ctx.sub_layouts or {}
    role_sub = sub_layouts.get(role, {})
    role_scs = role_sub.get("sub_containers", [])

    # #10: V-10 bold 키워드
    enh = ctx.enhancement_result or {}
    bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
    role_bold = bold_kw.get(role, [])

    # ── 블록 디자인 HTML에서 CSS 추출 ──
    ref_html = r0.design_reference_html or ""
    css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
    block_body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
    block_body = re.sub(r'<!--.*?-->', '', block_body, flags=re.DOTALL).strip()

    # CSS font-size override (font_hierarchy 기준)
    overridden_css = set()
    for css in css_parts:
        def _override_font(m):
            val = float(m.group(1))
            if val > font_size + 2:
                return f"font-size: {font_size + 1}px"
            elif val > font_size:
                return f"font-size: {font_size}px"
            return m.group(0)
        oc = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, css)
        # gap, padding, number size도 font_size 비례
        oc = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', oc)
        oc = re.sub(r'width:\s*32px;\s*\n\s*height:\s*32px',
            f'width: {int(font_size * 2)}px;\n  height: {int(font_size * 2)}px', oc)
        oc = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', oc)
        oc = oc.replace('white-space: pre-line', 'white-space: normal')
        overridden_css.add(oc)

    # ── structured_text 파싱 (들여쓰기 보존) ──
    st = primary_topic.structured_text or primary_topic.source_data or ""
    st_lines, popup_titles = _parse_structured_text(st, font_size)

    # ── sub_layouts 기반 판단 ──
    has_svg = any(sc.get("name") == "svg" for sc in role_scs)
    has_keymsg = any(sc.get("name") == "keymsg" for sc in role_scs)

    # #11: V-9 강조 블록
    emphasis_blocks = enh.get("emphasis_blocks", [])
    role_emphasis = ""
    for eb in emphasis_blocks:
        if eb.get("role") == role:
            role_emphasis = eb.get("sentence", "")
            break

    # #12: V-7 종속꼭지 텍스트
    is_hier = r0.is_hierarchical if hasattr(r0, 'is_hierarchical') else False
    sup_tids = r0.supporting_topic_ids if hasattr(r0, 'supporting_topic_ids') else []
    sub_topics_text = []
    if is_hier and sup_tids:
        for st_id in sup_tids:
            st_topic = topic_map.get(st_id)
            if st_topic:
                st_text = st_topic.structured_text or st_topic.source_data or ""
                sub_topics_text.append(st_text[:120])

    # ── 블록 구조별 조립 ──
    if "block-callout-warn" in block_body or "block-callout-sol" in block_body:
        inner = _assemble_callout(block_body, primary_topic, st_lines, font_size, role_bold, role_emphasis, sub_topics_text)
    elif "block-card-num" in block_body:
        inner = _assemble_card_numbered(primary_topic, st_lines, font_size, role_scs, role_bold)
    elif "block-banner-grad" in block_body:
        inner = _assemble_banner(block_body, ctx.analysis.core_message or primary_topic.title)
    elif has_svg:
        # 실제 이미지 파일이 있는 경우만 SVG 레이아웃 사용
        # slide_images에 실제 이미지가 있는지 확인
        has_real_image = any(
            img.get("b64") or img.get("path", "").strip()
            for img in (ctx.slide_images or [])
        )
        if has_real_image:
            inner = _assemble_svg_layout(block_body, primary_topic, st_lines, font_size, role_scs, ctx.analysis.core_message, has_keymsg, ctx.slide_images, bold_keywords=role_bold)
        else:
            inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)
    else:
        inner = _assemble_generic(primary_topic, st_lines, font_size, has_keymsg, ctx.analysis.core_message, role_scs, bold_keywords=role_bold)

    # V'-1: 팝업 링크를 컨테이너 우측상단에 배치
    popup_html = _popup_links_html(popup_titles, font_size)
    if popup_html:
        inner = f'<div style="position:relative;">{popup_html}{inner}</div>'

    return inner, overridden_css


def _parse_structured_text(st: str, font_size: float) -> tuple[list[tuple[int, str]], list[str]]:
    """structured_text → ([(indent, text)], [팝업 제목 리스트]).
    [팝업:]은 텍스트에서 분리하여 별도 리스트로 반환. [이미지:]는 제거. **bold** → <strong>."""
    lines = []
    popup_titles = []
    for raw_line in st.split("\n"):
        stripped = raw_line.strip()
        if not stripped:
            continue
        indent = 1 if raw_line.startswith("  ") else 0

        # 마커 처리 (bold 변환 전)
        popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
        if popup_match:
            popup_titles.append(popup_match.group(1))
            continue
        if re.search(r'\[이미지:', stripped):
            continue

        # 마크다운 bold → HTML (마커 처리 후)
        stripped = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', stripped)
        lines.append((indent, stripped))
    return lines, popup_titles


def _apply_bold(text: str, keywords: list[str]) -> str:
    """V-10 bold 키워드를 <strong>으로 감쌈."""
    for kw in keywords:
        if kw in text:
            text = text.replace(kw, f"<strong>{kw}</strong>")
    return text


def _popup_links_html(popup_titles: list[str], font_size: float) -> str:
    """팝업 제목 리스트 → 우측상단 배치용 HTML."""
    if not popup_titles:
        return ""
    links = " ".join(
        f'<span style="color:#2563eb;font-size:{font_size - 2}px;cursor:pointer;">[{t}→]</span>'
        for t in popup_titles
    )
    return (
        f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
        f'{links}</div>'
    )


def _st_lines_to_bullets(st_lines: list[tuple[int, str]], font_size: float, bold_keywords: list[str] | None = None) -> str:
    """(indent, text) 리스트를 HTML 불릿으로."""
    bk = bold_keywords or []
    html = ""
    for indent, text in st_lines:
        clean = _apply_bold(text.lstrip("• "), bk)
        if text.startswith("출처:") or clean.startswith("출처:"):
            # V'-3: "출처:" 라벨 삭제, 텍스트만 표시
            caption = re.sub(r'^출처:\s*', '', clean)
            html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
        elif indent == 1:
            html += f'<div class="bl" style="padding-left:1em;font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
        else:
            html += f'<div class="bl" style="font-size:{font_size}px;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
    return html


def _assemble_callout(block_body, topic, st_lines, font_size, bold_keywords=None, emphasis="", sub_topics_text=None):
    """callout-warning/solution 블록에 텍스트 채움."""
    bk = bold_keywords or []
    desc_html = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
    # V-7 종속꼭지 인라인
    sub_html = ""
    for st_text in (sub_topics_text or []):
        sub_html += (
            f'<div style="padding-left:1em;margin-top:2px;color:#9b1c1c;font-size:{font_size-1}px;'
            f'border-left:2px solid #fca5a5;">{_apply_bold(st_text, bk)}</div>'
        )
    # V-9 강조 블록
    emph_html = ""
    if emphasis:
        emph_html = (
            f'<div style="background:#991b1b;color:#fff;border-radius:3px;'
            f'padding:3px 8px;font-size:{font_size-1}px;font-weight:700;margin-top:2px;">'
            f'→ {_apply_bold(emphasis, bk)}</div>'
        )
    inner = re.sub(r'<div class="cw-title">.*?</div>',
        f'<div class="cw-title">{_apply_bold(topic.title, bk)}</div>', block_body, flags=re.DOTALL)
    inner = re.sub(r'<div class="cw-desc">.*?</div>',
        f'<div class="cw-desc" style="font-size:{font_size}px;">{desc_html}{sub_html}{emph_html}</div>', inner, flags=re.DOTALL)
    return inner


def _assemble_card_numbered(topic, st_lines, font_size, role_scs, bold_keywords=None):
    """card-numbered 블록에 카드별 텍스트 채움."""
    # indent=0 주불릿 = 카드 제목, indent=1 = 카드 설명
    cards = []
    current_title = ""
    current_descs = []
    for indent, text in st_lines:
        clean = text.lstrip("• ")
        if indent == 0 and text.startswith("• "):
            if current_title:
                cards.append((current_title, current_descs))
            current_title = clean
            current_descs = []
        else:
            current_descs.append(clean)
    if current_title:
        cards.append((current_title, current_descs))

    # sidebar 라벨
    label = f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:4px;">{topic.title}</div>'

    bk = bold_keywords or []
    card_gap = max(3, int(font_size * 0.4))
    items_html = ""
    for i, (title, descs) in enumerate(cards):
        desc_html = ""
        for d in descs:
            d = _apply_bold(d, bk)
            if d.startswith("출처:"):
                caption = re.sub(r'^출처:\s*', '', d)
                desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;">{caption}</div>\n'
            else:
                desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{d}</span></div>\n'
        num_size = int(font_size * 2)
        items_html += (
            f'<div class="cn-item">'
            f'<div class="cn-number" style="background:#2563eb;width:{num_size}px;height:{num_size}px;font-size:{font_size-1}px;">{i+1}</div>'
            f'<div class="cn-body">'
            f'<div class="cn-title" style="font-size:{font_size}px;">{_apply_bold(title, bk)}</div>'
            f'<div class="cn-desc" style="font-size:{font_size-1}px;white-space:normal;">{desc_html}</div>'
            f'</div></div>\n'
        )

    return f'{label}<div class="block-card-num" style="gap:{card_gap}px;">{items_html}</div>'


def _assemble_banner(block_body, message):
    """banner-gradient 블록에 메시지 채움."""
    inner = re.sub(r'<div class="bg-text">.*?</div>',
        f'<div class="bg-text">{message}</div>', block_body, flags=re.DOTALL)
    inner = re.sub(r'<div class="bg-sub">.*?</div>', '', inner, flags=re.DOTALL)
    return inner


def _assemble_svg_layout(block_body, topic, st_lines, font_size, role_scs, core_message, has_keymsg, slide_images=None, bold_keywords=None):
    """이미지(좌) + 텍스트(우) + key-msg(하단) 레이아웃. 실제 이미지 파일 사용."""
    # 실제 이미지가 있으면 <img> 사용, 없으면 빈 placeholder
    img_html = ""
    if slide_images:
        for img in slide_images:
            b64 = img.get("b64", "")
            if b64:
                img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
                break

    svg_sc = next((sc for sc in role_scs if sc["name"] == "svg"), None)
    text_sc = next((sc for sc in role_scs if sc["name"] == "text_and_table"), None)
    svg_w = int(svg_sc["width_px"]) if svg_sc else 200
    svg_h = int(svg_sc["height_px"]) if svg_sc else 265

    # 출처 라인을 이미지 아래 캡션으로 분리
    caption_lines = []
    content_lines = []
    for indent, text in st_lines:
        clean = text.lstrip("• ")
        if text.startswith("출처:") or clean.startswith("출처:"):
            caption_lines.append(re.sub(r'^출처:\s*', '', clean))
        else:
            content_lines.append((indent, text))

    img_caption = ""
    if caption_lines:
        img_caption = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{caption_lines[0]}</div>'

    bullets = _st_lines_to_bullets(content_lines, font_size, bold_keywords=bold_keywords)
    bk = bold_keywords or []

    keymsg_html = ""
    if has_keymsg and core_message:
        keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
        km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
        keymsg_html = (
            f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
            f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
            f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
            f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
        )

    return (
        f'<div style="display:flex;flex-direction:column;height:100%;padding:8px;box-sizing:border-box;">'
        f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
        f'{_apply_bold(topic.title, bk)}</div>'
        f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;flex:1;min-height:0;align-items:flex-start;">'
        f'<div style="width:{svg_w}px;flex-shrink:0;"><div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>{img_caption}</div>'
        f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
        f'</div>{keymsg_html}</div>'
    )


def _assemble_generic(topic, st_lines, font_size, has_keymsg, core_message, role_scs, bold_keywords=None):
    """기타 블록: 제목 + 불릿."""
    bk = bold_keywords or []
    bullets = _st_lines_to_bullets(st_lines, font_size, bold_keywords=bk)
    keymsg_html = ""
    if has_keymsg and core_message:
        keymsg_sc = next((sc for sc in role_scs if sc["name"] == "keymsg"), None)
        km_h = int(keymsg_sc["height_px"]) if keymsg_sc else 37
        keymsg_html = (
            f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
            f'padding:4px 8px;font-size:{font_size+2}px;font-weight:700;color:#1e40af;'
            f'text-align:center;height:{km_h}px;display:flex;align-items:center;'
            f'justify-content:center;flex-shrink:0;">{_apply_bold(core_message, bk)}</div>'
        )
    return (
        f'<div style="height:100%;padding:6px;font-size:{font_size}px;line-height:1.4;">'
        f'<div style="font-weight:700;font-size:{font_size+1}px;margin-bottom:4px;">{_apply_bold(topic.title, bk)}</div>'
        f'{bullets}{keymsg_html}</div>'
    )


def assemble_slide_html(ctx: "PipelineContext", title_text: str = "") -> str:
    """전체 슬라이드를 조립하여 HTML 반환.

    filled, assembled, stage_2 모두 이 함수를 호출.
    layout_template에 따라 유형 A/B 분기.
    """
    if ctx.analysis.layout_template == "B":
        return _assemble_slide_html_type_b(ctx, title_text)
    return _assemble_slide_html_type_a(ctx, title_text)


def _assemble_slide_html_type_a(ctx: "PipelineContext", title_text: str = "") -> str:
    """유형 A 전체 슬라이드 조립 (기존 코드 그대로)."""
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    pad = tokens["spacing_page"]
    header_h = tokens.get("header_height", 66)
    gap_block = tokens["spacing_block"]
    gap_small = tokens["spacing_small"]

    ratio = ctx.container_ratio
    slide_w = tokens.get("slide_width", 1280)
    slide_h = tokens.get("slide_height", 720)
    inner_w = slide_w - pad * 2
    body_w = int(inner_w * ratio[0] / 100)
    sidebar_w = inner_w - body_w - gap_block

    fit = ctx.fit_result or {}
    redist = fit.get("redistribution", {})

    all_css = set()
    role_htmls = {}

    for role in ["배경", "본심", "첨부", "결론"]:
        html, css = assemble_role_html(role, ctx)
        role_htmls[role] = html
        all_css.update(css)

    # 좌표 계산
    bg_h = int(redist.get("배경", ctx.containers.get("배경", type("", (), {"height_px": 0})).height_px))
    core_h = int(redist.get("본심", ctx.containers.get("본심", type("", (), {"height_px": 0})).height_px))
    sb_h = int(redist.get("첨부", ctx.containers.get("첨부", type("", (), {"height_px": 0})).height_px))
    concl_h = int(redist.get("결론", ctx.containers.get("결론", type("", (), {"height_px": 0})).height_px))

    bg_top = pad + header_h + gap_block
    core_top = bg_top + bg_h + gap_small
    sb_top = bg_top

    # V'-4: after(redistribution 있을 때)에서 결론 바로 위까지 body/sidebar 채움
    if redist:
        ft_top = slide_h - pad - concl_h - gap_block
        column_bottom = ft_top - gap_block
        core_h = column_bottom - core_top
        sb_h = column_bottom - sb_top
    else:
        ft_top = max(core_top + core_h, bg_top + sb_h) + gap_block

    title = title_text or ctx.analysis.title or ""
    css_block = "\n".join(all_css)

    return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
:root{{--radius:6px;--line-height-ko:1.7;--color-accent:#2563eb;--color-primary:#1e293b;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;}}.bl-t{{flex:1;}}
{css_block}
</style></head><body>
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">
<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>

<div class="area-body" style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border:2px solid #dc2626;border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#dc2626;opacity:0.5;">배경 ({body_w}x{bg_h}px)</span>
{role_htmls.get("배경", "")}</div>

<div class="area-body" style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border:2px solid #2563eb;border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#2563eb;opacity:0.5;">본심 ({body_w}x{core_h}px)</span>
{role_htmls.get("본심", "")}</div>

<div class="area-sidebar" style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border:2px solid #16a34a;border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#16a34a;opacity:0.5;">첨부 ({sidebar_w}x{sb_h}px)</span>
{role_htmls.get("첨부", "")}</div>

<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border:2px solid #7c3aed;border-radius:8px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:#7c3aed;opacity:0.5;">결론 ({inner_w}x{concl_h}px)</span>
{role_htmls.get("결론", "")}</div>

</div></body></html>"""


def _assemble_slide_html_type_b(ctx: "PipelineContext", title_text: str = "") -> str:
    """유형 B 전체 슬라이드 조립: 상단(top+이미지) + 하단 2분할 + 결론.

    assemble_stage2._assemble_type_b의 로직을 PipelineContext 기반으로 통합.
    filled/after 파이프라인에서 호출되어 Selenium 측정 가능한 HTML 생성.
    """
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    pad = tokens["spacing_page"]
    header_h = tokens.get("header_height", 66)
    gap_block = tokens["spacing_block"]
    gap_small = tokens["spacing_small"]
    slide_w = tokens.get("slide_width", 1280)
    slide_h = tokens.get("slide_height", 720)
    inner_w = slide_w - pad * 2

    ps = ctx.page_structure.roles
    enh = ctx.enhancement_result or {}
    bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
    font_h = ctx.font_hierarchy
    font_size = font_h.core
    title = title_text or ctx.analysis.title or ""
    core_message = ctx.analysis.core_message or ""
    slide_images = ctx.slide_images or []
    norm_sections = ctx.normalized.sections or []

    # ── zone별 역할 분류 ──
    top_role = None
    bottom_left_role = None
    bottom_right_role = None
    footer_role = None

    for role_name, info in ps.items():
        if not isinstance(info, dict):
            continue
        zone = info.get("zone", "")
        if zone == "top":
            top_role = (role_name, info)
        elif zone == "bottom_left":
            bottom_left_role = (role_name, info)
        elif zone == "bottom_right":
            bottom_right_role = (role_name, info)
        elif zone == "footer":
            footer_role = (role_name, info)

    # ── 좌표 계산 (containers에서 동적으로) ──
    footer_ci = ctx.containers.get(footer_role[0]) if footer_role else None
    footer_h_px = footer_ci.height_px if footer_ci else 53
    ft_top = slide_h - pad - footer_h_px

    top_ci = ctx.containers.get(top_role[0]) if top_role else None
    top_h = top_ci.height_px if top_ci else 200
    top_top = pad + header_h + gap_block

    # 이미지: block_constraints 또는 slide_images에서 판단
    img_constraints = top_ci.block_constraints if top_ci else {}
    img_w = img_constraints.get("img_width_px", 0)
    has_image = img_constraints.get("has_image", False)
    # block_constraints에 has_image가 없어도 slide_images에 b64가 있으면 사용
    if not has_image and slide_images:
        has_image = any(img.get("b64") for img in slide_images)
        if has_image and img_w <= 0:
            # 이미지 폭: top_h * ratio, 최대 45%
            first_img = next((img for img in slide_images if img.get("b64")), None)
            if first_img:
                img_ratio = first_img.get("ratio", 1)
                img_w = min(int(top_h * img_ratio), int(inner_w * 0.45))

    img_h = 0
    img_html = ""
    if has_image and slide_images:
        for img in slide_images:
            b64 = img.get("b64", "")
            if b64:
                img_ratio = img.get("ratio", 1)
                img_h = int(img_w / img_ratio) if img_ratio > 0 else top_h
                img_html = f'<img src="data:image/png;base64,{b64}" style="width:100%;height:100%;object-fit:contain;" />'
                break

    # 하단
    bottom_top = top_top + top_h + gap_small

    # V'-4: 결론 바로 위까지 채움
    fit = ctx.fit_result or {}
    redist = fit.get("redistribution", {})
    column_bottom = ft_top - gap_block
    bottom_h = column_bottom - bottom_top
    bottom_col_w = (inner_w - gap_block) // 2

    # ── 유틸 ──
    def _bold(text: str, role: str) -> str:
        for kw in bold_kw.get(role, []):
            if kw in text:
                text = text.replace(kw, f"<strong>{kw}</strong>")
        return text

    # ── 상단 조립: normalized.sections에서 직접 가져오기 ──
    top_html = ""
    if top_role:
        rn = top_role[0]
        topic_title_from_section = ""
        top_contents = []
        for s in norm_sections:
            if s.get("level") == 3:
                break  # level=3(소목차) 나오면 상단 끝
            if not topic_title_from_section and s.get("title"):
                topic_title_from_section = s["title"]
            content = s.get("content", "")
            if content:
                if s.get("title") and s["title"] != topic_title_from_section:
                    top_contents.append(f"### {s['title']}")
                top_contents.append(content)
        all_text = "\n".join(top_contents)
        all_text_clean = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', all_text)

        # 팝업 분리
        popup_titles = []
        content_lines = []
        for line in all_text_clean.split("\n"):
            stripped = line.strip()
            if not stripped:
                continue
            popup_match = re.search(r'\[팝업:\s*([^\]]+)\]', stripped)
            if popup_match:
                popup_titles.append(popup_match.group(1))
                continue
            if re.search(r'\[이미지:', stripped):
                continue
            content_lines.append(stripped)

        popup_html = _popup_links_html(popup_titles, font_size)

        # 소제목(### 또는 D1:) + 불릿(D2:)을 카드형으로 분리
        sections = []
        current_section = ("", [])
        for line in content_lines:
            if line.startswith("### ") or line.startswith("###"):
                if current_section[0] or current_section[1]:
                    sections.append(current_section)
                current_section = (line.lstrip("# ").strip(), [])
            elif re.match(r'^D1:\s*', line):
                # D1 = 1단 불릿 = 소제목 (카드 제목)
                title_text = re.sub(r'^D1:\s*', '', line).lstrip("• ")
                if current_section[0] or current_section[1]:
                    sections.append(current_section)
                current_section = (_bold(title_text, rn), [])
            elif re.match(r'^D[2-9]:\s*', line):
                # D2+ = 하위 불릿 = 본문
                clean = re.sub(r'^D[2-9]:\s*', '', line).lstrip("• ")
                if clean.startswith("출처:"):
                    continue
                current_section[1].append(_bold(clean, rn))
            else:
                clean = line.lstrip("• ")
                if clean.startswith("출처:"):
                    continue
                current_section[1].append(_bold(clean, rn))
        if current_section[0] or current_section[1]:
            sections.append(current_section)

        # 카드형 HTML
        _card_colors = [
            ("linear-gradient(135deg, #1a365d, #2d3748)", "#e2e8f0"),
            ("linear-gradient(135deg, #1e3a2f, #2d4a3e)", "#e2e8f0"),
            ("linear-gradient(135deg, #3b1f2b, #4a2d3b)", "#e2e8f0"),
            ("linear-gradient(135deg, #2d2b55, #3d3b65)", "#e2e8f0"),
        ]
        card_pad = int(font_size * 0.6)
        card_gap = max(3, int(font_size * 0.4))
        indent_body = int(font_size * 1.2)

        bullets = ""
        if len(sections) > 1 and sections[0][0]:
            for ci, (sec_title, sec_items) in enumerate(sections):
                bg, text_color = _card_colors[ci % len(_card_colors)]
                items_html = "".join(
                    f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
                    f'<span style="color:{text_color};font-size:{font_size-1}px;line-height:1.5;">• {item}</span></div>'
                    for item in sec_items
                )
                if sec_title:
                    bullets += (
                        f'<div style="background:{bg};border-radius:{int(font_size*0.4)}px;'
                        f'padding:{card_pad}px {int(card_pad*1.5)}px;margin-bottom:{card_gap}px;">'
                        f'<div style="font-size:{font_size}px;font-weight:700;color:#fbbf24;'
                        f'margin-bottom:{int(font_size*0.3)}px;">{_bold(sec_title, rn)}</div>'
                        f'{items_html}</div>\n'
                    )
                else:
                    bullets += items_html
        else:
            for _, sec_items in sections:
                for item in sec_items:
                    bullets += (
                        f'<div style="padding-left:{indent_body}px;margin-bottom:1px;">'
                        f'<span style="font-size:{font_size}px;">• {item}</span></div>\n'
                    )

        # 이미지 캡션
        img_caption = ""
        norm_images = ctx.normalized.images or []
        if norm_images:
            img_caption = norm_images[0].get("alt", "")
        if not img_caption:
            for line in all_text.split("\n"):
                stripped = line.strip().lstrip("• ")
                if stripped.startswith("출처:"):
                    img_caption = re.sub(r'^출처:\s*', '', stripped)
                    break
        caption_html = f'<div style="font-size:{font_size-2}px;color:#94a3b8;text-align:center;margin-top:2px;">{img_caption}</div>' if img_caption else ""

        # 이미지 블록
        img_block = ""
        if has_image and img_html:
            img_block = (
                f'<div style="width:{img_w}px;flex-shrink:0;">'
                f'<div style="height:{img_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
                f'{caption_html}</div>'
            )

        topic_title = _bold(topic_title_from_section or rn, rn)

        top_html = (
            f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
            f'display:flex;flex-direction:column;justify-content:space-between;">'
            f'{popup_html}'
            f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{topic_title}</div>'
            f'<div style="display:flex;gap:{max(6, int(font_size*0.8))}px;align-items:flex-start;flex:1;">'
            f'<div style="flex:1;font-size:{font_size}px;line-height:1.55;color:#333;">{bullets}</div>'
            f'{img_block}</div></div>'
        )

    # ── 하단: normalized.sections에서 직접 매핑 ──
    bottom_title = ""
    sub_sections_from_norm = []
    found_level3 = False
    for s in norm_sections:
        if s.get("level") == 3:
            found_level3 = True
            sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
    # 하단 대목차: level=3 바로 앞의 level=2
    for s in norm_sections:
        if s.get("level") == 2:
            idx = norm_sections.index(s)
            if idx + 1 < len(norm_sections) and norm_sections[idx + 1].get("level") == 3:
                bottom_title = s.get("title", "")
                break

    bl_indent = int(font_size * 1.2)

    # 하단 좌측
    bl_html = ""
    if sub_sections_from_norm and bottom_left_role:
        rn = bottom_left_role[0]
        sub_title, sub_content = sub_sections_from_norm[0]
        sub_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content)

        bul = ""
        for line in sub_content.split("\n"):
            stripped = line.strip()
            if not stripped:
                continue
            # D마커 제거 + depth별 스타일
            depth = 1
            dm = re.match(r'^D(\d+):\s*', stripped)
            if dm:
                depth = int(dm.group(1))
                stripped = re.sub(r'^D\d+:\s*', '', stripped)
            clean = stripped.lstrip("- ").lstrip("• ")
            clean = _bold(clean, rn)
            pad = bl_indent * depth
            fs = font_size if depth == 1 else font_size - 1
            weight = "font-weight:600;" if depth == 1 else ""
            bul += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'

        bl_html = (
            f'<div style="height:100%;padding:{gap_small}px;box-sizing:border-box;">'
            f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title, rn)}</div>'
            f'<div style="line-height:1.55;color:#333;">{bul}</div></div>'
        )

    # 하단 우측 + 표 요약
    br_html = ""
    if bottom_right_role and len(sub_sections_from_norm) > 1:
        rn = bottom_right_role[0]
        sub_title_br, sub_content_br = sub_sections_from_norm[1]
        sub_content_br = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', sub_content_br)

        # 팝업 링크
        popup_link_title = f"{sub_title_br} 바로가기"
        popup_html_br = (
            f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">'
            f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{popup_link_title} →]</span></div>'
        )

        # 불릿
        table_summaries = enh.get("table_summaries", {})
        bul = ""
        if not table_summaries:
            for line in sub_content_br.split("\n"):
                stripped = line.strip()
                if not stripped:
                    continue
                depth = 1
                dm = re.match(r'^D(\d+):\s*', stripped)
                if dm:
                    depth = int(dm.group(1))
                    stripped = re.sub(r'^D\d+:\s*', '', stripped)
                clean = stripped.lstrip("- ").lstrip("• ")
                if clean:
                    clean = _bold(clean, rn)
                    pad = bl_indent * depth
                    fs = font_size if depth == 1 else font_size - 1
                    weight = "font-weight:600;" if depth == 1 else ""
                    bul += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'

        # 표 요약 HTML
        table_html_br = ""
        for ts_key, ts_data in table_summaries.items():
            fmt = ts_data.get("format", "text")
            if fmt == "table":
                cols = ts_data.get("columns", [])
                data = ts_data.get("data", [])
                col_count = len(cols)
                if col_count > 0 and data:
                    header_cells = "".join(
                        f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.5)}px;font-size:{font_size-2}px;font-weight:700;color:#fff;text-align:center;">{c}</div>'
                        for c in cols
                    )
                    rows_html = ""
                    for ri, row in enumerate(data):
                        bg = "#f8fafc" if ri % 2 == 0 else "#fff"
                        cells = ""
                        for ci_idx, cell in enumerate(row):
                            c_color = "#1e40af" if ci_idx == 0 else "#475569"
                            c_weight = "600" if ci_idx == 0 else "400"
                            cells += f'<div style="padding:{int(font_size*0.2)}px {int(font_size*0.4)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{_bold(str(cell), rn)}</div>'
                        rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};">{cells}</div>\n'
                    table_html_br = (
                        f'<div style="margin-top:{int(font_size*0.5)}px;border:1px solid #e2e8f0;border-radius:{int(font_size*0.4)}px;overflow:hidden;">'
                        f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_cells}</div>'
                        f'{rows_html}</div>'
                    )
            elif fmt == "bullets":
                items = ts_data.get("items", [])
                table_html_br = "".join(
                    f'<div style="padding-left:{int(font_size*1.2)}px;font-size:{font_size-1}px;margin-bottom:1px;">• {_bold(str(item), rn)}</div>'
                    for item in items
                )
            elif fmt == "text":
                table_html_br = f'<div style="font-size:{font_size-1}px;color:#475569;margin-top:{int(font_size*0.5)}px;">{_bold(str(ts_data.get("summary", "")), rn)}</div>'

        br_html = (
            f'<div style="position:relative;height:100%;padding:{gap_small}px;box-sizing:border-box;'
            f'display:flex;flex-direction:column;">'
            f'{popup_html_br}'
            f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">{_bold(sub_title_br, rn)}</div>'
            f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bul}</div>'
            f'{table_html_br}</div>'
        )

    # ── 결론 ──
    footer_html = ""
    if footer_role:
        rn = footer_role[0]
        footer_html = (
            f'<div class="block-banner-grad" style="background:linear-gradient(135deg,#006aff 0%,#00aaff 100%);'
            f'border-radius:8px;padding:{int(font_size*1.2)}px;text-align:center;color:#fff;height:100%;'
            f'display:flex;align-items:center;justify-content:center;">'
            f'<div style="font-size:{font_h.key_msg}px;font-weight:700;">{_bold(core_message, rn)}</div></div>'
        )

    # ── HTML 조립 ──
    _color_palette = ["#2563eb", "#16a34a", "#d97706", "#7c3aed"]

    return f"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
.bl{{display:flex;gap:0;margin-bottom:2px;}}.bl-m{{flex-shrink:0;width:1em;text-align:left;}}.bl-t{{flex:1;word-break:keep-all;}}
</style></head><body>
<div class="slide" style="width:{slide_w}px;height:{slide_h}px;background:white;position:relative;border:1px solid #ccc;">

<div style="position:absolute;left:{pad}px;top:{pad}px;width:{inner_w}px;height:{header_h}px;background:#f8fafc;border-bottom:3px solid #2563eb;display:flex;align-items:center;padding:0 20px;font-size:{tokens.get('font_title', 22)}px;font-weight:900;color:#1e293b;">{title}</div>

<div class="area-top" style="position:absolute;left:{pad}px;top:{top_top}px;width:{inner_w}px;height:{top_h}px;border:2px solid {_color_palette[0]};border-radius:6px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[0]};opacity:0.5;">상단 ({inner_w}x{top_h}px)</span>
{top_html}</div>

<div class="area-bottom" style="position:absolute;left:{pad}px;top:{bottom_top}px;width:{inner_w}px;height:{bottom_h}px;border:2px solid {_color_palette[1]};border-radius:6px;overflow:hidden;">
<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;padding:{gap_small}px {gap_small}px 4px;border-bottom:1px solid #e2e8f0;">{_bold(bottom_title, "")}</div>
<div style="display:flex;height:calc(100% - {int(font_size*1.5 + gap_small + 5)}px);">
<div class="area-bottom-left" style="flex:1;overflow:hidden;">
{bl_html}</div>
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
<div class="area-bottom-right" style="flex:1;overflow:hidden;">
{br_html}</div>
</div></div>

<div class="area-footer" style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h_px}px;border-radius:8px;overflow:hidden;">
<span style="position:absolute;top:2px;left:4px;font-size:7px;color:{_color_palette[3]};opacity:0.5;">결론 ({inner_w}x{footer_h_px}px)</span>
{footer_html}</div>

</div></body></html>"""
