from __future__ import annotations

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

from liverylab_core import official_external_workflow, project_capabilities, project_types, saved_games_paths


EXTERNAL_LIVERY_JSON = "external_livery.json"
SAVED_CAR_SUFFIX = ".carfinalstatewithconsumable"
LOCAL_LIBRARY_CAR_ID = "_official_external"
UTF8_BOM = b"\xef\xbb\xbf"

GUID_RE = re.compile(
    r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
    re.IGNORECASE,
)

TEXTURE_REFERENCE_KEYS = {
    "base_color_replacement_texture_path",
    "roughness_replacement_texture_path",
    "metalness_replacement_texture_path",
    "clearcoat_replacement_texture_path",
    "clearcoat_roughness_replacement_texture_path",
    "emissive_replacement_texture_path",
    "splat_map_replacement_texture_path",
}

TEXTURE_SLOT_LABELS = {
    "base_color": "Base color",
    "detail_area": "Detail area",
    "roughness": "Roughness",
    "metalness": "Metalness",
    "clearcoat": "Clearcoat",
    "clearcoat_roughness": "Clearcoat roughness",
    "emissive": "Emissive",
    "unknown": "Unknown",
    "unmapped_source": "Unmapped source PNG",
}


def _ace_root(saved_games_ace_root: str | Path | None = None) -> Path:
    return saved_games_paths.ace_root(saved_games_ace_root)


def _external_root(
    external_liveries_root: str | Path | None = None,
    saved_games_ace_root: str | Path | None = None,
) -> Path:
    if external_liveries_root:
        return Path(external_liveries_root).expanduser()
    return project_capabilities.official_external_liveries_root(_ace_root(saved_games_ace_root))


def _is_relative_asset_path(value: str) -> bool:
    raw = str(value or "").replace("\\", "/").strip()
    if not raw or raw.startswith("/") or re.match(r"^[a-zA-Z]:", raw):
        return False
    return all(part not in ("", ".", "..") for part in raw.split("/"))


def _asset_path(folder: Path, value: str) -> Path | None:
    if not _is_relative_asset_path(value):
        return None
    return folder.joinpath(*str(value).replace("\\", "/").split("/"))


def _json_ready_path(path: Path | None) -> str:
    return str(path) if path else ""


def _display_name_from_folder(folder_name: str) -> str:
    text = str(folder_name or "").replace("_", " ").replace("-", " ").strip()
    return " ".join(part.capitalize() for part in text.split()) or str(folder_name or "External livery")


def _descriptor_id(folder_name: str, car_guid: str) -> str:
    key = str(folder_name or "").strip().lower() or "external_livery"
    guid = str(car_guid or "").strip().lower() or "no_guid"
    return f"official_external_livery:{key}:{guid}"


def _safe_segment(value: Any, fallback: str = "external_livery") -> str:
    raw = str(value or "").strip().lower().replace("\\", "_").replace("/", "_")
    raw = re.sub(r"[^a-z0-9._-]+", "_", raw)
    raw = re.sub(r"_+", "_", raw).strip("._-")
    return raw or fallback


def external_livery_json_has_bom(path: str | Path) -> bool:
    item = Path(path).expanduser()
    if not item.exists() or not item.is_file():
        return False
    return item.read_bytes()[:3] == UTF8_BOM


def livery_linker_from_car_guid(car_guid: Any) -> dict[str, str]:
    normalized = str(car_guid or "").strip().lower()
    if not GUID_RE.fullmatch(normalized):
        return {}
    value = int(normalized.replace("-", ""), 16)
    return {
        "a": str(value >> 64),
        "b": str(value & ((1 << 64) - 1)),
    }


def external_livery_data_with_linker(data: dict[str, Any]) -> dict[str, Any]:
    normalized = dict(data)
    linker = livery_linker_from_car_guid(normalized.get("car_guid"))
    if not linker:
        return normalized
    return {
        "livery_linker": linker,
        **{key: value for key, value in normalized.items() if key != "livery_linker"},
    }


def external_livery_json_text(data: dict[str, Any]) -> str:
    return json.dumps(external_livery_data_with_linker(data), indent=2, ensure_ascii=False) + "\n"


