from __future__ import annotations

import json
import shutil
from datetime import datetime
from pathlib import Path
from typing import Any


UV_TEMPLATE_NAME = "EXT_SKIN_UV.png"
PARTS_UV_TEMPLATE_NAME = "EXT_SKIN_PARTS_UV.png"
VARIANT_UV_TEMPLATE_NAME = "EXT_SKIN_VARIANT_UV.png"
BANNER_UV_TEMPLATE_NAME = "EXT_BANNER_UV.png"
APP_STORAGE_DIR_NAMES = {
    "liverylibrary",
    "liveryprojects",
    "liverypackageexport",
    "officialliverylibrary",
    "officialliveryprojects",
    "officialliverypackageexport",
    "preview_assets",
    "previewassets",
    "userdata",
}
DRIVER_UV_TEMPLATE_CATEGORIES = {"helmet", "suit", "gloves"}
UV_LINE_COLOR_BASE = (255, 54, 54, 115)
UV_LINE_COLOR_DEFAULT = (255, 54, 54, 230)
UV_LINE_COLOR_VARIANT = (32, 210, 255, 240)
UV_COLOR_POLICY_VARIANT_PARTS = "base_red_parts_cyan"
UV_COLOR_POLICY_PARTS_ONLY = "parts_cyan"


def _portable_rel(base: Path, target: Path) -> str:
    try:
        return str(target.resolve().relative_to(base.resolve())).replace("/", "\\")
    except Exception:
        return str(target).replace("/", "\\")


def _is_skin_range(material_range: dict[str, Any]) -> bool:
    if material_range.get("skinRelevant"):
        return True
    text = " ".join(
        str(material_range.get(key) or "")
        for key in ("name", "material", "materialPath", "resolvedMaterialPath")
    ).lower()
    return "skin" in text


def _banner_diagnostics_by_range(mesh: dict[str, Any]) -> dict[int, dict[str, Any]]:
    diagnostics: dict[int, dict[str, Any]] = {}
    for row in mesh.get("material_diagnostics", []) or []:
        if not isinstance(row, dict):
            continue
        try:
            index = int(row.get("rangeIndex"))
        except Exception:
            continue
        diagnostics[index] = row
    return diagnostics


def _is_banner_range(material_range: dict[str, Any], diagnostic: dict[str, Any] | None = None) -> bool:
    text_parts = [
        str(material_range.get(key) or "")
        for key in ("name", "material", "materialPath", "resolvedMaterialPath", "shader")
    ]
    if diagnostic:
        text_parts.extend(
            str(diagnostic.get(key) or "")
            for key in ("name", "material_path", "resolved_material_path", "shader", "parent_material_path")
        )
        for slot in diagnostic.get("texture_slots", []) or []:
            if not isinstance(slot, dict):
                continue
            text_parts.extend(str(slot.get(key) or "") for key in ("name", "value", "resolved_path"))
    text = " ".join(text_parts).lower()
    if "banner" not in text:
        return False
    return any(token in text for token in ("ext_banner", "banner_c", "base_basecolormap", "txdiffuse"))


def _triangles_for_ranges(mesh: dict[str, Any], predicate) -> tuple[list[int], int]:
    indices = mesh.get("indices") if isinstance(mesh.get("indices"), list) else []
    triangle_total = len(indices) // 3
    selected: list[int] = []
    range_count = 0
    for material_range in mesh.get("material_ranges", []) or []:
        if not isinstance(material_range, dict) or not predicate(material_range):
            continue
        start = max(0, int(material_range.get("startTriangle") or 0))
        count = max(0, int(material_range.get("triangleCount") or 0))
        end = min(triangle_total, start + count)
        if end <= start:
            continue
        selected.extend(range(start, end))
        range_count += 1
    return selected, range_count


def _uv_to_pixel(uv: Any, size: int) -> tuple[float, float] | None:
    if not isinstance(uv, (list, tuple)) or len(uv) < 2:
        return None
    try:
        u = float(uv[0])
        v = float(uv[1])
    except Exception:
        return None
    return u * size, (1.0 - v) * size


def _rgba_css(rgba: tuple[int, int, int, int]) -> str:
    r, g, b, a = rgba
    return f"rgba({r}, {g}, {b}, {a / 255:.3f})"


