#!/usr/bin/env python3
"""App Store Screenshot Team — canonical generator template (v1.3.0).

Generates pixel-perfect App Store marketing screenshots from a raw device
capture plus title/subtitle copy. Produces the default `headline-frame`
composition: caption band (centered title + subtitle + accent bar) on top of
a rounded-corner device screenshot, unified by a continuous gradient.

Output dimensions are LOCKED to the eight pairs App Store Connect accepts:

  iPhone:  1242 x 2688  /  2688 x 1242   (`iphone-6.5`)
           1284 x 2778  /  2778 x 1284   (`iphone-6.7`, default for new projects)
  iPad:    2048 x 2732  /  2732 x 2048   (`ipad-12.9`)
           2064 x 2752  /  2752 x 2064   (`ipad-13`,    default iPad)

Other dimensions (e.g. iPhone native 1320 x 2868 / 1290 x 2796) are not
exposed because App Store Connect rejects uploads at those sizes with
"One or more screenshots have invalid dimensions" /
"一张或多张截屏的尺寸存在错误".

Dimension-safety enforcement (v1.3.0 — belt & suspenders)
---------------------------------------------------------

Empirically, the blocking App Store Connect error can still appear even when
the device preset declares the correct pair — typically because:

  1. `rsvg-convert -w W -h H` is off by ±1 px for fractional viewBox math on
     some librsvg versions (bg + caption canvases drift 1 px).
  2. The source screenshot carries EXIF orientation metadata that silently
     swaps width/height during the first decode.
  3. An intermediate `-resize` or `-composite` leaves the canvas in an odd
     shape that only shows up in `identify` output.

v1.3.0 closes every path:

  * Every rsvg-convert output is *force-resized* to the exact target pair
    via `magick -resize WxH!` (the `!` disables aspect-preservation) — any
    1 px drift is stretched back, a ≤0.1 % distortion that is visually
    imperceptible on gradients and typography.
  * The input screenshot is read through `-auto-orient` so EXIF rotation is
    applied before the aspect-preserving resize, never after compositing.
  * The final composite pins the canvas with `-extent WxH` + `-gravity
    NorthWest` so cropping/extending happens in one deterministic direction.
  * `assert_accepted_dimensions()` runs on every output as the last line of
    defense — the build fails loud on any remaining mismatch, with a
    diff-to-closest-accepted hint in the error.

Single frame (CLI mode)
-----------------------

    python generate.py \\
        --input screenshots/en-US/iphone-6.7-01_home.png \\
        --device iphone-6.7 \\
        --lang en-US \\
        --title "Your Local Audio Library" \\
        --subtitle "Import, organize, and process on device — no account, no cloud."

Batch (config mode)
-------------------

    python generate.py --config screenshots.config.json

The batch config shape (defaults to the modern 6.7" iPhone slot and 13" iPad slot):

    {
      "screenshots_root": "screenshots",
      "scenes": ["01_home", "02_format", "03_trim"],
      "devices": { "iphone-6.7": {}, "ipad-13": {} },
      "copy": {
        "en-US": {
          "01_home":   { "title": "...", "subtitle": "..." },
          "02_format": { "title": "...", "subtitle": "..." }
        },
        "zh-Hans": { ... }
      },
      "theme": { "bg_from": "#05090C", "bg_to": "#0B1218" }
    }

Input screenshots are discovered at:

    {screenshots_root}/{lang}/{device}-{scene}.png

Output is written to (timestamped subfolder by default):

    {output_dir}/{run_id}/{lang}/{device}/{scene}.png

Requirements
------------

  * Python 3.9+
  * ImageMagick 7  (`magick` on PATH)
  * rsvg-convert   (librsvg, on PATH)

    macOS:  brew install imagemagick librsvg
    Linux:  apt-get install -y imagemagick librsvg2-bin fonts-noto-cjk

This template is versioned — pin your project to a semver tag and copy it
unchanged. Add config knobs to the canonical template upstream instead of
editing in place. See: https://www.teamsmarket.com/teams/app-store-screenshot-team
"""

from __future__ import annotations

