#!/usr/bin/env python3
"""
build_deck.py — generate a brand-correct Qvantum PowerPoint from a JSON deck spec.

The deck is produced from the official Qvantum .potx template, so every slide
inherits the real theme (navy #002656, the full palette, the Q logo baked into
each layout, placeholder positions). We only patch two things at build time:

  1. [Content_Types].xml  template -> presentation   (so python-pptx can open it)
  2. the theme/master/layout fonts  Aptos -> Host Grotesk  (the brand typeface)

Usage:
    python3 build_deck.py path/to/deck.json

Spec schema (see SKILL.md / reference/deck-spec.md for the full reference):

{
  "template": "<.potx or .pptx>",        # optional, defaults to bundled template
  "output":   "<output .pptx>",          # required
  "images_dir": "<base dir>",            # optional, base for relative image paths
  "footer":   "<default footer text>",   # optional
  "slides": [
    {
      "layout": "Main_slide_off-white",  # one of the 18 template layout names
      "footer": "<override>",            # optional
      "placeholders": { "<idx>": <field> },
      "images":       { "<idx>": "<path>" }
    }
  ]
}

<field> is either a plain string (auto-styled by placeholder type) or:
  { "paragraphs": [ {
        "text": "...", "weight": "regular|semibold", "size": <pt>,
        "color": "232222", "align": "left|center|right",
        "upper": false, "italic": false, "spacing": <pt letter-spacing>,
        "space_before": <pt>, "space_after": <pt>, "bullet": false
  } ] }
"""
import sys, os, json, zipfile, tempfile, copy
from pptx import Presentation
from pptx.util import Pt, Emu
from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.enum.shapes import PP_PLACEHOLDER
from lxml import etree

try:
    from PIL import Image
    Image.MAX_IMAGE_PIXELS = None
except ImportError:
    Image = None

MAX_IMAGE_PX = 2200  # cap longest side; keeps file size sane

# ---- Brand constants -------------------------------------------------------
FONT_REGULAR  = "Host Grotesk"
FONT_SEMIBOLD = "Host Grotesk SemiBold"
OFF_BLACK = "232222"
OFF_WHITE = "F4F4F4"
BROWN     = "91877A"
BEIGE     = "E5E6DF"
NAVY      = "002656"
WHITE     = "FFFFFF"

A = "http://schemas.openxmlformats.org/drawingml/2006/main"

# Role defaults when a field is given as a bare string, keyed by placeholder type.
ROLE_DEFAULTS = {
    "TITLE":  {"weight": "semibold", "size": 28, "color": OFF_BLACK},
    "BODY":   {"weight": "regular",  "size": 15, "color": OFF_BLACK},
    "OBJECT": {"weight": "regular",  "size": 15, "color": OFF_BLACK},
    "_default": {"weight": "regular", "size": 15, "color": OFF_BLACK},
}


# ---- Template patching -----------------------------------------------------
def patch_template(src, font_regular=FONT_REGULAR, font_semibold=FONT_SEMIBOLD):
    """Return a path to a temp .pptx: content-type fixed + theme font -> Host Grotesk.

    The *theme* font becomes the regular Host Grotesk family (so any text that
    inherits the theme renders correctly). Explicit per-run weights are applied
    later by the builder.
    """
    fd, out = tempfile.mkstemp(suffix=".pptx", prefix="qvantum_")
    os.close(fd)
    zin = zipfile.ZipFile(src)
    with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zout:
        for it in zin.infolist():
            data = zin.read(it.filename)
            if it.filename == "[Content_Types].xml":
                data = data.replace(b"presentationml.template.main+xml",
                                    b"presentationml.presentation.main+xml")
            elif (it.filename == "ppt/theme/theme1.xml"
                  or it.filename.startswith("ppt/slideMasters/")
                  or it.filename.startswith("ppt/slideLayouts/")):
                t = data.decode("utf-8")
                t = t.replace("Aptos Display", font_regular).replace("Aptos", font_regular)
                data = t.encode("utf-8")
            zout.writestr(it, data)
    zin.close()
    return out