def write_external_livery_json(path: str | Path, data: dict[str, Any]) -> None:
    target = Path(path).expanduser()
    target.parent.mkdir(parents=True, exist_ok=True)
    target.write_bytes(external_livery_json_text(data).encode("utf-8"))


def _texture_slot_from_field(field: str) -> str:
    normalized = str(field or "").lower()
    if "splat_map_replacement_texture_path" in normalized:
        return "detail_area"
    if "base_color_replacement_texture_path" in normalized:
        return "base_color"
    if "clearcoat_roughness_replacement_texture_path" in normalized:
        return "clearcoat_roughness"
    if "clearcoat_replacement_texture_path" in normalized:
        return "clearcoat"
    if "roughness_replacement_texture_path" in normalized:
        return "roughness"
    if "metalness_replacement_texture_path" in normalized:
        return "metalness"
    if "emissive_replacement_texture_path" in normalized:
        return "emissive"
    return "unknown"


def _texture_slot_from_name(name: str) -> str:
    stem = Path(str(name or "")).stem.lower()
    if stem.endswith("_d"):
        return "detail_area"
    if stem.endswith("_cr"):
        return "clearcoat_roughness"
    if stem.endswith("_cc"):
        return "clearcoat"
    if stem.endswith("_r"):
        return "roughness"
    if stem.endswith("_m"):
        return "metalness"
    if stem.endswith("_c"):
        return "base_color"
    return "unmapped_source"


def _read_external_livery_json(path: Path) -> tuple[dict[str, Any], list[str]]:
    warnings: list[str] = []
    if external_livery_json_has_bom(path):
        warnings.append(f"{EXTERNAL_LIVERY_JSON} has a UTF-8 BOM; AC EVO may ignore this folder")
    try:
        data = json.loads(path.read_text(encoding="utf-8-sig"))
    except json.JSONDecodeError as exc:
        raise ValueError(f"{EXTERNAL_LIVERY_JSON} is not valid JSON: {exc}") from exc
    if not isinstance(data, dict):
        raise ValueError(f"{EXTERNAL_LIVERY_JSON} must contain a JSON object")
    return data, warnings


def _texture_reference_key(key: str) -> bool:
    normalized = str(key or "").strip()
    return normalized in TEXTURE_REFERENCE_KEYS or normalized.endswith("_texture_path")


def _collect_texture_references(
    value: Any,
    *,
    field_path: str = "",
    material_batch_name: str = "",
) -> list[dict[str, str]]:
    if isinstance(value, dict):
        rows: list[dict[str, str]] = []
        for key, child in value.items():
            key_text = str(key)
            next_path = f"{field_path}.{key_text}" if field_path else key_text
            if _texture_reference_key(key_text) and isinstance(child, str) and child.strip():
                rows.append(
                    {
                        "field": next_path,
                        "path": child.strip(),
                        "materialBatchName": material_batch_name,
                    }
                )
            rows.extend(
                _collect_texture_references(
                    child,
                    field_path=next_path,
                    material_batch_name=material_batch_name,
                )
            )
        return rows
    if isinstance(value, list):
        rows: list[dict[str, str]] = []
        for index, child in enumerate(value):
            next_path = f"{field_path}[{index}]" if field_path else f"[{index}]"
            rows.extend(
                _collect_texture_references(
                    child,
                    field_path=next_path,
                    material_batch_name=material_batch_name,
                )
            )
        return rows
    return []


def _folder_files(folder: Path) -> dict[str, list[dict[str, Any]]]:
    source_textures: list[dict[str, Any]] = []
    generated_textures: list[dict[str, Any]] = []
    generated_mips: list[dict[str, Any]] = []
    other_files: list[dict[str, Any]] = []

    for path in sorted((item for item in folder.iterdir() if item.is_file()), key=lambda item: item.name.lower()):
        row = {
            "name": path.name,
            "path": str(path),
            "size": path.stat().st_size,
        }
        suffix = path.suffix.lower()
        if suffix == ".png":
            source_textures.append(row)
        elif suffix == ".texture":
            generated_textures.append(row)
        elif suffix == ".texturemips":
            generated_mips.append(row)
        elif path.name.lower() != EXTERNAL_LIVERY_JSON:
            other_files.append(row)

    return {
        "sourceTextures": source_textures,
        "generatedTextures": generated_textures,
        "generatedTextureMips": generated_mips,
        "otherFiles": other_files,
    }