def _skin_triangles(mesh: dict[str, Any]) -> tuple[list[int], int]:
    selected, range_count = _triangles_for_ranges(mesh, _is_skin_range)
    if not selected and any("skin" in str(block.get("name") or "").lower() for block in mesh.get("material_blocks", []) or []):
        indices = mesh.get("indices") if isinstance(mesh.get("indices"), list) else []
        triangle_total = len(indices) // 3
        selected = list(range(triangle_total))
        range_count = 1
    return selected, range_count


def _banner_triangles(mesh: dict[str, Any]) -> tuple[list[int], int]:
    diagnostics = _banner_diagnostics_by_range(mesh)

    def matches(material_range: dict[str, Any]) -> bool:
        try:
            index = int(material_range.get("index"))
        except Exception:
            index = -1
        return _is_banner_range(material_range, diagnostics.get(index))

    return _triangles_for_ranges(mesh, matches)


def _all_triangles(mesh: dict[str, Any]) -> tuple[list[int], int]:
    indices = mesh.get("indices") if isinstance(mesh.get("indices"), list) else []
    triangle_total = len(indices) // 3
    if triangle_total <= 0:
        return [], 0
    ranges = [row for row in (mesh.get("material_ranges") or []) if isinstance(row, dict)]
    return list(range(triangle_total)), len(ranges) or 1


def _backup_existing(path: Path) -> str:
    if not path.exists():
        return ""
    if any(part.lower() in APP_STORAGE_DIR_NAMES for part in path.parts):
        return ""
    backup_dir = path.parent / "_acevo_backups"
    backup_dir.mkdir(parents=True, exist_ok=True)
    stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
    backup = backup_dir / f"{path.name}.{stamp}.bak"
    shutil.copy2(path, backup)
    return str(backup)


def _write_uv_template(
    mesh: dict[str, Any],
    out_path: str | Path,
    triangles: list[int],
    range_count: int,
    *,
    width: int,
    height: int,
    overwrite: bool,
    empty_reason: str,
) -> dict[str, Any]:
    from PIL import Image, ImageDraw

    width = max(1, int(width))
    height = max(1, int(height))
    target = Path(out_path)
    target.parent.mkdir(parents=True, exist_ok=True)
    if target.exists() and not overwrite:
        return {
            "path": str(target),
            "name": target.name,
            "exists": True,
            "created": False,
            "skipped": True,
            "reason": "exists",
            "width": width,
            "height": height,
            "triangleCount": len(triangles),
            "drawnTriangleCount": len(triangles),
            "rangeCount": range_count,
        }
    if not triangles:
        return {
            "path": str(target),
            "name": target.name,
            "exists": target.exists(),
            "created": False,
            "skipped": True,
            "reason": empty_reason,
            "width": width,
            "height": height,
            "triangleCount": 0,
            "rangeCount": 0,
        }

    uvs = mesh.get("uvs") if isinstance(mesh.get("uvs"), list) else []
    indices = mesh.get("indices") if isinstance(mesh.get("indices"), list) else []
    image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(image)
    line_width = max(1, int(round(max(width, height) / 2048)))
    line_color = (255, 54, 54, 230)
    valid = 0
    for triangle in triangles:
        base = triangle * 3
        if base + 2 >= len(indices):
            continue
        points: list[tuple[float, float]] = []
        for vertex_index in (indices[base], indices[base + 1], indices[base + 2]):
            if not isinstance(vertex_index, int) or vertex_index < 0 or vertex_index >= len(uvs):
                points = []
                break
            uv = uvs[vertex_index]
            if not isinstance(uv, (list, tuple)) or len(uv) < 2:
                points = []
                break
            try:
                u = float(uv[0])
                v = float(uv[1])
            except Exception:
                points = []
                break
            points.append((u * width, (1.0 - v) * height))
        if len(points) != 3:
            continue
        draw.line([points[0], points[1], points[2], points[0]], fill=line_color, width=line_width)
        valid += 1

    transpose_flip_top_bottom = getattr(getattr(Image, "Transpose", Image), "FLIP_TOP_BOTTOM")
    image = image.transpose(transpose_flip_top_bottom)

    backup = _backup_existing(target) if overwrite else ""
    image.save(target, format="PNG")
    return {
        "path": str(target),
        "name": target.name,
        "exists": True,
        "created": True,
        "skipped": False,
        "reason": "",
        "backup": backup,
        "width": width,
        "height": height,
        "triangleCount": len(triangles),
        "drawnTriangleCount": valid,
        "rangeCount": range_count,
    }


