#!/usr/bin/env python3
"""Make an edited UI icon SVG renderable by Qt again, and record its hash.

Called by the build (cmake/IconHygiene.cmake) for every *.svg in this directory whose hash no
longer matches svg.sha256 -- i.e. every icon a developer has touched. An untouched tree needs
neither this script nor inkscape.

Inkscape is the authoring tool; Qt is the renderer. The two disagree on three things, and each
one has to be baked out of the file before Qt sees it:

  1. empty <flowRoot>        invisible leftover that inflates the drawing bbox; dropped first.
                             Blocks holding real text are kept.
  2. <text>                  Qt would need the same fonts installed. object-to-path outlines it.
  3. marker-*:url(...)       Qt renders <marker> but ignores markerUnits="strokeWidth" (the SVG
                             default), so arrowheads come out at raw size instead of scaled by
                             the path's stroke width. object-stroke-to-path bakes them in.

It then brings the icon to the style guide (README_ICON.md), so a developer only has to draw:

  4. artboard               tools/reframe.py -- uniform 0 0 64 64 box. It owns the page, which
                            is why fit-canvas-to-selection was retired here in Phase 4.
  5. palette                tools/palette.py -- one value per hue, each legible on both grounds.
  6. theme roles            tools/themesvg.py -- ink/paper/lead/mark markup derived from the
                            house colours, so the icon follows light/dark. Skipped for a name in
                            palette.OPTOUT, which is instead stripped back to literal colours.

All three are idempotent: an icon already to spec comes out byte-identical, so this cannot thrash
a tree. Order is load-bearing -- the artboard is set BEFORE the inkscape pass (its wrapper
survives the export), the colour work AFTER it, because the plain-svg export flattens CSS back
onto the elements and an inline fill would then shadow the themed class.

Only the steps a file actually needs are run. That matters: object-stroke-to-path also converts
every stroke to a filled outline, which bloats the file and costs stroke editability, so it is
run only where a live marker reference exists.

TRAP: object-stroke-to-path turns a zero-width stroke into a filled path of the whole shape --
stroke-width:0 paints nothing before, a solid rectangle after. Such strokes are neutralised to
stroke:none first (appearance-neutral: a zero-width stroke paints nothing either way).

NOT byte-idempotent: inkscape re-serialises the file on every pass (attribute order, decimals).
That is why the build gates on svg.sha256 and only runs this on an icon whose hash has changed.

  ./svghygiene --manifest svg.sha256 Foo.svg Bar.svg   # fix these, then rewrite the manifest
  ./svghygiene --manifest svg.sha256 --all             # fix everything (first-time bootstrap)
  ./svghygiene --manifest svg.sha256 --record-only     # no fixing; just record current hashes

For each icon it repairs it also regenerates the committed PNG raster(s) from the fixed SVG (via
mkicon), so the SVG/PNG pair cannot drift; existing sizes are updated, a brand-new icon gets the
default 32/48 pair. And it warns when a repaired icon still carries no dark-theme markup, which
now means the artwork uses none of the house colours there was anything to derive a role from.

Requires: python3, inkscape 1.x.
"""
import argparse
import concurrent.futures
import hashlib
import os
import re
import subprocess
import sys
import tempfile

HERE = os.path.dirname(os.path.abspath(__file__))

# The unification rules (artboard, palette, theme roles) are applied here, not left to the
# developer: an icon is normalised on the build that follows the edit, the same way its markers
# and thin strokes are. Each of the three is idempotent, so an untouched icon is byte-stable.
sys.path.insert(0, os.path.join(HERE, "tools"))
import palette  # noqa: E402
import reframe  # noqa: E402
import themesvg  # noqa: E402

FLOWROOT = re.compile(r"[ \t]*<flowRoot\b.*?</flowRoot>\s*", re.S)
FLOWPARA = re.compile(r"<flowPara[^>]*>(.*?)</flowPara>", re.S)
STYLE = re.compile(r'style="([^"]*)"')
# Any spelling of a zero width: bare, or with any CSS unit. "0.5px" must not match.
ZERO_WIDTH = re.compile(r"^0(\.0+)?\s*(pt|px|mm|cm|in|pc|em|ex|%)?$")
# One start tag with its quoted attributes; values may hold '>', so they are matched, not skipped.
ELEMENT = re.compile(r"""<[a-zA-Z][\w:.-]*(?:\s+[\w:.-]+\s*=\s*(?:"[^"]*"|'[^']*'))*\s*/?>""", re.S)
ATTR = re.compile(r"""([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')""")
# Both spellings Inkscape may emit: style="marker-end:url(...)" and marker-end="url(...)".
MARKER_REF = re.compile(r"marker-(start|mid|end)\s*[:=]\s*[\"']?url\(")

