"""
v5 of the invisible-chrome generator (2026-05-12 RE).

WHAT v5 ADDS (on top of v4):
  - Bargraph state-image preservation. When a Page-3 widget contains a
    <e c="BargraphTrapezio"> child, the script now copies all bar segment
    state images (m?off.{tgz,png}, m?on?.{tgz,png}) from the SOURCE
    Items/mdN/ folder AND from every sibling Items/mdN/ folder under the
    same Items root. This is required because the overlay's bargraph block
    references both 'm4on0.tgz'/'m4off.tgz' (from md4) and nested UDM
    references to 'm2on0.tgz'/'m2off.tgz' (from md2) — both file sets need
    to live in the firmware mask folder side-by-side. Without this, v4's
    wNN folders shipped only mask.tgz/mask.png and bar segments rendered
    blank on hardware. Also copies bargraph_prw.png if present (RS3 only,
    not used by firmware but keeps the theme valid in RS3's preview).
    State image files are NOT bkg-cropped — they're verbatim bar segment
    graphics; only mask.tgz (the chrome) is invisible.

  - Speed/RPM channel-binding preservation. The source file (Energima Twin
    install.zconf2) has the Page-3 Speed widget (md.04) Axis flagged with
    CHANNEL_NOT_SET=1 even though FID_OK=Speed and fid=16 are both set —
    this causes RS3 to throw an "internal error" on Save until the user
    manually re-maps Speed. v5 scans every Page-3 Axis block; for any
    Axis where fid is a non-zero integer AND FID_OK is non-empty (i.e.
    the binding is actually defined), it forces CHANNEL_NOT_SET=0. RPM
    widgets already had CHANNEL_NOT_SET=0 in the source so they don't
    need the same fix, but they may be inadvertently invalidated when
    the user opens the zconf2 in RS3 if some other Page-3 Axis field is
    wrong. Diagnostic output prints each Page-3 Axis's fid / FID_OK /
    CHANNEL_NOT_SET before+after so we can confirm what shipped.

WHAT v4 ADDED:
  - Canonical mdN folders (md2..md11) cloned from zenergimaracing donor.
  - Each mdN.M2.xml normalised to include PATH_IMAGE_SFONDO_FW=mask.tgz.
  - Each mdN/mask.tgz overwritten with a bkg crop at the canonical (XPOS,YPOS)
    pulled from the donor's pMain.P2.xml.
  - Page 3 widget refs rewritten to point at the new zinvisible theme, but
    multiple widgets that shared the SAME source mdN_name stayed sharing
    that ONE mask.tgz on disk -> 5 widgets all rendering the same bkg crop
    pulled from md3's (or md4's) canonical (XPOS,YPOS).
  - Hardware result: speed widget on Page 3 blends correctly (its canonical
    pos is its actual canvas pos); but trim digits + PRT/STB indicators
    render the wrong piece of bkg because md3/md4's canonical positions are
    the top-left bright-branding region of the bkg.

WHAT v4 ADDS (the fix):
  - Each Page-3 widget gets its OWN unique per-widget folder under
    zinvisible/Items/wNN/ (w00, w01, ...).
  - Each wNN/mask.tgz is a bkg crop at THAT widget's slot (XPOS,YPOS)
    sized by the SOURCE mdN's canonical WIDTH/HEIGHT (NOT the slot's
    on-canvas WIDTH/HEIGHT — firmware uses the canonical mask geometry).
  - wNN/{name}.M2.xml is the SOURCE mdN's M2.xml verbatim, patched to
    inject PATH_IMAGE_SFONDO_FW=mask.tgz and to retag the outer
    <e c="GMaskParam" i="..."> id to match the new folder name.
  - The canonical mdN folders stay on disk (RS3's pMain.P2.xml still
    references them and we don't want to break the theme catalog).
  - The output zconf2's Page-3 widget refs are rewritten to point at the
    new wNN folders instead of the canonical mdN folders.
  - Pages 1 + 2 still get the conservative old_theme/ -> new_theme/
    substring replace (unchanged from v3).

Inputs are read; outputs are derived. Nothing about MXG 1.2 Strada is hardcoded
beyond the explicit Path() constants below.
"""
from __future__ import annotations

import argparse
import os
import re
import shutil
import sys
import tempfile
import zipfile
from pathlib import Path

from PIL import Image

# bkg_tgz.encode/decode live next to this script
sys.path.insert(0, str(Path(__file__).parent))
from bkg_tgz import decode, encode  # noqa: E402

# ---- Device-to-overlay-folder mapping (used by --device flag) ---------------
# Maps the device key (same set as DashEditor's DeviceRegistry) to the
# overlay subfolder name under C:/AIM_SPORT/RaceStudio3/aim/resources/overlay/.
# Extend this when new devices are validated.
DEVICE_OVERLAY_MAP = {
    "mxg_12":      "mxg_12 strada",
    "mxs_12":      "mxs_12",
    "mxsl":        "mxsl",
    "mxp":         "mxp",
    "mxt":         "mxt",
    "solo2dl":     "solo2dl",
    "pdm/dash1":   "pdm/dash1",
    "pdm/dash2":   "pdm/dash2",
}

RS3_INSTALL_ROOT = Path(r"C:\AIM_SPORT\RaceStudio3")


def _build_paths_from_args(args) -> dict:
    """Resolve all path constants from CLI args (or legacy defaults).

    Returns a dict with keys:
      RS3_OVERLAY_ROOT, RS3_HOMEBREW, SRC_THEME, DST_THEME,
      SRC_ZCONF, DST_ZCONF, NEW_GETTEXT, OLD_THEME_NAME, NEW_THEME_NAME,
      OVERLAY_TREE_ROOT.
    """
    # Input .zconf2
    src_zconf = Path(args.input)

    # Output .dashtheme / .zconf2 path
    dst_zconf = Path(args.output)

    # Device -> overlay folder
    device_key = args.device.lower().strip()
    overlay_folder = DEVICE_OVERLAY_MAP.get(device_key)
    if overlay_folder is None:
        # Friendly error: list supported devices.
        supported = ", ".join(sorted(DEVICE_OVERLAY_MAP.keys()))
        raise SystemExit(
            f"Unsupported device: {device_key!r}\n"
            f"Supported devices: {supported}"
        )

    rs3_overlay_root = (
        RS3_INSTALL_ROOT
        / "aim" / "resources" / "overlay"
        / overlay_folder
        / "masks" / "set" / "standard"
    )
    rs3_homebrew = rs3_overlay_root / "s04_homebrew"

    # Source theme: if --src-theme is given use it; otherwise derive from
    # --input's zconf2 by reading the first page's PATH_REL_AiM_PAGE value
    # and inferring the theme folder name. Fallback: raise a clear error.
    if args.src_theme:
        src_theme = Path(args.src_theme)
    else:
        # Try to infer the source theme from the zconf2.
        src_theme = _infer_src_theme(src_zconf, rs3_homebrew)

    # Destination theme folder: named "zinvisible" inside the homebrew set,
    # or user-specified via --dst-theme.
    if args.dst_theme:
        dst_theme = Path(args.dst_theme)
    else:
        dst_theme = rs3_homebrew / "zinvisible"

    new_gettext   = args.theme_name or "Invisible Chrome"
    old_theme_name = src_theme.name
    new_theme_name = dst_theme.name

    return {
        "RS3_OVERLAY_ROOT":  rs3_overlay_root,
        "RS3_HOMEBREW":      rs3_homebrew,
        "SRC_THEME":         src_theme,
        "DST_THEME":         dst_theme,
        "SRC_ZCONF":         src_zconf,
        "DST_ZCONF":         dst_zconf,
        "NEW_GETTEXT":       new_gettext,
        "OLD_THEME_NAME":    old_theme_name,
        "NEW_THEME_NAME":    new_theme_name,
        "OVERLAY_TREE_ROOT": RS3_INSTALL_ROOT,
    }