def folder_manifest(folder: str | Path) -> dict[str, Any]:
    root = Path(folder).expanduser()
    files: list[dict[str, Any]] = []
    digest = hashlib.sha256()
    ignored_names = {"liverylab_skin.json"}
    if root.exists() and root.is_dir():
        for path in sorted((item for item in root.rglob("*") if item.is_file()), key=lambda item: item.relative_to(root).as_posix().lower()):
            if path.name.lower() in ignored_names:
                continue
            rel = path.relative_to(root).as_posix()
            file_digest = hashlib.sha256()
            size = 0
            with path.open("rb") as handle:
                for chunk in iter(lambda: handle.read(1024 * 1024), b""):
                    size += len(chunk)
                    file_digest.update(chunk)
            file_hash = file_digest.hexdigest()
            digest.update(rel.lower().encode("utf-8"))
            digest.update(str(size).encode("ascii"))
            digest.update(file_hash.encode("ascii"))
            files.append(
                {
                    "path": rel,
                    "size": size,
                    "sha256": file_hash,
                }
            )
    return {
        "path": str(root),
        "exists": root.exists() and root.is_dir(),
        "fileCount": len(files),
        "sha256": digest.hexdigest() if files else "",
        "files": files,
    }


def local_library_folder_name(descriptor: dict[str, Any]) -> str:
    folder = _safe_segment(descriptor.get("folderName") or descriptor.get("displayName"), "external_livery")
    guid = _safe_segment(str(descriptor.get("carGuid") or "")[:8], "no_guid")
    return f"{folder}_{guid}"


def car_model_from_descriptor(descriptor: dict[str, Any]) -> str:
    guid = str(descriptor.get("carGuid") or "").strip().lower()
    paths = descriptor.get("savedCarPaths") if isinstance(descriptor.get("savedCarPaths"), list) else []
    for raw_path in paths:
        stem = Path(str(raw_path or "")).stem
        suffix = f"_{guid}" if guid else ""
        if suffix and stem.lower().endswith(suffix):
            candidate = stem[: -len(suffix)].strip("_")
            if candidate:
                return _safe_segment(candidate, LOCAL_LIBRARY_CAR_ID)
    folder_name = str(descriptor.get("folderName") or "").strip()
    return _safe_segment(folder_name, LOCAL_LIBRARY_CAR_ID)


def copy_external_livery_folder_to_library(source_folder: str | Path, target_folder: str | Path) -> dict[str, Any]:
    source = Path(source_folder).expanduser()
    target = Path(target_folder).expanduser()
    if not source.exists() or not source.is_dir():
        raise FileNotFoundError(f"External livery folder not found: {source}")
    target.mkdir(parents=True, exist_ok=True)
    copied: list[str] = []
    skipped: list[str] = []
    for child in sorted((item for item in source.rglob("*") if item.is_file()), key=lambda item: item.relative_to(source).as_posix().lower()):
        rel = child.relative_to(source)
        destination = target / rel
        destination.parent.mkdir(parents=True, exist_ok=True)
        if destination.exists():
            skipped.append(rel.as_posix())
            continue
        shutil.copy2(child, destination)
        copied.append(rel.as_posix())
    return {
        "sourceFolder": str(source),
        "targetFolder": str(target),
        "copied": copied,
        "skipped": skipped,
        "copiedCount": len(copied),
        "skippedCount": len(skipped),
    }