def write_ext_skin_uv_template(
    mesh: dict[str, Any],
    out_path: str | Path,
    *,
    size: int = 4096,
    overwrite: bool = False,
) -> dict[str, Any]:
    triangles, range_count = _skin_triangles(mesh)
    return _write_uv_template(
        mesh,
        out_path,
        triangles,
        range_count,
        width=size,
        height=size,
        overwrite=overwrite,
        empty_reason="no EXT_SKIN UV triangles found",
    )


def write_ext_skin_uv_template_multi(
    meshes: list[dict[str, Any]],
    out_path: str | Path,
    *,
    size: int = 4096,
    overwrite: bool = False,
    colorize_variant_parts: bool = False,
) -> dict[str, Any]:
    from PIL import Image, ImageDraw

    target = Path(out_path)
    target.parent.mkdir(parents=True, exist_ok=True)
    width = max(1, int(size))
    height = width
    mesh_rows: list[dict[str, Any]] = []
    total_triangles = 0
    total_ranges = 0
    for index, mesh in enumerate(meshes):
        if not isinstance(mesh, dict):
            continue
        triangles, range_count = _skin_triangles(mesh)
        mesh_rows.append(
            {
                "index": index,
                "file": mesh.get("file") or "",
                "meshName": mesh.get("mesh_name") or mesh.get("meshName") or "",
                "role": "variant" if colorize_variant_parts and index > 0 else "base",
                "triangleCount": len(triangles),
                "rangeCount": range_count,
                "triangles": triangles,
                "mesh": mesh,
            }
        )
        total_triangles += len(triangles)
        total_ranges += range_count
    if target.exists() and not overwrite:
        return {
            "path": str(target),
            "name": target.name,
            "exists": True,
            "created": False,
            "skipped": True,
            "reason": "exists",
            "width": width,
            "height": height,
            "triangleCount": total_triangles,
            "drawnTriangleCount": total_triangles,
            "rangeCount": total_ranges,
            "meshCount": len(mesh_rows),
            "variantColorPolicy": UV_COLOR_POLICY_VARIANT_PARTS if colorize_variant_parts else "",
            "baseLineColor": _rgba_css(UV_LINE_COLOR_BASE) if colorize_variant_parts else _rgba_css(UV_LINE_COLOR_DEFAULT),
            "variantLineColor": _rgba_css(UV_LINE_COLOR_VARIANT) if colorize_variant_parts else "",
            "meshes": [
                {key: row[key] for key in ("index", "file", "meshName", "role", "triangleCount", "rangeCount")}
                for row in mesh_rows
            ],
        }
    if not total_triangles:
        return {
            "path": str(target),
            "name": target.name,
            "exists": target.exists(),
            "created": False,
            "skipped": True,
            "reason": "no EXT_SKIN UV triangles found",
            "width": width,
            "height": height,
            "triangleCount": 0,
            "rangeCount": 0,
            "meshCount": len(mesh_rows),
        }

    image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(image)
    line_width = max(1, int(round(max(width, height) / 2048)))
    valid = 0
    for row in mesh_rows:
        mesh = row["mesh"]
        line_color = UV_LINE_COLOR_DEFAULT
        if colorize_variant_parts:
            line_color = UV_LINE_COLOR_VARIANT if row.get("role") == "variant" else UV_LINE_COLOR_BASE
        uvs = mesh.get("uvs") if isinstance(mesh.get("uvs"), list) else []
        indices = mesh.get("indices") if isinstance(mesh.get("indices"), list) else []
        mesh_valid = 0
        for triangle in row["triangles"]:
            base = triangle * 3
            if base + 2 >= len(indices):
                continue
            points: list[tuple[float, float]] = []
            for vertex_index in (indices[base], indices[base + 1], indices[base + 2]):
                if not isinstance(vertex_index, int) or vertex_index < 0 or vertex_index >= len(uvs):
                    points = []
                    break
                uv = uvs[vertex_index]
                if not isinstance(uv, (list, tuple)) or len(uv) < 2:
                    points = []
                    break
                try:
                    u = float(uv[0])
                    v = float(uv[1])
                except Exception:
                    points = []
                    break
                points.append((u * width, (1.0 - v) * height))
            if len(points) != 3:
                continue
            draw.line([points[0], points[1], points[2], points[0]], fill=line_color, width=line_width)
            valid += 1
            mesh_valid += 1
        row["drawnTriangleCount"] = mesh_valid

    transpose_flip_top_bottom = getattr(getattr(Image, "Transpose", Image), "FLIP_TOP_BOTTOM")
    image = image.transpose(transpose_flip_top_bottom)

    backup = _backup_existing(target) if overwrite else ""
    image.save(target, format="PNG")
    return {
        "path": str(target),
        "name": target.name,
        "exists": True,
        "created": True,
        "skipped": False,
        "reason": "",
        "backup": backup,
        "width": width,
        "height": height,
        "triangleCount": total_triangles,
        "drawnTriangleCount": valid,
        "rangeCount": total_ranges,
        "meshCount": len(mesh_rows),
        "variantColorPolicy": UV_COLOR_POLICY_VARIANT_PARTS if colorize_variant_parts else "",
        "baseLineColor": _rgba_css(UV_LINE_COLOR_BASE) if colorize_variant_parts else _rgba_css(UV_LINE_COLOR_DEFAULT),
        "variantLineColor": _rgba_css(UV_LINE_COLOR_VARIANT) if colorize_variant_parts else "",
        "meshes": [
            {
                key: row.get(key)
                for key in ("index", "file", "meshName", "role", "triangleCount", "drawnTriangleCount", "rangeCount")
            }
            for row in mesh_rows
        ],
    }


