from __future__ import annotations

import copy
import filecmp
import hashlib
import json
import re
import shutil
from pathlib import Path
from typing import Any

from liverylab_core import generated_readmes, official_car_resources, official_external_liveries, official_material_surfaces, uv_templates, variant_parts


PROJECT_ASSET_LAYOUT = "project_root"
PROJECT_README = "README.txt"
PREVIEW_PROJECT = "preview_project.json"
PREVIEW_MODEL_NAME = "exterior.glb"
PREVIEW_AO_TEXTURE_NAME = "ext_skin_ao.png"
PLACEHOLDER_STATE = "official_project_placeholders.json"
AUTO_REFERENCED_PLACEHOLDER_SLOT_IDS = {"ext_skin_c", "ext_skin_d", "ext_skin_r", "ext_skin_m", "ext_skin_cc", "ext_skin_cr"}
LEGACY_BLUE_CARBON_COLOR = {"x": 0, "y": 82, "z": 153}
DEFAULT_BLUE_CARBON_COLOR = {"x": 0, "y": 0, "z": 0}

MATERIAL_FINISH_SLOTS: tuple[tuple[str, str], ...] = (
    ("metalness", "ext_skin_m"),
    ("roughness", "ext_skin_r"),
    ("clearCoat", "ext_skin_cc"),
    ("clearCoatRoughness", "ext_skin_cr"),
)


SLOT_DEFINITIONS: tuple[dict[str, str], ...] = (
    {
        "id": "ext_skin_c",
        "materialBatch": "EXT_SKIN",
        "group": "base",
        "field": "base_color_replacement_texture_path",
        "filename": "EXT_Skin_C.png",
        "label": "EXT_Skin_C",
        "pathKey": "liveTexturePath",
        "nameKey": "liveTextureName",
        "placeholderRgba": (255, 255, 255, 255),
    },
    {
        "id": "ext_skin_d",
        "materialBatch": "EXT_SKIN",
        "group": "",
        "field": "splat_map_replacement_texture_path",
        "filename": "EXT_Skin_D.png",
        "label": "EXT_Skin_D",
        "pathKey": "liveDetailTexturePath",
        "nameKey": "liveDetailTextureName",
        "placeholderRgba": (0, 0, 0, 255),
    },
    {
        "id": "ext_skin_r",
        "materialBatch": "EXT_SKIN",
        "group": "base",
        "field": "roughness_replacement_texture_path",
        "filename": "EXT_Skin_R.png",
        "label": "EXT_Skin_R",
        "pathKey": "liveRoughnessTexturePath",
        "nameKey": "liveRoughnessTextureName",
        "placeholderRgba": (77, 77, 77, 255),
    },
    {
        "id": "ext_skin_m",
        "materialBatch": "EXT_SKIN",
        "group": "base",
        "field": "metalness_replacement_texture_path",
        "filename": "EXT_Skin_M.png",
        "label": "EXT_Skin_M",
        "pathKey": "liveMetalnessTexturePath",
        "nameKey": "liveMetalnessTextureName",
        "placeholderRgba": (0, 0, 0, 255),
    },
    {
        "id": "ext_skin_cc",
        "materialBatch": "EXT_SKIN",
        "group": "base",
        "field": "clearcoat_replacement_texture_path",
        "filename": "EXT_Skin_CC.png",
        "label": "EXT_Skin_CC",
        "pathKey": "liveClearCoatTexturePath",
        "nameKey": "liveClearCoatTextureName",
        "placeholderRgba": (255, 255, 255, 255),
    },
    {
        "id": "ext_skin_cr",
        "materialBatch": "EXT_SKIN",
        "group": "base",
        "field": "clearcoat_roughness_replacement_texture_path",
        "filename": "EXT_Skin_CR.png",
        "label": "EXT_Skin_CR",
        "pathKey": "liveClearCoatRoughnessTexturePath",
        "nameKey": "liveClearCoatRoughnessTextureName",
        "placeholderRgba": (77, 77, 77, 255),
    },
    {
        "id": "ext_banner_c",
        "materialBatch": "EXT_BANNER",
        "group": "base",
        "field": "base_color_replacement_texture_path",
        "filename": "EXT_Banner_C.png",
        "label": "EXT_Banner_C",
        "pathKey": "liveBannerTexturePath",
        "nameKey": "liveBannerTextureName",
        "placeholderRgba": (255, 255, 255, 255),
    },
)


def slot_definitions() -> list[dict[str, str]]:
    return [dict(row) for row in SLOT_DEFINITIONS]


def slot_definitions_for_material_batch(material_batch: str = "") -> list[dict[str, str]]:
    batch = str(material_batch or "").strip() or "EXT_SKIN"
    rows = slot_definitions()
    for row in rows:
        if str(row.get("materialBatch") or "").strip().upper() == "EXT_SKIN":
            row["materialBatch"] = batch
    return rows


def asset_layout_manifest() -> dict[str, Any]:
    return {
        "formatVersion": 1,
        "layout": PROJECT_ASSET_LAYOUT,
        "textureRoot": ".",
        "slots": slot_definitions(),
    }


def _empty_material(batch_name: str) -> dict[str, Any]:
    if batch_name.upper().startswith("EXT_SKIN"):
        return {
            "batch_name": batch_name,
            "base": {
                "base_color_replacement_texture_path": "",
                "roughness_replacement_texture_path": "",
                "metalness_replacement_texture_path": "",
                "clearcoat_replacement_texture_path": "",
                "clearcoat_roughness_replacement_texture_path": "",
                "emissive_replacement_texture_path": "",
                "emissive_color": {"x": 0, "y": 0, "z": 0},
                "emissive_intensity": 0,
            },
            "red": {
                "base_color": {"x": 178, "y": 178, "z": 178},
                "roughness": 0.4,
                "metalness": 0.8,
                "clearcoat": 0.8,
                "clearcoat_roughness": 0.05,
                "normal_scale": 0.4,
                "clearcoat_normal_scale": 0.03,
            },
            "green": {
                "base_color": {"x": 55, "y": 55, "z": 55},
                "roughness": 0.5,
                "metalness": 0.3,
                "clearcoat": 0.8,
                "clearcoat_roughness": 0.15,
                "normal_scale": 0.2,
                "clearcoat_normal_scale": 0,
            },
            "blue": {
                "base_color": dict(DEFAULT_BLUE_CARBON_COLOR),
                "roughness": 0.4,
                "metalness": 0.8,
                "clearcoat": 0.6,
                "clearcoat_roughness": 0.28,
                "normal_scale": 0.4,
                "clearcoat_normal_scale": 0,
            },
            "splat_map_replacement_texture_path": "",
        }
    return {
        "batch_name": batch_name,
        "base": {
            "base_color_replacement_texture_path": "",
            "roughness_replacement_texture_path": "",
            "metalness_replacement_texture_path": "",
            "clearcoat_replacement_texture_path": "",
            "clearcoat_roughness_replacement_texture_path": "",
            "emissive_replacement_texture_path": "",
            "emissive_color": {"x": 0, "y": 0, "z": 0},
            "emissive_intensity": 0,
        },
        "red": {
            "base_color": {"x": 0, "y": 0, "z": 0},
            "roughness": 0,
            "metalness": 0,
            "clearcoat": 0,
            "clearcoat_roughness": 0,
            "normal_scale": 0,
            "clearcoat_normal_scale": 0,
        },
        "green": {
            "base_color": {"x": 0, "y": 0, "z": 0},
            "roughness": 0,
            "metalness": 0,
            "clearcoat": 0,
            "clearcoat_roughness": 0,
            "normal_scale": 0,
            "clearcoat_normal_scale": 0,
        },
        "blue": {
            "base_color": {"x": 0, "y": 0, "z": 0},
            "roughness": 0,
            "metalness": 0,
            "clearcoat": 0,
            "clearcoat_roughness": 0,
            "normal_scale": 0,
            "clearcoat_normal_scale": 0,
        },
        "splat_map_replacement_texture_path": "",
    }


