#!/usr/bin/env python3
"""Emit the three INLINE launcher-icon registries from the assets/launcher-icons masters.

The OS-icon pipeline prints its path data for a human to paste into each client. That is fine
for a mark you add once a year; it is not fine here, where three clients each need seven paths
of up to 3 kB and a single mangled character is a silently wrong logo. So these three files are
generated outright, with their commentary baked in below:

    web/src/components/launcher-icon.tsx           web console, inline SVG
    clients/android/.../components/LauncherIcons.kt Android, Compose ImageVector via PathParser
    crates/pf-console-ui/src/launcher_icons.rs      in-session console UI, Skia Path::from_svg

The baked derivatives (GTK / Windows / Apple) come from gen-launcher-icons.sh, which calls this.

Usage: python3 scripts/gen_launcher_icon_tables.py     (from anywhere; paths are repo-relative)
"""

from __future__ import annotations

import pathlib
import re
import sys

ROOT = pathlib.Path(__file__).resolve().parent.parent
MASTERS = ROOT / "assets" / "launcher-icons"

# Registry order — the order a reader of any of the three files sees. Live tiles first, then the
# marks that ship dormant (see the masters' README), so "what actually renders today" reads off
# the top of the list.
TOKENS = ["steam", "lutris", "heroic", "playnite", "epic", "gog", "xbox"]

BANNER = (
    "GENERATED by scripts/gen_launcher_icon_tables.py from the assets/launcher-icons masters.\n"
    "Do not edit by hand — re-run `bash scripts/gen-launcher-icons.sh` instead.\n"
    "Per-mark provenance and licensing: assets/launcher-icons/README.md."
)


def mark(token: str) -> tuple[str, str, float, float]:
    """(token, path data, viewport width, viewport height) for one master."""
    svg = (MASTERS / f"{token}.svg").read_text()
    box = re.search(r'viewBox="([^"]+)"', svg).group(1)
    paths = re.findall(r'<path[^>]*\sd="([^"]+)"', svg)
    if len(paths) != 1:
        sys.exit(f"{token}: expected exactly one <path>, found {len(paths)}")
    d = paths[0]
    if any(c in d for c in "\n\t\"\\"):
        sys.exit(f"{token}: path data must be single-line and free of quotes/backslashes")
    _, _, w, h = box.split()
    return token, d, float(w), float(h)


MARKS = [mark(t) for t in TOKENS]


def comment(prefix: str) -> str:
    return "\n".join(f"{prefix} {line}".rstrip() for line in BANNER.splitlines())


def write(rel: str, body: str) -> None:
    p = ROOT / rel
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_text(body)
    print(f"  {rel} ({len(body):,} bytes)")


# --- web console -----------------------------------------------------------------------------

rows = "\n".join(
    f'\t{t}: {{\n\t\tviewBox: "0 0 {w:g} {h:g}",\n\t\td: "{d}",\n\t}},'
    for t, d, w, h in MARKS
)
write(
    "web/src/components/launcher-icon.tsx",
    f"""{comment("//")}
//
// The mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token. lucide
// deliberately ships no brand marks, so this is a curated registry — the same shape as
// os-icon.tsx, which does the equivalent job for the host cards' OS marks.
import type {{ FC }} from "react";

/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
const LAUNCHER_ICONS: Record<string, {{ viewBox: string; d: string }}> = {{
{rows}
}};

/**
 * The mark for an entry's `icon` token, or null — render nothing — when the entry carries no
 * token or names one this console ships no art for. Callers fall back to the title, which is
 * what every launcher tile looked like before the token existed.
 *
 * The token is looked up in the shipped set, never interpolated into anything, so a host
 * sending something unexpected can only ever produce "no icon".
 */
export const LauncherIcon: FC<{{
\ticon?: string | null;
\tclassName?: string;
\tlabel?: string;
}}> = ({{ icon, className, label }}) => {{
\tconst m = icon ? LAUNCHER_ICONS[icon] : undefined;
\tif (!m) return null;
\treturn (
\t\t<svg
\t\t\txmlns="http://www.w3.org/2000/svg"
\t\t\tviewBox={{m.viewBox}}
\t\t\tfill="currentColor"
\t\t\tclassName={{className}}
\t\t\trole={{label ? "img" : undefined}}
\t\t\taria-label={{label}}
\t\t\taria-hidden={{label ? undefined : true}}
\t\t>
\t\t\t{{label && <title>{{label}}</title>}}
\t\t\t<path d={{m.d}} />
\t\t</svg>
\t);
}};

export default LauncherIcon;
""",
)

# --- Android ---------------------------------------------------------------------------------

