#!/usr/bin/env python3
"""Runs on THIS host (eurobuild6) only -- the one place Ollama is
installed, and per design never needed on the deployed machine, since its
output gets cached into generated_icons/silhouettes/ like everything else
scripts/10-generate-app-icons.sh produces.

For icons the algorithmic trace (potrace + dominant-color stripping, see
lib/icon_remote.py) produced a degenerate result for -- typically icon
packs that draw a badge/background shape UNDER an equally-opaque
foreground glyph, differentiated by fill color/opacity rather than alpha,
which no pixel-threshold heuristic can reliably separate -- this asks a
local coder LLM (via Ollama) to do the separation STRUCTURALLY instead:
read the actual SVG source elements and classify which ones are the badge
(drop) vs. the glyph (keep + recolor).

Deliberately scoped to SELECTION, not generation: the model is never asked
to invent geometry, only to include/exclude whole elements verbatim and
change their fill. Every kept element's geometry-bearing attributes (d,
points, cx/cy/r, x/y/width/height, transform) are verified byte-for-byte
against the original source afterward -- if the model altered, rounded, or
invented so much as one coordinate, the whole result is rejected and the
existing algorithmic trace is left untouched. That's what makes a small
model's unreliability tolerable here: a wrong/garbled attempt is just a
safe no-op, never silent corruption.

Two subcommands:
  scan     Measures every cached silhouette's rendered alpha coverage and
           prints the ones that look degenerate (solid blob or
           thin-outline-only) -- "name\\tratio" per line.
  refine   For a given candidate list + their fetched original SVG
           sources, asks the model to isolate the glyph, validates the
           result, and on success overwrites that icon's cached
           silhouette (still __ACCENT__-templated, same as every other
           entry in generated_icons/silhouettes/) and marks it in the
           manifest.
"""
import argparse
import json
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

OLLAMA_URL = "http://localhost:11434/api/generate"
ACCENT_PLACEHOLDER = "__ACCENT__"
GEOMETRY_ATTRS = [
    "d", "points", "cx", "cy", "r", "rx", "ry", "x", "y",
    "x1", "y1", "x2", "y2", "width", "height", "transform",
]
DEGENERATE_LOW = 0.08   # hollow outline/ring -- mostly empty within its own bounding box
DEGENERATE_HIGH = 0.85  # solid blob -- almost entirely filled within its own bounding box

PROMPT_TEMPLATE = """You are given the source of an SVG icon. It is drawn as a colored background "badge" shape (a rounded square/circle, often gradient-filled, covering most of the canvas) with a foreground pictogram on top, sometimes shaded using fill-opacity to suggest depth.

Task: output a NEW SVG document containing ONLY the foreground pictogram elements. Remove the background badge shape(s) entirely.

Rules, follow exactly:
1. Do not invent, modify, round, or reformat any "d", "points", "cx", "cy", "r", "rx", "ry", "x", "y", "x1", "y1", "x2", "y2", "width", "height", or "transform" attribute value anywhere. Copy kept elements' geometry byte-for-byte from the input.
2. Set every kept element's fill attribute (and any fill inside a style attribute) to the literal string __ACCENT__. Set stroke="none" on every kept element. Remove any fill-opacity/opacity attribute (make it fully opaque).
3. Drop the background badge element(s) entirely -- typically the first and/or largest shape, often fill="url(#...)" referencing a gradient -- and drop any <linearGradient>/<radialGradient> definitions too since nothing will reference them anymore.
4. Keep the original <svg> root tag's viewBox/width/height attributes unchanged.
5. Output ONLY the raw SVG document. No explanation, no markdown code fences, no commentary.

SVG source:
{svg}"""


def measure_alpha_coverage(svg_path):
    """Mean alpha WITHIN the shape's own trimmed bounding box (not the
    full canvas) -- a hollow outline/ring and a solid blob both read as
    degenerate here regardless of how much of the canvas their bounding
    box happens to occupy, which raw whole-canvas coverage can't tell
    apart from a legitimate thinner glyph (e.g. a ring trace and a real
    "A" glyph can have similar whole-canvas coverage, but very different
    fill density within their own bbox)."""
    with tempfile.TemporaryDirectory() as td:
        colored = Path(td) / "colored.svg"
        colored.write_text(Path(svg_path).read_text().replace(ACCENT_PLACEHOLDER, "#ff6600"))
        png = f"{td}/r.png"
        subprocess.run(["rsvg-convert", "-w", "256", "-h", "256", str(colored), "-o", png],
                        check=True, capture_output=True)
        trimmed = f"{td}/t.png"
        subprocess.run(["convert", png, "-trim", "+repage", trimmed], check=True, capture_output=True)
        out = subprocess.run(
            ["convert", trimmed, "-alpha", "extract", "-format", "%[fx:mean]", "info:"],
            check=True, capture_output=True, text=True,
        ).stdout
        return float(out.strip())


