from __future__ import annotations

import json
import os
import re
import struct
from datetime import datetime
from pathlib import Path
from typing import Any

from liverylab_core import project_types


IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp", ".tga", ".tif", ".tiff"}
MODEL_EXTENSIONS = {".glb", ".gltf"}
LIVE_TEXTURE_NAME = "EXT_SKIN_C.png"
LIVE_AO_TEXTURE_NAME = "ext_skin_ao.png"
LIVE_DETAIL_TEXTURE_NAME = "EXT_Skin_D.png"
LIVE_BANNER_TEXTURE_NAME = "EXT_Banner_C.png"
PROJECT_FILE_NAME = "preview_project.json"
BODY_BASE_COLOR_DEFAULT = "#7a00ff"
GLB_EXPORTER_VERSION = 8
GLB_MAGIC = 0x46546C67
GLB_VERSION = 2
JSON_CHUNK_TYPE = 0x4E4F534A
BIN_CHUNK_TYPE = 0x004E4942


def _clean_path(value: Any) -> str:
    return str(value or "").strip()


def clean_hex_color(value: Any, fallback: str = "#ffffff") -> str:
    text = str(value or "").strip()
    if re.fullmatch(r"#[0-9A-Fa-f]{6}", text):
        return text.lower()
    if re.fullmatch(r"[0-9A-Fa-f]{6}", text):
        return f"#{text.lower()}"
    return fallback


def _file_info(path_text: str, allowed_extensions: set[str]) -> dict[str, Any]:
    path = Path(_clean_path(path_text)).expanduser()
    exists = path.is_file()
    suffix = path.suffix.lower()
    info: dict[str, Any] = {
        "path": str(path) if str(path_text or "").strip() else "",
        "exists": bool(exists),
        "extension": suffix,
        "supported": bool(suffix in allowed_extensions),
        "size": None,
        "mtime": None,
        "mtime_ms": None,
    }
    if exists:
        stat = path.stat()
        info.update(
            {
                "size": int(stat.st_size),
                "mtime": float(stat.st_mtime),
                "mtime_ms": int(stat.st_mtime * 1000),
            }
        )
    return info


def _infer_auxiliary_model_paths(model_path: str) -> list[str]:
    path = Path(_clean_path(model_path)).expanduser()
    if not path.is_file():
        return []
    wheels_dir = path.parent / "wheels"
    if not wheels_dir.is_dir():
        return []
    return [str(row) for row in sorted(wheels_dir.glob("*.glb"), key=lambda item: item.name.lower())]


