from __future__ import annotations

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

from liverylab_core import driver_library, driver_skin, generated_readmes


DRIVER_PROJECTS_DIRNAME = "Drivers"
DRIVER_PROJECT_FILENAME = "driver_project.json"
DRIVER_PROJECT_README_FILENAME = "README.txt"
DRIVER_PROJECT_TYPE = "liverylab_driver_project"
DRIVER_PROJECT_VERSION = 1
DRIVER_PREVIEW_FILENAME = "preview.png"
DRIVER_TEXTURE_COLOR_SUFFIXES = ("_c", "_c_2")
DRIVER_TEXTURE_SOURCE_CATEGORIES = {"helmet", "gloves", "suit", "visor"}
DRIVER_TEXTURE_SOURCE_SLOT_NAMES = {"base_basecolormap", "txdiffuse"}
DRIVER_MASK_STEM_SUFFIXES = ("_d", "_d_2")
DRIVER_MATERIAL_SOURCE_CATEGORIES = {"helmet", "gloves", "suit"}
DRIVER_UV_TEMPLATE_CATEGORIES = {"helmet", "gloves", "suit"}
DRIVER_PNG_ONLY_NEUTRAL_PARAMETERS = driver_skin.DRIVER_PNG_ONLY_NEUTRAL_PARAMETERS
DRIVER_PROJECT_README = generated_readmes.DRIVER_PROJECT_README


def _json_ready(value: Any) -> Any:
    if isinstance(value, Path):
        return str(value)
    if isinstance(value, dict):
        return {str(key): _json_ready(item) for key, item in value.items()}
    if isinstance(value, (list, tuple, set)):
        return [_json_ready(item) for item in value]
    return value


def _now() -> str:
    return datetime.now().isoformat(timespec="seconds")


def _validate_square_png(source: str | Path, label: str = "Preview PNG") -> Path:
    path = Path(source).expanduser()
    if path.suffix.lower() != ".png":
        raise ValueError(f"{label} must be a PNG file")
    if not path.exists():
        raise FileNotFoundError(f"{label} not found: {path}")
    from PIL import Image
    with Image.open(path) as image:
        width, height = image.size
    if width != height:
        raise ValueError(f"{label} must be square. Selected image is {width}x{height}.")
    return path.resolve()


def projects_root(livery_projects_root: str | Path, create: bool = True) -> Path:
    root = Path(livery_projects_root).expanduser().resolve() / DRIVER_PROJECTS_DIRNAME
    if create:
        root.mkdir(parents=True, exist_ok=True)
    return root


def project_root_for(livery_projects_root: str | Path, car_model: str, profile_id: str, create: bool = True) -> Path:
    car_id = driver_library.sanitize_slug(car_model, "car")
    driver_id = driver_library.sanitize_slug(profile_id, "driver")
    root = projects_root(livery_projects_root, create=create) / car_id / driver_id
    if create:
        root.mkdir(parents=True, exist_ok=True)
    return root


def project_path_for(livery_projects_root: str | Path, car_model: str, profile_id: str) -> Path:
    return project_root_for(livery_projects_root, car_model, profile_id, create=False) / DRIVER_PROJECT_FILENAME


def _read_json(path: Path) -> dict[str, Any]:
    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:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(_json_ready(data), indent=2, ensure_ascii=False), encoding="utf-8")


def ensure_project_readme(project_root: str | Path) -> None:
    root = Path(project_root).expanduser()
    root.mkdir(parents=True, exist_ok=True)
    readme = root / DRIVER_PROJECT_README_FILENAME
    existing = readme.read_text(encoding="utf-8", errors="ignore") if readme.exists() else ""
    if not readme.exists() or generated_readmes.is_managed_driver_project_readme(existing):
        readme.write_text(DRIVER_PROJECT_README, encoding="utf-8")


def _normalize_png_only_project_data(data: dict[str, Any]) -> bool:
    changed = False
    controls = data.get("materialControls")
    if isinstance(controls, list):
        if controls:
            data["materialControls"] = []
            changed = True
    elif controls is not None:
        data["materialControls"] = []
        changed = True

    appearance = data.get("appearance")
    if isinstance(appearance, dict):
        if appearance:
            data["appearance"] = {}
            changed = True
    elif appearance is not None:
        data["appearance"] = {}
        changed = True

    project_appearance = data.get("projectAppearance")
    if isinstance(project_appearance, dict) and project_appearance:
        data["projectAppearance"] = {}
        changed = True
    return changed


def _asset_files(asset_root: Path) -> list[tuple[str, Path]]:
    if not asset_root.exists() or not asset_root.is_dir():
        return []
    files: list[tuple[str, Path]] = []
    for path in sorted((row for row in asset_root.rglob("*") if row.is_file()), key=lambda row: str(row).lower()):
        rel = driver_skin.normalize_game_rel_path(str(path.relative_to(asset_root)))
        if rel:
            files.append((rel, path))
    return files


def _source_files(project_root: Path) -> list[Path]:
    return sorted(
        (
            path
            for path in project_root.glob("*.png")
            if path.is_file() and path.name.lower() != DRIVER_PREVIEW_FILENAME
        ),
        key=lambda path: path.name.lower(),
    )


def _texture_category(rel: str) -> str:
    category = driver_skin.classify_driver_ref(rel)
    if category == "helmetGlass":
        return "visor"
    return category if category in {"helmet", "gloves", "suit"} else "driver"


def _texture_source_group_key(category: str, rel: str) -> tuple[str, str]:
    stem = Path(rel.replace("\\", "/")).stem.lower()
    if category in {"helmet", "visor"}:
        return category, "shared"
    return category, driver_skin.normalize_game_rel_path(rel).lower()


def _texture_source_priority(rel: str) -> tuple[int, int, str]:
    lower = driver_skin.normalize_game_rel_path(rel).lower()
    priority = 0
    if "\\driver\\helmet\\" in lower:
        priority = 30
    elif "\\driver\\characters\\1_helmets\\" in lower:
        priority = 20
    elif "\\content\\characters\\1_helmets\\" in lower:
        priority = 10
    return priority, -len(lower), lower


