from __future__ import annotations

import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence

from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches, Pt


@dataclass(frozen=True)
class Theme:
    navy: RGBColor
    navy_2: RGBColor
    bg: RGBColor
    white: RGBColor
    text: RGBColor
    muted: RGBColor
    line: RGBColor
    accent_yellow: RGBColor
    accent_blue: RGBColor
    accent_red: RGBColor
    accent_green: RGBColor
    accent_purple: RGBColor
    accent_orange: RGBColor
    accent_cyan: RGBColor

    font: str
    font_bold: str
    font_mono: str


T = Theme(
    navy=RGBColor(0x10, 0x1D, 0x2E),
    navy_2=RGBColor(0x14, 0x2A, 0x45),
    bg=RGBColor(0xF6, 0xF7, 0xFB),
    white=RGBColor(0xFF, 0xFF, 0xFF),
    text=RGBColor(0x1F, 0x29, 0x37),
    muted=RGBColor(0x6B, 0x72, 0x80),
    line=RGBColor(0xE5, 0xE7, 0xEB),
    accent_yellow=RGBColor(0xF0, 0xB4, 0x3B),
    accent_blue=RGBColor(0x3B, 0x82, 0xF6),
    accent_red=RGBColor(0xEF, 0x44, 0x44),
    accent_green=RGBColor(0x22, 0xC5, 0x5E),
    accent_purple=RGBColor(0xA8, 0x55, 0xF7),
    accent_orange=RGBColor(0xF9, 0x73, 0x16),
    accent_cyan=RGBColor(0x06, 0xB6, 0xD4),
    font="Apple SD Gothic Neo",
    font_bold="Apple SD Gothic Neo",
    font_mono="Menlo",
)


SLIDE_W = Inches(13.333)
SLIDE_H = Inches(7.5)


def _rgb(hex6: str) -> RGBColor:
    h = hex6.lstrip("#")
    return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))


def set_slide_bg(slide, color: RGBColor) -> None:
    fill = slide.background.fill
    fill.solid()
    fill.fore_color.rgb = color


def add_rect(slide, x, y, w, h, *, fill: RGBColor | None, line: RGBColor | None, radius: bool = False):
    shape_type = MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE if radius else MSO_AUTO_SHAPE_TYPE.RECTANGLE
    shp = slide.shapes.add_shape(shape_type, x, y, w, h)
    if fill is None:
        shp.fill.background()
    else:
        shp.fill.solid()
        shp.fill.fore_color.rgb = fill
    if line is None:
        shp.line.fill.background()
    else:
        shp.line.color.rgb = line
        shp.line.width = Pt(1)
    return shp


def add_shadow_card(slide, x, y, w, h, *, fill: RGBColor, line: RGBColor | None, radius: bool = True):
    # Faux shadow (editable) by layering two shapes.
    shadow = add_rect(slide, x + Inches(0.06), y + Inches(0.06), w, h, fill=_rgb("#E5E7EB"), line=None, radius=radius)
    shadow.fill.transparency = 0.35
    card = add_rect(slide, x, y, w, h, fill=fill, line=line, radius=radius)
    return card


def add_icon_circle(slide, x, y, d, icon: str, *, fill: RGBColor, fg: RGBColor):
    circ = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.OVAL, x, y, d, d)
    circ.fill.solid()
    circ.fill.fore_color.rgb = fill
    circ.line.fill.background()
    tf = circ.text_frame
    tf.clear()
    p = tf.paragraphs[0]
    p.alignment = PP_ALIGN.CENTER
    run = p.add_run()
    run.text = icon
    run.font.name = T.font_bold
    run.font.size = Pt(20)
    run.font.bold = True
    run.font.color.rgb = fg
    return circ


@dataclass(frozen=True)
class DeckStyle:
    accent: RGBColor
    accent_2: RGBColor
    badge_bg: RGBColor
    code_bg: RGBColor
    code_fg: RGBColor


def style_from_meta(meta: dict[str, Any]) -> DeckStyle:
    badge = str(meta.get("badge") or "")
    # Track colors
    if "윤리" in badge or "보안" in badge:
        accent = T.accent_red
        accent2 = _rgb("#FDECEC")
    elif "업무 효율" in badge:
        accent = T.accent_yellow
        accent2 = _rgb("#FFF7ED")
    elif "문제 해결" in badge:
        accent = T.accent_blue
        accent2 = _rgb("#EFF6FF")
    elif "협업" in badge or "공유" in badge:
        accent = T.accent_green
        accent2 = _rgb("#ECFDF5")
    elif "데이터" in badge:
        accent = T.accent_purple
        accent2 = _rgb("#F5F3FF")
    elif "트렌드" in badge:
        accent = T.accent_orange
        accent2 = _rgb("#FFF7ED")
    elif "리더십" in badge:
        accent = T.accent_cyan
        accent2 = _rgb("#ECFEFF")
    else:
        accent = T.accent_yellow
        accent2 = _rgb("#FFF7ED")

    return DeckStyle(
        accent=accent,
        accent_2=accent2,
        badge_bg=_rgb("#24364D"),
        code_bg=_rgb("#0B1220"),
        code_fg=_rgb("#E5E7EB"),
    )


def _strip_prefix(s: str) -> str:
    t = s.strip()
    t = re.sub(r"^\s*\d+\)\s*", "", t)
    t = re.sub(r"^\s*\d+\.\s*", "", t)
    return t.strip()


def _parse_minutes(text: str) -> float | None:
    """
    Parse rough time strings into minutes.
    Supports: "20분", "60분+", "15~25분", "1시간", "1.5시간", "1시간 30분".
    Returns None if not parseable.
    """
    s = str(text).strip().replace(" ", "")
    if not s:
        return None

    # Hours + minutes
    hm = re.fullmatch(r"(?:(\d+(?:\.\d+)?)시간)?(?:(\d+(?:\.\d+)?)분)?\+?", s)
    if hm and (hm.group(1) or hm.group(2)):
        h = float(hm.group(1)) if hm.group(1) else 0.0
        m = float(hm.group(2)) if hm.group(2) else 0.0
        return h * 60.0 + m

    # Range in minutes: 15~25분 / 15-25분
    mrange = re.fullmatch(r"(\d+(?:\.\d+)?)[~-](\d+(?:\.\d+)?)분\+?", s)
    if mrange:
        a = float(mrange.group(1))
        b = float(mrange.group(2))
        return (a + b) / 2.0

    # Simple minutes with plus
    m1 = re.fullmatch(r"(\d+(?:\.\d+)?)분\+?", s)
    if m1:
        return float(m1.group(1))

    return None