def normalize_project(payload: dict[str, Any] | None) -> dict[str, Any]:
    data = payload if isinstance(payload, dict) else {}
    interval = data.get("watchIntervalMs", 1000)
    try:
        interval_ms = int(interval)
    except Exception:
        interval_ms = 1000
    interval_ms = max(250, min(interval_ms, 10000))
    auxiliary_paths_raw = data.get("auxiliaryModelPaths") or []
    if isinstance(auxiliary_paths_raw, str):
        auxiliary_paths = [_clean_path(auxiliary_paths_raw)]
    elif isinstance(auxiliary_paths_raw, list):
        auxiliary_paths = [_clean_path(row) for row in auxiliary_paths_raw]
    else:
        auxiliary_paths = []
    auxiliary_paths = [row for row in auxiliary_paths if row]
    if not auxiliary_paths:
        auxiliary_paths = _infer_auxiliary_model_paths(_clean_path(data.get("modelPath")))
    auxiliary_models_raw = data.get("auxiliaryModels") or []
    auxiliary_models = [dict(row) for row in auxiliary_models_raw if isinstance(row, dict)] if isinstance(auxiliary_models_raw, list) else []
    hidden_materials_raw = data.get("hiddenMaterialNames") or []
    hidden_materials = (
        [_clean_path(row) for row in hidden_materials_raw]
        if isinstance(hidden_materials_raw, list)
        else []
    )
    hidden_materials = [row for row in hidden_materials if row]
    hidden_exterior_materials_raw = data.get("hiddenExteriorMaterialNames") or []
    hidden_exterior_materials = (
        [_clean_path(row) for row in hidden_exterior_materials_raw]
        if isinstance(hidden_exterior_materials_raw, list)
        else []
    )
    hidden_exterior_materials = [row for row in hidden_exterior_materials if row]
    material_finish_raw = data.get("materialFinish") if isinstance(data.get("materialFinish"), dict) else {}
    def _finish_float(name: str, fallback: float) -> float:
        try:
            number = float(material_finish_raw.get(name, fallback))
        except Exception:
            number = fallback
        return max(0.0, min(number, 1.0))
    return {
        "modelPath": _clean_path(data.get("modelPath")),
        "auxiliaryModelPaths": auxiliary_paths,
        "auxiliaryModels": auxiliary_models,
        "texturePath": _clean_path(data.get("texturePath")),
        "aoTexturePath": _clean_path(data.get("aoTexturePath")),
        "detailTexturePath": _clean_path(data.get("detailTexturePath")),
        "bannerTexturePath": _clean_path(data.get("bannerTexturePath")),
        "materialName": _clean_path(data.get("materialName")) or "EXT_SKIN",
        "bannerMaterialName": _clean_path(data.get("bannerMaterialName")) or "EXT_BANNER",
        "hiddenMaterialNames": hidden_materials,
        "hiddenExteriorMaterialNames": hidden_exterior_materials,
        "hideExteriorModel": data.get("hideExteriorModel") is True,
        "variantPartPreview": dict(data.get("variantPartPreview") or {}) if isinstance(data.get("variantPartPreview"), dict) else {},
        "autoRefresh": bool(data.get("autoRefresh", True)),
        "watchIntervalMs": interval_ms,
        "lastTextureMtimeMs": data.get("lastTextureMtimeMs"),
        "bodyBaseColor": clean_hex_color(data.get("bodyBaseColor"), BODY_BASE_COLOR_DEFAULT),
        "bodyBaseColorEnabled": data.get("bodyBaseColorEnabled") is True,
        "bodyBaseColorMode": _clean_path(data.get("bodyBaseColorMode")),
        "bodyBaseColorCapability": dict(data.get("bodyBaseColorCapability") or {}) if isinstance(data.get("bodyBaseColorCapability"), dict) else {},
        "materialFinish": {
            "metalness": _finish_float("metalness", 0.0),
            "roughness": _finish_float("roughness", 0.3),
            "clearCoat": _finish_float("clearCoat", 1.0),
            "clearCoatRoughness": _finish_float("clearCoatRoughness", 0.3),
            "scope": "base",
        },
    }


def _first_existing_path(*values: Any) -> Path | None:
    for value in values:
        text = _clean_path(value)
        if not text:
            continue
        path = Path(text).expanduser()
        if path.is_file():
            return path
    return None


def derive_body_base_color(payload: dict[str, Any] | None) -> dict[str, Any]:
    data = payload if isinstance(payload, dict) else {}
    color_path = _first_existing_path(data.get("colorTexturePath"), data.get("texturePath"), data.get("liveTexturePath"))
    detail_path = _first_existing_path(data.get("detailTexturePath"), data.get("liveDetailTexturePath"))
    if not color_path:
        raise FileNotFoundError("EXT_SKIN_C preview texture was not found")
    if not detail_path:
        raise FileNotFoundError("EXT_Skin_D preview texture was not found")
    try:
        from PIL import Image
    except Exception as exc:
        raise RuntimeError(f"Pillow is required to detect the body base color: {exc}") from exc

    with Image.open(color_path) as color_image_raw, Image.open(detail_path) as detail_image_raw:
        color_image = color_image_raw.convert("RGBA")
        detail_image = detail_image_raw.convert("RGB")
        resampling = getattr(Image, "Resampling", Image)
        max_edge = 512
        if max(color_image.size) > max_edge:
            color_image.thumbnail((max_edge, max_edge), resampling.BILINEAR)
        if detail_image.size != color_image.size:
            detail_image = detail_image.resize(color_image.size, resampling.NEAREST)

        color_pixels = color_image.load()
        detail_pixels = detail_image.load()
        red_values: list[int] = []
        green_values: list[int] = []
        blue_values: list[int] = []
        width, height = color_image.size
        for y in range(height):
            for x in range(width):
                mask_r, mask_g, mask_b = detail_pixels[x, y]
                if mask_r < 32 or mask_r < mask_g + 8 or mask_r < mask_b + 8:
                    continue
                source_r, source_g, source_b, source_a = color_pixels[x, y]
                if source_a < 16:
                    continue
                red_values.append(source_r)
                green_values.append(source_g)
                blue_values.append(source_b)

    sample_count = len(red_values)
    if not sample_count:
        raise ValueError("No red base-color area was found in EXT_Skin_D")

    red_values.sort()
    green_values.sort()
    blue_values.sort()
    mid = sample_count // 2
    if sample_count % 2:
        rgb = (red_values[mid], green_values[mid], blue_values[mid])
    else:
        rgb = (
            (red_values[mid - 1] + red_values[mid]) // 2,
            (green_values[mid - 1] + green_values[mid]) // 2,
            (blue_values[mid - 1] + blue_values[mid]) // 2,
        )
    return {
        "color": f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x}",
        "sampleCount": sample_count,
        "colorTexturePath": str(color_path),
        "detailTexturePath": str(detail_path),
        "method": "median-red-mask",
    }


