#!/usr/bin/env python3
"""Runs on THIS host only. Pure-vector alternative to the raster+CLUT
duotone master (lib/icon_remote.py's `grayscale` + lib/icon_duotone_deploy.
py): for icons with a real SVG source, recolors each drawable element by
ITS OWN lightness directly in the vector markup -- no rasterizing, no
embedded <image> data URIs.

That distinction turned out to matter: an embedded base64 PNG inside an
<image> tag renders fine standalone (confirmed via rsvg-convert and
GdkPixbuf.Pixbuf.new_from_file_at_size directly on the VM) but rofi's own
icon-list rendering silently fails to show it (confirmed with a live A/B
test -- a plain solid-color <rect> showed up instantly, the <image>-wrapped
duotone master never did). Plain shape elements with solid fills, the way
this module produces them, render everywhere -- same as any other icon.

cmd_tag (generate-time, cacheable): for each {name, svg_text}, walks every
drawable element, resolves its effective color (honoring fill-opacity by
blending toward white, approximating url(#gradient) refs as the average of
their stops), computes perceptual lightness, and replaces its fill with a
placeholder encoding that lightness ("__L0.734__") -- not a real color yet,
so the same cached file works for any future accent/palette. Written to
generated_icons/duotone_vector/<name>.svg.

cmd_colorize (deploy-time, cheap): given a cached tagged SVG + a dark/light
hex pair, replaces every "__L<f>__" placeholder with the real hex you get
by linearly interpolating dark->light at that lightness, and drops
anything left pointing at a now-removed gradient def.
"""
import argparse
import base64
import json
import re
import subprocess
import sys
import tempfile
import xml.etree.ElementTree as ET
from pathlib import Path

DRAWABLE_TAGS = {"path", "rect", "circle", "ellipse", "polygon", "polyline", "line"}
NAMED_COLORS = {"white": "#ffffff", "black": "#000000", "none": None}
XLINK_HREF = "{http://www.w3.org/1999/xlink}href"


def parse_color(value):
    if not value:
        return None
    value = value.strip().lower()
    if value in ("none", "transparent"):
        return None
    if value in NAMED_COLORS:
        hexval = NAMED_COLORS[value]
        return hex_to_rgb(hexval) if hexval else None
    if value.startswith("url("):
        return None  # gradient ref, resolved separately via resolve_gradient_avg
    if value.startswith("#"):
        return hex_to_rgb(value)
    m = re.match(r"rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)", value)
    if m:
        return tuple(int(x) for x in m.groups())
    return None


def hex_to_rgb(h):
    h = h.lstrip("#")
    if len(h) == 3:
        h = "".join(c * 2 for c in h)
    if len(h) != 6:
        return None
    return tuple(int(h[i:i + 2], 16) for i in (0, 2, 4))


def rgb_to_hex(rgb):
    return "#{:02x}{:02x}{:02x}".format(*(max(0, min(255, round(c))) for c in rgb))


def lightness(rgb):
    r, g, b = rgb
    return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255.0


def blend_toward_white(rgb, opacity):
    return tuple(c * opacity + 255 * (1 - opacity) for c in rgb)


def resolve_gradient_avg(root, grad_id, ns):
    for tag in ("linearGradient", "radialGradient"):
        el = root.find(f".//{{{ns}}}{tag}[@id='{grad_id}']")
        if el is None:
            continue
        stops = []
        for stop in el.findall(f"{{{ns}}}stop"):
            style = stop.get("style", "")
            style_m = re.search(r"stop-color:\s*([^;]+)", style)
            c = (style_m.group(1).strip() if style_m else None) or stop.get("stop-color") or ""
            rgb = parse_color(c)
            if rgb:
                stops.append(rgb)
        if stops:
            return tuple(sum(c[i] for c in stops) / len(stops) for i in range(3))
    return None


def parse_css_class_colors(root, ns):
    """KDE Breeze-style "symbolic" icons set fill:currentColor and a class
    like class="ColorScheme-Text", with the actual color defined in an
    embedded <style> block (".ColorScheme-Text { color:#dfdfdf; }") --
    these are deliberately single-color icons already, just resolved
    indirectly. Returns {class_name: rgb} from every such rule found."""
    rules = {}
    for style_el in root.iter(f"{{{ns}}}style"):
        css = style_el.text or ""
        for m in re.finditer(r"\.([\w-]+)\s*\{([^}]*)\}", css):
            cls, body = m.group(1), m.group(2)
            color_m = re.search(r"(?<!-)color:\s*([^;]+)", body)
            if color_m:
                rgb = parse_color(color_m.group(1).strip())
                if rgb:
                    rules[cls] = rgb
    return rules


def effective_color(el, root, ns, css_rules):
    style = el.get("style", "")
    style_fill = re.search(r"fill:\s*([^;]+)", style)
    fill_val = (style_fill.group(1).strip() if style_fill else None) or el.get("fill")

    if fill_val and fill_val.lower() == "currentcolor":
        rgb = None
        for cls in el.get("class", "").split():
            if cls in css_rules:
                rgb = css_rules[cls]
                break
    elif fill_val and fill_val.startswith("url(#"):
        grad_id = fill_val[5:-1]
        rgb = resolve_gradient_avg(root, grad_id, ns)
    else:
        rgb = parse_color(fill_val) if fill_val else (0, 0, 0)  # SVG default fill is black

    if rgb is None:
        return None

    style_opacity = re.search(r"fill-opacity:\s*([\d.]+)", style)
    opacity_val = (style_opacity.group(1) if style_opacity else None) or el.get("fill-opacity")
    try:
        opacity = float(opacity_val) if opacity_val is not None else 1.0
    except ValueError:
        opacity = 1.0

    if opacity < 1.0:
        rgb = blend_toward_white(rgb, opacity)
    return rgb


