from __future__ import annotations

import json
import re
import hashlib
import shutil
from pathlib import Path
from typing import Any
from datetime import datetime, timezone

from PIL import Image

from . import preview3d


TEXTURE_SIZE = 4096
PROJECT_DIRNAME = "layer-studio"
ASSETS_DIRNAME = "assets"
LAYERS_FILE_NAME = "layers.json"
BASE_TEXTURE_NAME = "base.png"
LEGACY_BASE_TEXTURE_NAME = "ext_skin_c_base.png"
BAKED_TEXTURE_NAME = preview3d.LIVE_TEXTURE_NAME
LAYER_MODEL_VERSION = 1
TEXTURE_NAME = "ext_skin_c"
DEFAULT_SURFACE_KEY = TEXTURE_NAME
SURFACE_BASE_TEXTURE_NAME = "base.png"
SURFACE_DEFINITIONS = [
    {
        "key": "ext_skin_c",
        "label": "EXT_SKIN_C",
        "fileName": preview3d.LIVE_TEXTURE_NAME,
        "pathKeys": ("liveTexturePath", "texturePath"),
        "nameKeys": ("liveTextureName", "textureName"),
        "required": True,
        "baseTextureName": BASE_TEXTURE_NAME,
        "kind": "paint",
    },
    {
        "key": "ext_banner_c",
        "label": "EXT_Banner_C",
        "fileName": preview3d.LIVE_BANNER_TEXTURE_NAME,
        "pathKeys": ("liveBannerTexturePath", "bannerTexturePath"),
        "nameKeys": ("liveBannerTextureName", "bannerTextureName"),
        "required": False,
        "baseTextureName": SURFACE_BASE_TEXTURE_NAME,
        "kind": "banner",
    },
    {
        "key": "ext_skin_d",
        "label": "EXT_Skin_D",
        "fileName": preview3d.LIVE_DETAIL_TEXTURE_NAME,
        "pathKeys": ("liveDetailTexturePath", "detailTexturePath"),
        "nameKeys": ("liveDetailTextureName", "detailTextureName"),
        "required": False,
        "showWhenMissing": True,
        "baseTextureName": SURFACE_BASE_TEXTURE_NAME,
        "kind": "detail",
    },
]
LIBRARY_DIRNAME = "LayerStudioLibrary"
LIBRARY_ASSETS_DIRNAME = "assets"
LIBRARY_FILE_NAME = "library.json"
LIBRARY_DEFAULT_GROUP_ID = "ungrouped"
PROJECT_LIBRARY_DIRNAME = "project-library"
BASE_COLOR_DEFAULT = "#ffffff"
SPLAT_MAP_BASE_COLOR_DEFAULT = "#000000"


def _default_base_color(texture_key: Any = DEFAULT_SURFACE_KEY) -> str:
    return SPLAT_MAP_BASE_COLOR_DEFAULT if _surface_key(texture_key) == "ext_skin_d" else BASE_COLOR_DEFAULT


def _int_value(value: Any, fallback: int, minimum: int = 0) -> int:
    try:
        number = int(round(float(value)))
    except Exception:
        number = fallback
    return max(minimum, number)


def _coordinate_value(value: Any, fallback: int = 0) -> int:
    try:
        return int(round(float(value)))
    except Exception:
        return fallback


def _float_value(value: Any, fallback: float, minimum: float | None = None, maximum: float | None = None) -> float:
    try:
        number = float(value)
    except Exception:
        number = fallback
    if minimum is not None:
        number = max(minimum, number)
    if maximum is not None:
        number = min(maximum, number)
    return number


def _clean_text(value: Any, fallback: str = "") -> str:
    text = str(value or "").strip()
    return text or fallback


