#!/usr/bin/env python3
"""Frame raw app screenshots for App Store marketing artwork.

Requirements:
    pip install Pillow
"""

from __future__ import annotations

import argparse
import json
import re
import sys
import textwrap
import warnings
from pathlib import Path
from typing import Iterable, Sequence

from PIL import Image, ImageDraw, ImageFilter, ImageFont


DEFAULT_GRAD_TOP = "#0B5FFF"
DEFAULT_GRAD_BOTTOM = "#00C2A8"
HEX_RE = re.compile(r"#[0-9A-Fa-f]{6}\b")


FONT_CANDIDATES = (
    # CJK-capable macOS fonts first.
    "/System/Library/Fonts/ヒラギノ角ゴシック W6.ttc",
    "/System/Library/Fonts/ヒラギノ角ゴシック W5.ttc",
    "/System/Library/Fonts/Hiragino Sans GB.ttc",
    "/System/Library/Fonts/PingFang.ttc",
    "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
    "/Library/Fonts/Arial Unicode.ttf",
    # Strong Latin fallbacks common on macOS.
    "/System/Library/Fonts/Supplemental/Arial Bold.ttf",
    "/System/Library/Fonts/Supplemental/Arial.ttf",
    "/System/Library/Fonts/SFNS.ttf",
)


def parse_hex_color(value: str) -> tuple[int, int, int]:
    """Return an RGB tuple from #RRGGBB."""
    if not HEX_RE.fullmatch(value):
        raise argparse.ArgumentTypeError(f"Expected #RRGGBB hex color, got {value!r}")
    value = value.lstrip("#")
    return tuple(int(value[i : i + 2], 16) for i in (0, 2, 4))


def find_repo_root(start: Path) -> Path:
    for path in (start, *start.parents):
        if (path / ".git").exists():
            return path
    return start


def find_brand_colors(repo_root: Path) -> tuple[str | None, str | None]:
    """Look for SwiftUI Color.appPrimary/appSecondary hex values, then assets."""
    primary = secondary = None

    for swift_file in repo_root.rglob("*.swift"):
        try:
            text = swift_file.read_text(encoding="utf-8")
        except UnicodeDecodeError:
            text = swift_file.read_text(errors="ignore")

        primary_match = re.search(r"appPrimary\s*=\s*Color\(hex:\s*\"(#[0-9A-Fa-f]{6})\"", text)
        secondary_match = re.search(r"appSecondary\s*=\s*Color\(hex:\s*\"(#[0-9A-Fa-f]{6})\"", text)
        if primary_match:
            primary = primary_match.group(1).upper()
        if secondary_match:
            secondary = secondary_match.group(1).upper()
        if primary and secondary:
            return primary, secondary

    for contents_file in repo_root.glob("**/*.colorset/Contents.json"):
        name = contents_file.parent.name.lower()
        if "primary" not in name and "secondary" not in name:
            continue
        color = read_xcasset_hex(contents_file)
        if not color:
            continue
        if "primary" in name and not primary:
            primary = color
        elif "secondary" in name and not secondary:
            secondary = color

    return primary, secondary


def read_xcasset_hex(path: Path) -> str | None:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return None

    for color_entry in data.get("colors", []):
        components = color_entry.get("color", {}).get("components", {})
        red, green, blue = components.get("red"), components.get("green"), components.get("blue")
        if red is None or green is None or blue is None:
            continue
        try:
            values = [component_to_byte(channel) for channel in (red, green, blue)]
        except ValueError:
            continue
        return "#{:02X}{:02X}{:02X}".format(*values)
    return None


def component_to_byte(value: str | float | int) -> int:
    if isinstance(value, str) and value.startswith("0x"):
        return int(value, 16)
    number = float(value)
    if number <= 1:
        number *= 255
    return max(0, min(255, round(number)))


def print_brand_colors(repo_root: Path) -> tuple[str, str]:
    primary, secondary = find_brand_colors(repo_root)
    top = primary or DEFAULT_GRAD_TOP
    bottom = secondary or DEFAULT_GRAD_BOTTOM
    source = "found" if primary and secondary else "default"
    print(f"Brand colors ({source}): appPrimary={top} appSecondary={bottom}")
    return top, bottom