def inspect_project(payload: dict[str, Any] | None) -> dict[str, Any]:
    project = normalize_project(payload)
    model = _file_info(project["modelPath"], MODEL_EXTENSIONS)
    auxiliary_models = [_file_info(row, MODEL_EXTENSIONS) for row in project.get("auxiliaryModelPaths", [])]
    texture = _file_info(project["texturePath"], IMAGE_EXTENSIONS)
    ao_texture = _file_info(project["aoTexturePath"], IMAGE_EXTENSIONS)
    detail_texture = _file_info(project["detailTexturePath"], IMAGE_EXTENSIONS)
    banner_texture = _file_info(project["bannerTexturePath"], IMAGE_EXTENSIONS)
    previous_mtime = project.get("lastTextureMtimeMs")
    changed = bool(texture.get("mtime_ms") and texture.get("mtime_ms") != previous_mtime)
    ready = bool(model["exists"] and model["supported"] and texture["exists"] and texture["supported"])
    warnings: list[str] = []
    if project["modelPath"] and not model["exists"]:
        warnings.append(f"Model file not found: {project['modelPath']}")
    elif project["modelPath"] and not model["supported"]:
        warnings.append("Model must be a .glb or .gltf file.")
    for auxiliary in auxiliary_models:
        if auxiliary["path"] and not auxiliary["exists"]:
            warnings.append(f"Auxiliary model file not found: {auxiliary['path']}")
        elif auxiliary["path"] and not auxiliary["supported"]:
            warnings.append("Auxiliary model must be a .glb or .gltf file.")
    if project["texturePath"] and not texture["exists"]:
        warnings.append(f"Texture file not found: {project['texturePath']}")
    elif project["texturePath"] and not texture["supported"]:
        warnings.append("Live texture must be a browser-readable image, preferably PNG.")
    if project["aoTexturePath"] and ao_texture["exists"] and not ao_texture["supported"]:
        warnings.append("AO texture must be a browser-readable image, preferably PNG.")
    if project["bannerTexturePath"] and banner_texture["exists"] and not banner_texture["supported"]:
        warnings.append("Banner texture must be a browser-readable image, preferably PNG.")
    if project["detailTexturePath"] and detail_texture["exists"] and not detail_texture["supported"]:
        warnings.append("Detail/Splat texture must be a browser-readable image, preferably PNG.")
    return {
        "project": project,
        "model": model,
        "auxiliaryModels": auxiliary_models,
        "texture": texture,
        "aoTexture": ao_texture,
        "detailTexture": detail_texture,
        "bannerTexture": banner_texture,
        "textureChanged": changed,
        "ready": ready,
        "warnings": warnings,
    }


def safe_segment(value: Any, fallback: str) -> str:
    text = str(value or "").strip()
    text = re.sub(r"[^0-9A-Za-z._-]+", "_", text).strip("._-")
    return text or fallback


def _split_content_path(path: str) -> list[str]:
    return [part for part in re.split(r"[\\/]+", str(path or "").strip()) if part]


def _norm_game_rel(path: str) -> str:
    return "\\".join(_split_content_path(path))