def my_skin_entry_from_descriptor(
    descriptor: dict[str, Any],
    local_root: str | Path,
    *,
    game_root: str | Path | None = None,
    source_manifest: dict[str, Any] | None = None,
    local_manifest: dict[str, Any] | None = None,
) -> dict[str, Any]:
    root = Path(local_root).expanduser()
    folder_name = str(descriptor.get("folderName") or root.name or "external_livery").strip()
    car_guid = str(descriptor.get("carGuid") or "").strip()
    key = str(descriptor.get("id") or _descriptor_id(folder_name, car_guid)).strip()
    skin_id = local_library_folder_name(descriptor)
    car_model = car_model_from_descriptor(descriptor)
    primary_texture = descriptor.get("textureSet", {}).get("primaryTexture", {}) if isinstance(descriptor.get("textureSet"), dict) else {}
    primary_texture_path = str(primary_texture.get("path") or "").replace("\\", "/").strip()
    source_hash = str((source_manifest or {}).get("sha256") or "").strip()
    local_hash = str((local_manifest or {}).get("sha256") or "").strip()
    deployment_status = "matches_source" if source_hash and source_hash == local_hash else "local_copy"
    now = datetime.now().isoformat(timespec="seconds")
    entry = {
        "key": key,
        "skin_id": skin_id,
        "display_name": str(descriptor.get("displayName") or _display_name_from_folder(folder_name)),
        "car_model": car_model,
        "car_guid": car_guid,
        "car_content_path": f"content\\cars\\{car_model}" if car_model and car_model != LOCAL_LIBRARY_CAR_ID else "",
        "game_root": str(game_root or ""),
        "created_at": now,
        "origin": "official_external",
        "project_type": project_types.PROJECT_TYPE_OFFICIAL_EXTERNAL_LIVERY,
        "project_state": "none",
        "managed_by": "liverylab",
        "liverylab_managed": True,
        "active": False,
        "local_root": str(root),
        "files": {
            "external_livery_json": EXTERNAL_LIVERY_JSON,
            "primary_texture": primary_texture_path,
        },
        "official_external": {
            "sourceDescriptorId": key,
            "sourceFolder": str(descriptor.get("externalLiveryFolder") or ""),
            "sourceJson": str(descriptor.get("externalLiveryJson") or ""),
            "sourceFolderName": folder_name,
            "importedAt": now,
            "deploymentStatus": deployment_status,
            "deployed": bool(descriptor.get("externalLiveryFolder")),
            "savedCarFound": bool(descriptor.get("savedCarFound")),
            "savedCarPaths": list(descriptor.get("savedCarPaths") or []),
            "sourceManifest": source_manifest or {},
            "localManifest": local_manifest or {},
            "descriptor": descriptor,
        },
    }
    return official_external_workflow.apply_library_entry_metadata(entry)


def _saved_car_matches(car_guid: str, saved_games_ace_root: str | Path | None = None) -> list[Path]:
    guid = str(car_guid or "").strip().lower()
    if not guid:
        return []
    matches: list[Path] = []
    for root in project_capabilities.saved_cars_roots(_ace_root(saved_games_ace_root)):
        for path in root.glob(f"*{SAVED_CAR_SUFFIX}"):
            name = path.name.lower()
            if name.endswith(f"_{guid}{SAVED_CAR_SUFFIX}") or guid in path.stem.lower():
                matches.append(path)
    return sorted(matches, key=lambda item: str(item).lower())


def _build_texture_set(
    texture_references: list[dict[str, Any]],
    source_textures: list[dict[str, Any]],
    generated_textures: list[dict[str, Any]],
    generated_mips: list[dict[str, Any]],
) -> dict[str, Any]:
    slots: list[dict[str, Any]] = []
    referenced_paths = {str(row.get("normalizedPath") or row.get("path") or "").replace("\\", "/").lower() for row in texture_references}

    for row in texture_references:
        slot = _texture_slot_from_field(str(row.get("field") or ""))
        slots.append(
            {
                "slot": slot,
                "label": TEXTURE_SLOT_LABELS.get(slot, slot),
                "path": row.get("normalizedPath") or row.get("path") or "",
                "absolutePath": row.get("absolutePath") or "",
                "exists": bool(row.get("exists")),
                "referenced": True,
                "field": row.get("field") or "",
                "materialBatchName": row.get("materialBatchName") or "",
            }
        )

    for row in source_textures:
        rel_path = str(row.get("name") or "").replace("\\", "/")
        if rel_path.lower() in referenced_paths:
            continue
        slot = _texture_slot_from_name(rel_path)
        slots.append(
            {
                "slot": slot,
                "label": TEXTURE_SLOT_LABELS.get(slot, slot),
                "path": rel_path,
                "absolutePath": row.get("path") or "",
                "exists": True,
                "referenced": False,
                "field": "",
                "materialBatchName": "",
            }
        )

    primary = next((row for row in slots if row.get("slot") == "base_color" and row.get("exists")), None)
    missing = [row for row in slots if row.get("referenced") and not row.get("exists")]
    return {
        "sourceTextureCount": len(source_textures),
        "generatedTextureCount": len(generated_textures),
        "generatedTextureMipCount": len(generated_mips),
        "referencedTextureCount": len(texture_references),
        "missingReferenceCount": len(missing),
        "primaryTexture": primary or {},
        "slots": slots,
    }