def _infer_src_theme(zconf_path: Path, homebrew_root: Path) -> Path:
    """Best-effort: crack open the zconf2 and read all GMaskParam
    PATH_REL_AiM_MASK values to find the source theme folder.

    RS3 overlay paths follow these conventions:
      - Stock themes:    sNN/pXX/Items/mdN/
                         e.g. s02/p09/Items/md2/
                              s03/p17i/Items/md4/
      - Homebrew themes: sNN_homebrew/<theme_name>/Items/mdN/
                         e.g. s04_homebrew/zsr50v4/Items/md3/

    A config may reference BOTH: individual widgets can reference stock mdN
    geometry (for canonical mask dimensions), while the page background and
    most widgets point at a homebrew theme.

    Strategy:
      1. Collect all PATH_REL_AiM_MASK values.
      2. Prefer any homebrew sNN_*homebrew*/<theme>/ candidate that exists
         on disk — homebrew themes own the page bkg.tgz we crop from.
      3. If no homebrew theme found, fall back to the first stock sNN/pXX/
         theme that exists on disk.
      4. If nothing resolves, raise a clear error.

    Raises SystemExit on failure with a human-readable message.
    """
    try:
        with zipfile.ZipFile(zconf_path) as z:
            overlay_names = [n for n in z.namelist() if n.endswith("overlay.ov3.xml")]
            if not overlay_names:
                raise SystemExit(
                    "Could not find overlay.ov3.xml inside "
                    f"{zconf_path.name}. Use --src-theme to specify the "
                    "source theme folder explicitly."
                )
            xml_text = z.read(overlay_names[0]).decode("utf-8", errors="replace")
    except (zipfile.BadZipFile, FileNotFoundError) as exc:
        raise SystemExit(f"Cannot open {zconf_path}: {exc}") from exc

    # Collect ALL PATH_REL_AiM_MASK values from the overlay.
    all_masks = re.findall(r'<p n="PATH_REL_AiM_MASK">([^<]+)</p>', xml_text)
    if not all_masks:
        raise SystemExit(
            f"No PATH_REL_AiM_MASK found in {zconf_path.name}. "
            "Use --src-theme to specify the source theme folder explicitly."
        )

    # The RS3 overlay root is homebrew_root.parent (…/standard/).
    # We need it to resolve stock paths like s02/p09/.
    standard_root = homebrew_root.parent

    homebrew_candidates: list[Path] = []
    stock_candidates:    list[Path] = []

    for raw in all_masks:
        rel = raw.strip().rstrip("/")
        parts = rel.replace("\\", "/").split("/")

        # Walk the parts looking for an sNN[_…] component.
        # The theme folder is:
        #   - For homebrew: the component AFTER the sNN_*homebrew* folder
        #     e.g. parts = [..., 's04_homebrew', 'zsr50v4', 'Items', 'md3']
        #          -> theme = standard_root / 's04_homebrew' / 'zsr50v4'
        #   - For stock:    the pXX folder directly under the sNN folder
        #     e.g. parts = [..., 's02', 'p09', 'Items', 'md2']
        #          -> theme = standard_root / 's02' / 'p09'
        for i, part in enumerate(parts):
            # Match sNN or sNN_something (the theme-set level).
            if not re.match(r'^s\d', part, re.IGNORECASE):
                continue
            if "homebrew" in part.lower():
                # Homebrew: next component is the theme name.
                if i + 1 < len(parts):
                    theme_name = parts[i + 1]
                    candidate = homebrew_root / theme_name
                    if candidate.exists() and candidate not in homebrew_candidates:
                        homebrew_candidates.append(candidate)
            else:
                # Stock: next component is the page folder (pXX).
                if i + 1 < len(parts):
                    page_name = parts[i + 1]
                    candidate = standard_root / part / page_name
                    if candidate.exists() and candidate not in stock_candidates:
                        stock_candidates.append(candidate)
            break  # only process the first sNN component per path

    # Prefer homebrew (it owns the page bkg.tgz we need to crop from).
    if homebrew_candidates:
        chosen = homebrew_candidates[0]
        if len(homebrew_candidates) > 1:
            print(
                f"  (infer_src_theme) multiple homebrew candidates found: "
                + ", ".join(c.name for c in homebrew_candidates)
                + f" — using first: {chosen.name}"
            )
        return chosen

    if stock_candidates:
        chosen = stock_candidates[0]
        if len(stock_candidates) > 1:
            print(
                f"  (infer_src_theme) multiple stock page candidates: "
                + ", ".join(f"{c.parent.name}/{c.name}" for c in stock_candidates)
                + f" — using first: {chosen.parent.name}/{chosen.name}"
            )
        print(
            f"  (infer_src_theme) no homebrew theme found — "
            f"using stock page {chosen.parent.name}/{chosen.name}"
        )
        return chosen

    raise SystemExit(
        f"Could not resolve source theme from any PATH_REL_AiM_MASK in "
        f"{zconf_path.name}.\n"
        f"  Checked {len(all_masks)} mask path(s); none matched a known\n"
        f"  homebrew or stock theme folder under:\n"
        f"    {standard_root}\n"
        "Use --src-theme to specify the source theme folder explicitly."
    )


# ---- Legacy module-level path constants (used by internal helpers below) ---
# These are overwritten by main() before any helper is called. Defined here
# so the module can be imported and tested without side effects.
RS3_OVERLAY_ROOT: Path = Path(".")
RS3_HOMEBREW:     Path = Path(".")
SRC_THEME:        Path = Path(".")
DST_THEME:        Path = Path(".")
SRC_ZCONF:        Path = Path(".")
DST_ZCONF:        Path = Path(".")
NEW_GETTEXT:      str  = "Invisible Chrome"
OLD_THEME_NAME:   str  = "zenergimaracing"
NEW_THEME_NAME:   str  = "zinvisible"
OVERLAY_TREE_ROOT: Path = Path(r"C:\AIM_SPORT\RaceStudio3")


# ---- 1. Read canonical slot table from source theme's pMain.P2.xml --------
def read_canonical_slots(theme_dir: Path):
    """Return ordered list of dicts: [{slot_id, mdN, xpos, ypos, width, height}, ...]"""
    pmain = (theme_dir / "pMain.P2.xml").read_text(encoding="utf-8")
    slots = []
    for m in re.finditer(
        r'<e c="GMaskParam"\s+i="(md\.\d+)">(.*?)</e>',
        pmain,
        re.DOTALL,
    ):
        slot_id = m.group(1)
        body = m.group(2)
        xpos = int(re.search(r'<p n="XPOS">(\d+)</p>', body).group(1))
        ypos = int(re.search(r'<p n="YPOS">(\d+)</p>', body).group(1))
        path_m = re.search(r'<p n="PATH_NAME_REL_AiM_MASK">([^<]+)</p>', body)
        if not path_m:
            continue
        rel = path_m.group(1).strip()
        mdN_name = Path(rel).parent.name
        items_dir = theme_dir / "Items" / mdN_name
        w, h = canonical_dims(items_dir, mdN_name)
        m2_path = items_dir / f"{mdN_name}.M2.xml"
        uses_mask_tgz = False
        if m2_path.exists():
            m2txt = m2_path.read_text(encoding="cp1252")
            sfondo = re.search(r'<p n="PATH_IMAGE_SFONDO_FW">([^<]*)</p>', m2txt)
            uses_mask_tgz = bool(sfondo and sfondo.group(1).strip() == "mask.tgz")
        slots.append(
            {
                "slot_id": slot_id,
                "mdN_name": mdN_name,
                "xpos": xpos,
                "ypos": ypos,
                "width": w,
                "height": h,
                "uses_mask_tgz": uses_mask_tgz,
            }
        )
    return slots


def canonical_dims(items_dir: Path, mdN_name: str) -> tuple[int, int]:
    """Read the canonical WIDTH, HEIGHT of an mdN folder. Prefer mask.png
    (the RS3 preview; always matches mask.tgz dims when both exist), fall
    back to M2.xml's WIDTH/HEIGHT."""
    png = items_dir / "mask.png"
    if png.exists():
        with Image.open(png) as im:
            return im.size
    m2 = items_dir / f"{mdN_name}.M2.xml"
    if m2.exists():
        txt = m2.read_text(encoding="cp1252")
        w = int(re.search(r'<p n="WIDTH">(\d+)</p>', txt).group(1))
        h = int(re.search(r'<p n="HEIGHT">(\d+)</p>', txt).group(1))
        return w, h
    raise FileNotFoundError(f"Cannot determine canonical dims for {items_dir}")


