#!/usr/bin/env python3
"""
meatsuit_server.py — CATBLASTER Meatsuit Generator, web edition.

A tiny stdlib local web server (sibling of forge_server.py / book_server.py) that
puts an HTML/JS avatar builder in front of a layered pixel-art asset library.
"Meatsuits" are CATBLASTER-canon character busts assembled from swappable parts
(Torso / Head / Nose / Mouth / Eyes / Hair), one library per house.

Per the Articles of CATBLASTER-Claude Engagement, this is a *machine*: it arranges
Alex's art. It does not author appearances, lore, or defaults.

What it does (v1 = the local builder loop):
  - browse a house's asset library, swap any slot, live-composite the bust
  - UPLOAD a new part: drop a PNG -> pick category + house + name -> it lands in
    assets/<house>/<category>/<slug>.png and appears in the panel immediately
  - SAVE a meatsuit: writes a recipe JSON (the game/sprite data contract) PLUS a
    flat 64x64 PNG and an 8x profile PNG, into 00-inbox/meatsuit-output/<id>/

Stack: BaseHTTPRequestHandler + ThreadingHTTPServer, PIL + stdlib only,
macOS system-Python compatible. No numpy. No third-party web framework.

Run:
  python3 meatsuit_server.py               # http://localhost:8790
  python3 meatsuit_server.py --port 9100 --no-open
"""

import argparse
import base64
import json
import re
import sys
import webbrowser
from collections import Counter
from datetime import datetime, timezone
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from io import BytesIO
from pathlib import Path
from urllib.parse import unquote

try:
    from PIL import Image, ImageChops
except ImportError:
    sys.exit("Pillow not installed. Run: /usr/bin/python3 -m pip install --user Pillow")

HERE = Path(__file__).resolve().parent
VAULT = HERE.parent
MEATSUIT_ROOT = VAULT / "02-areas/catblaster/meatsuit"
ASSET_ROOT = MEATSUIT_ROOT / "assets"
OUTPUT_ROOT = VAULT / "00-inbox" / "meatsuit-output"

# Palette + tint-role config live alongside the art so Alex can edit them
# directly (author-owned data, not code). Created with sensible defaults on
# first run; never overwritten if present.
PALETTES_FILE = ASSET_ROOT / "palettes.json"
TINT_ROLES_FILE = ASSET_ROOT / "tint-roles.json"

CANVAS = (64, 64)          # every part composites onto this grid
PROFILE_SCALE = 8          # 512x512 nearest-neighbor profile render

# Companion mask marking exactly which pixels of an eyes part are the iris, so
# eye color recolors ONLY the iris (not the sclera, brows, or surrounding art).
# Lives beside the part: assets/<house>/eyes/<stem>.iris.png. Opaque (or white)
# pixels = iris. Excluded from the library scan so it never shows as a part.
IRIS_SUFFIX = ".iris.png"

# Canonical CATBLASTER houses (Book of Ships order).
HOUSES = ["kirkland", "stal", "pearson", "roxdun", "hodel",
          "zikaron", "xingwu", "bukhara", "vahana"]

# Per-house accent (mirrors forge_server SHIELD_COLOR_NAMES intent).
HOUSE_ACCENT = {
    "kirkland": "#d9b24a", "stal": "#c0392b", "pearson": "#31c4d6",
    "roxdun": "#8794a3", "hodel": "#7fd63a", "zikaron": "#e0a92b",
    "xingwu": "#2bb8a8", "bukhara": "#a9d6e5", "vahana": "#a473a7",
}

# Layer slots the UI shows (panel order). Single-select — a meatsuit uses at
# most one part per slot.
CATEGORIES = ["torso", "head", "nose", "mouth", "eyes", "hair"]
CATEGORY_LABEL = {c: c.capitalize() for c in CATEGORIES}

# Compositing z-order, bottom -> top. HEAD sits BEHIND the torso, so the
# torso's neck/shoulders overlap the head's base; facial parts and hair paint
# on top. This is independent of the UI slot order above.
Z_ORDER = ["head", "torso", "nose", "mouth", "eyes", "hair"]

# Filename word -> canonical category, for auto-detecting a dropped part's slot
# (e.g. "Stal Head 1.png" -> head). Synonyms let loose naming still resolve.
CATEGORY_SYNONYMS = {
    "torso": "torso", "body": "torso", "chest": "torso", "shoulders": "torso",
    "neck": "torso", "bust": "torso",
    "head": "head", "face": "head", "skin": "head",
    "nose": "nose",
    "mouth": "mouth", "lips": "mouth", "jaw": "mouth",
    "eyes": "eyes", "eye": "eyes",
    "hair": "hair", "hairstyle": "hair",
}


def slugify(name):
    s = re.sub(r"[^a-z0-9]+", "-", str(name).strip().lower())
    return s.strip("-") or "part"


def is_house(h):
    return h in HOUSES


def is_category(c):
    return c in CATEGORIES


# ------------------------------------------------------- palettes & tint roles
#
# The recolor is a GRAYSCALE-MAP -> RAMP remap. A part is desaturated to its
# own luminance, that luminance is normalized (percentile-clipped) to [0,1],
# and remapped through an ordered color ramp (shadow -> highlight). Skin and
# hair each pick a ramp; which parts respond is set per-element by "tint role"
# (skin / hair / fixed). "fixed" parts composite in their authored colors.
#
# Per the Articles of Engagement this is mechanism only: Alex owns the ramps
# and role assignments (both live in editable JSON beside the art).

# Category -> default tint role. Overridable per-element in tint-roles.json.
# Defaults are CODE-authoritative (policy); only per-file overrides persist to
# disk — so evolving these here takes effect without a stale-file migration.
DEFAULT_TINT_DEFAULTS = {
    "head": "skin", "nose": "skin", "mouth": "skin",
    "hair": "hair", "eyes": "eyes", "torso": "fixed",
}
TINT_ROLES = ("skin", "hair", "eyes", "fixed")

# Eyes are band-limited: only the mid-luminance iris is recolored; the bright
# sclera/catchlight (above KEEP_HIGH) and dark pupil/outline (below KEEP_LOW)
# keep their authored colors. Tune if a specific eye art reads wrong.
EYE_KEEP_LOW = 0.16
EYE_KEEP_HIGH = 0.80

# How much of the original art's hand-painted chroma nuance to re-inject over
# the flat ramp tone (0 = pure ramp, ~1 = full original chroma). Default keeps
# skin/hair lively without over-saturating; live-tunable per render.
DEFAULT_DETAIL = 0.45