def _css_color(value: Any, fallback: str = "#e43d30") -> str:
    color = _clean_text(value, fallback)
    if not re.match(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$", color):
        color = fallback
    return color


def normalize_fill_gradient(raw: Any, fallback_color: str = "#e43d30") -> dict[str, Any] | None:
    data = raw if isinstance(raw, dict) else {}
    gradient_type = _clean_text(data.get("type"), "solid").lower()
    if gradient_type not in {"linear", "radial"}:
        return None
    return {
        "type": gradient_type,
        "colorA": _css_color(data.get("colorA") or data.get("from"), "#000000"),
        "alphaA": _float_value(data.get("alphaA"), 1.0, 0.0, 1.0),
        "colorB": _css_color(data.get("colorB") or data.get("to"), "#ffffff"),
        "alphaB": _float_value(data.get("alphaB"), 1.0, 0.0, 1.0),
        "angle": _float_value(data.get("angle"), 90.0, 0.0, 360.0),
    }


def _layer_text(value: Any, fallback: str = "NEW TEXT") -> str:
    if value is None:
        return fallback
    text = str(value).replace("\r\n", "\n").replace("\r", "\n")
    return text if text else fallback


def _text_layer_name(text: Any, fallback: str = "Text") -> str:
    name = re.sub(r"\s+", " ", str(text or "")).strip()[:48]
    return name or fallback


def _path_info(path: str | Path | None) -> dict[str, Any]:
    if not path:
        return {"path": "", "exists": False}
    item = Path(path)
    return {
        "path": str(item),
        "name": item.name,
        "exists": item.is_file(),
        "size": item.stat().st_size if item.is_file() else 0,
        "extension": item.suffix.lower(),
    }


def _surface_key(value: Any, fallback: str = DEFAULT_SURFACE_KEY) -> str:
    key = re.sub(r"[^0-9a-z_]+", "_", str(value or "").strip().lower()).strip("_")
    fallback_key = re.sub(r"[^0-9a-z_]+", "_", str(fallback or DEFAULT_SURFACE_KEY).strip().lower()).strip("_")
    return key or fallback_key or DEFAULT_SURFACE_KEY


def _surface_definition(surface_key: Any) -> dict[str, Any]:
    key = _surface_key(surface_key)
    for spec in SURFACE_DEFINITIONS:
        if spec["key"] == key:
            return dict(spec)
    return dict(SURFACE_DEFINITIONS[0])


def _variant_parts_surface_definitions(project: dict[str, Any]) -> list[dict[str, Any]]:
    return []


def _surface_root(project_folder: Path, surface_key: Any) -> Path:
    return project_folder / PROJECT_DIRNAME / _surface_key(surface_key)


def _surface_layers_path(project_folder: Path, surface_key: Any) -> Path:
    return _surface_root(project_folder, surface_key) / LAYERS_FILE_NAME


def _legacy_layers_path(project_folder: Path) -> Path:
    return project_folder / PROJECT_DIRNAME / LAYERS_FILE_NAME


def _migrate_legacy_surface_layers(project_folder: Path, surface_key: Any, layers_path: Path) -> str:
    if _surface_key(surface_key) != DEFAULT_SURFACE_KEY or layers_path.is_file():
        return ""
    legacy = _legacy_layers_path(project_folder)
    if not legacy.is_file():
        return ""
    layers_path.parent.mkdir(parents=True, exist_ok=True)
    shutil.copy2(legacy, layers_path)
    return str(legacy)


def _surface_texture_path(project_folder: Path, project: dict[str, Any], spec: dict[str, Any]) -> Path:
    for key in spec.get("pathKeys") or ():
        value = str(project.get(key) or "").strip()
        if value:
            return Path(value)
    return project_folder / str(spec.get("fileName") or preview3d.LIVE_TEXTURE_NAME)


def _image_size(path: str | Path | None, fallback: dict[str, Any] | None = None) -> dict[str, int]:
    fallback_size = fallback if isinstance(fallback, dict) else {}
    width = _int_value(fallback_size.get("width"), TEXTURE_SIZE, 1)
    height = _int_value(fallback_size.get("height"), TEXTURE_SIZE, 1)
    if not path:
        return {"width": width, "height": height}
    item = Path(path)
    if not item.is_file():
        return {"width": width, "height": height}
    try:
        with Image.open(item) as image:
            image_width, image_height = image.size
        return {"width": _int_value(image_width, width, 1), "height": _int_value(image_height, height, 1)}
    except Exception:
        return {"width": width, "height": height}


def _surface_base_path(project_folder: Path, surface_key: Any, spec: dict[str, Any]) -> Path:
    key = _surface_key(surface_key)
    target = _surface_root(project_folder, key) / str(spec.get("baseTextureName") or SURFACE_BASE_TEXTURE_NAME)
    if key == DEFAULT_SURFACE_KEY and not target.is_file():
        legacy = project_folder / LEGACY_BASE_TEXTURE_NAME
        if legacy.is_file():
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(legacy, target)
    return target


def _utc_now() -> str:
    return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")


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


def asset_library_root(livery_library_root: str | Path) -> Path:
    root = Path(livery_library_root) / LIBRARY_DIRNAME
    root.mkdir(parents=True, exist_ok=True)
    (root / LIBRARY_ASSETS_DIRNAME).mkdir(parents=True, exist_ok=True)
    return root


def project_asset_library_root(project_folder: str | Path) -> Path:
    root = Path(project_folder) / PROJECT_DIRNAME / PROJECT_LIBRARY_DIRNAME
    root.mkdir(parents=True, exist_ok=True)
    (root / LIBRARY_ASSETS_DIRNAME).mkdir(parents=True, exist_ok=True)
    return root


def _library_path(livery_library_root: str | Path) -> Path:
    return asset_library_root(livery_library_root) / LIBRARY_FILE_NAME


def _library_default() -> dict[str, Any]:
    return {
        "version": 1,
        "type": "layer-studio-asset-library",
        "groups": [{
            "id": LIBRARY_DEFAULT_GROUP_ID,
            "name": "Ungrouped",
            "collapsed": False,
            "order": 0,
        }],
        "assets": [],
    }


def _normalize_library_group(raw: dict[str, Any], index: int) -> dict[str, Any]:
    group_id = _clean_text(raw.get("id"), f"group_{index + 1:03d}")
    return {
        "id": group_id,
        "name": "Ungrouped" if group_id == LIBRARY_DEFAULT_GROUP_ID else _clean_text(raw.get("name"), f"Group {index + 1}"),
        "collapsed": bool(raw.get("collapsed", False)),
        "order": _int_value(raw.get("order"), index, 0),
    }


def _normalize_library_entry(raw: dict[str, Any], root: Path) -> dict[str, Any]:
    file_value = _clean_text(raw.get("file"))
    path = root / file_value.replace("\\", "/") if file_value else Path()
    entry = {
        "id": _clean_text(raw.get("id")),
        "name": _clean_text(raw.get("name"), "PNG Asset"),
        "type": "image/png",
        "file": file_value,
        "hash": _clean_text(raw.get("hash")),
        "groupId": _clean_text(raw.get("groupId"), LIBRARY_DEFAULT_GROUP_ID),
        "tags": [str(row).strip() for row in raw.get("tags", []) if str(row).strip()] if isinstance(raw.get("tags"), list) else [],
        "width": _int_value(raw.get("width"), 0, 0),
        "height": _int_value(raw.get("height"), 0, 0),
        "size": _int_value(raw.get("size"), path.stat().st_size if path.is_file() else 0, 0),
        "createdAt": _clean_text(raw.get("createdAt")),
        "updatedAt": _clean_text(raw.get("updatedAt")),
        "path": str(path) if file_value else "",
        "exists": path.is_file() if file_value else False,
    }
    return entry if entry["id"] and entry["file"] else {}


def _load_asset_library_root(root: str | Path) -> dict[str, Any]:
    root = Path(root)
    root.mkdir(parents=True, exist_ok=True)
    (root / LIBRARY_ASSETS_DIRNAME).mkdir(parents=True, exist_ok=True)
    path = root / LIBRARY_FILE_NAME
    if not path.is_file():
        return {**_library_default(), "root": str(root), "assetsRoot": str(root / LIBRARY_ASSETS_DIRNAME), "path": str(path)}
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        raw = {}
    groups: list[dict[str, Any]] = []
    group_seen: set[str] = set()
    for index, row in enumerate(raw.get("groups", []) if isinstance(raw.get("groups"), list) else []):
        if not isinstance(row, dict):
            continue
        group = _normalize_library_group(row, index)
        if not group["id"] or group["id"] in group_seen:
            continue
        group_seen.add(group["id"])
        groups.append(group)
    if LIBRARY_DEFAULT_GROUP_ID not in group_seen:
        groups.insert(0, _normalize_library_group({"id": LIBRARY_DEFAULT_GROUP_ID, "order": 0}, 0))
        group_seen.add(LIBRARY_DEFAULT_GROUP_ID)
    groups.sort(key=lambda row: (row["order"], row["name"].lower(), row["id"]))
    for index, group in enumerate(groups):
        group["order"] = index

    assets: list[dict[str, Any]] = []
    seen: set[str] = set()
    for row in raw.get("assets", []) if isinstance(raw.get("assets"), list) else []:
        if not isinstance(row, dict):
            continue
        entry = _normalize_library_entry(row, root)
        if not entry or entry["id"] in seen:
            continue
        if entry["groupId"] not in group_seen:
            entry["groupId"] = LIBRARY_DEFAULT_GROUP_ID
        seen.add(entry["id"])
        assets.append(entry)
    group_order = {group["id"]: group["order"] for group in groups}
    assets.sort(key=lambda row: (group_order.get(row.get("groupId"), 0), str(row.get("name") or "").lower(), str(row.get("id") or "")))
    return {
        "version": 1,
        "type": "layer-studio-asset-library",
        "root": str(root),
        "assetsRoot": str(root / LIBRARY_ASSETS_DIRNAME),
        "path": str(path),
        "groups": groups,
        "assets": assets,
    }


def load_asset_library(livery_library_root: str | Path) -> dict[str, Any]:
    return _load_asset_library_root(asset_library_root(livery_library_root))


def load_project_asset_library(project_folder: str | Path) -> dict[str, Any]:
    library = _load_asset_library_root(project_asset_library_root(project_folder))
    library["scope"] = "project"
    return library


def _save_asset_library_root(root: str | Path, document: dict[str, Any]) -> Path:
    root = Path(root)
    root.mkdir(parents=True, exist_ok=True)
    (root / LIBRARY_ASSETS_DIRNAME).mkdir(parents=True, exist_ok=True)
    path = root / LIBRARY_FILE_NAME
    groups = []
    seen_groups: set[str] = set()
    for index, row in enumerate(document.get("groups", []) if isinstance(document.get("groups"), list) else []):
        if not isinstance(row, dict):
            continue
        group = _normalize_library_group(row, index)
        if not group["id"] or group["id"] in seen_groups:
            continue
        seen_groups.add(group["id"])
        groups.append(group)
    if LIBRARY_DEFAULT_GROUP_ID not in seen_groups:
        groups.insert(0, _normalize_library_group({"id": LIBRARY_DEFAULT_GROUP_ID, "order": 0}, 0))
        seen_groups.add(LIBRARY_DEFAULT_GROUP_ID)
    groups.sort(key=lambda row: (row["order"], row["name"].lower(), row["id"]))
    for index, group in enumerate(groups):
        group["order"] = index

    assets = []
    for row in document.get("assets", []) if isinstance(document.get("assets"), list) else []:
        if not isinstance(row, dict):
            continue
        entry = _normalize_library_entry(row, root)
        if not entry:
            continue
        if entry["groupId"] not in seen_groups:
            entry["groupId"] = LIBRARY_DEFAULT_GROUP_ID
        assets.append({
            "id": entry["id"],
            "name": entry["name"],
            "type": "image/png",
            "file": entry["file"],
            "hash": entry["hash"],
            "groupId": entry["groupId"],
            "tags": entry["tags"],
            "width": entry["width"],
            "height": entry["height"],
            "size": entry["size"],
            "createdAt": entry["createdAt"],
            "updatedAt": entry["updatedAt"],
        })
    path.write_text(json.dumps({**_library_default(), "groups": groups, "assets": assets}, indent=2, ensure_ascii=False), encoding="utf-8")
    return path


def save_asset_library(livery_library_root: str | Path, document: dict[str, Any]) -> Path:
    return _save_asset_library_root(asset_library_root(livery_library_root), document)


def save_project_asset_library(project_folder: str | Path, document: dict[str, Any]) -> Path:
    return _save_asset_library_root(project_asset_library_root(project_folder), document)


def find_library_asset(livery_library_root: str | Path, asset_id: str) -> dict[str, Any]:
    clean_id = _clean_text(asset_id)
    if not clean_id:
        return {}
    library = load_asset_library(livery_library_root)
    return next((row for row in library.get("assets", []) if row.get("id") == clean_id), {})


def find_project_library_asset(project_folder: str | Path, asset_id: str) -> dict[str, Any]:
    clean_id = _clean_text(asset_id)
    if not clean_id:
        return {}
    library = load_project_asset_library(project_folder)
    return next((row for row in library.get("assets", []) if row.get("id") == clean_id), {})


def _next_library_group_id(library: dict[str, Any], name: str = "group") -> str:
    base = re.sub(r"[^a-z0-9_]+", "_", _clean_text(name, "group").lower()).strip("_") or "group"
    base = f"group_{base}" if not base.startswith("group_") else base
    existing = {str(row.get("id") or "") for row in library.get("groups", []) if isinstance(row, dict)}
    group_id = base
    counter = 2
    while group_id in existing or group_id == LIBRARY_DEFAULT_GROUP_ID:
        group_id = f"{base}_{counter}"
        counter += 1
    return group_id


def create_library_group(livery_library_root: str | Path, name: str) -> dict[str, Any]:
    library = load_asset_library(livery_library_root)
    groups = library.setdefault("groups", [])
    group = {
        "id": _next_library_group_id(library, name),
        "name": _clean_text(name, "New Group"),
        "collapsed": False,
        "order": len(groups),
    }
    groups.append(group)
    save_asset_library(livery_library_root, library)
    library = load_asset_library(livery_library_root)
    return {"assetLibrary": library, "group": next((row for row in library.get("groups", []) if row.get("id") == group["id"]), group)}


def update_library_group(livery_library_root: str | Path, group_id: str, patch: dict[str, Any]) -> dict[str, Any]:
    clean_id = _clean_text(group_id)
    if not clean_id:
        raise ValueError("Layer Studio library group id is required")
    library = load_asset_library(livery_library_root)
    group = next((row for row in library.get("groups", []) if row.get("id") == clean_id), None)
    if not group:
        raise FileNotFoundError(f"Layer Studio library group not found: {clean_id}")
    if clean_id != LIBRARY_DEFAULT_GROUP_ID and "name" in patch:
        group["name"] = _clean_text(patch.get("name"), group.get("name") or "Group")
    if "collapsed" in patch:
        group["collapsed"] = bool(patch.get("collapsed"))
    save_asset_library(livery_library_root, library)
    library = load_asset_library(livery_library_root)
    return {"assetLibrary": library, "group": next((row for row in library.get("groups", []) if row.get("id") == clean_id), group)}


def delete_library_group(livery_library_root: str | Path, group_id: str) -> dict[str, Any]:
    clean_id = _clean_text(group_id)
    if not clean_id or clean_id == LIBRARY_DEFAULT_GROUP_ID:
        raise ValueError("The default Layer Studio library group cannot be deleted")
    library = load_asset_library(livery_library_root)
    groups = [row for row in library.get("groups", []) if isinstance(row, dict)]
    if not any(row.get("id") == clean_id for row in groups):
        raise FileNotFoundError(f"Layer Studio library group not found: {clean_id}")
    moved = 0
    for asset in library.get("assets", []) if isinstance(library.get("assets"), list) else []:
        if isinstance(asset, dict) and asset.get("groupId") == clean_id:
            asset["groupId"] = LIBRARY_DEFAULT_GROUP_ID
            moved += 1
    library["groups"] = [row for row in groups if row.get("id") != clean_id]
    save_asset_library(livery_library_root, library)
    return {"assetLibrary": load_asset_library(livery_library_root), "deletedGroupId": clean_id, "movedAssets": moved}


def move_library_asset(livery_library_root: str | Path, asset_id: str, group_id: str) -> dict[str, Any]:
    clean_asset_id = _clean_text(asset_id)
    clean_group_id = _clean_text(group_id, LIBRARY_DEFAULT_GROUP_ID)
    library = load_asset_library(livery_library_root)
    groups = {row.get("id") for row in library.get("groups", []) if isinstance(row, dict)}
    if clean_group_id not in groups:
        raise FileNotFoundError(f"Layer Studio library group not found: {clean_group_id}")
    asset = next((row for row in library.get("assets", []) if row.get("id") == clean_asset_id), None)
    if not asset:
        raise FileNotFoundError(f"Layer Studio library asset not found: {clean_asset_id}")
    asset["groupId"] = clean_group_id
    asset["updatedAt"] = _utc_now()
    save_asset_library(livery_library_root, library)
    library = load_asset_library(livery_library_root)
    return {"assetLibrary": library, "asset": next((row for row in library.get("assets", []) if row.get("id") == clean_asset_id), asset)}


def delete_library_asset(livery_library_root: str | Path, asset_id: str) -> dict[str, Any]:
    clean_id = _clean_text(asset_id)
    if not clean_id:
        raise ValueError("Layer Studio library asset id is required")
    library = load_asset_library(livery_library_root)
    asset = next((row for row in library.get("assets", []) if row.get("id") == clean_id), None)
    if not asset:
        raise FileNotFoundError(f"Layer Studio library asset not found: {clean_id}")
    root = asset_library_root(livery_library_root).resolve()
    assets_root = (root / LIBRARY_ASSETS_DIRNAME).resolve()
    asset_path = Path(str(asset.get("path") or "")).resolve()
    removed_file = False
    if asset_path.is_file():
        try:
            asset_path.relative_to(assets_root)
        except ValueError as exc:
            raise ValueError("Layer Studio library asset delete target must stay inside the library assets folder") from exc
        asset_path.unlink()
        removed_file = True
    library["assets"] = [row for row in library.get("assets", []) if isinstance(row, dict) and row.get("id") != clean_id]
    save_asset_library(livery_library_root, library)
    return {"assetLibrary": load_asset_library(livery_library_root), "asset": asset, "removedFile": removed_file}


def create_project_library_group(project_folder: str | Path, name: str) -> dict[str, Any]:
    root = project_asset_library_root(project_folder)
    library = _load_asset_library_root(root)
    groups = library.setdefault("groups", [])
    group = {
        "id": _next_library_group_id(library, name),
        "name": _clean_text(name, "New Group"),
        "collapsed": False,
        "order": len(groups),
    }
    groups.append(group)
    _save_asset_library_root(root, library)
    library = load_project_asset_library(project_folder)
    return {"assetLibrary": library, "group": next((row for row in library.get("groups", []) if row.get("id") == group["id"]), group)}


def update_project_library_group(project_folder: str | Path, group_id: str, patch: dict[str, Any]) -> dict[str, Any]:
    clean_id = _clean_text(group_id)
    if not clean_id:
        raise ValueError("Layer Studio project library group id is required")
    root = project_asset_library_root(project_folder)
    library = _load_asset_library_root(root)
    group = next((row for row in library.get("groups", []) if row.get("id") == clean_id), None)
    if not group:
        raise FileNotFoundError(f"Layer Studio project library group not found: {clean_id}")
    if clean_id != LIBRARY_DEFAULT_GROUP_ID and "name" in patch:
        group["name"] = _clean_text(patch.get("name"), group.get("name") or "Group")
    if "collapsed" in patch:
        group["collapsed"] = bool(patch.get("collapsed"))
    _save_asset_library_root(root, library)
    library = load_project_asset_library(project_folder)
    return {"assetLibrary": library, "group": next((row for row in library.get("groups", []) if row.get("id") == clean_id), group)}


def delete_project_library_group(project_folder: str | Path, group_id: str) -> dict[str, Any]:
    clean_id = _clean_text(group_id)
    if not clean_id or clean_id == LIBRARY_DEFAULT_GROUP_ID:
        raise ValueError("The default Layer Studio project library group cannot be deleted")
    root = project_asset_library_root(project_folder)
    library = _load_asset_library_root(root)
    groups = [row for row in library.get("groups", []) if isinstance(row, dict)]
    if not any(row.get("id") == clean_id for row in groups):
        raise FileNotFoundError(f"Layer Studio project library group not found: {clean_id}")
    moved = 0
    for asset in library.get("assets", []) if isinstance(library.get("assets"), list) else []:
        if isinstance(asset, dict) and asset.get("groupId") == clean_id:
            asset["groupId"] = LIBRARY_DEFAULT_GROUP_ID
            moved += 1
    library["groups"] = [row for row in groups if row.get("id") != clean_id]
    _save_asset_library_root(root, library)
    return {"assetLibrary": load_project_asset_library(project_folder), "deletedGroupId": clean_id, "movedAssets": moved}


def move_project_library_asset(project_folder: str | Path, asset_id: str, group_id: str) -> dict[str, Any]:
    clean_asset_id = _clean_text(asset_id)
    clean_group_id = _clean_text(group_id, LIBRARY_DEFAULT_GROUP_ID)
    root = project_asset_library_root(project_folder)
    library = _load_asset_library_root(root)
    groups = {row.get("id") for row in library.get("groups", []) if isinstance(row, dict)}
    if clean_group_id not in groups:
        raise FileNotFoundError(f"Layer Studio project library group not found: {clean_group_id}")
    asset = next((row for row in library.get("assets", []) if row.get("id") == clean_asset_id), None)
    if not asset:
        raise FileNotFoundError(f"Layer Studio project library asset not found: {clean_asset_id}")
    asset["groupId"] = clean_group_id
    asset["updatedAt"] = _utc_now()
    _save_asset_library_root(root, library)
    library = load_project_asset_library(project_folder)
    return {"assetLibrary": library, "asset": next((row for row in library.get("assets", []) if row.get("id") == clean_asset_id), asset)}


def delete_project_library_asset(project_folder: str | Path, asset_id: str) -> dict[str, Any]:
    clean_id = _clean_text(asset_id)
    if not clean_id:
        raise ValueError("Layer Studio project library asset id is required")
    root = project_asset_library_root(project_folder).resolve()
    library = _load_asset_library_root(root)
    asset = next((row for row in library.get("assets", []) if row.get("id") == clean_id), None)
    if not asset:
        raise FileNotFoundError(f"Layer Studio project library asset not found: {clean_id}")
    assets_root = (root / LIBRARY_ASSETS_DIRNAME).resolve()
    asset_path = Path(str(asset.get("path") or "")).resolve()
    removed_file = False
    if asset_path.is_file():
        try:
            asset_path.relative_to(assets_root)
        except ValueError as exc:
            raise ValueError("Layer Studio project library asset delete target must stay inside the project library assets folder") from exc
        asset_path.unlink()
        removed_file = True
    library["assets"] = [row for row in library.get("assets", []) if isinstance(row, dict) and row.get("id") != clean_id]
    _save_asset_library_root(root, library)
    return {"assetLibrary": load_project_asset_library(project_folder), "asset": asset, "removedFile": removed_file}


def default_layer_document(
    texture_size: dict[str, Any] | None = None,
    texture_key: str | None = None,
    base_texture: str | None = None,
) -> dict[str, Any]:
    size = texture_size if isinstance(texture_size, dict) else {}
    width = _int_value(size.get("width"), TEXTURE_SIZE, 1)
    height = _int_value(size.get("height"), TEXTURE_SIZE, 1)
    key = _surface_key(texture_key, TEXTURE_NAME)
    return {
        "version": LAYER_MODEL_VERSION,
        "texture": key,
        "textureSize": {"width": width, "height": height},
        "baseTexture": _clean_text(base_texture, BASE_TEXTURE_NAME),
        "baseColor": {
            "fill": _default_base_color(key),
            "visible": True,
        },
        "guides": [],
        "guidesVisible": True,
        "snapEnabled": True,
        "layers": [],
    }


def normalize_base_color(raw: Any, texture_key: Any = DEFAULT_SURFACE_KEY) -> dict[str, Any]:
    data = raw if isinstance(raw, dict) else {}
    default_fill = _default_base_color(texture_key)
    fill = _clean_text(data.get("fill"), default_fill)
    if not re.match(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$", fill):
        fill = default_fill
    return {
        "fill": fill,
        "visible": data.get("visible") is not False,
        "autoHiddenForNonFallbackSplatMap": data.get("autoHiddenForNonFallbackSplatMap") is True,
        "autoHiddenForOriginalBaseTexture": data.get("autoHiddenForOriginalBaseTexture") is True,
        "autoHiddenForActiveTexture": data.get("autoHiddenForActiveTexture") is True,
    }


def normalize_guide(raw: dict[str, Any], index: int, texture_size: dict[str, int]) -> dict[str, Any]:
    axis = _clean_text(raw.get("axis"), "x").lower()
    if axis not in {"x", "y"}:
        axis = "x"
    limit = texture_size["height"] if axis == "y" else texture_size["width"]
    return {
        "id": _clean_text(raw.get("id"), f"guide_{axis}_{index + 1:03d}"),
        "axis": axis,
        "position": max(0, min(limit, _int_value(raw.get("position"), limit // 2, 0))),
    }


def normalize_image_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int]) -> dict[str, Any]:
    layer_id = _clean_text(raw.get("id"), f"layer_{index + 1:03d}")
    source_type_raw = _clean_text(raw.get("sourceType"), "project").lower().replace("_", "")
    if source_type_raw in {"projectlibrary", "projectasset", "projectassets"}:
        source_type = "projectLibrary"
    elif source_type_raw == "library":
        source_type = "library"
    else:
        source_type = "project"
    source = _clean_text(raw.get("source"))
    width = _int_value(raw.get("width"), 256, 1)
    height = _int_value(raw.get("height"), 256, 1)
    texture_width = texture_size["width"]
    texture_height = texture_size["height"]
    return {
        "id": layer_id,
        "name": _clean_text(raw.get("name"), f"Image Layer {index + 1}"),
        "type": "image",
        "sourceType": source_type,
        "source": source,
        "x": _coordinate_value(raw.get("x"), max(0, (texture_width - width) // 2)),
        "y": _coordinate_value(raw.get("y"), max(0, (texture_height - height) // 2)),
        "width": width,
        "height": height,
        "rotation": _float_value(raw.get("rotation"), 0.0),
        "flipX": bool(raw.get("flipX", False)),
        "flipY": bool(raw.get("flipY", False)),
        "opacity": _float_value(raw.get("opacity"), 1.0, 0.0, 1.0),
        "ratioLocked": raw.get("ratioLocked") is not False,
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "order": _int_value(raw.get("order"), index, 0),
    }


def _placement_origin(
    placement: dict[str, Any] | None,
    layer_width: int,
    layer_height: int,
    texture_size: dict[str, Any],
) -> dict[str, int]:
    size_width = int(texture_size.get("width") or TEXTURE_SIZE)
    size_height = int(texture_size.get("height") or TEXTURE_SIZE)
    fallback_x = max(0, (size_width - layer_width) // 2)
    fallback_y = max(0, (size_height - layer_height) // 2)
    data = placement if isinstance(placement, dict) else {}
    center_x = _float_value(data.get("centerX", data.get("x")), fallback_x + layer_width / 2)
    center_y = _float_value(data.get("centerY", data.get("y")), fallback_y + layer_height / 2)
    return {
        "x": int(round(center_x - layer_width / 2)),
        "y": int(round(center_y - layer_height / 2)),
    }


def normalize_polygon_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int]) -> dict[str, Any]:
    raw_points = raw.get("points") if isinstance(raw.get("points"), list) else []
    points: list[int] = []
    for item in raw_points:
        try:
            points.append(int(round(float(item))))
        except Exception:
            continue
    if len(points) < 6:
        width = texture_size["width"]
        height = texture_size["height"]
        points = [
            max(0, width // 2 - 220), max(0, height // 2 - 160),
            min(width, width // 2 + 220), max(0, height // 2 - 160),
            min(width, width // 2 + 180), min(height, height // 2 + 160),
            max(0, width // 2 - 180), min(height, height // 2 + 160),
        ]
    fill = _clean_text(raw.get("fill"), "#e43d30")
    return {
        "id": _clean_text(raw.get("id"), f"layer_{index + 1:03d}"),
        "name": _clean_text(raw.get("name"), f"Polygon Layer {index + 1}"),
        "type": "polygon",
        "points": points,
        "bezierHandles": normalize_polygon_bezier_handles(raw.get("bezierHandles"), points, texture_size),
        "fill": fill,
        "fillGradient": normalize_fill_gradient(raw.get("fillGradient"), fill),
        "stroke": raw.get("stroke") if raw.get("stroke") is not None else None,
        "strokeWidth": _int_value(raw.get("strokeWidth"), 0, 0),
        "opacity": _float_value(raw.get("opacity"), 1.0, 0.0, 1.0),
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "order": _int_value(raw.get("order"), index, 0),
    }


def _normalize_layer_point(raw: dict[str, Any]) -> dict[str, int] | None:
    if not isinstance(raw, dict):
        return None
    return {
        "x": _coordinate_value(raw.get("x")),
        "y": _coordinate_value(raw.get("y")),
    }


def normalize_point_bezier_handles(raw: Any, points: list[int], texture_size: dict[str, int], *, closed: bool = False) -> list[dict[str, Any] | None]:
    if not isinstance(raw, list):
        return []
    point_count = len(points) // 2
    segment_count = max(0, point_count if closed else point_count - 1)
    if not segment_count:
        return []
    handles: list[dict[str, Any] | None] = []
    has_handle = False
    for index in range(segment_count):
        source = raw[index] if index < len(raw) else None
        if not isinstance(source, dict):
            handles.append(None)
            continue
        out_point = _normalize_layer_point(source.get("out"))
        in_point = _normalize_layer_point(source.get("in"))
        if not out_point and not in_point:
            handles.append(None)
            continue
        has_handle = True
        handles.append({"out": out_point, "in": in_point})
    return handles if has_handle else []


def normalize_stripe_bezier_handles(raw: Any, points: list[int], texture_size: dict[str, int]) -> list[dict[str, Any] | None]:
    return normalize_point_bezier_handles(raw, points, texture_size, closed=False)


def normalize_polygon_bezier_handles(raw: Any, points: list[int], texture_size: dict[str, int]) -> list[dict[str, Any] | None]:
    return normalize_point_bezier_handles(raw, points, texture_size, closed=True)


def normalize_stripe_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int]) -> dict[str, Any]:
    raw_points = raw.get("points") if isinstance(raw.get("points"), list) else []
    points: list[int] = []
    for item in raw_points:
        try:
            points.append(int(round(float(item))))
        except Exception:
            continue
    if len(points) < 4:
        width = texture_size["width"]
        height = texture_size["height"]
        points = [
            max(0, width // 2 - 420), max(0, height // 2 - 120),
            max(0, width // 2 - 120), max(0, height // 2 - 180),
            min(width, width // 2 + 420), min(height, height // 2 + 140),
        ]
    line_cap = _clean_text(raw.get("lineCap"), "round").lower()
    line_join = _clean_text(raw.get("lineJoin"), "round").lower()
    stroke = _clean_text(raw.get("stroke") or raw.get("fill"), "#f07a00")
    return {
        "id": _clean_text(raw.get("id"), f"stripe_{index + 1:03d}"),
        "name": _clean_text(raw.get("name"), f"Stripe {index + 1}"),
        "type": "stripe",
        "points": points,
        "stroke": stroke,
        "strokeGradient": normalize_fill_gradient(raw.get("strokeGradient"), stroke),
        "strokeWidth": _int_value(raw.get("strokeWidth"), 20, 1),
        "opacity": _float_value(raw.get("opacity"), 1.0, 0.0, 1.0),
        "tension": _float_value(raw.get("tension"), 0.0, 0.0, 1.0),
        "bezierHandles": normalize_stripe_bezier_handles(raw.get("bezierHandles"), points, texture_size),
        "lineCap": line_cap if line_cap in {"butt", "round", "square"} else "round",
        "lineJoin": line_join if line_join in {"miter", "round", "bevel"} else "round",
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "order": _int_value(raw.get("order"), index, 0),
    }


def is_shape_layer_type(layer_type: str) -> bool:
    return layer_type in {"rectangle", "ellipse"}


def is_text_layer_type(layer_type: str) -> bool:
    return layer_type == "text"


def is_png_mask_group_type(layer_type: str) -> bool:
    return _clean_text(layer_type).lower() == "pngmaskgroup"


def is_renderable_layer_type(layer_type: str) -> bool:
    return layer_type in {"image", "polygon", "stripe"} or is_shape_layer_type(layer_type) or is_text_layer_type(layer_type)


def normalize_text_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int]) -> dict[str, Any]:
    width = _int_value(raw.get("width"), 720, 1)
    font_size = _int_value(raw.get("fontSize"), 96, 4)
    texture_width = texture_size["width"]
    texture_height = texture_size["height"]
    text = _layer_text(raw.get("text"), "NEW TEXT")
    return {
        "id": _clean_text(raw.get("id"), f"text_{index + 1:03d}"),
        "name": _text_layer_name(text, f"Text Layer {index + 1}"),
        "type": "text",
        "text": text,
        "fontSourceType": "app",
        "fontFamily": _clean_text(raw.get("fontFamily"), "Rajdhani"),
        "fontWeight": _int_value(raw.get("fontWeight"), 700, 100),
        "fontSize": font_size,
        "x": _coordinate_value(raw.get("x"), max(0, (texture_width - width) // 2)),
        "y": _coordinate_value(raw.get("y"), max(0, texture_height // 2 - font_size)),
        "width": width,
        "height": _int_value(raw.get("height"), max(font_size, round(font_size * 1.25)), 1),
        "rotation": _float_value(raw.get("rotation"), 0.0),
        "fill": _clean_text(raw.get("fill"), "#ffffff"),
        "opacity": _float_value(raw.get("opacity"), 1.0, 0.0, 1.0),
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "order": _int_value(raw.get("order"), index, 0),
    }


def normalize_shape_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int], layer_type: str) -> dict[str, Any]:
    width = _int_value(raw.get("width"), 360, 1)
    height = _int_value(raw.get("height"), 360, 1)
    texture_width = texture_size["width"]
    texture_height = texture_size["height"]
    fallback_name = "Ellipse" if layer_type == "ellipse" else "Rectangle"
    fill = _clean_text(raw.get("fill"), "#e43d30")
    return {
        "id": _clean_text(raw.get("id"), f"{layer_type}_{index + 1:03d}"),
        "name": _clean_text(raw.get("name"), f"{fallback_name} Layer {index + 1}"),
        "type": layer_type,
        "x": _coordinate_value(raw.get("x"), max(0, (texture_width - width) // 2)),
        "y": _coordinate_value(raw.get("y"), max(0, (texture_height - height) // 2)),
        "width": width,
        "height": height,
        "rotation": _float_value(raw.get("rotation"), 0.0),
        "fill": fill,
        "fillGradient": normalize_fill_gradient(raw.get("fillGradient"), fill),
        "opacity": _float_value(raw.get("opacity"), 1.0, 0.0, 1.0),
        "ratioLocked": raw.get("ratioLocked") is not False,
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "order": _int_value(raw.get("order"), index, 0),
    }


def _unique_layer_id(layer_id: str, seen_ids: set[str]) -> str:
    original_id = _clean_text(layer_id, "layer")
    clean_id = original_id
    suffix = 2
    while clean_id in seen_ids:
        clean_id = f"{original_id}_{suffix}"
        suffix += 1
    seen_ids.add(clean_id)
    return clean_id


def normalize_renderable_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int]) -> dict[str, Any]:
    layer_type = _clean_text(raw.get("type"), "image").lower()
    if layer_type == "polygon":
        return normalize_polygon_layer(raw, index, texture_size)
    if layer_type == "stripe":
        return normalize_stripe_layer(raw, index, texture_size)
    if is_shape_layer_type(layer_type):
        return normalize_shape_layer(raw, index, texture_size, layer_type)
    if is_text_layer_type(layer_type):
        return normalize_text_layer(raw, index, texture_size)
    return normalize_image_layer(raw, index, texture_size)


def normalize_group_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int], seen_ids: set[str]) -> dict[str, Any]:
    group_id = _unique_layer_id(_clean_text(raw.get("id"), f"group_{index + 1:03d}"), seen_ids)
    children: list[dict[str, Any]] = []
    for child_index, raw_child in enumerate(raw.get("children") if isinstance(raw.get("children"), list) else []):
        if not isinstance(raw_child, dict):
            continue
        child_type = _clean_text(raw_child.get("type"), "image").lower()
        if not is_renderable_layer_type(child_type):
            continue
        child = normalize_renderable_layer(raw_child, len(children), texture_size)
        child["id"] = _unique_layer_id(child["id"], seen_ids)
        child["order"] = len(children)
        child["groupId"] = group_id
        children.append(child)
    return {
        "id": group_id,
        "name": _clean_text(raw.get("name"), f"Group {index + 1}"),
        "type": "group",
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "collapsed": bool(raw.get("collapsed", False)),
        "order": _int_value(raw.get("order"), index, 0),
        "children": children,
    }


def normalize_png_mask_group_layer(raw: dict[str, Any], index: int, texture_size: dict[str, int], seen_ids: set[str]) -> dict[str, Any]:
    group_id = _unique_layer_id(_clean_text(raw.get("id"), f"mask_{index + 1:03d}"), seen_ids)
    raw_children = raw.get("children") if isinstance(raw.get("children"), list) else []
    raw_mask = raw.get("mask") if isinstance(raw.get("mask"), dict) else next(
        (child for child in raw_children if isinstance(child, dict) and _clean_text(child.get("type")).lower() == "polygon"),
        {},
    )
    raw_child = raw.get("child") if isinstance(raw.get("child"), dict) else next(
        (child for child in raw_children if isinstance(child, dict) and _clean_text(child.get("type")).lower() == "image"),
        {},
    )
    mask = normalize_polygon_layer(raw_mask, 0, texture_size)
    child = normalize_image_layer(raw_child, 1, texture_size)
    mask["id"] = _unique_layer_id(mask["id"], seen_ids)
    child["id"] = _unique_layer_id(child["id"], seen_ids)
    mask["order"] = 0
    mask["groupId"] = group_id
    mask["maskRole"] = "mask"
    child["order"] = 1
    child["groupId"] = group_id
    child["maskRole"] = "image"
    return {
        "id": group_id,
        "name": _clean_text(raw.get("name"), f"PNG Mask {index + 1}"),
        "type": "pngMaskGroup",
        "visible": raw.get("visible") is not False,
        "locked": bool(raw.get("locked", False)),
        "collapsed": bool(raw.get("collapsed", False)),
        "order": _int_value(raw.get("order"), index, 0),
        "mask": mask,
        "child": child,
    }


def normalize_layer_document(
    raw: dict[str, Any] | None,
    texture_size: dict[str, Any] | None = None,
    texture_key: str | None = None,
    base_texture: str | None = None,
) -> dict[str, Any]:
    base = default_layer_document(texture_size, texture_key, base_texture)
    data = raw if isinstance(raw, dict) else {}
    size_data = data.get("textureSize") if isinstance(data.get("textureSize"), dict) else base["textureSize"]
    normalized_size = {
        "width": _int_value(size_data.get("width"), base["textureSize"]["width"], 1),
        "height": _int_value(size_data.get("height"), base["textureSize"]["height"], 1),
    }
    guides: list[dict[str, Any]] = []
    seen_guides: set[str] = set()
    for index, raw_guide in enumerate(data.get("guides") if isinstance(data.get("guides"), list) else []):
        if not isinstance(raw_guide, dict):
            continue
        guide = normalize_guide(raw_guide, len(guides), normalized_size)
        guide["id"] = _unique_layer_id(guide["id"], seen_guides)
        guides.append(guide)
    layers: list[dict[str, Any]] = []
    seen_ids: set[str] = set()
    for index, raw_layer in enumerate(data.get("layers") if isinstance(data.get("layers"), list) else []):
        if not isinstance(raw_layer, dict):
            continue
        layer_type = _clean_text(raw_layer.get("type"), "image").lower()
        if is_png_mask_group_type(layer_type):
            layer = normalize_png_mask_group_layer(raw_layer, len(layers), normalized_size, seen_ids)
        elif layer_type == "group":
            layer = normalize_group_layer(raw_layer, len(layers), normalized_size, seen_ids)
        elif is_renderable_layer_type(layer_type):
            layer = normalize_renderable_layer(raw_layer, len(layers), normalized_size)
            layer["id"] = _unique_layer_id(layer["id"], seen_ids)
        else:
            continue
        layers.append(layer)
    layers.sort(key=lambda row: (row.get("order", 0), row.get("id", "")))
    for index, layer in enumerate(layers):
        layer["order"] = index
        if is_png_mask_group_type(layer.get("type")):
            if isinstance(layer.get("mask"), dict):
                layer["mask"]["order"] = 0
                layer["mask"]["groupId"] = layer["id"]
                layer["mask"]["maskRole"] = "mask"
            if isinstance(layer.get("child"), dict):
                layer["child"]["order"] = 1
                layer["child"]["groupId"] = layer["id"]
                layer["child"]["maskRole"] = "image"
        elif layer.get("type") == "group":
            for child_index, child in enumerate(layer.get("children") if isinstance(layer.get("children"), list) else []):
                child["order"] = child_index
                child["groupId"] = layer["id"]
    return {
        "version": LAYER_MODEL_VERSION,
        "texture": _surface_key(texture_key, _clean_text(data.get("texture"), base["texture"])),
        "textureSize": normalized_size,
        "baseTexture": _clean_text(data.get("baseTexture"), base["baseTexture"]),
        "baseColor": normalize_base_color(data.get("baseColor") if isinstance(data.get("baseColor"), dict) else base["baseColor"], texture_key),
        "guides": guides,
        "guidesVisible": data.get("guidesVisible") is not False,
        "snapEnabled": data.get("snapEnabled") is not False,
        "layers": layers,
    }


def _project_folder_for_layers_path(layers_path: Path) -> Path | None:
    try:
        parts = list(layers_path.resolve().parts)
        index = next(
            idx
            for idx, part in enumerate(parts)
            if str(part).lower() == PROJECT_DIRNAME.lower()
        )
        if index > 0:
            return Path(*parts[:index])
    except Exception:
        return None
    return None


def _iter_document_image_layers(document: dict[str, Any]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []

    def visit(layer: Any) -> None:
        if not isinstance(layer, dict):
            return
        layer_type = _clean_text(layer.get("type"), "image").lower()
        if layer_type == "image":
            rows.append(layer)
        if is_png_mask_group_type(layer_type):
            visit(layer.get("child"))
            return
        if layer_type == "group":
            for child in layer.get("children") if isinstance(layer.get("children"), list) else []:
                visit(child)

    for layer in document.get("layers") if isinstance(document.get("layers"), list) else []:
        visit(layer)
    return rows


def migrate_legacy_project_library_sources(document: dict[str, Any], project_folder: str | Path | None) -> int:
    if not isinstance(document, dict) or not project_folder:
        return 0
    project_library = load_project_asset_library(project_folder)
    project_asset_ids = {
        str(row.get("id") or "")
        for row in project_library.get("assets", [])
        if isinstance(row, dict) and str(row.get("id") or "")
    }
    if not project_asset_ids:
        return 0
    migrated = 0
    for layer in _iter_document_image_layers(document):
        source_type = _clean_text(layer.get("sourceType"), "project").lower().replace("_", "")
        source = _clean_text(layer.get("source"))
        if source_type == "library" and source in project_asset_ids:
            layer["sourceType"] = "projectLibrary"
            migrated += 1
    return migrated


def load_layer_document(
    layers_path: Path,
    texture_size: dict[str, Any] | None = None,
    texture_key: str | None = None,
    base_texture: str | None = None,
) -> tuple[dict[str, Any], list[str]]:
    warnings: list[str] = []
    if not layers_path.is_file():
        return normalize_layer_document(None, texture_size, texture_key, base_texture), warnings
    try:
        data = json.loads(layers_path.read_text(encoding="utf-8"))
    except Exception as exc:
        warnings.append(f"Layer document could not be read: {exc}")
        return normalize_layer_document(None, texture_size, texture_key, base_texture), warnings
    document = normalize_layer_document(data if isinstance(data, dict) else None, texture_size, texture_key, base_texture)
    migrate_legacy_project_library_sources(document, _project_folder_for_layers_path(layers_path))
    return document, warnings


def save_layer_document(
    layers_path: Path,
    document: dict[str, Any],
    texture_size: dict[str, Any] | None = None,
    texture_key: str | None = None,
    base_texture: str | None = None,
) -> Path:
    normalized = normalize_layer_document(document, texture_size, texture_key, base_texture)
    layers_path.parent.mkdir(parents=True, exist_ok=True)
    layers_path.write_text(json.dumps(normalized, indent=2, ensure_ascii=False), encoding="utf-8")
    return layers_path


def _is_absolute_or_parent_path(value: str) -> bool:
    text = str(value or "").strip()
    if not text:
        return False
    if re.match(r"^[a-zA-Z]:[\\/]", text) or text.startswith("\\\\"):
        return True
    parts = Path(text.replace("\\", "/")).parts
    return any(part == ".." for part in parts)


def iter_renderable_layers(document: dict[str, Any]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for layer in document.get("layers") if isinstance(document.get("layers"), list) else []:
        if not isinstance(layer, dict):
            continue
        layer_type = _clean_text(layer.get("type"), "image").lower()
        if is_png_mask_group_type(layer_type):
            for child in (layer.get("mask"), layer.get("child")):
                if isinstance(child, dict) and is_renderable_layer_type(_clean_text(child.get("type"), "image").lower()):
                    rows.append(child)
        elif layer_type == "group":
            rows.extend([
                child for child in layer.get("children", [])
                if isinstance(child, dict) and is_renderable_layer_type(_clean_text(child.get("type"), "image").lower())
            ])
        elif is_renderable_layer_type(layer_type):
            rows.append(layer)
    return rows


def asset_warnings(layer_root: Path, document: dict[str, Any], livery_library_root: str | Path | None = None) -> list[str]:
    warnings: list[str] = []
    library = load_asset_library(livery_library_root) if livery_library_root else {}
    project_folder = layer_root.parent if layer_root.name == PROJECT_DIRNAME else layer_root
    project_library = load_project_asset_library(project_folder)
    library_assets = {
        str(row.get("id") or ""): row
        for row in library.get("assets", []) if isinstance(row, dict)
    }
    project_assets = {
        str(row.get("id") or ""): row
        for row in project_library.get("assets", []) if isinstance(row, dict)
    }
    for layer in iter_renderable_layers(document):
        if _clean_text(layer.get("type"), "image").lower() != "image":
            continue
        source_type = _clean_text(layer.get("sourceType"), "project").lower().replace("_", "")
        source = _clean_text(layer.get("source"))
        if not source:
            warnings.append(f"Layer has no asset source: {layer.get('name') or layer.get('id') or '-'}")
            continue
        if source_type == "projectlibrary":
            asset = project_assets.get(source)
            if not asset:
                warnings.append(f"Project library PNG missing: {layer.get('name') or layer.get('id') or source}")
            elif not asset.get("exists"):
                warnings.append(f"Project library PNG file missing: {asset.get('file') or source}")
            continue
        if source_type == "library":
            asset = library_assets.get(source)
            if not asset:
                warnings.append(f"Layer library asset missing: {layer.get('name') or layer.get('id') or source}")
            elif not asset.get("exists"):
                warnings.append(f"Layer library file missing: {asset.get('file') or source}")
            continue
        if _is_absolute_or_parent_path(source):
            warnings.append(f"Layer asset must be project-relative: {layer.get('name') or layer.get('id') or source}")
            continue
        asset_path = layer_root / source.replace("\\", "/")
        if not asset_path.is_file():
            warnings.append(f"Layer asset missing: {source}")
    return warnings


def save_project_document(
    project_folder: str | Path,
    document: dict[str, Any],
    livery_library_root: str | Path | None = None,
    surface_key: str | None = None,
) -> dict[str, Any]:
    project_root = Path(project_folder)
    if not project_root:
        raise ValueError("Layer Studio project folder is required")
    key = _surface_key(surface_key, DEFAULT_SURFACE_KEY)
    size_data = document.get("textureSize") if isinstance(document, dict) and isinstance(document.get("textureSize"), dict) else None
    layer_root = project_root / PROJECT_DIRNAME
    layers_path = _surface_layers_path(project_root, key)
    normalized = normalize_layer_document(document, size_data or {"width": TEXTURE_SIZE, "height": TEXTURE_SIZE}, key, SURFACE_BASE_TEXTURE_NAME)
    invalid_sources = [
        str(layer.get("source") or "")
        for layer in iter_renderable_layers(normalized)
        if _clean_text(layer.get("sourceType"), "project").lower().replace("_", "") not in {"library", "projectlibrary"}
        and _is_absolute_or_parent_path(str(layer.get("source") or ""))
    ]
    if invalid_sources:
        raise ValueError("Layer Studio asset paths must be relative to the layer-studio folder.")
    save_layer_document(layers_path, normalized, normalized.get("textureSize"), key, SURFACE_BASE_TEXTURE_NAME)
    warnings = asset_warnings(layer_root, normalized, livery_library_root)
    return {
        "layerDocument": normalized,
        "layersPath": str(layers_path),
        "surfaceKey": key,
        "warnings": warnings,
    }


def _asset_file_name(source: Path, assets_root: Path) -> str:
    stem = re.sub(r"[^0-9A-Za-z._-]+", "_", source.stem).strip("._-") or "imported_png"
    suffix = source.suffix.lower()
    candidate = f"{stem}{suffix}"
    counter = 2
    while (assets_root / candidate).exists():
        candidate = f"{stem}_{counter}{suffix}"
        counter += 1
    return candidate


def import_png_asset(
    project_folder: str | Path,
    source_png: str | Path,
    texture_size: dict[str, Any] | None = None,
    placement: dict[str, Any] | None = None,
    surface_key: str | None = None,
    layer_document: dict[str, Any] | None = None,
) -> dict[str, Any]:
    project_root = Path(project_folder)
    if not project_root:
        raise ValueError("Layer Studio project folder is required")
    added = add_png_to_project_library(project_root, source_png)
    asset = added.get("asset") if isinstance(added.get("asset"), dict) else {}
    inserted = insert_project_library_asset_layer(project_root, str(asset.get("id") or ""), texture_size, placement, surface_key, layer_document)
    return {
        **inserted,
        "asset": asset,
        "projectAssetLibrary": load_project_asset_library(project_root),
        "source": str(source_png),
        "duplicate": bool(added.get("duplicate")),
    }


def add_png_to_library(livery_library_root: str | Path, source_png: str | Path) -> dict[str, Any]:
    return _add_png_to_asset_library_root(asset_library_root(livery_library_root), source_png)


def add_png_to_project_library(project_folder: str | Path, source_png: str | Path, display_name: str | None = None) -> dict[str, Any]:
    result = _add_png_to_asset_library_root(project_asset_library_root(project_folder), source_png, display_name=display_name, visible_file_names=True)
    result["assetLibrary"] = load_project_asset_library(project_folder)
    return result


def _add_png_to_asset_library_root(
    root: str | Path,
    source_png: str | Path,
    *,
    display_name: str | None = None,
    visible_file_names: bool = False,
) -> dict[str, Any]:
    source = Path(source_png)
    if not source.is_file():
        raise FileNotFoundError(f"PNG source file not found: {source}")
    if source.suffix.lower() != ".png":
        raise ValueError("Layer Studio asset library only supports PNG files.")
    try:
        with Image.open(source) as image:
            if image.format != "PNG":
                raise ValueError("Selected file is not a valid PNG image.")
            width, height = image.size
    except ValueError:
        raise
    except Exception as exc:
        raise ValueError(f"Selected PNG could not be read: {exc}") from exc

    root = Path(root)
    root.mkdir(parents=True, exist_ok=True)
    (root / LIBRARY_ASSETS_DIRNAME).mkdir(parents=True, exist_ok=True)
    library = _load_asset_library_root(root)
    digest = _sha256_file(source)
    hash_value = f"sha256:{digest}"
    existing = next((row for row in library.get("assets", []) if row.get("hash") == hash_value and row.get("exists")), None)
    if existing:
        return {
            "asset": existing,
            "assetLibrary": library,
            "duplicate": True,
            "source": str(source),
        }

    assets_root = root / LIBRARY_ASSETS_DIRNAME
    asset_id = f"asset_{digest[:16]}"
    name_source = _clean_text(display_name, source.stem)
    clean_stem = re.sub(r"[^0-9A-Za-z._-]+", "_", name_source).strip("._-") or asset_id
    target = assets_root / (f"{clean_stem}.png" if visible_file_names else f"{asset_id}.png")
    counter = 2
    while target.exists():
        asset_id = f"asset_{digest[:16]}_{counter}"
        target = assets_root / (f"{clean_stem}_{counter}.png" if visible_file_names else f"{asset_id}.png")
        counter += 1
    shutil.copy2(source, target)
    now = _utc_now()
    asset = {
        "id": asset_id,
        "name": name_source or _clean_text(source.stem, asset_id),
        "type": "image/png",
        "file": f"{LIBRARY_ASSETS_DIRNAME}/{target.name}",
        "hash": hash_value,
        "groupId": LIBRARY_DEFAULT_GROUP_ID,
        "tags": [],
        "width": int(width),
        "height": int(height),
        "size": target.stat().st_size,
        "createdAt": now,
        "updatedAt": now,
        "path": str(target),
        "exists": True,
    }
    library.setdefault("assets", []).append(asset)
    _save_asset_library_root(root, library)
    library = _load_asset_library_root(root)
    asset = next((row for row in library.get("assets", []) if row.get("id") == asset_id), {}) or asset
    return {
        "asset": asset,
        "assetLibrary": library,
        "duplicate": False,
        "source": str(source),
    }


def _insert_asset_layer(
    project_folder: str | Path,
    asset: dict[str, Any],
    source_type: str,
    texture_size: dict[str, Any] | None = None,
    placement: dict[str, Any] | None = None,
    surface_key: str | None = None,
    layer_document: dict[str, Any] | None = None,
) -> dict[str, Any]:
    project_root = Path(project_folder)
    key = _surface_key(surface_key, DEFAULT_SURFACE_KEY)
    layers_path = _surface_layers_path(project_root, key)
    warnings: list[str] = []
    if isinstance(layer_document, dict):
        document = normalize_layer_document(layer_document, texture_size, key, SURFACE_BASE_TEXTURE_NAME)
    else:
        document, warnings = load_layer_document(layers_path, texture_size, key, SURFACE_BASE_TEXTURE_NAME)
    size = document.get("textureSize") if isinstance(document.get("textureSize"), dict) else default_layer_document(texture_size, key, SURFACE_BASE_TEXTURE_NAME)["textureSize"]
    width = min(_int_value(asset.get("width"), 256, 1), int(size.get("width") or TEXTURE_SIZE))
    height = min(_int_value(asset.get("height"), 256, 1), int(size.get("height") or TEXTURE_SIZE))
    origin = _placement_origin(placement, width, height, size)
    layer_index = len(document.get("layers") or [])
    asset_id = _clean_text(asset.get("id"))
    layer = normalize_image_layer(
        {
            "id": f"layer_{layer_index + 1:03d}",
            "name": asset.get("name") or asset_id,
            "type": "image",
            "sourceType": source_type,
            "source": asset_id,
            "x": origin["x"],
            "y": origin["y"],
            "width": width,
            "height": height,
            "rotation": 0,
            "opacity": 1,
            "visible": True,
            "locked": False,
            "order": layer_index,
        },
        layer_index,
        {"width": int(size.get("width") or TEXTURE_SIZE), "height": int(size.get("height") or TEXTURE_SIZE)},
    )
    layer_id = layer["id"]
    document.setdefault("layers", []).append(layer)
    document = normalize_layer_document(document, size, key, SURFACE_BASE_TEXTURE_NAME)
    layer = next((row for row in document.get("layers", []) if row.get("id") == layer_id), layer)
    save_layer_document(layers_path, document, size, key, SURFACE_BASE_TEXTURE_NAME)
    return {
        "layerDocument": document,
        "layer": layer,
        "asset": asset,
        "layersPath": str(layers_path),
        "surfaceKey": key,
        "warnings": warnings,
    }


def insert_project_library_asset_layer(
    project_folder: str | Path,
    asset_id: str,
    texture_size: dict[str, Any] | None = None,
    placement: dict[str, Any] | None = None,
    surface_key: str | None = None,
    layer_document: dict[str, Any] | None = None,
) -> dict[str, Any]:
    project_root = Path(project_folder)
    if not project_root:
        raise ValueError("Layer Studio project folder is required")
    asset = find_project_library_asset(project_root, asset_id)
    if not asset:
        raise FileNotFoundError(f"Layer Studio project library asset not found: {asset_id}")
    asset_path = Path(str(asset.get("path") or ""))
    if not asset_path.is_file():
        raise FileNotFoundError(f"Layer Studio project library PNG is missing: {asset.get('file') or asset_id}")
    result = _insert_asset_layer(project_root, asset, "projectLibrary", texture_size, placement, surface_key, layer_document)
    return {
        **result,
        "projectAssetLibrary": load_project_asset_library(project_root),
    }


def insert_library_asset_layer(
    project_folder: str | Path,
    livery_library_root: str | Path,
    asset_id: str,
    texture_size: dict[str, Any] | None = None,
    placement: dict[str, Any] | None = None,
    surface_key: str | None = None,
    layer_document: dict[str, Any] | None = None,
) -> dict[str, Any]:
    project_root = Path(project_folder)
    if not project_root:
        raise ValueError("Layer Studio project folder is required")
    asset = find_library_asset(livery_library_root, asset_id)
    if not asset:
        raise FileNotFoundError(f"Layer Studio library asset not found: {asset_id}")
    asset_path = Path(str(asset.get("path") or ""))
    if not asset_path.is_file():
        raise FileNotFoundError(f"Layer Studio library PNG is missing: {asset.get('file') or asset_id}")
    copied = add_png_to_project_library(project_root, asset_path, display_name=str(asset.get("name") or asset_id))
    project_asset = copied.get("asset") if isinstance(copied.get("asset"), dict) else {}
    result = _insert_asset_layer(project_root, project_asset, "projectLibrary", texture_size, placement, surface_key, layer_document)
    return {
        **result,
        "sourceAsset": asset,
        "asset": project_asset,
        "assetLibrary": load_asset_library(livery_library_root),
        "projectAssetLibrary": load_project_asset_library(project_root),
        "copiedToProject": True,
        "duplicate": bool(copied.get("duplicate")),
    }


def _texture_surface_context(
    project_folder: Path,
    project: dict[str, Any],
    spec: dict[str, Any],
    livery_library_root: str | Path | None = None,
) -> dict[str, Any]:
    key = _surface_key(spec.get("key"), DEFAULT_SURFACE_KEY)
    layer_root = project_folder / PROJECT_DIRNAME
    surface_root = _surface_root(project_folder, key)
    layers_path = _surface_layers_path(project_folder, key)
    legacy_layers_source = _migrate_legacy_surface_layers(project_folder, key, layers_path)
    texture_path = _surface_texture_path(project_folder, project, spec)
    texture_info = _path_info(texture_path)
    texture_size = _image_size(texture_path)
    base_path = _surface_base_path(project_folder, key, spec)
    base_info = _path_info(base_path)
    layer_document, layer_warnings = load_layer_document(layers_path, texture_size, key, SURFACE_BASE_TEXTURE_NAME)
    warnings = list(layer_warnings)
    if legacy_layers_source:
        warnings.append(f"Legacy Layer Studio layers were copied from: {legacy_layers_source}")
    warnings.extend(asset_warnings(layer_root, layer_document, livery_library_root))
    if spec.get("required") and not texture_path.is_file():
        warnings.append(f"Active {spec.get('label') or key} texture was not found: {texture_path}")
    if key == DEFAULT_SURFACE_KEY and not base_path.is_file():
        warnings.append(f"Clean base texture is not created yet: {base_path}")
    uv_template_key = str(spec.get("uvTemplateKey") or ("bannerUvTemplate" if key == "ext_banner_c" else "uvTemplate"))
    uv_template = project.get(uv_template_key) if isinstance(project.get(uv_template_key), dict) else {}
    uv_template_path_key = str(spec.get("uvTemplatePathKey") or "")
    uv_template_path = str(project.get(uv_template_path_key) or uv_template.get("path") or "").strip()
    return {
        "key": key,
        "label": spec.get("label") or key,
        "kind": spec.get("kind") or "texture",
        "description": spec.get("description") or "",
        "required": bool(spec.get("required")),
        "bakeEnabled": spec.get("bakeEnabled") is not False,
        "fileName": spec.get("fileName") or texture_path.name,
        "surfaceRoot": str(surface_root),
        "layersPath": str(layers_path),
        "textureSize": texture_size,
        "baseTexture": base_info,
        "activeTexture": texture_info,
        "uvTemplate": uv_template,
        "uvTemplatePath": uv_template_path,
        "layerDocument": layer_document,
        "warnings": warnings,
    }


def _project_texture_surfaces(
    project_folder: Path,
    project: dict[str, Any],
    livery_library_root: str | Path | None = None,
) -> list[dict[str, Any]]:
    surfaces: list[dict[str, Any]] = []
    for spec in [*SURFACE_DEFINITIONS, *_variant_parts_surface_definitions(project)]:
        if spec.get("layerStudioEnabled") is False:
            continue
        texture_path = _surface_texture_path(project_folder, project, spec)
        if not spec.get("required") and not texture_path.is_file() and not spec.get("showWhenMissing"):
            continue
        surfaces.append(_texture_surface_context(project_folder, project, spec, livery_library_root))
    if not surfaces:
        surfaces.append(_texture_surface_context(project_folder, project, SURFACE_DEFINITIONS[0], livery_library_root))
    return surfaces


def workspace_context(
    preview_build: dict[str, Any] | None,
    livery_library_root: str | Path | None = None,
    surface_key: str | None = None,
) -> dict[str, Any]:
    data = preview_build if isinstance(preview_build, dict) else {}
    project = data.get("project") if isinstance(data.get("project"), dict) else {}
    project_folder_text = str(project.get("projectFolder") or data.get("projectFolder") or "").strip()
    if not project_folder_text:
        raise ValueError("Layer Studio needs a LiveryProjects workspace folder")

    project_folder = Path(project_folder_text)
    layer_root = project_folder / PROJECT_DIRNAME
    assets_root = layer_root / ASSETS_DIRNAME
    surfaces = _project_texture_surfaces(project_folder, project, livery_library_root)
    requested_key = _surface_key(surface_key or data.get("surfaceKey") or project.get("layerStudioSurfaceKey"), DEFAULT_SURFACE_KEY)
    active_surface = next((row for row in surfaces if row.get("key") == requested_key), None) or surfaces[0]
    asset_library = load_asset_library(livery_library_root) if livery_library_root else {}
    project_asset_library = load_project_asset_library(project_folder)
    warnings = [row for row in active_surface.get("warnings", []) if row]

    return {
        "version": 1,
        "skinKey": project.get("skinKey") or "",
        "displayName": project.get("displayName") or project.get("skinId") or "Layer Studio",
        "skinId": project.get("skinId") or "",
        "carModel": project.get("carModel") or "",
        "projectFolder": str(project_folder),
        "layerRoot": str(layer_root),
        "assetsRoot": str(assets_root),
        "surfaceRoot": active_surface.get("surfaceRoot") or "",
        "activeSurfaceKey": active_surface.get("key") or DEFAULT_SURFACE_KEY,
        "textureSurfaces": surfaces,
        "layersPath": active_surface.get("layersPath") or "",
        "textureSize": active_surface.get("textureSize") or {"width": TEXTURE_SIZE, "height": TEXTURE_SIZE},
        "baseTexture": active_surface.get("baseTexture") or {},
        "activeTexture": active_surface.get("activeTexture") or {},
        "layerDocument": active_surface.get("layerDocument") or default_layer_document(),
        "assetLibrary": asset_library,
        "projectAssetLibrary": project_asset_library,
        "previewProject": project,
        "warnings": warnings,
    }
