#!/usr/bin/env python3
"""Runs on the target machine (VM or local) -- the only phase of the icon
pipeline that needs to, since it reads /usr/share/applications and the
live icon theme on disk there.

Two subcommands, both driven by scripts/10-generate-app-icons.sh:

  resolve --tray-list PATH
      Enumerates every candidate icon name (Icon= from all installed
      .desktop files, plus the tray/status names in --tray-list), resolves
      each to its real on-disk icon file via the actual XDG icon-theme
      lookup chain (so it works whether or not the configured GTK theme is
      even installed), and prints one JSON object per line to stdout:
      {"name", "source_path", "source_hash", "is_status"} or
      {"name", "error": "not_found"}.

      This is the only step that needs to run on every target -- it is
      cheap (no rasterizing/tracing) and lets the driver script decide,
      by comparing source_hash against the manifest already committed in
      generated_icons/, which icons are actually new/changed and need the
      expensive step below.

  trace --jobs PATH --outdir DIR --alphamax F --padding-ratio F
      For just the jobs the driver decided it needs (a JSON file of
      {"name", "source_path", "is_status"} entries), produces a colorless
      silhouette SVG per icon in DIR, filled with the literal placeholder
      string "__ACCENT__" wherever the final accent color goes -- actual
      color substitution happens later, on the host, in
      11-deploy-icon-theme.sh. Needs potrace + rsvg-convert + ImageMagick
      on the target for this step only.

  grayscale --jobs PATH --outdir DIR --size N
      Driven by 14-generate-duotone-icons.sh. For each job, rasterizes the
      original icon at NxN preserving its alpha channel, then replaces its
      RGB with plain luminance (R=G=B=lightness) while keeping that alpha
      -- a colorless "duotone master" per icon in DIR. Applying a two-stop
      accent gradient to this via -clut (done later, on the host, in
      15-deploy-duotone-theme.sh) recolors the ORIGINAL shading/depth
      instead of discarding it, which is what makes this approach work for
      icons with no real alpha-separable badge/glyph structure (see
      13-llm-refine-icons.sh's docstring for why that trips up `trace`).
"""
import argparse
import hashlib
import json
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

HOME = Path.home()
ICON_THEME_BASE_DIRS = [HOME / ".local/share/icons", Path("/usr/share/icons")]
APP_DIRS = [Path("/usr/share/applications"), HOME / ".local/share/applications"]
CONTEXTS = ["apps", "status", "devices", "categories", "mimetypes", "places", "actions"]
SIZE_DIRS_ORDER = [
    "scalable", "512x512", "256x256", "192x192", "128x128",
    "96x96", "64x64", "48x48", "32x32", "24x24", "22x22", "16x16",
    # breeze (KDE) names its size dirs by the bare number, not "NxN"
    "512", "256", "192", "128", "96", "64", "48", "32", "24", "22", "16",
]

# Searched in addition to whatever's configured (kora, the GTK setting here,
# turns out to not be installed at all -- same for rofi's hardcoded
# "Tela-circle-dracula" -- so in practice lookups bottom out on hicolor,
# which doesn't carry generic status icons like battery/bluetooth/volume;
# those live in breeze/Adwaita, which *are* installed here). This only
# widens where we SEARCH for a real source file to trace -- it doesn't
# change where results get installed (see 11-deploy-icon-theme.sh).
FALLBACK_THEMES = ["breeze-dark", "breeze", "Adwaita", "AdwaitaLegacy"]
ACCENT_PLACEHOLDER = "__ACCENT__"


# --- shared: resolving a name to a real icon file via the XDG lookup chain

def configured_gtk_theme():
    try:
        out = subprocess.run(
            ["gsettings", "get", "org.gnome.desktop.interface", "icon-theme"],
            capture_output=True, text=True, timeout=5,
        )
        if out.returncode == 0:
            return out.stdout.strip().strip("'")
    except (OSError, subprocess.SubprocessError):
        pass
    return None


def theme_inherits(theme_name):
    for base in ICON_THEME_BASE_DIRS:
        idx = base / theme_name / "index.theme"
        if idx.is_file():
            text = idx.read_text(errors="ignore")
            m = re.search(r"^Inherits=(.+)$", text, re.MULTILINE)
            return [t.strip() for t in m.group(1).split(",") if t.strip()] if m else []
    return None  # theme not found anywhere -- e.g. configured but not installed


