"""
SignalWire Slide Deck Core — DTCG tokens, helpers, brand assets.

Import with: from signalwire_slides.core import *
         or: from signalwire_slides import *

All colors, fonts, dimensions, and helper functions live here.
Layout modules import this and define one slide function each.
"""

import json
import os
import io
import re
from importlib import resources as _pkg_resources
import cairosvg
from lxml import etree
from pptx import Presentation
from pptx.util import Inches, Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR
from pptx.enum.shapes import MSO_SHAPE
from pptx.oxml.ns import qn

# ═══════════════════════════════════════════════════════════════════════════════
# DTCG TOKEN LOADER (single source of truth)
# ═══════════════════════════════════════════════════════════════════════════════

__all__ = [
    # Token loader
    "_hex_to_rgb", "_load_dtcg", "_tv",
    # Theme dicts and constants
    "GRAD", "LIGHT", "LIGHT_PAGE_BG", "GRADIENT_STOPS",
    "BLUE", "FUCHSIA", "TURQUOISE", "GOLD", "WHITE",
    "FONT_H", "FONT_B",
    "SW", "SH", "ML", "CW",
    "FOOTER_Y", "FOOTER_H", "LOGO_BODY_H", "LOGO_TITLE_H", "LOGO_X", "LOGO_Y",
    "TITLE_SIZE", "TITLE_LG", "BODY_SIZE", "BODY_SM",
    "CAPTION_SIZE", "EYEBROW_SIZE", "STAT_SIZE", "SUBTITLE_SIZE",
    "LINE_SP", "BULLET_AFTER",
    # SVG logos
    "LOGO_WHITE_SVG", "LOGO_BLACK_SVG",
    # Helpers
    "svg_to_png", "set_bg", "gradient_rect", "gradient_rect_force", "apply_bg",
    "rect", "hline", "vline", "txt", "eyebrow", "bullets", "numbered_list",
    "add_logo", "footer_bar", "set_cell_border", "chevron",
    # Icons
    "ICON_DIR", "ICONS_AVAILABLE", "CURATED_TARBALL", "CURATED_ICONS",
    "icon_to_png", "add_icon_to_slide",
    # Builder
    "SlideBuilder",
    # Re-exports from pptx used by layouts
    "Presentation", "Inches", "Pt", "Emu", "RGBColor",
    "PP_ALIGN", "MSO_ANCHOR", "MSO_SHAPE", "qn", "etree",
    "os", "io",
]

def _asset_path(filename):
    """Resolve path to a bundled asset file."""
    ref = _pkg_resources.files("signalwire_slides").joinpath("assets", filename)
    # Python 3.9+: as_posix works for files on disk
    return str(ref)

DTCG_PATH = _asset_path("signalwire-tokens.dtcg.json")

def _load_dtcg():
    with open(DTCG_PATH) as f:
        return json.load(f)

def _tv(obj, *keys):
    """Traverse nested DTCG token object and return $value."""
    for k in keys:
        obj = obj[k]
    return obj["$value"]

def _hex_to_rgb(hex_str):
    """Convert '#RRGGBB' or 'RRGGBB' to RGBColor."""
    h = hex_str.lstrip("#")
    return RGBColor(int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))

def _load_tokens():
    """Load DTCG tokens and build brand colors, fonts, and theme dicts."""
    dtcg = _load_dtcg()
    shared = dtcg["shared"]
    dark = dtcg["dark"]
    light = dtcg["light"]

    brand = {
        "blue": _hex_to_rgb(_tv(shared, "brand", "blue")),
        "fuchsia": _hex_to_rgb(_tv(shared, "brand", "fuchsia")),
        "turquoise": _hex_to_rgb(_tv(shared, "brand", "turquoise")),
        "gold": _hex_to_rgb(_tv(shared, "brand", "gold")),
    }

    fonts = {
        "heading": _tv(shared, "typography", "family-heading").split(",")[0].strip().strip("'"),
        "body": _tv(shared, "typography", "family-body").split(",")[0].strip().strip("'"),
    }

    grad = dict(
        name="gradient", is_gradient=True,
        surface=_hex_to_rgb(_tv(dark, "background", "surface")),
        raised=_hex_to_rgb(_tv(dark, "background", "surface-raised")),
        text_hi=_hex_to_rgb(_tv(dark, "foreground", "default")),
        text2=_hex_to_rgb(_tv(dark, "foreground", "secondary")),
        muted=_hex_to_rgb(_tv(dark, "foreground", "muted")),
        subtle=_hex_to_rgb(_tv(dark, "foreground", "subtle")),
        footer_bg=_hex_to_rgb(_tv(dark, "neutral", "black")),
        emphasis=_hex_to_rgb(_tv(dark, "foreground", "emphasis")),
        emphasis_alt=_hex_to_rgb(_tv(dark, "foreground", "emphasis-alt")),
        emphasis_small=_hex_to_rgb(_tv(dark, "foreground", "emphasis-small")),
    )

    lt = dict(
        name="light", is_gradient=False,
        surface=_hex_to_rgb(_tv(light, "background", "surface")),
        raised=_hex_to_rgb(_tv(light, "background", "surface-raised")),
        text_hi=_hex_to_rgb(_tv(light, "foreground", "default")),
        text2=_hex_to_rgb(_tv(light, "foreground", "secondary")),
        muted=_hex_to_rgb(_tv(light, "foreground", "muted")),
        subtle=_hex_to_rgb(_tv(light, "foreground", "subtle")),
        footer_bg=_hex_to_rgb(_tv(light, "background", "surface")),
        emphasis=_hex_to_rgb(_tv(light, "foreground", "emphasis")),
        emphasis_alt=_hex_to_rgb(_tv(light, "foreground", "emphasis-alt")),
        emphasis_small=_hex_to_rgb(_tv(light, "foreground", "emphasis-small")),
    )

    lt_page = _hex_to_rgb(_tv(light, "background", "page"))
    grad_token = _tv(dark, "background", "gradient-slide")

    return brand, fonts, grad, lt, lt_page, grad_token