def _build_project_descriptor(
    *,
    root: Path,
    json_path: Path,
    valid: bool,
    errors: list[str],
    warnings: list[str],
    car_guid: str,
    car_guid_valid: bool,
    saved_car_matches: list[Path],
    batch_names: list[str],
    texture_set: dict[str, Any],
) -> dict[str, Any]:
    saved_car_paths = [str(path) for path in saved_car_matches]
    return {
        "schemaVersion": 1,
        "id": _descriptor_id(root.name, car_guid),
        "projectType": project_types.PROJECT_TYPE_OFFICIAL_EXTERNAL_LIVERY,
        "officialExternalRole": official_external_workflow.ROLE_DEPLOYMENT,
        "deploymentTarget": official_external_workflow.DEPLOYMENT_TARGET_SAVED_GAMES_EXTERNAL_LIVERIES,
        "sourceType": "official_external_livery_folder",
        "projectState": "external_detected",
        "readOnly": True,
        "valid": bool(valid),
        "displayName": _display_name_from_folder(root.name),
        "folderName": root.name,
        "externalLiveryFolder": str(root),
        "externalLiveryJson": str(json_path),
        "carGuid": car_guid,
        "carGuidValid": car_guid_valid,
        "savedCarFound": bool(saved_car_matches),
        "savedCarPaths": saved_car_paths,
        "primarySavedCarPath": saved_car_paths[0] if saved_car_paths else "",
        "materialBatchNames": batch_names,
        "textureSet": texture_set,
        "errors": list(errors),
        "warnings": list(warnings),
    }