# ---- 2. Build the new theme directory on disk -----------------------------
def _safe_rmtree(p: Path, *, must_succeed: bool = False) -> bool:
    """Remove p recursively.

    If must_succeed=True, raise RuntimeError (with a human-readable hint) if
    any file in the tree cannot be deleted. This is used for the output theme
    folder so stale content from a previous run can never bleed into the new
    output.

    Returns True when the tree was removed (or did not exist), False on partial
    failure (only when must_succeed=False).
    """
    if not p.exists():
        return True
    try:
        shutil.rmtree(p)
        return True
    except (PermissionError, OSError):
        # File-by-file fallback — needed when RS3 holds individual files open
        # while the containing folder can still be traversed.
        failed: list[str] = []
        for root, dirs, files in os.walk(p, topdown=False):
            for f in files:
                fp = Path(root) / f
                try:
                    fp.unlink()
                except (PermissionError, OSError) as exc:
                    failed.append(f"{fp}: {exc}")
            for d in dirs:
                dp = Path(root) / d
                try:
                    dp.rmdir()
                except (PermissionError, OSError):
                    pass  # will be re-tried at parent level
        try:
            p.rmdir()
        except (PermissionError, OSError) as exc:
            failed.append(f"{p}: {exc}")
        if failed:
            if must_succeed:
                hint = (
                    f"Cannot clean output folder:\n  {p}\n\n"
                    f"First failure: {failed[0]}\n\n"
                    f"RS3 may have the folder open. Close RaceStudio3, then retry.\n"
                    f"If the problem persists, check folder permissions on:\n  {p}"
                )
                raise RuntimeError(hint)
            return False
        return True


def _copy_overwrite(src_dir: Path, dst_dir: Path):
    locked_skipped = []
    for root, dirs, files in os.walk(src_dir):
        rel = Path(root).relative_to(src_dir)
        out_root = dst_dir / rel
        out_root.mkdir(parents=True, exist_ok=True)
        for f in files:
            src = Path(root) / f
            dst = out_root / f
            try:
                shutil.copy2(src, dst)
            except (PermissionError, OSError):
                if dst.exists():
                    locked_skipped.append(str(dst.relative_to(dst_dir)))
                else:
                    raise
    if locked_skipped:
        print(
            f"  (skipped {len(locked_skipped)} locked file(s) "
            f"that already exist: {', '.join(locked_skipped[:3])}"
            + (" ..." if len(locked_skipped) > 3 else "")
            + ")"
        )


def _write_canonical_mask_tgz_and_png(items_dir: Path, mdN_name: str,
                                      crop: Image.Image):
    """Force every pixel to alpha=255 then write mask.tgz + mask.png.

    Raises RuntimeError if the write fails — stale content from a previous run
    must never be silently inherited. If RS3 is holding the file open, the
    caller will surface a clear error asking the user to close RS3.
    """
    expected_w, expected_h = crop.size
    crop = crop.convert("RGBA").copy()
    px = crop.load()
    for cy in range(expected_h):
        for cx in range(expected_w):
            r, g, b, _ = px[cx, cy]
            px[cx, cy] = (r, g, b, 255)
    tgz_path = items_dir / "mask.tgz"
    png_path = items_dir / "mask.png"
    try:
        encode(crop, tgz_path)
    except (PermissionError, OSError) as exc:
        raise RuntimeError(
            f"Failed to write {tgz_path}:\n  {exc}\n\n"
            f"RS3 may have the file open. Close RaceStudio3 and retry."
        ) from exc
    # Post-write verification: decode the tgz back and confirm dimensions match.
    try:
        vw, vh, _ = decode(tgz_path)
    except Exception as exc:
        raise RuntimeError(
            f"Post-write verification failed for {tgz_path}:\n  {exc}\n\n"
            f"The encoded file could not be decoded. Check disk space / permissions."
        ) from exc
    if vw != expected_w or vh != expected_h:
        raise RuntimeError(
            f"Post-write dimension mismatch for {tgz_path}:\n"
            f"  wrote {expected_w}x{expected_h}, decoded back as {vw}x{vh}\n"
            f"The bkg_tgz encoder may have a rounding error. Do not proceed."
        )
    try:
        crop.save(png_path)
    except (PermissionError, OSError) as exc:
        raise RuntimeError(
            f"Failed to write {png_path}:\n  {exc}\n\n"
            f"RS3 may have the file open. Close RaceStudio3 and retry."
        ) from exc


def _copy_bargraph_state_images(src_items: Path, dst_dir: Path,
                                state_image_refs: set[str]) -> list[str]:
    """For a bargraph widget, copy bar-segment state graphics into dst_dir.

    v5.2 fix: Copy ONLY the specific filenames the widget's overlay block
    references (passed in `state_image_refs`, e.g. {'m4off.tgz', 'm4on0.tgz',
    'm2off.tgz', 'm2on0.tgz'}). For each referenced filename:
      a) Look in the widget's own source mdN folder first.
      b) If not found, look in sibling mdN folders under the SAME source
         template (e.g. s03/p17i/Items/md2/ for m2off.tgz when src_items is
         s03/p17i/Items/md4/). Do NOT walk siblings under different source
         templates — those may contain circular-gauge state graphics that
         look wildly different (analog dial appearing in test6/test7 was
         caused by exactly this).
      c) Also copy the .png sibling of each .tgz ref (e.g. m2off.png when
         m2off.tgz is referenced) — RS3 uses .png for preview.
      d) Always copy bargraph_prw.png if found in the source folder.

    Returns the list of basenames actually copied to dst_dir.
    """
    copied: dict[str, Path] = {}  # basename -> source path used

    def _maybe_copy(src_file: Path) -> bool:
        name = src_file.name
        if name in copied:
            return False
        if name.lower() in ("mask.tgz", "mask.png"):
            return False
        try:
            shutil.copy2(src_file, dst_dir / name)
            copied[name] = src_file
            return True
        except (PermissionError, OSError) as exc:
            # dst_dir is freshly created by emit_per_widget_folders, so a
            # permission error here is unexpected — print a warning rather
            # than silently dropping the file, so failures are visible.
            print(
                f"  WARNING: could not copy bargraph state image "
                f"{src_file.name} to {dst_dir}: {exc}"
            )
            return False

    # Build the full set of filenames we want: every referenced .tgz, its
    # .png sibling, plus bargraph_prw.png.
    wanted: set[str] = set()
    for ref in state_image_refs:
        wanted.add(ref)
        if ref.lower().endswith(".tgz"):
            wanted.add(ref[:-4] + ".png")
        elif ref.lower().endswith(".png"):
            wanted.add(ref[:-4] + ".tgz")
    wanted.add("bargraph_prw.png")

    # Sources to search: widget's own folder, then siblings under SAME Items/
    items_root = src_items.parent
    search_roots = [src_items]
    if items_root.exists() and items_root.is_dir():
        for sib in items_root.iterdir():
            if sib.is_dir() and sib != src_items:
                search_roots.append(sib)

    def _is_ring_source(folder: Path) -> bool:
        """True if folder's M2.xml declares <e c="Ring">. Such folders
        contain dial graphics that are wrong for linear bargraph widgets."""
        m2_files = list(folder.glob("*.M2.xml"))
        if not m2_files:
            return False
        try:
            txt = m2_files[0].read_text(encoding="cp1252", errors="replace")
        except OSError:
            return False
        return '<e c="Ring"' in txt

    def _bargraph_substitute(target_name: str) -> Path | None:
        """For a target filename like m2on0.tgz / m2off.tgz / m2on0.png /
        m2off.png, find an equivalent BargraphTrapezio segment graphic in
        src_items (the widget's own linear-bar source) and return that path.
        We substitute m4 -> m2 (and m4*.png -> m2*.png) because m4 is the
        widget's actual linear bar segment graphic. RS3 firmware reads
        mask.tgz by the filename in the wNN folder; it doesn't care what
        was originally at that name."""
        # Map e.g. m2on0.tgz -> m4on0.tgz; m2off.png -> m4off.png
        m = re.match(r'^m\d+(off|on\d*)\.(tgz|png)$', target_name, re.IGNORECASE)
        if not m:
            return None
        state_suffix = m.group(1)  # 'off' / 'on0' / 'on1' / ...
        ext = m.group(2)
        # Try m4-prefix in src_items
        candidate = src_items / f"m4{state_suffix}.{ext}"
        if candidate.exists() and candidate.is_file():
            return candidate
        # Generic: try any m{N} prefix in src_items
        for cand in src_items.glob(f"m*{state_suffix}.{ext}"):
            if cand.is_file():
                return cand
        return None

    for fname in wanted:
        copied_this_round = False
        # First pass: look for the file in BargraphTrapezio sources (not Ring)
        for root in search_roots:
            cand = root / fname
            if not (cand.exists() and cand.is_file()):
                continue
            if _is_ring_source(root):
                continue  # ring source — file is a dial graphic, skip for now
            _maybe_copy(cand)
            copied_this_round = True
            break
        if copied_this_round:
            continue
        # Second pass: substitute using a linear-bar segment from src_items
        sub = _bargraph_substitute(fname)
        if sub is not None:
            # Copy with destination renamed to the target filename so firmware
            # finds it under the expected name.
            try:
                shutil.copy2(sub, dst_dir / fname)
                copied[fname] = sub
                continue
            except (PermissionError, OSError) as exc:
                print(
                    f"  WARNING: bargraph substitute copy failed "
                    f"({sub.name} -> {fname}): {exc}"
                )
        # Third pass: fall back to a ring source — better than nothing
        for root in search_roots:
            cand = root / fname
            if cand.exists() and cand.is_file():
                _maybe_copy(cand)
                break

    return list(copied.keys())