def _portable_rel(base: Path, path: str | Path | None) -> str:
    if not path:
        return ""
    try:
        return _norm_game_rel(os.path.relpath(Path(path), base))
    except Exception:
        return Path(path).name


def _project_stored_path(project_dir: Path, value: Any) -> str:
    text = str(value or "").strip()
    if not text:
        return ""
    path = Path(text)
    if path.is_absolute():
        return str(path)
    return str((project_dir / path).resolve())


def _path_info(path: Path | None) -> dict[str, Any]:
    if not path:
        return {"path": "", "exists": False}
    return {"path": str(path), "exists": path.exists()}


def find_exterior_mesh(game_root: Path, car_content_path: str) -> dict[str, Any]:
    parts = _split_content_path(car_content_path)
    if not parts:
        return {"path": "", "relPath": "", "exists": False, "candidates": [], "warnings": ["Car content path is empty."]}

    car_dir = game_root / Path(*parts)
    mesh_dir = car_dir / "meshes"
    warnings: list[str] = []
    if not mesh_dir.exists():
        return {
            "path": "",
            "relPath": "",
            "exists": False,
            "candidates": [],
            "warnings": [f"Meshes folder not found: {mesh_dir}"],
        }

    candidates = sorted(mesh_dir.glob("*exterior.mesh"), key=lambda row: row.name.lower())
    if not candidates:
        warnings.append(f"No *exterior.mesh file found in: {mesh_dir}")
        selected = None
    else:
        selected = candidates[0]
        if len(candidates) > 1:
            warnings.append("Multiple exterior meshes found; using the first one.")

    rel_path = ""
    if selected:
        try:
            rel_path = _norm_game_rel(str(selected.relative_to(game_root)))
        except Exception:
            rel_path = _norm_game_rel(str(selected))

    return {
        "path": str(selected) if selected else "",
        "relPath": rel_path,
        "exists": bool(selected and selected.exists()),
        "candidates": [str(row) for row in candidates],
        "warnings": warnings,
    }