# Seed ramps (dark -> light). Naturalistic starting set — curate/extend freely
# by editing assets/palettes.json; the app re-reads it every render.
DEFAULT_PALETTES = {
    "skin": [
        {"id": "porcelain", "name": "Porcelain", "ramp": ["#6b4a3f", "#a9755f", "#d9a688", "#f2d3bd"]},
        {"id": "light",     "name": "Light",     "ramp": ["#7a4a37", "#b97a54", "#e0a97f", "#f6d9b8"]},
        {"id": "medium",    "name": "Medium",    "ramp": ["#5c3b2a", "#8f5a3c", "#c08654", "#e6b98a"]},
        {"id": "tan",       "name": "Tan",       "ramp": ["#4a2f20", "#7a4c30", "#a9743f", "#d6a866"]},
        {"id": "brown",     "name": "Brown",     "ramp": ["#35211a", "#5f3a29", "#8a5a3c", "#b98a63"]},
        {"id": "deep",      "name": "Deep",      "ramp": ["#241612", "#43281f", "#6b4231", "#9a6b4f"]},
        {"id": "ebony",     "name": "Ebony",     "ramp": ["#120b08", "#241610", "#3d2619", "#5e3d28"]},
    ],
    "hair": [
        {"id": "jet",      "name": "Jet Black", "ramp": ["#0b0b0f", "#1a1b24", "#33364a", "#565b78"]},
        {"id": "brown",    "name": "Brown",     "ramp": ["#1c110b", "#3a2417", "#5f3d24", "#8a5f3c"]},
        {"id": "chestnut", "name": "Chestnut",  "ramp": ["#241009", "#4a2113", "#7a3d20", "#a86b3c"]},
        {"id": "blond",    "name": "Blond",     "ramp": ["#4a3212", "#8a6326", "#c39a4a", "#e8cf86"]},
        {"id": "auburn",   "name": "Auburn",    "ramp": ["#2a0f0a", "#5a2114", "#8a3a1f", "#b56a3a"]},
        {"id": "silver",   "name": "Silver",    "ramp": ["#26292f", "#484d57", "#767c88", "#aab0bc"]},
        {"id": "white",    "name": "White",     "ramp": ["#4a4d55", "#7a7f88", "#b0b5bf", "#e6e9ee"]},
    ],
    # Eye ramps color the IRIS only (band-limited remap keeps sclera + pupil).
    "eyes": [
        {"id": "brown", "name": "Brown", "ramp": ["#2a1608", "#4a2a12", "#6e4321", "#9a6a3a"]},
        {"id": "hazel", "name": "Hazel", "ramp": ["#3a2a12", "#5c4420", "#846534", "#b0925a"]},
        {"id": "amber", "name": "Amber", "ramp": ["#4a2e08", "#7a4d10", "#b0801e", "#e0b44a"]},
        {"id": "green", "name": "Green", "ramp": ["#16301c", "#2c5432", "#4d7d4a", "#82b072"]},
        {"id": "blue",  "name": "Blue",  "ramp": ["#14283f", "#254a6e", "#4a7ba8", "#8fb6d6"]},
        {"id": "gray",  "name": "Gray",  "ramp": ["#2a2f36", "#4a525c", "#767f8b", "#aab2bd"]},
        {"id": "violet","name": "Violet","ramp": ["#2a1636", "#4a2a5e", "#734a94", "#a07fc0"]},
    ],
}


PALETTE_KINDS = ("skin", "hair", "eyes")


def load_palettes():
    """Disk file overrides per-kind; any kind it omits falls back to defaults,
    so an older palettes.json (skin+hair only) still yields eye ramps."""
    d = {k: list(v) for k, v in DEFAULT_PALETTES.items()}
    if PALETTES_FILE.is_file():
        try:
            raw = json.loads(PALETTES_FILE.read_text())
            if isinstance(raw, dict):
                for kind in PALETTE_KINDS:
                    if raw.get(kind):
                        d[kind] = raw[kind]
        except Exception:
            pass
    return d


def ensure_palettes_file():
    """Create the file if missing, or backfill any kind it lacks (e.g. add
    'eyes' to a file written before eye color existed). Never clobbers edits."""
    data = None
    if PALETTES_FILE.is_file():
        try:
            data = json.loads(PALETTES_FILE.read_text())
        except Exception:
            data = None
    if not isinstance(data, dict):
        data = {}
    changed = False
    for kind in PALETTE_KINDS:
        if not data.get(kind):
            data[kind] = DEFAULT_PALETTES[kind]
            changed = True
    if changed:
        PALETTES_FILE.parent.mkdir(parents=True, exist_ok=True)
        PALETTES_FILE.write_text(json.dumps(data, indent=2))


def palette_ramp(kind, pid):
    """Resolve a (kind, id) selection to its list of hex stops, or None."""
    if not pid:
        return None
    for p in load_palettes().get(kind, []):
        if p.get("id") == pid:
            return p.get("ramp")
    return None


def load_tint_roles():
    """Category defaults are code-authoritative; only per-file overrides are
    read from disk. (Prevents a stale on-disk default from pinning, e.g.,
    eyes to 'fixed' after the code default became 'eyes'.)"""
    d = {"defaults": dict(DEFAULT_TINT_DEFAULTS), "overrides": {}}
    if TINT_ROLES_FILE.is_file():
        try:
            raw = json.loads(TINT_ROLES_FILE.read_text())
            if isinstance(raw, dict):
                d["overrides"] = {k: v for k, v in (raw.get("overrides") or {}).items()
                                  if v in TINT_ROLES}
        except Exception:
            pass
    return d


def save_tint_roles(d):
    # Persist overrides only; defaults are code-owned and included for reference.
    TINT_ROLES_FILE.parent.mkdir(parents=True, exist_ok=True)
    out = {"defaults": dict(DEFAULT_TINT_DEFAULTS), "overrides": d.get("overrides", {})}
    TINT_ROLES_FILE.write_text(json.dumps(out, indent=2))


def resolve_role(roles, cat, house, filename):
    key = f"{house}/{cat}/{filename}"
    return roles["overrides"].get(key) or roles["defaults"].get(cat, "fixed")


# ------------------------------------------------------- grayscale->ramp remap

def _hex_rgb(h):
    h = str(h).lstrip("#")
    return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16))


def _ramp_lut256(stops):
    """Interpolate an ordered list of hex stops into 256 evenly-spaced RGB
    entries (a gradient lookup table)."""
    cols = [_hex_rgb(s) for s in stops] or [(128, 128, 128)]
    if len(cols) == 1:
        cols = cols * 2
    n = len(cols)
    out = []
    for i in range(256):
        t = i / 255.0 * (n - 1)
        k = int(t)
        if k >= n - 1:
            out.append(cols[-1])
        else:
            f = t - k
            a, b = cols[k], cols[k + 1]
            out.append((round(a[0] + (b[0] - a[0]) * f),
                        round(a[1] + (b[1] - a[1]) * f),
                        round(a[2] + (b[2] - a[2]) * f)))
    return out


def _masked_lum_bounds(lum, alpha, lo_pct=2, hi_pct=98):
    """Percentile luminance bounds over non-transparent pixels, so a few stray
    pixels don't compress the ramp. Returns (lo, hi) or None if fully clear."""
    hist = [0] * 256
    total = 0
    for l, a in zip(lum.getdata(), alpha.getdata()):
        if a > 8:
            hist[l] += 1
            total += 1
    if total == 0:
        return None
    lo, hi, acc = 0, 255, 0
    lo_target = total * lo_pct / 100.0
    for v in range(256):
        acc += hist[v]
        if acc >= lo_target:
            lo = v
            break
    acc = 0
    hi_rem = total * (100 - hi_pct) / 100.0
    for v in range(255, -1, -1):
        acc += hist[v]
        if acc >= hi_rem:
            hi = v
            break
    if hi <= lo:  # near-flat part — fall back to true extent
        present = [v for v in range(256) if hist[v]]
        lo, hi = present[0], max(present[-1], present[0] + 1)
    return lo, hi