def initial_external_livery_json(car_guid: str = "") -> dict[str, Any]:
    return official_external_liveries.external_livery_data_with_linker({
        "car_guid": str(car_guid or "").strip(),
        "materials": [_empty_material("EXT_SKIN"), _empty_material("EXT_BANNER")],
    })


def readme_text() -> str:
    return generated_readmes.official_project_readme()


def write_project_readme(project_folder: str | Path, *, overwrite: bool = False) -> Path:
    folder = Path(project_folder).expanduser()
    folder.mkdir(parents=True, exist_ok=True)
    target = folder / PROJECT_README
    existing = target.read_text(encoding="utf-8", errors="ignore") if target.exists() else ""
    if overwrite or not target.exists() or generated_readmes.is_managed_official_project_readme(existing):
        target.write_text(readme_text(), encoding="utf-8")
    return target


def _material_by_batch(data: dict[str, Any], batch_name: str) -> dict[str, Any]:
    materials = data.get("materials") if isinstance(data.get("materials"), list) else []
    for material in materials:
        if isinstance(material, dict) and str(material.get("batch_name") or "").strip().upper() == batch_name.upper():
            return material
    return {}


def _ensure_material_by_batch(data: dict[str, Any], batch_name: str) -> dict[str, Any]:
    materials = data.get("materials") if isinstance(data.get("materials"), list) else None
    if materials is None:
        materials = []
        data["materials"] = materials
    for material in materials:
        if isinstance(material, dict) and str(material.get("batch_name") or "").strip().upper() == batch_name.upper():
            return material
    material = _empty_material(batch_name)
    materials.append(material)
    return material


def _slot_json_value(data: dict[str, Any], slot: dict[str, str]) -> str:
    material = _material_by_batch(data, slot.get("materialBatch") or "")
    if not material:
        return ""
    field = str(slot.get("field") or "").strip()
    group = str(slot.get("group") or "").strip()
    if group:
        group_data = material.get(group) if isinstance(material.get(group), dict) else {}
        return str(group_data.get(field) or "").strip()
    return str(material.get(field) or "").strip()


def _set_slot_json_value(data: dict[str, Any], slot: dict[str, str], value: str) -> None:
    material = _ensure_material_by_batch(data, slot.get("materialBatch") or "")
    field = str(slot.get("field") or "").strip()
    group = str(slot.get("group") or "").strip()
    if group:
        group_data = material.get(group) if isinstance(material.get(group), dict) else {}
        group_data[field] = value
        material[group] = group_data
        return
    material[field] = value


def _load_external_json(project_folder: Path) -> tuple[dict[str, Any], list[str]]:
    path = project_folder / official_external_liveries.EXTERNAL_LIVERY_JSON
    if not path.exists() or not path.is_file():
        return {}, [f"{official_external_liveries.EXTERNAL_LIVERY_JSON} is missing"]
    try:
        data, warnings = official_external_liveries._read_external_livery_json(path)
    except Exception as exc:
        return {}, [str(exc)]
    return data, list(warnings or [])


def _external_json_for_refresh(project_folder: Path, car_guid: str | None = None) -> tuple[dict[str, Any], list[str]]:
    path = project_folder / official_external_liveries.EXTERNAL_LIVERY_JSON
    warnings: list[str] = []
    if path.exists() and path.is_file():
        data, loaded_warnings = official_external_liveries._read_external_livery_json(path)
        warnings.extend(loaded_warnings or [])
    else:
        data = initial_external_livery_json(car_guid or "")
        warnings.append(f"{official_external_liveries.EXTERNAL_LIVERY_JSON} was created")
    if not isinstance(data.get("materials"), list):
        data["materials"] = []
    for batch_name in ("EXT_SKIN", "EXT_BANNER"):
        _ensure_material_by_batch(data, batch_name)
    for material in data["materials"]:
        if not isinstance(material, dict) or not str(material.get("batch_name") or "").strip().upper().startswith("EXT_SKIN"):
            continue
        blue = material.get("blue") if isinstance(material.get("blue"), dict) else {}
        if blue.get("base_color") == LEGACY_BLUE_CARBON_COLOR:
            blue["base_color"] = dict(DEFAULT_BLUE_CARBON_COLOR)
            material["blue"] = blue
            warnings.append("Legacy LiveryLab blue carbon tint was migrated to neutral black")
    if car_guid is not None:
        data["car_guid"] = str(car_guid or "").strip()
    else:
        data["car_guid"] = str(data.get("car_guid") or "").strip()
    return official_external_liveries.external_livery_data_with_linker(data), warnings


def active_skin_material_batch(data: dict[str, Any], fallback: str = "EXT_SKIN") -> str:
    def has_texture_reference(value: Any) -> bool:
        if isinstance(value, dict):
            for key, child in value.items():
                if str(key or "").endswith("_texture_path") and str(child or "").strip():
                    return True
                if has_texture_reference(child):
                    return True
        if isinstance(value, list):
            return any(has_texture_reference(child) for child in value)
        return False

    materials = data.get("materials") if isinstance(data.get("materials"), list) else []
    fallback = str(fallback or "").strip() or "EXT_SKIN"
    first_skin_batch = ""
    for material in materials:
        if not isinstance(material, dict):
            continue
        batch = str(material.get("batch_name") or "").strip()
        if not batch or batch.upper() == "EXT_BANNER":
            continue
        if batch.upper().startswith("EXT_SKIN"):
            if not first_skin_batch:
                first_skin_batch = batch
            if has_texture_reference(material):
                return batch
    if first_skin_batch:
        return first_skin_batch
    for material in materials:
        if not isinstance(material, dict):
            continue
        batch = str(material.get("batch_name") or "").strip()
        if batch and batch.upper() != "EXT_BANNER":
            return batch
    return fallback


def _channel_base_color_hex(channel: dict[str, Any], fallback: str) -> str:
    raw = channel.get("base_color") if isinstance(channel.get("base_color"), dict) else {}
    values: list[int] = []
    fallback_values = [int(fallback[index:index + 2], 16) for index in (1, 3, 5)]
    for index, key in enumerate(("x", "y", "z")):
        try:
            value = int(round(float(raw.get(key, fallback_values[index]))))
        except (TypeError, ValueError):
            value = fallback_values[index]
        values.append(max(0, min(255, value)))
    return "#" + "".join(f"{value:02x}" for value in values)


def _channel_preview_values(material: dict[str, Any], name: str, fallback_color: str) -> dict[str, Any]:
    channel = material.get(name) if isinstance(material.get(name), dict) else {}

    def normalized_number(key: str, fallback: float) -> float:
        try:
            value = float(channel.get(key, fallback))
        except (TypeError, ValueError):
            value = fallback
        return max(0.0, min(1.0, value))

    return {
        "baseColor": _channel_base_color_hex(channel, fallback_color),
        "roughness": normalized_number("roughness", 0.4),
        "metalness": normalized_number("metalness", 0.0),
        "clearCoat": normalized_number("clearcoat", 0.0),
        "clearCoatRoughness": normalized_number("clearcoat_roughness", 0.0),
    }


def official_material_preview_config(
    project_folder: str | Path,
    *,
    material_batch: str = "",
) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    data, warnings = _load_external_json(folder)
    active_batch = str(material_batch or "").strip() or active_skin_material_batch(data)
    material = _material_by_batch(data, active_batch)
    if not material:
        return {
            "available": False,
            "materialBatch": active_batch,
            "reason": f"Material batch not found: {active_batch}",
            "warnings": warnings,
        }
    return {
        "available": True,
        "source": "external_livery_json",
        "materialBatch": active_batch,
        "red": _channel_preview_values(material, "red", "#b2b2b2"),
        "green": _channel_preview_values(material, "green", "#373737"),
        "blue": _channel_preview_values(material, "blue", "#000000"),
        "warnings": warnings,
    }


