"""Stage 1.8 context 데이터만으로 stage_2 HTML을 코드로 조립.

사용하는 데이터 (해당 run의 stage_1_8_context.json에서):
- topics[].structured_text — Kei가 원본 85% 보존하여 구조화한 텍스트 (조립용)
- topics[].source_data — Kei가 정리한 메타 요약 (fallback)
- references[].design_reference_html — 블록 템플릿 렌더링 결과 (SVG 포함)
- sub_layouts — 서브 컨테이너 치수
- enhancement_result — V-7 종속꼭지, V-9 강조, V-10 bold
- containers — 컨테이너 크기 (재배분 후)
- font_hierarchy — 폰트 크기
- analysis.core_message — 핵심 메시지
- normalized.popups — 팝업 데이터 (비교표 등)

외부 데이터, 하드코딩 없음. 이 JSON 파일만으로 조립.
"""
import json
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent.parent))


def assemble(run_dir: str):
    run = Path(run_dir)
    ctx = json.loads((run / "stage_1_8_context.json").read_text(encoding="utf-8"))

    topics = ctx["topics"]
    topic_map = {t["id"]: t for t in topics}
    ps = ctx["page_structure"]
    if "roles" in ps:
        ps = ps["roles"]
    containers = ctx["containers"]
    refs = ctx["references"]
    sub_layouts = ctx.get("sub_layouts", {})
    enh = ctx.get("enhancement_result", {})
    fit = ctx.get("fit_result", {})
    redist = fit.get("redistribution", {})
    fh = ctx.get("font_hierarchy", {})
    core_message = ctx.get("analysis", {}).get("core_message", "")
    bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
    emphasis_blocks = enh.get("emphasis_blocks", [])
    subordinate_treatments = enh.get("subordinate_treatments", [])
    popups = ctx.get("normalized", {}).get("popups", [])
    title = ctx.get("analysis", {}).get("title", "")
    ratio = ctx.get("container_ratio", [71, 29])
    layout_template = ctx.get("analysis", {}).get("layout_template", "A")

    # Phase X-B: 유형 B면 별도 함수로 분기
    if layout_template == "B":
        return _assemble_type_b(run, ctx)

    # ── 유틸 (유형 A) ──
    def bold(text, role):
        """V-10 bold 키워드 적용."""
        for kw in bold_kw.get(role, []):
            if kw in text:
                text = text.replace(kw, f"<strong>{kw}</strong>")
        return text

    def get_text(topic):
        """structured_text 우선, 없으면 source_data fallback."""
        return topic.get("structured_text", "") or topic.get("source_data", "")

    def get_emphasis(role):
        """V-9 강조 문장 가져오기."""
        for e in emphasis_blocks:
            if e.get("role") == role:
                return e.get("sentence", "")
        return ""

    def extract_block_html(ref_html):
        """design_reference_html에서 주석 제거, CSS와 본문 분리."""
        css_parts = re.findall(r'<style>(.*?)</style>', ref_html, re.DOTALL)
        body = re.sub(r'<style>.*?</style>', '', ref_html, flags=re.DOTALL)
        body = re.sub(r'<!--.*?-->', '', body, flags=re.DOTALL)
        return "\n".join(css_parts), body.strip()

    def popup_link(text, role):
        """[팝업: 제목] 마커를 클릭 가능한 링크로 변환."""
        def _repl(m):
            popup_title = m.group(1)
            return f'<span style="color:#2563eb;font-size:{fh.get("sidebar",10)}px;cursor:pointer;">[{popup_title} 상세보기→]</span>'
        return re.sub(r'\[팝업:\s*([^\]]+)\]', _repl, text)

    def image_marker(text):
        """[이미지: 제목] 마커를 제거 (SVG로 별도 처리되므로)."""
        return re.sub(r'\[이미지:\s*[^\]]+\]', '', text)

    def structured_to_bullets(text, role, font_size, exclude_source=False):
        """structured_text → (불릿 HTML, [팝업 제목 리스트]).
        [팝업:]은 텍스트에서 분리. [이미지:]는 제거. **bold** → <strong>. 출처: → 캡션."""
        # [팝업:], [이미지:] 마커를 bold 변환 전에 먼저 처리
        items = []  # [(indent, text)]
        popup_titles = []
        for raw_line in text.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
            # [이미지:] → 제거 (bold 변환 전)
            if re.search(r'\[이미지:', stripped):
                continue

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

        # items → HTML
        html = ""
        for indent, line in items:
            line = bold(line, role)
            clean = line.lstrip("• ")
            if line.startswith("출처:") or clean.startswith("출처:"):
                if exclude_source:
                    continue  # 이미지 아래에 별도 배치됨
                caption = re.sub(r'^출처:\s*', '', clean)
                html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;margin-top:3px;">{caption}</div>\n'
            elif indent == 1:
                html += f'<div class="bl bl-sub" style="padding-left:1em;"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
            else:
                html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{clean}</span></div>\n'
        return html, popup_titles

    def find_popup(title_keyword):
        """팝업 목록에서 제목 키워드로 매칭."""
        for p in popups:
            if title_keyword in p.get("title", ""):
                return p
        return None

    def popup_to_compact_table(popup_content, font_size):
        """팝업의 마크다운 표를 compact HTML 테이블로 변환."""
        # 마크다운 bold → HTML (팝업 정화가 안 된 run 대응)
        popup_content = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', popup_content)
        # JSX style 제거
        popup_content = re.sub(r'<div\s+style=\{\{[^}]*\}\}\s*>', '', popup_content)
        popup_content = popup_content.replace('</div>', '')
        popup_content = re.sub(r'<br\s*/?>', '\n', popup_content)

        lines = popup_content.split("\n")
        table_lines = [l.strip() for l in lines if l.strip().startswith("|")]
        if len(table_lines) < 3:
            return ""
        # 헤더
        headers = [c.strip() for c in table_lines[0].split("|") if c.strip()]
        # 구분선 스킵 (|---|---|)
        rows = []
        for tl in table_lines[2:]:
            cells = [c.strip() for c in tl.split("|") if c.strip()]
            if cells:
                rows.append(cells)
        if not headers or not rows:
            return ""
        # HTML 테이블 (compact)
        col_count = len(headers)
        header_html = "".join(f'<div style="padding:4px 8px;font-size:{font_size-1}px;font-weight:700;color:#fff;text-align:center;">{h}</div>' for h in headers)
        rows_html = ""
        for ri, row in enumerate(rows[:4]):  # 최대 4행
            bg = "#f8fafc" if ri % 2 == 0 else "#fff"
            cells_html = ""
            for ci, cell in enumerate(row):
                align = "center" if ci == len(row) // 2 else ("left" if ci == 0 else "right")
                weight = "600" if ci == 0 else "400"
                color = "#1e40af" if ci == 0 else "#64748b"
                cells_html += f'<div style="padding:4px 8px;font-size:{font_size-2}px;color:{color};font-weight:{weight};text-align:{align};">{bold(cell, "본심")}</div>'
            rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells_html}</div>\n'

        return (
            f'<div style="border:1px solid #e2e8f0;border-radius:6px;overflow:hidden;margin-top:6px;">'
            f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);background:linear-gradient(135deg,#0d47a1,#1565c0);">{header_html}</div>'
            f'{rows_html}</div>'
        )

    # ── 좌표 계산 (디자인 토큰에서 동적으로) ──
    from src.fit_verifier import _load_design_tokens
    tokens = _load_design_tokens()
    slide_w = tokens.get("slide_width", 1280)
    slide_h = tokens.get("slide_height", 720)
    pad = tokens["spacing_page"]
    header_h = tokens.get("header_height", 66)
    gap_block = tokens["spacing_block"]
    gap_small = tokens["spacing_small"]
    inner_w = slide_w - pad * 2
    body_w = int(inner_w * ratio[0] / 100)
    sidebar_w = inner_w - body_w - gap_block

    all_css = set()
    role_htmls = {}

    # ── 각 역할별 조립 ──
    for role in ["배경", "본심", "첨부", "결론"]:
        info = ps.get(role, {})
        if not isinstance(info, dict):
            continue
        tids = info.get("topic_ids", [])
        if not tids:
            continue

        ref_list = refs.get(role, [])
        r0 = ref_list[0] if ref_list else {}
        block_id = r0.get("block_id", "")
        ref_html = r0.get("design_reference_html", "")
        is_hier = r0.get("is_hierarchical", False)
        sup_tids = r0.get("supporting_topic_ids", [])
        primary_tid = r0.get("topic_id") or (tids[0] if tids else None)

        ci = containers.get(role, {})
        h = int(redist.get(role, ci.get("height_px", 0)))
        w = ci.get("width_px", 0)
        font_key = {"배경": "bg", "본심": "core", "첨부": "sidebar", "결론": "key_msg"}.get(role, "core")
        font_size = fh.get(font_key, 12)

        block_css, block_body = extract_block_html(ref_html)
        if block_css:
            # #7: CSS font-size override (font_hierarchy 기준으로 큰 폰트 축소)
            def _override_font(m, fs=font_size):
                val = float(m.group(1))
                if val > fs + 2:
                    return f"font-size: {fs + 1}px"
                elif val > fs:
                    return f"font-size: {fs}px"
                return m.group(0)
            block_css = re.sub(r'font-size:\s*(\d+(?:\.\d+)?)px', _override_font, block_css)
            # gap, padding, number size도 font_size 비례
            block_css = re.sub(r'gap:\s*\d+px', f'gap: {max(3, int(font_size * 0.4))}px', block_css)
            block_css = 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', block_css)
            block_css = re.sub(r'padding:\s*12px\s+16px', f'padding: {int(font_size*0.7)}px {int(font_size)}px', block_css)
            # #6: white-space: pre-line → normal (카드 불릿 간 빈줄 방지)
            block_css = block_css.replace('white-space: pre-line', 'white-space: normal')
            all_css.add(block_css)

        layout = sub_layouts.get(role, {})
        scs = layout.get("sub_containers", [])

        primary_topic = topic_map.get(primary_tid, {})
        topic_title = bold(primary_topic.get("title", ""), role)

        # ════════════════════════════════════
        # 결론
        # ════════════════════════════════════
        if role == "결론":
            assembled = block_body
            assembled = re.sub(r'>핵심 메시지 한 줄<', f'>{bold(core_message, role)}<', assembled)
            assembled = re.sub(r'>부연 설명<', '><', assembled)
            role_htmls[role] = assembled

        # ════════════════════════════════════
        # 첨부 — structured_text의 주불릿(•) = 카드 제목, 하위불릿(  •) = 카드 설명
        # ════════════════════════════════════
        elif role == "첨부":
            st = get_text(primary_topic)
            # 마크다운 bold → HTML
            st = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', st)
            # 파싱: 주불릿(줄 시작 "• ")을 카드 구분자로
            items = []  # [(title, [desc_lines])]
            current_title = ""
            current_descs = []
            for line in st.split("\n"):
                stripped = line.strip()
                if not stripped:
                    continue
                # 주불릿: 새 카드 시작
                if stripped.startswith("• ") and not line.startswith("  "):
                    if current_title:
                        items.append((current_title, current_descs))
                    current_title = stripped[2:]
                    current_descs = []
                # 하위불릿 또는 출처: 현재 카드의 설명
                elif stripped.startswith("• ") and line.startswith("  "):
                    current_descs.append(stripped[2:])
                elif stripped.startswith("출처:"):
                    current_descs.append(stripped)
                else:
                    current_descs.append(stripped)
            if current_title:
                items.append((current_title, current_descs))
            if not items:
                items = [(primary_topic.get("title", ""), [st])]

            cards = ""
            for i, (card_title, desc_lines) in enumerate(items):
                desc_html = ""
                for dl in desc_lines:
                    dl = dl.strip()
                    if not dl:
                        continue
                    dl = bold(dl, role)
                    if dl.startswith("출처:"):
                        caption = re.sub(r'^출처:\s*', '', dl)
                        desc_html += f'<div style="font-size:{font_size-2}px;color:#94a3b8;margin-top:3px;">{caption}</div>\n'
                    else:
                        desc_html += f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{dl}</span></div>\n'
                cards += (
                    f'<div style="display:flex;gap:{max(4, int(font_size * 0.8))}px;align-items:flex-start;padding:{int(font_size*0.7)}px {int(font_size)}px;'
                    f'background:#f8fafc;border-radius:8px;border:1px solid #e2e8f0;">'
                    f'<div style="width:{int(font_size*2)}px;height:{int(font_size*2)}px;border-radius:50%;background:#2563eb;'
                    f'display:flex;align-items:center;justify-content:center;color:#fff;'
                    f'font-size:{font_size-1}px;font-weight:800;flex-shrink:0;">{i+1}</div>'
                    f'<div>'
                    f'<div style="font-size:{font_size}px;font-weight:700;color:#1e293b;margin-bottom:2px;">'
                    f'{bold(card_title.strip(), role)}</div>'
                    f'<div style="font-size:{font_size-1}px;color:#475569;line-height:1.4;">'
                    f'{desc_html}</div></div></div>\n'
                )
            # 첨부 컨테이너 높이에 맞게 gap/padding 동적 조절
            sb_container_h = int(redist.get(role, ci.get("height_px", 0)))
            n_cards = len(items)
            sb_pad = min(10, max(4, sb_container_h // 50))
            sb_gap = min(7, max(3, (sb_container_h - sb_pad * 2) // (n_cards * 10)))

            role_htmls[role] = (
                f'<div style="padding:{sb_pad}px;display:flex;flex-direction:column;gap:{sb_gap}px;'
                f'height:100%;box-sizing:border-box;">'
                f'<div style="font-size:{font_size-1}px;color:#64748b;font-weight:700;margin-bottom:2px;">'
                f'{topic_title}</div>{cards}</div>'
            )

        # ════════════════════════════════════
        # 배경 — callout 구조 + 종속꼭지 인라인 + 강조
        # ════════════════════════════════════
        elif role == "배경":
            sub_html = ""
            if is_hier and sup_tids:
                for st_id in sup_tids:
                    st_topic = topic_map.get(st_id, {})
                    st_text = get_text(st_topic)
                    st_text = popup_link(st_text, role)
                    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;">'
                        f'{bold(st_text[:120], role)}</div>'
                    )

            emph = get_emphasis(role)
            emph_html = ""
            if emph:
                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'→ {emph}</div>'
                )

            bullets_html, bg_popups = structured_to_bullets(get_text(primary_topic), role, font_size)
            # V'-1: 팝업 링크 우측상단
            bg_popup_html = ""
            if bg_popups:
                links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in bg_popups)
                bg_popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'

            # padding을 컨테이너 높이에 맞게 동적 조절
            container_h = int(redist.get(role, ci.get("height_px", 0)))
            pad_v = min(10, max(4, container_h // 20))  # 컨테이너 높이의 5% 정도
            pad_h = min(14, max(6, container_h // 10))

            role_htmls[role] = (
                f'<div style="position:relative;background:linear-gradient(135deg,#fef2f2 0%,#fee2e2 100%);'
                f'border:2px solid #fca5a5;border-radius:6px;padding:{pad_v}px {pad_h}px;'
                f'display:flex;gap:{max(3, int(font_size * 0.4))}px;align-items:flex-start;width:100%;height:100%;box-sizing:border-box;">'
                f'{bg_popup_html}'
                f'<div style="font-size:1.1rem;flex-shrink:0;">⚠️</div>'
                f'<div style="flex:1;font-size:{font_size}px;line-height:1.4;color:#7f1d1d;">'
                f'<div style="font-size:{font_size+1}px;font-weight:800;color:#991b1b;margin-bottom:2px;">'
                f'{topic_title}</div>'
                f'{bullets_html}{sub_html}{emph_html}'
                f'</div></div>'
            )

        # ════════════════════════════════════
        # 본심 — SVG(좌) + 텍스트(우상) + 비교표(우하) + key-msg(하단)
        # ════════════════════════════════════
        elif role == "본심":
            svg_sc = next((sc for sc in scs if sc["name"] == "svg"), None)
            text_sc = next((sc for sc in scs if sc["name"] == "text_and_table"), None)
            keymsg_sc = next((sc for sc in scs if sc["name"] == "keymsg"), None)

            # 이미지: slide_images에서 실제 이미지 사용, 없으면 빈 placeholder
            slide_images = ctx.get("slide_images", [])
            img_html = ""
            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_w = int(svg_sc["width_px"]) if svg_sc else 200
            # 이미지 높이: 실제 비율로 계산 (sub_layout 고정값 대신)
            slide_images = ctx.get("slide_images", [])
            img_ratio = next((img.get("ratio", 1) for img in slide_images if img.get("b64")), 1)
            svg_h = int(svg_w / img_ratio) if img_ratio > 0 else int(svg_sc["height_px"]) if svg_sc else 265
            # 본심의 모든 topic 텍스트를 합침
            all_core_text = "\n".join(get_text(topic_map.get(tid, {})) for tid in tids if topic_map.get(tid))

            # 출처 텍스트를 이미지 아래에 배치
            img_caption = ""
            for line in all_core_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 ""
            svg_wrapped = (
                f'<div style="width:{svg_w}px;flex-shrink:0;">'
                f'<div style="height:{svg_h}px;border-radius:6px;overflow:hidden;">{img_html}</div>'
                f'{caption_html}</div>'
            )

            # 텍스트 불릿 (출처는 이미지 아래에 별도 배치했으므로 제외)
            bullets, core_popups = structured_to_bullets(all_core_text, role, font_size, exclude_source=True)

            # V'-2: 팝업 원본을 Kei가 요약한 결과를 사용 (없으면 기존 compact 변환 fallback)
            popup_summaries = enh.get("popup_summaries", {})
            table_html = ""
            used_popups = []
            for pr in core_popups:
                summary = popup_summaries.get(pr)
                if summary:
                    used_popups.append(pr)
                    fmt = summary.get("format", "text")
                    popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'

                    if fmt == "table":
                        cols = summary.get("columns", [])
                        data = summary.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.6)}px;font-size:{font_size-1}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, cell in enumerate(row):
                                    c_color = "#1e40af" if ci == 0 else "#64748b"
                                    c_weight = "600" if ci == 0 else "400"
                                    cells += f'<div style="padding:{int(font_size*0.3)}px {int(font_size*0.6)}px;font-size:{font_size-2}px;color:{c_color};font-weight:{c_weight};">{bold(cell, role)}</div>'
                                rows_html += f'<div style="display:grid;grid-template-columns:repeat({col_count},1fr);border-top:1px solid #e2e8f0;background:{bg};align-items:center;">{cells}</div>\n'
                            compact = (
                                f'<div style="border:1px solid #e2e8f0;border-radius:{int(font_size*0.5)}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>'
                            )
                            table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'

                    elif fmt == "bullets":
                        items = summary.get("items", [])
                        bullets_html = "".join(
                            f'<div class="bl"><span class="bl-m">•</span><span class="bl-t">{bold(item, role)}</span></div>'
                            for item in items
                        )
                        table_html += f'<div style="margin-top:{int(font_size*0.5)}px;font-size:{font_size-1}px;">{popup_link_html}{bullets_html}</div>'

                    elif fmt == "text":
                        text = summary.get("summary", "")
                        table_html += f'<div style="margin-top:{int(font_size*0.5)}px;font-size:{font_size-1}px;color:#475569;">{popup_link_html}{bold(text, role)}</div>'

                else:
                    # fallback: 기존 compact 변환
                    popup = find_popup(pr)
                    if popup:
                        content = popup.get("content", "")
                        if content.count("|") > 3:
                            compact = popup_to_compact_table(content, font_size)
                            if compact:
                                popup_link_html = f'<div style="text-align:right;margin-bottom:2px;"><span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{pr}→]</span></div>'
                                table_html += f'<div style="margin-top:{int(font_size*0.5)}px;">{popup_link_html}{compact}</div>'
                                used_popups.append(pr)

            # 표/요약에 연결되지 않은 팝업은 컨테이너 우측상단에
            remaining_popups = [p for p in core_popups if p not in used_popups]
            core_popup_html = ""
            if remaining_popups:
                links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in remaining_popups)
                core_popup_html = f'<div style="text-align:right;margin-bottom:2px;">{links}</div>'

            text_wrapped = (
                f'<div style="flex:1;display:flex;flex-direction:column;min-height:0;">'
                f'{core_popup_html}'
                f'<div style="font-size:{font_size}px;line-height:1.55;color:#333;flex:1;">{bullets}</div>'
                f'</div>'
            )

            # key-msg
            keymsg_html = ""
            if keymsg_sc and core_message:
                keymsg_html = (
                    f'<div style="background:#dbeafe;border:1px solid #2563eb;border-radius:4px;'
                    f'padding:{int(font_size*0.4)}px {int(font_size*0.8)}px;font-size:{font_size+1}px;font-weight:700;color:#1e40af;text-align:center;'
                    f'margin-top:{gap_small}px;flex-shrink:0;">{bold(core_message, role)}</div>'
                )

            # 레이아웃: 이미지(좌)+불릿(우) 위, 표(전체 폭) 아래, keymsg 최하단
            role_htmls[role] = (
                f'<div style="width:100%;height:100%;background:white;padding:{gap_small}px;box-sizing:border-box;'
                f'display:flex;flex-direction:column;">'
                f'<div style="font-weight:700;font-size:{font_size+1}px;color:#1a365d;margin-bottom:4px;">'
                f'{topic_title}</div>'
                f'<div style="display:flex;gap:{max(6, int(font_size * 0.8))}px;min-height:0;align-items:flex-start;">'
                f'{svg_wrapped}{text_wrapped}</div>'
                f'{table_html}'
                f'{keymsg_html}</div>'
            )

    # ── 슬라이드 좌표 ──
    bg_h = int(redist.get("배경", containers.get("배경", {}).get("height_px", 0)))
    core_h = int(redist.get("본심", containers.get("본심", {}).get("height_px", 0)))
    sb_h = int(redist.get("첨부", containers.get("첨부", {}).get("height_px", 0)))
    concl_h = int(redist.get("결론", containers.get("결론", {}).get("height_px", 0)))

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

    # #9: 결론 바로 위까지 body/sidebar 모두 채움 — 공란 제거
    ft_top = slide_h - pad - concl_h - gap_block  # 결론 위치: 슬라이드 바닥 - pad - 결론높이 - gap
    column_bottom = ft_top - gap_block  # body/sidebar 바닥: 결론 위 gap만큼 위
    core_h = column_bottom - core_top  # 본심: 배경 아래~column 바닥
    sb_h = column_bottom - sb_top  # 첨부: column 바닥까지

    css_block = "\n".join(all_css)

    html = 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;}}