MKICON = os.path.join(HERE, "mkicon")
# An icon follows the theme if it carries the current-color-scheme block or a currentColor fill.
THEME_MARK = re.compile(r'id="current-color-scheme"|currentColor', re.I)

# Minimum painted-stroke width, in 64-artboard units. Below this a theme-following
# (currentColor) line washes out on the dark ground -- the reframe scales strokes with the
# drawing, so the true width is declared * the wrapper scale (reframe.py).
STROKE_FLOOR = 2.5
WRAP_SCALE = re.compile(r'scale\(([0-9.]+)\)"')
STROKE_CURRENT = re.compile(r'stroke\s*[:=]\s*"?\s*currentColor', re.I)
STROKE_W = re.compile(r'stroke-width(\s*:\s*|=")([0-9.]+)([a-z%]*)')
SIZE_DIR = re.compile(r"(\d+)x(\d+)")
ACT_ICON = re.compile(r"Act[A-Z]")


def sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def strip_empty_flowroots(text):
    """Drop flowRoot blocks with no text. Returns (new_text, n_stripped)."""
    n = 0

    def repl(m):
        nonlocal n
        block = m.group(0)
        if "".join(FLOWPARA.findall(block)).strip():
            return block  # real text -- keep
        n += 1
        return ""

    return FLOWROOT.sub(repl, text), n


def style_decls(s):
    """{property: value} from a style attribute body."""
    decls = {}
    for part in s.split(";"):
        if ":" in part:
            k, v = part.split(":", 1)
            decls[k.strip()] = v.strip()
    return decls


def neutralise_zero_width_strokes(text):
    """stroke:none on each element whose stroke-width is 0, so object-stroke-to-path cannot fill it.

    Inkscape spells both stroke and stroke-width either way -- style="stroke-width:0" or
    stroke-width="0" -- so both are read. A style declaration beats a presentation attribute, so
    that is where the override is written. An inherited stroke is not resolved: Inkscape writes
    the style out per element.
    """
    n = 0

    def repl(m):
        nonlocal n
        tag = m.group(0)
        attrs = {a: (q or s or "") for a, q, s in ATTR.findall(tag)}
        style = style_decls(attrs.get("style", ""))
        width = style.get("stroke-width", attrs.get("stroke-width"))
        stroke = style.get("stroke", attrs.get("stroke"))
        if width is None or not ZERO_WIDTH.match(width.strip()) or stroke in (None, "none"):
            return tag
        n += 1
        style["stroke"] = "none"
        body = 'style="' + ";".join(f"{k}:{v}" for k, v in style.items()) + '"'
        if "style" in attrs:
            return STYLE.sub(lambda _: body, tag, count=1)
        return re.sub(r"\s*(/?>)$", lambda e: " " + body + e.group(1), tag, count=1)

    return ELEMENT.sub(repl, text), n


def make_profile(tmp):
    """Throwaway inkscape profile pinning the bbox preference to visual (0)."""
    d = os.path.join(tmp, "profile")
    os.makedirs(d, exist_ok=True)
    with open(os.path.join(d, "preferences.xml"), "w") as f:
        f.write('<inkscape version="1.x" '
                'xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape">\n'
                '  <group id="tools" bounding_box="0"/>\n'
                '</inkscape>\n')
    return d


def existing_png_sizes(icon_dir, stem):
    """Sizes N for which <icon_dir>/NxN/<stem>.png already exists."""
    sizes = []
    for entry in os.listdir(icon_dir):
        m = SIZE_DIR.fullmatch(entry)
        if m and m.group(1) == m.group(2) and os.path.isfile(os.path.join(icon_dir, entry, stem + ".png")):
            sizes.append(int(m.group(1)))
    return sorted(sizes)


def render_pngs(path):
    """Regenerate this icon's committed PNG raster(s) from the (already hygiened) SVG so they cannot
    drift from the source. Updates whatever NxN/<stem>.png already exist; a brand-new icon with none
    gets the default 32/48 pair (plus 16 for an Act* activity icon, the one family that ships 16).
    Returns (sizes, error_or_None). PNGs are secondary -- the apps draw the ".svgt" -- so a failure
    here is a warning, not a build stopper.
    """
    icon_dir = os.path.dirname(path)
    stem = os.path.basename(path)[:-4]
    sizes = existing_png_sizes(icon_dir, stem)
    if not sizes:
        sizes = [16, 32, 48] if ACT_ICON.match(stem) else [32, 48]
    r = subprocess.run([MKICON, path, *[str(s) for s in sizes]],
                       stdout=subprocess.DEVNULL, stderr=subprocess.PIPE)
    if r.returncode != 0:
        return sizes, f"mkicon failed: {r.stderr.decode()[:160]}"
    return sizes, None