def _is_color_driver_texture(rel: str, car_content_path: str) -> bool:
    normalized = driver_skin.normalize_game_rel_path(rel)
    lower = normalized.lower()
    if not lower.endswith(".texture"):
        return False
    stem = Path(lower.replace("\\", "/")).stem
    if lower.startswith("content\\characters\\"):
        category = driver_skin.classify_driver_ref(lower)
        if category == "helmetGlass":
            return "glass_c" in stem
        return category == "helmet" and stem.endswith(DRIVER_TEXTURE_COLOR_SUFFIXES)
    car_prefix = driver_skin.norm_slashes(car_content_path).lower().rstrip("\\")
    skins_prefix = f"{car_prefix}\\skins\\"
    if not lower.startswith(skins_prefix):
        return False
    skin_parts = driver_skin.split_content_path(lower[len(skins_prefix) :])
    if not skin_parts or (skin_parts[0] != "driver" and (len(skin_parts) < 2 or skin_parts[1] != "driver")):
        return False
    if _texture_category(lower) not in DRIVER_TEXTURE_SOURCE_CATEGORIES:
        return False
    if "glass_c" in stem:
        return True
    return stem.endswith(DRIVER_TEXTURE_COLOR_SUFFIXES)


def _is_driver_source_texture_slot(slot_name: str) -> bool:
    return str(slot_name or "").strip().lower() in DRIVER_TEXTURE_SOURCE_SLOT_NAMES


def _simple_source_name(category: str, rel: str, grouped_count: int) -> str:
    if grouped_count <= 1 and category in {"helmet", "gloves", "suit", "visor"}:
        return f"{category}.png"
    stem = driver_library.sanitize_slug(Path(rel.replace("\\", "/")).stem, category)
    return f"{category}_{stem}.png"


def _is_driver_mask_texture(rel: str) -> bool:
    normalized = driver_skin.normalize_game_rel_path(rel)
    if not normalized.lower().endswith(".texture"):
        return False
    if driver_skin.classify_driver_ref(normalized) not in {"helmet", "suit", "gloves"}:
        return False
    stem = Path(normalized.replace("\\", "/")).stem.lower()
    return stem.endswith(DRIVER_MASK_STEM_SUFFIXES)


def _linear_to_srgb(value: float) -> int:
    linear = max(0.0, min(1.0, float(value)))
    if linear <= 0.0031308:
        srgb = 12.92 * linear
    else:
        srgb = 1.055 * (linear ** (1.0 / 2.4)) - 0.055
    return max(0, min(255, round(srgb * 255)))


def _srgb_to_linear(value: int) -> float:
    srgb = max(0.0, min(1.0, int(value) / 255.0))
    if srgb <= 0.04045:
        return srgb / 12.92
    return ((srgb + 0.055) / 1.055) ** 2.4


def _rgb_to_hex(rgb: Any) -> str:
    if not isinstance(rgb, (list, tuple)) or len(rgb) < 3:
        return ""
    return "#{:02x}{:02x}{:02x}".format(
        _linear_to_srgb(float(rgb[0])),
        _linear_to_srgb(float(rgb[1])),
        _linear_to_srgb(float(rgb[2])),
    )


def _hex_to_linear_rgb(value: str) -> list[float] | None:
    text = str(value or "").strip()
    if text.startswith("#"):
        text = text[1:]
    if len(text) == 3:
        text = "".join(part * 2 for part in text)
    if len(text) != 6:
        return None
    try:
        parts = [int(text[index : index + 2], 16) for index in (0, 2, 4)]
    except ValueError:
        return None
    return [_srgb_to_linear(part) for part in parts]


def _control_key(control: dict[str, Any]) -> str:
    return "|".join(
        [
            str(control.get("id") or ""),
            driver_skin.normalize_game_rel_path(str(control.get("materialRel") or "")).lower(),
            str(control.get("parameter") or ""),
        ]
    )


def _existing_material_control_map(existing: dict[str, Any]) -> dict[str, dict[str, Any]]:
    controls = existing.get("materialControls") if isinstance(existing.get("materialControls"), list) else []
    result: dict[str, dict[str, Any]] = {}
    for control in controls:
        if isinstance(control, dict):
            result[_control_key(control)] = control
    return result


def _material_color_specs(category: str) -> tuple[tuple[str, str, str], ...]:
    return ()


def _collect_project_material_controls(
    profile: dict[str, Any],
    library_asset_root: Path,
    existing: dict[str, Any],
) -> tuple[list[dict[str, Any]], dict[str, str]]:
    return [], {}


def _collect_project_texture_sources(profile: dict[str, Any], library_asset_root: Path) -> list[dict[str, Any]]:
    car_content_path = str(profile.get("carContentPath") or "").strip()
    files = [
        driver_skin.normalize_game_rel_path(str(rel or ""))
        for rel in (profile.get("files") or [])
        if str(rel or "").strip()
    ]
    candidates: list[dict[str, Any]] = []
    for rel in files:
        if not rel.lower().endswith(".material"):
            continue
        category = _texture_category(rel)
        if category not in DRIVER_TEXTURE_SOURCE_CATEGORIES:
            continue
        material_path = library_asset_root / "assets" / Path(*driver_skin.split_content_path(rel))
        if not material_path.exists():
            continue
        try:
            material_info = driver_skin.inspect_driver_material(material_path)
        except Exception:
            continue
        for slot in material_info.get("textureSlots") or []:
            slot_name = str(slot.get("name") or "")
            if not _is_driver_source_texture_slot(slot_name):
                continue
            texture_rel = driver_skin.normalize_game_rel_path(str(slot.get("value") or ""))
            if not _is_color_driver_texture(texture_rel, car_content_path):
                continue
            texture_path = library_asset_root / "assets" / Path(*driver_skin.split_content_path(texture_rel))
            mips_path = texture_path.with_suffix(".texturemips")
            if not texture_path.exists() or not mips_path.exists():
                continue
            candidates.append(
                {
                    "category": category,
                    "label": category[:1].upper() + category[1:],
                    "materialRel": rel,
                    "textureSlot": slot_name,
                    "textureRel": texture_rel,
                    "texturemipsRel": driver_skin.normalize_game_rel_path(str(Path(texture_rel).with_suffix(".texturemips"))),
                    "texturePath": str(texture_path),
                    "texturemipsPath": str(mips_path),
                }
            )
    grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
    for item in candidates:
        grouped.setdefault(_texture_source_group_key(str(item.get("category") or ""), str(item.get("textureRel") or "")), []).append(item)
    texture_sources: list[dict[str, Any]] = []
    for items in grouped.values():
        ordered = sorted(items, key=lambda row: _texture_source_priority(str(row.get("textureRel") or "")), reverse=True)
        primary = dict(ordered[0])
        linked = ordered[1:]
        if linked:
            linked_texture_rels: list[str] = []
            linked_texturemips_rels: list[str] = []
            seen_linked: set[str] = {driver_skin.normalize_game_rel_path(str(primary.get("textureRel") or "")).lower()}
            for item in linked:
                texture_rel = driver_skin.normalize_game_rel_path(str(item.get("textureRel") or ""))
                if not texture_rel or texture_rel.lower() in seen_linked:
                    continue
                seen_linked.add(texture_rel.lower())
                linked_texture_rels.append(texture_rel)
                texturemips_rel = driver_skin.normalize_game_rel_path(str(item.get("texturemipsRel") or ""))
                if texturemips_rel:
                    linked_texturemips_rels.append(texturemips_rel)
            if linked_texture_rels:
                primary["linkedTextureRels"] = linked_texture_rels
            if linked_texturemips_rels:
                primary["linkedTexturemipsRels"] = linked_texturemips_rels
        texture_sources.append(primary)
    grouped_counts: dict[str, int] = {}
    for item in texture_sources:
        grouped_counts[item["category"]] = grouped_counts.get(item["category"], 0) + 1
    seen_names: set[str] = set()
    for item in texture_sources:
        name = _simple_source_name(item["category"], item["textureRel"], grouped_counts.get(item["category"], 1))
        base = Path(name).stem
        candidate = name
        index = 2
        while candidate.lower() in seen_names:
            candidate = f"{base}_{index}.png"
            index += 1
        seen_names.add(candidate.lower())
        item["sourcePng"] = candidate
    return sorted(texture_sources, key=lambda row: (str(row.get("category", "")), str(row.get("sourcePng", ""))))