def _patch_m2_xml(m2_path: Path, new_outer_id: str | None = None) -> str:
    """Ensure M2.xml declares PATH_IMAGE_SFONDO_FW=mask.tgz, and optionally
    retag the outer <e c="GMaskParam" i="..."> id. Returns a short status
    string like 'normalized' / 'injected' / 'no-M2'."""
    if not m2_path.exists():
        return "no-M2"
    m2 = m2_path.read_text(encoding="cp1252")
    if '<p n="PATH_IMAGE_SFONDO_FW">' in m2:
        m2 = re.sub(
            r'<p n="PATH_IMAGE_SFONDO_FW">[^<]*</p>',
            '<p n="PATH_IMAGE_SFONDO_FW">mask.tgz</p>',
            m2,
            count=1,
        )
        status = "normalized"
    else:
        # Inject after PATH_IMAGE_PRW if present, else right after <e c="GMaskParam" ...>
        if '<p n="PATH_IMAGE_PRW">' in m2:
            m2 = re.sub(
                r'(<p n="PATH_IMAGE_PRW">[^<]*</p>)',
                r'\g<1>' + "\n\t\t" + '<p n="PATH_IMAGE_SFONDO_FW">mask.tgz</p>',
                m2,
                count=1,
            )
        else:
            m2 = re.sub(
                r'(<e c="GMaskParam"\s+i="[^"]*">)',
                r'\g<1>' + "\n\t\t" + '<p n="PATH_IMAGE_SFONDO_FW">mask.tgz</p>',
                m2,
                count=1,
            )
        status = "injected"
    if new_outer_id is not None:
        m2 = re.sub(
            r'(<e c="GMaskParam"\s+i=")[^"]*(">)',
            rf'\g<1>{new_outer_id}\g<2>',
            m2,
            count=1,
        )
    m2_path.write_text(m2, encoding="cp1252")
    return status


def build_new_theme(src_dir: Path, dst_dir: Path, slots, new_gettext: str):
    """Clone src_dir -> dst_dir, overwrite mask.tgz files with canonical crops
    of the (new) page bkg, patch AiM_folder.xml gettext, clean cruft.
    Per-widget folders are added later by emit_per_widget_folders().

    IMPORTANT: dst_dir is force-deleted and recreated from scratch on every
    call.  Any stale content from a previous run (e.g. bkg.tgz / mask.tgz
    from a different source config) is guaranteed to be gone before we write
    new content.  If the deletion fails (RS3 holds a file open, permissions
    problem) we raise immediately with a clear message rather than silently
    inheriting old data.
    """
    if dst_dir.exists():
        print(f"  Force-cleaning output folder: {dst_dir}")
        _safe_rmtree(dst_dir, must_succeed=True)
        # Confirm the folder is truly gone before proceeding.
        if dst_dir.exists():
            raise RuntimeError(
                f"Cannot clean output folder (directory still present after rmtree):\n"
                f"  {dst_dir}\n\n"
                f"Close RaceStudio3, check folder permissions, and retry."
            )
    dst_dir.mkdir(parents=True, exist_ok=True)
    _copy_overwrite(src_dir, dst_dir)

    canonical_mdN_set = {s["mdN_name"] for s in slots}
    items_dir = dst_dir / "Items"
    if items_dir.exists():
        for sub in items_dir.iterdir():
            if sub.is_dir() and sub.name not in canonical_mdN_set:
                print(f"  removing non-canonical folder Items/{sub.name}/")
                _safe_rmtree(sub)
    for stray in list(dst_dir.glob("**/*_BACKUP")) + list(dst_dir.glob("*.BACKUP")):
        if stray.is_dir():
            _safe_rmtree(stray)
        else:
            try:
                stray.unlink()
            except (PermissionError, OSError):
                pass

    bkg_tgz = src_dir / "bkg.tgz"
    bkg_png  = src_dir / "bkg.png"
    if bkg_tgz.exists():
        bw, bh, page_bkg = decode(bkg_tgz)
        print(f"  Page bkg.tgz: {bw}x{bh} (from source)")
        # Always (re)copy bkg.tgz from source into dst so that the output
        # theme contains THIS source's page background, never a stale copy
        # from a previous run. (dst_dir was force-cleaned above, so this
        # copy_overwrite may have already placed it; explicit copy here is
        # a belt-and-suspenders guarantee.)
        dst_bkg_tgz = dst_dir / "bkg.tgz"
        shutil.copy2(bkg_tgz, dst_bkg_tgz)
        # Verify the destination bkg.tgz decodes to the same dimensions.
        vbw, vbh, _ = decode(dst_bkg_tgz)
        if vbw != bw or vbh != bh:
            raise RuntimeError(
                f"bkg.tgz copy verification failed:\n"
                f"  source {bw}x{bh} but decoded destination is {vbw}x{vbh}.\n"
                f"Check disk space / write permissions on {dst_bkg_tgz.parent}."
            )
    elif bkg_png.exists():
        page_bkg = Image.open(bkg_png)
        bw, bh = page_bkg.size
        print(f"  Page bkg.png: {bw}x{bh} (source has no bkg.tgz — encoding now)")
        # Encode bkg.tgz into dst so firmware finds it.
        encode(page_bkg, dst_dir / "bkg.tgz")
    else:
        raise FileNotFoundError(
            f"Source theme folder not found on this RS3 install:\n"
            f"  {src_dir}\n\n"
            "This usually means the config was generated against a homebrew\n"
            "theme that isn't installed locally (e.g. zinvisible or another\n"
            "derived theme). Try running against the original config the\n"
            "homebrew theme was forked from (e.g. an Energima Racing /\n"
            "standard theme config).\n\n"
            "If the folder exists but has no bkg.tgz / bkg.png, the inferred\n"
            "theme may be an already-generated invisible-chrome output rather\n"
            "than a source theme. Open the original config and try again."
        )
    page_rgba = page_bkg.convert("RGBA")

    seen = set()
    unique = []
    for s in slots:
        if s["mdN_name"] in seen:
            continue
        seen.add(s["mdN_name"])
        unique.append(s)

    for s in unique:
        out_items_dir = dst_dir / "Items" / s["mdN_name"]
        out_items_dir.mkdir(parents=True, exist_ok=True)
        x = max(0, min(s["xpos"], bw - 1))
        y = max(0, min(s["ypos"], bh - 1))
        w = max(1, min(s["width"], bw - x))
        h = max(1, min(s["height"], bh - y))
        crop = page_rgba.crop((x, y, x + w, y + h)).copy()
        _write_canonical_mask_tgz_and_png(out_items_dir, s["mdN_name"], crop)
        status = _patch_m2_xml(out_items_dir / f"{s['mdN_name']}.M2.xml")
        print(
            f"  {s['slot_id']:>6} -> Items/{s['mdN_name']}/mask.tgz "
            f"({w}x{h} @ {x},{y})  M2={status}"
        )

    # Patch AiM_folder.xml gettext
    af = dst_dir / "AiM_folder.xml"
    txt = af.read_text(encoding="utf-8")
    txt = re.sub(
        r'(<p n="gettext">)[^<]+(</p>)',
        rf"\g<1>{new_gettext}\g<2>",
        txt,
        count=1,
    )
    af.write_text(txt, encoding="utf-8")
    print(f"  AiM_folder.xml gettext -> '{new_gettext}'")

    return bw, bh, page_rgba