def inspect_external_livery_folder(
    folder: str | Path,
    *,
    saved_games_ace_root: str | Path | None = None,
) -> dict[str, Any]:
    root = Path(folder).expanduser()
    errors: list[str] = []
    warnings: list[str] = []
    json_path = root / EXTERNAL_LIVERY_JSON
    data: dict[str, Any] = {}
    json_loaded = False

    if not root.exists() or not root.is_dir():
        errors.append(f"Folder not found: {root}")
    elif not json_path.exists():
        errors.append(f"{EXTERNAL_LIVERY_JSON} is missing")
    else:
        try:
            data, read_warnings = _read_external_livery_json(json_path)
            json_loaded = True
            warnings.extend(read_warnings)
        except ValueError as exc:
            errors.append(str(exc))

    car_guid = str(data.get("car_guid") or "").strip()
    if json_loaded and not car_guid:
        errors.append("car_guid is missing")
    car_guid_valid = bool(car_guid and GUID_RE.match(car_guid))
    if car_guid and not car_guid_valid:
        warnings.append("car_guid does not look like a GUID")

    materials_raw = data.get("materials") if isinstance(data, dict) else None
    if json_loaded and not isinstance(materials_raw, list):
        errors.append("materials must be a list")
        materials_raw = []

    materials: list[dict[str, Any]] = []
    texture_references: list[dict[str, Any]] = []
    batch_names: list[str] = []

    for index, material in enumerate(materials_raw or []):
        if not isinstance(material, dict):
            warnings.append(f"materials[{index}] is not an object")
            continue
        batch_name = str(material.get("batch_name") or "").strip()
        if not batch_name:
            warnings.append(f"materials[{index}] has no batch_name")
        elif batch_name not in batch_names:
            batch_names.append(batch_name)

        material_refs = []
        for ref in _collect_texture_references(material, field_path=f"materials[{index}]", material_batch_name=batch_name):
            raw_path = ref["path"]
            candidate = _asset_path(root, raw_path)
            exists = bool(candidate and candidate.exists() and candidate.is_file())
            if candidate is None:
                warnings.append(f"Invalid texture path reference: {raw_path}")
            row = {
                **ref,
                "normalizedPath": raw_path.replace("\\", "/"),
                "exists": exists,
                "absolutePath": _json_ready_path(candidate),
            }
            texture_references.append(row)
            material_refs.append(row)

        materials.append(
            {
                "index": index,
                "batchName": batch_name,
                "channelGroups": [
                    key for key in ("base", "red", "green", "blue") if isinstance(material.get(key), dict)
                ],
                "textureReferences": material_refs,
            }
        )

    file_rows = _folder_files(root) if root.exists() and root.is_dir() else {
        "sourceTextures": [],
        "generatedTextures": [],
        "generatedTextureMips": [],
        "otherFiles": [],
    }
    saved_car_matches = _saved_car_matches(car_guid, saved_games_ace_root)
    missing_refs = [row for row in texture_references if not row.get("exists")]
    texture_set = _build_texture_set(
        texture_references,
        file_rows["sourceTextures"],
        file_rows["generatedTextures"],
        file_rows["generatedTextureMips"],
    )
    valid = not errors
    descriptor = _build_project_descriptor(
        root=root,
        json_path=json_path,
        valid=valid,
        errors=errors,
        warnings=warnings,
        car_guid=car_guid,
        car_guid_valid=car_guid_valid,
        saved_car_matches=saved_car_matches,
        batch_names=batch_names,
        texture_set=texture_set,
    )

    return {
        "projectType": project_types.PROJECT_TYPE_OFFICIAL_EXTERNAL_LIVERY,
        "folderName": root.name,
        "displayName": descriptor["displayName"],
        "path": str(root),
        "externalLiveryJson": str(json_path),
        "valid": valid,
        "descriptor": descriptor,
        "errors": errors,
        "warnings": warnings,
        "carGuid": car_guid,
        "carGuidValid": car_guid_valid,
        "savedCar": {
            "found": bool(saved_car_matches),
            "matchCount": len(saved_car_matches),
            "paths": [str(path) for path in saved_car_matches],
        },
        "materialBatchNames": batch_names,
        "materials": materials,
        "textureReferences": texture_references,
        "textureSet": texture_set,
        "missingTextureReferenceCount": len(missing_refs),
        "textureCounts": {
            "png": len(file_rows["sourceTextures"]),
            "texture": len(file_rows["generatedTextures"]),
            "texturemips": len(file_rows["generatedTextureMips"]),
            "other": len(file_rows["otherFiles"]),
        },
        **file_rows,
    }


def discover_external_liveries(
    *,
    saved_games_ace_root: str | Path | None = None,
    external_liveries_root: str | Path | None = None,
    include_invalid: bool = True,
) -> dict[str, Any]:
    ace_root = _ace_root(saved_games_ace_root)
    root = _external_root(external_liveries_root, ace_root)
    warnings: list[str] = []
    items: list[dict[str, Any]] = []

    if root.exists() and root.is_dir():
        for folder in sorted((item for item in root.iterdir() if item.is_dir()), key=lambda item: item.name.lower()):
            item = inspect_external_livery_folder(folder, saved_games_ace_root=ace_root)
            if include_invalid or item.get("valid"):
                items.append(item)
    else:
        warnings.append(f"ExternalLiveries folder not found: {root}")

    valid_count = sum(1 for item in items if item.get("valid"))
    linked_count = sum(1 for item in items if isinstance(item.get("savedCar"), dict) and item["savedCar"].get("found"))
    descriptors = [item["descriptor"] for item in items if isinstance(item.get("descriptor"), dict)]

    return {
        "projectType": project_types.PROJECT_TYPE_OFFICIAL_EXTERNAL_LIVERY,
        "savedGamesAceRoot": str(ace_root),
        "externalLiveriesRoot": str(root),
        "externalLiveriesRootExists": root.exists() and root.is_dir(),
        "items": items,
        "descriptors": descriptors,
        "summary": {
            "count": len(items),
            "validCount": valid_count,
            "invalidCount": len(items) - valid_count,
            "linkedSavedCarCount": linked_count,
            "descriptorCount": len(descriptors),
        },
        "warnings": warnings,
    }
