"""Slide furniture for the catalogue playbook deck.

One place for the palette, the type scale and the six slide shapes the deck
uses, so every slide is laid out by the same rules instead of by hand.
"""
import textwrap

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

# ---- palette ---------------------------------------------------------------
# Navy is the product's own brand colour; everything else is built around it.
# Semantic tones are used ONLY for status, never for decoration.
NAVY      = RGBColor(0x1E, 0x3A, 0x5F)
NAVY_DEEP = RGBColor(0x12, 0x24, 0x3D)
INK       = RGBColor(0x14, 0x1A, 0x22)
BODY      = RGBColor(0x3A, 0x45, 0x54)
MUTED     = RGBColor(0x6E, 0x7A, 0x8A)
HAIRLINE  = RGBColor(0xD9, 0xDF, 0xE7)
PAPER     = RGBColor(0xFF, 0xFF, 0xFF)
WASH      = RGBColor(0xF3, 0xF6, 0xF9)
GO        = RGBColor(0x0F, 0x7B, 0x4F)
GO_WASH   = RGBColor(0xE6, 0xF3, 0xEC)
WARN      = RGBColor(0xA9, 0x66, 0x0B)
WARN_WASH = RGBColor(0xFB, 0xF1, 0xE2)
STOP      = RGBColor(0xA8, 0x1E, 0x14)
STOP_WASH = RGBColor(0xFA, 0xEA, 0xE8)
GOLD      = RGBColor(0xC2, 0x8E, 0x2A)

DISPLAY = "Calibri Light"   # bundled with Office on Windows and Mac
TEXT    = "Calibri"
MONO    = "Consolas"

W, H = Inches(13.333), Inches(7.5)
M     = Inches(0.72)          # page margin
CW    = W - 2 * M             # content width


# Average glyph advance as a fraction of point size. Calibri sits near .47;
# .50 buys a little slack so a measurement never comes out SHORT, which is the
# failure that overlaps two blocks.
_CHAR_W = {TEXT: 0.56, DISPLAY: 0.60, MONO: 0.62}
# Uppercase labels carry 1.6pt of tracking (see box(); spc is in 1/100 pt),
# which is a fixed addition per glyph rather than a fraction of the size.
_CAPS_TRACK_PT = 1.6
# PowerPoint's float line_spacing is a multiple of SINGLE line height, and a
# single line is roughly 1.2x the point size — not 1.0. Leaving this out made
# every measurement ~20% short, which is exactly one extra line on a five-line
# paragraph.
_LINE_BOX = 1.2
EMU_PT = 12700


def text_height(text, w, size, spacing=1.24, font=None, caps=False):
    """How tall this string will actually render in a box `w` wide.

    python-pptx cannot measure text and PowerPoint's autofit only resolves when
    the file is opened, so stacking blocks by eye silently overlaps them the
    moment a sentence gets longer. Wrapping the string at an estimated
    characters-per-line and counting the result is crude but it is a
    MEASUREMENT, and it errs long.
    """
    cw = _CHAR_W.get(font or TEXT, 0.56) * size + (_CAPS_TRACK_PT if caps else 0)
    per_line = max(6, int((w / EMU_PT) / cw))
    lines = 0
    for para in str(text).split("\n"):
        lines += max(1, len(textwrap.wrap(para, per_line)))
    return Emu(int(lines * size * spacing * _LINE_BOX * EMU_PT))


def deck():
    p = Presentation()
    p.slide_width, p.slide_height = W, H
    return p


def blank(prs, fill=PAPER):
    s = prs.slides.add_slide(prs.slide_layouts[6])
    bg = s.shapes.add_shape(MSO_SHAPE.RECTANGLE, 0, 0, W, H)
    bg.fill.solid(); bg.fill.fore_color.rgb = fill
    bg.line.fill.background(); bg.shadow.inherit = False
    return s


def box(slide, x, y, w, h, text="", size=14, color=BODY, font=TEXT,
        bold=False, align=PP_ALIGN.LEFT, spacing=1.18, caps=False, space_after=0):
    # Grow to fit rather than clip: a caller's height is a minimum, never a cap.
    h = max(h, text_height(text, w, size, spacing, font, caps))
    tb = slide.shapes.add_textbox(x, y, w, h)
    tf = tb.text_frame
    tf.word_wrap = True
    tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
    p = tf.paragraphs[0]
    p.alignment = align
    p.line_spacing = spacing
    p.space_after = Pt(space_after)
    r = p.add_run()
    r.text = text.upper() if caps else text
    f = r.font
    f.name, f.size, f.bold, f.color.rgb = font, Pt(size), bold, color
    if caps:
        f._rPr.set('spc', '160')       # letter-spacing for uppercase labels
    return tb