def prepare_livery_project(preview_root: Path, game_root: Path, skin_entry: dict[str, Any]) -> dict[str, Any]:
    car_model = safe_segment(skin_entry.get("car_model"), "car")
    skin_id = safe_segment(skin_entry.get("skin_id") or skin_entry.get("display_name"), "skin")
    car_content_path = _norm_game_rel(str(skin_entry.get("car_content_path") or f"content\\cars\\{car_model}"))
    skin_key = str(skin_entry.get("key") or "").strip()
    display_name = str(skin_entry.get("display_name") or skin_id).strip()
    project_type = project_types.project_type_for_entry(skin_entry)

    project_dir = preview_root / car_model / skin_id
    models_dir = preview_root / "_models" / car_model
    project_dir.mkdir(parents=True, exist_ok=True)
    models_dir.mkdir(parents=True, exist_ok=True)

    live_texture_path = project_dir / LIVE_TEXTURE_NAME
    live_detail_texture_path = project_dir / LIVE_DETAIL_TEXTURE_NAME
    live_banner_texture_path = project_dir / LIVE_BANNER_TEXTURE_NAME
    project_path = project_dir / PROJECT_FILE_NAME
    exterior_mesh = find_exterior_mesh(game_root, car_content_path)
    now = datetime.now().isoformat(timespec="seconds")

    existing: dict[str, Any] = {}
    if project_path.exists():
        try:
            data = json.loads(project_path.read_text(encoding="utf-8"))
            existing = data if isinstance(data, dict) else {}
        except Exception:
            existing = {}

    project_ao_texture_path = project_dir / LIVE_AO_TEXTURE_NAME
    converted_ao_texture_path = preview_root.parent / "LiveryLibrary" / "Converted" / "Textures" / car_model / LIVE_AO_TEXTURE_NAME
    existing_ao_texture_path = _project_stored_path(project_dir, existing.get("aoTexturePath"))
    ao_texture_path = existing_ao_texture_path if existing_ao_texture_path and Path(existing_ao_texture_path).exists() else ""
    if not ao_texture_path and project_ao_texture_path.exists():
        ao_texture_path = str(project_ao_texture_path)
    if not ao_texture_path and converted_ao_texture_path.exists():
        ao_texture_path = str(converted_ao_texture_path)

    body_base_color = clean_hex_color(existing.get("bodyBaseColor"), "")
    body_base_color_detection: dict[str, Any] | None = None
    if not body_base_color and live_texture_path.exists() and live_detail_texture_path.exists():
        try:
            body_base_color_detection = derive_body_base_color(
                {
                    "colorTexturePath": str(live_texture_path),
                    "detailTexturePath": str(live_detail_texture_path),
                }
            )
            body_base_color = body_base_color_detection.get("color", "")
        except Exception:
            body_base_color_detection = None
    if not body_base_color:
        body_base_color = BODY_BASE_COLOR_DEFAULT

    project = {
        "version": 1,
        "skinKey": skin_key,
        "displayName": display_name,
        "skinId": skin_id,
        "projectType": project_type,
        "carModel": car_model,
        "carContentPath": car_content_path,
        "gameRoot": str(game_root),
        "projectFolder": str(project_dir),
        "liveTextureName": LIVE_TEXTURE_NAME,
        "liveTexturePath": str(live_texture_path),
        "liveAoTextureName": LIVE_AO_TEXTURE_NAME,
        "liveAoTexturePath": str(project_ao_texture_path),
        "aoTexturePath": ao_texture_path,
        "liveDetailTextureName": LIVE_DETAIL_TEXTURE_NAME,
        "liveDetailTexturePath": str(live_detail_texture_path),
        "liveBannerTextureName": LIVE_BANNER_TEXTURE_NAME,
        "liveBannerTexturePath": str(live_banner_texture_path),
        "projectPath": str(project_path),
        "exteriorMeshPath": exterior_mesh["path"],
        "exteriorMeshRelPath": exterior_mesh["relPath"],
        "modelCachePath": str(models_dir / "exterior.glb"),
        "modelMetaPath": str(models_dir / "exterior.meta.json"),
        "materialName": str(existing.get("materialName") or "EXT_SKIN"),
        "bannerMaterialName": str(existing.get("bannerMaterialName") or "EXT_BANNER"),
        "createdAt": str(existing.get("createdAt") or now),
        "updatedAt": now,
    }
    if isinstance(existing.get("materialFinish"), dict):
        project["materialFinish"] = normalize_project({"materialFinish": existing["materialFinish"]})["materialFinish"]
    if body_base_color:
        project["bodyBaseColor"] = clean_hex_color(body_base_color)
    if body_base_color_detection:
        project["bodyBaseColorDetection"] = body_base_color_detection
    if isinstance(existing.get("numberplateOverlay"), dict):
        project["numberplateOverlay"] = existing["numberplateOverlay"]
    stored_project = {
        **project,
        "projectFolder": ".",
        "liveTexturePath": LIVE_TEXTURE_NAME,
        "liveAoTexturePath": LIVE_AO_TEXTURE_NAME,
        "aoTexturePath": _portable_rel(project_dir, ao_texture_path) if ao_texture_path else "",
        "liveDetailTexturePath": LIVE_DETAIL_TEXTURE_NAME,
        "liveBannerTexturePath": LIVE_BANNER_TEXTURE_NAME,
        "projectPath": PROJECT_FILE_NAME,
        "exteriorMeshPath": exterior_mesh["relPath"],
        "modelCachePath": _portable_rel(project_dir, models_dir / "exterior.glb"),
        "modelMetaPath": _portable_rel(project_dir, models_dir / "exterior.meta.json"),
    }
    project_path.write_text(json.dumps(stored_project, indent=2, ensure_ascii=False), encoding="utf-8")

    warnings = list(exterior_mesh.get("warnings") or [])
    if not live_texture_path.exists():
        warnings.append(f"Live texture is not created yet. Export your Photoshop PNG as {LIVE_TEXTURE_NAME}.")
    if not project_ao_texture_path.exists():
        warnings.append(f"AO texture is optional. Import ext_skin_ao to PNG at {project_ao_texture_path} to test ambient occlusion.")
    if not live_banner_texture_path.exists():
        warnings.append(f"Banner texture is optional. Export it as {LIVE_BANNER_TEXTURE_NAME} when this livery uses a custom window banner.")
    if not live_detail_texture_path.exists():
        warnings.append(f"Detail/Splat texture is optional for preview inspection. Export it as {LIVE_DETAIL_TEXTURE_NAME} when needed.")

    return {
        "project": project,
        "projectPath": str(project_path),
        "projectFolder": str(project_dir),
        "liveTexture": _path_info(live_texture_path),
        "aoTexture": _path_info(project_ao_texture_path),
        "liveDetailTexture": _path_info(live_detail_texture_path),
        "liveBannerTexture": _path_info(live_banner_texture_path),
        "exteriorMesh": exterior_mesh,
        "warnings": warnings,
    }