.bl-sub{{padding-left:1em;}}
{css_block}
</style></head><body>
<div style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 결과 (context 데이터만, Sonnet 없음)</div>
<div style="font-size:11px;color:#666;margin-bottom:10px;">sub_layouts + design_reference_html + structured_text + V-7~V-10 + popups</div>
<div 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:22px;font-weight:900;color:#1e293b;">{title}</div>

<div style="position:absolute;left:{pad}px;top:{bg_top}px;width:{body_w}px;height:{bg_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("배경", "")}
</div>

<div style="position:absolute;left:{pad}px;top:{core_top}px;width:{body_w}px;height:{core_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("본심", "")}
</div>

<div style="position:absolute;left:{pad + body_w + gap_block}px;top:{sb_top}px;width:{sidebar_w}px;height:{sb_h}px;border-radius:6px;overflow:hidden;">
{role_htmls.get("첨부", "")}
</div>

<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{concl_h}px;border-radius:8px;overflow:hidden;">
{role_htmls.get("결론", "")}
</div>

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

    out = run / "steps" / "stage_2_code_assembled.html"
    out.write_text(html, encoding="utf-8")
    print(f"저장: {out} ({len(html)} bytes)")


if __name__ == "__main__":
    run_dir = sys.argv[1] if len(sys.argv) > 1 else "data/runs/20260403_120051"
    assemble(run_dir)