_brand, _fonts, GRAD, LIGHT, LIGHT_PAGE_BG, SLIDE_GRADIENT = _load_tokens()

BLUE = _brand["blue"]
FUCHSIA = _brand["fuchsia"]
TURQUOISE = _brand["turquoise"]
GOLD = _brand["gold"]
WHITE = RGBColor(0xFF, 0xFF, 0xFF)

FONT_H = _fonts["heading"]
FONT_B = _fonts["body"]

# Slide dimensions (16:9)
SW = Inches(13.333)
SH = Inches(7.5)

# Margins
ML = Inches(0.7)
CW = Inches(11.933)

# Footer zone
FOOTER_Y = SH - Inches(0.45)
FOOTER_H = Inches(0.45)

# Logo placement (bottom-right, consistent)
LOGO_BODY_H = Inches(0.25)
LOGO_TITLE_H = Inches(0.4)
LOGO_X = SW - ML - Inches(2.2)
LOGO_Y = FOOTER_Y + Inches(0.1)

# Typography
TITLE_SIZE = Pt(36)
TITLE_LG = Pt(48)
BODY_SIZE = Pt(24)
BODY_SM = Pt(20)
CAPTION_SIZE = Pt(14)
EYEBROW_SIZE = Pt(14)
STAT_SIZE = Pt(96)
SUBTITLE_SIZE = Pt(24)
LINE_SP = 1.2
BULLET_AFTER = Pt(10)

# ═══════════════════════════════════════════════════════════════════════════════
# SVG LOGOS (full wordmark, white text + black text)
# ═══════════════════════════════════════════════════════════════════════════════

with open(_asset_path("signalwire-full-logo-white-text.svg")) as _f:
    LOGO_WHITE_SVG = _f.read()
with open(_asset_path("signalwire-full-logo-black-text.svg")) as _f:
    LOGO_BLACK_SVG = _f.read()

# ═══════════════════════════════════════════════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════════════════════════════════════════════

def svg_to_png(svg_str, width=None):
    kw = {"output_width": width} if width else {}
    return cairosvg.svg2png(bytestring=svg_str.encode("utf-8"), **kw)

def set_bg(obj, color):
    obj.background.fill.solid()
    obj.background.fill.fore_color.rgb = color

def _parse_gradient_stops(css_gradient):
    """Parse CSS linear-gradient into (angle, [(pos%, hex), ...])."""
    stops = re.findall(r'#([0-9A-Fa-f]{6})\s+(\d+)%', css_gradient)
    return [(str(int(pct) * 1000), hexval.upper()) for hexval, pct in stops]

GRADIENT_STOPS = _parse_gradient_stops(SLIDE_GRADIENT)

def gradient_rect(slide):
    shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SW, SH)
    shape.line.fill.background()
    spPr = shape._element.spPr
    for child in list(spPr):
        if child.tag.endswith(('solidFill', 'noFill', 'gradFill')):
            spPr.remove(child)
    gf = etree.SubElement(spPr, qn("a:gradFill"))
    gl = etree.SubElement(gf, qn("a:gsLst"))
    for pos, val in GRADIENT_STOPS:
        gs = etree.SubElement(gl, qn("a:gs"), attrib={"pos": pos})
        etree.SubElement(gs, qn("a:srgbClr"), attrib={"val": val})
    etree.SubElement(gf, qn("a:lin"), attrib={"ang": "0", "scaled": "1"})

def gradient_rect_force(slide):
    """Gradient rect that doesn't depend on theme. Uses DTCG gradient-slide token."""
    shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SW, SH)
    shape.line.fill.background()
    spPr = shape._element.spPr
    for child in list(spPr):
        if child.tag.endswith(('solidFill', 'noFill', 'gradFill')):
            spPr.remove(child)
    gf = etree.SubElement(spPr, qn("a:gradFill"))
    gl = etree.SubElement(gf, qn("a:gsLst"))
    for pos, val in GRADIENT_STOPS:
        gs = etree.SubElement(gl, qn("a:gs"), attrib={"pos": pos})
        etree.SubElement(gs, qn("a:srgbClr"), attrib={"val": val})
    etree.SubElement(gf, qn("a:lin"), attrib={"ang": "0", "scaled": "1"})

def _add_ai_notes(slide, text, bg_color):
    """Add hidden text behind the background layer. 1pt font, same color as bg."""
    tb = slide.shapes.add_textbox(0, 0, Inches(1), Inches(0.2))
    tf = tb.text_frame; tf.word_wrap = False
    p = tf.paragraphs[0]; p.text = text
    p.font.size = Pt(1); p.font.color.rgb = bg_color
    p.font.name = FONT_B

PATTERN_OVERLAY = _asset_path("tumbling-blocks-overlay.png")

def _add_pattern_overlay(slide):
    """Add subtle geometric pattern overlay on top of background."""
    if os.path.exists(PATTERN_OVERLAY):
        with open(PATTERN_OVERLAY, "rb") as f:
            slide.shapes.add_picture(io.BytesIO(f.read()), 0, 0, width=SW, height=SH)