# ---- 3. Enumerate top-level GMaskParam children of Page 3 -----------------
def _find_matching_close(txt: str, open_start: int) -> int:
    """Given txt and the index where '<e c="GMaskParam" i="...">' starts,
    return the index just past the matching </e>. Handles nested <e ...>...</e>.

    Approach: scan forward, tracking depth on every <e ...> open and </e> close.
    Self-closing <e .../> tags are not counted.
    """
    # Move past the opening tag.
    tag_end = txt.find(">", open_start)
    if tag_end < 0:
        return -1
    depth = 1
    i = tag_end + 1
    while i < len(txt) and depth > 0:
        # find next <e ...> or </e>
        next_open = txt.find("<e ", i)
        next_close = txt.find("</e>", i)
        if next_close < 0:
            return -1
        if next_open >= 0 and next_open < next_close:
            # Is it self-closing?
            close_gt = txt.find(">", next_open)
            if close_gt < 0:
                return -1
            tag = txt[next_open:close_gt + 1]
            if tag.rstrip().endswith("/>"):
                # self-closing — does not increase depth
                i = close_gt + 1
            else:
                depth += 1
                i = close_gt + 1
        else:
            depth -= 1
            i = next_close + len("</e>")
    return i


def enumerate_page3_widgets(overlay_xml: str) -> list[dict]:
    """Return a list of widget dicts in order of appearance inside Page 3:
        {span_start, span_end, slot_attr_i, xpos, ypos, path_rel}
    span_start/span_end delimit the full <e c="GMaskParam" i="X">...</e>
    inside Page 3.
    """
    p3 = re.search(r'<e c="GPageParam"\s+i="2"\s*>', overlay_xml)
    if not p3:
        return []
    p3_start = p3.start()
    p3_end = _find_matching_close(overlay_xml, p3_start)
    if p3_end < 0:
        return []
    widgets = []
    cursor = p3.end()
    # Walk depth-1 children: every "<e c=..." we hit at depth 1 is a child of
    # GPageParam. We find each top-level GMaskParam by skipping over nested
    # closings.
    i = cursor
    while i < p3_end:
        # find next top-level <e ...> tag
        next_e = overlay_xml.find("<e ", i)
        if next_e < 0 or next_e >= p3_end:
            break
        # this is a depth-1 open. Read attrs.
        tag_close = overlay_xml.find(">", next_e)
        if tag_close < 0 or tag_close >= p3_end:
            break
        opening = overlay_xml[next_e:tag_close + 1]
        if opening.rstrip().endswith("/>"):
            i = tag_close + 1
            continue
        # find matching </e>
        end_of_child = _find_matching_close(overlay_xml, next_e)
        if end_of_child < 0 or end_of_child > p3_end:
            break
        child_xml = overlay_xml[next_e:end_of_child]
        # only interested in GMaskParam
        cm = re.match(r'<e c="GMaskParam"\s+i="([^"]+)"\s*>', opening)
        if cm:
            slot_i = cm.group(1)
            # Extract XPOS and YPOS at TOP level of the child (not nested).
            # The challenge: child_xml has <p n="XPOS">...</p> at top level AND
            # possibly inside nested <e c="Digit">/<e c="Label"> blocks. We
            # need to scan only the top-level <p> tags. Strip nested <e>...</e>
            # blocks first, then regex the remainder.
            body = _strip_nested_e_blocks(child_xml)
            xpos_m = re.search(r'<p n="XPOS">(-?\d+)</p>', body)
            ypos_m = re.search(r'<p n="YPOS">(-?\d+)</p>', body)
            path_m = re.search(r'<p n="PATH_REL_AiM_MASK">([^<]*)</p>', body)
            # Detect bargraph widgets — they contain a <e c="BargraphTrapezio">
            # child nested inside the GMaskParam. Detect from the FULL child
            # XML (not the stripped body).
            has_bargraph = '<e c="BargraphTrapezio"' in child_xml
            # Collect state image filenames ONLY from the primary UDM block
            # (SCELTA_UTENTE=00). Alternate-display UDMs (SCELTA_UTENTE=01+)
            # often point at a completely different widget type — e.g. a
            # ring/dial graphic from md2 — and pulling those state images
            # into a linear-bargraph wNN folder makes firmware render an
            # analog gauge over the bar. Found 2026-05-12 after test7/test8
            # showed a 360x280 ring graphic from md2 (Page 1's RPM dial)
            # leaking into Page 3's trim widgets.
            state_image_refs = set()
            if has_bargraph:
                # Collect EVERY state image filename referenced in the
                # widget block. Don't filter here — some widgets (e.g.
                # Ch03 in this config) reference m2on0.tgz as their PRIMARY
                # bar segment, not as a ring. The filter happens later in
                # _copy_bargraph_state_images, where we inspect each
                # source folder's M2.xml: if it's a Ring (= the file is a
                # dial graphic, wrong for our linear bar), we substitute
                # using the widget's own BargraphTrapezio m4 equivalent.
                for tag in ("PATH_IMG_ON", "PATH_IMG_OFF", "PATH_IMAGE"):
                    for fm in re.finditer(
                        rf'<p n="{tag}">([^<]+)</p>', child_xml
                    ):
                        v = fm.group(1).strip()
                        if not v or v in ("mask.png", "mask.tgz"):
                            continue
                        if "/" in v or "\\" in v:
                            continue
                        state_image_refs.add(v)
            widgets.append({
                "span_start": next_e,
                "span_end": end_of_child,
                "slot_attr_i": slot_i,
                "xpos": int(xpos_m.group(1)) if xpos_m else None,
                "ypos": int(ypos_m.group(1)) if ypos_m else None,
                "path_rel": (path_m.group(1).strip() if path_m else "") or "",
                "has_bargraph": has_bargraph,
                "state_image_refs": state_image_refs,
            })
        i = end_of_child
    return widgets


def _strip_nested_e_blocks(child_xml: str) -> str:
    """Return child_xml with every nested <e ...>...</e> (and self-closing
    <e .../>) removed, but the wrapping outermost <e c="GMaskParam">...</e>
    preserved. This makes top-level <p> extraction safe."""
    # The outer wrapper is the first <e ...> and the last </e>. Inner content
    # is between them. We walk the inner range and remove every depth-1
    # <e ...>...</e> block found there.
    first_open = child_xml.find(">")
    last_close = child_xml.rfind("</e>")
    if first_open < 0 or last_close < 0:
        return child_xml
    head = child_xml[:first_open + 1]
    inner = child_xml[first_open + 1:last_close]
    tail = child_xml[last_close:]
    out = []
    i = 0
    while i < len(inner):
        nxt = inner.find("<e ", i)
        if nxt < 0:
            out.append(inner[i:])
            break
        out.append(inner[i:nxt])
        # find matching close (or self-close)
        tag_close = inner.find(">", nxt)
        if tag_close < 0:
            break
        opening = inner[nxt:tag_close + 1]
        if opening.rstrip().endswith("/>"):
            i = tag_close + 1
            continue
        # find matching </e> respecting nested depth
        depth = 1
        j = tag_close + 1
        while j < len(inner) and depth > 0:
            no = inner.find("<e ", j)
            nc = inner.find("</e>", j)
            if nc < 0:
                j = len(inner)
                break
            if no >= 0 and no < nc:
                jclose = inner.find(">", no)
                if jclose < 0:
                    j = len(inner)
                    break
                o = inner[no:jclose + 1]
                if not o.rstrip().endswith("/>"):
                    depth += 1
                j = jclose + 1
            else:
                depth -= 1
                j = nc + len("</e>")
        i = j
    return head + "".join(out) + tail