def write_official_body_base_color(
    project_folder: str | Path,
    color: str,
    *,
    material_batch: str = "",
    material_surfaces: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    data, warnings = _external_json_for_refresh(folder)
    active_batch = str(material_batch or "").strip() or active_skin_material_batch(data)
    text = str(color or "").strip().lower()
    if not re.fullmatch(r"#[0-9a-f]{6}", text):
        raise ValueError(f"Invalid body base color: {color}")
    batches = official_material_surfaces.body_color_batches(material_surfaces, active_batch)
    previous_values: list[str] = []
    for batch in batches:
        material = _ensure_material_by_batch(data, batch)
        red = material.get("red") if isinstance(material.get("red"), dict) else {}
        previous_values.append(_channel_base_color_hex(red, "#b2b2b2"))
        red["base_color"] = {"x": int(text[1:3], 16), "y": int(text[3:5], 16), "z": int(text[5:7], 16)}
        material["red"] = red
    previous = previous_values[0] if previous_values else "#b2b2b2"
    target = folder / official_external_liveries.EXTERNAL_LIVERY_JSON
    changed = any(value != text for value in previous_values)
    if changed:
        official_external_liveries.write_external_livery_json(target, data)
    return {
        "changed": changed,
        "color": text,
        "previousColor": previous,
        "materialBatch": active_batch,
        "materialBatches": batches,
        "externalLiveryJson": str(target),
        "preview": official_material_preview_config(folder, material_batch=active_batch),
        "warnings": warnings,
    }


def clear_official_body_base_color(
    project_folder: str | Path,
    *,
    material_batch: str = "",
    material_surfaces: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    data, warnings = _external_json_for_refresh(folder)
    active_batch = str(material_batch or "").strip() or active_skin_material_batch(data)
    batches = official_material_surfaces.body_color_batches(material_surfaces, active_batch)
    changed_batches: list[str] = []
    for batch in batches:
        material = _material_by_batch(data, batch)
        if material and "red" in material:
            material.pop("red", None)
            changed_batches.append(batch)
    target = folder / official_external_liveries.EXTERNAL_LIVERY_JSON
    if changed_batches:
        official_external_liveries.write_external_livery_json(target, data)
    return {
        "changed": bool(changed_batches),
        "materialBatch": active_batch,
        "materialBatches": batches,
        "changedBatches": changed_batches,
        "externalLiveryJson": str(target),
        "warnings": warnings,
    }


def inspect_project(project_folder: str | Path, material_batch: str = "") -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    external_json = folder / official_external_liveries.EXTERNAL_LIVERY_JSON
    data, warnings = _load_external_json(folder) if folder.exists() and folder.is_dir() else ({}, [f"Project folder not found: {folder}"])
    slot_rows: list[dict[str, Any]] = []
    present_count = 0
    referenced_count = 0
    active_batch = str(material_batch or "").strip() or active_skin_material_batch(data)

    for slot in slot_definitions_for_material_batch(active_batch):
        filename = slot["filename"]
        expected_path = folder / filename
        json_value = _slot_json_value(data, slot)
        json_path = folder / json_value.replace("\\", "/") if json_value else None
        exists = expected_path.exists() and expected_path.is_file()
        referenced = bool(json_value)
        present_count += 1 if exists else 0
        referenced_count += 1 if referenced else 0
        slot_rows.append(
            {
                **slot,
                "path": str(expected_path),
                "relativePath": filename,
                "exists": exists,
                "referenced": referenced,
                "jsonPath": json_value,
                "jsonPathExists": bool(json_path and json_path.exists() and json_path.is_file()),
                "matchesDefaultPath": not json_value or json_value.replace("\\", "/").lower() == filename.lower(),
            }
        )

    return {
        "supported": True,
        "layout": PROJECT_ASSET_LAYOUT,
        "projectFolder": str(folder),
        "externalLiveryJson": str(external_json),
        "externalLiveryJsonExists": external_json.exists() and external_json.is_file(),
        "carGuid": str(data.get("car_guid") or ""),
        "materialBatch": active_batch,
        "slots": slot_rows,
        "slotCount": len(slot_rows),
        "presentSlotCount": present_count,
        "referencedSlotCount": referenced_count,
        "warnings": warnings,
    }


def _read_json(path: Path) -> dict[str, Any]:
    if not path.exists() or not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def _write_json(path: Path, data: dict[str, Any]) -> None:
    official_external_liveries.write_external_livery_json(path, data)


def _sha256_file(path: Path) -> str:
    hasher = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            hasher.update(chunk)
    return hasher.hexdigest()


def _path_info(path: Path) -> dict[str, Any]:
    exists = path.exists() and path.is_file()
    return {
        "path": str(path),
        "name": path.name,
        "exists": exists,
        "size": path.stat().st_size if exists else None,
        "mtimeMs": int(path.stat().st_mtime * 1000) if exists else None,
    }


def _selected_vehicle_variant(entry: dict[str, Any], derived_manifest: dict[str, Any]) -> dict[str, Any]:
    variants = [row for row in derived_manifest.get("vehicleVariants", []) or [] if isinstance(row, dict)]
    if not variants:
        return {}
    details = entry.get("official_external") if isinstance(entry.get("official_external"), dict) else {}
    selected = entry.get("vehicle_variant") if isinstance(entry.get("vehicle_variant"), dict) else {}
    if not selected:
        selected = details.get("vehicleVariant") if isinstance(details.get("vehicleVariant"), dict) else {}
    wanted_id = str(selected.get("id") or details.get("vehicleVariantId") or "").strip()
    if wanted_id:
        match = next((row for row in variants if str(row.get("id") or "").strip() == wanted_id), None)
        if match:
            return match
        match = next(
            (
                row
                for row in variants
                if wanted_id in {str(item or "").strip() for item in row.get("sourceVariantIds", []) or []}
            ),
            None,
        )
        if match:
            return match
    default_id = str(derived_manifest.get("defaultVehicleVariantId") or "").strip()
    if default_id:
        match = next((row for row in variants if str(row.get("id") or "").strip() == default_id), None)
        if match:
            return match
        match = next(
            (
                row
                for row in variants
                if default_id in {str(item or "").strip() for item in row.get("sourceVariantIds", []) or []}
            ),
            None,
        )
        if match:
            return match
    return variants[0]


def _variant_parts_uv_source(
    variant: dict[str, Any],
    derived_root: Path | None = None,
    manifest: dict[str, Any] | None = None,
) -> Path | None:
    if not isinstance(variant, dict):
        return None
    template = variant.get("partsUvTemplate") if isinstance(variant.get("partsUvTemplate"), dict) else {}
    path_text = str(variant.get("partsUvTemplatePath") or template.get("path") or "").strip()
    if not path_text:
        return None
    path = (
        official_car_resources.relocate_derived_asset_path(
            derived_root,
            path_text,
            stored_derived_root=(manifest or {}).get("derivedRoot"),
        )
        if derived_root is not None
        else Path(path_text)
    )
    return path if path.exists() and path.is_file() else None


def _variant_ao_source(
    variant: dict[str, Any],
    derived_root: Path | None = None,
    manifest: dict[str, Any] | None = None,
) -> Path | None:
    if not isinstance(variant, dict):
        return None
    ao = variant.get("variantAoTexture") if isinstance(variant.get("variantAoTexture"), dict) else {}
    if str(variant.get("aoPolicy") or ao.get("aoPolicy") or "").strip().lower() != "variant":
        return None
    path_text = str(variant.get("aoTexturePath") or ao.get("path") or "").strip()
    if not path_text:
        return None
    path = (
        official_car_resources.relocate_derived_asset_path(
            derived_root,
            path_text,
            stored_derived_root=(manifest or {}).get("derivedRoot"),
        )
        if derived_root is not None
        else Path(path_text)
    )
    return path if path.exists() and path.is_file() else None


def _apply_vehicle_variant_preview_project(project: dict[str, Any], variant: dict[str, Any], project_parts_uv_path: Path | None = None) -> None:
    if not isinstance(variant, dict) or not variant:
        return
    project["vehicleVariant"] = copy.deepcopy(variant)
    policy = variant.get("variantPolicy") if isinstance(variant.get("variantPolicy"), dict) else {}
    surfaces = variant.get("surfacePolicies") if isinstance(variant.get("surfacePolicies"), list) else []
    if policy:
        project["variantPolicy"] = copy.deepcopy(policy)
        project["variantPolicyVersion"] = policy.get("variantPolicyVersion")
    if surfaces:
        project["surfacePolicies"] = copy.deepcopy(surfaces)
    ao_policy = str(variant.get("aoPolicy") or "").strip()
    if ao_policy:
        project["aoPolicy"] = ao_policy
    variant_ao = variant.get("variantAoTexture") if isinstance(variant.get("variantAoTexture"), dict) else {}
    if variant_ao:
        project["variantAoTexture"] = copy.deepcopy(variant_ao)
    preview = variant.get("variantPartPreview") if isinstance(variant.get("variantPartPreview"), dict) else {}
    if preview and preview.get("meshRefs"):
        preview_copy = copy.deepcopy(preview)
        classification = str(policy.get("classification") or "").strip()
        hide_exterior = bool(preview_copy.get("hideExteriorModel"))
        if classification in {
            variant_parts.CLASS_SHARED_ADDITIVE,
            variant_parts.CLASS_SHARED_ADDITIVE_VARIANT_AO,
            variant_parts.CLASS_HYBRID_REPLACEMENT,
        }:
            hide_exterior = False
            preview_copy["hideExteriorModel"] = False
        project["variantPartPreview"] = preview_copy
        project["hideExteriorModel"] = hide_exterior
        hidden_exterior = preview_copy.get("hiddenExteriorComponentMaterials")
        if not isinstance(hidden_exterior, list):
            hidden_exterior = variant.get("hiddenExteriorComponentMaterials")
        if not isinstance(hidden_exterior, list):
            hidden_exterior = variant.get("hiddenExteriorMaterialNames")
        clean_hidden = [str(row or "").strip() for row in (hidden_exterior or []) if str(row or "").strip()]
        if clean_hidden:
            project["hiddenExteriorMaterialNames"] = clean_hidden
            project["hiddenExteriorComponentMaterials"] = clean_hidden
        else:
            project.pop("hiddenExteriorMaterialNames", None)
            project.pop("hiddenExteriorComponentMaterials", None)
        auxiliary_models = [row for row in preview.get("auxiliaryModels", []) or [] if isinstance(row, dict) and str(row.get("path") or "").strip()]
        if auxiliary_models:
            project["auxiliaryModels"] = auxiliary_models
            project["auxiliaryModelPaths"] = [str(row.get("path") or "") for row in auxiliary_models if str(row.get("path") or "").strip()]
    if project_parts_uv_path and project_parts_uv_path.exists() and project_parts_uv_path.is_file():
        parts_template = variant.get("partsUvTemplate") if isinstance(variant.get("partsUvTemplate"), dict) else {}
        project["partsUvTemplateName"] = uv_templates.PARTS_UV_TEMPLATE_NAME
        project["partsUvTemplatePath"] = str(project_parts_uv_path)
        project["partsUvTemplate"] = {
            **copy.deepcopy(parts_template),
            "path": str(project_parts_uv_path),
            "name": project_parts_uv_path.name,
            "exists": True,
            "source": "official_vehicle_variant",
        }


def _primary_texture_path(project_folder: Path) -> Path:
    for slot in SLOT_DEFINITIONS:
        if slot.get("id") == "ext_skin_c":
            return project_folder / slot["filename"]
    return project_folder / "EXT_Skin_C.png"


def _placeholder_state_path(project_folder: Path) -> Path:
    return project_folder / PLACEHOLDER_STATE


def _placeholder_state(project_folder: Path) -> dict[str, Any]:
    data = _read_json(_placeholder_state_path(project_folder))
    slots = data.get("slots") if isinstance(data.get("slots"), dict) else {}
    return {
        "schemaVersion": 1,
        "slots": slots,
    }


def _slot_size_hint(derived_manifest: dict[str, Any], slot_id: str) -> dict[str, Any]:
    hints = derived_manifest.get("textureSizeHints") if isinstance(derived_manifest.get("textureSizeHints"), dict) else {}
    slots = hints.get("slots") if isinstance(hints.get("slots"), dict) else {}
    hint = slots.get(slot_id) if isinstance(slots.get(slot_id), dict) else {}
    candidates = hint.get("candidates") if isinstance(hint.get("candidates"), list) else []
    fallback_only = bool(candidates) and all(
        isinstance(row, dict) and str(row.get("source") or "") == "material_slot_size_fallback"
        for row in candidates
    )
    if slot_id == "ext_banner_c" and (
        str(hint.get("selectionRule") or "").startswith("material_slot_size_fallback")
        or fallback_only
    ):
        return {"ok": False, "reason": "banner_base_color_slot_missing", "hint": hint}
    try:
        width = int(hint.get("width") or 0)
        height = int(hint.get("height") or 0)
    except Exception:
        width = 0
        height = 0
    if width > 0 and height > 0:
        return {"ok": True, "width": width, "height": height, "source": "texture_size_hints", "hint": hint}
    return {"ok": False, "reason": "missing"}


def _project_slot_explicitly_unsupported(project_folder: Path, slot_id: str) -> bool:
    project = _read_json(project_folder / PREVIEW_PROJECT)
    preview_assets = project.get("officialPreviewAssets") if isinstance(project.get("officialPreviewAssets"), dict) else {}
    hint = _slot_size_hint({"textureSizeHints": preview_assets.get("textureSizeHints")}, slot_id)
    return hint.get("reason") == "banner_base_color_slot_missing"


def _slot_texture_size(slot: dict[str, Any], derived_manifest: dict[str, Any], project_uv_path: Path) -> dict[str, Any]:
    slot_id = str(slot.get("id") or "")
    hint = _slot_size_hint(derived_manifest, slot_id)
    if hint.get("ok"):
        return hint
    if slot_id == "ext_skin_c":
        image = _image_size(project_uv_path)
        if image.get("ok"):
            return {"ok": True, "width": int(image["width"]), "height": int(image["height"]), "source": "uv_template_png"}
        manifest_size = _manifest_uv_size(derived_manifest)
        if manifest_size.get("ok"):
            return manifest_size
        return {"ok": False, "reason": hint.get("reason") or manifest_size.get("reason") or "missing"}
    return {"ok": False, "reason": hint.get("reason") or "missing_texture_size_hint"}


def _slot_placeholder_rgba(slot: dict[str, Any]) -> tuple[int, int, int, int]:
    raw = slot.get("placeholderRgba")
    if isinstance(raw, (list, tuple)) and len(raw) == 4:
        try:
            return tuple(max(0, min(255, int(value))) for value in raw)  # type: ignore[return-value]
        except Exception:
            pass
    return (255, 255, 255, 255)


def _is_generated_placeholder(project_folder: Path, slot_id: str, path: Path) -> bool:
    if not path.exists() or not path.is_file():
        return False
    state = _placeholder_state(project_folder)
    slots = state.get("slots") if isinstance(state.get("slots"), dict) else {}
    row = slots.get(slot_id) if isinstance(slots.get(slot_id), dict) else {}
    expected_hash = str(row.get("sha256") or "").strip().lower()
    if not expected_hash:
        return False
    try:
        return _sha256_file(path).lower() == expected_hash
    except Exception:
        return False


def mark_slot_as_user_authored(project_folder: str | Path, slot_id: str) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    normalized_slot = str(slot_id or "").strip()
    if not normalized_slot:
        return {"changed": False, "reason": "slot id missing", "projectFolder": str(folder), "slotId": ""}
    state_path = _placeholder_state_path(folder)
    state = _placeholder_state(folder)
    slots = state.get("slots") if isinstance(state.get("slots"), dict) else {}
    if normalized_slot not in slots:
        return {
            "changed": False,
            "reason": "slot was not tracked as generated placeholder",
            "projectFolder": str(folder),
            "slotId": normalized_slot,
            "statePath": str(state_path),
        }
    removed = slots.pop(normalized_slot)
    state["slots"] = slots
    _write_json(state_path, state)
    return {
        "changed": True,
        "reason": "slot marked as user authored",
        "projectFolder": str(folder),
        "slotId": normalized_slot,
        "statePath": str(state_path),
        "removed": removed,
    }


def write_material_finish_maps(project_folder: str | Path, finish: dict[str, Any]) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    slots_by_id = {str(row.get("id") or ""): row for row in SLOT_DEFINITIONS}
    primary = _primary_texture_path(folder)
    items: list[dict[str, Any]] = []
    changed_count = 0
    authored_count = 0

    try:
        from PIL import Image
    except Exception as exc:
        raise RuntimeError(f"Pillow is required to save Official paint finish maps: {exc}") from exc

    for finish_key, slot_id in MATERIAL_FINISH_SLOTS:
        slot = slots_by_id[slot_id]
        target = folder / str(slot["filename"])
        size_source = target if target.exists() and target.is_file() else primary
        if not size_source.exists() or not size_source.is_file():
            raise FileNotFoundError(f"Texture size source not found for {slot.get('label') or slot_id}: {size_source}")
        with Image.open(size_source) as source_image:
            size = source_image.size

        try:
            normalized_value = max(0.0, min(1.0, float(finish.get(finish_key, 0.0))))
        except (TypeError, ValueError):
            normalized_value = 0.0
        byte_value = int(round(normalized_value * 255.0))
        before = target.read_bytes() if target.exists() and target.is_file() else None
        target.parent.mkdir(parents=True, exist_ok=True)
        Image.new("RGBA", size, (byte_value, byte_value, byte_value, 255)).save(target)
        file_changed = before != target.read_bytes()
        authored = mark_slot_as_user_authored(folder, slot_id)
        authored_changed = authored.get("changed") is True
        if file_changed:
            changed_count += 1
        if authored_changed:
            authored_count += 1
        items.append({
            "finishKey": finish_key,
            "slotId": slot_id,
            "path": str(target),
            "value": normalized_value,
            "byteValue": byte_value,
            "width": int(size[0]),
            "height": int(size[1]),
            "changed": file_changed,
            "authored": authored,
        })

    return {
        "changed": changed_count > 0 or authored_count > 0,
        "updated": changed_count,
        "authored": authored_count,
        "skipped": len(items) - changed_count,
        "missing": 0,
        "items": items,
    }


def _ensure_slot_placeholders(project_folder: Path, project_uv_path: Path, derived_manifest: dict[str, Any]) -> dict[str, Any]:
    state = _placeholder_state(project_folder)
    slots_state = state.get("slots") if isinstance(state.get("slots"), dict) else {}
    items: list[dict[str, Any]] = []
    created_count = 0
    skipped_count = 0
    warnings: list[str] = []

    try:
        from PIL import Image
    except Exception as exc:
        return {
            "ready": False,
            "createdCount": 0,
            "skippedCount": 0,
            "items": [],
            "warnings": [f"Pillow is required to create official placeholder textures: {exc}"],
            "statePath": str(_placeholder_state_path(project_folder)),
        }

    for slot in SLOT_DEFINITIONS:
        slot_id = str(slot["id"])
        target = project_folder / str(slot["filename"])
        size = _slot_texture_size(slot, derived_manifest, project_uv_path)
        if not size.get("ok"):
            warning = f"{slot.get('label') or slot_id} placeholder size unavailable: {size.get('reason') or 'unknown'}"
            warnings.append(warning)
            items.append({
                **slot,
                "created": False,
                "skipped": True,
                "reason": warning,
                "texture": _path_info(target),
            })
            skipped_count += 1
            continue

        width = int(size["width"])
        height = int(size["height"])
        if target.exists() and target.is_file():
            generated = _is_generated_placeholder(project_folder, slot_id, target)
            items.append({
                **slot,
                "created": False,
                "skipped": True,
                "reason": "generated placeholder current" if generated else "target exists",
                "generatedPlaceholder": generated,
                "width": width,
                "height": height,
                "sizeSource": size.get("source") or "",
                "texture": _path_info(target),
            })
            skipped_count += 1
            continue

        target.parent.mkdir(parents=True, exist_ok=True)
        rgba = _slot_placeholder_rgba(slot)
        Image.new("RGBA", (width, height), rgba).save(target)
        digest = _sha256_file(target)
        slots_state[slot_id] = {
            "slotId": slot_id,
            "filename": target.name,
            "path": str(target),
            "sha256": digest,
            "width": width,
            "height": height,
            "rgba": list(rgba),
            "sizeSource": size.get("source") or "",
        }
        items.append({
            **slot,
            "created": True,
            "skipped": False,
            "reason": "",
            "generatedPlaceholder": True,
            "width": width,
            "height": height,
            "sizeSource": size.get("source") or "",
            "texture": _path_info(target),
        })
        created_count += 1

    state["slots"] = slots_state
    if created_count:
        _write_json(_placeholder_state_path(project_folder), state)
    return {
        "ready": not warnings,
        "createdCount": created_count,
        "skippedCount": skipped_count,
        "items": items,
        "statePath": str(_placeholder_state_path(project_folder)),
        "warnings": warnings,
    }


def _on_demand_placeholder_status(project_folder: Path) -> dict[str, Any]:
    return {
        "ready": True,
        "createdCount": 0,
        "skippedCount": 0,
        "items": [],
        "reason": "side slot placeholders are created on demand",
        "statePath": str(_placeholder_state_path(project_folder)),
        "warnings": [],
    }


def _copy_project_uv_template(source: Path, target: Path, *, overwrite: bool = False) -> dict[str, Any]:
    if not source.exists() or not source.is_file():
        return {
            "copied": False,
            "skipped": True,
            "reason": "source missing",
            "source": str(source),
            "target": str(target),
        }
    target.parent.mkdir(parents=True, exist_ok=True)
    if target.exists() and target.is_file():
        try:
            same_file = filecmp.cmp(source, target, shallow=False)
        except Exception:
            same_file = False
        if same_file:
            return {
                "copied": False,
                "skipped": True,
                "reason": "current",
                "source": str(source),
                "target": str(target),
            }
        if not overwrite:
            return {
                "copied": False,
                "skipped": True,
                "reason": "target exists",
                "source": str(source),
                "target": str(target),
            }
    shutil.copy2(source, target)
    return {
        "copied": True,
        "skipped": False,
        "reason": "",
        "source": str(source),
        "target": str(target),
    }


def _image_size(path: Path) -> dict[str, Any]:
    if not path.exists() or not path.is_file():
        return {"ok": False, "reason": "missing", "path": str(path)}
    try:
        from PIL import Image
        with Image.open(path) as image:
            width, height = image.size
    except Exception as exc:
        return {"ok": False, "reason": str(exc), "path": str(path)}
    return {"ok": True, "width": int(width), "height": int(height), "path": str(path)}


def _manifest_uv_size(manifest: dict[str, Any]) -> dict[str, Any]:
    template = manifest.get("uvTemplate") if isinstance(manifest.get("uvTemplate"), dict) else {}
    width = template.get("width") or manifest.get("uvSize")
    height = template.get("height") or manifest.get("uvSize")
    try:
        width_int = int(width)
        height_int = int(height)
    except Exception:
        return {"ok": False, "reason": "missing"}
    if width_int <= 0 or height_int <= 0:
        return {"ok": False, "reason": "invalid"}
    return {"ok": True, "width": width_int, "height": height_int, "source": "derived_manifest"}


def _create_neutral_primary_texture(
    project_folder: Path,
    project_uv_path: Path,
    derived_manifest: dict[str, Any],
) -> dict[str, Any]:
    target = _primary_texture_path(project_folder)
    if target.exists() and target.is_file():
        return {"created": False, "skipped": True, "reason": "current", "texture": _path_info(target)}

    size = _image_size(project_uv_path)
    source = "uv_template_png"
    if not size.get("ok"):
        size = _manifest_uv_size(derived_manifest)
        source = "derived_manifest"
    if not size.get("ok"):
        return {
            "created": False,
            "skipped": True,
            "reason": f"texture size unavailable: {size.get('reason') or 'unknown'}",
            "texture": _path_info(target),
        }

    width = int(size["width"])
    height = int(size["height"])
    target.parent.mkdir(parents=True, exist_ok=True)
    from PIL import Image
    Image.new("RGBA", (width, height), (255, 255, 255, 255)).save(target)
    return {
        "created": True,
        "skipped": False,
        "reason": "",
        "source": source,
        "width": width,
        "height": height,
        "texture": _path_info(target),
    }


def _preview_project_payload(
    *,
    project_folder: Path,
    entry: dict[str, Any],
    derived_manifest: dict[str, Any],
    glb_path: Path,
    project_uv_path: Path,
    ao_path: Path | None = None,
) -> dict[str, Any]:
    texture_path = _primary_texture_path(project_folder)
    car_model = official_car_resources.safe_car_id(entry.get("car_model") or entry.get("carModel"), "car")
    skin_id = str(entry.get("skin_id") or entry.get("skinId") or project_folder.name).strip() or project_folder.name
    display_name = str(entry.get("display_name") or entry.get("displayName") or skin_id).strip() or skin_id
    project_path = project_folder / PREVIEW_PROJECT
    derived_ao_raw = str(
        derived_manifest.get("aoTexturePath")
        or (derived_manifest.get("aoTexture") if isinstance(derived_manifest.get("aoTexture"), dict) else {}).get("path")
        or ""
    ).strip()
    derived_ao_path = Path(derived_ao_raw) if derived_ao_raw else None
    derived_ao_exists = bool(derived_ao_path and derived_ao_path.exists() and derived_ao_path.is_file())
    selected_ao_path = ao_path if ao_path and ao_path.exists() and ao_path.is_file() else derived_ao_path
    selected_ao_exists = bool(selected_ao_path and selected_ao_path.exists() and selected_ao_path.is_file())
    uv_template = {
        "path": str(project_uv_path),
        "name": project_uv_path.name,
        "exists": project_uv_path.exists() and project_uv_path.is_file(),
        "source": "official_preview_assets",
        "sourcePath": str(derived_manifest.get("uvTemplatePath") or ""),
        "width": derived_manifest.get("uvSize") or (derived_manifest.get("uvTemplate") or {}).get("width"),
        "height": derived_manifest.get("uvSize") or (derived_manifest.get("uvTemplate") or {}).get("height"),
    }
    payload = {
        "version": 1,
        "skinKey": entry.get("key") or "",
        "displayName": display_name,
        "skinId": skin_id,
        "projectType": "official_external_livery",
        "carModel": car_model,
        "carContentPath": entry.get("car_content_path") or f"content\\cars\\{car_model}",
        "gameRoot": entry.get("game_root") or "",
        "projectFolder": str(project_folder),
        "liveTextureName": texture_path.name,
        "liveTexturePath": str(texture_path),
        "texturePath": str(texture_path),
        "liveAoTextureName": PREVIEW_AO_TEXTURE_NAME,
        "liveAoTexturePath": str(selected_ao_path) if selected_ao_exists else "",
        "aoTexturePath": str(selected_ao_path) if selected_ao_exists else "",
        "projectPath": str(project_path),
        "modelPath": str(glb_path),
        "modelCachePath": str(glb_path),
        "materialName": "EXT_SKIN",
        "bannerMaterialName": "EXT_BANNER",
        "uvTemplateName": uv_templates.UV_TEMPLATE_NAME,
        "uvTemplatePath": str(project_uv_path),
        "uvTemplate": uv_template,
        "officialPreviewAssets": {
            "source": "OfficialLiveryLibrary/PreviewAssets/Derived",
            "derivedRoot": str(derived_manifest.get("derivedRoot") or glb_path.parent),
            "manifestPath": str(derived_manifest.get("manifestPath") or ""),
            "sourceMeshPath": str(derived_manifest.get("sourceMeshPath") or ""),
            "sourceMeshRelPath": str(derived_manifest.get("sourceMeshRelPath") or ""),
            "glbPath": str(glb_path),
            "derivedUvTemplatePath": str(derived_manifest.get("uvTemplatePath") or ""),
            "projectUvTemplatePath": str(project_uv_path),
            "derivedAoTexturePath": str(derived_ao_path) if derived_ao_exists else "",
            "selectedAoTexturePath": str(selected_ao_path) if selected_ao_exists else "",
            "projectAoTexturePath": str(ao_path) if ao_path and ao_path.exists() and ao_path.is_file() else "",
            "aoTexture": derived_manifest.get("aoTexture") if isinstance(derived_manifest.get("aoTexture"), dict) else {},
            "glbExporterVersion": derived_manifest.get("glbExporterVersion"),
            "uvSize": derived_manifest.get("uvSize"),
            "textureSizeHints": derived_manifest.get("textureSizeHints") if isinstance(derived_manifest.get("textureSizeHints"), dict) else {},
        },
    }
    for slot in SLOT_DEFINITIONS:
        slot_path = project_folder / slot["filename"]
        path_key = str(slot.get("pathKey") or "").strip()
        name_key = str(slot.get("nameKey") or "").strip()
        slot_exists = slot_path.exists() and slot_path.is_file()
        if slot.get("id") == "ext_banner_c" and _slot_size_hint(derived_manifest, "ext_banner_c").get("reason") == "banner_base_color_slot_missing":
            slot_exists = False
        if slot.get("id") == "ext_skin_c":
            slot_exists = True
        if path_key:
            payload[path_key] = str(slot_path) if slot_exists else ""
        if name_key:
            payload[name_key] = slot_path.name if slot_exists else ""
    payload.setdefault("texturePath", str(texture_path))
    payload.setdefault("liveTexturePath", str(texture_path))
    return payload


def _case_insensitive_file(folder: Path, filename: str) -> Path | None:
    direct = folder / filename
    if direct.exists() and direct.is_file():
        return direct
    expected = str(filename or "").strip().lower()
    if not expected or not folder.exists() or not folder.is_dir():
        return None
    for item in folder.iterdir():
        if item.is_file() and item.name.lower() == expected:
            return item
    return None


def prepare_read_only_preview_cache(
    source_folder: str | Path,
    cache_folder: str | Path,
    entry: dict[str, Any],
    *,
    preview_assets_root: str | Path | None,
) -> dict[str, Any]:
    source = Path(source_folder).expanduser()
    cache = Path(cache_folder).expanduser()
    car_id = official_car_resources.safe_car_id(entry.get("car_model") or entry.get("carModel"), "")
    warnings: list[str] = []
    if not source.exists() or not source.is_dir():
        return {
            "ready": False,
            "carId": car_id,
            "sourceFolder": str(source),
            "cacheFolder": str(cache),
            "reason": "read-only source folder missing",
            "warnings": [f"Read-only Official livery folder not found: {source}"],
        }
    if not car_id:
        return {
            "ready": False,
            "sourceFolder": str(source),
            "cacheFolder": str(cache),
            "reason": "car id missing",
            "warnings": ["Official read-only preview assets need a car id."],
        }
    if not preview_assets_root:
        return {
            "ready": False,
            "carId": car_id,
            "sourceFolder": str(source),
            "cacheFolder": str(cache),
            "reason": "preview assets root missing",
            "warnings": ["Official PreviewAssets root is not configured."],
        }
    try:
        source_resolved = source.resolve()
        cache_resolved = cache.resolve()
        if cache_resolved == source_resolved or source_resolved in cache_resolved.parents:
            raise ValueError("Official read-only preview cache must be outside the library source folder")
    except OSError:
        pass

    manifest_path = official_car_resources.derived_manifest_path(preview_assets_root, car_id)
    manifest = _read_json(manifest_path)
    derived_root = official_car_resources.derived_car_root(preview_assets_root, car_id)
    glb_path = official_car_resources.resolve_derived_manifest_asset(derived_root, manifest, "glbPath", PREVIEW_MODEL_NAME)
    derived_uv_path = official_car_resources.resolve_derived_manifest_asset(derived_root, manifest, "uvTemplatePath", uv_templates.UV_TEMPLATE_NAME)
    derived_ao_path = official_car_resources.resolve_derived_manifest_asset(derived_root, manifest, "aoTexturePath", PREVIEW_AO_TEXTURE_NAME)
    selected_variant = _selected_vehicle_variant(entry, manifest)
    variant_parts_uv_source = _variant_parts_uv_source(selected_variant, derived_root, manifest)
    variant_ao_source = _variant_ao_source(selected_variant, derived_root, manifest)
    selected_ao_source = variant_ao_source or (derived_ao_path if derived_ao_path.exists() and derived_ao_path.is_file() else None)
    preview_project_path = cache / PREVIEW_PROJECT
    cache_uv_path = cache / uv_templates.UV_TEMPLATE_NAME
    cache_parts_uv_path = cache / uv_templates.PARTS_UV_TEMPLATE_NAME
    cache_ao_path = cache / PREVIEW_AO_TEXTURE_NAME

    if not manifest.get("ready"):
        warnings.append(f"Official derived preview assets are not ready for {car_id}.")
    if not glb_path.exists() or not glb_path.is_file():
        warnings.append(f"Official preview GLB not found: {glb_path}")

    primary_slot = next((row for row in SLOT_DEFINITIONS if row.get("id") == "ext_skin_c"), None)
    primary_source = _case_insensitive_file(source, str((primary_slot or {}).get("filename") or "EXT_Skin_C.png"))
    if primary_source is None:
        warnings.append(f"Read-only Official livery texture not found: {source / 'EXT_Skin_C.png'}")

    if not glb_path.exists() or not glb_path.is_file() or primary_source is None:
        for stale in (preview_project_path, cache / "EXT_Skin_C.png"):
            if stale.exists() and stale.is_file():
                stale.unlink()
        return {
            "ready": False,
            "carId": car_id,
            "sourceFolder": str(source),
            "cacheFolder": str(cache),
            "manifestPath": str(manifest_path),
            "derivedRoot": str(derived_root),
            "glb": _path_info(glb_path),
            "texture": _path_info(primary_source or (source / "EXT_Skin_C.png")),
            "vehicleVariant": selected_variant,
            "previewProjectPath": str(preview_project_path),
            "warnings": warnings,
        }

    cache.mkdir(parents=True, exist_ok=True)
    texture_copies: list[dict[str, Any]] = []
    for slot in SLOT_DEFINITIONS:
        slot_source = _case_insensitive_file(source, slot["filename"])
        target = cache / slot["filename"]
        if slot_source is None:
            if target.exists() and target.is_file():
                target.unlink()
            texture_copies.append({
                "id": slot["id"],
                "source": "",
                "target": str(target),
                "copied": False,
                "reason": "source missing",
            })
            continue
        copy_result = _copy_project_uv_template(slot_source, target, overwrite=True)
        texture_copies.append({"id": slot["id"], **copy_result})

    uv_copy = _copy_project_uv_template(derived_uv_path, cache_uv_path, overwrite=True)
    parts_uv_copy = (
        _copy_project_uv_template(variant_parts_uv_source, cache_parts_uv_path, overwrite=True)
        if variant_parts_uv_source
        else {"copied": False, "skipped": True, "reason": "variant parts UV missing", "source": "", "target": str(cache_parts_uv_path)}
    )
    ao_copy = (
        _copy_project_uv_template(selected_ao_source, cache_ao_path, overwrite=True)
        if selected_ao_source
        else {"copied": False, "skipped": True, "reason": "AO source missing", "source": "", "target": str(cache_ao_path)}
    )
    project = _preview_project_payload(
        project_folder=cache,
        entry=entry,
        derived_manifest={**manifest, "manifestPath": str(manifest_path)},
        glb_path=glb_path,
        project_uv_path=cache_uv_path if cache_uv_path.is_file() else derived_uv_path,
        ao_path=cache_ao_path if cache_ao_path.is_file() else selected_ao_source,
    )
    project["sourceFolder"] = str(source)
    project["readOnly"] = True
    _apply_vehicle_variant_preview_project(
        project,
        selected_variant,
        cache_parts_uv_path if cache_parts_uv_path.is_file() else None,
    )
    _write_json(preview_project_path, project)
    texture_path = _primary_texture_path(cache)
    if not selected_ao_source:
        warnings.append(f"Official preview AO texture is optional but not available: {derived_ao_path}")
    return {
        "ready": bool(glb_path.is_file() and texture_path.is_file()),
        "carId": car_id,
        "sourceFolder": str(source),
        "cacheFolder": str(cache),
        "projectFolder": str(cache),
        "manifestPath": str(manifest_path),
        "derivedRoot": str(derived_root),
        "glb": _path_info(glb_path),
        "derivedUvTemplate": _path_info(derived_uv_path),
        "derivedAoTexture": _path_info(derived_ao_path),
        "selectedAoTexture": _path_info(selected_ao_source) if selected_ao_source else _path_info(derived_ao_path),
        "projectAoTexture": _path_info(cache_ao_path),
        "projectUvTemplate": _path_info(cache_uv_path),
        "projectPartsUvTemplate": _path_info(cache_parts_uv_path),
        "uvCopy": uv_copy,
        "partsUvCopy": parts_uv_copy,
        "aoCopy": ao_copy,
        "textureCopies": texture_copies,
        "texture": _path_info(texture_path),
        "vehicleVariant": selected_variant,
        "textureSizeHints": manifest.get("textureSizeHints") if isinstance(manifest.get("textureSizeHints"), dict) else {},
        "previewProjectPath": str(preview_project_path),
        "previewProjectWritten": True,
        "warnings": warnings,
    }


def prepare_project_preview_assets(
    project_folder: str | Path,
    entry: dict[str, Any],
    *,
    preview_assets_root: str | Path | None,
    overwrite_uv: bool = False,
) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    car_id = official_car_resources.safe_car_id(entry.get("car_model") or entry.get("carModel"), "")
    if not car_id:
        return {"ready": False, "reason": "car id missing", "warnings": ["Official preview assets need a car id."]}
    if not preview_assets_root:
        return {"ready": False, "carId": car_id, "reason": "preview assets root missing", "warnings": ["Official PreviewAssets root is not configured."]}

    manifest_path = official_car_resources.derived_manifest_path(preview_assets_root, car_id)
    manifest = _read_json(manifest_path)
    derived_root = official_car_resources.derived_car_root(preview_assets_root, car_id)
    glb_path = official_car_resources.resolve_derived_manifest_asset(derived_root, manifest, "glbPath", PREVIEW_MODEL_NAME)
    derived_uv_path = official_car_resources.resolve_derived_manifest_asset(derived_root, manifest, "uvTemplatePath", uv_templates.UV_TEMPLATE_NAME)
    derived_ao_path = official_car_resources.resolve_derived_manifest_asset(derived_root, manifest, "aoTexturePath", PREVIEW_AO_TEXTURE_NAME)
    project_uv_path = folder / uv_templates.UV_TEMPLATE_NAME
    project_parts_uv_path = folder / uv_templates.PARTS_UV_TEMPLATE_NAME
    project_ao_path = folder / PREVIEW_AO_TEXTURE_NAME
    preview_project_path = folder / PREVIEW_PROJECT
    selected_variant = _selected_vehicle_variant(entry, manifest)
    variant_parts_uv_source = _variant_parts_uv_source(selected_variant, derived_root, manifest)
    variant_ao_source = _variant_ao_source(selected_variant, derived_root, manifest)
    selected_ao_source = variant_ao_source or (derived_ao_path if derived_ao_path.exists() and derived_ao_path.is_file() else None)
    warnings: list[str] = []

    if not manifest.get("ready"):
        warnings.append(f"Official derived preview assets are not ready for {car_id}.")
    if not glb_path.exists() or not glb_path.is_file():
        warnings.append(f"Official preview GLB not found: {glb_path}")
    if not derived_uv_path.exists() or not derived_uv_path.is_file():
        warnings.append(f"Official preview UV template not found: {derived_uv_path}")
    if warnings:
        return {
            "ready": False,
            "carId": car_id,
            "projectFolder": str(folder),
            "manifestPath": str(manifest_path),
            "derivedRoot": str(derived_root),
            "glb": _path_info(glb_path),
            "derivedUvTemplate": _path_info(derived_uv_path),
            "derivedAoTexture": _path_info(derived_ao_path),
            "selectedAoTexture": _path_info(selected_ao_source) if selected_ao_source else _path_info(derived_ao_path),
            "projectAoTexture": _path_info(project_ao_path),
            "projectUvTemplate": _path_info(project_uv_path),
            "projectPartsUvTemplate": _path_info(project_parts_uv_path),
            "vehicleVariant": selected_variant,
            "previewProjectPath": str(preview_project_path),
            "textureSizeHints": manifest.get("textureSizeHints") if isinstance(manifest.get("textureSizeHints"), dict) else {},
            "warnings": warnings,
        }

    uv_copy = _copy_project_uv_template(derived_uv_path, project_uv_path, overwrite=overwrite_uv)
    parts_uv_copy = (
        _copy_project_uv_template(variant_parts_uv_source, project_parts_uv_path, overwrite=True)
        if variant_parts_uv_source
        else {"copied": False, "skipped": True, "reason": "variant parts UV missing", "source": "", "target": str(project_parts_uv_path)}
    )
    ao_copy = (
        _copy_project_uv_template(selected_ao_source, project_ao_path, overwrite=True)
        if selected_ao_source
        else {"copied": False, "skipped": True, "reason": "AO source missing", "source": "", "target": str(project_ao_path)}
    )
    placeholders = _ensure_slot_placeholders(project_folder=folder, project_uv_path=project_uv_path, derived_manifest=manifest)
    neutral_texture = {}
    for item in placeholders.get("items", []):
        if not isinstance(item, dict) or str(item.get("id") or "").strip().lower() != "ext_skin_c":
            continue
        neutral_texture = {
            "created": bool(item.get("created")),
            "skipped": bool(item.get("skipped")),
            "reason": item.get("reason") or "",
            "source": item.get("sizeSource") or "",
            "width": item.get("width"),
            "height": item.get("height"),
            "texture": item.get("texture") if isinstance(item.get("texture"), dict) else {},
        }
        break
    if not neutral_texture:
        neutral_texture = _create_neutral_primary_texture(project_folder=folder, project_uv_path=project_uv_path, derived_manifest=manifest)
    project = _preview_project_payload(
        project_folder=folder,
        entry=entry,
        derived_manifest={**manifest, "manifestPath": str(manifest_path)},
        glb_path=glb_path,
        project_uv_path=project_uv_path,
        ao_path=project_ao_path if project_ao_path.exists() and project_ao_path.is_file() else selected_ao_source,
    )
    _apply_vehicle_variant_preview_project(
        project,
        selected_variant,
        project_parts_uv_path if variant_parts_uv_source and project_parts_uv_path.exists() else None,
    )
    _write_json(preview_project_path, project)
    texture_path = _primary_texture_path(folder)
    texture_info = _path_info(texture_path)
    preview_ready = bool(glb_path.exists() and texture_info.get("exists"))
    if not texture_info.get("exists"):
        warnings.append(f"EXT_Skin_C texture is not created yet: {texture_path}")
    if not selected_ao_source or not selected_ao_source.exists() or not selected_ao_source.is_file():
        warnings.append(f"Official preview AO texture is optional but not available: {derived_ao_path}")
    warnings.extend(str(row) for row in placeholders.get("warnings", []) or [])
    external_refresh = refresh_external_livery_json(folder) if texture_info.get("exists") else {}
    return {
        "ready": preview_ready,
        "carId": car_id,
        "projectFolder": str(folder),
        "manifestPath": str(manifest_path),
        "derivedRoot": str(derived_root),
        "glb": _path_info(glb_path),
        "derivedUvTemplate": _path_info(derived_uv_path),
        "derivedAoTexture": _path_info(derived_ao_path),
        "selectedAoTexture": _path_info(selected_ao_source) if selected_ao_source else _path_info(derived_ao_path),
        "projectAoTexture": _path_info(project_ao_path),
        "projectUvTemplate": _path_info(project_uv_path),
        "projectPartsUvTemplate": _path_info(project_parts_uv_path),
        "uvCopy": uv_copy,
        "partsUvCopy": parts_uv_copy,
        "aoCopy": ao_copy,
        "neutralTexture": neutral_texture,
        "placeholders": placeholders,
        "texture": texture_info,
        "vehicleVariant": selected_variant,
        "textureSizeHints": manifest.get("textureSizeHints") if isinstance(manifest.get("textureSizeHints"), dict) else {},
        "externalLiveryRefresh": external_refresh,
        "previewProjectPath": str(preview_project_path),
        "previewProjectWritten": True,
        "warnings": warnings,
    }


def refresh_external_livery_json(
    project_folder: str | Path,
    car_guid: str | None = None,
    *,
    material_batch: str = "",
) -> dict[str, Any]:
    folder = Path(project_folder).expanduser()
    if not folder.exists() or not folder.is_dir():
        raise FileNotFoundError(f"Official external project folder not found: {folder}")
    target = folder / official_external_liveries.EXTERNAL_LIVERY_JSON
    before_text = target.read_text(encoding="utf-8", errors="ignore") if target.exists() else ""
    data, warnings = _external_json_for_refresh(folder, car_guid=car_guid)
    active_batch = str(material_batch or "").strip() or active_skin_material_batch(data)

    for slot in slot_definitions_for_material_batch(active_batch):
        filename = slot["filename"]
        source = folder / filename
        slot_id = str(slot.get("id") or "")
        generated_placeholder = _is_generated_placeholder(folder, slot_id, source)
        should_reference = source.exists() and source.is_file() and (
            not generated_placeholder or slot_id in AUTO_REFERENCED_PLACEHOLDER_SLOT_IDS
        )
        if slot_id == "ext_banner_c" and _project_slot_explicitly_unsupported(folder, slot_id):
            should_reference = False
        _set_slot_json_value(data, slot, filename if should_reference else "")
    materials = data.get("materials") if isinstance(data.get("materials"), list) else []
    data["materials"] = sorted(
        materials,
        key=lambda material: (
            0
            if isinstance(material, dict)
            and str(material.get("batch_name") or "").strip().upper() == active_batch.upper()
            else 1
            if isinstance(material, dict)
            and str(material.get("batch_name") or "").strip().upper() == "EXT_BANNER"
            else 2
        ),
    )

    next_text = official_external_liveries.external_livery_json_text(data)
    changed = next_text != before_text
    if changed:
        official_external_liveries.write_external_livery_json(target, data)
    status = inspect_project(folder, material_batch=active_batch)
    return {
        "changed": changed,
        "projectFolder": str(folder),
        "externalLiveryJson": str(target),
        "carGuid": str(data.get("car_guid") or ""),
        "materialBatch": active_batch,
        "assetStatus": status,
        "presentSlotCount": status.get("presentSlotCount", 0),
        "referencedSlotCount": status.get("referencedSlotCount", 0),
        "slotCount": status.get("slotCount", 0),
        "warnings": warnings,
    }


def with_project_asset_metadata(manifest: dict[str, Any], project_folder: str | Path) -> dict[str, Any]:
    next_manifest = copy.deepcopy(manifest)
    next_manifest["assetLayout"] = asset_layout_manifest()
    next_manifest["assetStatus"] = inspect_project(project_folder)
    return next_manifest