def _pad4(data: bytes, pad: bytes = b"\x00") -> bytes:
    remainder = len(data) % 4
    if not remainder:
        return data
    return data + (pad * (4 - remainder))


def _float_buffer(values: list[tuple[float, ...]], width: int) -> bytes:
    flat: list[float] = []
    for row in values:
        if len(row) != width:
            raise ValueError(f"Invalid vector width for GLB export: expected {width}, got {len(row)}")
        flat.extend(float(value) for value in row)
    return struct.pack("<" + ("f" * len(flat)), *flat) if flat else b""


def _uint_buffer(values: list[int], component_size: int) -> bytes:
    if component_size == 2:
        return struct.pack("<" + ("H" * len(values)), *[int(value) for value in values]) if values else b""
    return struct.pack("<" + ("I" * len(values)), *[int(value) for value in values]) if values else b""


def _bounds_vec3(values: list[tuple[float, ...]]) -> tuple[list[float], list[float]]:
    xs = [float(row[0]) for row in values]
    ys = [float(row[1]) for row in values]
    zs = [float(row[2]) for row in values]
    return [min(xs), min(ys), min(zs)], [max(xs), max(ys), max(zs)]


def _preview_material(material_name: str) -> dict[str, Any]:
    name = str(material_name or "MATERIAL").strip() or "MATERIAL"
    upper = name.upper()
    pbr: dict[str, Any] = {"baseColorFactor": [0.02, 0.022, 0.025, 1], "metallicFactor": 0.0, "roughnessFactor": 0.68}
    material: dict[str, Any] = {"name": name, "pbrMetallicRoughness": pbr}
    if upper.startswith("EXT_SKIN"):
        pbr.update({"baseColorFactor": [1, 1, 1, 1], "metallicFactor": 0.0, "roughnessFactor": 0.42})
    elif "WINDOW" in upper or "GLASS" in upper:
        pbr.update({"baseColorFactor": [0.045, 0.06, 0.075, 1.0], "metallicFactor": 0.0, "roughnessFactor": 0.18})
        material.update({"doubleSided": True})
    elif "CARBON" in upper or "GRID" in upper or "TYRE" in upper:
        pbr.update({"baseColorFactor": [0.035, 0.037, 0.04, 1], "metallicFactor": 0.0, "roughnessFactor": 0.76})
    elif "DISC" in upper or "RIM" in upper or "METAL" in upper or "EXHAUST" in upper:
        pbr.update({"baseColorFactor": [0.48, 0.48, 0.46, 1], "metallicFactor": 0.55, "roughnessFactor": 0.34})
    elif "LIGHT" in upper:
        pbr.update({"baseColorFactor": [0.9, 0.88, 0.78, 0.72], "metallicFactor": 0.0, "roughnessFactor": 0.18})
        material.update({"alphaMode": "BLEND", "doubleSided": True})
    elif "DECAL" in upper or "BANNER" in upper:
        pbr.update({"baseColorFactor": [0.92, 0.92, 0.88, 1], "metallicFactor": 0.0, "roughnessFactor": 0.5})
    return material