# ---- Text styling ----------------------------------------------------------
def _set_spacing(run, pts):
    """Set character letter-spacing (a:rPr@spc, units = 1/100 pt)."""
    rPr = run._r.get_or_add_rPr()
    rPr.set("spc", str(int(round(pts * 100))))


def _no_bullet(paragraph):
    """Force no bullet + flush-left (no inherited list indent) on a paragraph."""
    pPr = paragraph._pPr
    if pPr is None:
        pPr = paragraph.get_or_add_pPr()
    pPr.set("marL", "0")
    pPr.set("indent", "0")
    for tag in ("buChar", "buAutoNum", "buNone"):
        for el in pPr.findall(f"{{{A}}}{tag}"):
            pPr.remove(el)
    etree.SubElement(pPr, f"{{{A}}}buNone")


def _apply_font(run, weight="regular", size=None, color=None, italic=False, spacing=None):
    f = run.font
    f.name = FONT_SEMIBOLD if weight == "semibold" else FONT_REGULAR
    if size is not None:
        f.size = Pt(size)
    if italic:
        f.italic = True
    if color is not None:
        f.color.rgb = RGBColor.from_string(color)
    if spacing is not None:
        _set_spacing(run, spacing)


ALIGN = {"left": PP_ALIGN.LEFT, "center": PP_ALIGN.CENTER, "right": PP_ALIGN.RIGHT}


def set_text_frame(tf, field, default_role):
    """Populate a text frame from a <field> (string or {paragraphs:[...]})."""
    if isinstance(field, str):
        field = {"paragraphs": [{"text": field}]}
    paras = field.get("paragraphs", [])
    role = ROLE_DEFAULTS.get(default_role, ROLE_DEFAULTS["_default"])
    tf.word_wrap = True

    first = True
    for spec in paras:
        p = tf.paragraphs[0] if first else tf.add_paragraph()
        first = False
        # clear any inherited runs on the reused first paragraph
        for r in list(p.runs):
            r._r.getparent().remove(r._r)
        text = spec.get("text", "")
        if spec.get("upper"):
            text = text.upper()
        run = p.add_run()
        run.text = text
        _apply_font(
            run,
            weight=spec.get("weight", role["weight"]),
            size=spec.get("size", role["size"]),
            color=spec.get("color", role["color"]),
            italic=spec.get("italic", False),
            spacing=spec.get("spacing"),
        )
        if "align" in spec:
            p.alignment = ALIGN.get(spec["align"], PP_ALIGN.LEFT)
        if spec.get("space_before") is not None:
            p.space_before = Pt(spec["space_before"])
        if spec.get("space_after") is not None:
            p.space_after = Pt(spec["space_after"])
        if not spec.get("bullet", False):
            _no_bullet(p)


# ---- Slide building --------------------------------------------------------
def discover_pics_base(start_dirs, name="pics from Sharepoint", max_up=6):
    """Find a folder named `name` by walking up from each start dir.

    Makes a deck spec portable: as long as a `pics from Sharepoint/` folder
    sits somewhere at or above the spec (or the cwd), relative image paths like
    'Stock Images/foo.jpg' resolve on any machine — no absolute paths needed.
    """
    for start in start_dirs:
        if not start:
            continue
        d = os.path.abspath(start)
        for _ in range(max_up + 1):
            cand = os.path.join(d, name)
            if os.path.isdir(cand):
                return cand
            parent = os.path.dirname(d)
            if parent == d:
                break
            d = parent
    return None


def resolve_image(path, bases):
    """Resolve an image path against a list of candidate base directories."""
    if os.path.isabs(path) and os.path.exists(path):
        return path
    for b in [None] + list(bases):
        cand = os.path.join(b, path) if b else path
        if os.path.exists(cand):
            return cand
    raise FileNotFoundError(f"image not found: {path!r}; tried bases={bases}")