import argparse
import json
import shutil
import subprocess
import sys
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple

__version__ = "1.3.0"


# ---------------------------------------------------------------------------
# Allow-list of dimension pairs App Store Connect accepts as screenshot
# uploads. The Submission Auditor checks every output PNG against this set
# and fails the run on any mismatch — modern iPhones (1320 x 2868 native,
# 1290 x 2796 native) ship into the 6.7" slot at exactly 1284 x 2778, so
# those native resolutions are intentionally absent.
# ---------------------------------------------------------------------------

ACCEPTED_DIMENSIONS: set = {
    # iPhone 6.5" slot — XS Max, 11 Pro Max
    (1242, 2688), (2688, 1242),
    # iPhone 6.7"/6.9" slot — 12 Pro Max through 17 Pro Max, all Plus models
    (1284, 2778), (2778, 1284),
    # iPad Pro 12.9" slot
    (2048, 2732), (2732, 2048),
    # iPad Pro 13" slot — M4 / M5
    (2064, 2752), (2752, 2064),
}


# ---------------------------------------------------------------------------
# Device presets — pixel geometry per App Store Connect-accepted dimension
# pair. `width` and `height` are LOCKED to the accepted dimensions; do not
# override them in config (the auditor will reject the run). All other
# fields tune the `headline-frame` composition and may be overridden.
# ---------------------------------------------------------------------------

DEVICE_PRESETS: Dict[str, Dict[str, int]] = {
    # iPhone 6.5" slot — XS Max, 11 Pro Max.
    "iphone-6.5": {
        "width": 1242,
        "height": 2688,
        "caption_h": 850,
        "caption_side_pad": 80,
        "inner_pad_x": 70,
        "inner_pad_top": 40,
        "inner_pad_bottom": 80,
        "radius": 70,
        "title_size_max": 100,
        "title_size_min": 68,
        "subtitle_size_max": 48,
        "subtitle_size_min": 36,
        "title_line_gap": 38,
        "caption_title_top": 230,
    },
    # iPhone 6.7"/6.9" slot — 12 Pro Max through 17 Pro Max, all Plus models.
    # Default device for new projects (modern iPhone Pro Max ships here).
    "iphone-6.7": {
        "width": 1284,
        "height": 2778,
        "caption_h": 880,
        "caption_side_pad": 85,
        "inner_pad_x": 75,
        "inner_pad_top": 40,
        "inner_pad_bottom": 80,
        "radius": 75,
        "title_size_max": 105,
        "title_size_min": 72,
        "subtitle_size_max": 50,
        "subtitle_size_min": 38,
        "title_line_gap": 40,
        "caption_title_top": 240,
    },
    # iPad Pro 12.9" slot — legacy.
    "ipad-12.9": {
        "width": 2048,
        "height": 2732,
        "caption_h": 780,
        "caption_side_pad": 130,
        "inner_pad_x": 130,
        "inner_pad_top": 40,
        "inner_pad_bottom": 80,
        "radius": 55,
        "title_size_max": 125,
        "title_size_min": 86,
        "subtitle_size_max": 58,
        "subtitle_size_min": 44,
        "title_line_gap": 40,
        "caption_title_top": 180,
    },
    # iPad Pro 13" slot — M4 / M5. Default iPad for new projects.
    "ipad-13": {
        "width": 2064,
        "height": 2752,
        "caption_h": 800,
        "caption_side_pad": 140,
        "inner_pad_x": 140,
        "inner_pad_top": 40,
        "inner_pad_bottom": 80,
        "radius": 60,
        "title_size_max": 130,
        "title_size_min": 90,
        "subtitle_size_max": 60,
        "subtitle_size_min": 46,
        "title_line_gap": 40,
        "caption_title_top": 190,
    },
}

# Defaults emitted when batch config omits an explicit `devices` map.
DEFAULT_DEVICES: Tuple[str, ...] = ("iphone-6.7", "ipad-13")

# ---------------------------------------------------------------------------
# Default theme — dark gradient with green-to-cyan accent bar.
# Every value can be overridden via CLI flags or the `theme` key in config.
# ---------------------------------------------------------------------------

