from __future__ import annotations

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

from . import driver_skin, preview3d


DRIVER_PREVIEW_DIRNAME = "_preview"
DRIVER_PREVIEW_PROJECT_FILENAME = "driver_preview_project.json"
DRIVER_PREVIEW_PROJECT_TYPE = "liverylab_driver_preview_project"
DRIVER_PREVIEW_PROJECT_VERSION = 1

DRIVER_PREVIEW_MESHES = (
    {
        "slot": "helmet",
        "label": "Helmet",
        "meshRelPath": "content\\characters\\1_helmets\\helmet_2\\meshes\\helmet_2.mesh",
        "textureSlots": ("helmet", "visor"),
    },
    {
        "slot": "suit",
        "label": "Suit",
        "meshRelPath": "content\\characters\\2_suits\\suit_2\\mesh\\male_suit_2.mesh",
        "textureSlots": ("suit",),
    },
    {
        "slot": "gloves",
        "label": "Gloves",
        "meshRelPath": "content\\characters\\3_gloves\\gloves_2\\meshes\\male_gloves_2.mesh",
        "textureSlots": ("gloves",),
    },
)

TEXTURE_SLOT_LABELS = {
    "helmet": "Helmet",
    "visor": "Visor",
    "suit": "Suit",
    "gloves": "Gloves",
}


MeshInspector = Callable[[str | Path, str | Path | None], dict[str, Any]]


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


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(data, indent=2, ensure_ascii=False), encoding="utf-8")


def _norm_rel(path: str | Path) -> str:
    return "\\".join(part for part in str(path or "").replace("/", "\\").split("\\") if part)


def _resolve_game_path(game_root: str | Path, rel_path: str | Path) -> Path:
    parts = [part for part in _norm_rel(rel_path).split("\\") if part]
    return Path(game_root).expanduser() / Path(*parts)


def _file_info(path: Path) -> dict[str, Any]:
    exists = path.exists() and path.is_file()
    info: dict[str, Any] = {
        "path": str(path),
        "exists": exists,
        "sizeBytes": 0,
        "mtimeMs": 0,
    }
    if exists:
        stat = path.stat()
        info.update({"sizeBytes": int(stat.st_size), "mtimeMs": int(stat.st_mtime * 1000)})
    return info


def _project_path(project_path_or_root: str | Path) -> Path:
    path = Path(project_path_or_root).expanduser()
    if path.is_dir():
        return path / "driver_project.json"
    return path


def _texture_sources(project: dict[str, Any], project_root: Path) -> dict[str, dict[str, Any]]:
    sources: dict[str, dict[str, Any]] = {}
    raw_sources = project.get("textureSources") if isinstance(project.get("textureSources"), list) else []
    for source in raw_sources:
        if not isinstance(source, dict):
            continue
        category = str(source.get("category") or "").strip().lower()
        if category not in TEXTURE_SLOT_LABELS:
            continue
        source_png = str(source.get("sourcePng") or "").strip()
        source_path = Path(str(source.get("sourcePngPath") or "")).expanduser() if str(source.get("sourcePngPath") or "").strip() else project_root / source_png
        sources[category] = {
            "slot": category,
            "label": str(source.get("label") or TEXTURE_SLOT_LABELS.get(category) or category),
            "sourcePng": source_png or source_path.name,
            "path": str(source_path),
            "info": _file_info(source_path),
            "textureRel": _norm_rel(str(source.get("textureRel") or "")),
            "texturemipsRel": _norm_rel(str(source.get("texturemipsRel") or "")),
        }
    return sources


def _texture_binding(slot: str, source: dict[str, Any]) -> dict[str, Any]:
    material_hints = {
        "helmet": ["skin"],
        "visor": ["glass", "visor"],
        "suit": ["suit"],
        "gloves": ["gloves"],
    }.get(slot, [slot])
    return {
        "slot": slot,
        "label": source.get("label") or TEXTURE_SLOT_LABELS.get(slot) or slot,
        "texturePath": source.get("path") or "",
        "textureFile": source.get("sourcePng") or "",
        "textureRel": source.get("textureRel") or "",
        "texturemipsRel": source.get("texturemipsRel") or "",
        "materialHints": material_hints,
        "liveReload": True,
        "exists": bool((source.get("info") or {}).get("exists")),
        "mtimeMs": int((source.get("info") or {}).get("mtimeMs") or 0),
    }


def _profile_asset_path(project: dict[str, Any], rel: str) -> Path:
    root = Path(str(project.get("profileAssetRoot") or "")).expanduser()
    return root / "assets" / Path(*driver_skin.split_content_path(rel))