def _find_existing_source_png_for_texture(project_root: Path, existing: dict[str, Any], source: dict[str, Any]) -> Path | None:
    wanted = {
        driver_skin.normalize_game_rel_path(str(source.get("textureRel") or "")).lower(),
        *{
            driver_skin.normalize_game_rel_path(str(rel or "")).lower()
            for rel in (source.get("linkedTextureRels") or [])
            if str(rel or "").strip()
        },
    }
    texture_sources = existing.get("textureSources") if isinstance(existing.get("textureSources"), list) else []
    matches: list[Path] = []
    for row in texture_sources:
        if not isinstance(row, dict):
            continue
        rel = driver_skin.normalize_game_rel_path(str(row.get("textureRel") or "")).lower()
        if rel not in wanted:
            continue
        source_png = str(row.get("sourcePng") or "").strip()
        if not source_png:
            continue
        path = project_root / source_png
        if path.exists() and path.is_file():
            matches.append(path)
    preferred_rel = driver_skin.normalize_game_rel_path(str(source.get("textureRel") or "")).lower()
    for row in texture_sources:
        if not isinstance(row, dict):
            continue
        rel = driver_skin.normalize_game_rel_path(str(row.get("textureRel") or "")).lower()
        if rel != preferred_rel:
            continue
        path = project_root / str(row.get("sourcePng") or "")
        if path.exists() and path.is_file():
            return path
    return matches[0] if matches else None


def _copy_assets(source_asset_root: Path, target_asset_root: Path, overwrite: bool = False) -> list[str]:
    copied: list[str] = []
    for rel, source in _asset_files(source_asset_root):
        target = target_asset_root / Path(*driver_skin.split_content_path(rel))
        if target.exists() and not overwrite:
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, target)
        copied.append(rel)
    return copied


def _profile_asset_file(profile_asset_root: Path, rel: str) -> Path:
    return profile_asset_root / Path(*driver_skin.split_content_path(rel))


def _driver_project_mesh_refs(profile: dict[str, Any], library_asset_root: Path) -> dict[str, str]:
    refs: dict[str, str] = {}
    for key in ("localDriverFinalState", "driverFinalState"):
        driverfinalstate_rel = driver_skin.normalize_game_rel_path(str(profile.get(key) or ""))
        if not driverfinalstate_rel:
            continue
        driverfinalstate_path = library_asset_root / "assets" / Path(*driver_skin.split_content_path(driverfinalstate_rel))
        if not driverfinalstate_path.exists():
            continue
        try:
            mesh_refs = driver_skin.extract_driverfinalstate_mesh_refs(driverfinalstate_path.read_bytes())
        except Exception:
            continue
        for mesh_ref in mesh_refs:
            category = driver_skin.classify_driver_ref(mesh_ref)
            if category in DRIVER_UV_TEMPLATE_CATEGORIES and category not in refs:
                refs[category] = driver_skin.normalize_game_rel_path(mesh_ref)
    return refs


def _png_dimensions(path: Path, fallback: int = 4096) -> tuple[int, int]:
    try:
        from PIL import Image
        with Image.open(path) as image:
            width, height = image.size
            return max(1, int(width)), max(1, int(height))
    except Exception:
        return int(fallback), int(fallback)


def _uv_template_name(source_png: str) -> str:
    stem = driver_library.sanitize_slug(Path(str(source_png or "driver")).stem, "driver")
    return f"{stem}_uv.png"


def _write_project_uv_templates(
    profile: dict[str, Any],
    library_asset_root: Path,
    project_root: Path,
    texture_sources: list[dict[str, Any]],
    driver_uv_to_png: Callable[[str, str, Path, int, int, bool], dict[str, Any]] | None,
    overwrite_assets: bool,
) -> list[dict[str, Any]]:
    if driver_uv_to_png is None:
        return []
    mesh_refs = _driver_project_mesh_refs(profile, library_asset_root)
    uv_templates: list[dict[str, Any]] = []
    for source in texture_sources:
        category = str(source.get("category") or "").strip().lower()
        if category not in DRIVER_UV_TEMPLATE_CATEGORIES:
            continue
        source_png = str(source.get("sourcePng") or "").strip()
        if not source_png:
            continue
        uv_name = _uv_template_name(source_png)
        uv_path = project_root / uv_name
        source["uvTemplatePng"] = uv_name
        source["uvTemplatePngPath"] = str(uv_path)
        mesh_ref = mesh_refs.get(category, "")
        template: dict[str, Any] = {
            "category": category,
            "sourcePng": source_png,
            "uvTemplatePng": uv_name,
            "uvTemplatePngPath": str(uv_path),
            "meshRel": mesh_ref,
            "status": "pending",
        }
        if not mesh_ref:
            template.update({"status": "skipped", "reason": "driver mesh reference not found"})
            source["uvTemplate"] = template
            uv_templates.append(template)
            continue
        width, height = _png_dimensions(project_root / source_png)
        overwrite_uv = overwrite_assets
        if uv_path.exists() and _png_dimensions(uv_path, fallback=width) != (width, height):
            overwrite_uv = True
        try:
            result = driver_uv_to_png(category, mesh_ref, uv_path, width, height, overwrite_uv)
            template.update(
                {
                    **result,
                    "category": category,
                    "sourcePng": source_png,
                    "uvTemplatePng": uv_name,
                    "uvTemplatePngPath": str(uv_path),
                    "meshRel": mesh_ref,
                    "status": "created" if result.get("created") else ("existing" if result.get("reason") == "exists" else "skipped"),
                }
            )
        except Exception as exc:
            template.update({"status": "error", "reason": str(exc)})
        source["uvTemplate"] = template
        uv_templates.append(template)
    return uv_templates


