#!/usr/bin/env python3
"""Runs locally (no VM needed) -- pure network fetches against public icon-pack
raw-github hosts, driven by scripts/20-fetch-pilot-candidates.sh.

Every icon in icons.html's `#icon-data` JSON already has a `pilots[0]` candidate,
hand-matched in an earlier session: a Material Symbols icon name for "generic"
system icons (generated_icons/pilot_public/generic/), or a simple-icons logo for
"brand" app icons (generated_icons/pilot_public/brand/). This script adds MORE
candidates to that same list, up to 5 total per icon. Both kinds work the same
way -- a short list of slug guesses tried against a handful of OTHER icon packs,
one candidate per pack (first slug that 200s wins), so what gets added is a
genuinely different glyph design, not just a style/fill variant of the same
glyph (an earlier version of this script did the latter for generic icons --
same Material Symbol, different rounded/outlined/sharp x filled/outline combo --
and it turned out to barely look different at a glance; cross-pack sourcing
gives real variance instead):

  generic -- the symbol name is already recorded in pilots[0]['src']
             ("material symbols rounded: <name>, filled (Apache-2.0)"), so slug
             guesses are derived from it mechanically (see slug_guesses below)
             rather than hand-curated -- 180 icons is too many to hand-match.
             Tried against Tabler Icons, Font Awesome Free (solid, then
             regular), and Lucide.

  brand   -- tries a short hand-written list of slug guesses per icon (see
             BRAND_SLUGS below) against Tabler Icons and Font Awesome Free's
             brand sets. Low hit rate expected (most of these 25 are
             niche/FOSS names neither pack carries) -- that's fine, whatever
             doesn't 200 is just skipped.

Every fetched SVG needs one mechanical recolor fix before it's usable with the
page's __ACCENT__ placeholder convention: Material Symbols/Font Awesome raw SVGs
have no fill anywhere (path inherits black) so __ACCENT__ goes on the root <svg
fill=...>; Tabler/Lucide are stroke-based (fill="none" stroke="currentColor") so
__ACCENT__ replaces stroke="currentColor" instead.

Fetched SVGs are cached into generated_icons/pilot_public/{generic,brand}/<key>/<n>.svg
and appended to that icon's `pilots` list directly inside icons.html (same
find-the-script-block / json.loads / re-inject technique used for the pilot ->
pilots migration). Index 0 is never touched, so previously saved Save/Load choice
files (which store a pilot index) stay valid. One candidate per pack per icon is
kept (deduped against the pack names already present), so re-running is safe.

Usage:
  icon_pilot_candidates.py [--icon NAME ...] [--force] [--dry-run]
"""
import argparse
import json
import re
import sys
import urllib.error
import urllib.request
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent.parent
ICONS_HTML = REPO_ROOT / "icons.html"
PILOT_PUBLIC = REPO_ROOT / "generated_icons" / "pilot_public"

MAX_PILOTS = 5
TIMEOUT = 10

MATERIAL_SYMBOL_RE = re.compile(r"material symbols rounded: ([a-z0-9_]+), filled")
SUFFIX_RE = re.compile(r"_(alt|outline|rounded|sharp|filled|fill1|symbolic)$")

# Per-icon slug guesses to try against the brand sources below. Most of these
# 25 icons are niche/FOSS names that won't exist in either pack -- that's
# expected, the fetch just 404s and that candidate is skipped.
BRAND_SLUGS = {
    "Alacritty": ["alacritty"],
    "brave-desktop": ["brave"],
    "brave-origin": ["brave"],
    "discord": ["discord"],
    "firefox": ["firefox", "firefox-browser"],
    "gvim": ["vim", "neovim"],
    "io.elementary.granite-7": ["elementary"],
    "io.element.Element": ["element", "matrix"],
    "kde": ["kde"],
    "kde-frameworks": ["kde"],
    "kdenlive": ["kdenlive"],
    "keepassxc": ["keepassxc", "keepass"],
    "krita": ["krita"],
    "libreoffice-startcenter": ["libreoffice"],
    "localsend": ["localsend"],
    "Nextcloud": ["nextcloud"],
    "nvim": ["vim", "neovim"],
    "obsidian": ["obsidian"],
    "onlyoffice-desktopeditors": ["onlyoffice"],
    "org.mozilla.Thunderbird": ["thunderbird"],
    "plasma": ["kde-plasma", "plasma"],
    "plasmashell": ["kde-plasma", "plasma"],
    "plasma-symbolic": ["kde-plasma", "plasma"],
    "steam": ["steam"],
    "vlc": ["vlc", "vlc-media-player"],
}


def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": "timhyperland-icon-pilot/1.0"})
    try:
        with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
            return resp.read().decode("utf-8")
    except (urllib.error.HTTPError, urllib.error.URLError):
        return None