def make_vertical_gradient(size: tuple[int, int], top: str, bottom: str) -> Image.Image:
    width, height = size
    top_rgb = parse_hex_color(top)
    bottom_rgb = parse_hex_color(bottom)
    gradient = Image.new("RGB", size)
    draw = ImageDraw.Draw(gradient)

    for y in range(height):
        ratio = y / max(1, height - 1)
        color = tuple(round(top_rgb[i] + (bottom_rgb[i] - top_rgb[i]) * ratio) for i in range(3))
        draw.line([(0, y), (width, y)], fill=color)

    return gradient.convert("RGBA")


def load_font(size: int) -> ImageFont.ImageFont:
    for font_path in FONT_CANDIDATES:
        path = Path(font_path)
        if not path.exists():
            continue
        try:
            return ImageFont.truetype(str(path), size=size)
        except OSError:
            continue

    warnings.warn(
        "No CJK-capable macOS font found; falling back to PIL default. "
        "Japanese or other non-Latin text may not render correctly.",
        RuntimeWarning,
    )
    return ImageFont.load_default()


def text_bbox(draw: ImageDraw.ImageDraw, text: str, font: ImageFont.ImageFont) -> tuple[int, int]:
    if not text:
        return 0, 0
    left, top, right, bottom = draw.multiline_textbbox((0, 0), text, font=font, spacing=8, align="center")
    return right - left, bottom - top


def wrap_headline(
    text: str,
    font: ImageFont.ImageFont,
    max_width: int,
    draw: ImageDraw.ImageDraw,
) -> str:
    if contains_cjk(text):
        return wrap_cjk_text(text, font, max_width, draw)
    return wrap_word_text(text, font, max_width, draw)


def contains_cjk(text: str) -> bool:
    return any(
        "\u3040" <= char <= "\u30ff"
        or "\u3400" <= char <= "\u4dbf"
        or "\u4e00" <= char <= "\u9fff"
        or "\uf900" <= char <= "\ufaff"
        for char in text
    )


def wrap_word_text(
    text: str,
    font: ImageFont.ImageFont,
    max_width: int,
    draw: ImageDraw.ImageDraw,
) -> str:
    words = text.split()
    if not words:
        return text

    lines: list[str] = []
    current = words[0]
    for word in words[1:]:
        candidate = f"{current} {word}"
        if text_bbox(draw, candidate, font)[0] <= max_width:
            current = candidate
        else:
            lines.append(current)
            current = word
    lines.append(current)

    return "\n".join(lines)


def wrap_cjk_text(
    text: str,
    font: ImageFont.ImageFont,
    max_width: int,
    draw: ImageDraw.ImageDraw,
) -> str:
    lines: list[str] = []
    current = ""

    for char in text:
        if char.isspace():
            if current:
                lines.append(current)
                current = ""
            continue

        candidate = f"{current}{char}"
        if current and text_bbox(draw, candidate, font)[0] > max_width:
            lines.append(current)
            current = char
        else:
            current = candidate

    if current:
        lines.append(current)
    return "\n".join(lines)


def fit_headline(
    headline: str,
    canvas_size: tuple[int, int],
    max_width: int,
    max_height: int,
    draw: ImageDraw.ImageDraw,
) -> tuple[str, ImageFont.ImageFont, int, int]:
    _, height = canvas_size
    max_size = max(34, round(height * 0.052))
    min_size = 28

    for size in range(max_size, min_size - 1, -2):
        font = load_font(size)
        wrapped = wrap_headline(headline, font, max_width, draw)
        text_width, text_height = text_bbox(draw, wrapped, font)
        if text_width <= max_width and text_height <= max_height:
            return wrapped, font, text_width, text_height

    font = load_font(min_size)
    wrapped = wrap_headline(headline, font, max_width, draw)
    text_width, text_height = text_bbox(draw, wrapped, font)
    return wrapped, font, text_width, text_height


def rounded_image(image: Image.Image, radius: int) -> Image.Image:
    image = image.convert("RGBA")
    mask = Image.new("L", image.size, 0)
    draw = ImageDraw.Draw(mask)
    draw.rounded_rectangle((0, 0, image.width, image.height), radius=radius, fill=255)
    image.putalpha(mask)
    return image


def paste_with_shadow(
    canvas: Image.Image,
    screenshot: Image.Image,
    position: tuple[int, int],
    radius: int,
) -> None:
    x, y = position
    shadow_offset = max(10, round(canvas.width * 0.014))
    shadow_blur = max(18, round(canvas.width * 0.025))
    shadow_opacity = 105

    shadow = Image.new("RGBA", screenshot.size, (0, 0, 0, 0))
    mask = Image.new("L", screenshot.size, 0)
    ImageDraw.Draw(mask).rounded_rectangle((0, 0, screenshot.width, screenshot.height), radius=radius, fill=shadow_opacity)
    shadow.putalpha(mask)
    shadow = shadow.filter(ImageFilter.GaussianBlur(shadow_blur))

    canvas.alpha_composite(shadow, (x, y + shadow_offset))
    canvas.alpha_composite(rounded_image(screenshot, radius), position)