def _sync_png_sources_to_profile(
    project: dict[str, Any],
    profile_asset_root: Path,
    png_to_texturemips: Callable[[Path, Path, Path, str], dict[str, Any]] | None,
) -> dict[str, Any]:
    project_root = Path(str(project.get("projectRoot") or "")).expanduser()
    car_content_path = str(project.get("carContentPath") or "").strip()
    skin_id = str(project.get("sourceSkinId") or "").strip()
    texture_sources = project.get("textureSources") if isinstance(project.get("textureSources"), list) else []
    items: list[dict[str, Any]] = []
    if not texture_sources:
        return {"changed": False, "skipped": "driver project has no PNG texture sources", "items": []}
    if png_to_texturemips is None:
        return {"changed": False, "skipped": "driver project PNG converter missing", "items": []}

    def add_texture_target(result: list[tuple[str, str]], texture_rel: str, mips_rel: str) -> None:
        if not texture_rel:
            return
        if not mips_rel:
            mips_rel = driver_skin.normalize_game_rel_path(str(Path(texture_rel).with_suffix(".texturemips")))
        if (texture_rel.lower(), mips_rel.lower()) not in {
            (texture.lower(), mips.lower()) for texture, mips in result
        }:
            result.append((texture_rel, mips_rel))

    def texture_targets(source: dict[str, Any]) -> list[tuple[str, str]]:
        texture_rel = driver_skin.normalize_game_rel_path(str(source.get("textureRel") or ""))
        mips_rel = driver_skin.normalize_game_rel_path(str(source.get("texturemipsRel") or ""))
        if not mips_rel and texture_rel:
            mips_rel = driver_skin.normalize_game_rel_path(str(Path(texture_rel).with_suffix(".texturemips")))
        result: list[tuple[str, str]] = [(texture_rel, mips_rel)] if texture_rel else []
        for linked_rel in source.get("linkedTextureRels") or []:
            linked_texture_rel = driver_skin.normalize_game_rel_path(str(linked_rel or ""))
            if not linked_texture_rel:
                continue
            linked_mips_rel = ""
            for linked_mips in source.get("linkedTexturemipsRels") or []:
                linked_mips_norm = driver_skin.normalize_game_rel_path(str(linked_mips or ""))
                if linked_mips_norm and Path(linked_mips_norm.replace("\\", "/")).with_suffix(".texture").name.lower() == Path(linked_texture_rel.replace("\\", "/")).name.lower():
                    linked_mips_rel = linked_mips_norm
                    break
            if not linked_mips_rel:
                linked_mips_rel = driver_skin.normalize_game_rel_path(str(Path(linked_texture_rel).with_suffix(".texturemips")))
            add_texture_target(result, linked_texture_rel, linked_mips_rel)
            if linked_texture_rel.lower().startswith("content\\characters\\") and car_content_path and skin_id:
                local_texture_rel = driver_skin.target_ref_for_driver_asset(linked_texture_rel, car_content_path, skin_id)
                local_mips_rel = driver_skin.normalize_game_rel_path(str(Path(local_texture_rel).with_suffix(".texturemips")))
                add_texture_target(result, local_texture_rel, local_mips_rel)
        return result

    for source in texture_sources:
        if not isinstance(source, dict):
            continue
        label = str(source.get("label") or source.get("category") or "driver texture")
        source_png = project_root / str(source.get("sourcePng") or "")
        item: dict[str, Any] = {
            "label": label,
            "sourcePng": str(source_png),
            "targets": [],
            "status": "pending",
        }
        if not source_png.exists():
            item.update({"status": "missing_png", "reason": "project PNG not found"})
            items.append(item)
            continue
        for index, (texture_rel, mips_rel) in enumerate(texture_targets(source)):
            target_texture = _profile_asset_file(profile_asset_root, texture_rel) if texture_rel else Path()
            target_mips = _profile_asset_file(profile_asset_root, mips_rel) if mips_rel else Path()
            target_item: dict[str, Any] = {
                "texture": str(target_texture) if texture_rel else "",
                "texturemips": str(target_mips) if mips_rel else "",
                "status": "pending",
                "primary": index == 0,
            }
            if not texture_rel or not target_texture.exists():
                target_item.update({"status": "missing_texture", "reason": "profile .texture not found"})
            elif not mips_rel or not target_mips.exists():
                target_item.update({"status": "missing_mips", "reason": "profile .texturemips not found"})
            elif source_png.stat().st_mtime <= target_mips.stat().st_mtime + 0.001:
                target_item.update({"status": "skipped", "reason": "profile texturemips is current"})
            else:
                target_item.update({
                    "status": "updated",
                    "result": png_to_texturemips(target_texture, target_mips, source_png, label),
                })
            item["targets"].append(target_item)
        target_statuses = [str(target.get("status") or "") for target in item["targets"]]
        if any(status == "updated" for status in target_statuses):
            item["status"] = "updated"
        elif any(status.startswith("missing") for status in target_statuses):
            item["status"] = next((status for status in target_statuses if status.startswith("missing")), "missing")
        else:
            item.update({"status": "skipped", "reason": "profile texturemips is current"})
        items.append(item)

    return {
        "changed": any(item.get("status") == "updated" for item in items),
        "updatedCount": sum(1 for item in items if item.get("status") == "updated"),
        "skippedCount": sum(1 for item in items if item.get("status") == "skipped"),
        "missingCount": sum(1 for item in items if str(item.get("status", "")).startswith("missing")),
        "items": items,
    }