DEFAULT_THEME: Dict[str, str] = {
    "bg_from": "#05090C",
    "bg_to": "#0B1218",
    "accent_from": "#4ADE80",
    "accent_to": "#38BDF8",
    "title_color": "#FFFFFF",
    "subtitle_color": "#A5B4C2",
    "glow_color": "#22C55E",
}

# ---------------------------------------------------------------------------
# Locale awareness — font stack + wrap behavior.
# ---------------------------------------------------------------------------

CJK_LOCALES = {"zh-Hans", "zh-Hant", "zh-CN", "zh-TW", "ja", "ko"}

FONT_STACK_CJK = (
    "'Hiragino Sans GB', 'PingFang SC', 'Noto Sans CJK SC', "
    "'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', sans-serif"
)
FONT_STACK_LATIN = (
    "'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', 'Segoe UI', sans-serif"
)

CJK_BREAK_CHARS = {"\uFF0C", "\u3002", "\uFF1B", "\uFF1F", "\uFF01", " "}


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def escape_xml(text: str) -> str:
    return (
        text.replace("&", "&amp;")
        .replace("<", "&lt;")
        .replace(">", "&gt;")
        .replace('"', "&quot;")
        .replace("'", "&apos;")
    )


def run_cmd(cmd: List[str]) -> str:
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        sys.stderr.write("$ " + " ".join(str(c) for c in cmd) + "\n")
        sys.stderr.write(result.stdout)
        sys.stderr.write(result.stderr)
        raise SystemExit(result.returncode)
    return result.stdout


def check_tools(required: List[str]) -> None:
    missing = [t for t in required if shutil.which(t) is None]
    if missing:
        sys.stderr.write(
            f"Required tool(s) not on PATH: {', '.join(missing)}\n"
            "Install ImageMagick 7 and librsvg:\n"
            "  macOS:  brew install imagemagick librsvg\n"
            "  Linux:  apt-get install -y imagemagick librsvg2-bin\n"
        )
        raise SystemExit(2)


def font_stack_for(lang: str) -> str:
    return FONT_STACK_CJK if lang in CJK_LOCALES else FONT_STACK_LATIN


def auto_run_id() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H-%M-%SZ")


# ---------------------------------------------------------------------------
# Text measurement + font-fit
# ---------------------------------------------------------------------------


def measure_text_width(text: str, lang: str, size: int) -> int:
    """Pixel width of `text` rendered at `size` via rsvg-convert + magick -trim.

    We route through SVG so CJK fonts (Hiragino / PingFang / Noto CJK) are
    resolved by fontconfig — ImageMagick's native text path can't load `.ttc`
    collections reliably on every system.
    """
    family = font_stack_for(lang)
    canvas_w = max(4000, len(text) * size * 2)
    canvas_h = size * 3
    svg = (
        '<?xml version="1.0" encoding="UTF-8"?>'
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{canvas_w}" height="{canvas_h}">'
        f'<text x="20" y="{size * 2}" font-family="{family}" '
        f'font-size="{size}" font-weight="700" fill="white">{escape_xml(text)}</text>'
        "</svg>"
    )
    with tempfile.TemporaryDirectory() as tmp:
        tmp_path = Path(tmp)
        svg_p = tmp_path / "m.svg"
        png_p = tmp_path / "m.png"
        svg_p.write_text(svg, encoding="utf-8")
        r1 = subprocess.run(
            ["rsvg-convert", "-o", str(png_p), str(svg_p)],
            capture_output=True, text=True,
        )
        if r1.returncode != 0 or not png_p.exists():
            return int(len(text) * size * 0.6)
        r2 = subprocess.run(
            ["magick", str(png_p), "-trim", "+repage", "-format", "%w", "info:"],
            capture_output=True, text=True,
        )
        if r2.returncode != 0:
            return int(len(text) * size * 0.6)
        try:
            return int(r2.stdout.strip().splitlines()[-1])
        except (ValueError, IndexError):
            return int(len(text) * size * 0.6)