def raise_thin_strokes(text):
    """Raise any currentColor stroke below STROKE_FLOOR up to it (up-only). Returns (text, n).

    Only theme-following (currentColor) strokes are floored: those are the ones that theme
    to a light ink and vanish on the dark ground. Literal-coloured strokes -- semantic marks
    and the whole self-backgrounded / fixed-appearance (OPTOUT) set -- are left untouched,
    so this needs no OPTOUT list. Width is the declared value times the reframe wrapper scale.
    """
    m = WRAP_SCALE.search(text)
    scale = float(m.group(1)) if m else 1.0
    minw = STROKE_FLOOR / scale
    n = 0

    def per_element(em):
        nonlocal n
        tag = em.group(0)
        if not STROKE_CURRENT.search(tag):
            return tag

        def bump(wm):
            nonlocal n
            if 0 < float(wm.group(2)) < minw:
                n += 1
                return f"stroke-width{wm.group(1)}{minw:.4f}"  # separator (: or =") kept; unit dropped
            return wm.group(0)

        return STROKE_W.sub(bump, tag)

    return ELEMENT.sub(per_element, text), n


def normalise_artboard(path, steps, env, tmp):
    """Style-guide step 1: the fixed 0 0 64 64 artboard. Returns error_or_None.

    Runs BEFORE the inkscape pass -- the reframe wrapper is a plain <g transform>, verified to
    survive object-to-path and the plain-svg export unchanged. Writes in place, and costs no
    inkscape call at all when the icon is already framed.
    """
    _, scale, err = reframe.one(path, env, True, tmp)
    if err and err.startswith("ERROR"):
        return f"reframe: {err}"
    if scale is not None:
        steps.append(f"reframe x{scale:.3f}")
    return None


def normalise_colour(text, stem, steps):
    """Style-guide steps 2-3: canonical palette, then the ink/paper/lead/mark theme markup.

    Runs AFTER the inkscape pass, and must: inkscape's plain-svg export flattens CSS back onto
    the elements, so a class-supplied `fill` reappears as an inline one. An inline fill beats the
    class for the engine too (CSvgtIconEngine skips an element that already has one), which would
    silently leave the shape unthemed -- exactly the two-colour .paper-ink form. Theming last
    keeps the markup as written.

    The thin-stroke floor rides along here because it measures `currentColor` strokes, which do
    not exist until the roles above are assigned.
    """
    # A fixed-appearance icon keeps its literal colours; it is only stripped of markup a previous
    # pass left behind, so that adding a name to OPTOUT actually un-themes it.
    if stem in palette.OPTOUT:
        bare = themesvg.bare(text)
        if bare != text:
            steps.append("un-themed (opt-out)")
        text = bare
    else:
        # Palette first: the roles are derived from the literal colours, so those have to be the
        # canonical ones before anything is derived from them.
        hued = palette.normalise(text, accents=stem not in palette.KEEP_BLUE)
        if hued != text:
            steps.append("palette")
            text = hued
        themed, tokens = themesvg.theme(text, keep=themesvg.keep_for(stem))
        if themed is not None and themed != text:
            steps.append(f"theme roles x{len(tokens)}")
            text = themed

    text, n_thin = raise_thin_strokes(text)
    if n_thin:
        steps.append(f"thin-stroke floor x{n_thin}")
    return text


def process(path, profile, tmp):
    """Hygiene one file in place. Returns (name, [steps applied], error_or_None)."""
    name = os.path.basename(path)
    steps = []
    env = dict(os.environ, INKSCAPE_PROFILE_DIR=profile)

    try:
        err = normalise_artboard(path, steps, env, tmp)
    except Exception as e:  # noqa: BLE001 -- report against this icon, never abort the batch
        err = f"reframe failed: {e}"
    if err:
        return name, steps, err, True, None

    try:
        raw = open(path, encoding="utf-8").read()
    except OSError as e:
        return name, [], f"read failed: {e}", True, None

    text = raw

    text, n_flow = strip_empty_flowroots(text)
    if n_flow:
        steps.append(f"flowRoot x{n_flow}")

    has_markers = bool(MARKER_REF.search(text))
    if has_markers:
        text, n_zero = neutralise_zero_width_strokes(text)
        if n_zero:
            steps.append(f"zero-width stroke x{n_zero}")

    if text != raw:
        open(path, "w", encoding="utf-8").write(text)

    # The page is NOT re-fit. Every icon carries the fixed 0 0 64 64 artboard set by
    # reframe.py; fit-canvas-to-selection would rewrite it back to the drawing bbox and
    # undo the reframe. reframe.py owns the page; this script only repairs the drawing for Qt.
    actions = ["select-all:all", "object-to-path"]
    steps.append("object-to-path")
    if has_markers:
        actions += ["select-all:all", "object-stroke-to-path"]
        steps.append("marker bake")
    actions += ["vacuum-defs",
                f"export-filename:{path}", "export-plain-svg", "export-overwrite", "export-do"]

    r = subprocess.run(["inkscape", "--actions=" + ";".join(actions), path],
                       stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, env=env)
    if r.returncode != 0:
        return name, steps, f"inkscape exit {r.returncode}: {r.stderr.decode()[:160]}", True, None

    # A silent no-op here would ship a Qt-unrenderable icon. Prove the marker refs are gone.
    after = open(path, encoding="utf-8").read()
    if MARKER_REF.search(after):
        return name, steps, "marker references survived object-stroke-to-path", True, None

    try:
        coloured = normalise_colour(after, name[:-4], steps)
    except Exception as e:  # noqa: BLE001 -- report against this icon, never abort the batch
        return name, steps, f"colour normalise failed: {e}", True, None
    if coloured != after:
        open(path, "w", encoding="utf-8").write(coloured)
        after = coloured

    # Keep the committed PNG raster(s) in step with the repaired SVG. The apps draw the ".svgt"
    # and never read these, but <pixmap> labels, rich-text <img> and the history/DB icon caches
    # still consume PNGs -- and a stale PNG is exactly the drift this gate exists to prevent.
    sizes, png_err = render_pngs(path)
    if sizes:
        steps.append("png " + "/".join(f"{s}x{s}" for s in sizes))

    themed = bool(THEME_MARK.search(after))
    return name, steps, None, themed, png_err