# ---- 4. Emit per-widget folders + retarget Page-3 widget refs -------------
def resolve_source_items_dir(path_rel: str) -> Path | None:
    """Map an XML path like
       aim/resources/overlay/mxg_12 strada/masks/set/standard/s03/p17i/Items/md4/
    to an absolute folder on disk under OVERLAY_TREE_ROOT.
    Returns None if path_rel is empty or the folder doesn't exist."""
    if not path_rel:
        return None
    pp = path_rel.strip().strip("/")
    p = OVERLAY_TREE_ROOT / pp
    if p.exists() and p.is_dir():
        return p
    return None


def emit_per_widget_folders(dst_theme: Path, widgets: list[dict],
                            page_rgba: Image.Image,
                            bw: int, bh: int) -> dict:
    """For each Page-3 widget, materialise a unique zinvisible/Items/wNN/
    folder with mask.tgz cropped at the widget's slot (XPOS,YPOS) and
    sized by the SOURCE mdN's canonical WIDTH/HEIGHT. Return a map
    {widget_index_in_page3: new_path_rel_value} so the overlay rewriter
    can patch the refs.
    """
    out_map = {}
    items_root = dst_theme / "Items"
    items_root.mkdir(parents=True, exist_ok=True)

    for idx, w in enumerate(widgets):
        path_rel = w["path_rel"]
        # Widgets without a PATH_REL_AiM_MASK are label-only / no chrome —
        # skip them. Also skip widgets that already live in zinvisible (the
        # earlier substring step might already have rewritten them).
        if not path_rel:
            out_map[idx] = None
            continue
        src_items = resolve_source_items_dir(path_rel)
        if src_items is None:
            print(
                f"  w{idx:02d} ({w['slot_attr_i']}): could not resolve source "
                f"items dir from {path_rel!r} — leaving unchanged"
            )
            out_map[idx] = None
            continue
        # Canonical WIDTH/HEIGHT of source mdN
        src_mdN_name = src_items.name
        try:
            cw, ch = canonical_dims(src_items, src_mdN_name)
        except FileNotFoundError:
            print(
                f"  w{idx:02d} ({w['slot_attr_i']}): no mask.png + no M2.xml "
                f"WIDTH/HEIGHT in {src_items} — skipping"
            )
            out_map[idx] = None
            continue

        # Crop bkg at (slot_XPOS, slot_YPOS) sized (canonical_W, canonical_H)
        sx = w["xpos"] or 0
        sy = w["ypos"] or 0
        x = max(0, min(sx, bw - 1))
        y = max(0, min(sy, bh - 1))
        ww = max(1, min(cw, bw - x))
        hh = max(1, min(ch, bh - y))
        crop = page_rgba.crop((x, y, x + ww, y + hh)).copy()

        # Per-widget folder name. Use w00, w01... — doesn't collide with mdN.
        folder_name = f"w{idx:02d}"
        out_dir = items_root / folder_name
        if out_dir.exists():
            _safe_rmtree(out_dir, must_succeed=True)
        out_dir.mkdir(parents=True, exist_ok=True)

        # mask.tgz + mask.png with forced alpha=255
        _write_canonical_mask_tgz_and_png(out_dir, folder_name, crop)

        # Copy source M2.xml verbatim then patch:
        #  - inject/normalize PATH_IMAGE_SFONDO_FW=mask.tgz
        #  - retag outer <e c="GMaskParam" i="..."> id to the new folder name
        src_m2 = src_items / f"{src_mdN_name}.M2.xml"
        dst_m2 = out_dir / f"{folder_name}.M2.xml"
        if src_m2.exists():
            shutil.copy2(src_m2, dst_m2)
            status = _patch_m2_xml(dst_m2, new_outer_id=folder_name)
        else:
            status = "no-source-M2"

        # ---- Bargraph state-image copy ---------------------------------
        # If the widget XML contains a <e c="BargraphTrapezio">, copy bar
        # segment graphics (m?off/m?on?) from the source mdN folder AND
        # every sibling Items/mdN/ folder so all referenced state images
        # are present in the firmware mask folder. The XML references
        # both m4on0.tgz (typically in md4) and m2on0.tgz (typically in
        # md2), so we have to look across siblings.
        bar_status = ""
        if w.get("has_bargraph"):
            copied = _copy_bargraph_state_images(
                src_items, out_dir, w.get("state_image_refs") or set()
            )
            bar_status = f"  bar_state={len(copied)} ({','.join(sorted(copied)[:4])}{'...' if len(copied) > 4 else ''})"

        new_path_rel = (
            f"aim/resources/overlay/mxg_12 strada/masks/set/standard/"
            f"s04_homebrew/{dst_theme.name}/Items/{folder_name}/"
        )
        out_map[idx] = new_path_rel
        print(
            f"  w{idx:02d} ({w['slot_attr_i']}): "
            f"{ww}x{hh} @ ({x},{y})  src={src_mdN_name}  M2={status}"
            f"{bar_status}"
        )

    return out_map


# ---- 5. Repack the .zconf2 with patched theme refs -----------------------
def _retarget_page3_per_widget(overlay_xml: str, widgets: list[dict],
                               widget_path_map: dict) -> tuple[str, int]:
    """Rewrite Page-3 widget refs: replace each widget's PATH_REL_AiM_MASK
    with the per-widget path (when one was emitted). Patches are applied
    from the END of the file towards the start so earlier patches don't
    invalidate later spans.
    """
    count = 0
    # Sort widgets by descending span_start so later patches don't shift
    # earlier indices.
    indexed = sorted(enumerate(widgets), key=lambda kv: -kv[1]["span_start"])
    out = overlay_xml
    for idx, w in indexed:
        new_path = widget_path_map.get(idx)
        if not new_path:
            continue
        s, e = w["span_start"], w["span_end"]
        widget_xml = out[s:e]
        new_xml, n = re.subn(
            r'<p n="PATH_REL_AiM_MASK">[^<]*</p>',
            f'<p n="PATH_REL_AiM_MASK">{new_path}</p>',
            widget_xml,
        )
        if n > 0:
            count += n
            out = out[:s] + new_xml + out[e:]
        else:
            # No PATH_REL_AiM_MASK in widget — inject one right before </e>
            # of the outer GMaskParam. Find the LAST </e> in widget_xml.
            last_close = widget_xml.rfind("</e>")
            if last_close > 0:
                inject = (
                    "\n\t\t  "
                    + f'<p n="PATH_REL_AiM_MASK">{new_path}</p>'
                    + "\n\t\t"
                )
                new_xml = widget_xml[:last_close] + inject + widget_xml[last_close:]
                out = out[:s] + new_xml + out[e:]
                count += 1
    return out, count


