#!/usr/bin/env python3
"""Emit the Skia console's OS-mark table from the assets/os-icons masters.

    crates/pf-console-ui/src/os_marks.rs    in-session console UI, Skia Path::from_svg

The console parses SVG path data at runtime, so unlike the GTK/Windows/Apple clients it needs
no baked raster or PDF — it needs the path string. And unlike the three hand-kept inline
registries (web, Decky, Android), it is generated outright for the same reason the
launcher-icon tables are: thirteen paths of up to 3.5 kB each, where one mangled character is a
silently wrong logo that no test would catch.

Always emits EVERY master, whatever tokens gen-os-icons.sh was invoked with — this is one file,
and a partial rewrite would drop the rest.

Usage: python3 scripts/gen_os_mark_table.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" / "os-icons"
OUT = "crates/pf-console-ui/src/os_marks.rs"

BANNER = (
    "GENERATED by scripts/gen_os_mark_table.py from the assets/os-icons masters.\n"
    "Do not edit by hand — re-run `bash scripts/gen-os-icons.sh` instead.\n"
    "Per-mark provenance and licensing: assets/os-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)


# Sorted so the emitted file has a stable order across runs and machines.
TOKENS = sorted(p.stem for p in MASTERS.glob("*.svg"))
MARKS = [mark(t) for t in TOKENS]

# `!r` on a float always writes a decimal point (`384.0`, never `384`), which matters: the
# table's type is `f32` and a bare integer literal is a type error, not a coercion.
rows = "\n".join(f'    ("{t}", {w!r}, {h!r}, "{d}"),' for t, d, w, h in MARKS)
banner = "\n".join(f"//! {line}".rstrip() for line in BANNER.splitlines())

body = f"""{banner}
//!
//! The OS mark a host tile draws, resolved from the host's advertised OS-identity chain.
//!
//! The RESOLUTION is not ours: [`pf_client_core::os::os_icon_tokens`] walks the chain
//! most-specific-first and applies the brand aliases (`macos` → `apple`, `steamos` →
//! `steam`), and every front-end — GTK, WinUI, Swift, Kotlin, the web console — walks the
//! same list. That is the whole point of it living in the shared crate: a Bazzite host must
//! not draw Tux here and a Fedora hat there. All this module owns is which tokens it has
//! art for, and how the art is fitted.

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.
///
/// Parsed once per token and cached: `Path::from_svg` on a 3 kB string is not free, and the home
/// carousel 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 an OS-identity `chain` (`"linux/fedora/bazzite"`), scaled to fit `dst` and
/// centred in it — aspect ratio preserved, because the masters' viewports are not all square.
///
/// `None` when the chain is empty, unknown, or made of tokens we ship no art for; the tile then
/// draws its monogram, exactly as every tile did before OS marks existed. A chain we only
/// partly know still resolves: `linux/fedora/bazzite` on a build shipping no Bazzite mark falls
/// to Fedora, then to Tux, because that is the order the shared resolver hands back.
pub(crate) fn os_mark(chain: &str, dst: Rect) -> Option<Path> {{
    let (path, vw, vh) = pf_client_core::os::os_icon_tokens(chain)
        .into_iter()
        .find_map(|token| 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");
        }}
    }}

    /// The chain resolves most-specific-first, through the shared resolver. `steamos` reaching
    /// the Steam mark is the alias doing its job, not a coincidence of table order.
    #[test]
    fn chains_resolve_most_specific_first() {{
        let dst = Rect::from_wh(64.0, 64.0);
        for chain in [
            "windows",
            "linux",
            "linux/arch/steamos",
            "linux/fedora/bazzite",
            "macos",
        ] {{
            assert!(os_mark(chain, dst).is_some(), "{{chain}} resolved nothing");
        }}
        // A distro we ship no art for still lands on its family's mark.
        let known = os_mark("linux/debian/raspbian", dst);
        assert!(known.is_some(), "an unknown leaf must fall back to its family");
    }}

    /// An unknown or empty chain draws NOTHING, so the tile keeps its monogram — older hosts
    /// advertise no `os` at all, and they must look exactly as they did.
    #[test]
    fn unknown_chain_draws_nothing() {{
        let dst = Rect::from_wh(64.0, 64.0);
        assert!(os_mark("", dst).is_none());
        assert!(os_mark("plan9/glenda", dst).is_none());
        // Untrusted mDNS input that sanitizes away entirely is the same case.
        assert!(os_mark("!!!/???", dst).is_none());
    }}

    /// The mark is letterboxed into the destination, never stretched past it — the guarantee the
    /// non-square viewports (apple is 384x512, windows 24x24) depend on.
    #[test]
    fn mark_is_contained_and_centred() {{
        let dst = Rect::from_xywh(10.0, 20.0, 80.0, 40.0);
        let b = os_mark("apple", dst).unwrap().compute_tight_bounds();
        assert!(b.width() <= dst.width() + 0.5 && b.height() <= dst.height() + 0.5);
        assert!((b.center_x() - dst.center_x()).abs() < 1.0);
        assert!((b.center_y() - dst.center_y()).abs() < 1.0);
    }}
}}
"""

path = ROOT / OUT
path.write_text(body)
print(f"  {OUT} ({len(body):,} bytes, {len(MARKS)} marks)")