def _parse_percent(text: str) -> float | None:
    """
    Parse rough percent strings into 0~100 float.
    Supports: "30%", "30~70%", "30-70%", "0.3" (treated as 30% if <= 1.0).
    """
    s = str(text).strip().replace(" ", "")
    if not s:
        return None
    s = s.rstrip("+")
    if s.endswith("%"):
        s0 = s[:-1]
        mrange = re.fullmatch(r"(\d+(?:\.\d+)?)[~-](\d+(?:\.\d+)?)", s0)
        if mrange:
            a = float(mrange.group(1))
            b = float(mrange.group(2))
            return max(0.0, min(100.0, (a + b) / 2.0))
        m1 = re.fullmatch(r"(\d+(?:\.\d+)?)", s0)
        if m1:
            return max(0.0, min(100.0, float(m1.group(1))))
        return None

    # bare number
    m = re.fullmatch(r"\d+(?:\.\d+)?", s)
    if not m:
        return None
    v = float(s)
    if 0.0 <= v <= 1.0:
        v *= 100.0
    if 0.0 <= v <= 100.0:
        return v
    return None


@dataclass(frozen=True)
class SlideMood:
    label: str
    icon: str
    color: RGBColor
    bg: RGBColor


def mood_from_title(title: str, *, style: DeckStyle) -> SlideMood:
    t = str(title or "").strip()
    tl = t.lower()
    # Korean + English heuristics
    if any(k in t for k in ["리스크", "위험", "주의", "환각", "보안"]) or any(k in tl for k in ["risk", "warning", "security"]):
        return SlideMood(label="RISK", icon="!", color=T.accent_red, bg=_rgb("#FDECEC"))
    if any(k in t for k in ["체크리스트", "점검", "do", "don't", "dont"]) or any(k in tl for k in ["checklist", "do &", "don't", "dont"]):
        return SlideMood(label="CHECK", icon="✓", color=T.accent_green, bg=_rgb("#ECFDF5"))
    if any(k in t for k in ["요약", "정리", "핵심"]) or any(k in tl for k in ["summary", "key"]):
        return SlideMood(label="SUMMARY", icon="≡", color=style.accent, bg=style.accent_2)
    if any(k in t for k in ["기대 효과", "효과", "성과", "절감", "개선"]) or any(k in tl for k in ["impact", "benefit", "kpi", "saving"]):
        return SlideMood(label="IMPACT", icon="+", color=style.accent, bg=style.accent_2)
    if any(k in t for k in ["사례", "적용", "예시", "데모"]) or any(k in tl for k in ["case", "example", "demo"]):
        return SlideMood(label="CASE", icon="◆", color=style.accent, bg=style.accent_2)
    if any(k in t for k in ["프레임워크", "루틴", "프로세스", "단계"]) or any(k in tl for k in ["framework", "process", "steps"]):
        return SlideMood(label="FRAME", icon="→", color=style.accent, bg=style.accent_2)
    return SlideMood(label="INFO", icon="i", color=style.accent, bg=style.accent_2)


def extract_kpis(lines: Sequence[str]) -> list[tuple[str, str]]:
    """
    Extract up to 3 KPI-like tokens from bullet lines.
    Returns list of (value, label).
    """
    out: list[tuple[str, str]] = []
    seen: set[str] = set()

    def add(value: str, label: str) -> None:
        v = value.strip()
        if not v or v in seen:
            return
        seen.add(v)
        out.append((v, label.strip()[:18] or "KPI"))

    for s in lines:
        text = str(s)
        # percent
        m = re.search(r"(\d+(?:\.\d+)?\s*(?:~|-)\s*\d+(?:\.\d+)?\s*%|\d+(?:\.\d+)?\s*%)", text)
        if m:
            label = "비율"
            if "단축" in text or "절감" in text or "감소" in text:
                label = "단축/절감"
            add(m.group(1).replace(" ", ""), label)
        # minutes / hours
        m2 = re.search(r"(\d+(?:\.\d+)?\s*(?:~|-)\s*\d+(?:\.\d+)?\s*분\+?|\d+(?:\.\d+)?\s*분\+?|\d+(?:\.\d+)?\s*시간(?:\s*\d+(?:\.\d+)?\s*분)?)", text)
        if m2:
            label = "시간"
            if "절감" in text or "단축" in text:
                label = "절감"
            add(m2.group(1).replace(" ", ""), label)
        # weeks
        m3 = re.search(r"(\d+)\s*주", text)
        if m3:
            add(f"{m3.group(1)}주", "기간")
        # counts
        m4 = re.search(r"(\d+)\s*개", text)
        if m4:
            add(f"{m4.group(1)}개", "항목")
        if len(out) >= 3:
            break
    return out[:3]


def add_kpi_cards(slide, *, kpis: Sequence[tuple[str, str]], style: DeckStyle) -> None:
    if not kpis:
        return
    n = min(3, len(kpis))
    w = Inches(3.85)
    gap = Inches(0.25)
    x0 = Inches(0.7)
    y0 = Inches(6.0)
    for i in range(n):
        value, label = kpis[i]
        xx = x0 + (w + gap) * i
        add_shadow_card(slide, xx, y0, w, Inches(0.95), fill=T.white, line=_rgb("#E5E7EB"))
        add_rect(slide, xx, y0, Inches(0.08), Inches(0.95), fill=style.accent, line=None, radius=False)
        add_textbox(slide, xx + Inches(0.22), y0 + Inches(0.18), w - Inches(0.35), Inches(0.45), value, size=22, bold=True, color=T.text)
        add_textbox(slide, xx + Inches(0.22), y0 + Inches(0.62), w - Inches(0.35), Inches(0.25), label, size=11, bold=True, color=T.muted)