def para(slide, x, y, w, text, size=14, color=BODY, font=TEXT, bold=False,
         spacing=1.34, align=PP_ALIGN.LEFT, caps=False):
    """A measured paragraph. Returns the y coordinate directly beneath it."""
    h = text_height(text, w, size, spacing, font, caps)
    box(slide, x, y, w, h, text, size=size, color=color, font=font, bold=bold,
        align=align, spacing=spacing, caps=caps)
    return y + h


def rect(slide, x, y, w, h, fill=None, line=None, lw=1.0):
    sh = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, w, h)
    if fill is None:
        sh.fill.background()
    else:
        sh.fill.solid(); sh.fill.fore_color.rgb = fill
    if line is None:
        sh.line.fill.background()
    else:
        sh.line.color.rgb = line; sh.line.width = Pt(lw)
    sh.shadow.inherit = False
    return sh


def rule(slide, x, y, w, color=HAIRLINE, weight=1.0):
    return rect(slide, x, y, w, Pt(weight), fill=color)


def header(slide, kicker, title, n=None, total=None):
    """Standard page head. Returns the y where body content may start.

    The title is MEASURED — a two-line title used to run straight through the
    hairline and into the first paragraph.
    """
    box(slide, M, Inches(0.46), CW, Inches(0.22), kicker, size=10.5,
        color=NAVY, bold=True, caps=True)
    th = text_height(title, CW, 30, 1.06, DISPLAY)
    box(slide, M, Inches(0.74), CW, th, title, size=30, color=INK,
        font=DISPLAY, bold=True, spacing=1.06)
    ry = Inches(0.74) + th + Inches(0.16)
    rule(slide, M, ry, CW)
    if n is not None:
        box(slide, W - M - Inches(1.2), Inches(6.92), Inches(1.2),
            Inches(0.24), f"{n:02d} / {total:02d}", size=9.5, color=MUTED,
            font=MONO, align=PP_ALIGN.RIGHT)
    return ry + Inches(0.30)


def bullets(slide, x, y, w, items, size=14.5, gap=Inches(0.14), marker=True):
    """items: list of (bold_lead, rest) or plain strings. Returns the y below."""
    cy = y
    indent = Inches(0.22) if marker else 0
    for it in items:
        lead, rest = it if isinstance(it, tuple) else ("", it)
        joined = (lead + " " + rest) if lead else rest
        h = text_height(joined, w - indent, size, 1.26)
        if marker:
            rect(slide, x, cy + Pt(size * 0.42), Pt(3.2), Pt(3.2), fill=NAVY)
        tb = slide.shapes.add_textbox(x + indent, cy, w - indent, h)
        tf = tb.text_frame; tf.word_wrap = True
        tf.margin_left = tf.margin_right = tf.margin_top = tf.margin_bottom = 0
        p = tf.paragraphs[0]; p.line_spacing = 1.26
        if lead:
            r = p.add_run(); r.text = lead + " "
            r.font.name, r.font.size, r.font.bold, r.font.color.rgb = TEXT, Pt(size), True, INK
        r = p.add_run(); r.text = rest
        r.font.name, r.font.size, r.font.color.rgb = TEXT, Pt(size), BODY
        cy += h + gap
    return cy


def stat(slide, x, y, w, value, label, note="", tone=NAVY, h=Inches(1.30)):
    """A big number with a label and an optional note, sized to fit all three."""
    iw = w - Inches(0.44)
    vh = text_height(value, iw, 32, 1.0, DISPLAY)
    lh = text_height(label, iw, 11.5, 1.20)
    nh = text_height(note, iw, 10, 1.24) if note else 0
    h = max(h, Inches(0.20) + vh + Inches(0.06) + lh + (Inches(0.05) + nh if note else 0) + Inches(0.18))
    rect(slide, x, y, w, h, fill=WASH)
    rect(slide, x, y, w, Pt(3), fill=tone)
    cy = y + Inches(0.20)
    box(slide, x + Inches(0.22), cy, iw, vh, value, size=32, color=tone,
        font=DISPLAY, bold=True, spacing=1.0)
    cy += vh + Inches(0.06)
    box(slide, x + Inches(0.22), cy, iw, lh, label, size=11.5, color=INK, bold=True)
    if note:
        box(slide, x + Inches(0.22), cy + lh + Inches(0.05), iw, nh, note,
            size=10, color=MUTED, spacing=1.24)
    return y + h