def write_ext_skin_parts_uv_template(
    meshes: list[dict[str, Any]],
    out_path: str | Path,
    *,
    size: int = 4096,
    overwrite: bool = False,
) -> dict[str, Any]:
    from PIL import Image, ImageDraw

    target = Path(out_path)
    target.parent.mkdir(parents=True, exist_ok=True)
    width = max(1, int(size))
    height = width
    mesh_rows: list[dict[str, Any]] = []
    total_triangles = 0
    total_ranges = 0
    for index, mesh in enumerate(meshes):
        if not isinstance(mesh, dict):
            continue
        triangles, range_count = _skin_triangles(mesh)
        mesh_rows.append(
            {
                "index": index,
                "file": mesh.get("file") or "",
                "meshName": mesh.get("mesh_name") or mesh.get("meshName") or "",
                "role": "parts",
                "variantPartSlot": mesh.get("variantPartSlot") or "",
                "variantPartRelPath": mesh.get("variantPartRelPath") or "",
                "triangleCount": len(triangles),
                "rangeCount": range_count,
                "triangles": triangles,
                "mesh": mesh,
            }
        )
        total_triangles += len(triangles)
        total_ranges += range_count
    if target.exists() and not overwrite:
        return {
            "path": str(target),
            "name": target.name,
            "exists": True,
            "created": False,
            "skipped": True,
            "reason": "exists",
            "width": width,
            "height": height,
            "triangleCount": total_triangles,
            "drawnTriangleCount": total_triangles,
            "rangeCount": total_ranges,
            "meshCount": len(mesh_rows),
            "variantPartMeshCount": len(mesh_rows),
            "variantColorPolicy": UV_COLOR_POLICY_PARTS_ONLY,
            "partsLineColor": _rgba_css(UV_LINE_COLOR_VARIANT),
            "meshes": [
                {
                    key: row.get(key)
                    for key in ("index", "file", "meshName", "role", "variantPartSlot", "variantPartRelPath", "triangleCount", "rangeCount")
                }
                for row in mesh_rows
            ],
        }
    if not total_triangles:
        return {
            "path": str(target),
            "name": target.name,
            "exists": target.exists(),
            "created": False,
            "skipped": True,
            "reason": "no EXT_SKIN part UV triangles found",
            "width": width,
            "height": height,
            "triangleCount": 0,
            "rangeCount": 0,
            "meshCount": len(mesh_rows),
            "variantPartMeshCount": len(mesh_rows),
            "variantColorPolicy": UV_COLOR_POLICY_PARTS_ONLY,
            "partsLineColor": _rgba_css(UV_LINE_COLOR_VARIANT),
        }

    image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(image)
    line_width = max(1, int(round(max(width, height) / 2048)))
    valid = 0
    for row in mesh_rows:
        mesh = row["mesh"]
        mesh_valid = 0
        for triangle in row["triangles"]:
            points = _triangle_points(mesh, triangle, width, height)
            if len(points) != 3:
                continue
            draw.line([points[0], points[1], points[2], points[0]], fill=UV_LINE_COLOR_VARIANT, width=line_width)
            valid += 1
            mesh_valid += 1
        row["drawnTriangleCount"] = mesh_valid

    transpose_flip_top_bottom = getattr(getattr(Image, "Transpose", Image), "FLIP_TOP_BOTTOM")
    image = image.transpose(transpose_flip_top_bottom)

    backup = _backup_existing(target) if overwrite else ""
    image.save(target, format="PNG")
    return {
        "path": str(target),
        "name": target.name,
        "exists": True,
        "created": True,
        "skipped": False,
        "reason": "",
        "backup": backup,
        "width": width,
        "height": height,
        "triangleCount": total_triangles,
        "drawnTriangleCount": valid,
        "rangeCount": total_ranges,
        "meshCount": len(mesh_rows),
        "variantPartMeshCount": len(mesh_rows),
        "variantColorPolicy": UV_COLOR_POLICY_PARTS_ONLY,
        "partsLineColor": _rgba_css(UV_LINE_COLOR_VARIANT),
        "meshes": [
            {
                key: row.get(key)
                for key in ("index", "file", "meshName", "role", "variantPartSlot", "variantPartRelPath", "triangleCount", "drawnTriangleCount", "rangeCount")
            }
            for row in mesh_rows
        ],
    }