def _patch_page3_axis_channel_bound(overlay_xml: str) -> tuple[str, int, list]:
    """Scan Page 3's <e c="Axis"> children. For each Axis where:
        - <p n="fid"> is a non-empty positive integer
        - <p n="FID_OK"> is non-empty
        - <p n="CHANNEL_NOT_SET"> exists and is currently 1
    force CHANNEL_NOT_SET=0 so RS3 doesn't drop the binding on save.

    Returns (new_xml, n_patched, diag) where diag is a list of strings
    documenting what was inspected and what changed.
    """
    p3 = re.search(r'<e c="GPageParam"\s+i="2"\s*>', overlay_xml)
    if not p3:
        return overlay_xml, 0, ["Page 3 not found"]
    p3_start = p3.start()
    p3_end = _find_matching_close(overlay_xml, p3_start)
    if p3_end < 0:
        return overlay_xml, 0, ["Page 3 close not found"]

    diag: list[str] = []
    edits: list[tuple[int, int, str]] = []  # (start, end, replacement)

    # Find every <e c="Axis" ...> inside p3 range, treating any depth.
    for am in re.finditer(r'<e c="Axis"\s+i="[^"]*"\s*>', overlay_xml[p3_start:p3_end]):
        ax_open_start = p3_start + am.start()
        ax_end = _find_matching_close(overlay_xml, ax_open_start)
        if ax_end < 0 or ax_end > p3_end:
            continue
        ax_xml = overlay_xml[ax_open_start:ax_end]
        # body excludes nested <e>...</e>
        body = _strip_nested_e_blocks(ax_xml)
        fid_m = re.search(r'<p n="fid">(-?\d+)</p>', body)
        fid_ok_m = re.search(r'<p n="FID_OK">([^<]*)</p>', body)
        cns_m = re.search(r'<p n="CHANNEL_NOT_SET">(\d+)</p>', body)
        fid_val = int(fid_m.group(1)) if fid_m else None
        fid_ok_val = (fid_ok_m.group(1).strip() if fid_ok_m else "")
        cns_val = int(cns_m.group(1)) if cns_m else None
        # Bound = a real channel assignment exists.
        bound = (fid_val is not None and fid_val > 0 and fid_ok_val != "")
        if not bound:
            diag.append(
                f"    Axis fid={fid_val!r} FID_OK={fid_ok_val!r} "
                f"CHANNEL_NOT_SET={cns_val!r} — unbound, leaving alone"
            )
            continue
        if cns_val is None:
            diag.append(
                f"    Axis fid={fid_val} FID_OK={fid_ok_val!r} "
                f"— no CHANNEL_NOT_SET field, leaving alone"
            )
            continue
        if cns_val == 0:
            diag.append(
                f"    Axis fid={fid_val} FID_OK={fid_ok_val!r} "
                f"CHANNEL_NOT_SET=0 — already bound"
            )
            continue
        # Edit: replace the CHANNEL_NOT_SET line inside ax_xml.
        new_ax_xml, nsub = re.subn(
            r'<p n="CHANNEL_NOT_SET">\d+</p>',
            '<p n="CHANNEL_NOT_SET">0</p>',
            ax_xml,
            count=1,
        )
        if nsub > 0:
            edits.append((ax_open_start, ax_end, new_ax_xml))
            diag.append(
                f"    Axis fid={fid_val} FID_OK={fid_ok_val!r} "
                f"CHANNEL_NOT_SET={cns_val} -> 0 (binding preserved)"
            )

    # Apply edits from END to START so indices stay valid.
    out = overlay_xml
    for s, e, repl in sorted(edits, key=lambda t: -t[0]):
        out = out[:s] + repl + out[e:]
    return out, len(edits), diag


def _suppress_page3_prepathxmask(overlay_xml: str) -> tuple[str, int]:
    """For every Page-3 GMaskParam block, force `USA_PREPATHXMASK` to 0.

    Hypothesis (v5.6, 2026-05-12): USA_PREPATHXMASK=1 tells firmware to
    prepend a default chrome-decoration mask path around each widget,
    producing the 5-10 px chrome shadow we still see at widget edges
    after the invisible-chrome bkg crops are applied. Setting it to 0
    disables that firmware-side default chrome.

    Returns (patched_xml, count_modified).
    """
    p3_start = overlay_xml.find('<e c="GPageParam" i="2"')
    if p3_start < 0:
        return overlay_xml, 0
    next_page = re.search(r'<e c="GPageParam" i="3"', overlay_xml[p3_start + 1:])
    p3_end = p3_start + 1 + next_page.start() if next_page else len(overlay_xml)
    p3 = overlay_xml[p3_start:p3_end]
    gp_starts = [mm.start() for mm in re.finditer(r'<e c="GMaskParam"\s+i="', p3)]
    gp_starts.append(len(p3))

    edits = []
    count = 0
    for i in range(len(gp_starts) - 1):
        block_start = gp_starts[i]
        block_end = gp_starts[i + 1]
        block = p3[block_start:block_end]
        flag_m = re.search(
            r'<p n="USA_PREPATHXMASK">([^<]*)</p>', block
        )
        if not flag_m:
            continue
        if flag_m.group(1).strip() == "0":
            continue
        new_block = (
            block[: flag_m.start()]
            + '<p n="USA_PREPATHXMASK">0</p>'
            + block[flag_m.end():]
        )
        edits.append((p3_start + block_start, p3_start + block_end, new_block))
        count += 1

    if not edits:
        return overlay_xml, 0
    out = overlay_xml
    for s, e, repl in sorted(edits, key=lambda t: -t[0]):
        out = out[:s] + repl + out[e:]
    return out, count


def _suppress_page3_label_chrome(overlay_xml: str) -> tuple[str, int]:
    """Find Page-3 GMaskParam blocks that are label-only AND have no
    PATH_REL_AiM_MASK, then force their USA_MASK_COL_E_THICKNESS to 0.

    Such widgets are user-added text annotations (e.g. "PRT RPM" labels
    under the big values) that get drawn with a firmware chrome rectangle
    around them by default. Setting the flag to 0 removes the rectangle
    without needing to invent a wNN mask folder for them.

    Returns (patched_xml, count_modified).
    """
    p3_start = overlay_xml.find('<e c="GPageParam" i="2"')
    if p3_start < 0:
        return overlay_xml, 0
    # Bound Page 3
    next_page = re.search(r'<e c="GPageParam" i="3"', overlay_xml[p3_start + 1:])
    p3_end = p3_start + 1 + next_page.start() if next_page else len(overlay_xml)

    # Walk top-level GMaskParam blocks inside Page 3
    p3 = overlay_xml[p3_start:p3_end]
    gp_starts = [mm.start() for mm in re.finditer(r'<e c="GMaskParam"\s+i="', p3)]
    gp_starts.append(len(p3))

    edits = []  # list of (abs_start, abs_end, replacement)
    count = 0
    for i in range(len(gp_starts) - 1):
        block_start = gp_starts[i]
        block_end = gp_starts[i + 1]
        block = p3[block_start:block_end]
        # Qualifier 1: empty PATH_REL_AiM_MASK (handle both self-closing
        # `<p n="X" />` and open-close `<p n="X"></p>` forms)
        path_open_close = re.search(r'<p n="PATH_REL_AiM_MASK">([^<]*)</p>', block)
        path_self_close = re.search(r'<p n="PATH_REL_AiM_MASK"\s*/>', block)
        if path_open_close:
            if path_open_close.group(1).strip():
                continue  # non-empty path -> not a label-only widget
        elif path_self_close:
            pass  # self-closing tag = empty
        else:
            continue  # no PATH_REL_AiM_MASK at all -> skip
        # Qualifier 2: contains a <e c="Label"> child
        if '<e c="Label"' not in block:
            continue
        # Find USA_MASK_COL_E_THICKNESS and force it to 0 if currently non-zero
        flag_m = re.search(
            r'<p n="USA_MASK_COL_E_THICKNESS">([^<]*)</p>', block
        )
        if not flag_m:
            continue
        if flag_m.group(1).strip() == "0":
            continue
        new_block = (
            block[: flag_m.start()]
            + '<p n="USA_MASK_COL_E_THICKNESS">0</p>'
            + block[flag_m.end():]
        )
        abs_start = p3_start + block_start
        abs_end = p3_start + block_end
        edits.append((abs_start, abs_end, new_block))
        count += 1

    if not edits:
        return overlay_xml, 0
    out = overlay_xml
    for s, e, repl in sorted(edits, key=lambda t: -t[0]):
        out = out[:s] + repl + out[e:]
    return out, count