def build_theme_chain():
    chain, seen = [], set()
    queue = []
    configured = configured_gtk_theme()
    if configured:
        queue.append(configured)
    queue.extend(FALLBACK_THEMES)
    queue.append("hicolor")
    while queue:
        name = queue.pop(0)
        if not name or name in seen:
            continue
        seen.add(name)
        inherits = theme_inherits(name)
        if inherits is None:
            continue
        chain.append(name)
        queue.extend(inherits)
    if "hicolor" not in chain:
        chain.append("hicolor")
    return chain


def resolve_icon_source(name, theme_chain):
    """Returns (absolute_path, theme_name, relative_path_within_theme) --
    or (None, None, None). The theme name + relative path matter as much as
    the absolute path: whichever theme a consumer actually resolves this
    icon through (which may be a REAL installed theme like "kora" or
    "breeze", not just our hicolor/breeze-dark fallback overlay -- see
    11-deploy-icon-theme.sh), overriding it at that theme's own name and
    relative path is what actually shadows it, per the standard XDG
    per-user override ($HOME/.local/share/icons/<theme>/<relpath> beats
    /usr/share/icons/<theme>/<relpath> in that SAME theme)."""
    if name.startswith("/"):
        # Icon= is a hardcoded absolute path -- the app shows that exact
        # file itself, bypassing icon-theme lookup entirely, so an overlay
        # placed by name could never intercept it anyway. Not resolvable
        # for our purposes.
        return None, None, None
    for theme in theme_chain:
        for base in ICON_THEME_BASE_DIRS:
            theme_dir = base / theme
            if not theme_dir.is_dir():
                continue
            for size in SIZE_DIRS_ORDER:
                for ctx in CONTEXTS:
                    for ext in ("svg", "png"):
                        # Standard layout: <size>/<context>/name (hicolor,
                        # Adwaita, ...). breeze/kora invert it: <context>/
                        # <size>/name -- so try both orderings.
                        for rel in (
                            f"{size}/{ctx}/{name}.{ext}",
                            f"{ctx}/{size}/{name}.{ext}",
                        ):
                            p = theme_dir / rel
                            if p.is_file():
                                return p, theme, rel
    for ext in ("svg", "png", "xpm"):
        p = Path("/usr/share/pixmaps") / f"{name}.{ext}"
        if p.is_file():
            return p, None, None  # unthemed -- no per-theme override target
    return None, None, None


def read_desktop_icon_names():
    names = set()
    for d in APP_DIRS:
        if not d.is_dir():
            continue
        for f in d.glob("*.desktop"):
            try:
                text = f.read_text(errors="ignore")
            except OSError:
                continue
            m = re.search(r"^Icon=(.+)$", text, re.MULTILINE)
            if m:
                names.add(m.group(1).strip())
    return names


def read_tray_candidates(path):
    names = set()
    for line in Path(path).read_text().splitlines():
        line = line.split("#", 1)[0].strip()
        if line:
            names.add(line)
    return names


def sha256_file(path):
    return hashlib.sha256(Path(path).read_bytes()).hexdigest()


def cmd_find_absolute(args):
    """A few apps (xgps/xgpsspeed -> gpsd's own Tux logo) hardcode Icon= as
    an absolute path rather than a themed name -- resolve_icon_source
    already treats those as unresolvable, correctly, since no icon-theme
    override could ever intercept a path the app reads directly, and it's
    typically root-owned to boot. Informational only: finds every such
    path (deduplicated -- xgps and xgpsspeed share the same file) for a
    human to look at. Nothing in this pipeline writes to these paths --
    see scripts/README.md for why a sudo-based in-place override was
    tried and removed.
    """
    paths = set()
    for d in APP_DIRS:
        if not d.is_dir():
            continue
        for f in d.glob("*.desktop"):
            try:
                text = f.read_text(errors="ignore")
            except OSError:
                continue
            m = re.search(r"^Icon=(/.+)$", text, re.MULTILINE)
            if m:
                p = Path(m.group(1).strip())
                if p.is_file():
                    paths.add(str(p))
    print(json.dumps(sorted(paths)))


# --- resolve subcommand

def cmd_resolve(args):
    tray_names = read_tray_candidates(args.tray_list)
    app_names = read_desktop_icon_names()
    theme_chain = build_theme_chain()
    for name in sorted(app_names | tray_names):
        src, theme, rel = resolve_icon_source(name, theme_chain)
        if src is None:
            print(json.dumps({"name": name, "error": "not_found"}))
            continue
        print(json.dumps({
            "name": name,
            "source_path": str(src),
            "source_hash": sha256_file(src),
            "is_status": name in tray_names,
            "source_theme": theme,
            "source_relpath": rel,
        }))