def apply_ramp(img, stops, keep_low=0.0, keep_high=1.0, detail=0.0, mask=None):
    """Recolor an RGBA part by remapping its (auto-derived) luminance through a
    color ramp. Alpha is preserved. Pure PIL, numpy-free.

    mask (L image, 255 = recolor) restricts the recolor to exactly those pixels
    (used for eyes: an iris mask). When given, the masked region uses the full
    ramp range and everything else keeps its authored color — keep_low/high are
    ignored.

    keep_low / keep_high band-limit the remap when there's no mask (the eye
    fallback): normalized luminance outside [keep_low, keep_high] keeps its
    authored color. Defaults (0..1) remap everything — the skin/hair path.

    detail (0..~1) re-injects the ORIGINAL art's per-pixel chroma (its color
    deviation from neutral gray) on top of the flat ramp tone, so warm cheeks,
    cool shadows and other hand-painted hue nuance survive the recolor instead
    of being flattened to a single monotonic gradient. 0 = pure ramp."""
    if not stops:
        return img
    r_ch, g_ch, b_ch, alpha = img.split()
    lum = img.convert("L")
    bounds = _masked_lum_bounds(lum, alpha)
    if bounds is None:
        return img
    lo, hi = bounds
    span = max(1, hi - lo)
    ramp = _ramp_lut256(stops)
    kl, kh = (0.0, 1.0) if mask is not None else (keep_low, keep_high)
    band = max(1e-6, kh - kl)
    r_lut = [0] * 256
    g_lut = [0] * 256
    b_lut = [0] * 256
    for v in range(256):
        t = (v - lo) / span
        t = 0.0 if t < 0 else 1.0 if t > 1 else t
        u = (t - kl) / band                # rescale band to full ramp
        u = 0.0 if u < 0 else 1.0 if u > 1 else u
        c = ramp[round(u * 255)]
        r_lut[v], g_lut[v], b_lut[v] = c
    rr, gg, bb = lum.point(r_lut), lum.point(g_lut), lum.point(b_lut)

    if detail > 0:
        # Original chroma = original RGB minus its own gray. Add a fraction of
        # that signed offset back onto the ramp tone (split into +/- because
        # PIL channel ops are unsigned).
        gray = lum.convert("RGB")
        orig = Image.merge("RGB", (r_ch, g_ch, b_ch))
        newc = Image.merge("RGB", (rr, gg, bb))
        warm = ImageChops.subtract(orig, gray)   # where original is warmer/brighter per channel
        cool = ImageChops.subtract(gray, orig)   # where original is cooler/darker per channel
        scale = lambda x: int(x * detail)
        newc = ImageChops.subtract(ImageChops.add(newc, warm.point(scale)),
                                   cool.point(scale))
        rr, gg, bb = newc.split()

    remapped = Image.merge("RGBA", (rr, gg, bb, alpha))
    if mask is not None:
        out = img.copy()
        out.paste(remapped, (0, 0), mask)
        return out
    if kl <= 0.0 and kh >= 1.0:
        return remapped
    # Outside the band, keep the original pixel. Build a mask that's 255 inside
    # the band (use remapped) and 0 outside (keep original), then paste.
    lo_v = lo + kl * span
    hi_v = lo + kh * span
    in_band = lum.point([255 if lo_v <= v <= hi_v else 0 for v in range(256)])
    out = img.copy()
    out.paste(remapped, (0, 0), in_band)
    return out


def load_iris_mask(house, filename):
    """Load the iris mask for an eyes part, if one exists. Returns an L image
    (255 = iris) sized to CANVAS, else None."""
    stem = Path(filename).stem
    p = (ASSET_ROOT / house / "eyes" / (stem + IRIS_SUFFIX)).resolve()
    if ASSET_ROOT.resolve() not in p.parents or not p.is_file():
        return None
    m = Image.open(p)
    if m.mode in ("RGBA", "LA"):
        mask = m.convert("RGBA").getchannel("A")
    else:
        mask = m.convert("L").point(lambda x: 255 if x > 32 else 0)
    if mask.size != CANVAS:
        mask = mask.resize(CANVAS, Image.NEAREST)
    return mask


def iris_mask_from_art(part):
    """Heuristic STARTER iris mask from an eyes PNG: opaque pixels that are
    cool/neutral (not warm skin/brow red, not near-white sclera, not near-black
    outline). A bootstrap for hand-refinement, not a final answer."""
    px = part.load()
    W, H = part.size
    mask = Image.new("L", part.size, 0)
    mp = mask.load()
    for y in range(H):
        for x in range(W):
            r, g, b, a = px[x, y]
            if a <= 16:
                continue
            if r >= g and r > b:          # warm (skin / brow / warm sclera)
                continue
            if min(r, g, b) > 200:        # near-white
                continue
            if max(r, g, b) < 40:         # near-black outline/pupil
                continue
            mp[x, y] = 255
    return mask


def make_iris_masks(force=False):
    """Write starter <stem>.iris.png beside every eyes part lacking one."""
    n = 0
    for house in HOUSES:
        d = ASSET_ROOT / house / "eyes"
        if not d.is_dir():
            continue
        for p in sorted(d.glob("*.png")):
            if p.name.endswith(IRIS_SUFFIX):
                continue
            out = d / (p.stem + IRIS_SUFFIX)
            if out.exists() and not force:
                continue
            part = Image.open(p).convert("RGBA")
            if part.size != CANVAS:
                part = part.resize(CANVAS, Image.NEAREST)
            mask = iris_mask_from_art(part)
            if mask.getextrema()[1] == 0:          # no iris pixels detected
                print(f"  (skip {p.name}: no iris pixels detected)")
                continue
            mask.save(out)
            n += 1
            print(f"  iris mask → {out.relative_to(ASSET_ROOT)}")
    print(f"wrote {n} starter iris mask(s)")


# ---------------------------------------------------------------- library scan

def scan_library():
    """Return {house: {category: [ {id, name, file, url, role} ]}} for every
    house. Each part carries its resolved tint role (skin / hair / fixed)."""
    roles = load_tint_roles()
    lib = {}
    for house in HOUSES:
        lib[house] = {}
        for cat in CATEGORIES:
            cat_dir = ASSET_ROOT / house / cat
            parts = []
            if cat_dir.is_dir():
                for p in sorted(cat_dir.glob("*.png")):
                    if p.name.endswith(IRIS_SUFFIX):   # companion mask, not a part
                        continue
                    parts.append({
                        "id": p.stem,
                        "name": p.stem,
                        "file": p.name,
                        "url": f"/asset/{house}/{cat}/{p.name}",
                        "role": resolve_role(roles, cat, house, p.name),
                    })
            lib[house][cat] = parts
    return lib


def manifest():
    return {
        "houses": HOUSES,
        "house_accent": HOUSE_ACCENT,
        "categories": CATEGORIES,
        "category_label": CATEGORY_LABEL,
        "z_order": Z_ORDER,
        "canvas": list(CANVAS),
        "library": scan_library(),
        "palettes": load_palettes(),
        "tint_defaults": load_tint_roles()["defaults"],
        "default_detail": DEFAULT_DETAIL,
    }