def repack_zconf(src_zconf: Path, dst_zconf: Path, old_theme: str, new_theme: str,
                 dst_theme_dir: Path,
                 bw: int, bh: int, page_rgba: Image.Image):
    if dst_zconf.exists():
        dst_zconf.unlink()
    with tempfile.TemporaryDirectory() as td:
        tdp = Path(td)
        with zipfile.ZipFile(src_zconf, "r") as z:
            z.extractall(tdp)
        overlay_paths = list(tdp.rglob("overlay.ov3.xml"))
        if not overlay_paths:
            raise SystemExit("overlay.ov3.xml not found in source zconf2")
        for overlay_path in overlay_paths:
            txt = overlay_path.read_text(encoding="utf-8")
            # Enumerate Page-3 widgets BEFORE any substring replace (so spans
            # are anchored in the original file).
            widgets = enumerate_page3_widgets(txt)
            print(f"  Found {len(widgets)} Page-3 GMaskParam widget(s)")
            # Emit per-widget folders + build widget_path_map
            widget_path_map = emit_per_widget_folders(
                dst_theme_dir, widgets, page_rgba, bw, bh,
            )
            # Apply per-widget retarget on Page 3 (only those widgets we
            # emitted folders for).
            txt, n_p3 = _retarget_page3_per_widget(txt, widgets, widget_path_map)
            # Conservative substring replace for Pages 1+2 references
            # (without disturbing the now-already-rewritten Page-3 refs,
            # since per-widget paths point at /Items/wNN/ not /Items/mdN/).
            txt, n_sub = re.subn(
                rf"(s04_homebrew/){re.escape(old_theme)}(/)",
                rf"\g<1>{new_theme}\g<2>",
                txt,
            )
            # Preserve channel bindings for Page-3 Axis elements where
            # fid + FID_OK are valid but CHANNEL_NOT_SET=1 (RS3 would
            # otherwise throw "internal error" on save until the user
            # re-maps the channel by hand).
            print("  Inspecting Page-3 Axis bindings:")
            txt, n_axis, axis_diag = _patch_page3_axis_channel_bound(txt)
            for line in axis_diag:
                print(line)
            # Suppress chrome border on Page-3 label-only widgets that have
            # no mask folder. Custom labels (PRT RPM / STB RPM / SPEED text
            # annotations) get USA_MASK_COL_E_THICKNESS=1 by default, which
            # tells firmware to draw a chrome rectangle outline. Since we
            # can't make those invisible (they have no PATH_REL_AiM_MASK to
            # retarget at a wNN folder), just set the flag to 0.
            txt, n_label = _suppress_page3_label_chrome(txt)
            # NOTE: v5.6 experiment with USA_PREPATHXMASK=0 caused RS3
            # internal-error on save (the field is required by RS3
            # validation). Reverted — the 5-10 px chrome edge shadow at
            # widget borders is not controllable through this flag.
            # If we ever want to revisit, the suppress fn is preserved
            # below but no longer called.
            overlay_path.write_text(txt, encoding="utf-8")
            print(
                f"  {overlay_path.name}: "
                f"Page3 per-widget retargeted={n_p3}  "
                f"global substring (Pages1/2/canonical hosts)={n_sub}  "
                f"Page3 axis-binding patched={n_axis}  "
                f"Page3 label-chrome suppressed={n_label}"
            )
        with zipfile.ZipFile(dst_zconf, "w", zipfile.ZIP_DEFLATED) as zout:
            for root, _, files in os.walk(tdp):
                for fn in files:
                    fp = Path(root) / fn
                    zout.write(fp, fp.relative_to(tdp).as_posix())
    print(f"  wrote: {dst_zconf}")


# ---- 6. Main -------------------------------------------------------------

def _parse_args(argv=None):
    """Build and return the argparse parser (shared between main() and Qt's
    QProcess call so both get identical behaviour)."""
    parser = argparse.ArgumentParser(
        prog="make_invisible_chrome.py",
        description=(
            "Generate an Invisible Chrome .dashtheme from an existing AIM\n"
            ".zconf2 file. The script crops each widget's background slice\n"
            "from the page bkg.tgz and replaces the theme's mask.tgz files\n"
            "with transparent-border versions, making the firmware chrome\n"
            "(decorative borders around dash elements) visually invisible.\n"
            "\n"
            "IMPORTANT — mask.tgz canonical dimensions:\n"
            "  Each mdN/wNN folder's mask.tgz must match the canonical\n"
            "  dimensions of the source mdN slot.  DashEditor reads these\n"
            "  from the slot's mask.png (or M2.xml WIDTH/HEIGHT fallback).\n"
            "  Do NOT resize mask.tgz manually — the firmware relies on\n"
            "  exact pixel dimensions for overlay compositing.\n"
            "  Canonical slot sizes for common MXG 1.2 Strada slots:\n"
            "    md4  (Speed bar): 300x90\n"
            "    md11 (Gear):      200x180\n"
            "    md6  (Trim):       30x150\n"
            "  (Run with --device mxg_12 and inspect output for your config.)\n"
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--input", "-i", required=True, metavar="ZCONF2",
        help="Source .zconf2 file to generate invisible chrome from.",
    )
    parser.add_argument(
        "--output", "-o", required=True, metavar="OUTPUT",
        help=(
            "Destination path for the output file. Use a .zconf2 extension\n"
            "to produce an AIM-compatible config, or .dashtheme to produce\n"
            "a DashEditor portable theme bundle."
        ),
    )
    parser.add_argument(
        "--device", "-d", required=True, metavar="DEVICE",
        help=(
            "Target device family. Controls which RS3 overlay tree is used\n"
            "to resolve mask dimensions and the homebrew set location.\n"
            "Supported values: "
            + ", ".join(sorted(DEVICE_OVERLAY_MAP.keys()))
        ),
    )
    parser.add_argument(
        "--src-theme", metavar="DIR",
        help=(
            "Path to the source homebrew theme directory (must contain\n"
            "pMain.P2.xml and Items/). If omitted, the script attempts to\n"
            "infer it from the input zconf2's PATH_REL_AiM_MASK."
        ),
    )
    parser.add_argument(
        "--dst-theme", metavar="DIR",
        help=(
            "Path where the new invisible theme directory will be written.\n"
            "Default: <rs3_homebrew_root>/zinvisible"
        ),
    )
    parser.add_argument(
        "--theme-name", metavar="NAME", default="Invisible Chrome",
        help=(
            "Display name written into AiM_folder.xml gettext field.\n"
            "Default: 'Invisible Chrome'"
        ),
    )
    return parser


def main(argv=None):
    parser = _parse_args()
    args = parser.parse_args(argv)

    # Resolve all path constants from args.
    p = _build_paths_from_args(args)

    # Overwrite the module-level globals so the existing helper functions
    # (which reference them by name) work unchanged.
    global RS3_OVERLAY_ROOT, RS3_HOMEBREW, SRC_THEME, DST_THEME
    global SRC_ZCONF, DST_ZCONF, NEW_GETTEXT, OLD_THEME_NAME, NEW_THEME_NAME
    global OVERLAY_TREE_ROOT
    RS3_OVERLAY_ROOT  = p["RS3_OVERLAY_ROOT"]
    RS3_HOMEBREW      = p["RS3_HOMEBREW"]
    SRC_THEME         = p["SRC_THEME"]
    DST_THEME         = p["DST_THEME"]
    SRC_ZCONF         = p["SRC_ZCONF"]
    DST_ZCONF         = p["DST_ZCONF"]
    NEW_GETTEXT       = p["NEW_GETTEXT"]
    OLD_THEME_NAME    = p["OLD_THEME_NAME"]
    NEW_THEME_NAME    = p["NEW_THEME_NAME"]
    OVERLAY_TREE_ROOT = p["OVERLAY_TREE_ROOT"]

    print(f"Input:       {SRC_ZCONF}")
    print(f"Output:      {DST_ZCONF}")
    print(f"Device:      {args.device}")
    print(f"Src theme:   {SRC_THEME}")
    print(f"Dst theme:   {DST_THEME}")
    print(f"Theme name:  {NEW_GETTEXT}")
    print()

    if not SRC_ZCONF.exists():
        raise SystemExit(f"Input file not found: {SRC_ZCONF}")
    if not SRC_THEME.exists():
        raise SystemExit(
            f"Source theme folder not found on this RS3 install:\n"
            f"  {SRC_THEME}\n\n"
            "This usually means the config was generated against a homebrew\n"
            "theme that isn't installed locally (e.g. zinvisible or another\n"
            "derived theme). Try running against the original config the\n"
            "homebrew theme was forked from (e.g. an Energima Racing /\n"
            "standard theme config).\n\n"
            "Or use --src-theme to point at an existing source theme folder."
        )

    print("--- Reading canonical slots from source theme ---")
    slots = read_canonical_slots(SRC_THEME)
    if not slots:
        raise SystemExit("No GMaskParam slots found in source pMain.P2.xml")
    print(f"  Found {len(slots)} canonical mdN slot(s):")
    for s in slots:
        print(
            f"    {s['slot_id']} -> {s['mdN_name']} "
            f"@ ({s['xpos']},{s['ypos']}) size {s['width']}x{s['height']}"
        )

    print(f"\n--- Building new theme at {DST_THEME} ---")
    bw, bh, page_rgba = build_new_theme(SRC_THEME, DST_THEME, slots, NEW_GETTEXT)

    print(f"\n--- Repacking -> {DST_ZCONF} ---")
    repack_zconf(
        SRC_ZCONF, DST_ZCONF, OLD_THEME_NAME, NEW_THEME_NAME,
        DST_THEME, bw, bh, page_rgba,
    )

    print("\nDone.")


if __name__ == "__main__":
    main()