def normalize_fill(svg):
    """Material Symbols / Font Awesome: no fill anywhere, path inherits black."""
    return re.sub(r"<svg\b", '<svg fill="__ACCENT__"', svg, count=1)


def normalize_stroke(svg):
    """Tabler / Lucide: stroke-based (fill="none" stroke="currentColor" on root)."""
    return svg.replace('stroke="currentColor"', 'stroke="__ACCENT__"', 1)


# Each source: (pack label, license, slug -> list of candidate URLs to try in
# order, normalize fn). One candidate per pack per icon -- first URL (across
# all slug guesses) that 200s wins.
BRAND_SOURCES = [
    ("tabler icons", "MIT",
     lambda slug: [f"https://raw.githubusercontent.com/tabler/tabler-icons/main/icons/outline/brand-{slug}.svg"],
     normalize_stroke),
    ("font awesome free", "CC-BY-4.0",
     lambda slug: [f"https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/brands/{slug}.svg"],
     normalize_fill),
]

GENERIC_SOURCES = [
    ("tabler icons", "MIT",
     lambda slug: [f"https://raw.githubusercontent.com/tabler/tabler-icons/main/icons/outline/{slug}.svg"],
     normalize_stroke),
    ("font awesome free", "CC-BY-4.0",
     lambda slug: [
         f"https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/solid/{slug}.svg",
         f"https://raw.githubusercontent.com/FortAwesome/Font-Awesome/6.x/svgs/regular/{slug}.svg",
     ],
     normalize_fill),
    ("lucide", "ISC",
     lambda slug: [f"https://raw.githubusercontent.com/lucide-icons/lucide/main/icons/{slug}.svg"],
     normalize_stroke),
]


# Material Symbol names that don't exist verbatim in Tabler/Lucide/FA under
# any mechanical transform (checked: exact, suffix-stripped, first-word all
# miss) but clearly correspond to a differently-named icon in those packs --
# e.g. "monitoring" is "activity"/"gauge" there, "lan" is "router"/"network".
# Hand-matched by searching each pack's full icon index (not guessed blind)
# and verified with a real fetch before being added here. Covers the
# concepts behind ~50 generic icons that had zero cross-pack candidates.
SYNONYMS = {
    "backup": ["archive", "device-floppy", "cloud-backup"],
    "calculate": ["calculator"],
    "support_agent": ["headset", "help", "life-buoy"],
    "monitoring": ["activity", "chart-line", "gauge"],
    "storage": ["database", "server"],
    "update": ["download", "refresh", "cloud-download"],
    "system_update": ["download", "refresh", "cloud-download"],
    "developer_board": ["cpu", "circuit-board"],
    "design_services": ["palette", "ruler", "pencil-ruler"],
    "error": ["alert-circle", "alert-triangle", "exclamation-circle"],
    "warning": ["alert-triangle"],
    "new_releases": ["sparkles", "star", "bell-ringing"],
    "tune": ["adjustments", "sliders"],
    "videocam": ["video", "camera"],
    "content_paste": ["clipboard", "clipboard-paste", "paste"],
    "draw": ["pencil", "brush"],
    "functions": ["math", "math-function", "sigma"],
    "translate": ["language", "languages"],
    "public": ["globe", "world"],
    "vpn_lock": ["shield-lock"],
    "lan": ["router", "network"],
    "compare_arrows": ["git-compare", "arrows-right-left"],
    "equalizer": ["sliders", "adjustments-horizontal"],
    "extension": ["puzzle"],
    "animation": ["wand", "player-play"],
    "auto_awesome": ["sparkles", "wand"],
    "mood": ["mood-smile", "mood-happy"],
    "feedback": ["message-report", "message-dots", "comment"],
    "sports_esports": ["gamepad", "device-gamepad"],
    "notifications": ["bell"],
    "touch_app": ["pointer", "hand-click"],
    "account_circle": ["user-circle"],
    "security": ["shield"],
    "build": ["hammer", "settings", "tool"],
    "schedule": ["clock", "calendar"],
    "hub": ["share-2", "topology-star"],
    "groups": ["users"],
    "wb_sunny": ["sun"],
    "add_link": ["link", "link-plus"],
    "grid_view": ["grid-3x3", "layout-grid", "grid-dots"],
    "waving_hand": ["hand", "hand-stop"],
    "arrow_selector_tool": ["selector", "pointer"],
    "description": ["file-description", "file-text"],
    "tab": ["layout-navbar", "browser", "apps"],
    "spellcheck": ["text-spellcheck", "abc"],
}


def slug_guesses(name):
    """Slug guesses derived from a Material Symbol name like
    "battery_full_alt" -- mostly mechanical (exact hyphenated, common suffix
    stripped, first word), plus a hand-verified synonym list (SYNONYMS
    above) for names known not to exist verbatim anywhere else.
    """
    guesses = [name.replace("_", "-")]
    stripped = SUFFIX_RE.sub("", name)
    if stripped != name:
        hyphenated = stripped.replace("_", "-")
        if hyphenated not in guesses:
            guesses.append(hyphenated)
    first = name.split("_")[0]
    if first not in guesses:
        guesses.append(first)
    for synonym in SYNONYMS.get(name, []):
        if synonym not in guesses:
            guesses.append(synonym)
    return guesses