def fit_font_size(
    text: str, lang: str, max_size: int, min_size: int, max_width: int
) -> int:
    """Largest size in [min_size, max_size] whose 1-line render fits max_width."""
    if measure_text_width(text, lang, max_size) <= max_width:
        return max_size
    lo, hi = min_size, max_size
    best = min_size
    while lo <= hi:
        mid = (lo + hi) // 2
        if mid % 2:
            mid += 1
        w = measure_text_width(text, lang, mid)
        if w <= max_width:
            best = mid
            lo = mid + 2
        else:
            hi = mid - 2
    return best


def wrap_text_to_lines(
    text: str, lang: str, size: int, max_width: int, max_lines: int = 2
) -> List[str]:
    """Wrap `text` into at most `max_lines` lines, each within `max_width`.

    CJK: character-level split, preferring punctuation breaks near the middle.
    Latin: word-level split, minimizing line-width difference.
    """
    if measure_text_width(text, lang, size) <= max_width:
        return [text]

    if lang in CJK_LOCALES:
        chars = list(text)
        n = len(chars)
        candidate_positions = [i for i, c in enumerate(chars) if c in CJK_BREAK_CHARS]
        positions = candidate_positions if candidate_positions else list(range(1, n))
        best: List[str] = [text]
        best_diff: Optional[int] = None
        for i in positions:
            left = "".join(chars[: i + 1]).rstrip()
            right = "".join(chars[i + 1 :]).lstrip()
            if not left or not right:
                continue
            lw = measure_text_width(left, lang, size)
            rw = measure_text_width(right, lang, size)
            if lw <= max_width and rw <= max_width:
                diff = abs(lw - rw)
                if best_diff is None or diff < best_diff:
                    best_diff = diff
                    best = [left, right]
        return best[:max_lines] if len(best) > max_lines else best

    # Latin word-boundary wrap
    words = text.split(" ")
    best_split: Optional[List[str]] = None
    best_diff: Optional[int] = None
    for i in range(1, len(words)):
        left = " ".join(words[:i])
        right = " ".join(words[i:])
        lw = measure_text_width(left, lang, size)
        rw = measure_text_width(right, lang, size)
        if lw <= max_width and rw <= max_width:
            diff = abs(lw - rw)
            if best_diff is None or diff < best_diff:
                best_diff = diff
                best_split = [left, right]
    return best_split if best_split is not None else [text]


def fit_or_wrap(
    text: str, lang: str, max_size: int, min_size: int, max_width: int
) -> Tuple[int, List[str]]:
    """Prefer largest 1-line fit; fall back to balanced 2-line wrap at max_size."""
    one_line_best = fit_font_size(text, lang, max_size, min_size, max_width)
    if measure_text_width(text, lang, one_line_best) <= max_width:
        if one_line_best >= int(max_size * 0.9):
            return one_line_best, [text]
    for candidate in range(max_size, min_size - 1, -2):
        lines = wrap_text_to_lines(text, lang, candidate, max_width, max_lines=2)
        if len(lines) <= 2 and all(
            measure_text_width(line, lang, candidate) <= max_width for line in lines
        ):
            if candidate >= one_line_best:
                return candidate, lines
            break
    return one_line_best, [text]


# ---------------------------------------------------------------------------
# SVG builders
# ---------------------------------------------------------------------------


def build_bg_svg(width: int, height: int, bg_from: str, bg_to: str) -> str:
    return f"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}">
  <defs>
    <linearGradient id="bg" x1="0%" y1="0%" x2="0%" y2="100%">
      <stop offset="0%" stop-color="{bg_from}"/>
      <stop offset="100%" stop-color="{bg_to}"/>
    </linearGradient>
  </defs>
  <rect x="0" y="0" width="{width}" height="{height}" fill="url(#bg)"/>