# ------------------------------------------------------------ compositing (PIL)

def load_part(house, cat, filename):
    p = (ASSET_ROOT / house / cat / filename).resolve()
    # containment guard — never escape the asset root
    if ASSET_ROOT.resolve() not in p.parents or not p.is_file():
        return None
    img = Image.open(p).convert("RGBA")
    if img.size != CANVAS:
        img = img.resize(CANVAS, Image.NEAREST)
    return img


def composite(layers, z_order=None, skin_palette=None, hair_palette=None,
              eyes_palette=None, detail=DEFAULT_DETAIL):
    """layers = {category: {"house": h, "file": f}}; parts may span houses.
    Skin/hair/eyes-roled parts are remapped through the chosen ramps before
    compositing (eyes band-limited to the iris); "fixed" parts keep their
    authored colors. `detail` re-injects the original art's chroma nuance on
    skin/hair. Returns a flat RGBA CANVAS image."""
    order = z_order or Z_ORDER
    roles = load_tint_roles()
    skin_ramp = palette_ramp("skin", skin_palette)
    hair_ramp = palette_ramp("hair", hair_palette)
    eyes_ramp = palette_ramp("eyes", eyes_palette)
    base = Image.new("RGBA", CANVAS, (0, 0, 0, 0))
    for cat in order:
        entry = layers.get(cat)
        if not entry:
            continue
        house, filename = entry.get("house"), entry.get("file")
        if not (is_house(house) and filename):
            continue
        part = load_part(house, cat, filename)
        if part is None:
            continue
        role = resolve_role(roles, cat, house, filename)
        if role == "skin" and skin_ramp:
            part = apply_ramp(part, skin_ramp, detail=detail)
        elif role == "hair" and hair_ramp:
            part = apply_ramp(part, hair_ramp, detail=detail)
        elif role == "eyes" and eyes_ramp:
            iris = load_iris_mask(house, filename)
            if iris is not None:               # precise: recolor only the iris
                part = apply_ramp(part, eyes_ramp, mask=iris)
            else:                              # fallback until a mask is authored
                part = apply_ramp(part, eyes_ramp, EYE_KEEP_LOW, EYE_KEEP_HIGH)
        base.alpha_composite(part)
    return base


# --------------------------------------------------------------------- handlers

def handle_upload(payload):
    """Save a new part into the library. payload: house, category, name, data(b64)."""
    house = payload.get("house", "")
    cat = payload.get("category", "")
    if not is_house(house):
        return 400, {"error": f"unknown house: {house}"}
    if not is_category(cat):
        return 400, {"error": f"unknown category: {cat}"}
    raw = payload.get("data", "")
    if "," in raw:                       # strip data:image/png;base64, prefix
        raw = raw.split(",", 1)[1]
    try:
        img = Image.open(BytesIO(base64.b64decode(raw))).convert("RGBA")
    except Exception as e:
        return 400, {"error": f"not a valid image: {e}"}

    name = payload.get("name", "").strip()
    stem = slugify(name) if name else None
    cat_dir = ASSET_ROOT / house / cat
    cat_dir.mkdir(parents=True, exist_ok=True)
    if not stem:                          # auto-number within the slot
        n = len(list(cat_dir.glob("*.png"))) + 1
        stem = f"{cat}-{n}"
    dest = cat_dir / f"{stem}.png"
    i = 2
    while dest.exists():                  # never overwrite an existing part
        dest = cat_dir / f"{stem}-{i}.png"
        i += 1

    img.save(dest)
    return 200, {
        "ok": True,
        "house": house,
        "category": cat,
        "part": {"id": dest.stem, "name": dest.stem, "file": dest.name,
                 "url": f"/asset/{house}/{cat}/{dest.name}"},
    }


def parse_filename(name):
    """Guess {house, category, name, variant, confidence} from a part filename
    like 'Stal Head 1.png'. Fields stay null when not confidently detected;
    the verify page lets the user confirm or fix before anything is saved."""
    stem = Path(name).stem.lower()
    tokens = [t for t in re.split(r"[^a-z0-9]+", stem) if t]
    house = next((t for t in tokens if t in HOUSES), None)
    category = next((CATEGORY_SYNONYMS[t] for t in tokens
                     if t in CATEGORY_SYNONYMS), None)
    variant = next((t for t in tokens if t.isdigit()), None)
    if house and category:
        confidence = "high"
    elif house or category:
        confidence = "medium"
    else:
        confidence = "low"
    suggested = f"{category}-{variant}" if (category and variant) else (category or "")
    return {"filename": name, "house": house, "category": category,
            "name": suggested, "variant": variant, "confidence": confidence}


def handle_parse(payload):
    """Batch pre-flight: filenames in -> per-file detections out (no saving)."""
    names = payload.get("names", []) or []
    return 200, {"detections": [parse_filename(n) for n in names]}


def handle_import(payload):
    """Commit a verified batch. items: [{filename, house, category, name, data}].
    Reuses handle_upload per item so validation/never-overwrite rules apply."""
    items = payload.get("items", []) or []
    results = []
    for it in items:
        _code, body = handle_upload(it)
        results.append({
            "filename": it.get("filename"),
            "ok": bool(body.get("ok")),
            "error": body.get("error"),
            "part": body.get("part"),
            "house": body.get("house"),
            "category": body.get("category"),
        })
    return 200, {"ok": True,
                 "added": sum(1 for r in results if r["ok"]),
                 "results": results}


def handle_save(payload):
    """Persist a meatsuit: recipe JSON + flat PNG + profile PNG.
    Parts may span houses; each layer carries its own {house, file}."""
    raw = payload.get("layers", {}) or {}
    layers = {}
    for cat, entry in raw.items():
        if not is_category(cat) or not isinstance(entry, dict):
            continue
        h, f = entry.get("house"), entry.get("file")
        if is_house(h) and f:
            layers[cat] = {"house": h, "file": f}
    if not layers:
        return 400, {"error": "no layers selected"}
    z_order = [c for c in (payload.get("z_order") or Z_ORDER) if c in CATEGORIES]
    skin_palette = payload.get("skin_palette") or None
    hair_palette = payload.get("hair_palette") or None
    eyes_palette = payload.get("eyes_palette") or None
    detail = _clamp_detail(payload.get("detail"))

    # primary house = the torso's house, else the most-used house among parts
    if "torso" in layers:
        house = layers["torso"]["house"]
    else:
        house = Counter(l["house"] for l in layers.values()).most_common(1)[0][0]

    name = payload.get("name", "").strip() or "meatsuit"
    suit_id = f"{house}-{slugify(name)}"
    out_dir = OUTPUT_ROOT / suit_id
    out_dir.mkdir(parents=True, exist_ok=True)

    flat = composite(layers, z_order, skin_palette, hair_palette, eyes_palette, detail)
    flat.save(out_dir / "meatsuit.png")
    profile = flat.resize(
        (CANVAS[0] * PROFILE_SCALE, CANVAS[1] * PROFILE_SCALE), Image.NEAREST)
    profile.save(out_dir / "meatsuit-profile.png")

    recipe = {
        "meatsuit": suit_id,
        "name": name,
        "house": house,
        "layers": layers,
        "z_order": z_order,
        "skin_palette": skin_palette,
        "hair_palette": hair_palette,
        "eyes_palette": eyes_palette,
        "detail": detail,
        "canvas": list(CANVAS),
        "created": datetime.now(timezone.utc).isoformat(timespec="seconds"),
    }
    (out_dir / "recipe.json").write_text(json.dumps(recipe, indent=2))
    return 200, {
        "ok": True,
        "id": suit_id,
        "recipe": recipe,
        "png": f"/output/{suit_id}/meatsuit.png",
        "profile": f"/output/{suit_id}/meatsuit-profile.png",
        "dir": str(out_dir),
    }