def add_textbox(
    slide,
    x,
    y,
    w,
    h,
    text: str,
    *,
    size: int,
    bold: bool = False,
    color: RGBColor | None = None,
    align: int = PP_ALIGN.LEFT,
    line_spacing: float | None = None,
):
    tb = slide.shapes.add_textbox(x, y, w, h)
    tf = tb.text_frame
    tf.clear()
    tf.word_wrap = True
    p = tf.paragraphs[0]
    p.alignment = align
    run = p.add_run()
    run.text = text
    f = run.font
    f.name = T.font_bold if bold else T.font
    f.size = Pt(size)
    f.bold = bold
    f.color.rgb = color or T.text
    if line_spacing is not None:
        p.line_spacing = line_spacing
    return tb


def add_textbox_mono(slide, x, y, w, h, text: str, *, size: int, color: RGBColor):
    tb = slide.shapes.add_textbox(x, y, w, h)
    tf = tb.text_frame
    tf.clear()
    tf.word_wrap = True
    # Keep line breaks as paragraphs for better rendering.
    lines = text.splitlines() if text else [""]
    for i, line in enumerate(lines):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.text = line
        p.font.name = T.font_mono
        p.font.size = Pt(size)
        p.font.color.rgb = color
        p.line_spacing = 1.12
        p.space_after = Pt(0)
    return tb


def add_bullets(
    slide,
    x,
    y,
    w,
    h,
    bullets: Sequence[str],
    *,
    size: int = 18,
    color: RGBColor | None = None,
    line_spacing: float = 1.15,
):
    tb = slide.shapes.add_textbox(x, y, w, h)
    tf = tb.text_frame
    tf.clear()
    tf.word_wrap = True
    for i, b in enumerate(bullets):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.text = b
        p.level = 0
        p.font.name = T.font
        p.font.size = Pt(size)
        p.font.color.rgb = color or T.text
        p.line_spacing = line_spacing
        p.space_after = Pt(6)
        p.bullet = True
    return tb


def add_pill(slide, x, y, w, h, text: str, *, fill: RGBColor, fg: RGBColor):
    pill = add_rect(slide, x, y, w, h, fill=fill, line=None, radius=True)
    tf = pill.text_frame
    tf.clear()
    p = tf.paragraphs[0]
    p.alignment = PP_ALIGN.CENTER
    run = p.add_run()
    run.text = text
    run.font.name = T.font
    run.font.size = Pt(12)
    run.font.color.rgb = fg
    return pill


def add_footer(slide, slide_no: int, *, dark: bool, footer_left: str | None = None) -> None:
    y = SLIDE_H - Inches(0.35)
    add_rect(slide, Inches(0), y, SLIDE_W, Inches(0.35), fill=(T.navy if dark else T.bg), line=None, radius=False)
    fg = _rgb("#D1D5DB") if dark else T.muted
    if footer_left:
        add_textbox(
            slide,
            Inches(0.5),
            y + Inches(0.06),
            Inches(9.5),
            Inches(0.25),
            footer_left,
            size=11,
            bold=False,
            color=fg,
            align=PP_ALIGN.LEFT,
        )
    add_textbox(
        slide,
        SLIDE_W - Inches(2.0),
        y + Inches(0.06),
        Inches(1.5),
        Inches(0.25),
        f"{slide_no:02d}",
        size=11,
        bold=False,
        color=fg,
        align=PP_ALIGN.RIGHT,
    )


def set_notes(slide, notes: str | None) -> None:
    if notes is None:
        return
    text = str(notes).strip()
    if not text:
        return
    ns = slide.notes_slide
    tf = ns.notes_text_frame
    tf.clear()
    tf.text = text


def _header(slide, title: str, *, chapter: str | None = None, style: DeckStyle) -> None:
    # Top bar + accent strip (more "PPT-like")
    add_rect(slide, Inches(0), Inches(0), SLIDE_W, Inches(0.95), fill=T.white, line=None, radius=False)
    add_rect(slide, Inches(0), Inches(0), SLIDE_W, Inches(0.06), fill=style.accent, line=None, radius=False)
    add_textbox(slide, Inches(0.7), Inches(0.22), Inches(10.0), Inches(0.55), title, size=28, bold=True, color=T.text)
    if chapter:
        add_pill(slide, Inches(11.2), Inches(0.26), Inches(1.8), Inches(0.38), chapter, fill=_rgb("#EEF2F7"), fg=T.muted)
    add_rect(slide, Inches(0.7), Inches(0.93), Inches(11.93), Inches(0.04), fill=T.line, line=None, radius=False)


def slide_cover(prs: Presentation, *, title: str, subtitle: str | None, badge: str | None, footer_left: str | None, style: DeckStyle):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.navy)
    # Subtle pattern bubbles
    for (x, y, d, a) in [(11.0, 0.6, 1.4, 0.12), (10.1, 1.7, 2.2, 0.08), (11.7, 2.8, 1.7, 0.07)]:
        c = s.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.OVAL, Inches(x), Inches(y), Inches(d), Inches(d))
        c.fill.solid()
        c.fill.fore_color.rgb = style.accent
        c.fill.transparency = 1 - a
        c.line.fill.background()
    # Corner star (spark)
    star = s.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.STAR_5_POINT, Inches(0.55), Inches(0.65), Inches(0.65), Inches(0.65))
    star.fill.solid()
    star.fill.fore_color.rgb = style.accent
    star.line.fill.background()
    star.fill.transparency = 0.08
    if badge:
        add_pill(s, Inches(4.2), Inches(0.45), Inches(4.9), Inches(0.45), badge, fill=style.badge_bg, fg=_rgb("#E5E7EB"))
    # Icon badge
    add_icon_circle(s, Inches(6.2), Inches(1.35), Inches(0.9), "★", fill=style.accent, fg=T.navy)
    add_textbox(
        s,
        Inches(1.0),
        Inches(2.15),
        Inches(11.33),
        Inches(1.5),
        title,
        size=48 if len(title) <= 18 else 38,
        bold=True,
        color=T.white,
        align=PP_ALIGN.CENTER,
        line_spacing=1.05,
    )
    if subtitle:
        add_textbox(s, Inches(1.3), Inches(4.0), Inches(10.8), Inches(0.5), subtitle, size=18, bold=True, color=_rgb("#E5E7EB"), align=PP_ALIGN.CENTER)
    add_rect(s, Inches(4.9), Inches(4.55), Inches(3.5), Inches(0.08), fill=style.accent, line=None)
    add_footer(s, 1, dark=True, footer_left=footer_left)
    return s