def apply_bg(slide, T, ai_notes=None):
    if T["is_gradient"]:
        first_hex = GRADIENT_STOPS[0][1] if GRADIENT_STOPS else "010915"
        bg_color = _hex_to_rgb(first_hex)
        set_bg(slide, bg_color)
        if ai_notes:
            _add_ai_notes(slide, ai_notes, bg_color)
        gradient_rect(slide)
        _add_pattern_overlay(slide)
    else:
        set_bg(slide, LIGHT_PAGE_BG)
        if ai_notes:
            _add_ai_notes(slide, ai_notes, LIGHT_PAGE_BG)
        # Light background rect layer (covers the ai_notes)
        sh = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, SW, SH)
        sh.fill.solid(); sh.fill.fore_color.rgb = LIGHT_PAGE_BG
        sh.line.fill.background()
        _add_pattern_overlay(slide)

def rect(s, l, t, w, h, c):
    sh = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, l, t, w, h)
    sh.fill.solid(); sh.fill.fore_color.rgb = c; sh.line.fill.background()
    return sh

def hline(s, l, t, w, c, h=Inches(0.04)): return rect(s, l, t, w, h, c)
def vline(s, l, t, h, c, w=Inches(0.04)): return rect(s, l, t, w, h, c)

def txt(s, l, t, w, h, text, font=None, size=None, color=None,
        bold=False, italic=False, align=PP_ALIGN.LEFT, anchor="t",
        line_spacing=None, space_after=None):
    if font is None: font = FONT_B
    if size is None: size = BODY_SIZE
    tb = s.shapes.add_textbox(l, t, w, h)
    tf = tb.text_frame; tf.word_wrap = True
    bp = tf.paragraphs[0]._p.getparent().find(qn("a:bodyPr"))
    if bp is not None:
        bp.set("anchor", anchor)
        # Enable normAutofit so text shrinks to fit in PowerPoint/Google Slides
        for _child in list(bp):
            if _child.tag.endswith(("noAutofit", "spAutoFit", "normAutofit")):
                bp.remove(_child)
        etree.SubElement(bp, qn("a:normAutofit"))
    lines = text.split("\n")
    for i, line_text in enumerate(lines):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.text = line_text; p.font.name = font; p.font.size = size
        if color: p.font.color.rgb = color
        p.font.bold = bold; p.font.italic = italic; p.alignment = align
        if line_spacing: p.line_spacing = line_spacing
        if space_after: p.space_after = space_after
    return tb

def eyebrow(s, l, t, w, text, color):
    return txt(s, l, t, w, Inches(0.3), text.upper(), FONT_H, EYEBROW_SIZE, color)

def bullets(s, l, t, w, h, items, font=None, size=None, color=None, bc=None):
    if font is None: font = FONT_B
    if size is None: size = BODY_SIZE
    tb = s.shapes.add_textbox(l, t, w, h)
    tf = tb.text_frame; tf.word_wrap = True
    _bp = tf.paragraphs[0]._p.getparent().find(qn("a:bodyPr"))
    if _bp is not None:
        for _c in list(_bp):
            if _c.tag.endswith(("noAutofit", "spAutoFit", "normAutofit")):
                _bp.remove(_c)
        etree.SubElement(_bp, qn("a:normAutofit"))
    for i, item in enumerate(items):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.text = item; p.font.name = font; p.font.size = size
        if color: p.font.color.rgb = color
        p.line_spacing = LINE_SP; p.space_after = BULLET_AFTER
        pPr = p._p.get_or_add_pPr()
        bn = pPr.find(qn("a:buNone"))
        if bn is not None: pPr.remove(bn)
        if bc:
            buClr = pPr.makeelement(qn("a:buClr"), {})
            etree.SubElement(buClr, qn("a:srgbClr"), attrib={"val": "%02X%02X%02X" % (bc[0], bc[1], bc[2])})
            pPr.append(buClr)
        pPr.append(pPr.makeelement(qn("a:buChar"), {"char": "\u2022"}))
        pPr.set("marL", str(Emu(Inches(0.4)))); pPr.set("indent", str(-Emu(Inches(0.4))))
    return tb

def numbered_list(s, l, t, w, h, items, size=None, color=None, nc=None):
    if size is None: size = BODY_SIZE
    tb = s.shapes.add_textbox(l, t, w, h)
    tf = tb.text_frame; tf.word_wrap = True
    _bp = tf.paragraphs[0]._p.getparent().find(qn("a:bodyPr"))
    if _bp is not None:
        for _c in list(_bp):
            if _c.tag.endswith(("noAutofit", "spAutoFit", "normAutofit")):
                _bp.remove(_c)
        etree.SubElement(_bp, qn("a:normAutofit"))
    for i, item in enumerate(items):
        p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
        p.text = item; p.font.name = FONT_B; p.font.size = size
        if color: p.font.color.rgb = color
        p.line_spacing = LINE_SP; p.space_after = BULLET_AFTER
        pPr = p._p.get_or_add_pPr()
        bn = pPr.find(qn("a:buNone"))
        if bn is not None: pPr.remove(bn)
        if nc:
            buClr = pPr.makeelement(qn("a:buClr"), {})
            etree.SubElement(buClr, qn("a:srgbClr"), attrib={"val": "%02X%02X%02X" % (nc[0], nc[1], nc[2])})
            pPr.append(buClr)
        pPr.append(pPr.makeelement(qn("a:buAutoNum"), {"type": "arabicPeriod"}))
        pPr.set("marL", str(Emu(Inches(0.4)))); pPr.set("indent", str(-Emu(Inches(0.4))))
    return tb