def _clean_layers(raw):
    layers = {}
    for cat, entry in (raw or {}).items():
        if not is_category(cat) or not isinstance(entry, dict):
            continue
        h, f = entry.get("house"), entry.get("file")
        if is_house(h) and f:
            layers[cat] = {"house": h, "file": f}
    return layers


def _clamp_detail(v):
    try:
        v = float(v)
    except (TypeError, ValueError):
        return DEFAULT_DETAIL
    return 0.0 if v < 0 else 1.0 if v > 1 else v


def handle_render(payload):
    """Live-preview composite with palettes applied. Returns raw PNG bytes so
    the browser preview is pixel-identical to the saved meatsuit.png."""
    layers = _clean_layers(payload.get("layers"))
    z_order = [c for c in (payload.get("z_order") or Z_ORDER) if c in CATEGORIES]
    img = composite(layers, z_order,
                    payload.get("skin_palette") or None,
                    payload.get("hair_palette") or None,
                    payload.get("eyes_palette") or None,
                    _clamp_detail(payload.get("detail")))
    buf = BytesIO()
    img.save(buf, "PNG")
    return buf.getvalue()


def handle_tint_role(payload):
    """Set a single part's tint role (skin / hair / fixed). Persists to
    tint-roles.json as an override; clears the override when it matches the
    category default."""
    house, cat = payload.get("house"), payload.get("category")
    filename, role = payload.get("file"), payload.get("role")
    if role not in TINT_ROLES:
        return 400, {"error": f"bad role: {role}"}
    if not (is_house(house) and is_category(cat) and filename):
        return 400, {"error": "bad target"}
    roles = load_tint_roles()
    key = f"{house}/{cat}/{filename}"
    if role == roles["defaults"].get(cat, "fixed"):
        roles["overrides"].pop(key, None)
    else:
        roles["overrides"][key] = role
    save_tint_roles(roles)
    return 200, {"ok": True, "role": role}


def _mask_pixels(mask):
    if mask is None:
        return []
    md = mask.load()
    W, H = mask.size
    return [[x, y] for y in range(H) for x in range(W) if md[x, y] > 0]


def handle_iris_get(house, filename):
    """Editor data for an eyes part: its current iris-mask pixels + a heuristic
    'suggested' set for the Auto button. The art itself is fetched by the
    editor from /asset/<house>/eyes/<file>."""
    if not is_house(house) or not filename:
        return 400, {"error": "bad target"}
    part = load_part(house, "eyes", filename)
    if part is None:
        return 404, {"error": "no such eyes part"}
    return 200, {
        "ok": True, "house": house, "file": filename, "size": list(CANVAS),
        "pixels": _mask_pixels(load_iris_mask(house, filename)),
        "suggested": _mask_pixels(iris_mask_from_art(part)),
    }


def handle_iris_save(payload):
    """Write an iris mask from an explicit pixel list (the marker editor)."""
    house, filename = payload.get("house"), payload.get("file")
    pixels = payload.get("pixels") or []
    if not is_house(house) or not filename:
        return 400, {"error": "bad target"}
    if load_part(house, "eyes", filename) is None:
        return 400, {"error": "no such eyes part"}
    mask = Image.new("L", CANVAS, 0)
    mp = mask.load()
    for xy in pixels:
        try:
            x, y = int(xy[0]), int(xy[1])
        except (TypeError, ValueError, IndexError):
            continue
        if 0 <= x < CANVAS[0] and 0 <= y < CANVAS[1]:
            mp[x, y] = 255
    out = ASSET_ROOT / house / "eyes" / (Path(filename).stem + IRIS_SUFFIX)
    out.parent.mkdir(parents=True, exist_ok=True)
    mask.save(out)
    return 200, {"ok": True, "count": len(_mask_pixels(mask))}


# ------------------------------------------------------------------- HTTP glue

class Handler(BaseHTTPRequestHandler):
    def log_message(self, *a):           # quiet
        pass

    def _send(self, code, body, ctype="application/json"):
        if isinstance(body, (dict, list)):
            body = json.dumps(body).encode()
        elif isinstance(body, str):
            body = body.encode()
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def _send_file(self, path, ctype):
        if not path.is_file():
            return self._send(404, {"error": "not found"})
        data = path.read_bytes()
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(data)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(data)

    def do_GET(self):
        path = unquote(self.path.split("?", 1)[0])
        if path in ("/", "/index.html"):
            return self._send(200, PAGE, "text/html; charset=utf-8")
        if path == "/assets":
            return self._send(200, manifest())
        if path.startswith("/asset/"):
            parts = path[len("/asset/"):].split("/")
            if len(parts) == 3 and is_house(parts[0]) and is_category(parts[1]):
                f = (ASSET_ROOT / parts[0] / parts[1] / parts[2]).resolve()
                if ASSET_ROOT.resolve() in f.parents:
                    return self._send_file(f, "image/png")
            return self._send(404, {"error": "not found"})
        if path.startswith("/output/"):
            rel = path[len("/output/"):]
            f = (OUTPUT_ROOT / rel).resolve()
            if OUTPUT_ROOT.resolve() in f.parents and f.suffix == ".png":
                return self._send_file(f, "image/png")
            return self._send(404, {"error": "not found"})
        if path.startswith("/iris/"):
            rest = path[len("/iris/"):].split("/", 1)
            if len(rest) == 2:
                code, body = handle_iris_get(rest[0], rest[1])
                return self._send(code, body)
            return self._send(404, {"error": "not found"})
        return self._send(404, {"error": "not found"})

    def do_POST(self):
        path = unquote(self.path.split("?", 1)[0])
        length = int(self.headers.get("Content-Length", 0))
        try:
            payload = json.loads(self.rfile.read(length) or b"{}")
        except Exception:
            return self._send(400, {"error": "bad json"})
        if path == "/upload":
            code, body = handle_upload(payload)
            return self._send(code, body)
        if path == "/parse":
            code, body = handle_parse(payload)
            return self._send(code, body)
        if path == "/import":
            code, body = handle_import(payload)
            return self._send(code, body)
        if path == "/save":
            code, body = handle_save(payload)
            return self._send(code, body)
        if path == "/render":
            return self._send(200, handle_render(payload), "image/png")
        if path == "/tint-role":
            code, body = handle_tint_role(payload)
            return self._send(code, body)
        if path == "/iris":
            code, body = handle_iris_save(payload)
            return self._send(code, body)
        return self._send(404, {"error": "not found"})