def _mesh_block_components(indices: list[int], minimum_triangles: int = 1) -> list[dict[str, Any]]:
    triangle_count = len(indices) // 3
    if triangle_count <= 0:
        return []
    vertex_to_triangles: dict[int, list[int]] = {}
    triangles: list[tuple[int, int, int]] = []
    for triangle_index in range(triangle_count):
        triangle = tuple(int(indices[triangle_index * 3 + offset]) for offset in range(3))
        triangles.append(triangle)
        for vertex_index in triangle:
            vertex_to_triangles.setdefault(vertex_index, []).append(triangle_index)

    seen = [False] * triangle_count
    components: list[dict[str, Any]] = []
    for triangle_index in range(triangle_count):
        if seen[triangle_index]:
            continue
        stack = [triangle_index]
        seen[triangle_index] = True
        component_triangles: list[int] = []
        while stack:
            current = stack.pop()
            component_triangles.append(current)
            for vertex_index in triangles[current]:
                for neighbor in vertex_to_triangles.get(vertex_index, []):
                    if seen[neighbor]:
                        continue
                    seen[neighbor] = True
                    stack.append(neighbor)
        if len(component_triangles) < minimum_triangles:
            continue
        component_indices: list[int] = []
        for component_triangle in component_triangles:
            component_indices.extend(triangles[component_triangle])
        components.append({"triangle_count": len(component_triangles), "indices": component_indices})
    components.sort(key=lambda row: int(row.get("triangle_count") or 0), reverse=True)
    return components