def add_logo(s, png, left, top, height):
    s.shapes.add_picture(io.BytesIO(png), left, top, height=height)

def footer_bar(s, T, logo, lh=None):
    if lh is None: lh = LOGO_BODY_H
    rect(s, Inches(0), FOOTER_Y, SW, FOOTER_H, T["footer_bg"])
    add_logo(s, logo, LOGO_X, LOGO_Y, lh)
    # Slide number removed: not reliable when mixing slides across decks
    txt(s, Inches(3), LOGO_Y, Inches(7), Inches(0.2),
        "\u00a9SignalWire  |  Do Not Distribute Without Company Permission",
        FONT_B, Pt(8), T["subtle"], align=PP_ALIGN.CENTER)

def set_cell_border(cell, color_hex="333338", width='6350'):
    tc = cell._tc; tcPr = tc.get_or_add_tcPr()
    for edge in ['a:lnL', 'a:lnR', 'a:lnT', 'a:lnB']:
        ln = tcPr.makeelement(qn(edge), {}); ln.set('w', width)
        sf = ln.makeelement(qn('a:solidFill'), {})
        sf.append(sf.makeelement(qn('a:srgbClr'), {'val': color_hex}))
        ln.append(sf); tcPr.append(ln)

def chevron(s, l, t, w, h, color, label="", tc=None):
    sh = s.shapes.add_shape(MSO_SHAPE.CHEVRON, l, t, w, h)
    sh.fill.solid(); sh.fill.fore_color.rgb = color; sh.line.fill.background()
    if label:
        tf = sh.text_frame; tf.word_wrap = True
        p = tf.paragraphs[0]; p.text = label; p.font.name = FONT_H
        p.font.size = Pt(16); p.font.bold = True; p.alignment = PP_ALIGN.CENTER
        if tc: p.font.color.rgb = tc
        bp = p._p.getparent().find(qn("a:bodyPr"))
        if bp: bp.set("anchor", "ctr")

# ═══════════════════════════════════════════════════════════════════════════════
# CURATED ICON SET (Font Awesome Pro 7.2.0, fill="currentColor")
# ═══════════════════════════════════════════════════════════════════════════════

_PKG_DIR = os.path.dirname(os.path.abspath(__file__))
ICON_DIR = os.path.join(_PKG_DIR, "icons_svg")
ICONS_AVAILABLE = os.path.isdir(ICON_DIR)
CURATED_TARBALL = os.path.join(_PKG_DIR, "signalwire-icons-curated.tar.gz")

CURATED_ICONS = {
    "Communications": [
        "phone", "phone-volume", "headset", "microphone", "video",
        "comments", "message", "envelope", "paper-plane", "tower-broadcast",
    ],
    "Technology": [
        "server", "cloud", "database", "network-wired", "microchip",
        "code", "gear", "robot", "brain", "bolt",
    ],
    "Security": [
        "shield-halved", "lock", "key", "fingerprint", "eye",
    ],
    "Business": [
        "user", "users", "user-tie", "building", "briefcase",
        "handshake", "dollar-sign", "chart-line", "chart-bar", "chart-pie",
    ],
    "Status": [
        "check", "circle-check", "xmark", "triangle-exclamation",
        "circle-info", "star", "flag", "bell",
    ],
    "Actions": [
        "magnifying-glass", "download", "upload", "share", "link",
        "play", "pause", "arrow-trend-up", "arrows-rotate", "wand-magic-sparkles",
    ],
}


def _find_icon_svg(name):
    """Find an icon SVG file. Checks full dir first, then curated tarball."""
    path = os.path.join(ICON_DIR, f"{name}.svg")
    if os.path.exists(path):
        with open(path) as f:
            return f.read()
    if os.path.exists(CURATED_TARBALL):
        import tarfile
        try:
            with tarfile.open(CURATED_TARBALL, "r:gz") as tf:
                member = f"icons/{name}.svg"
                try:
                    f = tf.extractfile(member)
                    if f:
                        return f.read().decode("utf-8")
                except KeyError:
                    pass
        except Exception:
            pass
    return None


def icon_to_png(name, color_hex, size=48):
    """Load an SVG icon, recolor it, and convert to PNG."""
    svg = _find_icon_svg(name)
    if not svg:
        return None
    svg = svg.replace('fill="currentColor"', f'fill="#{color_hex}"')
    try:
        return cairosvg.svg2png(bytestring=svg.encode("utf-8"), output_width=size, output_height=size)
    except Exception:
        return None


def add_icon_to_slide(slide, name, left, top, size, color_hex):
    """Add a single icon to a slide. Works with full dir or curated tarball.
    Returns True if icon was added, False if not found."""
    png = icon_to_png(name, color_hex, size)
    if not png:
        return False
    slide.shapes.add_picture(io.BytesIO(png), left, top, height=Inches(size / 96))
    return True


# ═══════════════════════════════════════════════════════════════════════════════
# SLIDE BUILDER — high-level API for building slides
# ═══════════════════════════════════════════════════════════════════════════════