def fetch_from_sources(sources, slugs, existing_n, existing_packs, dry_run):
    """Try each source in turn (skipping any pack already represented in
    existing_packs), trying each slug guess until one URL 200s. At most one
    candidate added per pack, stopping once the 5-candidate cap is reached.
    """
    added = []
    for pack, license_, url_fn, normalize in sources:
        if existing_n + len(added) >= MAX_PILOTS:
            break
        if pack in existing_packs or not slugs:
            continue
        found = False
        for slug in slugs:
            label = f"{pack}: {slug} ({license_})"
            for url in url_fn(slug):
                if dry_run:
                    print(f"  [{pack}] GET {url}")
                    found = True
                    break
                raw = fetch(url)
                if raw is None:
                    continue
                added.append({"svg": normalize(raw), "src": label})
                found = True
                break
            if found:
                break
    return added


def fetch_generic_candidates(entry, existing_n, existing_packs, dry_run):
    src0 = entry["pilots"][0].get("src", "")
    m = MATERIAL_SYMBOL_RE.search(src0)
    if not m:
        return []
    return fetch_from_sources(
        GENERIC_SOURCES, slug_guesses(m.group(1)), existing_n, existing_packs, dry_run
    )


def fetch_brand_candidates(key, existing_n, existing_packs, dry_run):
    return fetch_from_sources(
        BRAND_SOURCES, BRAND_SLUGS.get(key, []), existing_n, existing_packs, dry_run
    )


def save_cache(kind, key, pilots):
    outdir = PILOT_PUBLIC / kind / key
    outdir.mkdir(parents=True, exist_ok=True)
    flat = PILOT_PUBLIC / kind / f"{key}.svg"
    zero = outdir / "0.svg"
    if flat.exists() and not zero.exists():
        flat.rename(zero)
    elif flat.exists():
        flat.unlink()
    for i, p in enumerate(pilots):
        if i == 0:
            continue  # pre-existing candidate, handled via the rename above
        path = outdir / f"{i}.svg"
        if not path.exists():
            path.write_text(p["svg"], encoding="utf-8")


def main():
    ap = argparse.ArgumentParser(
        description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
    )
    ap.add_argument("--icon", action="append", default=None,
                     help="limit to this icon key (repeatable)")
    ap.add_argument("--force", action="store_true",
                     help="refetch even if already at 5 pilots")
    ap.add_argument("--dry-run", action="store_true",
                     help="print what would be fetched, write nothing")
    args = ap.parse_args()

    content = ICONS_HTML.read_text(encoding="utf-8")
    order_m = re.search(
        r'(<script id="icon-order" type="application/json">)(.*?)(</script>)',
        content, re.S,
    )
    data_m = re.search(
        r'(<script id="icon-data" type="application/json">)(.*?)(</script>)',
        content, re.S,
    )
    order = json.loads(order_m.group(2))
    data = json.loads(data_m.group(2))

    keys = args.icon if args.icon else order
    touched = 0
    for key in keys:
        if key not in data:
            print(f"unknown icon key: {key}", file=sys.stderr)
            continue
        entry = data[key]
        pilots = entry.get("pilots") or []
        if not pilots:
            continue
        existing_n = len(pilots)
        if existing_n >= MAX_PILOTS and not args.force:
            continue

        brand_flat = PILOT_PUBLIC / "brand" / f"{key}.svg"
        brand_dir = PILOT_PUBLIC / "brand" / key
        is_brand = brand_flat.exists() or brand_dir.exists()
        existing_packs = {p["src"].split(":", 1)[0].strip() for p in pilots}

        if is_brand:
            added = fetch_brand_candidates(key, existing_n, existing_packs, args.dry_run)
            kind = "brand"
        else:
            added = fetch_generic_candidates(entry, existing_n, existing_packs, args.dry_run)
            kind = "generic"

        if args.dry_run or not added:
            continue

        pilots = pilots + added[: MAX_PILOTS - existing_n]
        entry["pilots"] = pilots
        save_cache(kind, key, pilots)
        touched += 1
        print(f"{key}: {existing_n} -> {len(pilots)} pilots")

    if args.dry_run:
        print("dry run -- nothing written")
        return

    if touched == 0:
        print("no icons updated")
        return

    new_blob = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
    new_content = (
        content[: data_m.start()]
        + data_m.group(1) + new_blob + data_m.group(3)
        + content[data_m.end():]
    )
    ICONS_HTML.write_text(new_content, encoding="utf-8")
    print(f"updated {touched} icons in {ICONS_HTML}")


if __name__ == "__main__":
    main()