# --- trace subcommand

def is_simple_flat_svg(path):
    text = Path(path).read_text(errors="ignore")
    if re.search(r"linearGradient|radialGradient|<image", text):
        return False
    colors = set(re.findall(r'fill="(#[0-9a-fA-F]{3,8})"', text))
    colors |= set(re.findall(r"fill:\s*(#[0-9a-fA-F]{3,8})", text))
    return len(colors) <= 3


def strip_to_silhouette(src_path, out_path):
    ET.register_namespace("", "http://www.w3.org/2000/svg")
    tree = ET.parse(src_path)
    root = tree.getroot()
    any_set = False
    for el in root.iter():
        if "fill" in el.attrib and el.attrib["fill"].lower() != "none":
            el.attrib["fill"] = ACCENT_PLACEHOLDER
            any_set = True
        if "stroke" in el.attrib and el.attrib["stroke"].lower() != "none":
            el.attrib["stroke"] = "none"
        if "style" in el.attrib:
            style = el.attrib["style"]
            new_style, n = re.subn(r"fill:\s*[^;]+", f"fill:{ACCENT_PLACEHOLDER}", style)
            new_style = re.sub(r"stroke:\s*[^;]+", "stroke:none", new_style)
            el.attrib["style"] = new_style
            any_set = any_set or n > 0
    if not any_set:
        root.set("fill", ACCENT_PLACEHOLDER)
    tree.write(out_path)


def apply_padding(svg_path, padding_ratio):
    """Widen the viewBox by padding_ratio on every side so the glyph sits
    with the same inset as the style guide's samples, without touching the
    traced/stripped geometry itself."""
    if padding_ratio <= 0:
        return
    text = Path(svg_path).read_text()
    m = re.search(r'viewBox="([\d.-]+) ([\d.-]+) ([\d.-]+) ([\d.-]+)"', text)
    if not m:
        return
    x, y, w, h = (float(v) for v in m.groups())
    pad_x, pad_y = w * padding_ratio, h * padding_ratio
    new_box = f'viewBox="{x - pad_x:.3f} {y - pad_y:.3f} {w + 2 * pad_x:.3f} {h + 2 * pad_y:.3f}"'
    text = text[: m.start()] + new_box + text[m.end():]
    Path(svg_path).write_text(text)


def flatten_background_badge(png_path, tmpdir):
    """Some icon packs (kora, ...) draw each icon as a solid, fully-opaque
    colored "badge" (rounded square/circle) with a glyph on top -- also
    fully opaque, just a different color. Pure alpha-threshold tracing
    can't distinguish them (both >=50% alpha) and ends up tracing the
    whole badge as one blob. Fix: find the single most common pixel color
    (the badge, since it covers more area than the glyph) and make
    near-matches to it transparent before alpha-extracting. For a
    genuinely transparent-background icon this is a no-op, since the most
    common color there is already the transparent one."""
    hist = subprocess.run(
        ["convert", str(png_path), "-format", "%c", "histogram:info:-"],
        check=True, capture_output=True, text=True,
    ).stdout
    dominant_hex, best_count = None, -1
    for line in hist.splitlines():
        m = re.match(r"\s*(\d+):\s*\([^)]*\)\s*(#[0-9A-Fa-f]+)", line)
        if m and int(m.group(1)) > best_count:
            best_count = int(m.group(1))
            dominant_hex = m.group(2)
    if not dominant_hex:
        return png_path
    flattened = Path(tmpdir) / "flattened.png"
    subprocess.run(
        ["convert", str(png_path), "-fuzz", "15%", "-transparent", dominant_hex, str(flattened)],
        check=True, capture_output=True,
    )
    return flattened


def trace_to_silhouette(src_path, out_path, alphamax, tmpdir):
    png = Path(tmpdir) / "render.png"
    if str(src_path).lower().endswith(".svg"):
        subprocess.run(
            ["rsvg-convert", "-w", "512", "-h", "512", str(src_path), "-o", str(png)],
            check=True, capture_output=True,
        )
    else:
        subprocess.run(
            ["convert", str(src_path), "-resize", "512x512", "-background", "none",
             "-gravity", "center", "-extent", "512x512", str(png)],
            check=True, capture_output=True,
        )
    png = flatten_background_badge(png, tmpdir)
    pbm = Path(tmpdir) / "mask.pbm"
    subprocess.run(
        ["convert", str(png), "-alpha", "extract", "-threshold", "50%", str(pbm)],
        check=True, capture_output=True,
    )
    svg_tmp = Path(tmpdir) / "traced.svg"
    subprocess.run(
        ["potrace", "-s", "-a", str(alphamax), "-o", str(svg_tmp), str(pbm)],
        check=True, capture_output=True,
    )
    text = svg_tmp.read_text()
    text, n = re.subn(r'fill="#?[0-9a-fA-F]{3,6}"', f'fill="{ACCENT_PLACEHOLDER}"', text)
    if n == 0:
        text = text.replace("<g ", f'<g fill="{ACCENT_PLACEHOLDER}" ', 1)
    Path(out_path).write_text(text)


