from __future__ import annotations

import json
import os
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable


PIPELINE_VERSION = 5

_STAGE_RANGES: dict[str, tuple[float, float]] = {
    "start": (0.0, 0.0),
    "scan": (0.0, 0.08),
    "variants": (0.08, 0.72),
    "mesh": (0.72, 0.82),
    "uv": (0.82, 0.90),
    "ao": (0.90, 0.94),
    "thumbnail": (0.94, 0.98),
    "manifest": (0.98, 1.0),
    "done": (1.0, 1.0),
}


def package_signature(path: str | Path) -> dict[str, Any]:
    package = Path(path)
    stat = package.stat()
    return {
        "sourcePackage": str(package),
        "sourcePackageSize": int(stat.st_size),
        "sourcePackageMtime": float(stat.st_mtime),
        "sourcePackageMtimeNs": int(stat.st_mtime_ns),
    }


def file_cache_key(path: str | Path) -> tuple[str, int, int]:
    source = Path(path)
    stat = source.stat()
    try:
        normalized = os.path.normcase(str(source.resolve()))
    except OSError:
        normalized = os.path.normcase(str(source.absolute()))
    return normalized, int(stat.st_size), int(stat.st_mtime_ns)


def reusable_preview_assets(
    preview_root: str | Path,
    pack_path: str | Path,
    *,
    exporter_version: int,
    uv_size: int = 4096,
) -> dict[str, Any]:
    root = Path(preview_root)
    manifest_path = root / "official_car_resources_manifest.json"
    result: dict[str, Any] = {
        "reusable": False,
        "reason": "manifest missing",
        "manifestPath": str(manifest_path),
        "carCount": 0,
        "carIds": [],
    }
    if not manifest_path.exists():
        return result
    try:
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    except Exception as exc:
        result["reason"] = f"manifest unreadable: {exc}"
        return result
    result["manifest"] = manifest
    if int(manifest.get("previewPipelineVersion") or 0) != PIPELINE_VERSION:
        result["reason"] = "preview pipeline version changed"
        return result
    stored_preview_root = str(manifest.get("previewAssetsRoot") or "").strip()
    if stored_preview_root:
        try:
            current_root_key = os.path.normcase(str(root.resolve()))
            stored_root_key = os.path.normcase(str(Path(stored_preview_root).resolve()))
        except OSError:
            current_root_key = os.path.normcase(str(root.absolute()))
            stored_root_key = os.path.normcase(str(Path(stored_preview_root).absolute()))
        if stored_root_key != current_root_key:
            result["reason"] = "preview asset installation path changed"
            return result
    signature = package_signature(pack_path)
    stored_mtime_ns = int(manifest.get("sourcePackageMtimeNs") or 0)
    if int(manifest.get("sourcePackageSize") or -1) != signature["sourcePackageSize"]:
        result["reason"] = "source package size changed"
        return result
    if stored_mtime_ns != signature["sourcePackageMtimeNs"]:
        result["reason"] = "source package timestamp changed"
        return result
    preparation = manifest.get("derivedPreparation") if isinstance(manifest.get("derivedPreparation"), dict) else {}
    if preparation.get("ready") is not True:
        result["reason"] = "previous preview preparation was incomplete"
        return result
    car_ids = [str(row or "").strip() for row in manifest.get("carIds", []) or [] if str(row or "").strip()]
    if not car_ids:
        result["reason"] = "manifest has no cars"
        return result
    derived_root = root / "Derived"
    for car_id in car_ids:
        car_root = derived_root / car_id
        car_manifest_path = car_root / "preview_assets.json"
        try:
            car_manifest = json.loads(car_manifest_path.read_text(encoding="utf-8"))
        except Exception:
            result["reason"] = f"derived manifest missing or unreadable: {car_id}"
            return result
        variants = car_manifest.get("vehicleVariants") if isinstance(car_manifest.get("vehicleVariants"), list) else []
        compatibility_metadata_ready = bool(variants) and all(
            isinstance(variant, dict) and "visualPresetExternalCustomizableKnown" in variant
            for variant in variants
        )
        if (
            car_manifest.get("ready") is not True
            or int(car_manifest.get("previewPipelineVersion") or 0) != PIPELINE_VERSION
            or int(car_manifest.get("glbExporterVersion") or 0) != int(exporter_version)
            or int(car_manifest.get("uvSize") or 0) != int(uv_size)
            or not (car_root / "exterior.glb").exists()
            or not (car_root / "EXT_SKIN_UV.png").exists()
            or not compatibility_metadata_ready
        ):
            result["reason"] = f"derived assets are stale or incomplete: {car_id}"
            return result
    result.update(
        {
            "reusable": True,
            "reason": "source package and preview pipeline are unchanged",
            "carCount": len(car_ids),
            "carIds": car_ids,
            "packageSignature": signature,
        }
    )
    return result