def _sync_material_controls_to_profile(project: dict[str, Any], profile_asset_root: Path) -> dict[str, Any]:
    return {"changed": False, "updatedCount": 0, "skippedCount": 0, "missingCount": 0, "items": [], "skipped": "driver projects are png-only"}


def _material_parameter_matches(current: Any, target: float | list[float]) -> bool:
    if isinstance(target, list):
        if not isinstance(current, list) or len(current) < len(target):
            return False
        try:
            return all(abs(float(current[index]) - float(value)) <= 0.0001 for index, value in enumerate(target))
        except (TypeError, ValueError):
            return False
    try:
        return abs(float(current) - float(target)) <= 0.0001
    except (TypeError, ValueError):
        return False


def _sync_driver_material_defaults_to_profile(profile_asset_root: Path) -> dict[str, Any]:
    items: list[dict[str, Any]] = []
    if not profile_asset_root.exists():
        return {"changed": False, "updatedCount": 0, "skippedCount": 0, "missingCount": 0, "items": []}
    for rel, material_path in _asset_files(profile_asset_root):
        if not rel.lower().endswith(".material") or driver_skin.classify_driver_ref(rel) not in DRIVER_MATERIAL_SOURCE_CATEGORIES:
            continue
        try:
            info = driver_skin.inspect_driver_material(material_path)
        except Exception as exc:
            items.append({
                "label": "Driver material",
                "material": str(material_path),
                "status": "skipped",
                "reason": f"material inspect failed: {exc}",
            })
            continue
        params = {
            str(row.get("name") or ""): row.get("value")
            for row in (info.get("parameters") or [])
            if isinstance(row, dict) and str(row.get("name") or "")
        }
        category = driver_skin.classify_driver_ref(rel)
        neutral_parameters = driver_skin.png_only_neutral_parameters_for_category(category)
        parameters = {
            name: value
            for name, value in neutral_parameters.items()
            if name in params and not _material_parameter_matches(params.get(name), value)
        }
        if not parameters:
            items.append({
                "label": "Driver material",
                "material": str(material_path),
                "status": "skipped",
                "reason": "driver material already png-only",
            })
            continue
        before = material_path.read_bytes()
        changed = driver_skin.set_material_parameters_file(material_path, parameters)
        item = {
            "label": "Driver material",
            "material": str(material_path),
            "status": "updated" if changed else "skipped",
            "reason": "" if changed else "driver material already png-only",
            "parameters": parameters,
            "bytes": material_path.stat().st_size,
        }
        if changed:
            item["beforeBytes"] = len(before)
        items.append(item)
    return {
        "changed": any(item.get("status") == "updated" for item in items),
        "updatedCount": sum(1 for item in items if item.get("status") == "updated"),
        "skippedCount": sum(1 for item in items if item.get("status") == "skipped"),
        "missingCount": 0,
        "items": items,
    }


def _sync_driver_mask_defaults_to_profile(
    profile_asset_root: Path,
    neutralize_texturemips: Callable[[Path], dict[str, Any]] | None,
) -> dict[str, Any]:
    items: list[dict[str, Any]] = []
    if neutralize_texturemips is None:
        return {"changed": False, "updatedCount": 0, "skippedCount": 0, "missingCount": 0, "items": []}
    if not profile_asset_root.exists():
        return {"changed": False, "updatedCount": 0, "skippedCount": 0, "missingCount": 0, "items": []}
    seen: set[str] = set()
    for rel, texture_path in _asset_files(profile_asset_root):
        if not _is_driver_mask_texture(rel):
            continue
        key = str(texture_path).lower()
        if key in seen:
            continue
        seen.add(key)
        category = driver_skin.classify_driver_ref(rel)
        item: dict[str, Any] = {
            "label": f"{category[:1].upper() + category[1:]} mask",
            "category": category,
            "texture": str(texture_path),
            "texturemips": str(texture_path.with_suffix(".texturemips")),
            "status": "pending",
        }
        if not texture_path.with_suffix(".texturemips").exists():
            item.update({"status": "missing_mips", "reason": "profile driver mask .texturemips not found"})
            items.append(item)
            continue
        try:
            result = neutralize_texturemips(texture_path)
        except Exception as exc:
            item.update({"status": "skipped", "reason": f"driver mask neutralization failed: {exc}"})
            items.append(item)
            continue
        item["result"] = result
        item["status"] = "updated" if result.get("updated") else "skipped"
        item["reason"] = "" if result.get("updated") else str(result.get("reason") or "driver mask already neutral")
        items.append(item)
    return {
        "changed": any(item.get("status") == "updated" for item in items),
        "updatedCount": sum(1 for item in items if item.get("status") == "updated"),
        "skippedCount": sum(1 for item in items if item.get("status") == "skipped"),
        "missingCount": sum(1 for item in items if str(item.get("status", "")).startswith("missing")),
        "items": items,
    }


def read_project(project_path: str | Path) -> dict[str, Any] | None:
    path = Path(project_path).expanduser()
    if path.is_dir():
        path = path / DRIVER_PROJECT_FILENAME
    if not path.exists() or not path.is_file():
        return None
    data = _read_json(path)
    if not data:
        return None
    if _normalize_png_only_project_data(data):
        _write_json(path, data)
    data["projectPath"] = str(path)
    data["projectRoot"] = str(path.parent)
    data["assetRoot"] = str(path.parent / "assets")
    return data