</svg>
"""


def build_caption_svg(
    *,
    width: int,
    height: int,
    title_lines: List[str],
    subtitle_lines: List[str],
    title_size: int,
    subtitle_size: int,
    caption_title_top: int,
    title_line_gap: int,
    lang: str,
    theme: Dict[str, str],
) -> str:
    family = font_stack_for(lang)
    title_lh = int(title_size * 1.1)
    sub_lh = int(subtitle_size * 1.3)
    title_y = caption_title_top + title_size
    subtitle_y = (
        title_y
        + (len(title_lines) - 1) * title_lh
        + title_line_gap
        + subtitle_size
    )

    def tspans(lines: List[str], lh: int) -> str:
        return "".join(
            (
                f'<tspan x="{width // 2}" dy="0">{escape_xml(line)}</tspan>'
                if idx == 0
                else f'<tspan x="{width // 2}" dy="{lh}">{escape_xml(line)}</tspan>'
            )
            for idx, line in enumerate(lines)
        )

    title_tspans = tspans(title_lines, title_lh)
    sub_tspans = tspans(subtitle_lines, sub_lh)

    accent_y = caption_title_top - 40

    return f"""<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
  <defs>
    <linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
      <stop offset="0%" stop-color="{theme['bg_from']}"/>
      <stop offset="100%" stop-color="{theme['bg_to']}"/>
    </linearGradient>
    <linearGradient id="accent" x1="0%" y1="50%" x2="100%" y2="50%">
      <stop offset="0%" stop-color="{theme['accent_from']}"/>
      <stop offset="100%" stop-color="{theme['accent_to']}"/>
    </linearGradient>
    <radialGradient id="glow" cx="50%" cy="20%" r="70%">
      <stop offset="0%" stop-color="{theme['glow_color']}" stop-opacity="0.18"/>
      <stop offset="60%" stop-color="{theme['accent_to']}" stop-opacity="0.06"/>
      <stop offset="100%" stop-color="#000000" stop-opacity="0"/>
    </radialGradient>
  </defs>

  <rect x="0" y="0" width="{width}" height="{height}" fill="url(#bg)"/>
  <rect x="0" y="0" width="{width}" height="{height}" fill="url(#glow)"/>

  <rect x="{width // 2 - 120}" y="{accent_y}" width="240" height="10" rx="5" fill="url(#accent)"/>

  <text x="{width // 2}" y="{title_y}" text-anchor="middle"
        font-family="{family}" font-size="{title_size}"
        font-weight="700" fill="{theme['title_color']}" letter-spacing="-1">{title_tspans}</text>

  <text x="{width // 2}" y="{subtitle_y}" text-anchor="middle"
        font-family="{family}" font-size="{subtitle_size}"
        font-weight="400" fill="{theme['subtitle_color']}">{sub_tspans}</text>