def _mesh_skin_triangle_rows(mesh: dict[str, Any], index: int = 0) -> dict[str, Any]:
    triangles, range_count = _skin_triangles(mesh)
    return {
        "index": index,
        "file": mesh.get("file") or "",
        "meshName": mesh.get("mesh_name") or mesh.get("meshName") or "",
        "triangleCount": len(triangles),
        "rangeCount": range_count,
        "triangles": triangles,
        "mesh": mesh,
    }


def _triangle_points(mesh: dict[str, Any], triangle: int, width: int, height: int) -> list[tuple[float, float]]:
    uvs = mesh.get("uvs") if isinstance(mesh.get("uvs"), list) else []
    indices = mesh.get("indices") if isinstance(mesh.get("indices"), list) else []
    base = int(triangle) * 3
    if base + 2 >= len(indices):
        return []
    points: list[tuple[float, float]] = []
    for vertex_index in (indices[base], indices[base + 1], indices[base + 2]):
        if not isinstance(vertex_index, int) or vertex_index < 0 or vertex_index >= len(uvs):
            return []
        point = _uv_to_pixel(uvs[vertex_index], width)
        if point is None:
            return []
        points.append(point)
    return points


def _mask_has_pixel(mask: Any, x: float, y: float) -> bool:
    width, height = mask.size
    px = max(0, min(width - 1, int(round(x))))
    py = max(0, min(height - 1, int(round(y))))
    return bool(mask.getpixel((px, py)))


def _triangle_sample_points(points: list[tuple[float, float]]) -> list[tuple[float, float]]:
    if len(points) != 3:
        return []
    a, b, c = points
    samples = [
        a,
        b,
        c,
        ((a[0] + b[0] + c[0]) / 3.0, (a[1] + b[1] + c[1]) / 3.0),
    ]
    for p1, p2 in ((a, b), (b, c), (c, a)):
        samples.extend(
            [
                ((p1[0] + p2[0]) / 2.0, (p1[1] + p2[1]) / 2.0),
                ((p1[0] * 0.75) + (p2[0] * 0.25), (p1[1] * 0.75) + (p2[1] * 0.25)),
                ((p1[0] * 0.25) + (p2[0] * 0.75), (p1[1] * 0.25) + (p2[1] * 0.75)),
            ]
        )
    samples.extend(
        [
            ((a[0] * 0.5) + (b[0] * 0.25) + (c[0] * 0.25), (a[1] * 0.5) + (b[1] * 0.25) + (c[1] * 0.25)),
            ((a[0] * 0.25) + (b[0] * 0.5) + (c[0] * 0.25), (a[1] * 0.25) + (b[1] * 0.5) + (c[1] * 0.25)),
            ((a[0] * 0.25) + (b[0] * 0.25) + (c[0] * 0.5), (a[1] * 0.25) + (b[1] * 0.25) + (c[1] * 0.5)),
        ]
    )
    return samples