rows = "\n".join(
    f'    "{t}" to LauncherGlyph(\n'
    f"        viewportWidth = {w:g}f,\n"
    f"        viewportHeight = {h:g}f,\n"
    f'        d = "{d}",\n'
    f"    ),"
    for t, d, w, h in MARKS
)
write(
    "clients/android/app/src/main/kotlin/io/unom/punktfunk/components/LauncherIcons.kt",
    f"""package io.unom.punktfunk.components

{comment("//")}

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import kotlin.math.max

/**
 * The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
 * Material ships no brand icons, so this is a curated registry — the sibling of [OsIcons],
 * which does the equivalent job for the host cards.
 *
 * Held as raw SVG path strings rather than transcribed ImageVector DSL: [PathParser] builds
 * the vector once and [launcherIcon] caches it. Viewports are the masters' own and are NOT
 * all square, so the builder letterboxes — a mark forced into a square box is a squashed mark.
 */
private class LauncherGlyph(
    val viewportWidth: Float,
    val viewportHeight: Float,
    val d: String,
)

private val GLYPHS: Map<String, LauncherGlyph> = mapOf(
{rows}
)

private val CACHE = HashMap<String, ImageVector>()

/**
 * The [ImageVector] for an `icon` token, or null when the entry carries none or names a mark
 * this build ships no art for — the caller then falls back to naming the launcher, which is
 * what every launcher tile looked like before the token existed.
 *
 * Tinted by the caller via `tint`, so one mark serves every palette.
 */
fun launcherIcon(token: String?): ImageVector? {{
    val glyph = GLYPHS[token ?: return null] ?: return null
    return CACHE.getOrPut(token) {{
        // Square the box and centre the mark in it, so a wide or tall master keeps its aspect
        // ratio instead of being stretched to the tile.
        val side = max(glyph.viewportWidth, glyph.viewportHeight)
        val dx = (side - glyph.viewportWidth) / 2f
        val dy = (side - glyph.viewportHeight) / 2f
        ImageVector.Builder(
            name = "launcher_$token",
            defaultWidth = 24.dp,
            defaultHeight = 24.dp,
            viewportWidth = side,
            viewportHeight = side,
        ).apply {{
            addGroup(translationX = dx, translationY = dy)
            addPath(
                pathData = PathParser().parsePathString(glyph.d).toNodes(),
                fill = SolidColor(Color.White),
            )
            clearGroup()
        }}.build()
    }}
}}
""",
)

# --- in-session console UI (Skia) --------------------------------------------------------------

rows = "\n".join(
    f'    ("{t}", {w:g}.0, {h:g}.0, "{d}"),' for t, d, w, h in MARKS
)
write(
    "crates/pf-console-ui/src/launcher_icons.rs",
    f"""{comment("//!")}
//!
//! The brand mark a `role: "launcher"` tile draws, resolved from the entry's `icon` token.
//! Skia parses SVG path data directly, so the masters need no transcription into a drawing
//! DSL — the path string is the asset.

use skia_safe::{{Matrix, Path, Rect}};
use std::collections::HashMap;
use std::sync::{{Mutex, OnceLock}};

/// A parsed mark and the viewport its coordinates are in.
type Glyph = (Path, f32, f32);

/// Token → parsed mark, with `None` memoizing "no such token / did not parse" so a miss is not
/// re-attempted every frame. Named because `clippy::type_complexity` rejects it inline, and this
/// file is generated — an inline type would fail the `-D warnings` gate on every regeneration.
type GlyphCache = HashMap<String, Option<Glyph>>;

/// `(token, viewport width, viewport height, path data)` — the masters, verbatim.
const GLYPHS: &[(&str, f32, f32, &str)] = &[
{rows}
];

/// The parsed path for a token plus the viewport it was authored in, or `None` when the token is
/// absent, unknown, or (defensively) unparseable — the tile then names its launcher instead,
/// which is exactly how every launcher tile looked before icons existed.
///
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the
/// library shelf re-renders every frame while the cursor springs.
fn glyph(token: &str) -> Option<Glyph> {{
    static CACHE: OnceLock<Mutex<GlyphCache>> = OnceLock::new();
    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
    let mut cache = cache.lock().ok()?;
    if let Some(hit) = cache.get(token) {{
        return hit.clone();
    }}
    let built = GLYPHS
        .iter()
        .find(|(t, ..)| *t == token)
        .and_then(|(_, w, h, d)| Path::from_svg(d).map(|p| (p, *w, *h)));
    cache.insert(token.to_string(), built.clone());
    built
}}

/// The mark for `token`, scaled to fit `dst` and centred in it — aspect ratio preserved, because
/// the masters' viewports are not all square. `None` when there is no mark to draw.
pub(crate) fn launcher_mark(token: &str, dst: Rect) -> Option<Path> {{
    let (path, vw, vh) = glyph(token)?;
    let scale = (dst.width() / vw).min(dst.height() / vh);
    let mut m = Matrix::new_identity();
    m.set_scale((scale, scale), None);
    m.post_translate((
        dst.left + (dst.width() - vw * scale) / 2.0,
        dst.top + (dst.height() - vh * scale) / 2.0,
    ));
    Some(path.with_transform(&m))
}}

#[cfg(test)]
mod tests {{
    use super::*;

    /// Every shipped master parses. A mark that silently fails to parse is a tile that silently
    /// loses its icon, which no other test in this crate would notice.
    #[test]
    fn every_glyph_parses() {{
        for (token, ..) in GLYPHS {{
            assert!(glyph(token).is_some(), "{{token}} failed to parse");
        }}
    }}

    #[test]
    fn unknown_token_draws_nothing() {{
        assert!(launcher_mark("not-a-launcher", Rect::from_wh(64.0, 64.0)).is_none());
    }}

    /// The mark is letterboxed into the destination, never stretched past it — the guarantee the
    /// non-square viewports (playnite is 1024x1024, steam 496x512) depend on.
    #[test]
    fn mark_is_contained_and_centred() {{
        let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
        let b = launcher_mark("steam", dst).unwrap().compute_tight_bounds();
        assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
        let (cx, cy) = (b.center_x(), b.center_y());
        assert!((cx - dst.center_x()).abs() < 1.0, "off-centre horizontally: {{cx}}");
        assert!((cy - dst.center_y()).abs() < 1.0, "off-centre vertically: {{cy}}");
    }}
}}
""",
)