def write_mesh_info_glb(mesh_info: dict[str, Any], glb_path: str | Path, material_name: str | None = None) -> dict[str, Any]:
    positions = [tuple(row) for row in (mesh_info.get("positions") or [])]
    indices = [int(value) for value in (mesh_info.get("indices") or [])]
    if not positions or not indices:
        raise ValueError("GLB export requires positions and triangle indices")

    normals = [tuple(row) for row in (mesh_info.get("normals") or [])]
    uvs = [tuple(row) for row in (mesh_info.get("uvs") or [])]
    use_normals = len(normals) == len(positions)
    use_uvs = len(uvs) == len(positions)
    max_index = max(indices) if indices else 0
    index_component_size = 2 if max_index <= 65535 else 4
    index_component_type = 5123 if index_component_size == 2 else 5125

    buffer = bytearray()
    buffer_views: list[dict[str, Any]] = []
    accessors: list[dict[str, Any]] = []

    def add_blob(blob: bytes, target: int | None = None) -> tuple[int, int]:
        offset = len(buffer)
        buffer.extend(_pad4(blob))
        view: dict[str, Any] = {"buffer": 0, "byteOffset": offset, "byteLength": len(blob)}
        if target:
            view["target"] = target
        buffer_views.append(view)
        return len(buffer_views) - 1, offset

    pos_min, pos_max = _bounds_vec3(positions)
    position_view, _ = add_blob(_float_buffer(positions, 3), 34962)
    accessors.append(
        {
            "bufferView": position_view,
            "byteOffset": 0,
            "componentType": 5126,
            "count": len(positions),
            "type": "VEC3",
            "min": pos_min,
            "max": pos_max,
        }
    )
    position_accessor = len(accessors) - 1

    normal_accessor = None
    if use_normals:
        normal_view, _ = add_blob(_float_buffer(normals, 3), 34962)
        accessors.append({"bufferView": normal_view, "byteOffset": 0, "componentType": 5126, "count": len(normals), "type": "VEC3"})
        normal_accessor = len(accessors) - 1

    uv_accessor = None
    if use_uvs:
        uv_view, _ = add_blob(_float_buffer(uvs, 2), 34962)
        accessors.append({"bufferView": uv_view, "byteOffset": 0, "componentType": 5126, "count": len(uvs), "type": "VEC2"})
        uv_accessor = len(accessors) - 1

    attributes: dict[str, int] = {"POSITION": position_accessor}
    if normal_accessor is not None:
        attributes["NORMAL"] = normal_accessor
    if uv_accessor is not None:
        attributes["TEXCOORD_0"] = uv_accessor

    material_name = str(material_name or mesh_info.get("material_name") or mesh_info.get("mesh_name") or "EXT_SKIN").strip() or "EXT_SKIN"
    mesh_name = str(mesh_info.get("mesh_name") or Path(str(mesh_info.get("file") or "exterior")).stem).strip() or "exterior"
    material_blocks = [dict(block) for block in (mesh_info.get("material_blocks") or []) if int(block.get("index_count") or 0) > 0]
    if sum(int(block.get("index_count") or 0) for block in material_blocks) != len(indices):
        material_blocks = [{"name": material_name, "material_path": mesh_info.get("material_path"), "index_count": len(indices)}]

    material_names: list[str] = []
    material_indices: dict[str, int] = {}
    materials: list[dict[str, Any]] = []
    primitives: list[dict[str, Any]] = []
    component_materials: list[dict[str, Any]] = []
    split_materials = {
        str(name or "").strip().upper()
        for name in (mesh_info.get("component_split_materials") or [])
        if str(name or "").strip()
    }
    minimum_component_triangles = max(1, int(mesh_info.get("minimum_component_triangles") or 1))
    index_offset = 0
    for block in material_blocks:
        block_count = int(block.get("index_count") or 0)
        if block_count <= 0:
            continue
        block_name = str(block.get("name") or material_name).strip() or material_name
        block_indices = indices[index_offset : index_offset + block_count]
        index_offset += block_count
        component_rows = _mesh_block_components(block_indices, minimum_component_triangles) if block_name.upper() in split_materials else []
        primitive_rows = component_rows or [{"triangle_count": len(block_indices) // 3, "indices": block_indices}]
        for component_index, primitive_row in enumerate(primitive_rows):
            primitive_indices = [int(value) for value in (primitive_row.get("indices") or [])]
            if not primitive_indices:
                continue
            primitive_name = block_name
            if component_rows:
                primitive_name = f"{block_name}_COMP_{component_index:03d}"
                component_positions = [positions[index] for index in sorted(set(primitive_indices)) if 0 <= index < len(positions)]
                component_min, component_max = _bounds_vec3(component_positions) if component_positions else ([], [])
                component_materials.append(
                    {
                        "sourceMaterial": block_name,
                        "material": primitive_name,
                        "componentIndex": component_index,
                        "triangleCount": int(primitive_row.get("triangle_count") or 0),
                        "boundsMin": component_min,
                        "boundsMax": component_max,
                    }
                )
            if primitive_name not in material_indices:
                material_indices[primitive_name] = len(materials)
                material_names.append(primitive_name)
                materials.append(_preview_material(primitive_name))
            index_view, _ = add_blob(_uint_buffer(primitive_indices, index_component_size), 34963)
            accessors.append({"bufferView": index_view, "byteOffset": 0, "componentType": index_component_type, "count": len(primitive_indices), "type": "SCALAR"})
            primitives.append({"attributes": attributes, "indices": len(accessors) - 1, "material": material_indices[primitive_name], "mode": 4})

    gltf = {
        "asset": {"version": "2.0", "generator": "LiveryLab"},
        "scene": 0,
        "scenes": [{"nodes": [0]}],
        "nodes": [{"mesh": 0, "name": mesh_name}],
        "meshes": [{"name": mesh_name, "primitives": primitives}],
        "materials": materials,
        "buffers": [{"byteLength": len(buffer)}],
        "bufferViews": buffer_views,
        "accessors": accessors,
    }

    json_chunk = _pad4(json.dumps(gltf, separators=(",", ":"), ensure_ascii=False).encode("utf-8"), b" ")
    bin_chunk = _pad4(bytes(buffer))
    total_length = 12 + 8 + len(json_chunk) + 8 + len(bin_chunk)
    glb = bytearray()
    glb.extend(struct.pack("<III", GLB_MAGIC, GLB_VERSION, total_length))
    glb.extend(struct.pack("<II", len(json_chunk), JSON_CHUNK_TYPE))
    glb.extend(json_chunk)
    glb.extend(struct.pack("<II", len(bin_chunk), BIN_CHUNK_TYPE))
    glb.extend(bin_chunk)

    out = Path(glb_path)
    out.parent.mkdir(parents=True, exist_ok=True)
    out.write_bytes(bytes(glb))
    return {
        "glbPath": str(out),
        "sizeBytes": out.stat().st_size,
        "meshName": mesh_name,
        "materialName": material_name,
        "materialCount": len(materials),
        "materials": material_names,
        "componentMaterials": component_materials,
        "primitiveCount": len(primitives),
        "vertexCount": len(positions),
        "indexCount": len(indices),
        "triangleCount": len(indices) // 3,
        "hasNormals": use_normals,
        "hasUvs": use_uvs,
        "boundsMin": pos_min,
        "boundsMax": pos_max,
        "exporterVersion": GLB_EXPORTER_VERSION,
    }