def _triangle_hits_mask(points: list[tuple[float, float]], mask: Any) -> bool:
    if len(points) != 3:
        return False
    return any(_mask_has_pixel(mask, x, y) for x, y in _triangle_sample_points(points))


def write_ext_skin_uv_template_multi_deconflicted(
    base_mesh: dict[str, Any],
    variant_meshes: list[dict[str, Any]],
    out_path: str | Path,
    *,
    mask_variant_meshes: list[dict[str, Any]] | None = None,
    size: int = 4096,
    overwrite: bool = False,
) -> dict[str, Any]:
    from PIL import Image, ImageDraw, ImageFilter

    target = Path(out_path)
    target.parent.mkdir(parents=True, exist_ok=True)
    width = max(1, int(size))
    height = width
    mask_expansion_pixels = max(1, int(round(width / 2048)) * 2)
    base_row = _mesh_skin_triangle_rows(base_mesh, 0)
    variant_rows = [_mesh_skin_triangle_rows(mesh, index + 1) for index, mesh in enumerate(variant_meshes)]
    mask_rows = [_mesh_skin_triangle_rows(mesh, index + 1) for index, mesh in enumerate(mask_variant_meshes if mask_variant_meshes is not None else variant_meshes)]
    total_variant_triangles = sum(int(row.get("triangleCount") or 0) for row in variant_rows)
    total_ranges = int(base_row.get("rangeCount") or 0) + sum(int(row.get("rangeCount") or 0) for row in variant_rows)

    if target.exists() and not overwrite:
        return {
            "path": str(target),
            "name": target.name,
            "exists": True,
            "created": False,
            "skipped": True,
            "reason": "exists",
            "width": width,
            "height": height,
            "triangleCount": int(base_row.get("triangleCount") or 0) + total_variant_triangles,
            "drawnTriangleCount": int(base_row.get("triangleCount") or 0) + total_variant_triangles,
            "rangeCount": total_ranges,
            "meshCount": 1 + len(variant_rows),
            "variantPartMeshCount": len(variant_rows),
            "deconflictMaskMeshCount": len(mask_rows),
            "variantColorPolicy": UV_COLOR_POLICY_VARIANT_PARTS,
            "baseLineColor": _rgba_css(UV_LINE_COLOR_BASE),
            "variantLineColor": _rgba_css(UV_LINE_COLOR_VARIANT),
        }

    variant_mask = Image.new("L", (width, height), 0)
    variant_mask_draw = ImageDraw.Draw(variant_mask)
    variant_mask_triangles = 0
    for row in mask_rows:
        mesh = row["mesh"]
        for triangle in row["triangles"]:
            points = _triangle_points(mesh, triangle, width, height)
            if len(points) != 3:
                continue
            variant_mask_draw.polygon(points, fill=255)
            variant_mask_triangles += 1
    if variant_mask_triangles and mask_expansion_pixels:
        variant_mask = variant_mask.filter(ImageFilter.MaxFilter((mask_expansion_pixels * 2) + 1))

    filtered_base_triangles: list[int] = []
    suppressed_base_triangles = 0
    base_mesh_data = base_row["mesh"]
    for triangle in base_row["triangles"]:
        points = _triangle_points(base_mesh_data, triangle, width, height)
        if len(points) == 3 and variant_mask_triangles and _triangle_hits_mask(points, variant_mask):
            suppressed_base_triangles += 1
            continue
        filtered_base_triangles.append(triangle)

    if not filtered_base_triangles and not total_variant_triangles:
        return {
            "path": str(target),
            "name": target.name,
            "exists": target.exists(),
            "created": False,
            "skipped": True,
            "reason": "no EXT_SKIN UV triangles found",
            "width": width,
            "height": height,
            "triangleCount": 0,
            "rangeCount": 0,
            "meshCount": 1 + len(variant_rows),
            "variantPartMeshCount": len(variant_rows),
            "deconflictMaskMeshCount": len(mask_rows),
            "baseSuppressedTriangleCount": suppressed_base_triangles,
            "maskExpansionPixels": mask_expansion_pixels,
            "variantColorPolicy": UV_COLOR_POLICY_VARIANT_PARTS,
            "baseLineColor": _rgba_css(UV_LINE_COLOR_BASE),
            "variantLineColor": _rgba_css(UV_LINE_COLOR_VARIANT),
        }

    image = Image.new("RGBA", (width, height), (0, 0, 0, 0))
    draw = ImageDraw.Draw(image)
    line_width = max(1, int(round(max(width, height) / 2048)))

    def draw_triangles(mesh: dict[str, Any], triangles: list[int], line_color: tuple[int, int, int, int]) -> int:
        drawn = 0
        for triangle in triangles:
            points = _triangle_points(mesh, triangle, width, height)
            if len(points) != 3:
                continue
            draw.line([points[0], points[1], points[2], points[0]], fill=line_color, width=line_width)
            drawn += 1
        return drawn

    base_drawn = draw_triangles(base_mesh_data, filtered_base_triangles, UV_LINE_COLOR_BASE)
    variant_drawn = 0
    for row in variant_rows:
        row["role"] = "variant"
        mesh_drawn = draw_triangles(row["mesh"], row["triangles"], UV_LINE_COLOR_VARIANT)
        row["drawnTriangleCount"] = mesh_drawn
        variant_drawn += mesh_drawn

    transpose_flip_top_bottom = getattr(getattr(Image, "Transpose", Image), "FLIP_TOP_BOTTOM")
    image = image.transpose(transpose_flip_top_bottom)

    backup = _backup_existing(target) if overwrite else ""
    image.save(target, format="PNG")
    return {
        "path": str(target),
        "name": target.name,
        "exists": True,
        "created": True,
        "skipped": False,
        "reason": "",
        "backup": backup,
        "width": width,
        "height": height,
        "triangleCount": int(base_row.get("triangleCount") or 0) + total_variant_triangles,
        "drawnTriangleCount": base_drawn + variant_drawn,
        "rangeCount": total_ranges,
        "meshCount": 1 + len(variant_rows),
        "variantPartMeshCount": len(variant_rows),
        "deconflictMaskMeshCount": len(mask_rows),
        "baseTriangleCount": int(base_row.get("triangleCount") or 0),
        "baseDrawnTriangleCount": base_drawn,
        "baseSuppressedTriangleCount": suppressed_base_triangles,
        "variantMaskTriangleCount": variant_mask_triangles,
        "deconflictPolicy": "variant_uv_mask",
        "maskExpansionPixels": mask_expansion_pixels,
        "variantColorPolicy": UV_COLOR_POLICY_VARIANT_PARTS,
        "baseLineColor": _rgba_css(UV_LINE_COLOR_BASE),
        "variantLineColor": _rgba_css(UV_LINE_COLOR_VARIANT),
        "meshes": [
            {
                "index": 0,
                "file": base_row.get("file"),
                "meshName": base_row.get("meshName"),
                "role": "base",
                "triangleCount": int(base_row.get("triangleCount") or 0),
                "drawnTriangleCount": base_drawn,
                "suppressedTriangleCount": suppressed_base_triangles,
                "rangeCount": int(base_row.get("rangeCount") or 0),
            },
            *[
                {
                    key: row.get(key)
                    for key in ("index", "file", "meshName", "role", "triangleCount", "drawnTriangleCount", "rangeCount")
                }
                for row in variant_rows
            ],
        ],
    }