def write_manifest(manifest, svgs):
    with open(manifest, "w", encoding="utf-8") as f:
        f.write("# sha256 of each hygiened icon. Maintained by svghygiene; the build compares\n"
                "# against it to spot an edited icon. Do not hand-edit.\n")
        for s in sorted(svgs, key=os.path.basename):
            f.write(f"{sha256(s)}  {os.path.basename(s)}\n")


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--dir", default=HERE, help="icon directory (default: this script's)")
    ap.add_argument("--manifest", required=True, help="hash manifest to rewrite")
    ap.add_argument("--all", action="store_true", help="process every icon, not just the listed")
    ap.add_argument("--record-only", action="store_true", help="rewrite the manifest, fix nothing")
    ap.add_argument("--jobs", type=int, default=os.cpu_count() or 4)
    ap.add_argument("files", nargs="*", help="the icons to fix")
    args = ap.parse_args()

    every = sorted(os.path.join(args.dir, f)
                   for f in os.listdir(args.dir) if f.endswith(".svg"))
    if not every:
        sys.exit(f"no SVGs in {args.dir}")

    if args.record_only:
        write_manifest(args.manifest, every)
        print(f"svghygiene: recorded {len(every)} hashes in {args.manifest}")
        return 0

    targets = every if args.all else [os.path.abspath(f) for f in args.files]
    if not targets:
        sys.exit("nothing to do: pass files, --all or --record-only")

    tmp = tempfile.mkdtemp(prefix="svghygiene")
    try:
        profile = make_profile(tmp)
        print(f"svghygiene: {len(targets)} edited icon(s) to repair")
        results = []
        with concurrent.futures.ThreadPoolExecutor(max_workers=args.jobs) as ex:
            for r in ex.map(lambda s: process(s, profile, tmp), targets):
                results.append(r)

        for name, steps, err, themed, png_err in sorted(results):
            if not err:
                print(f"   {name}: {', '.join(steps)}")
                if png_err:
                    print(f"     ! {name}: {png_err} (SVG is fixed; PNG left stale)", file=sys.stderr)

        errors = [(n, e) for n, s, e, t, pe in results if e]
        if errors:
            print(f"\n  FAILED ({len(errors)}):", file=sys.stderr)
            for n, e in sorted(errors):
                print(f"     {n}: {e}", file=sys.stderr)
            return 1

        write_manifest(args.manifest, every)
        print(f"  OK. {len(targets)} repaired, {len(every)} hashes recorded.\n"
              f"  The icons above (SVG + regenerated PNGs) were written to your working tree --\n"
              f"  review and commit them together with {os.path.basename(args.manifest)}.")

        unthemed = sorted(n for n, s, e, t, pe in results if not e and not t)
        if unthemed:
            print(f"\n  NOTE: {len(unthemed)} icon(s) render in their authored colours only, with no\n"
                  f"  light/dark theming: {', '.join(unthemed)}\n"
                  f"  Roles are assigned from the house colours, so this means the icon uses none of\n"
                  f"  them. If it should follow the theme, redraw it in #000080 / #000000 / #ffffff /\n"
                  f"  #0000ff (see README_ICON.md). Fixed-colour icons (Mime*, warning/semantic) need nothing.")
        return 0
    finally:
        import shutil
        shutil.rmtree(tmp, ignore_errors=True)


if __name__ == "__main__":
    sys.exit(main())