class SlideBuilder:
    """High-level slide builder. Handles background, footer, and common elements.

    Numbers passed as positions/sizes are auto-converted to Inches.
    Font sizes accept plain numbers (converted to Pt).

    Usage:
        S = SlideBuilder(prs, bk, GRAD, lw)
        S.title("Assertion headline here")
        S.body(["Point one", "Point two", "Point three"])
        S.done()
    """

    def __init__(self, prs, bk, T, logo, ai_notes=None):
        self.s = prs.slides.add_slide(bk)
        apply_bg(self.s, T, ai_notes=ai_notes)
        self.T = T
        self.logo = logo
        # Pre-computed color tuples for bullet/numbered list colors
        self.ec = (T["emphasis"][0], T["emphasis"][1], T["emphasis"][2])
        self.fc = (FUCHSIA[0], FUCHSIA[1], FUCHSIA[2])
        # Small text accent: from DTCG emphasis-small token.
        # Dark: turquoise. Light: near-black. Safe at any size on both themes.
        self.small_accent = T.get("emphasis_small", T["emphasis"])

    # ── font metrics (measured from installed Instrument Sans + Outfit) ────────
    # Average character width in points per font/size/weight.
    # Used for auto-fit: if text is wider than available space, step down font size.
    _CHAR_WIDTH = {
        # (font_key, bold, pt_size) -> avg char width in points
        ("h", True, 48): 29.2, ("h", True, 40): 24.4, ("h", True, 36): 22.0,
        ("h", True, 30): 18.4, ("h", True, 28): 17.1, ("h", True, 24): 14.7,
        ("h", False, 48): 28.5, ("h", False, 36): 21.5, ("h", False, 30): 17.8,
        ("h", False, 28): 16.6, ("h", False, 24): 14.3, ("h", False, 22): 13.1,
        ("h", False, 20): 11.9, ("h", False, 18): 10.7, ("h", False, 16): 9.6,
        ("h", False, 14): 8.2,
        ("b", False, 24): 13.8, ("b", False, 20): 11.4, ("b", False, 18): 10.3,
        ("b", False, 16): 9.1, ("b", False, 14): 8.0, ("b", False, 12): 6.9,
        ("b", True, 24): 14.2, ("b", True, 20): 11.8, ("b", True, 18): 10.7,
        ("b", True, 16): 9.5, ("b", True, 14): 8.3,
    }
    # Scale ladders: (default_pt, [step_down_sizes...], minimum_pt)
    _TITLE_SCALE = [48, 42, 40, 36]
    _HEADING_SCALE = [36, 32, 30, 28]
    _BODY_SCALE = [24, 22, 20, 18]
    _SMALL_SCALE = [20, 18, 16]

    def _measure_width(self, text, font_key, bold, pt_size):
        """Estimate text width in points using hardcoded character metrics."""
        key = (font_key, bold, pt_size)
        avg = self._CHAR_WIDTH.get(key)
        if avg is None:
            # Interpolate: find nearest size
            candidates = [(k, v) for k, v in self._CHAR_WIDTH.items()
                          if k[0] == font_key and k[1] == bold]
            if not candidates:
                return 0
            nearest = min(candidates, key=lambda kv: abs(kv[0][2] - pt_size))
            avg = nearest[1] * pt_size / nearest[0][2]
        return avg * len(text)

    def _auto_fit(self, text, width_emu, font_key, bold, default_pt, scale_ladder):
        """Find the largest font size from scale_ladder that fits text in width.
        Returns (Pt_size, was_shrunk). Prints warning if shrunk."""
        width_pt = width_emu * 72 / 914400  # Emu to points
        word_count = len(text.split())
        default_width = self._measure_width(text, font_key, bold, default_pt)
        overflow_pct = int((default_width / width_pt - 1) * 100) if default_width > width_pt else 0

        for pt in scale_ladder:
            if pt > default_pt:
                continue
            text_width = self._measure_width(text, font_key, bold, pt)
            if text_width <= width_pt:
                shrunk = pt < default_pt
                if shrunk:
                    # How many words would have fit at the default size?
                    avg_char = self._CHAR_WIDTH.get((font_key, bold, default_pt), 20)
                    chars_fit = width_pt / avg_char
                    words_fit = int(chars_fit / 6)  # avg word = 5 chars + space
                    over_by = word_count - words_fit
                    print(f"  [auto-fit] {default_pt}pt -> {pt}pt ({word_count} words, ~{over_by} over limit of ~{words_fit}): \"{text[:60]}{'...' if len(text)>60 else ''}\"")
                return Pt(pt), shrunk
        # Nothing fits, use minimum
        min_pt = scale_ladder[-1]
        avg_char = self._CHAR_WIDTH.get((font_key, bold, min_pt), 15)
        chars_fit = width_pt / avg_char
        words_fit = int(chars_fit / 6)
        over_by = word_count - words_fit
        print(f"  [auto-fit] OVERFLOW at {min_pt}pt (minimum): {word_count} words, ~{over_by} over limit of ~{words_fit}. Shorten text: \"{text[:60]}{'...' if len(text)>60 else ''}\"")
        return Pt(min_pt), True

    def _fit_wrapped(self, text, width_emu, height_emu, default_pt, line_spacing=1.4,
                      font_key="b", bold=False):
        """Auto-fit for wrapping prose. Shrinks only if wrapped text overflows vertically.
        Returns Pt-sized value."""
        width_pt = width_emu * 72 / 914400
        height_pt = height_emu * 72 / 914400
        ladder = self._CARD_BODY_SCALE

        for pt in ladder:
            if pt > default_pt:
                continue
            key = (font_key, bold, pt)
            avg_char = self._CHAR_WIDTH.get(key)
            if avg_char is None:
                candidates = [(k, v) for k, v in self._CHAR_WIDTH.items()
                              if k[0] == font_key and k[1] == bold]
                if not candidates:
                    return Pt(pt)
                nearest = min(candidates, key=lambda kv: abs(kv[0][2] - pt))
                avg_char = nearest[1] * pt / nearest[0][2]
            chars_per_line = max(1, int(width_pt / avg_char))
            # Count lines: split on \n first, then wrap each line
            total_lines = 0
            for paragraph in text.split("\n"):
                lines = max(1, -(-len(paragraph) // chars_per_line))  # ceiling division
                total_lines += lines
            line_height_pt = pt * line_spacing
            total_height = total_lines * line_height_pt
            if total_height <= height_pt:
                if pt < default_pt:
                    print(f"  [auto-fit] card body {default_pt}pt -> {pt}pt ({total_lines} lines): \"{text[:50]}{'...' if len(text)>50 else ''}\"")
                return Pt(pt)
        min_pt = ladder[-1]
        print(f"  [auto-fit] card body OVERFLOW at {min_pt}pt: text too long for box. Shorten: \"{text[:50]}{'...' if len(text)>50 else ''}\"")
        return Pt(min_pt)

    # Additional scale ladders for smaller elements
    _CARD_TITLE_SCALE = [22, 20, 18, 16]
    _CARD_BODY_SCALE = [18, 16, 14]
    _CAPTION_SCALE = [16, 14, 12]
    _STAT_SCALE = [96, 80, 72, 60]

    def _fit(self, text, width_emu, default_pt, font_key="b", bold=False):
        """Auto-fit helper. Picks the right scale ladder based on default size.
        Returns Pt-sized value. For multi-line text, measures the longest line."""
        # Multi-line: measure only the longest line
        if "\n" in text:
            text = max(text.split("\n"), key=len)
        if default_pt >= 60:
            ladder = self._STAT_SCALE
        elif default_pt >= 42:
            ladder = self._TITLE_SCALE
        elif default_pt >= 36:
            ladder = self._HEADING_SCALE
        elif default_pt >= 24:
            ladder = self._BODY_SCALE
        elif default_pt >= 20:
            ladder = self._SMALL_SCALE
        elif default_pt >= 16:
            ladder = self._CARD_BODY_SCALE
        else:
            ladder = self._CAPTION_SCALE
        sz, _ = self._auto_fit(text, width_emu, font_key, bold, default_pt, ladder)
        return sz

    # ── unit helpers ──────────────────────────────────────────────────────────

    def _in(self, v):
        """Convert to Emu. Floats and small ints (<20) treated as inches."""
        if v is None:
            return None
        if isinstance(v, float) or (isinstance(v, int) and -20 <= v <= 20):
            return Inches(v)
        return v  # already Emu

    def _pt(self, v):
        """Convert to Pt. Plain numbers become Pt(n)."""
        if v is None:
            return None
        if isinstance(v, (int, float)) and v < 200:
            return Pt(v)
        return v  # already Pt/Emu

    def _color(self, c):
        """Resolve a color. Accepts RGBColor, string name, or None (default text)."""
        if c is None:
            return self.T["text_hi"]
        if isinstance(c, RGBColor):
            return c
        return {
            "emphasis": self.T["emphasis"], "small_accent": self.small_accent,
            "text": self.T["text2"], "muted": self.T["muted"], "subtle": self.T["subtle"],
            "blue": BLUE, "fuchsia": FUCHSIA, "turquoise": TURQUOISE,
            "gold": GOLD, "white": WHITE,
        }.get(c, self.T["text_hi"])

    def _font(self, f):
        """Resolve a font name. 'h'/'heading' = heading font, else body."""
        if f in ("h", "heading"):
            return FONT_H
        return FONT_B

    # ── text elements ─────────────────────────────────────────────────────────

    def title(self, text, size=None, top=0.5, large=False, auto_fit=True):
        """Slide title. large=True for 48pt cover/section titles. Auto-shrinks if too wide."""
        default_pt = 48 if large else 36
        if size:
            sz = self._pt(size)
        elif auto_fit:
            scale = self._TITLE_SCALE if large else self._HEADING_SCALE
            sz, _ = self._auto_fit(text, CW, "h", True, default_pt, scale)
        else:
            sz = TITLE_LG if large else TITLE_SIZE
        h = Inches(2.0) if large else Inches(1.0)
        ls = 1.1 if large else 1.15
        txt(self.s, ML, self._in(top), CW, h,
            text, FONT_H, sz, self.T["text_hi"], bold=True, line_spacing=ls)
        return self

    def subtitle(self, text, top=4.5):
        """Muted subtitle text."""
        txt(self.s, ML, self._in(top), Inches(7), Inches(0.7),
            text, FONT_B, SUBTITLE_SIZE, self.T["text2"])
        return self

    def heading(self, text, left=None, top=0.5, size=None):
        """Alias for title with more positioning control."""
        left = self._in(left) if left is not None else ML
        txt(self.s, left, self._in(top), CW, Inches(1.0),
            text, FONT_H, self._pt(size) or TITLE_SIZE, self.T["text_hi"],
            bold=True, line_spacing=1.15)
        return self

    def label(self, text, left=None, top=0.35, color=None):
        """Uppercase eyebrow label. Uses small_accent (safe at small sizes)."""
        left = self._in(left) if left is not None else ML
        eyebrow(self.s, left, self._in(top), Inches(4), text,
                 self._color(color) if color else self.small_accent)
        return self

    def body(self, items, top=1.7, left=None, width=None, size=None, auto_fit=True):
        """Bullet list. Default position below a standard title. Auto-shrinks if items too wide."""
        l = self._in(left) if left is not None else ML + Inches(0.4)
        w = self._in(width) if width is not None else CW - Inches(0.5)
        if size:
            sz = self._pt(size)
        elif auto_fit:
            default_pt = 24
            longest = max(items, key=len) if items else ""
            sz, _ = self._auto_fit(longest, w, "b", False, default_pt, self._BODY_SCALE)
        else:
            sz = BODY_SIZE
        bullets(self.s, l, self._in(top), w, Inches(4.3),
                items, size=sz, color=self.T["text2"], bc=self.ec)
        return self

    def numbered(self, items, top=1.7):
        """Numbered list."""
        numbered_list(self.s, ML, self._in(top), Inches(10), Inches(4.8),
                      items, color=self.T["text2"], nc=self.ec)
        return self

    def text(self, content, left, top, w=None, h=None,
             font="body", size=None, color=None, bold=False, italic=False,
             align=PP_ALIGN.LEFT, anchor="t", line_spacing=None, space_after=None):
        """Place text anywhere. Numbers are inches, size is Pt."""
        w = self._in(w) if w is not None else CW
        h = self._in(h) if h is not None else Inches(1.0)
        txt(self.s, self._in(left), self._in(top), w, h,
            content, self._font(font), self._pt(size) or BODY_SIZE,
            self._color(color), bold=bold, italic=italic, align=align,
            anchor=anchor, line_spacing=line_spacing, space_after=space_after)
        return self

    # ── visual elements ───────────────────────────────────────────────────────

    def card(self, left, top, w, h, accent=None, title=None, body_text=None,
             title_size=22, body_size=16, title_color=None):
        """Card: surface rect + optional accent bar + optional title/body text.
        title_color defaults to theme emphasis (WCAG-safe). Accent is for the bar only."""
        l, t = self._in(left), self._in(top)
        w, h = self._in(w), self._in(h)
        rect(self.s, l, t, w, h, self.T["surface"])
        if accent is not None:
            hline(self.s, l, t, w, accent, Inches(0.04))
        pad = Inches(0.4)
        text_w = w - pad * 2
        if title:
            tc = self._color(title_color) if title_color else self.small_accent
            sz = self._fit(title, text_w, title_size, "h", True)
            txt(self.s, l + pad, t + Inches(0.3), text_w, Inches(0.5),
                title, FONT_H, sz, tc, bold=True)
        if body_text:
            bt = t + (Inches(1.0) if title else Inches(0.3))
            body_h = h - (bt - t) - Inches(0.3)
            # Wrapping prose: only shrink if it overflows the box vertically
            sz = self._fit_wrapped(body_text, text_w, body_h, body_size)
            txt(self.s, l + pad, bt, text_w, body_h,
                body_text, FONT_B, sz, self.T["text2"], line_spacing=1.4)
        return self

    def image_area(self, left, top, w, h, label="[ Image ]"):
        """Placeholder rectangle for an image or screenshot."""
        l, t, w, h = self._in(left), self._in(top), self._in(w), self._in(h)
        rect(self.s, l, t, w, h, self.T["surface"])
        txt(self.s, l, t + h // 2 - Inches(0.2), w, Inches(0.5),
            label, FONT_B, Pt(18), self.T["subtle"], align=PP_ALIGN.CENTER)
        return self

    def icon(self, name, left, top, size=48, color_hex=None):
        """Add an icon from the curated set."""
        if color_hex is None:
            color_hex = "%02X%02X%02X" % self.ec
        add_icon_to_slide(self.s, name, self._in(left), self._in(top), size, color_hex)
        return self

    # ── compound layouts ──────────────────────────────────────────────────────

    def kpi(self, value, caption, label="KEY METRIC"):
        """Big centered stat with eyebrow and caption."""
        eyebrow(self.s, Inches(2), Inches(1.5), Inches(9), label, self.T["muted"])
        sz = self._fit(value, Inches(11.3), 96, "h", True)
        txt(self.s, Inches(1), Inches(2.0), Inches(11.3), Inches(2.5),
            value, FONT_H, sz, self.T["emphasis"], bold=True,
            align=PP_ALIGN.CENTER, anchor="b")
        sz = self._fit(caption, Inches(9.3), 24, "b", False)
        txt(self.s, Inches(2), Inches(4.7), Inches(9.3), Inches(0.8),
            caption, FONT_B, sz, self.T["text2"], align=PP_ALIGN.CENTER)
        return self

    def quote(self, text, name, company=""):
        """Full-width quotation with attribution."""
        txt(self.s, Inches(1.2), Inches(0.8), Inches(2), Inches(1.8),
            "\u201C", FONT_H, Pt(120), self.T["emphasis"], bold=True)
        sz = self._fit(text, Inches(9), 28, "b", False)
        txt(self.s, Inches(1.8), Inches(2.4), Inches(9), Inches(2.5),
            text, FONT_B, sz, self.T["text_hi"], italic=True, line_spacing=1.3)
        sz = self._fit(name, Inches(8), 18, "h", True)
        txt(self.s, Inches(1.8), Inches(5.5), Inches(8), Inches(0.4),
            name, FONT_H, sz, self.T["emphasis"], bold=True)
        if company:
            txt(self.s, Inches(1.8), Inches(5.9), Inches(8), Inches(0.3),
                company, FONT_B, CAPTION_SIZE, self.T["muted"])
        return self

    def two_col(self, left_title, left_items, right_title, right_items,
                left_accent=None, right_accent=None):
        """Two side-by-side cards with titles and bullet lists."""
        la = left_accent or self.T["emphasis_alt"]
        ra = right_accent or FUCHSIA
        cw, cy, ch, pad = Inches(5.75), Inches(1.6), Inches(4.8), Inches(0.45)
        text_w = cw - pad * 2
        # Auto-fit: use longest item from both columns to pick a consistent bullet size
        all_items = list(left_items) + list(right_items)
        longest = max(all_items, key=len) if all_items else ""
        bullet_sz = self._fit(longest, text_w, 20, "b", False)
        # Left
        rect(self.s, ML, cy, cw, ch, self.T["surface"])
        hline(self.s, ML, cy, cw, la, Inches(0.05))
        lt_sz = self._fit(left_title, text_w, 22, "h", True)
        txt(self.s, ML + pad, cy + Inches(0.3), text_w, Inches(0.5),
            left_title, FONT_H, lt_sz, self.small_accent, bold=True)
        lac = (la[0], la[1], la[2]) if hasattr(la, '__getitem__') else self.ec
        bullets(self.s, ML + pad, cy + Inches(1.0), text_w, Inches(3.2),
                left_items, size=bullet_sz, color=self.T["text2"], bc=lac)
        # Right
        rx = ML + cw + Inches(0.4)
        rect(self.s, rx, cy, cw, ch, self.T["surface"])
        hline(self.s, rx, cy, cw, ra, Inches(0.05))
        rt_sz = self._fit(right_title, text_w, 22, "h", True)
        txt(self.s, rx + pad, cy + Inches(0.3), text_w, Inches(0.5),
            right_title, FONT_H, rt_sz, self.small_accent, bold=True)
        rac = (ra[0], ra[1], ra[2])
        bullets(self.s, rx + pad, cy + Inches(1.0), text_w, Inches(3.2),
                right_items, size=bullet_sz, color=self.T["text2"], bc=rac)
        return self

    def process_steps(self, steps, descriptions):
        """Horizontal chevron process flow (4 steps)."""
        sw, sh_c, gap = Inches(2.8), Inches(1.2), Inches(0.15)
        sx, sy = ML + Inches(0.2), Inches(2.4)
        desc_w = sw - Inches(0.3)
        for i in range(len(steps)):
            x = sx + (sw + gap) * i
            c = FUCHSIA if i == len(steps) - 1 else BLUE
            chevron(self.s, x, sy, sw, sh_c, c, steps[i], WHITE)
            sz = self._fit(descriptions[i], desc_w, 16, "b", False)
            txt(self.s, x + Inches(0.3), sy + sh_c + Inches(0.3),
                desc_w, Inches(1.2),
                descriptions[i], FONT_B, sz, self.T["text2"], line_spacing=1.3)
        return self

    def timeline(self, milestones, descriptions, today_index=0):
        """Horizontal timeline with dots and labels."""
        ly = Inches(3.5)
        hline(self.s, ML + Inches(0.5), ly, Inches(11), self.T["subtle"], Inches(0.03))
        dr = Inches(0.15)
        sp = Inches(2.9)
        sx = ML + Inches(1.0)
        for i in range(len(milestones)):
            x = sx + sp * i
            dot = self.s.shapes.add_shape(MSO_SHAPE.OVAL, x - dr, ly - dr, dr * 2, dr * 2)
            dot.fill.solid()
            dot.fill.fore_color.rgb = FUCHSIA if i == today_index else BLUE
            dot.line.fill.background()
            ms_w = Inches(1.8)
            sz = self._fit(milestones[i], ms_w, 16, "h", True)
            txt(self.s, x - Inches(0.8), ly - Inches(1.0), ms_w, Inches(0.5),
                milestones[i], FONT_H, sz, self.T["emphasis"], bold=True,
                align=PP_ALIGN.CENTER)
            sz = self._fit(descriptions[i], ms_w, 15, "b", False)
            txt(self.s, x - Inches(0.8), ly + Inches(0.4), ms_w, Inches(1.2),
                descriptions[i], FONT_B, sz, self.T["text2"],
                align=PP_ALIGN.CENTER, line_spacing=1.3)
        if today_index == 0:
            txt(self.s, sx - Inches(0.5), ly - Inches(1.6), Inches(1.2), Inches(0.35),
                "TODAY", FONT_H, EYEBROW_SIZE, self.small_accent, bold=True,
                align=PP_ALIGN.CENTER)
        return self

    def metric_cards(self, metrics, top=1.5):
        """Row of KPI cards. metrics: list of (value, label, desc, accent_color)."""
        n = len(metrics)
        cw = Inches(2.8)
        gap = Inches(0.24)
        pad = Inches(0.35)
        sy = self._in(top)
        ch = Inches(3.5)
        text_w = cw - pad * 2
        for i, (val, label, desc, accent) in enumerate(metrics):
            cx = ML + (cw + gap) * i
            rect(self.s, cx, sy, cw, ch, self.T["surface"])
            hline(self.s, cx, sy, cw, accent, Inches(0.04))
            val_sz = self._fit(val, text_w, 40, "h", True)
            txt(self.s, cx + pad, sy + Inches(0.4), text_w, Inches(1.2),
                val, FONT_H, val_sz, accent, bold=True, align=PP_ALIGN.CENTER)
            lbl_sz = self._fit(label, text_w, 16, "h", True)
            txt(self.s, cx + pad, sy + Inches(1.6), text_w, Inches(0.4),
                label, FONT_H, lbl_sz, self.T["text_hi"], bold=True,
                align=PP_ALIGN.CENTER)
            desc_sz = self._fit(desc, text_w, 14, "b", False)
            txt(self.s, cx + pad, sy + Inches(2.2), text_w, Inches(0.8),
                desc, FONT_B, desc_sz, self.T["muted"],
                align=PP_ALIGN.CENTER, line_spacing=1.3)
        return self

    # ── finalize ──────────────────────────────────────────────────────────────

    def done(self):
        """Add footer bar with logo. Call this last."""
        footer_bar(self.s, self.T, self.logo)
        return self.s