def resize_screenshot(image: Image.Image, canvas_width: int, canvas_height: int) -> Image.Image:
    target_width = round(canvas_width * 0.78)
    target_height = round(canvas_height * 0.72)
    scale = min(target_width / image.width, target_height / image.height, 1.0)
    new_size = (max(1, round(image.width * scale)), max(1, round(image.height * scale)))
    if new_size == image.size:
        return image.convert("RGBA")
    return image.convert("RGBA").resize(new_size, Image.Resampling.LANCZOS)


def frame_screenshot(
    input_path: Path,
    output_path: Path,
    width: int,
    height: int,
    headline: str,
    grad_top: str,
    grad_bottom: str,
    device: str | None = None,
) -> None:
    del device  # Reserved for future per-device tuning.
    canvas = make_vertical_gradient((width, height), grad_top, grad_bottom)
    draw = ImageDraw.Draw(canvas)

    horizontal_margin = round(width * 0.08)
    top_margin = round(height * 0.052)
    headline_area_height = round(height * 0.14)
    max_text_width = width - horizontal_margin * 2
    wrapped, font, _, text_height = fit_headline(
        headline,
        (width, height),
        max_text_width,
        headline_area_height,
        draw,
    )
    headline_y = top_margin + max(0, (headline_area_height - text_height) // 2)
    draw.multiline_text(
        (width // 2, headline_y),
        wrapped,
        font=font,
        fill=(255, 255, 255, 255),
        anchor="ma",
        align="center",
        spacing=max(8, round(height * 0.006)),
    )

    screenshot = resize_screenshot(Image.open(input_path), width, height)
    screenshot_x = (width - screenshot.width) // 2
    min_screenshot_y = round(height * 0.22)
    screenshot_y = max(top_margin + headline_area_height + round(height * 0.035), min_screenshot_y)
    available_bottom = height - round(height * 0.045)
    if screenshot_y + screenshot.height > available_bottom:
        screenshot_y = max(round(height * 0.19), available_bottom - screenshot.height)

    radius = max(24, round(width * 0.03))
    paste_with_shadow(canvas, screenshot, (screenshot_x, screenshot_y), radius)

    output_path.parent.mkdir(parents=True, exist_ok=True)
    canvas.convert("RGB").save(output_path, "PNG")


def positive_int(value: str) -> int:
    number = int(value)
    if number <= 0:
        raise argparse.ArgumentTypeError("Value must be greater than zero")
    return number


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Create gradient App Store marketing screenshots from raw PNG captures.",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=textwrap.dedent(
            """\
            Example:
              python Scripts/aso/frame_screenshot.py \\
                --input raw.png --output framed.png --width 1320 --height 2868 \\
                --headline "Watch your wealth grow" --grad-top "#3B82F6" --grad-bottom "#10B981"
            """
        ),
    )
    parser.add_argument("--input", required=True, type=Path, help="Raw app screenshot PNG.")
    parser.add_argument("--output", required=True, type=Path, help="Framed output PNG.")
    parser.add_argument("--width", required=True, type=positive_int, help="Final canvas width.")
    parser.add_argument("--height", required=True, type=positive_int, help="Final canvas height.")
    parser.add_argument("--headline", required=True, help="Localized headline text.")
    parser.add_argument("--grad-top", required=True, help="Top gradient color as #RRGGBB.")
    parser.add_argument("--grad-bottom", required=True, help="Bottom gradient color as #RRGGBB.")
    parser.add_argument("--device", choices=("iphone", "ipad"), help="Optional device family hint.")
    return parser


def main(argv: Sequence[str] | None = None) -> int:
    parser = build_parser()
    args = parser.parse_args(argv)

    parse_hex_color(args.grad_top)
    parse_hex_color(args.grad_bottom)
    print_brand_colors(find_repo_root(Path(__file__).resolve()))

    frame_screenshot(
        input_path=args.input,
        output_path=args.output,
        width=args.width,
        height=args.height,
        headline=args.headline,
        grad_top=args.grad_top,
        grad_bottom=args.grad_bottom,
        device=args.device,
    )
    print(f"Wrote {args.output} ({args.width}x{args.height})")
    return 0


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