def write_ext_banner_uv_template(
    mesh: dict[str, Any],
    out_path: str | Path,
    *,
    width: int = 4096,
    height: int = 1024,
    overwrite: bool = False,
) -> dict[str, Any]:
    triangles, range_count = _banner_triangles(mesh)
    return _write_uv_template(
        mesh,
        out_path,
        triangles,
        range_count,
        width=width,
        height=height,
        overwrite=overwrite,
        empty_reason="no EXT_BANNER UV triangles found",
    )


def write_driver_uv_template(
    mesh: dict[str, Any],
    out_path: str | Path,
    category: str,
    *,
    size: int | None = None,
    width: int | None = None,
    height: int | None = None,
    overwrite: bool = False,
) -> dict[str, Any]:
    target_width = int(width or size or 4096)
    target_height = int(height or size or target_width)
    driver_category = str(category or "").strip().lower()
    if driver_category not in DRIVER_UV_TEMPLATE_CATEGORIES:
        return {
            "path": str(Path(out_path)),
            "name": Path(out_path).name,
            "exists": Path(out_path).exists(),
            "created": False,
            "skipped": True,
            "reason": f"unsupported driver UV category: {driver_category or '-'}",
            "width": target_width,
            "height": target_height,
            "triangleCount": 0,
            "rangeCount": 0,
        }
    triangles, range_count = _skin_triangles(mesh) if driver_category == "helmet" else _all_triangles(mesh)
    return _write_uv_template(
        mesh,
        out_path,
        triangles,
        range_count,
        width=target_width,
        height=target_height,
        overwrite=overwrite,
        empty_reason=f"no {driver_category} UV triangles found",
    )