def create_project_from_profile(
    livery_projects_root: str | Path,
    profile: dict[str, Any],
    overwrite_assets: bool = False,
    texture_to_png: Callable[[Path, Path, bool], dict[str, Any]] | None = None,
    driver_uv_to_png: Callable[[str, str, Path, int, int, bool], dict[str, Any]] | None = None,
) -> dict[str, Any]:
    profile_id = str(profile.get("id") or "").strip()
    car_model = str(profile.get("carModel") or "").strip()
    if not profile_id:
        raise ValueError("Driver profile id is required")
    if not car_model:
        raise ValueError("Driver profile car model is required")

    project_root = project_root_for(livery_projects_root, car_model, profile_id)
    ensure_project_readme(project_root)
    project_path = project_root / DRIVER_PROJECT_FILENAME
    library_asset_root = Path(str(profile.get("assetRoot") or "")).expanduser()
    existing = read_project(project_path) or {}
    texture_sources = _collect_project_texture_sources(profile, library_asset_root)
    material_controls, appearance = _collect_project_material_controls(profile, library_asset_root, existing)
    exported: list[dict[str, Any]] = []
    for source in texture_sources:
        source_png = project_root / str(source.get("sourcePng") or "")
        source["sourcePngPath"] = str(source_png)
        fallback_png = _find_existing_source_png_for_texture(project_root, existing, source)
        if fallback_png and fallback_png.resolve() != source_png.resolve() and not source_png.exists():
            shutil.copy2(fallback_png, source_png)
            source["status"] = "migrated"
            source["migratedFrom"] = fallback_png.name
            exported.append(source)
            continue
        if texture_to_png is None:
            source["status"] = "mapped"
            continue
        if source_png.exists() and not overwrite_assets:
            source["status"] = "existing"
            continue
        try:
            result = texture_to_png(Path(str(source["texturePath"])), source_png, True)
            source["status"] = "exported" if not result.get("skipped") else "existing"
            source["export"] = result
            exported.append(source)
        except Exception as exc:
            source["status"] = "error"
            source["error"] = str(exc)
    source_files = [
        str(source.get("sourcePng") or "")
        for source in texture_sources
        if str(source.get("sourcePng") or "").strip()
    ]
    uv_template_files = _write_project_uv_templates(
        profile,
        library_asset_root,
        project_root,
        texture_sources,
        driver_uv_to_png,
        overwrite_assets,
    )
    preview_png = str(existing.get("previewPng") or "").strip()
    preview_path = project_root / preview_png if preview_png else project_root / DRIVER_PREVIEW_FILENAME
    if preview_path.exists() and preview_path.is_file():
        preview_png = preview_path.name
    now = _now()
    project = {
        "formatVersion": DRIVER_PROJECT_VERSION,
        "type": DRIVER_PROJECT_TYPE,
        "workflow": "png_sources",
        "id": profile_id,
        "name": str(profile.get("name") or profile_id),
        "carModel": car_model,
        "carContentPath": str(profile.get("carContentPath") or ""),
        "profileManifestPath": str(profile.get("manifestPath") or ""),
        "profileAssetRoot": str(library_asset_root),
        "sourceSkinKey": str(profile.get("sourceSkinKey") or ""),
        "sourceSkinId": str(profile.get("sourceSkinId") or ""),
        "sourceDisplayName": str(profile.get("sourceDisplayName") or ""),
        "sourceVisualPreset": str(profile.get("sourceVisualPreset") or ""),
        "driverFinalState": str(profile.get("driverFinalState") or ""),
        "localDriverFinalState": str(profile.get("localDriverFinalState") or ""),
        "sourceFolder": ".",
        "sourceFiles": source_files,
        "previewPng": preview_png,
        "textureSources": texture_sources,
        "uvTemplates": uv_template_files,
        "uvTemplateCount": len(uv_template_files),
        "appearance": appearance,
        "materialControls": material_controls,
        "createdAt": str(existing.get("createdAt") or now),
        "updatedAt": now,
    }
    _write_json(project_path, project)
    return {
        "project": _json_ready({**project, "projectPath": str(project_path), "projectRoot": str(project_root)}),
        "projectPath": str(project_path),
        "projectRoot": str(project_root),
        "sourceCount": len(texture_sources),
        "sourceFiles": source_files,
        "exportedCount": len(exported),
        "uvTemplateCount": len(uv_template_files),
        "uvTemplates": uv_template_files,
        "fileCount": len(source_files),
    }


def project_info_for_profile(livery_projects_root: str | Path, profile: dict[str, Any]) -> dict[str, Any]:
    profile_id = str(profile.get("id") or "").strip()
    car_model = str(profile.get("carModel") or "").strip()
    if not profile_id or not car_model:
        return {"exists": False, "projectRoot": "", "projectPath": "", "assetRoot": "", "fileCount": 0}
    project_path = project_path_for(livery_projects_root, car_model, profile_id)
    project = read_project(project_path)
    if not project:
        return {
            "exists": False,
            "projectRoot": str(project_path.parent),
            "projectPath": str(project_path),
            "assetRoot": str(project_path.parent / "assets"),
            "fileCount": 0,
        }
    asset_root = Path(str(project.get("assetRoot") or project_path.parent / "assets"))
    source_files = _source_files(project_path.parent)
    tracked_source_files = [
        str(source.get("sourcePng") or "")
        for source in (project.get("textureSources") or [])
        if isinstance(source, dict) and str(source.get("sourcePng") or "").strip()
    ]
    uv_templates = project.get("uvTemplates") if isinstance(project.get("uvTemplates"), list) else []
    uv_template_files = [
        str(template.get("uvTemplatePng") or template.get("name") or "")
        for template in uv_templates
        if isinstance(template, dict) and str(template.get("uvTemplatePng") or template.get("name") or "").strip()
    ]
    files = [rel for rel, _path in _asset_files(asset_root)]
    source_count = len(project.get("textureSources") or []) if isinstance(project.get("textureSources"), list) else len(source_files)
    material_controls: list[dict[str, Any]] = []
    appearance: dict[str, str] = {}
    preview_png = str(project.get("previewPng") or "").strip()
    preview_path = project_path.parent / preview_png if preview_png else project_path.parent / DRIVER_PREVIEW_FILENAME
    preview_exists = preview_path.exists() and preview_path.is_file()
    return {
        "exists": True,
        "projectRoot": str(project_path.parent),
        "projectPath": str(project_path),
        "assetRoot": str(asset_root),
        "previewPng": preview_path.name if preview_exists else "",
        "previewPngPath": str(preview_path) if preview_exists else "",
        "sourceCount": source_count,
        "sourceFiles": tracked_source_files or [path.name for path in source_files],
        "uvTemplateCount": len(uv_templates),
        "uvTemplateFiles": uv_template_files,
        "uvTemplates": uv_templates,
        "materialControlCount": len(material_controls),
        "materialControls": material_controls,
        "appearance": appearance,
        "fileCount": source_count or len(files),
        "workflow": str(project.get("workflow") or ("png_sources" if source_files else "assets")),
        "updatedAt": str(project.get("updatedAt") or ""),
    }