def chip(slide, x, y, text, fill, fg, w=None, size=9.5):
    w = w or Emu(int(len(text) * (_CHAR_W[TEXT] * size + _CAPS_TRACK_PT) * EMU_PT)) + Inches(0.24)
    sh = rect(slide, x, y, w, Inches(0.235), fill=fill)
    tf = sh.text_frame
    tf.word_wrap = False          # a one-word chip must never break mid-word
    tf.margin_left = tf.margin_right = Inches(0.06)
    tf.margin_top = tf.margin_bottom = 0
    tf.vertical_anchor = MSO_ANCHOR.MIDDLE
    p = tf.paragraphs[0]; p.alignment = PP_ALIGN.CENTER
    r = p.add_run(); r.text = text.upper()
    r.font.name, r.font.size, r.font.bold, r.font.color.rgb = TEXT, Pt(size), True, fg
    r.font._rPr.set('spc', '100')
    return x + w


def callout(slide, x, y, w, text, head=None, tone=STOP, wash=STOP_WASH,
            size=12.5, head_size=13, pad=Inches(0.28)):
    """A tinted note with an accent bar, sized to its own text.

    Every one of these used to be a fixed height guessed at authoring time, and
    the longer ones spilled out of the tint. Returns the y beneath the block.
    """
    iw = w - pad * 2
    hh = text_height(head, iw, head_size, 1.20) if head else 0
    bh = text_height(text, iw, size, 1.32)
    gap = Inches(0.08) if head else 0
    total = Inches(0.34) + hh + gap + bh
    rect(slide, x, y, w, total, fill=wash)
    rect(slide, x, y, Pt(4), total, fill=tone)
    cy = y + Inches(0.17)
    if head:
        box(slide, x + pad, cy, iw, hh, head, size=head_size, color=tone,
            bold=True, spacing=1.20)
        cy += hh + gap
    box(slide, x + pad, cy, iw, bh, text, size=size, color=INK, spacing=1.32)
    return y + total


def table(slide, x, y, w, cols, rows, widths=None, size=11.5, head_size=9.5,
          row_h=Inches(0.34), zebra=True, colorer=None):
    """A hand-drawn table — python-pptx's own table styling is unusable.

    Each row is as tall as its tallest cell. A fixed row height silently
    overlapped every two-line cell with the row beneath it.
    """
    widths = widths or [w / len(cols)] * len(cols)
    cx = x
    for i, c in enumerate(cols):
        box(slide, cx, y, widths[i] - Inches(0.1), Inches(0.22), c,
            size=head_size, color=MUTED, bold=True, caps=True)
        cx += widths[i]
    cy = y + Inches(0.30)
    rule(slide, x, cy, w, color=HAIRLINE, weight=1.2)
    cy += Inches(0.08)
    pad = Inches(0.13)
    for ri, row in enumerate(rows):
        h = max(row_h, pad + max(
            text_height(cell, widths[ci] - Inches(0.1), size, 1.22)
            for ci, cell in enumerate(row)))
        if zebra and ri % 2 == 1:
            rect(slide, x - Inches(0.08), cy - Inches(0.04),
                 w + Inches(0.16), h, fill=WASH)
        cx = x
        for ci, cell in enumerate(row):
            col, bold, font = BODY, False, TEXT
            if colorer:
                col, bold, font = colorer(ri, ci, cell, col, bold, font)
            box(slide, cx, cy + Inches(0.02), widths[ci] - Inches(0.1),
                h - pad, str(cell), size=size, color=col, bold=bold,
                font=font, spacing=1.22)
            cx += widths[ci]
        cy += h
    return cy


def divider(prs, kicker, title, blurb, n, total):
    """Section break. Title and blurb are measured and bottom-aligned as a group."""
    sl = blank(prs, NAVY_DEEP)
    # The caller's n was evaluated BEFORE this slide existed, so it is one
    # short. Read it back from the deck now that the slide is in.
    n = len(prs.slides._sldIdLst)
    rect(sl, 0, 0, Pt(6), H, fill=GOLD)
    tw = CW * 0.78
    th = text_height(title, tw, 42, 1.03, DISPLAY)
    bh = text_height(blurb, CW * 0.62, 15, 1.32)
    top = (H - (Inches(0.40) + th + Inches(0.26) + bh)) / 2
    box(sl, M, top, tw, Inches(0.3), kicker, size=11, color=GOLD, bold=True, caps=True)
    box(sl, M, top + Inches(0.40), tw, th, title, size=42, color=PAPER,
        font=DISPLAY, bold=True, spacing=1.03)
    box(sl, M, top + Inches(0.40) + th + Inches(0.26), CW * 0.62, bh, blurb,
        size=15, color=RGBColor(0xB9, 0xC7, 0xD8), spacing=1.32)
    box(sl, W - M - Inches(1.2), Inches(6.92), Inches(1.2), Inches(0.24),
        f"{n:02d} / {total:02d}", size=9.5, color=RGBColor(0x6C, 0x83, 0xA0),
        font=MONO, align=PP_ALIGN.RIGHT)
    return sl