def _resolve_driver_asset_path(project: dict[str, Any], game_root: str | Path, rel_path: str | Path) -> tuple[Path, str]:
    rel = _norm_rel(rel_path)
    profile_path = _profile_asset_path(project, rel)
    if profile_path.exists():
        return profile_path, "profile"
    game_path = _resolve_game_path(game_root, rel)
    return game_path, "game"


def _driver_project_mesh_refs(project: dict[str, Any]) -> dict[str, str]:
    refs: dict[str, str] = {}
    for key in ("localDriverFinalState", "driverFinalState"):
        driverfinalstate_rel = _norm_rel(str(project.get(key) or ""))
        if not driverfinalstate_rel:
            continue
        driverfinalstate_path = _profile_asset_path(project, 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 {"helmet", "suit", "gloves"} and category not in refs:
                refs[category] = _norm_rel(mesh_ref)
    return refs


def _suit_spatmap_source(project: dict[str, Any]) -> dict[str, Any] | None:
    controls = project.get("materialControls") if isinstance(project.get("materialControls"), list) else []
    material_rels = [
        driver_skin.normalize_game_rel_path(str(control.get("materialRel") or ""))
        for control in controls
        if isinstance(control, dict) and str(control.get("category") or "").strip().lower() == "suit"
    ]
    files = project.get("files") if isinstance(project.get("files"), list) else []
    material_rels.extend(
        driver_skin.normalize_game_rel_path(str(rel or ""))
        for rel in files
        if str(rel or "").strip().lower().endswith(".material") and driver_skin.classify_driver_ref(str(rel or "")) == "suit"
    )
    seen: set[str] = set()
    for material_rel in material_rels:
        material_key = material_rel.lower()
        if not material_rel or material_key in seen:
            continue
        seen.add(material_key)
        material_path = _profile_asset_path(project, material_rel)
        if not material_path.exists():
            continue
        try:
            info = driver_skin.inspect_driver_material(material_path)
        except Exception:
            continue
        for slot in info.get("textureSlots") or []:
            name = str(slot.get("name") or "").strip()
            if name.lower() not in {"spatmap", "splatmap"}:
                continue
            texture_rel = driver_skin.normalize_game_rel_path(str(slot.get("value") or ""))
            if not texture_rel:
                continue
            texture_path = _profile_asset_path(project, texture_rel)
            return {
                "slot": "suitMask",
                "label": "Suit layer mask",
                "materialRel": material_rel,
                "textureSlot": name,
                "textureRel": texture_rel,
                "texturemipsRel": driver_skin.normalize_game_rel_path(str(Path(texture_rel).with_suffix(".texturemips"))),
                "texturePath": str(texture_path),
                "exists": texture_path.exists(),
                "mtimeMs": int(texture_path.stat().st_mtime * 1000) if texture_path.exists() else 0,
            }
    return None


def _cache_valid(meta: dict[str, Any], mesh_path: Path) -> bool:
    if not mesh_path.exists():
        return False
    stat = mesh_path.stat()
    return (
        str(meta.get("sourceMeshPath") or "") == str(mesh_path)
        and int(meta.get("sourceMeshSize") or -1) == int(stat.st_size)
        and int(meta.get("sourceMeshMtimeMs") or -1) == int(stat.st_mtime * 1000)
        and int(meta.get("exporterVersion") or 0) == int(preview3d.GLB_EXPORTER_VERSION)
    )


def _export_mesh_glb(
    mesh_path: Path,
    glb_path: Path,
    game_root: str | Path,
    inspect_mesh: MeshInspector,
    force: bool,
) -> dict[str, Any]:
    meta_path = glb_path.with_suffix(".meta.json")
    meta = _read_json(meta_path) if meta_path.exists() else {}
    if glb_path.exists() and _cache_valid(meta, mesh_path) and not force:
        return {
            **meta,
            "glbPath": str(glb_path),
            "metaPath": str(meta_path),
            "cached": True,
        }

    mesh_info = inspect_mesh(mesh_path, game_root)
    glb = preview3d.write_mesh_info_glb(mesh_info, glb_path, str(mesh_info.get("material_name") or mesh_path.stem))
    stat = mesh_path.stat()
    meta = {
        **glb,
        "sourceMeshPath": str(mesh_path),
        "sourceMeshSize": int(stat.st_size),
        "sourceMeshMtimeMs": int(stat.st_mtime * 1000),
        "materialBlocks": [
            {
                "name": str(block.get("name") or ""),
                "materialPath": str(block.get("material_path") or ""),
                "indexCount": int(block.get("index_count") or 0),
            }
            for block in (mesh_info.get("material_blocks") or [])
            if isinstance(block, dict)
        ],
        "updatedAt": _now(),
    }
    _write_json(meta_path, meta)
    return {
        **meta,
        "glbPath": str(glb_path),
        "metaPath": str(meta_path),
        "cached": False,
    }


def prepare_driver_preview_project(
    driver_project_path_or_root: str | Path,
    game_root: str | Path,
    inspect_mesh: MeshInspector,
    force: bool = False,
) -> dict[str, Any]:
    driver_project_path = _project_path(driver_project_path_or_root)
    if not driver_project_path.exists():
        raise FileNotFoundError(f"Driver project was not found: {driver_project_path}")
    driver_project = _read_json(driver_project_path)
    if not driver_project:
        raise ValueError(f"Driver project is not readable: {driver_project_path}")

    project_root = driver_project_path.parent
    preview_root = project_root / DRIVER_PREVIEW_DIRNAME
    models_root = preview_root / "models"
    preview_root.mkdir(parents=True, exist_ok=True)
    models_root.mkdir(parents=True, exist_ok=True)

    textures = _texture_sources(driver_project, project_root)
    mesh_refs = _driver_project_mesh_refs(driver_project)
    models: list[dict[str, Any]] = []
    warnings: list[str] = []

    for spec in DRIVER_PREVIEW_MESHES:
        slot = str(spec["slot"])
        mesh_rel = _norm_rel(mesh_refs.get(slot) or str(spec["meshRelPath"]))
        mesh_path, mesh_source = _resolve_driver_asset_path(driver_project, game_root, mesh_rel)
        glb_path = models_root / f"{slot}.glb"
        texture_bindings = [
            _texture_binding(texture_slot, textures[texture_slot])
            for texture_slot in spec["textureSlots"]
            if texture_slot in textures
        ]
        model: dict[str, Any] = {
            "slot": slot,
            "label": str(spec["label"]),
            "meshRelPath": mesh_rel,
            "meshPath": str(mesh_path),
            "meshSource": mesh_source,
            "modelPath": str(glb_path),
            "textureBindings": texture_bindings,
            "available": False,
        }
        if not mesh_path.exists():
            warnings.append(f"{spec['label']} mesh was not found: {mesh_rel}")
            models.append(model)
            continue
        try:
            export = _export_mesh_glb(mesh_path, glb_path, game_root, inspect_mesh, force)
            model.update(
                {
                    "available": True,
                    "modelPath": str(glb_path),
                    "metaPath": str(export.get("metaPath") or ""),
                    "cached": bool(export.get("cached")),
                    "meshName": str(export.get("meshName") or ""),
                    "materialName": str(export.get("materialName") or ""),
                    "materials": export.get("materials") if isinstance(export.get("materials"), list) else [],
                    "materialBlocks": export.get("materialBlocks") if isinstance(export.get("materialBlocks"), list) else [],
                    "vertexCount": int(export.get("vertexCount") or 0),
                    "triangleCount": int(export.get("triangleCount") or 0),
                    "boundsMin": export.get("boundsMin"),
                    "boundsMax": export.get("boundsMax"),
                }
            )
        except Exception as exc:
            model["error"] = str(exc)
            warnings.append(f"{spec['label']} GLB export failed: {exc}")
        models.append(model)

    missing_textures = [label for key, label in TEXTURE_SLOT_LABELS.items() if key not in textures]
    for label in missing_textures:
        warnings.append(f"{label} PNG source is missing from this driver project.")

    project = {
        "formatVersion": DRIVER_PREVIEW_PROJECT_VERSION,
        "type": DRIVER_PREVIEW_PROJECT_TYPE,
        "driverProjectPath": str(driver_project_path),
        "driverProjectRoot": str(project_root),
        "previewRoot": str(preview_root),
        "gameRoot": str(Path(game_root).expanduser()),
        "id": str(driver_project.get("id") or ""),
        "name": str(driver_project.get("name") or driver_project.get("id") or ""),
        "carModel": str(driver_project.get("carModel") or ""),
        "carContentPath": str(driver_project.get("carContentPath") or ""),
        "mode": "single-model",
        "models": models,
        "textureSources": list(textures.values()),
        "suitMask": {},
        "appearance": {},
        "materialControls": [],
        "watchFiles": [
            {
                "slot": source["slot"],
                "path": source["path"],
                "mtimeMs": int((source.get("info") or {}).get("mtimeMs") or 0),
            }
            for source in textures.values()
            if (source.get("info") or {}).get("exists")
        ],
        "warnings": warnings,
        "updatedAt": _now(),
    }
    preview_project_path = preview_root / DRIVER_PREVIEW_PROJECT_FILENAME
    _write_json(preview_project_path, project)
    return {
        "projectPath": str(preview_project_path),
        "previewRoot": str(preview_root),
        "modelCount": len(models),
        "availableModelCount": sum(1 for model in models if model.get("available")),
        "textureCount": len(textures),
        "watchFileCount": len(project["watchFiles"]),
        "warnings": warnings,
        "project": project,
    }