def attach_project_info(livery_projects_root: str | Path, profile: dict[str, Any]) -> dict[str, Any]:
    item = dict(profile)
    info = project_info_for_profile(livery_projects_root, item)
    item["project"] = info
    item["projectRoot"] = info.get("projectRoot", "")
    item["projectPath"] = info.get("projectPath", "")
    item["projectAssetRoot"] = info.get("assetRoot", "")
    return _json_ready(item)


def _project_child_path(project_root: Path, value: str | Path) -> Path | None:
    text = str(value or "").strip()
    if not text:
        return None
    path = Path(text).expanduser()
    if not path.is_absolute():
        path = project_root / path
    try:
        resolved = path.resolve()
        resolved.relative_to(project_root.resolve())
    except Exception:
        return None
    return resolved


def cleanup_project_generated_files(livery_projects_root: str | Path, profile: dict[str, Any]) -> dict[str, Any]:
    profile_id = str(profile.get("id") or "").strip()
    car_model = str(profile.get("carModel") or "").strip()
    if not profile_id or not car_model:
        return {"exists": False, "projectRoot": "", "projectPath": "", "removed": [], "kept": [], "warnings": ["Driver profile id or car model is missing"]}

    project_path = project_path_for(livery_projects_root, car_model, profile_id)
    project_root = project_path.parent
    project = read_project(project_path)
    if not project:
        return {
            "exists": False,
            "projectRoot": str(project_root),
            "projectPath": str(project_path),
            "removed": [],
            "kept": [],
            "warnings": [],
        }

    candidates: dict[str, Path] = {}

    def add_candidate(value: str | Path, reason: str) -> None:
        path = _project_child_path(project_root, value)
        if path is None:
            return
        candidates[str(path).lower()] = path

    for name in project.get("sourceFiles") or []:
        add_candidate(str(name or ""), "source file")
        if str(name or "").strip():
            add_candidate(_uv_template_name(str(name)), "uv template")

    for source in project.get("textureSources") or []:
        if not isinstance(source, dict):
            continue
        source_png = str(source.get("sourcePng") or "").strip()
        add_candidate(source_png, "texture source")
        add_candidate(str(source.get("sourcePngPath") or ""), "texture source")
        if source_png:
            add_candidate(_uv_template_name(source_png), "uv template")
        add_candidate(str(source.get("uvTemplatePng") or ""), "uv template")
        add_candidate(str(source.get("uvTemplatePngPath") or ""), "uv template")
        template = source.get("uvTemplate") if isinstance(source.get("uvTemplate"), dict) else {}
        add_candidate(str(template.get("uvTemplatePng") or ""), "uv template")
        add_candidate(str(template.get("uvTemplatePngPath") or ""), "uv template")

    for template in project.get("uvTemplates") or []:
        if not isinstance(template, dict):
            continue
        add_candidate(str(template.get("uvTemplatePng") or template.get("name") or ""), "uv template")
        add_candidate(str(template.get("uvTemplatePngPath") or ""), "uv template")

    preview_png = str(project.get("previewPng") or "").strip() or DRIVER_PREVIEW_FILENAME
    add_candidate(preview_png, "preview")
    add_candidate(project_path, "project manifest")
    add_candidate(project_root / DRIVER_PROJECT_README_FILENAME, "project readme")

    removed: list[str] = []
    missing: list[str] = []
    warnings: list[str] = []
    for path in sorted(candidates.values(), key=lambda row: str(row).lower()):
        if not path.exists():
            missing.append(str(path))
            continue
        if not path.is_file():
            continue
        try:
            path.unlink()
            removed.append(str(path))
        except Exception as exc:
            warnings.append(f"Could not remove {path}: {exc}")

    removed_dirs: list[str] = []
    preview_root = project_root / "_preview"
    if preview_root.exists() and preview_root.is_dir():
        try:
            shutil.rmtree(preview_root)
            removed_dirs.append(str(preview_root))
        except Exception as exc:
            warnings.append(f"Could not remove {preview_root}: {exc}")

    kept = [str(path) for path in sorted(project_root.iterdir(), key=lambda row: str(row).lower())] if project_root.exists() else []
    project_root_removed = False
    if project_root.exists() and not kept:
        try:
            project_root.rmdir()
            project_root_removed = True
        except Exception as exc:
            warnings.append(f"Could not remove empty project folder {project_root}: {exc}")

    parent_removed = False
    car_root = project_root.parent
    if project_root_removed and car_root.exists() and car_root.is_dir() and not any(car_root.iterdir()):
        try:
            car_root.rmdir()
            parent_removed = True
        except Exception:
            parent_removed = False

    return _json_ready(
        {
            "exists": True,
            "projectRoot": str(project_root),
            "projectPath": str(project_path),
            "removed": removed,
            "removedDirs": removed_dirs,
            "removedCount": len(removed) + len(removed_dirs),
            "missing": missing,
            "kept": kept,
            "projectRootRemoved": project_root_removed,
            "carRootRemoved": parent_removed,
            "warnings": warnings,
        }
    )


def update_project_preview_image(
    livery_projects_root: str | Path,
    profile: dict[str, Any],
    preview_png: str | Path,
) -> dict[str, Any]:
    profile_id = str(profile.get("id") or "").strip()
    car_model = str(profile.get("carModel") or "").strip()
    if not profile_id or not car_model:
        raise ValueError("Driver profile id and car model are required")
    project_path = project_path_for(livery_projects_root, car_model, profile_id)
    project = read_project(project_path)
    if not project:
        raise FileNotFoundError("Driver project is missing")

    source = _validate_square_png(preview_png, "Driver preview PNG")
    target = project_path.parent / DRIVER_PREVIEW_FILENAME
    target.parent.mkdir(parents=True, exist_ok=True)
    if source != target.resolve():
        shutil.copy2(source, target)
    project["previewPng"] = target.name
    project["updatedAt"] = _now()
    _write_json(project_path, project)
    return {
        "project": _json_ready({**project, "projectPath": str(project_path), "projectRoot": str(project_path.parent), "previewPngPath": str(target)}),
        "projectPath": str(project_path),
        "projectRoot": str(project_path.parent),
        "previewPng": target.name,
        "previewPngPath": str(target),
    }