def _uv_template_project_data(project_dir: str | Path, uv_template: dict[str, Any]) -> dict[str, Any]:
    uv_path = Path(str(uv_template.get("path") or ""))
    rel_path = _portable_rel(Path(project_dir), uv_path) if uv_path else ""
    data = {
        "path": rel_path,
        "width": int(uv_template.get("width") or 0),
        "height": int(uv_template.get("height") or 0),
        "triangleCount": int(uv_template.get("triangleCount") or 0),
        "drawnTriangleCount": int(uv_template.get("drawnTriangleCount") or 0),
        "rangeCount": int(uv_template.get("rangeCount") or 0),
        "updatedAt": datetime.now().isoformat(timespec="seconds"),
    }
    for key in (
        "meshCount",
        "variantPartMeshCount",
        "replacementLikeVariantPartMeshCount",
        "deconflictPolicy",
        "deconflictMaskMeshCount",
        "baseTriangleCount",
        "baseDrawnTriangleCount",
        "baseSuppressedTriangleCount",
        "variantMaskTriangleCount",
        "maskExpansionPixels",
        "variantPolicy",
        "variantColorPolicy",
        "baseLineColor",
        "variantLineColor",
        "partsLineColor",
        "meshes",
    ):
        if key in uv_template:
            data[key] = uv_template.get(key)
    return data


def update_preview_project_uv_template(project_path: str | Path, project_dir: str | Path, uv_template: dict[str, Any]) -> None:
    path = Path(project_path)
    if not path.exists():
        return
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return
    if not isinstance(data, dict):
        return
    data["uvTemplateName"] = str(uv_template.get("name") or UV_TEMPLATE_NAME)
    data["uvTemplate"] = _uv_template_project_data(project_dir, uv_template)
    data["uvTemplatePath"] = data["uvTemplate"]["path"]
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def update_preview_project_parts_uv_template(project_path: str | Path, project_dir: str | Path, uv_template: dict[str, Any]) -> None:
    path = Path(project_path)
    if not path.exists():
        return
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return
    if not isinstance(data, dict):
        return
    data["partsUvTemplateName"] = str(uv_template.get("name") or PARTS_UV_TEMPLATE_NAME)
    data["partsUvTemplate"] = _uv_template_project_data(project_dir, uv_template)
    data["partsUvTemplatePath"] = data["partsUvTemplate"]["path"]
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def clear_preview_project_parts_uv_template(project_path: str | Path) -> None:
    path = Path(project_path)
    if not path.exists():
        return
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return
    if not isinstance(data, dict):
        return
    for key in ("partsUvTemplateName", "partsUvTemplatePath", "partsUvTemplate"):
        data.pop(key, None)
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def update_preview_project_variant_uv_template(project_path: str | Path, project_dir: str | Path, uv_template: dict[str, Any]) -> None:
    path = Path(project_path)
    if not path.exists():
        return
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return
    if not isinstance(data, dict):
        return
    data["variantUvTemplateName"] = str(uv_template.get("name") or VARIANT_UV_TEMPLATE_NAME)
    data["variantUvTemplate"] = _uv_template_project_data(project_dir, uv_template)
    data["variantUvTemplatePath"] = data["variantUvTemplate"]["path"]
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")


def update_preview_project_banner_uv_template(project_path: str | Path, project_dir: str | Path, uv_template: dict[str, Any]) -> None:
    path = Path(project_path)
    if not path.exists():
        return
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return
    if not isinstance(data, dict):
        return
    data["bannerUvTemplateName"] = str(uv_template.get("name") or BANNER_UV_TEMPLATE_NAME)
    data["bannerUvTemplate"] = _uv_template_project_data(project_dir, uv_template)
    data["bannerUvTemplatePath"] = data["bannerUvTemplate"]["path"]
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