def prepare_image(path, tmp_paths):
    """Downscale oversized photos to keep the .pptx small. Returns a path."""
    if Image is None:
        return path
    try:
        im = Image.open(path)
    except Exception:
        return path
    if max(im.size) <= MAX_IMAGE_PX:
        return path
    im = im.convert("RGB")
    scale = MAX_IMAGE_PX / max(im.size)
    im = im.resize((int(im.width * scale), int(im.height * scale)), Image.LANCZOS)
    fd, out = tempfile.mkstemp(suffix=".jpg", prefix="qvimg_")
    os.close(fd)
    im.save(out, "JPEG", quality=85)
    tmp_paths.append(out)
    return out


def clear_slides(prs):
    """Remove the template's pre-existing example slides; keep only what we add."""
    sldIdLst = prs.slides._sldIdLst
    for sldId in list(sldIdLst):
        rId = sldId.get("{http://schemas.openxmlformats.org/officeDocument/2006/relationships}id")
        try:
            prs.part.drop_rel(rId)
        except Exception:
            pass
        sldIdLst.remove(sldId)


def build(spec_path):
    with open(spec_path, encoding="utf-8") as f:  # utf-8 explicit: Windows default is cp1252
        spec = json.load(f)

    here = os.path.dirname(os.path.abspath(__file__))
    default_template = os.path.join(here, "..", "assets", "Qvantum_PPT_template_251118.potx")
    template = spec.get("template", default_template)
    spec_dir = os.path.dirname(os.path.abspath(spec_path))
    images_dir = spec.get("images_dir")
    if images_dir and not os.path.isabs(images_dir):
        for base in (spec_dir, os.getcwd()):
            cand = os.path.join(base, images_dir)
            if os.path.isdir(cand):
                images_dir = cand
                break
    # auto-discover the SharePoint pics folder so specs stay portable
    pics_base = discover_pics_base([spec_dir, os.getcwd()])
    image_bases = [b for b in (images_dir, pics_base, spec_dir, os.getcwd()) if b]
    if pics_base:
        print(f"images: using pics base -> {pics_base}")
    default_footer = spec.get("footer")
    output = spec["output"]

    patched = patch_template(template)
    tmp_imgs = []
    try:
        prs = Presentation(patched)
        clear_slides(prs)
        layouts = {lay.name: lay for lay in prs.slide_layouts}

        for si, slide_spec in enumerate(spec["slides"]):
            lname = slide_spec["layout"]
            if lname not in layouts:
                raise KeyError(f"slide {si}: unknown layout {lname!r}. "
                               f"Available: {sorted(layouts)}")
            slide = prs.slides.add_slide(layouts[lname])
            ph_by_idx = {ph.placeholder_format.idx: ph for ph in slide.placeholders}

            # images first (insert_picture replaces the placeholder element)
            for idx, img in slide_spec.get("images", {}).items():
                idx = int(idx)
                if idx not in ph_by_idx:
                    raise KeyError(f"slide {si}: no placeholder idx {idx} for image")
                src = prepare_image(resolve_image(img, image_bases), tmp_imgs)
                ph_by_idx[idx].insert_picture(src)

            # refresh map (picture insertion swaps element identity)
            ph_by_idx = {ph.placeholder_format.idx: ph for ph in slide.placeholders}

            # text placeholders
            for idx, field in slide_spec.get("placeholders", {}).items():
                idx = int(idx)
                if idx not in ph_by_idx:
                    raise KeyError(f"slide {si}: no placeholder idx {idx}")
                ph = ph_by_idx[idx]
                role = str(ph.placeholder_format.type).split()[0] if ph.placeholder_format.type else "_default"
                set_text_frame(ph.text_frame, field, role)

            # footer
            footer_text = slide_spec.get("footer", default_footer)
            if footer_text is not None:
                for ph in slide.placeholders:
                    if ph.placeholder_format.type == PP_PLACEHOLDER.FOOTER:
                        ph.text = footer_text
                        for p in ph.text_frame.paragraphs:
                            for r in p.runs:
                                _apply_font(r, weight="regular", size=9, color=BROWN)

        os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
        prs.save(output)
        print(f"OK  {len(spec['slides'])} slides -> {output}")
    finally:
        for t in [patched] + tmp_imgs:
            try:
                os.remove(t)
            except OSError:
                pass


if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(__doc__)
        sys.exit(1)
    build(sys.argv[1])