def is_degenerate(ratio):
    return ratio > DEGENERATE_HIGH or ratio < DEGENERATE_LOW


def cmd_scan(args):
    for svg_path in sorted(Path(args.silhouette_dir).glob("*.svg")):
        try:
            ratio = measure_alpha_coverage(svg_path)
        except subprocess.CalledProcessError:
            continue
        if is_degenerate(ratio):
            print(f"{svg_path.stem}\t{ratio:.3f}")


def strip_code_fence(text):
    text = text.strip()
    if text.startswith("```"):
        text = re.sub(r"^```[a-zA-Z]*\n", "", text)
        text = re.sub(r"\n```$", "", text)
    return text.strip()


def extract_attr_values(text, attr):
    return re.findall(rf'{attr}="([^"]*)"', text)


def validate(original_text, candidate_text):
    candidate_text = strip_code_fence(candidate_text)
    try:
        ET.fromstring(candidate_text)
    except ET.ParseError as e:
        return None, f"invalid XML: {e}"

    for attr in GEOMETRY_ATTRS:
        orig_values = set(extract_attr_values(original_text, attr))
        for v in extract_attr_values(candidate_text, attr):
            if v not in orig_values:
                return None, f'geometry mismatch on {attr}="{v[:40]}" not found in original'

    if ACCENT_PLACEHOLDER not in candidate_text:
        return None, f"no {ACCENT_PLACEHOLDER} placeholder in output"

    if "url(#" in original_text and "url(#" in candidate_text:
        return None, "model kept a gradient reference -- did not remove the badge"

    return candidate_text, None


def call_ollama(model, svg_text, timeout):
    prompt = PROMPT_TEMPLATE.format(svg=svg_text)
    req = {"model": model, "prompt": prompt, "stream": False, "options": {"temperature": 0}}
    result = subprocess.run(
        ["curl", "-s", OLLAMA_URL, "-d", json.dumps(req)],
        check=True, capture_output=True, text=True, timeout=timeout,
    )
    return json.loads(result.stdout)["response"]


def cmd_refine(args):
    manifest = json.loads(Path(args.manifest).read_text())
    candidates = [
        line.split("\t")[0]
        for line in Path(args.candidates).read_text().splitlines()
        if line.strip()
    ]

    refined, rejected, skipped = 0, 0, 0
    for name in candidates:
        src = Path(args.sources_dir) / f"{name}.svg"
        if not src.is_file():
            print(f"SKIP  {name}: no fetched SVG source (PNG-sourced or unavailable)")
            skipped += 1
            continue

        original_text = src.read_text()
        try:
            raw_response = call_ollama(args.model, original_text, args.timeout)
        except (subprocess.CalledProcessError, subprocess.TimeoutExpired, json.JSONDecodeError, KeyError) as e:
            print(f"REJECT {name}: ollama call failed ({e})")
            rejected += 1
            continue

        result, reason = validate(original_text, raw_response)
        if result is None:
            print(f"REJECT {name}: {reason}")
            rejected += 1
            continue

        out_path = Path(args.silhouette_dir) / f"{name}.svg"
        out_path.write_text(result)
        if name in manifest:
            manifest[name]["refine_method"] = "llm"
            manifest[name]["refine_model"] = args.model
        print(f"OK    {name}: refined")
        refined += 1

    Path(args.manifest).write_text(json.dumps(manifest, indent=2, sort_keys=True) + "\n")
    print(f">>> {refined} refined, {rejected} rejected (left as algorithmic trace), {skipped} skipped (no SVG source)")


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

    p_scan = sub.add_parser("scan")
    p_scan.add_argument("--silhouette-dir", required=True)
    p_scan.set_defaults(func=cmd_scan)

    p_refine = sub.add_parser("refine")
    p_refine.add_argument("--manifest", required=True)
    p_refine.add_argument("--silhouette-dir", required=True)
    p_refine.add_argument("--sources-dir", required=True)
    p_refine.add_argument("--candidates", required=True)
    p_refine.add_argument("--model", default="qwen2.5-coder:7b")
    p_refine.add_argument("--timeout", type=int, default=180)
    p_refine.set_defaults(func=cmd_refine)

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


if __name__ == "__main__":
    main()