def slide_section(prs: Presentation, slide_no: int, *, title: str, subtitle: str | None, footer_left: str | None, style: DeckStyle):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.navy_2)
    # Big part number if exists
    m = re.match(r"^\s*(\d+)\.\s*(.*)$", title)
    num = m.group(1) if m else None
    rest = m.group(2) if m else title
    if num:
        add_textbox(s, Inches(0.9), Inches(1.6), Inches(1.6), Inches(1.2), num, size=84, bold=True, color=style.accent)
        add_textbox(s, Inches(2.25), Inches(2.15), Inches(10.2), Inches(1.0), rest, size=44 if len(rest) <= 18 else 34, bold=True, color=T.white, line_spacing=1.05)
    else:
        add_textbox(s, Inches(0.9), Inches(2.15), Inches(11.6), Inches(1.0), rest, size=44 if len(rest) <= 18 else 34, bold=True, color=T.white, line_spacing=1.05)
    if subtitle:
        add_textbox(s, Inches(2.25) if num else Inches(0.9), Inches(3.25), Inches(11.0), Inches(0.6), subtitle, size=18, bold=True, color=_rgb("#D1D5DB"))
    add_rect(s, Inches(0.9), Inches(4.25), Inches(4.2), Inches(0.08), fill=style.accent, line=None)
    # Accent corner
    add_rect(s, Inches(11.4), Inches(6.0), Inches(2.0), Inches(1.5), fill=style.accent, line=None, radius=True).fill.transparency = 0.15
    add_footer(s, slide_no, dark=True, footer_left=footer_left)
    return s


def slide_agenda(prs: Presentation, slide_no: int, *, title: str, items: Sequence[str], footer_left: str | None, style: DeckStyle):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.bg)
    _header(s, title, style=style)

    # Stepper layout (horizontal)
    card = add_shadow_card(s, Inches(0.7), Inches(1.55), Inches(11.93), Inches(4.9), fill=T.white, line=T.line)
    _ = card
    steps = [_strip_prefix(str(x)) for x in items][:6]
    n = max(1, len(steps))
    left = Inches(1.1)
    top = Inches(2.25)
    usable_w = Inches(11.2)
    gap = usable_w / n
    for i, txt in enumerate(steps):
        cx = left + gap * i + gap / 2 - Inches(0.35)
        add_icon_circle(s, cx, top, Inches(0.7), f"{i+1}", fill=style.accent, fg=T.navy)
        add_textbox(s, cx - Inches(0.6), top + Inches(0.85), Inches(2.0), Inches(0.55), txt, size=14, bold=True, color=T.text, align=PP_ALIGN.CENTER, line_spacing=1.05)
        if i < n - 1:
            # connector
            add_rect(s, cx + Inches(0.7), top + Inches(0.33), gap - Inches(0.7), Inches(0.05), fill=_rgb("#CBD5E1"), line=None, radius=True)

    add_rect(s, Inches(1.1), Inches(5.8), Inches(11.2), Inches(0.7), fill=style.accent_2, line=None, radius=True)
    add_textbox(s, Inches(1.35), Inches(5.93), Inches(10.7), Inches(0.45), "TIP: 각 파트는 5~7분 내로, 사례는 2개만 깊게 보면 충분합니다.", size=15, bold=True, color=T.text)
    add_footer(s, slide_no, dark=False, footer_left=footer_left)
    return s


