#!/usr/bin/env python3
"""
PPT Master - Beautify Identity Extractor

Extract a source deck's visual identity as JSON for the beautify-pptx profile:
the declared `theme` (palette + major/minor fonts + master placeholder sizes,
full bodyStyle ramp in `sizes.body_levels`) plus `observed` usage (run-level
fonts incl. CJK `ea`, explicit point sizes, and frequent explicit fill colors)
sampled across slides, plus `layout_sizes_pt` (in-use layout body-placeholder
level-1 sizes — a reference hint, not an auto-seed) — so the workflow can
recommend theme vs actual-usage identity, incl. a source-derived body size
(seed chain: observed → theme.sizes.body → canvas baseline) and let the user
confirm. Pure read: reuses the pptx_to_svg resolver, writes
no PPTX.

Usage:
    python3 scripts/beautify_identity.py <source.pptx> [-o identity.json]

Examples:
    python3 scripts/beautify_identity.py projects/x/sources/deck.pptx
    python3 scripts/beautify_identity.py deck.pptx -o projects/x/analysis/deck.identity.json

Dependencies:
    None beyond the standard library (reuses scripts/pptx_to_svg/).

See workflows/profiles/beautify-pptx.md for how the emitted identity is consumed.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Optional

_SCRIPTS_DIR = Path(__file__).resolve().parent
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from console_encoding import configure_utf8_stdio  # noqa: E402
from pptx_to_svg.color_resolver import ColorPalette  # noqa: E402
from pptx_to_svg.emu_units import NS  # noqa: E402
from pptx_to_svg.ooxml_loader import OoxmlPackage  # noqa: E402

configure_utf8_stdio()


def _font_pair(theme_root, font_tag: str) -> dict[str, object]:
    """Read one theme font family, including explicit CJK script mappings."""
    out: dict[str, object] = {}
    font = theme_root.find(f".//a:fontScheme/a:{font_tag}", NS)
    if font is None:
        return out
    for slot, key in (("a:latin", "latin"), ("a:ea", "ea"), ("a:cs", "cs")):
        elem = font.find(slot, NS)
        if elem is not None:
            face = (elem.attrib.get("typeface") or "").strip()
            if face:
                out[key] = face
    scripts: dict[str, str] = {}
    for elem in font.findall("a:font", NS):
        script = (elem.attrib.get("script") or "").strip()
        face = (elem.attrib.get("typeface") or "").strip()
        if script in {"Hans", "Hant", "Jpan", "Hang"} and face:
            scripts[script] = face
    if scripts:
        out["scripts"] = scripts
    return out


def _rank(counter: dict, limit: int) -> list[dict]:
    """Frequency-ranked [{value, count}, ...], most common first."""
    ranked = sorted(counter.items(), key=lambda kv: (-kv[1], kv[0]))
    return [{"value": v, "count": n} for v, n in ranked[:limit]]


def _master_text_sizes(master_root) -> dict:
    """Declared title/body point sizes from the master's <p:txStyles>.

    `title` is <p:titleStyle> level-1; `body` is <p:bodyStyle> level-1 and
    `body_levels` is every declared body outline level in order (lvl1..lvl9).
    `sz` is in hundredths of a point. This is the *declared* size — the
    placeholder default a run inherits when it sets no explicit `sz` — the size
    counterpart of `theme.fonts`. Note level-1 is the coarsest/largest body
    level and commonly over-reads real density; `body_levels` exposes the full
    ramp. Returns {} when no txStyles are present.
    """
    out: dict[str, object] = {}
    title = master_root.find(".//p:txStyles/p:titleStyle//a:defRPr", NS)
    if title is not None:
        sz = (title.attrib.get("sz") or "").strip()
        if sz.isdigit():
            out["title"] = int(sz) / 100
    body = master_root.find(".//p:txStyles/p:bodyStyle", NS)
    levels: list[float] = []
    if body is not None:
        for lvl in body:  # a:lvl1pPr .. a:lvl9pPr, in document order
            defrpr = lvl.find("a:defRPr", NS)
            if defrpr is None:
                continue
            sz = (defrpr.attrib.get("sz") or "").strip()
            if sz.isdigit():
                levels.append(int(sz) / 100)
    if levels:
        out["body"] = levels[0]
        out["body_levels"] = levels
    return out


# Placeholder types that carry chrome, not body text — excluded from the layout
# body-size sample so a 9pt footer / slide-number doesn't masquerade as body.
_NON_BODY_PH = {"title", "ctrTitle", "ftr", "sldNum", "dt", "hdr"}


def _layout_text_sizes(pkg: "OoxmlPackage") -> list[dict]:
    """Frequency-ranked **body** placeholder `defRPr` sizes across the layouts in use.

    For a theme/template-driven deck whose runs set no explicit `sz` (everything
    inherits its placeholder), this is where a real body size can show: each
    layout's body placeholder may declare its own size where the master only gives
    the coarse level-1 default. Only layouts actually referenced by a slide count,
    only body-ish placeholders (title / footer / slide-number / date / header
    skipped), and only the placeholder's **level-1** `defRPr` — the primary size —
    so deeper outline levels (lvl2..lvl9) don't masquerade as body. Sizes in
    points. Often sparse: placeholders that don't override level-1 inherit the
    master default and contribute nothing here.
    """
    sizes: dict[float, int] = {}
    seen: set[str] = set()
    for slide in pkg.iter_slides():
        layout = slide.layout
        if layout is None or layout.path in seen:
            continue
        seen.add(layout.path)
        for sp in layout.xml.iterfind(".//p:sp", NS):
            ph = sp.find(".//p:nvSpPr/p:nvPr/p:ph", NS)
            ph_type = ph.attrib.get("type") if ph is not None else None
            if ph_type in _NON_BODY_PH:
                continue
            lvl1 = sp.find(".//a:lstStyle/a:lvl1pPr/a:defRPr", NS)
            if lvl1 is None:
                continue
            sz = (lvl1.attrib.get("sz") or "").strip()
            if sz.isdigit():
                pt = int(sz) / 100
                sizes[pt] = sizes.get(pt, 0) + 1
    return _rank(sizes, 10)


def _sample_observed(pkg: "OoxmlPackage") -> dict:
    """Aggregate run-level fonts, explicit point sizes, and explicit fill colors.

    Theme extraction reports the *declared* identity; a hand-edited deck often
    overrides it per shape / run. This is a frequency sample of run-level usage
    (not a full style resolution — it misses schemeClr + master/layout
    inheritance, and counts chart/gradient fills), enough for the workflow to
    recommend theme vs observed. `sizes_pt` only counts runs that set an explicit
    `sz`; runs inheriting the placeholder size are not seen here (use
    `theme.sizes` for those), so a small sample is a hint, not the full picture.
    """
    latin: dict[str, int] = {}
    ea: dict[str, int] = {}
    sizes: dict[float, int] = {}
    colors: dict[str, int] = {}
    for slide in pkg.iter_slides():
        root = slide.part.xml
        for tag, bucket in (("a:latin", latin), ("a:ea", ea)):
            for elem in root.iterfind(f".//{tag}", NS):
                face = (elem.attrib.get("typeface") or "").strip()
                if face and not face.startswith("+"):  # skip +mj-*/+mn-* theme refs
                    bucket[face] = bucket.get(face, 0) + 1
        for elem in root.iterfind(".//a:rPr", NS):
            sz = (elem.attrib.get("sz") or "").strip()
            if sz.isdigit():
                pt = int(sz) / 100
                sizes[pt] = sizes.get(pt, 0) + 1
        for elem in root.iterfind(".//a:srgbClr", NS):
            val = (elem.attrib.get("val") or "").strip().upper()
            if val:
                colors[f"#{val}"] = colors.get(f"#{val}", 0) + 1
    return {
        "fonts": {"latin": _rank(latin, 5), "ea": _rank(ea, 5)},
        "sizes_pt": _rank(sizes, 8),
        "colors": _rank(colors, 8),
    }


def extract_identity(pptx_path: Path) -> dict:
    """Resolve the deck's theme + observed-usage identity, plus canvas."""
    with OoxmlPackage(pptx_path) as pkg:
        first = pkg.get_slide(1)
        master = first.master if first else None
        theme = pkg.resolve_theme(master)
        palette_resolver = ColorPalette(master, theme, strict=False)

        # Presentation-level scheme names; ColorPalette applies clrMap + aliases.
        scheme = {
            "background": palette_resolver.resolve_scheme("bg1"),
            "background_alt": palette_resolver.resolve_scheme("bg2"),
            "text": palette_resolver.resolve_scheme("tx1"),
            "text_alt": palette_resolver.resolve_scheme("tx2"),
            "hyperlink": palette_resolver.resolve_scheme("hlink"),
        }
        accents = {
            f"accent{i}": palette_resolver.resolve_scheme(f"accent{i}")
            for i in range(1, 7)
        }
        palette = {
            k: (f"#{v}" if v else None)
            for k, v in {**scheme, **accents}.items()
        }
        # accent1 is the conventional primary.
        palette["primary"] = palette.get("accent1")

        fonts = {}
        if theme is not None:
            fonts = {
                "title": _font_pair(theme.xml, "majorFont"),
                "body": _font_pair(theme.xml, "minorFont"),
            }
        sizes = _master_text_sizes(master.xml) if master is not None else {}

        w, h = pkg.slide_size_px
        canvas = {
            "width_px": round(w),
            "height_px": round(h),
            "aspect": round(w / h, 4) if h else None,
        }

        return {
            "source": str(pptx_path),
            "slide_count": pkg.slide_count,
            "canvas": canvas,
            "theme": {"palette": palette, "fonts": fonts, "sizes": sizes},
            "observed": _sample_observed(pkg),
            "layout_sizes_pt": _layout_text_sizes(pkg),
        }


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Extract a source deck's theme palette + fonts + sizes + canvas as JSON.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument("source", help="Source .pptx file")
    parser.add_argument(
        "-o", "--output",
        help="Write JSON here (default: stdout)",
    )
    return parser


def main(argv: Optional[list[str]] = None) -> int:
    args = build_parser().parse_args(argv)
    src = Path(args.source)
    if not src.is_file():
        print(f"[ERROR] source not found: {src}", file=sys.stderr)
        return 1

    try:
        identity = extract_identity(src)
    except (RuntimeError, KeyError, ValueError) as exc:
        print(f"[ERROR] failed to extract identity: {exc}", file=sys.stderr)
        return 1

    payload = json.dumps(identity, ensure_ascii=False, indent=2)
    if args.output:
        out = Path(args.output)
        out.parent.mkdir(parents=True, exist_ok=True)
        out.write_text(payload + "\n", encoding="utf-8")
        print(f"[OK] identity written to: {out}", file=sys.stderr)
    else:
        print(payload)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