</svg>
"""


def render_svg_to_png(svg_path: Path, png_path: Path, width: int, height: int) -> None:
    """Render SVG to PNG at *exactly* width x height pixels.

    `rsvg-convert -w W -h H` can be off by 1 px on some librsvg versions
    when the SVG viewBox contains fractional math. We pipe the result
    through `magick -resize WxH!` (the trailing `!` disables aspect
    preservation) so any 1 px drift is stretched back to the target —
    imperceptible (<=0.1 %) on gradients and typography, and it's the
    only thing standing between an otherwise-correct composite and App
    Store Connect's "One or more screenshots have invalid dimensions"
    rejection.
    """
    run_cmd([
        "rsvg-convert",
        "-w", str(width),
        "-h", str(height),
        "-o", str(png_path),
        str(svg_path),
    ])
    run_cmd([
        "magick", str(png_path),
        "-resize", f"{width}x{height}!",
        str(png_path),
    ])


# ---------------------------------------------------------------------------
# Core: render a single frame
# ---------------------------------------------------------------------------


def render_frame(
    *,
    input_path: Path,
    output_path: Path,
    device: Dict[str, int],
    lang: str,
    title: str,
    subtitle: str,
    theme: Dict[str, str],
) -> None:
    if not input_path.exists():
        raise FileNotFoundError(f"Input screenshot not found: {input_path}")

    width = device["width"]
    height = device["height"]
    caption_h = device["caption_h"]
    inner_pad_x = device["inner_pad_x"]
    inner_pad_top = device["inner_pad_top"]
    inner_pad_bottom = device["inner_pad_bottom"]
    radius = device["radius"]
    caption_side_pad = device["caption_side_pad"]

    inner_w = width - 2 * inner_pad_x
    inner_h = height - caption_h - inner_pad_top - inner_pad_bottom
    max_title_width = width - 2 * caption_side_pad

    title_size, title_lines = fit_or_wrap(
        title, lang,
        device["title_size_max"], device["title_size_min"], max_title_width,
    )
    subtitle_size, subtitle_lines = fit_or_wrap(
        subtitle, lang,
        device["subtitle_size_max"], device["subtitle_size_min"], max_title_width,
    )

    output_path.parent.mkdir(parents=True, exist_ok=True)

    with tempfile.TemporaryDirectory() as tmp:
        tmp_path = Path(tmp)

        # 1. Caption band (top).
        caption_svg = tmp_path / "caption.svg"
        caption_png = tmp_path / "caption.png"
        caption_svg.write_text(
            build_caption_svg(
                width=width,
                height=caption_h,
                title_lines=title_lines,
                subtitle_lines=subtitle_lines,
                title_size=title_size,
                subtitle_size=subtitle_size,
                caption_title_top=device["caption_title_top"],
                title_line_gap=device["title_line_gap"],
                lang=lang,
                theme=theme,
            ),
            encoding="utf-8",
        )
        render_svg_to_png(caption_svg, caption_png, width, caption_h)

        # 2. Scaled + rounded-corner screenshot (lower zone).
        #    `-auto-orient` applies any EXIF rotation BEFORE the resize, so a
        #    portrait capture tagged with EXIF-6 (rotate 90 CW) can't silently
        #    swap width/height and poison downstream geometry.
        inner_png = tmp_path / "inner.png"
        run_cmd([
            "magick", str(input_path),
            "-auto-orient",
            "-resize", f"{inner_w}x{inner_h}",
            "(",
                "+clone",
                "-alpha", "extract",
                "-draw",
                f"fill black polygon 0,0 0,{radius} {radius},0 "
                f"fill white circle {radius},{radius} {radius},0",
                "(", "+clone", "-flip", ")", "-compose", "Multiply", "-composite",
                "(", "+clone", "-flop", ")", "-compose", "Multiply", "-composite",
            ")",
            "-alpha", "off", "-compose", "CopyOpacity", "-composite",
            str(inner_png),
        ])

        size_out = subprocess.run(
            ["magick", "identify", "-format", "%wx%h", str(inner_png)],
            capture_output=True, text=True, check=True,
        ).stdout.strip()
        inner_actual_w, inner_actual_h = (int(v) for v in size_out.split("x"))

        # 3. Background canvas (full device dimensions).
        bg_svg = tmp_path / "bg.svg"
        bg_png = tmp_path / "bg.png"
        bg_svg.write_text(
            build_bg_svg(width, height, theme["bg_from"], theme["bg_to"]),
            encoding="utf-8",
        )
        render_svg_to_png(bg_svg, bg_png, width, height)

        # 4. Composite: bg <- caption (top) <- screenshot (centered in lower zone).
        inner_x = (width - inner_actual_w) // 2
        inner_y = caption_h + inner_pad_top + ((inner_h - inner_actual_h) // 2)

        #    Final canvas lock: `-gravity NorthWest -extent WxH` pins the
        #    output to exactly the App Store Connect-accepted pair, padding
        #    with the background color if any upstream step produced a
        #    sub-pixel canvas (belt + suspenders — rsvg is already forced
        #    to exact dims, but cheap to guarantee once more here).
        run_cmd([
            "magick", str(bg_png),
            str(caption_png), "-geometry", "+0+0", "-compose", "Over", "-composite",
            str(inner_png), "-geometry", f"+{inner_x}+{inner_y}",
            "-compose", "Over", "-composite",
            "-gravity", "NorthWest",
            "-background", theme["bg_to"],
            "-extent", f"{width}x{height}",
            "-colorspace", "sRGB",
            "-strip",
            str(output_path),
        ])

    assert_accepted_dimensions(output_path)


# ---------------------------------------------------------------------------
# Config loader + batch mode
# ---------------------------------------------------------------------------


_LOCKED_DEVICE_FIELDS = ("width", "height")


def resolve_device(
    device_slug: str, overrides: Optional[Dict[str, Any]] = None
) -> Dict[str, int]:
    if device_slug not in DEVICE_PRESETS:
        available = ", ".join(DEVICE_PRESETS.keys())
        raise SystemExit(
            f"Unknown device: {device_slug!r}. Available: {available}"
        )
    spec = dict(DEVICE_PRESETS[device_slug])
    if overrides:
        for k, v in overrides.items():
            if v is None:
                continue
            if k in _LOCKED_DEVICE_FIELDS:
                # width/height are locked to the App Store Connect-accepted pair
                # for this slug — overriding them would produce uploads Apple
                # rejects with "One or more screenshots have invalid dimensions".
                raise SystemExit(
                    f"Refusing to override locked field {k!r} on device "
                    f"{device_slug!r}. App Store Connect only accepts the "
                    f"preset's dimensions; pick a different device slug instead."
                )
            spec[k] = int(v)
    return spec


def assert_accepted_dimensions(png_path: Path) -> None:
    """Fail fast if `png_path` is not at an App Store Connect-accepted size."""
    out = subprocess.run(
        ["magick", "identify", "-format", "%wx%h", str(png_path)],
        capture_output=True, text=True, check=True,
    ).stdout.strip()
    w, h = (int(v) for v in out.split("x"))
    if (w, h) in ACCEPTED_DIMENSIONS:
        return

    closest = min(
        ACCEPTED_DIMENSIONS,
        key=lambda pair: (w - pair[0]) ** 2 + (h - pair[1]) ** 2,
    )
    dw, dh = w - closest[0], h - closest[1]
    accepted = "\n  ".join(f"{a} x {b}" for a, b in sorted(ACCEPTED_DIMENSIONS))
    raise SystemExit(
        f"Output dimension check FAILED for {png_path}:\n"
        f"  rendered   : {w} x {h}\n"
        f"  closest OK : {closest[0]} x {closest[1]}  (delta {dw:+d} x {dh:+d})\n"
        f"\nApp Store Connect accepts ONLY these pairs:\n  {accepted}\n"
        "\nAt any other size the upload is rejected with:\n"
        '  "One or more screenshots have invalid dimensions /\n'
        '   一张或多张截屏的尺寸存在错误"\n'
        "\nThis assertion should be unreachable in v1.3.0+ because every\n"
        "rsvg-convert output is force-resized and the final composite is\n"
        "locked with `-extent`. If you see this, please file an issue with\n"
        "the template version (--version) and the input screenshot size."
    )


def merge_theme(overrides: Dict[str, Any]) -> Dict[str, str]:
    merged = dict(DEFAULT_THEME)
    for k, v in overrides.items():
        if v is not None:
            merged[k] = v
    return merged


def run_batch(config_path: Path, run_id: str, output_dir: Path) -> int:
    cfg = json.loads(config_path.read_text(encoding="utf-8"))
    theme = merge_theme(cfg.get("theme", {}))
    scenes: List[str] = cfg["scenes"]
    copy: Dict[str, Dict[str, Dict[str, str]]] = cfg["copy"]
    devices_cfg: Dict[str, Any] = cfg.get("devices") or {
        slug: {} for slug in DEFAULT_DEVICES
    }
    screenshots_root = Path(cfg.get("screenshots_root", "screenshots"))

    count = 0
    for lang, scene_map in copy.items():
        for device_slug, device_overrides in devices_cfg.items():
            device = resolve_device(device_slug, device_overrides or {})
            for scene in scenes:
                entry = scene_map.get(scene)
                if not entry:
                    continue
                input_path = screenshots_root / lang / f"{device_slug}-{scene}.png"
                output_path = (
                    output_dir / run_id / lang / device_slug / f"{scene}.png"
                )
                count += 1
                print(f"[{count}] {output_path}")
                render_frame(
                    input_path=input_path,
                    output_path=output_path,
                    device=device,
                    lang=lang,
                    title=entry["title"],
                    subtitle=entry["subtitle"],
                    theme=theme,
                )
    return count


def run_single(args: argparse.Namespace, run_id: str, output_dir: Path) -> Path:
    device = resolve_device(args.device)
    theme = merge_theme({
        "bg_from": args.bg_from,
        "bg_to": args.bg_to,
        "accent_from": args.accent_from,
        "accent_to": args.accent_to,
        "title_color": args.title_color,
        "subtitle_color": args.subtitle_color,
    })
    if args.output:
        output_path = Path(args.output)
    else:
        stem = Path(args.input).stem
        output_path = output_dir / run_id / args.lang / args.device / f"{stem}.png"
    render_frame(
        input_path=Path(args.input),
        output_path=output_path,
        device=device,
        lang=args.lang,
        title=args.title,
        subtitle=args.subtitle,
        theme=theme,
    )
    return output_path


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--version", action="version", version=f"%(prog)s {__version__}"
    )
    parser.add_argument(
        "--list-devices", action="store_true",
        help="List available device presets and exit",
    )

    # Single-frame mode
    parser.add_argument("--input", help="Path to the raw screenshot PNG (single-frame mode)")
    parser.add_argument("--output", help="Output PNG path (default: inside --output-dir/<run-id>/)")
    parser.add_argument(
        "--device", choices=list(DEVICE_PRESETS.keys()),
        help="Device preset (required in single-frame mode)",
    )
    parser.add_argument(
        "--lang", default="en-US",
        help="Locale tag — affects font stack and wrap behavior (default: en-US)",
    )
    parser.add_argument("--title", help="Main headline text")
    parser.add_argument("--subtitle", help="Subheadline text")

    # Theme overrides
    parser.add_argument("--bg-from", dest="bg_from", help="Background gradient start color (hex)")
    parser.add_argument("--bg-to", dest="bg_to", help="Background gradient end color (hex)")
    parser.add_argument("--accent-from", dest="accent_from", help="Accent bar gradient start color (hex)")
    parser.add_argument("--accent-to", dest="accent_to", help="Accent bar gradient end color (hex)")
    parser.add_argument("--title-color", dest="title_color", help="Title color (hex)")
    parser.add_argument("--subtitle-color", dest="subtitle_color", help="Subtitle color (hex)")

    # Batch mode
    parser.add_argument("--config", help="Path to screenshots.config.json (enables batch mode)")

    # Output scope
    parser.add_argument(
        "--output-dir", default="output",
        help="Root output directory (default: ./output)",
    )
    parser.add_argument(
        "--run-id",
        help="Override the auto-timestamp run subfolder (e.g., launch-2026-q2)",
    )

    return parser.parse_args()


def main() -> int:
    args = parse_args()

    if args.list_devices:
        print("App Store Connect-accepted device presets:")
        for slug, spec in DEVICE_PRESETS.items():
            w, h = spec["width"], spec["height"]
            mark = "  (default)" if slug in DEFAULT_DEVICES else ""
            print(f"  {slug:<14}  portrait {w}x{h}  landscape {h}x{w}{mark}")
        print(
            "\nOnly these dimension pairs are accepted by App Store Connect.\n"
            "Other sizes (e.g. iPhone native 1320x2868 or 1290x2796) are\n"
            "intentionally not exposed."
        )
        return 0

    check_tools(["magick", "rsvg-convert"])

    run_id = args.run_id or auto_run_id()
    output_dir = Path(args.output_dir)

    if args.config:
        n = run_batch(Path(args.config), run_id, output_dir)
        print(f"\nDone. Wrote {n} screenshots to {output_dir / run_id}/")
        return 0

    missing = [
        k for k in ("input", "device", "title", "subtitle")
        if getattr(args, k) is None
    ]
    if missing:
        sys.stderr.write(
            "Missing required flags for single-frame mode: "
            + ", ".join("--" + m for m in missing)
            + "\nOr pass --config <path> for batch mode. Run --help for details.\n"
        )
        return 2

    out = run_single(args, run_id, output_dir)
    print(f"Wrote {out}")
    return 0


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