PAGE = r"""<!doctype html><html><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CATBLASTER · Meatsuit Generator</title>
<style>
  :root{ --bg:#0d0f13; --panel:#161a21; --panel2:#1d222b; --edge:#2a313d;
         --ink:#e8ecf2; --dim:#8b95a5; --accent:#c0392b; }
  *{box-sizing:border-box}
  body{margin:0;background:var(--bg);color:var(--ink);
       font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
  header{display:flex;align-items:center;gap:14px;padding:12px 18px;
         border-bottom:1px solid var(--edge);background:var(--panel)}
  header h1{font-size:15px;margin:0;letter-spacing:.14em;text-transform:uppercase}
  header .tag{color:var(--dim);font-size:11px;letter-spacing:.12em}
  select,button,input{font:inherit;color:var(--ink);background:var(--panel2);
         border:1px solid var(--edge);border-radius:8px;padding:7px 10px}
  button{cursor:pointer}
  button.primary{background:var(--accent);border-color:var(--accent);font-weight:600}
  button:hover{filter:brightness(1.12)}
  .wrap{display:grid;grid-template-columns:300px 1fr 300px;gap:0;height:calc(100vh - 53px)}
  .col{padding:16px;overflow-y:auto}
  .col.left{border-right:1px solid var(--edge)}
  .col.right{border-left:1px solid var(--edge);background:var(--panel)}
  .stage{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px}
  #suit{image-rendering:pixelated;width:384px;height:384px;
        background:
          linear-gradient(45deg,#12151b 25%,transparent 25%,transparent 75%,#12151b 75%) 0 0/24px 24px,
          linear-gradient(45deg,#12151b 25%,#171b22 25%,#171b22 75%,#12151b 75%) 12px 12px/24px 24px,#171b22;
        border:1px solid var(--edge);border-radius:12px}
  h2{font-size:11px;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);
     margin:0 0 10px;border-bottom:1px solid var(--edge);padding-bottom:6px}
  .house-sec{margin-bottom:26px}
  .house-hd{font-size:13px;font-weight:700;letter-spacing:.16em;text-transform:uppercase;
     padding-bottom:6px;margin-bottom:14px;border-bottom:2px solid var(--edge)}
  .slot{margin-bottom:18px}
  .swatches{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}
  .sw{position:relative;aspect-ratio:1;border:1px solid var(--edge);border-radius:8px;
      background:#0f1319;cursor:pointer;overflow:hidden;display:flex;align-items:center;justify-content:center}
  .sw img{image-rendering:pixelated;width:88%;height:88%;object-fit:contain}
  .sw.active{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent) inset}
  .sw.none{font-size:10px;color:var(--dim);letter-spacing:.1em}
  .field{margin-bottom:12px}
  .field label{display:block;font-size:11px;color:var(--dim);letter-spacing:.1em;
        text-transform:uppercase;margin-bottom:5px}
  .field select,.field input{width:100%}
  .drop{border:1.5px dashed var(--edge);border-radius:10px;padding:18px;text-align:center;
        color:var(--dim);cursor:pointer;transition:.15s}
  .drop.hot{border-color:var(--accent);color:var(--ink);background:var(--panel2)}
  .row{display:flex;gap:8px}.row>*{flex:1}
  .msg{font-size:12px;color:var(--dim);min-height:16px;margin-top:8px}
  .msg.ok{color:#7fd63a}.msg.err{color:#e5533d}
  .hint{font-size:11px;color:var(--dim);margin-top:4px}
  hr{border:none;border-top:1px solid var(--edge);margin:18px 0}
  .overlay{position:fixed;inset:0;background:rgba(6,8,11,.78);z-index:50;
     display:flex;align-items:center;justify-content:center;padding:28px}
  .modal{background:var(--panel);border:1px solid var(--edge);border-radius:14px;
     width:min(920px,94vw);max-height:88vh;display:flex;flex-direction:column;padding:20px}
  .modal-hd{display:flex;align-items:center;gap:12px;margin-bottom:6px}
  .vgrid{overflow-y:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(190px,1fr));
     gap:12px;padding-top:4px}
  .vcard{border:1px solid var(--edge);border-radius:10px;background:var(--panel2);
     padding:10px;display:flex;flex-direction:column;gap:7px}
  .vcard.ready{border-color:#3f6d3a}
  .vcard.notready{border-color:#7a4a2a;background:#241a12}
  .vthumb{width:100%;aspect-ratio:1;image-rendering:pixelated;object-fit:contain;
     background:#0f1319;border-radius:7px}
  .vname{font-size:11px;color:var(--dim);word-break:break-all;line-height:1.25}
  .vcard select,.vcard input{width:100%;padding:5px 7px;font-size:12px}
  .badge{font-size:9px;letter-spacing:.1em;text-transform:uppercase;padding:2px 6px;
     border-radius:5px;align-self:flex-start;font-weight:700}
  .badge.high{background:#24401f;color:#8fe06a}
  .badge.medium{background:#403315;color:#e0b84a}
  .badge.low{background:#4a1f1f;color:#f08a7a}
  /* palette pickers under the canvas */
  .palettes{width:320px;display:flex;flex-direction:column;gap:10px}
  .prow{display:flex;align-items:center;gap:10px}
  .plabel{width:38px;font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--dim)}
  .pchips{display:flex;flex-wrap:wrap;gap:6px;flex:1}
  .pchip{width:30px;height:22px;border-radius:6px;border:1px solid var(--edge);
         cursor:pointer;transition:.12s}
  .pchip:hover{filter:brightness(1.12)}
  .pchip.active{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent) inset}
  /* per-part tint-role badge (click to cycle) */
  .rbadge{position:absolute;top:2px;right:2px;width:15px;height:15px;border-radius:5px;
          font-size:9px;font-weight:700;line-height:15px;text-align:center;cursor:pointer;
          background:#0f1319cc;border:1px solid var(--edge);color:var(--dim);z-index:2}
  .rbadge:hover{border-color:var(--accent);color:var(--ink)}
  .rbadge.skin{color:#e6b98a;border-color:#7a4c30}
  .rbadge.hair{color:#c39a4a;border-color:#5f3d24}
  .rbadge.eyes{color:#8fb6d6;border-color:#254a6e}
  .rbadge.fixed{color:var(--dim)}
</style></head><body>
<header>
  <h1>Meatsuit Generator</h1><span class="tag">CATBLASTER · local forge</span>
</header>
<div class="wrap">
  <div class="col left" id="slots"></div>
  <div class="col stage">
    <canvas id="suit" width="64" height="64"></canvas>
    <div class="palettes" id="palettes"></div>
    <div class="prow" style="width:320px">
      <span class="plabel">detail</span>
      <input type="range" id="detail" min="0" max="100" value="45" style="flex:1">
      <span class="plabel" id="detailval" style="width:34px;text-align:right">45%</span>
    </div>
    <button id="editiris" style="width:320px" disabled>✎ Mark iris pixels</button>
    <div class="row" style="width:280px">
      <button id="randomize">Randomize</button>
      <button id="clear">Clear</button>
    </div>
  </div>
  <div class="col right">
    <h2>Save Meatsuit</h2>
    <div class="field"><label>Name</label><input id="suitname" placeholder="e.g. Captain Jax"></div>
    <button class="primary" id="save" style="width:100%">Save Meatsuit</button>
    <div class="msg" id="savemsg"></div>
    <div id="result"></div>
    <hr>
    <h2>Load Assets</h2>
    <div class="drop" id="drop">Drop PNGs here<br>
      <span class="hint">one part or a whole set — multi-select ok</span></div>
    <input type="file" id="file" accept="image/png" multiple style="display:none">
    <div class="hint" style="margin-top:8px">Filenames like “Stal Head 1.png”
      auto-detect house + slot. You verify everything before it’s saved.</div>
    <div class="msg" id="upmsg"></div>
  </div>
</div>

<!-- Iris marker editor: click/drag the iris pixels of an eyes part -->
<div id="irismodal" class="overlay" style="display:none">
  <div class="modal" style="width:auto;max-width:94vw">
    <div class="modal-hd">
      <h2 style="border:none;margin:0;padding:0">Mark Iris</h2>
      <span class="tag" id="irissub"></span>
      <div style="margin-left:auto;display:flex;gap:10px">
        <button id="irisauto">Auto</button>
        <button id="irisclear">Clear</button>
        <button id="iriscancel">Cancel</button>
        <button class="primary" id="irissave">Save iris</button>
      </div>
    </div>
    <div class="hint" style="margin-bottom:10px">Click or drag the <b>iris</b> pixels — the only pixels eye color recolors.
      Everything else (brows, sclera) stays as drawn. Click a marked pixel to un-mark it.</div>
    <canvas id="iriscanvas" style="image-rendering:pixelated;background:#0f1319;
      border:1px solid var(--edge);border-radius:8px;cursor:crosshair;touch-action:none"></canvas>
  </div>
</div>

<!-- Verify-import overlay: shows every detection, asks before saving anything -->
<div id="verify" class="overlay" style="display:none">
  <div class="modal">
    <div class="modal-hd">
      <h2 style="border:none;margin:0;padding:0">Verify Import</h2>
      <span class="tag" id="verifysub"></span>
      <div style="margin-left:auto;display:flex;gap:10px">
        <button id="verifycancel">Cancel</button>
        <button class="primary" id="verifygo">Import ready</button>
      </div>
    </div>
    <div class="hint" style="margin-bottom:12px">Confirm each part’s house + slot.
      Rows missing either are <b>not ready</b> and won’t import until you set them.</div>
    <div class="vgrid" id="verifygrid"></div>
  </div>
</div>
<script>
let M=null, sel={}, pending=null;
const $=id=>document.getElementById(id);
const ctx=$('suit').getContext('2d'); ctx.imageSmoothingEnabled=false;
const cap=s=>s[0].toUpperCase()+s.slice(1);
const PAL_KINDS=['skin','hair','eyes'];
let palSel={skin:null,hair:null,eyes:null}, detailPct=45, renderSeq=0;

function housesWithAssets(){
  return M.houses.filter(h=>M.categories.some(c=>(M.library[h][c]||[]).length));
}

// Preview is composited server-side (/render) so it's byte-identical to the
// saved meatsuit.png — palettes are baked the same way in both paths.
async function render(){
  const seq=++renderSeq;
  const r=await fetch('/render',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({layers:sel,z_order:M.z_order,
      skin_palette:palSel.skin,hair_palette:palSel.hair,eyes_palette:palSel.eyes,
      detail:detailPct/100})});
  if(seq!==renderSeq) return;                 // a newer render superseded this one
  const url=URL.createObjectURL(await r.blob());
  const im=new Image();
  im.onload=()=>{ if(seq===renderSeq){ ctx.clearRect(0,0,64,64); ctx.drawImage(im,0,0,64,64); }
    URL.revokeObjectURL(url); };
  im.src=url;
}

const rampCss=stops=>'linear-gradient(135deg,'+stops.join(',')+')';

// Ramp-chip rows (skin / hair / eyes). Selecting one bakes it into the render.
function buildPalettes(){
  const box=$('palettes'); box.innerHTML='';
  for(const kind of PAL_KINDS){
    const cur=palSel[kind];
    const row=document.createElement('div'); row.className='prow';
    const lbl=document.createElement('span'); lbl.className='plabel'; lbl.textContent=kind;
    const chips=document.createElement('div'); chips.className='pchips';
    for(const p of (M.palettes[kind]||[])){
      const c=document.createElement('div');
      c.className='pchip'+(p.id===cur?' active':'');
      c.style.background=rampCss(p.ramp); c.title=p.name;
      c.onclick=()=>{ palSel[kind]=p.id; buildPalettes(); render(); };
      chips.appendChild(c);
    }
    row.appendChild(lbl); row.appendChild(chips); box.appendChild(row);
  }
}

// Left column groups parts by HOUSE, then by slot. Click a swatch to fill that
// slot (from any house); click the selected swatch again to clear the slot.
function buildSlots(){
  const box=$('slots'); box.innerHTML='';
  for(const house of housesWithAssets()){
    const sec=document.createElement('div'); sec.className='house-sec';
    const hd=document.createElement('div'); hd.className='house-hd';
    hd.textContent=cap(house);
    const accent=M.house_accent[house]||'var(--ink)';
    hd.style.color=accent; hd.style.borderColor=accent;
    sec.appendChild(hd);
    for(const cat of M.categories){
      const parts=M.library[house][cat]||[]; if(!parts.length) continue;
      const slot=document.createElement('div'); slot.className='slot';
      slot.innerHTML='<h2>'+M.category_label[cat]+'</h2>';
      const grid=document.createElement('div'); grid.className='swatches';
      for(const p of parts){
        const active=sel[cat]&&sel[cat].house===house&&sel[cat].file===p.file;
        const role=p.role||'fixed';
        const RLABEL={skin:'S',hair:'H',eyes:'E',fixed:'–'};
        const sw=document.createElement('div');
        sw.className='sw'+(active?' active':'');
        sw.innerHTML='<img src="'+p.url+'">'+
          '<span class="rbadge '+role+'" title="tint role: '+role+' — click to cycle">'+
          (RLABEL[role]||'–')+'</span>';
        sw.title=p.name;
        sw.onclick=()=>{
          if(active) delete sel[cat]; else sel[cat]={house,file:p.file};
          buildSlots(); render();
        };
        sw.querySelector('.rbadge').onclick=async(ev)=>{
          ev.stopPropagation();                 // don't toggle the slot selection
          const cycle=['skin','hair','eyes','fixed'];
          const next=cycle[(cycle.indexOf(role)+1)%cycle.length];
          await fetch('/tint-role',{method:'POST',headers:{'Content-Type':'application/json'},
            body:JSON.stringify({house,category:cat,file:p.file,role:next})});
          p.role=next; buildSlots(); render();
        };
        grid.appendChild(sw);
      }
      slot.appendChild(grid); sec.appendChild(slot);
    }
    box.appendChild(sec);
  }
}

function defaultSelection(){
  sel={};
  for(const cat of M.categories){
    for(const house of housesWithAssets()){
      const parts=M.library[house][cat]||[];
      if(parts.length){ sel[cat]={house,file:parts[0].file}; break; }
    }
  }
}

function defaultPalettes(){
  for(const kind of PAL_KINDS) palSel[kind]=((M.palettes[kind]||[])[0]||{}).id||null;
  if(typeof M.default_detail==='number'){
    detailPct=Math.round(M.default_detail*100);
    $('detail').value=detailPct; $('detailval').textContent=detailPct+'%';
  }
}

$('detail').oninput=e=>{
  detailPct=+e.target.value; $('detailval').textContent=detailPct+'%'; render();
};

async function loadManifest(){
  M=await (await fetch('/assets')).json();
  defaultSelection(); defaultPalettes(); buildSlots(); buildPalettes(); render();
}

$('randomize').onclick=()=>{
  for(const cat of M.categories){
    const pool=[];
    for(const house of housesWithAssets())
      for(const p of (M.library[house][cat]||[])) pool.push({house,file:p.file});
    if(pool.length) sel[cat]=pool[Math.floor(Math.random()*pool.length)];
    else delete sel[cat];
  }
  buildSlots(); render();
};
$('clear').onclick=()=>{ sel={}; buildSlots(); render(); };

$('save').onclick=async()=>{
  const name=$('suitname').value.trim();
  const r=await fetch('/save',{method:'POST',headers:{'Content-Type':'application/json'},
    body:JSON.stringify({name,layers:sel,z_order:M.z_order,
      skin_palette:palSel.skin,hair_palette:palSel.hair,eyes_palette:palSel.eyes,
      detail:detailPct/100})});
  const d=await r.json(); const m=$('savemsg');
  if(d.ok){ m.className='msg ok'; m.textContent='Saved: '+d.id;
    $('result').innerHTML='<div class="hint" style="margin-top:8px">recipe.json + PNG + 8× profile written to<br>'+
      d.dir.replace(/^.*second-brain\//,'')+'</div>'+
      '<img src="'+d.profile+'?t='+Date.now()+'" style="image-rendering:pixelated;width:100%;'+
      'margin-top:10px;border:1px solid var(--edge);border-radius:10px">';
  } else { m.className='msg err'; m.textContent=d.error||'save failed'; }
};

// ---- batch load + verify ----
const drop=$('drop');
let staged=[];   // [{filename, data}] awaiting verification
['dragover','dragenter'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.add('hot');}));
['dragleave','drop'].forEach(e=>drop.addEventListener(e,ev=>{ev.preventDefault();drop.classList.remove('hot');}));
drop.addEventListener('drop',ev=>handleFiles(ev.dataTransfer.files));
drop.onclick=()=>$('file').click();
$('file').onchange=e=>handleFiles(e.target.files);

function handleFiles(list){
  const files=[...list].filter(f=>/\.png$/i.test(f.name));
  const m=$('upmsg');
  if(!files.length){ m.className='msg err'; m.textContent='Drop PNG files'; return; }
  m.className='msg'; m.textContent='Reading '+files.length+' file(s)…';
  let done=0; staged=new Array(files.length);
  files.forEach((f,i)=>{ const rd=new FileReader();
    rd.onload=()=>{ staged[i]={filename:f.name,data:rd.result};
      if(++done===files.length) openVerify(); };
    rd.readAsDataURL(f);
  });
}

async function openVerify(){
  const det=(await (await fetch('/parse',{method:'POST',
    headers:{'Content-Type':'application/json'},
    body:JSON.stringify({names:staged.map(s=>s.filename)})})).json()).detections;
  const grid=$('verifygrid'); grid.innerHTML='';
  const houseOpts='<option value="">— pick house —</option>'+
    M.houses.map(h=>'<option value="'+h+'">'+cap(h)+'</option>').join('');
  const catOpts='<option value="">— pick slot —</option>'+
    M.categories.map(c=>'<option value="'+c+'">'+M.category_label[c]+'</option>').join('');
  staged.forEach((s,i)=>{
    const d=det[i]||{};
    const card=document.createElement('div'); card.className='vcard'; card.dataset.i=i;
    card.innerHTML=
      '<span class="badge '+(d.confidence||'low')+'">'+(d.confidence||'low')+'</span>'+
      '<img class="vthumb" src="'+s.data+'">'+
      '<div class="vname">'+s.filename+'</div>'+
      '<select class="vh">'+houseOpts+'</select>'+
      '<select class="vc">'+catOpts+'</select>'+
      '<input class="vn" placeholder="name (optional)" value="'+(d.name||'')+'">';
    grid.appendChild(card);
    card.querySelector('.vh').value=d.house||'';
    card.querySelector('.vc').value=d.category||'';
    const mark=()=>{ const ok=card.querySelector('.vh').value&&card.querySelector('.vc').value;
      card.classList.toggle('ready',!!ok); card.classList.toggle('notready',!ok); updateGo(); };
    card.querySelector('.vh').onchange=mark;
    card.querySelector('.vc').onchange=mark;
    mark();
  });
  $('verifysub').textContent=staged.length+' part(s) detected';
  $('verify').style.display='flex';
  updateGo();
}

function readyCount(){
  return [...document.querySelectorAll('.vcard')].filter(c=>c.classList.contains('ready')).length;
}
function updateGo(){
  const n=readyCount(); const b=$('verifygo');
  b.textContent='Import '+n+' ready'; b.disabled=n===0; b.style.opacity=n?1:.5;
}

$('verifycancel').onclick=()=>{ $('verify').style.display='none'; staged=[]; $('upmsg').textContent=''; };

$('verifygo').onclick=async()=>{
  const items=[];
  document.querySelectorAll('.vcard').forEach(c=>{
    const h=c.querySelector('.vh').value, cat=c.querySelector('.vc').value;
    if(!h||!cat) return;
    const i=+c.dataset.i;
    items.push({house:h,category:cat,name:c.querySelector('.vn').value,
      filename:staged[i].filename,data:staged[i].data});
  });
  if(!items.length) return;
  const d=await (await fetch('/import',{method:'POST',
    headers:{'Content-Type':'application/json'},body:JSON.stringify({items})})).json();
  M=await (await fetch('/assets')).json();
  buildSlots(); render();
  $('verify').style.display='none'; staged=[];
  const m=$('upmsg'); m.className='msg ok';
  m.textContent='Imported '+d.added+' part(s) into the library';
};

loadManifest();
</script></body></html>"""


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--port", type=int, default=8790)
    ap.add_argument("--no-open", action="store_true")
    ap.add_argument("--make-iris-masks", action="store_true",
                    help="write starter <stem>.iris.png beside every eyes part, then exit")
    ap.add_argument("--force", action="store_true",
                    help="with --make-iris-masks, overwrite existing masks")
    args = ap.parse_args()

    ASSET_ROOT.mkdir(parents=True, exist_ok=True)
    OUTPUT_ROOT.mkdir(parents=True, exist_ok=True)
    ensure_palettes_file()

    if args.make_iris_masks:
        make_iris_masks(force=args.force)
        return

    port = args.port
    for _ in range(20):
        try:
            httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
            break
        except OSError:
            port += 1
    else:
        sys.exit("no free port")

    url = f"http://localhost:{port}"
    print(f"Meatsuit Generator → {url}")
    if not args.no_open:
        webbrowser.open(url)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\nbye")


if __name__ == "__main__":
    main()