def average_lightness_of_data_uri(data_uri):
    """Some kora icons embed tiny decorative details (e.g. CachyOS Hello's
    three accent dots) as a base64-encoded raster <image> instead of a
    vector shape -- cmd_tag's element loop only recolors DRAWABLE_TAGS, so
    those passed through untouched at their original color (the "blue
    dots" bug). Decodes the embedded PNG and returns the average pixel's
    lightness, so the <image> can be flattened into a same-lightness solid
    shape instead -- see the image-to-rect conversion in cmd_tag."""
    m = re.match(r"data:image/\w+;base64,(.+)", data_uri, re.DOTALL)
    if not m:
        return None
    png_bytes = base64.b64decode(m.group(1))
    with tempfile.NamedTemporaryFile(suffix=".png") as tf:
        tf.write(png_bytes)
        tf.flush()
        try:
            out = subprocess.run(
                ["convert", tf.name, "-resize", "1x1", "txt:-"],
                check=True, capture_output=True, text=True,
            ).stdout
        except (subprocess.CalledProcessError, FileNotFoundError):
            return None
    m2 = re.search(r"#([0-9A-Fa-f]{6})", out)
    if not m2:
        return None
    rgb = hex_to_rgb("#" + m2.group(1))
    return lightness(rgb) if rgb else None


def cmd_tag(args):
    ns = "http://www.w3.org/2000/svg"
    ET.register_namespace("", ns)
    sources = json.loads(Path(args.sources).read_text())
    outdir = Path(args.outdir)
    outdir.mkdir(parents=True, exist_ok=True)

    ok = fail = 0
    for name, svg_text in sources.items():
        try:
            root = ET.fromstring(svg_text)
            css_rules = parse_css_class_colors(root, ns)
            tagged_any = False
            for el in root.iter():
                tag = el.tag.split("}")[-1]
                if tag == "image":
                    href = el.get(XLINK_HREF) or el.get("href")
                    L = average_lightness_of_data_uri(href) if href else None
                    if L is None:
                        continue  # can't decode it -- leave it as-is rather than guess
                    # Flatten to a same-geometry solid rect. Any ancestor
                    # clip-path (kora clips these dots to a circle via a
                    # wrapping <g clip-path="...">) still applies exactly
                    # the same way, so the visual shape is unchanged.
                    el.tag = f"{{{ns}}}rect"
                    for attr in list(el.attrib):
                        if attr not in ("x", "y", "width", "height", "transform"):
                            del el.attrib[attr]
                    el.set("fill", f"__L{L:.3f}__")
                    el.set("stroke", "none")
                    tagged_any = True
                    continue
                if tag not in DRAWABLE_TAGS:
                    continue
                rgb = effective_color(el, root, ns, css_rules)
                if rgb is None:
                    el.set("fill", "none")
                    continue
                L = lightness(rgb)
                el.set("fill", f"__L{L:.3f}__")
                if "style" in el.attrib:
                    el.attrib["style"] = re.sub(r"fill(-opacity)?:[^;]+;?", "", el.attrib["style"])
                if "fill-opacity" in el.attrib:
                    del el.attrib["fill-opacity"]
                el.set("stroke", "none")
                tagged_any = True
            if not tagged_any:
                fail += 1
                print(f"SKIP {name}: no drawable fill found", file=sys.stderr)
                continue
            # drop gradient defs -- nothing references them anymore
            for tag in ("linearGradient", "radialGradient"):
                for grad in root.findall(f".//{{{ns}}}{tag}"):
                    for parent in root.iter():
                        if grad in list(parent):
                            parent.remove(grad)
                            break
            (outdir / f"{name}.svg").write_text(ET.tostring(root, encoding="unicode"))
            ok += 1
        except ET.ParseError as e:
            print(f"FAIL {name}: {e}", file=sys.stderr)
            fail += 1

    print(f">>> tagged {ok} ok, {fail} skipped/failed", file=sys.stderr)


def cmd_colorize(args):
    dark = hex_to_rgb(args.dark)
    light = hex_to_rgb(args.light)
    text = Path(args.input).read_text()

    def repl(m):
        L = float(m.group(1))
        rgb = tuple(dark[i] + (light[i] - dark[i]) * L for i in range(3))
        return f'fill="{rgb_to_hex(rgb)}"'

    text = re.sub(r'fill="__L([\d.]+)__"', repl, text)
    Path(args.output).write_text(text)


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

    p_tag = sub.add_parser("tag")
    p_tag.add_argument("--sources", required=True, help="JSON file: {name: raw_svg_text}")
    p_tag.add_argument("--outdir", required=True)
    p_tag.set_defaults(func=cmd_tag)

    p_colorize = sub.add_parser("colorize")
    p_colorize.add_argument("--input", required=True)
    p_colorize.add_argument("--output", required=True)
    p_colorize.add_argument("--dark", required=True)
    p_colorize.add_argument("--light", required=True)
    p_colorize.set_defaults(func=cmd_colorize)

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


if __name__ == "__main__":
    main()