def slide_bullets(prs: Presentation, slide_no: int, *, title: str, bullets: Sequence[str], chapter: str | None, footer_left: str | None, style: DeckStyle):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.bg)
    _header(s, title, chapter=chapter, style=style)

    # Left icon rail + bullet cards (more visual)
    add_rect(s, Inches(0), Inches(0.95), Inches(0.22), SLIDE_H - Inches(0.95), fill=style.accent, line=None, radius=False)
    add_icon_circle(s, Inches(0.35), Inches(1.55), Inches(0.8), "✓", fill=style.accent, fg=T.navy)
    # Decorative blob
    blob = s.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.OVAL, Inches(11.6), Inches(6.0), Inches(1.6), Inches(1.6))
    blob.fill.solid()
    blob.fill.fore_color.rgb = style.accent
    blob.fill.transparency = 0.88
    blob.line.fill.background()

    # Mood badge (top-right)
    mood = mood_from_title(title, style=style)
    add_pill(s, Inches(10.65), Inches(1.12), Inches(2.55), Inches(0.38), f"{mood.icon}  {mood.label}", fill=mood.bg, fg=T.text)

    items = list(bullets)
    n = len(items)
    if n <= 5:
        card = add_shadow_card(s, Inches(1.2), Inches(1.55), Inches(12.1), Inches(4.25), fill=T.white, line=T.line)
        _ = card
        y0 = Inches(1.95)
        for i, txt in enumerate(items):
            yy = y0 + Inches(0.78) * i
            add_icon_circle(s, Inches(1.45), yy, Inches(0.38), f"{i+1}", fill=style.accent_2, fg=T.text)
            add_textbox(s, Inches(1.9), yy - Inches(0.02), Inches(11.1), Inches(0.5), txt, size=18, bold=True, color=T.text, line_spacing=1.1)
            add_rect(s, Inches(1.45), yy + Inches(0.55), Inches(11.6), Inches(0.02), fill=_rgb("#E5E7EB"), line=None, radius=False)
    else:
        # 2-column grid of mini cards
        add_shadow_card(s, Inches(1.2), Inches(1.55), Inches(12.1), Inches(4.25), fill=T.white, line=T.line)
        col_w = Inches(5.8)
        row_h = Inches(1.1)
        left_x = Inches(1.45)
        right_x = Inches(7.35)
        top_y = Inches(1.9)
        for i, txt in enumerate(items[:8]):
            xx = left_x if i % 2 == 0 else right_x
            yy = top_y + row_h * (i // 2)
            add_shadow_card(s, xx, yy, col_w, Inches(0.92), fill=_rgb("#F9FAFB"), line=_rgb("#E5E7EB"))
            add_rect(s, xx, yy, Inches(0.08), Inches(0.92), fill=style.accent, line=None, radius=False)
            add_textbox(s, xx + Inches(0.25), yy + Inches(0.18), col_w - Inches(0.35), Inches(0.6), txt, size=15, bold=True, color=T.text, line_spacing=1.08)

    # KPI cards (auto extracted from bullets)
    add_kpi_cards(s, kpis=extract_kpis(items), style=style)
    add_footer(s, slide_no, dark=False, footer_left=footer_left)
    return s


def slide_two_column(
    prs: Presentation,
    slide_no: int,
    *,
    title: str,
    left_title: str,
    left_bullets: Sequence[str],
    right_title: str,
    right_bullets: Sequence[str],
    chapter: str | None,
    footer_left: str | None,
    style: DeckStyle,
):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.bg)
    _header(s, title, chapter=chapter, style=style)

    lx = Inches(0.7)
    rx = Inches(6.75)
    y = Inches(1.55)
    w = Inches(5.88)
    h = Inches(5.0)
    add_shadow_card(s, lx, y, w, h, fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB"))
    add_shadow_card(s, rx, y, w, h, fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB"))

    # Colored headers + icons (auto semantics)
    add_rect(s, lx, y, w, Inches(0.9), fill=_rgb("#EFF6FF"), line=None, radius=True)
    add_rect(s, rx, y, w, Inches(0.9), fill=_rgb("#ECFDF5"), line=None, radius=True)
    add_rect(s, lx, y, w, Inches(0.08), fill=T.accent_blue, line=None, radius=False)
    add_rect(s, rx, y, w, Inches(0.08), fill=T.accent_green, line=None, radius=False)
    left_icon = "!"
    right_icon = "✓"
    lt = left_title.lower()
    rt = right_title.lower()
    if "오해" in left_title or "bad" in lt or "don" in lt or "리스크" in left_title:
        left_icon = "!"
    if "현실" in right_title or "good" in rt or "do" in rt or "완화" in right_title:
        right_icon = "✓"
    if "before" in lt or "기존" in left_title:
        left_icon = "◼"
    if "after" in rt or "ai" in rt or "협업" in right_title:
        right_icon = "▲"
    add_icon_circle(s, lx + Inches(0.35), y + Inches(0.22), Inches(0.45), left_icon, fill=_rgb("#DBEAFE"), fg=_rgb("#1D4ED8"))
    add_icon_circle(s, rx + Inches(0.35), y + Inches(0.22), Inches(0.45), right_icon, fill=_rgb("#D1FAE5"), fg=_rgb("#065F46"))
    add_textbox(s, lx + Inches(0.9), y + Inches(0.18), w - Inches(1.2), Inches(0.55), left_title, size=18, bold=True, color=T.text)
    add_textbox(s, rx + Inches(0.9), y + Inches(0.18), w - Inches(1.2), Inches(0.55), right_title, size=18, bold=True, color=T.text)

    add_bullets(s, lx + Inches(0.35), y + Inches(1.05), w - Inches(0.7), h - Inches(1.25), list(left_bullets), size=15, color=T.text, line_spacing=1.18)
    add_bullets(s, rx + Inches(0.35), y + Inches(1.05), w - Inches(0.7), h - Inches(1.25), list(right_bullets), size=15, color=T.text, line_spacing=1.18)

    # VS badge
    add_icon_circle(s, Inches(6.05), Inches(3.65), Inches(0.75), "VS", fill=_rgb("#111827"), fg=_rgb("#E5E7EB"))
    add_footer(s, slide_no, dark=False, footer_left=footer_left)
    return s


def slide_process(prs: Presentation, slide_no: int, *, title: str, steps: Sequence[str], chapter: str | None, footer_left: str | None, style: DeckStyle):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.bg)
    _header(s, title, chapter=chapter, style=style)

    add_shadow_card(s, Inches(0.7), Inches(1.55), Inches(11.93), Inches(4.9), fill=T.white, line=T.line)

    st = list(steps)
    n = len(st)
    # Timeline (horizontal) when possible
    if n <= 6:
        left = Inches(1.1)
        y = Inches(3.05)
        usable_w = Inches(11.2)
        gap = usable_w / n
        add_rect(s, left, y + Inches(0.35), usable_w, Inches(0.06), fill=_rgb("#CBD5E1"), line=None, radius=True)
        for i, txt in enumerate(st):
            cx = left + gap * i + gap / 2 - Inches(0.32)
            add_icon_circle(s, cx, y, Inches(0.64), f"{i+1}", fill=style.accent, fg=T.navy)
            add_shadow_card(s, cx - Inches(0.55), y - Inches(1.25), Inches(2.1), Inches(0.95), fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB"))
            add_textbox(s, cx - Inches(0.45), y - Inches(1.1), Inches(1.9), Inches(0.75), txt, size=13, bold=True, color=T.text, align=PP_ALIGN.CENTER, line_spacing=1.05)
    else:
        # Grid fallback
        cols = 3
        card_w = Inches(3.75)
        card_h = Inches(1.1)
        x0 = Inches(1.05)
        y0 = Inches(1.95)
        gx = Inches(0.35)
        gy = Inches(0.25)
        for i, txt in enumerate(st[:9]):
            r = i // cols
            c = i % cols
            xx = x0 + (card_w + gx) * c
            yy = y0 + (card_h + gy) * r
            add_shadow_card(s, xx, yy, card_w, card_h, fill=_rgb("#F8FAFC"), line=_rgb("#E5E7EB"))
            add_icon_circle(s, xx + Inches(0.22), yy + Inches(0.28), Inches(0.5), f"{i+1}", fill=style.accent_2, fg=T.text)
            add_textbox(s, xx + Inches(0.85), yy + Inches(0.25), card_w - Inches(1.05), Inches(0.7), txt, size=14, bold=True, color=T.text, line_spacing=1.05)

    add_footer(s, slide_no, dark=False, footer_left=footer_left)
    return s


def slide_prompt(
    prs: Presentation,
    slide_no: int,
    *,
    title: str,
    prompt: str,
    tips: Sequence[str] | None,
    chapter: str | None,
    footer_left: str | None,
    style: DeckStyle,
):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.bg)
    _header(s, title, chapter=chapter, style=style)

    # Code block style prompt card
    add_shadow_card(s, Inches(0.7), Inches(1.55), Inches(11.93), Inches(3.2), fill=style.code_bg, line=None, radius=True)
    add_pill(s, Inches(0.95), Inches(1.72), Inches(2.2), Inches(0.38), "Copy & Paste", fill=style.accent, fg=T.navy)
    add_icon_circle(s, Inches(11.85), Inches(1.67), Inches(0.55), "AI", fill=style.accent, fg=T.navy)
    add_textbox_mono(s, Inches(0.95), Inches(2.15), Inches(11.33), Inches(2.35), prompt, size=14, color=style.code_fg)

    if tips:
        # Tip cards (3)
        add_textbox(s, Inches(0.7), Inches(4.95), Inches(6.0), Inches(0.35), "핵심 포인트", size=16, bold=True, color=T.text)
        tip_list = list(tips)[:3]
        tw = Inches(3.85)
        ty = Inches(5.3)
        for i, tip in enumerate(tip_list):
            tx = Inches(0.7) + (tw + Inches(0.25)) * i
            add_shadow_card(s, tx, ty, tw, Inches(1.15), fill=T.white, line=_rgb("#E5E7EB"))
            add_icon_circle(s, tx + Inches(0.25), ty + Inches(0.28), Inches(0.55), "i", fill=style.accent_2, fg=T.text)
            add_textbox(s, tx + Inches(0.95), ty + Inches(0.22), tw - Inches(1.15), Inches(0.9), tip, size=14, bold=True, color=T.text, line_spacing=1.08)

    add_footer(s, slide_no, dark=False, footer_left=footer_left)
    return s


def slide_table(
    prs: Presentation,
    slide_no: int,
    *,
    title: str,
    columns: Sequence[str],
    rows: Sequence[Sequence[str]],
    chapter: str | None,
    footer_left: str | None,
    style: DeckStyle,
):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    set_slide_bg(s, T.bg)
    _header(s, title, chapter=chapter, style=style)

    x = Inches(0.7)
    y = Inches(1.55)
    w = Inches(11.93)
    h = Inches(4.9)
    add_shadow_card(s, x, y, w, h, fill=T.white, line=T.line)
    add_rect(s, x, y, w, Inches(0.08), fill=style.accent, line=None, radius=False)
    add_icon_circle(s, x + Inches(0.2), y + Inches(0.18), Inches(0.55), "▦", fill=style.accent_2, fg=T.text)

    n_rows = 1 + len(rows)
    n_cols = max(1, len(columns))
    # Leave a strip at bottom for mini chart.
    table_h = h - Inches(1.45)
    tbl = s.shapes.add_table(n_rows, n_cols, x + Inches(0.35), y + Inches(0.35), w - Inches(0.7), table_h).table

    col_names = [str(c) for c in columns]
    col_names_l = [c.lower() for c in col_names]
    savings_idx = next((j for j, c in enumerate(col_names_l) if ("절감" in c or "감소" in c or "saving" in c)), None)
    before_idx = next((j for j, c in enumerate(col_names_l) if ("기존" in c or "before" in c or "as-is" in c or "현재" in c)), None)
    after_idx = next((j for j, c in enumerate(col_names_l) if ("ai" in c or "after" in c or "to-be" in c or "협업" in c or "개선" in c)), None)

    for j, col in enumerate(columns):
        cell = tbl.cell(0, j)
        cell.text = col
        for p in cell.text_frame.paragraphs:
            for run in p.runs:
                run.font.name = T.font_bold
                run.font.bold = True
                run.font.size = Pt(14)
                run.font.color.rgb = T.white
            p.alignment = PP_ALIGN.CENTER
        cell.fill.solid()
        if savings_idx is not None and j == savings_idx:
            cell.fill.fore_color.rgb = _rgb("#166534")  # deep green header for savings
        else:
            cell.fill.fore_color.rgb = T.navy_2

    for i, r in enumerate(rows, start=1):
        for j in range(n_cols):
            cell = tbl.cell(i, j)
            cell.text = (r[j] if j < len(r) else "")
            for p in cell.text_frame.paragraphs:
                for run in p.runs:
                    run.font.name = T.font
                    run.font.size = Pt(12)
                    run.font.color.rgb = T.text
                p.alignment = PP_ALIGN.LEFT if j != 0 else PP_ALIGN.CENTER
            cell.fill.solid()
            base = _rgb("#F9FAFB") if i % 2 == 0 else T.white
            if savings_idx is not None and j == savings_idx:
                # subtle green tint for savings column
                base = _rgb("#ECFDF5") if i % 2 == 0 else _rgb("#F0FDF4")
            cell.fill.fore_color.rgb = base

    # --- Mini chart strip (paired bars / savings emphasis / percent) ---
    def minutes_ok(col_idx: int) -> int:
        ok = 0
        for rr in rows:
            if col_idx < len(rr) and _parse_minutes(rr[col_idx]) is not None:
                ok += 1
        return ok

    def percent_ok(col_idx: int) -> int:
        ok = 0
        for rr in rows:
            if col_idx < len(rr) and _parse_percent(rr[col_idx]) is not None:
                ok += 1
        return ok

    minute_cols = [(minutes_ok(j), j) for j in range(1, n_cols)]
    minute_cols.sort(reverse=True)
    percent_cols = [(percent_ok(j), j) for j in range(1, n_cols)]
    percent_cols.sort(reverse=True)

    # choose mode
    mode: str | None = None
    if before_idx is not None and after_idx is not None and minutes_ok(before_idx) >= 2 and minutes_ok(after_idx) >= 2:
        mode = "paired_time"
        a_idx = before_idx
        b_idx = after_idx
    elif percent_cols and percent_cols[0][0] >= 2:
        mode = "percent"
        p_idx = percent_cols[0][1]
    elif minute_cols and minute_cols[0][0] >= 2:
        mode = "single_time"
        a_idx = minute_cols[0][1]
        b_idx = minute_cols[1][1] if len(minute_cols) > 1 and minute_cols[1][0] >= 2 else None
    else:
        mode = None

    if mode:
        chart_y = y + Inches(0.35) + table_h + Inches(0.18)
        chart_h = Inches(0.86)
        add_rect(s, x, chart_y, w, chart_h, fill=style.accent_2, line=None, radius=True)
        add_textbox(s, x + Inches(0.35), chart_y + Inches(0.12), Inches(2.0), Inches(0.25), "Mini Chart", size=12, bold=True, color=T.text)

        label_x = x + Inches(0.35)
        label_w = Inches(5.6)
        bar_x = x + Inches(6.15)
        bar_w = Inches(6.1)

        if mode == "percent":
            # Legend
            add_rect(s, x + Inches(2.45), chart_y + Inches(0.18), Inches(0.22), Inches(0.14), fill=style.accent, line=None, radius=True)
            add_textbox(s, x + Inches(2.72), chart_y + Inches(0.11), Inches(3.4), Inches(0.3), str(columns[p_idx])[:18], size=11, bold=True, color=T.muted)
            max_rows = min(4, len(rows))
            for i in range(max_rows):
                rr = rows[i]
                label = (rr[0] if rr else f"Row {i+1}")[:24]
                v = _parse_percent(rr[p_idx]) if p_idx < len(rr) else None
                if v is None:
                    continue
                yy = chart_y + Inches(0.38) + Inches(0.18) * i
                add_textbox(s, label_x, yy - Inches(0.06), label_w, Inches(0.2), label, size=11, bold=True, color=T.text)
                bw = bar_w * (v / 100.0)
                add_rect(s, bar_x, yy, bw, Inches(0.12), fill=style.accent, line=None, radius=True)
                add_textbox(s, bar_x + bw + Inches(0.08), yy - Inches(0.06), Inches(1.0), Inches(0.2), f"{v:.0f}%", size=10, bold=True, color=T.muted)

        elif mode == "paired_time":
            before_name = str(columns[a_idx])
            after_name = str(columns[b_idx])
            # Legend: before/after + savings
            c_before = _rgb("#94A3B8")
            c_after = style.accent
            c_save = T.accent_green
            lx = x + Inches(2.45)
            for k, (cc, name) in enumerate([(c_before, before_name), (c_after, after_name), (c_save, "절감")]):
                add_rect(s, lx + Inches(1.45) * k, chart_y + Inches(0.18), Inches(0.22), Inches(0.14), fill=cc, line=None, radius=True)
                add_textbox(s, lx + Inches(1.45) * k + Inches(0.28), chart_y + Inches(0.11), Inches(1.2), Inches(0.3), name[:10], size=11, bold=True, color=T.muted)

            max_rows = min(3, len(rows))
            max_v = 0.0
            parsed: list[tuple[str, float | None, float | None, float | None]] = []
            for i in range(max_rows):
                rr = rows[i]
                label = (rr[0] if rr else f"Row {i+1}")[:24]
                va = _parse_minutes(rr[a_idx]) if a_idx < len(rr) else None
                vb = _parse_minutes(rr[b_idx]) if b_idx < len(rr) else None
                vs = _parse_minutes(rr[savings_idx]) if (savings_idx is not None and savings_idx < len(rr)) else (va - vb if (va is not None and vb is not None) else None)
                parsed.append((label, va, vb, vs))
                for vv in (va, vb):
                    if vv is not None:
                        max_v = max(max_v, vv)
            if max_v <= 0:
                max_v = 1.0

            for i, (label, va, vb, vs) in enumerate(parsed):
                yy = chart_y + Inches(0.36) + Inches(0.26) * i
                add_textbox(s, label_x, yy - Inches(0.04), label_w, Inches(0.2), label, size=11, bold=True, color=T.text)
                # stacked bars
                if va is not None:
                    add_rect(s, bar_x, yy, bar_w * (va / max_v), Inches(0.10), fill=c_before, line=None, radius=True)
                if vb is not None:
                    add_rect(s, bar_x, yy + Inches(0.12), bar_w * (vb / max_v), Inches(0.10), fill=c_after, line=None, radius=True)
                if vs is not None and vs > 0:
                    add_pill(s, bar_x + bar_w + Inches(0.08), yy - Inches(0.03), Inches(1.05), Inches(0.22), f"-{vs:.0f}분", fill=_rgb("#DCFCE7"), fg=_rgb("#166534"))

        else:  # single_time
            # pick up to two cols and highlight savings if present
            chosen = [a_idx] + ([b_idx] if b_idx is not None else [])
            colors = [style.accent, _rgb("#94A3B8")]
            lx = x + Inches(2.45)
            for k, j in enumerate(chosen[:2]):
                add_rect(s, lx + Inches(1.45) * k, chart_y + Inches(0.18), Inches(0.22), Inches(0.14), fill=colors[k], line=None, radius=True)
                add_textbox(s, lx + Inches(1.45) * k + Inches(0.28), chart_y + Inches(0.11), Inches(1.2), Inches(0.3), str(columns[j])[:10], size=11, bold=True, color=T.muted)

            max_rows = min(4, len(rows))
            max_v = 0.0
            for i in range(max_rows):
                rr = rows[i]
                for j in chosen[:2]:
                    v = _parse_minutes(rr[j]) if j < len(rr) else None
                    if v is not None:
                        max_v = max(max_v, v)
            if max_v <= 0:
                max_v = 1.0

            for i in range(max_rows):
                rr = rows[i]
                label = (rr[0] if rr else f"Row {i+1}")[:24]
                yy = chart_y + Inches(0.38) + Inches(0.18) * i
                add_textbox(s, label_x, yy - Inches(0.06), label_w, Inches(0.2), label, size=11, bold=True, color=T.text)
                for k, j in enumerate(chosen[:2]):
                    v = _parse_minutes(rr[j]) if j < len(rr) else None
                    if v is None:
                        continue
                    bw = bar_w * (v / max_v)
                    yk = yy + Inches(0.14) * k
                    add_rect(s, bar_x, yk, bw, Inches(0.12), fill=colors[k], line=None, radius=True)
                    add_textbox(s, bar_x + bw + Inches(0.08), yk - Inches(0.06), Inches(1.0), Inches(0.2), str(rr[j])[:8], size=10, bold=True, color=T.muted)

    add_footer(s, slide_no, dark=False, footer_left=footer_left)
    return s


def _require(cond: bool, msg: str) -> None:
    if not cond:
        raise ValueError(msg)


def _as_list(x: Any) -> list[Any]:
    if x is None:
        return []
    if isinstance(x, list):
        return x
    raise TypeError(f"expected list, got {type(x).__name__}")


def build_ppt(spec: dict[str, Any]) -> Presentation:
    meta = spec.get("meta") or {}
    title = str(meta.get("title") or "Untitled")
    subtitle = meta.get("subtitle")
    footer_left = meta.get("footer_left")
    badge = meta.get("badge")
    style = style_from_meta(meta)

    slides = _as_list(spec.get("slides"))
    _require(len(slides) >= 20, f"slides must be >= 20 (got {len(slides)})")

    prs = Presentation()
    prs.slide_width = SLIDE_W
    prs.slide_height = SLIDE_H

    first = slides[0]
    _require(first.get("type") == "cover", "first slide must be type=cover")
    s0 = slide_cover(prs, title=title, subtitle=str(subtitle) if subtitle else None, badge=str(badge) if badge else None, footer_left=str(footer_left) if footer_left else None, style=style)
    set_notes(s0, first.get("notes"))

    slide_no = 2
    for item in slides[1:]:
        t = item.get("type")
        chapter = item.get("chapter")
        if t == "agenda":
            s = slide_agenda(prs, slide_no, title=str(item.get("title") or "Agenda"), items=[str(x) for x in _as_list(item.get("items"))], footer_left=str(footer_left) if footer_left else None, style=style)
        elif t == "section":
            s = slide_section(prs, slide_no, title=str(item.get("title") or ""), subtitle=str(item.get("subtitle")) if item.get("subtitle") else None, footer_left=str(footer_left) if footer_left else None, style=style)
        elif t == "bullets":
            s = slide_bullets(prs, slide_no, title=str(item.get("title") or ""), bullets=[str(x) for x in _as_list(item.get("bullets"))], chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style)
        elif t == "two_column":
            s = slide_two_column(
                prs,
                slide_no,
                title=str(item.get("title") or ""),
                left_title=str(item.get("left_title") or "Left"),
                left_bullets=[str(x) for x in _as_list(item.get("left_bullets"))],
                right_title=str(item.get("right_title") or "Right"),
                right_bullets=[str(x) for x in _as_list(item.get("right_bullets"))],
                chapter=str(chapter) if chapter else None,
                footer_left=str(footer_left) if footer_left else None,
                style=style,
            )
        elif t == "process":
            s = slide_process(prs, slide_no, title=str(item.get("title") or ""), steps=[str(x) for x in _as_list(item.get("steps"))], chapter=str(chapter) if chapter else None, footer_left=str(footer_left) if footer_left else None, style=style)
        elif t == "prompt":
            s = slide_prompt(
                prs,
                slide_no,
                title=str(item.get("title") or ""),
                prompt=str(item.get("prompt") or ""),
                tips=[str(x) for x in _as_list(item.get("tips"))] if item.get("tips") is not None else None,
                chapter=str(chapter) if chapter else None,
                footer_left=str(footer_left) if footer_left else None,
                style=style,
            )
        elif t == "table":
            s = slide_table(
                prs,
                slide_no,
                title=str(item.get("title") or ""),
                columns=[str(x) for x in _as_list(item.get("columns"))],
                rows=[[str(c) for c in _as_list(r)] for r in _as_list(item.get("rows"))],
                chapter=str(chapter) if chapter else None,
                footer_left=str(footer_left) if footer_left else None,
                style=style,
            )
        else:
            raise ValueError(f"unknown slide type: {t!r}")
        set_notes(s, item.get("notes"))
        slide_no += 1

    return prs


def load_spec(path: Path) -> dict[str, Any]:
    data = json.loads(path.read_text(encoding="utf-8"))
    if not isinstance(data, dict):
        raise TypeError("spec must be a JSON object")
    return data


def maybe_install_deps() -> None:
    try:
        import pptx  # noqa: F401
    except Exception:
        subprocess.check_call([sys.executable, "-m", "pip", "install", "python-pptx"])


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--spec", required=True, help="slides.json path")
    ap.add_argument("--out", required=False, help="output .pptx path (default: <spec-dir>/<title>.pptx)")
    ap.add_argument("--install-deps", action="store_true", help="pip install python-pptx if missing")
    args = ap.parse_args()

    if args.install_deps:
        maybe_install_deps()

    spec_path = Path(args.spec).resolve()
    spec = load_spec(spec_path)
    meta = spec.get("meta") or {}
    title = str(meta.get("title") or spec_path.parent.name)
    out = Path(args.out).resolve() if args.out else (spec_path.parent / f"{title}.pptx").resolve()
    out.parent.mkdir(parents=True, exist_ok=True)

    prs = build_ppt(spec)
    prs.save(str(out))
    print(f"wrote {out} ({out.stat().st_size:,} bytes)")


if __name__ == "__main__":
    main()