@dataclass
class PreviewRunCache:
    mesh_uv: dict[tuple[str, int, int], dict[str, Any]] = field(default_factory=dict)
    mesh_info: dict[tuple[str, int, int], dict[str, Any]] = field(default_factory=dict)
    mesh_materials: dict[tuple[str, int, int], list[dict[str, Any]]] = field(default_factory=dict)
    variant_previews: dict[tuple[Any, ...], dict[str, Any]] = field(default_factory=dict)
    auxiliary_models: dict[tuple[Any, ...], dict[str, Any]] = field(default_factory=dict)
    parts_uv: dict[tuple[Any, ...], dict[str, Any]] = field(default_factory=dict)
    counters: dict[str, int] = field(default_factory=lambda: {
        "meshUvHits": 0,
        "meshUvMisses": 0,
        "meshInfoHits": 0,
        "meshInfoMisses": 0,
        "meshMaterialHits": 0,
        "meshMaterialMisses": 0,
        "variantPreviewHits": 0,
        "variantPreviewMisses": 0,
        "auxModelHits": 0,
        "auxModelMisses": 0,
        "partsUvHits": 0,
        "partsUvMisses": 0,
    })

    def hit(self, key: str) -> None:
        self.counters[key] = int(self.counters.get(key) or 0) + 1

    def summary(self) -> dict[str, int]:
        return dict(self.counters)


class PreviewProgress:
    def __init__(self, total: int, callback: Callable[[dict[str, Any]], None] | None) -> None:
        self.total = max(0, int(total))
        self.callback = callback
        self.started_at = time.perf_counter()
        self._active_car = ""
        self._active_stage = ""
        self._active_stage_started_at = self.started_at
        self._stage_timings: dict[str, dict[str, float]] = {}

    def _switch_stage(self, car_id: str, stage: str, now: float) -> None:
        if self._active_car and self._active_stage:
            elapsed = max(0.0, now - self._active_stage_started_at)
            car_timings = self._stage_timings.setdefault(self._active_car, {})
            car_timings[self._active_stage] = float(car_timings.get(self._active_stage) or 0.0) + elapsed
        self._active_car = str(car_id or "")
        self._active_stage = str(stage or "")
        self._active_stage_started_at = now

    def timing_summary(self) -> dict[str, dict[str, float]]:
        now = time.perf_counter()
        self._switch_stage("", "", now)
        return {
            car_id: {stage: round(seconds, 3) for stage, seconds in stages.items() if seconds > 0}
            for car_id, stages in self._stage_timings.items()
        }

    def emit(
        self,
        index: int,
        car_id: str,
        stage: str,
        *,
        current: int = 0,
        stage_total: int = 0,
        message: str = "",
    ) -> None:
        if self.total <= 0:
            return
        stage_key = stage if stage in _STAGE_RANGES else "scan"
        now = time.perf_counter()
        clean_car_id = str(car_id or "")
        if clean_car_id != self._active_car or stage_key != self._active_stage:
            self._switch_stage(clean_car_id, stage_key, now)
        start, end = _STAGE_RANGES[stage_key]
        fraction = 1.0 if stage_key == "done" else 0.0
        if stage_total > 0:
            fraction = min(1.0, max(0.0, float(current) / float(stage_total)))
        stage_progress = start + ((end - start) * fraction)
        completed = max(0.0, min(float(self.total), float(max(0, index - 1)) + stage_progress))
        elapsed = max(0.0, now - self.started_at)
        rate = completed / elapsed if completed > 0 and elapsed > 0 else 0.0
        eta = (self.total - completed) / rate if rate > 0 else 0.0
        if not self.callback:
            return
        self.callback(
            {
                "phase": "prepare_preview_assets",
                "current": max(0, int(index)),
                "total": self.total,
                "percent": round(completed * 100.0 / self.total, 1),
                "carId": clean_car_id,
                "previewStage": stage_key,
                "previewStageCurrent": max(0, int(current)),
                "previewStageTotal": max(0, int(stage_total)),
                "previewMessage": str(message or ""),
                "elapsedSeconds": round(elapsed, 1),
                "etaSeconds": round(eta, 1),
                "bytesProcessed": 0,
                "totalBytes": 0,
                "bytesPerSecond": 0,
            }
        )