# ══════════════════════════════════════
# Phase X-B: 유형 B 조립
# ══════════════════════════════════════
def _assemble_type_b(run: Path, ctx: dict):
    """유형 B: 상단(top+이미지) + 하단 2분할 + 결론.

    기존 유형 A 코드를 건드리지 않는 별도 함수.
    """
    import re
    from src.fit_verifier import _load_design_tokens

    topics = ctx["topics"]
    topic_map = {t["id"]: t for t in topics}
    ps = ctx["page_structure"]
    if "roles" in ps:
        ps = ps["roles"]
    containers = ctx["containers"]
    fh = ctx.get("font_hierarchy", {})
    enh = ctx.get("enhancement_result", {})
    bold_kw = enh.get("bold_keywords", {}) if isinstance(enh.get("bold_keywords"), dict) else {}
    popups = ctx.get("normalized", {}).get("popups", [])
    title = ctx.get("analysis", {}).get("title", "")
    core_message = ctx.get("analysis", {}).get("core_message", "")
    slide_images = ctx.get("slide_images", [])

    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

    # ── 유틸 ──
    def get_text(topic):
        if isinstance(topic, dict):
            return topic.get("structured_text", "") or topic.get("source_data", "")
        return ""

    def bold(text, role):
        for kw in bold_kw.get(role, []):
            if kw in text:
                text = text.replace(kw, f"<strong>{kw}</strong>")
        return text

    def find_popup(title_keyword):
        for p in popups:
            if title_keyword in p.get("title", ""):
                return p
        return None

    # ── 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
    footer_ci = containers.get(footer_role[0], {}) if footer_role else {}
    footer_h = footer_ci.get("height_px", 53) if isinstance(footer_ci, dict) else 53
    ft_top = slide_h - pad - footer_h

    # 상단
    top_ci = containers.get(top_role[0], {}) if top_role else {}
    top_h = top_ci.get("height_px", 200) if isinstance(top_ci, dict) else 200
    top_w = top_ci.get("width_px", inner_w) if isinstance(top_ci, dict) else inner_w
    top_top = pad + header_h + gap_block

    # 이미지 크기
    img_constraints = top_ci.get("block_constraints", {}) if isinstance(top_ci, dict) else {}
    img_w = img_constraints.get("img_width_px", 0)
    has_image = img_constraints.get("has_image", False)

    # 이미지 높이: 실제 비율로
    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: 결론 바로 위까지 채움
    column_bottom = ft_top - gap_block
    bottom_h = column_bottom - bottom_top
    bottom_col_w = (inner_w - gap_block) // 2

    # ── normalized.sections에서 직접 텍스트 가져오기 ──
    norm_sections = ctx.get("normalized", {}).get("sections", [])
    font_size = fh.get("core", 12)

    # 상단 (텍스트 + 이미지 나란히) — sections[0] 사용
    top_html = ""
    if top_role:
        rn, info = top_role
        tids = info.get("topic_ids", [])
        # MDX 원본 sections에서 직접 가져오기
        # 상단: 첫 번째 level=2 section + 하단 대목차 전까지의 level=2 sections를 합침
        # (03번처럼 기술/사람/자연이 별도 section으로 분리된 경우 대응)
        topic_title_from_section = ""
        top_contents = []
        for s in norm_sections:
            if s["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:
                # section title도 소제목으로 포함 (첫 번째 제외)
                if s["title"] and s["title"] != topic_title_from_section:
                    top_contents.append(f"### {s['title']}")
                top_contents.append(content)
        all_text = "\n".join(top_contents)
        # 마크다운 bold → HTML
        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 = ""
        if popup_titles:
            links = " ".join(f'<span style="color:#2563eb;font-size:{font_size-2}px;cursor:pointer;">[{t}→]</span>' for t in popup_titles)
            popup_html = f'<div style="position:absolute;top:4px;right:8px;text-align:right;z-index:1;">{links}</div>'

        # 소제목(### 또는 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)

        # X'-2: 카드형 HTML — 소제목별 들여쓰기 계층
        # X'-5: 카드 디자인 — 다크 그라데이션 배경, 밝은 텍스트
        _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_title, 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'
                    )

        # X'-3: 이미지 캡션 — normalized.images alt → 출처 → [이미지:] 마커 순
        img_caption = ""
        norm_images = ctx.get("normalized", {}).get("images", [])
        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
        if not img_caption:
            img_marker = re.search(r'\[이미지:\s*([^\]]+)\]', all_text)
            if img_marker:
                img_caption = img_marker.group(1)

        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>'
            )

        # 제목 — MDX 원본 section 제목 사용
        topic_title = bold(topic_title_from_section or rn, rn)

        # X'-4: 상단 컨테이너 — 내용을 전체 높이에 균등 배분
        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에서 직접 매핑
    # sections 구조: [level=2 상단, level=2 하단대목차, level=3 하단좌, level=3 하단우, ...]
    # 하단 대목차 = level=2 두 번째
    # 하단 소목차들 = level=3
    # 하단: level=3이 존재하는 구역의 level=2가 대목차
    bottom_title = ""
    sub_sections_from_norm = []  # [(제목, content)]
    found_level3 = False
    for s in norm_sections:
        if s["level"] == 3:
            found_level3 = True
            sub_sections_from_norm.append((s.get("title", ""), s.get("content", "")))
        elif s["level"] == 2 and not found_level3:
            # level=3 전의 level=2는 상단에 속함 → 건너뜀
            continue
        elif s["level"] == 2 and found_level3:
            # level=3 이후의 level=2 → 하단 대목차 후보 (이미 잡혔으면 무시)
            pass
    # 하단 대목차: level=3 바로 앞의 level=2
    for s in norm_sections:
        if s["level"] == 2:
            # 이 section 다음에 level=3이 오면 이게 대목차
            idx = norm_sections.index(s)
            if idx + 1 < len(norm_sections) and norm_sections[idx + 1]["level"] == 3:
                bottom_title = s.get("title", "")
                break

    bl_indent = int(font_size * 1.2)

    # 하단 좌측 = 첫 번째 소목차 (level=3)
    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)

        bullets = ""
        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 ""
            bullets += 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;">{bullets}</div></div>'
        )

    # 하단 우측 = 두 번째 소목차 (level=3) + 표 요약
    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)

        content_lines_br = [l.strip() for l in sub_content_br.split("\n") if l.strip()]

        # 팝업 링크 — 소목차 제목으로 팝업 링크 생성
        popup_html_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가 있으면 표 데이터는 Kei 요약으로 대체되므로 불릿은 간략하게
        table_summaries = enh.get("table_summaries", {})
        bullets = ""
        if not table_summaries:
            # 표 요약 없으면 content 그대로
            for line in content_lines_br:
                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 ""
                    bullets += f'<div style="padding-left:{pad}px;font-size:{fs}px;margin-bottom:2px;{weight}">• {clean}</div>\n'

        # X'-6: 본문 표 요약이 있으면 하단 우측에 추가
        table_summaries = enh.get("table_summaries", {})
        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, cell in enumerate(row):
                            c_color = "#1e40af" if ci == 0 else "#475569"
                            c_weight = "600" if ci == 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;">{bullets}</div>'
            f'{table_html_br}</div>'
        )

    # 결론
    footer_html = ""
    if footer_role:
        rn, info = footer_role
        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:{fh.get("key_msg",14)}px;font-weight:700;">{bold(core_message, rn)}</div></div>'
        )

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

    html = 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 style="font-size:14px;font-weight:bold;margin-bottom:4px;">Stage 2: 코드 조립 (유형 B)</div>
<div 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:22px;font-weight:900;color:#1e293b;">{title}</div>

<div 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;">
{top_html}</div>

<div 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 style="flex:1;overflow:hidden;">
{bl_html}</div>
<div style="width:1px;background:#cbd5e1;flex-shrink:0;"></div>
<div style="flex:1;overflow:hidden;">
{br_html}</div>
</div></div>

<div style="position:absolute;left:{pad}px;top:{ft_top}px;width:{inner_w}px;height:{footer_h}px;border-radius:8px;overflow:hidden;">
{footer_html}</div>

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

    out = run / "steps" / "stage_2_code_assembled.html"
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_text(html, encoding="utf-8")
    print(f"저장: {out} ({len(html)} bytes)")