def cmd_trace(args):
    jobs = json.loads(Path(args.jobs).read_text())
    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)
    ok = fail = 0
    for job in jobs:
        name, src = job["name"], Path(job["source_path"])
        out_path = outdir / f"{name}.svg"
        try:
            if src.suffix.lower() == ".svg" and is_simple_flat_svg(src):
                strip_to_silhouette(src, out_path)
            else:
                with tempfile.TemporaryDirectory() as tmpdir:
                    trace_to_silhouette(src, out_path, args.alphamax, tmpdir)
            apply_padding(out_path, args.padding_ratio)
            ok += 1
        except Exception as e:  # noqa: BLE001 -- keep going on a per-icon failure
            print(f"FAILED {name}: {e}", file=sys.stderr)
            fail += 1
    print(f">>> traced {ok} ok, {fail} failed", file=sys.stderr)


def rasterize_rgba(src_path, size, tmpdir):
    png = Path(tmpdir) / "r.png"
    if str(src_path).lower().endswith(".svg"):
        subprocess.run(
            ["rsvg-convert", "-w", str(size), "-h", str(size), str(src_path), "-o", str(png)],
            check=True, capture_output=True,
        )
    else:
        subprocess.run(
            ["convert", str(src_path), "-resize", f"{size}x{size}", "-background", "none",
             "-gravity", "center", "-extent", f"{size}x{size}", str(png)],
            check=True, capture_output=True,
        )
    return png


def to_grayscale_with_alpha(src_path, out_path, size, tmpdir):
    png = rasterize_rgba(src_path, size, tmpdir)
    alpha = Path(tmpdir) / "alpha.png"
    subprocess.run(["convert", str(png), "-alpha", "extract", str(alpha)],
                    check=True, capture_output=True)
    gray = Path(tmpdir) / "gray.png"
    # -alpha off: luminance must come from RGB only, not blended with
    # (likely mostly-transparent) background first, or edge pixels skew dark.
    subprocess.run(["convert", str(png), "-alpha", "off", "-colorspace", "Gray", str(gray)],
                    check=True, capture_output=True)
    subprocess.run(
        ["convert", str(gray), str(alpha), "-alpha", "off", "-compose", "CopyOpacity",
         "-composite", str(out_path)],
        check=True, capture_output=True,
    )


def cmd_grayscale(args):
    jobs = json.loads(Path(args.jobs).read_text())
    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)
    ok = fail = 0
    for job in jobs:
        name, src = job["name"], Path(job["source_path"])
        out_path = outdir / f"{name}.png"
        try:
            with tempfile.TemporaryDirectory() as tmpdir:
                to_grayscale_with_alpha(src, out_path, args.size, tmpdir)
            ok += 1
        except Exception as e:  # noqa: BLE001 -- keep going on a per-icon failure
            print(f"FAILED {name}: {e}", file=sys.stderr)
            fail += 1
    print(f">>> rasterized {ok} ok, {fail} failed", file=sys.stderr)


def main():
    ap = argparse.ArgumentParser()
    sub = ap.add_subparsers(dest="mode", required=True)

    p_resolve = sub.add_parser("resolve")
    p_resolve.add_argument("--tray-list", required=True)
    p_resolve.set_defaults(func=cmd_resolve)

    p_abs = sub.add_parser("find-absolute")
    p_abs.set_defaults(func=cmd_find_absolute)

    p_trace = sub.add_parser("trace")
    p_trace.add_argument("--jobs", required=True)
    p_trace.add_argument("--outdir", required=True)
    p_trace.add_argument("--alphamax", type=float, default=1.0)
    p_trace.add_argument("--padding-ratio", type=float, default=0.0)
    p_trace.set_defaults(func=cmd_trace)

    p_gray = sub.add_parser("grayscale")
    p_gray.add_argument("--jobs", required=True)
    p_gray.add_argument("--outdir", required=True)
    p_gray.add_argument("--size", type=int, default=256)
    p_gray.set_defaults(func=cmd_grayscale)

    args = ap.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