def update_project_appearance(
    livery_projects_root: str | Path,
    profile: dict[str, Any],
    appearance: dict[str, Any],
) -> dict[str, Any]:
    profile_id = str(profile.get("id") or "").strip()
    car_model = str(profile.get("carModel") or "").strip()
    if not profile_id or not car_model:
        raise ValueError("Driver profile id and car model are required")
    project_path = project_path_for(livery_projects_root, car_model, profile_id)
    project = read_project(project_path)
    if not project:
        raise FileNotFoundError("Driver project is missing")

    material_controls: list[dict[str, Any]] = []
    next_appearance: dict[str, str] = {}
    project["materialControls"] = material_controls
    project["appearance"] = next_appearance
    project["updatedAt"] = _now()
    _write_json(project_path, project)
    return {
        "project": _json_ready({**project, "projectPath": str(project_path), "projectRoot": str(project_path.parent)}),
        "projectPath": str(project_path),
        "projectRoot": str(project_path.parent),
        "appearance": next_appearance,
        "materialControls": material_controls,
    }


def sync_project_to_profile(
    profile: dict[str, Any],
    png_to_texturemips: Callable[[Path, Path, Path, str], dict[str, Any]] | None = None,
    neutralize_helmet_mask: Callable[[Path], dict[str, Any]] | None = None,
) -> dict[str, Any]:
    project_root_text = str(profile.get("projectRoot") or "").strip()
    project_path_text = str(profile.get("projectPath") or "").strip()
    project = read_project(project_path_text or project_root_text)
    if not project:
        return {"changed": False, "skipped": "driver project missing"}

    project_asset_root = Path(str(project.get("assetRoot") or ""))
    profile_root_text = str(profile.get("assetRoot") or "").strip()
    if not profile_root_text:
        raise ValueError("Driver profile asset root is missing")
    profile_root = Path(profile_root_text).expanduser()
    profile_asset_root = profile_root / "assets"
    workflow = str(project.get("workflow") or "").strip()
    if workflow == "png_sources" or isinstance(project.get("textureSources"), list):
        texture_sync = _sync_png_sources_to_profile(project, profile_asset_root, png_to_texturemips)
        material_defaults_sync = _sync_driver_material_defaults_to_profile(profile_asset_root)
        helmet_mask_sync = _sync_driver_mask_defaults_to_profile(profile_asset_root, neutralize_helmet_mask)
        material_sync = _sync_material_controls_to_profile(project, profile_asset_root)
        manifest_path = Path(str(profile.get("manifestPath") or ""))
        files = [rel for rel, _path in _asset_files(profile_asset_root)]
        if manifest_path.exists() and manifest_path.is_file():
            manifest = _read_json(manifest_path)
            if manifest:
                manifest["files"] = files
                manifest["projectRoot"] = str(project.get("projectRoot") or "")
                manifest["projectPath"] = str(project.get("projectPath") or "")
                manifest["projectAssetRoot"] = str(profile_asset_root)
                manifest["projectWorkflow"] = "png_sources"
                manifest["projectSourceFiles"] = [
                    str(source.get("sourcePng") or "")
                    for source in (project.get("textureSources") or [])
                    if isinstance(source, dict) and str(source.get("sourcePng") or "").strip()
                ]
                manifest["projectAppearance"] = {}
                manifest["projectSyncedAt"] = _now()
                manifest["updatedAt"] = _now()
                _write_json(manifest_path, manifest)
                profile.update(manifest)
        profile["files"] = files
        profile["projectRoot"] = str(project.get("projectRoot") or "")
        profile["projectPath"] = str(project.get("projectPath") or "")
        profile["projectAssetRoot"] = str(profile_asset_root)
        return {
            "changed": bool(texture_sync.get("changed") or material_defaults_sync.get("changed") or helmet_mask_sync.get("changed") or material_sync.get("changed")),
            "updatedCount": int(texture_sync.get("updatedCount") or 0) + int(material_defaults_sync.get("updatedCount") or 0) + int(helmet_mask_sync.get("updatedCount") or 0) + int(material_sync.get("updatedCount") or 0),
            "skippedCount": int(texture_sync.get("skippedCount") or 0) + int(material_defaults_sync.get("skippedCount") or 0) + int(helmet_mask_sync.get("skippedCount") or 0) + int(material_sync.get("skippedCount") or 0),
            "missingCount": int(texture_sync.get("missingCount") or 0) + int(material_defaults_sync.get("missingCount") or 0) + int(helmet_mask_sync.get("missingCount") or 0) + int(material_sync.get("missingCount") or 0),
            "textureSync": texture_sync,
            "materialDefaultsSync": material_defaults_sync,
            "helmetMaskSync": helmet_mask_sync,
            "materialSync": material_sync,
            "projectPath": str(project.get("projectPath") or ""),
            "profileRoot": str(profile_root),
            "profileAssetRoot": str(profile_asset_root),
            "workflow": "png_sources",
        }

    if not project_asset_root.exists():
        return {"changed": False, "skipped": "driver project assets missing", "projectPath": project.get("projectPath", "")}
    copied: list[str] = []
    for rel, source in _asset_files(project_asset_root):
        target = profile_asset_root / Path(*driver_skin.split_content_path(rel))
        before = target.read_bytes() if target.exists() and target.is_file() else None
        data = source.read_bytes()
        if before == data:
            continue
        target.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(source, target)
        copied.append(rel)

    manifest_path = Path(str(profile.get("manifestPath") or ""))
    files = [rel for rel, _path in _asset_files(profile_asset_root)]
    if manifest_path.exists() and manifest_path.is_file():
        manifest = _read_json(manifest_path)
        if manifest:
            manifest["files"] = files
            manifest["projectRoot"] = str(project.get("projectRoot") or "")
            manifest["projectPath"] = str(project.get("projectPath") or "")
            manifest["projectAssetRoot"] = str(project_asset_root)
            manifest["projectSyncedAt"] = _now()
            manifest["updatedAt"] = _now()
            _write_json(manifest_path, manifest)
            profile.update(manifest)

    profile["files"] = files
    profile["projectRoot"] = str(project.get("projectRoot") or "")
    profile["projectPath"] = str(project.get("projectPath") or "")
    profile["projectAssetRoot"] = str(project_asset_root)
    return {
        "changed": bool(copied),
        "copiedCount": len(copied),
        "copiedFiles": copied,
        "projectPath": str(project.get("projectPath") or ""),
        "profileRoot": str(profile_root),
    }
