from __future__ import annotations

import hashlib
import json
import os
from pathlib import Path
import shutil
from typing import Any

from liverylab_core import official_preview_storage, official_saved_car_state, project_capabilities, saved_games_paths


SAVED_CAR_SUFFIX = ".carfinalstatewithconsumable"
COMPACT_ARCHIVE_DIRNAME = "S"
COMPACT_ARCHIVE_SUFFIX = ".savedcar"
COMPACT_ARCHIVE_BUCKET_LENGTH = 12
COMPACT_ARCHIVE_FILE_HASH_LENGTH = 8
WINDOWS_LEGACY_MAX_PATH = 259


class OfficialSavedCarVaultPathLengthError(OSError):
    """Raised before deactivation when its compact archive path is still unsafe."""


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


def _entry_guid(entry: dict[str, Any]) -> str:
    details = entry.get("official_external") if isinstance(entry.get("official_external"), dict) else {}
    generated = details.get("generatedSavedCar") if isinstance(details.get("generatedSavedCar"), dict) else {}
    return official_saved_car_state.normalize_guid(
        entry.get("car_guid")
        or details.get("linkedSavedCarGuid")
        or details.get("carGuid")
        or generated.get("carGuid")
    )


def _is_within(path: Path, root: Path) -> bool:
    try:
        path.resolve().relative_to(root.resolve())
        return True
    except Exception:
        return False


def _saved_car_roots(saved_games_ace_root: str | Path | None) -> list[Path]:
    return [root.resolve() for root in project_capabilities.saved_cars_roots(saved_games_ace_root)]


def _candidate_paths(
    entry: dict[str, Any],
    saved_games_ace_root: str | Path | None,
    guid: str,
) -> list[Path]:
    details = entry.get("official_external") if isinstance(entry.get("official_external"), dict) else {}
    generated = details.get("generatedSavedCar") if isinstance(details.get("generatedSavedCar"), dict) else {}
    raw_paths = [
        *(details.get("savedCarPaths") if isinstance(details.get("savedCarPaths"), list) else []),
        generated.get("deployedPath"),
    ]
    for root in _saved_car_roots(saved_games_ace_root):
        raw_paths.extend(
            path
            for path in root.glob(f"*{SAVED_CAR_SUFFIX}")
            if guid in path.name.lower()
        )

    candidates: list[Path] = []
    seen: set[str] = set()
    for raw in raw_paths:
        text = str(raw or "").strip()
        if not text:
            continue
        path = Path(text).expanduser()
        key = str(path.resolve(strict=False)).lower()
        if key in seen:
            continue
        seen.add(key)
        candidates.append(path)
    return candidates


def _validate_saved_car(path: Path, guid: str) -> dict[str, Any]:
    inspection = official_saved_car_state.inspect_saved_car_file(path)
    if not inspection.get("valid") or not inspection.get("wrapped"):
        raise ValueError(f"SavedCar is not a valid wrapped car state: {path}")
    if not official_saved_car_state.matches_official_livery_guid(inspection, guid):
        raise ValueError(f"SavedCar livery identity does not match the Official livery: {path}")
    return inspection


def _compact_archive_target(vault_root: Path, relative: Path, guid: str) -> Path:
    parent_key = relative.parent.as_posix().casefold()
    bucket = hashlib.sha256(parent_key.encode("utf-8")).hexdigest()[:COMPACT_ARCHIVE_BUCKET_LENGTH]
    file_key = hashlib.sha256(relative.name.casefold().encode("utf-8")).hexdigest()[:COMPACT_ARCHIVE_FILE_HASH_LENGTH]
    return vault_root / COMPACT_ARCHIVE_DIRNAME / bucket / f"{guid}_{file_key}{COMPACT_ARCHIVE_SUFFIX}"


def _preflight_archive_targets(
    planned: list[dict[str, Any]],
    *,
    workspace_root: str | Path | None = None,
) -> None:
    seen: set[str] = set()
    longest_target: Path | None = None
    longest_length = 0
    for row in planned:
        target = Path(row["target"])
        key = str(target.resolve(strict=False)).casefold()
        if key in seen:
            raise FileExistsError(f"Multiple SavedCars map to the same compact vault target: {target}")
        seen.add(key)
        if target.exists():
            raise FileExistsError(f"SavedCar vault target already exists: {target}")
        path_length = len(str(target))
        if path_length > longest_length:
            longest_target = target
            longest_length = path_length
    if longest_target is None or longest_length <= WINDOWS_LEGACY_MAX_PATH:
        return
    probe_root = Path(workspace_root).expanduser() if workspace_root else longest_target.parent
    runtime = official_preview_storage.probe_runtime_long_path_support(
        probe_root,
        required_path_length=longest_length,
    )
    if runtime.get("available"):
        return
    registry = runtime.get("registry") if isinstance(runtime.get("registry"), dict) else {}
    registry_label = "enabled" if registry.get("enabled") else ("disabled" if registry.get("available") else "unavailable")
    raise OfficialSavedCarVaultPathLengthError(
        "Official livery deactivation could not prepare a safe SavedCar archive because Windows Long "
        f"Path Support is not available to the running LiveryLab process ({longest_length} characters; "
        f"legacy limit {WINDOWS_LEGACY_MAX_PATH}): {longest_target}\n"
        "Close LiveryLab, run Enable_Long_Windows_Paths.bat as administrator, restart Windows and try "
        "again. You can also search online for how to enable Win32 long paths. No SavedCar files were "
        f"moved.\nLongPathsEnabled: {registry_label}\n"
        f"Runtime long path test: {runtime.get('probeError') or 'failed'}"
    )


def archive_matching_saved_cars(
    entry: dict[str, Any],
    vault_session_root: str | Path,
    *,
    saved_games_ace_root: str | Path | None = None,
) -> dict[str, Any]:
    guid = _entry_guid(entry)
    roots = _saved_car_roots(saved_games_ace_root)
    result: dict[str, Any] = {
        "status": "already_missing",
        "carGuid": guid,
        "savedCarsRoots": [str(root) for root in roots],
        "moved": [],
        "movedCount": 0,
    }
    if not guid:
        result["status"] = "no_guid"
        return result

    candidates = [path for path in _candidate_paths(entry, saved_games_ace_root, guid) if path.exists()]
    validated: list[tuple[Path, Path, Path, dict[str, Any]]] = []
    ace_root = saved_games_paths.ace_root(saved_games_ace_root).resolve()
    for path in candidates:
        if not path.is_file():
            raise ValueError(f"SavedCar candidate is not a file: {path}")
        owner_root = next((root for root in roots if _is_within(path, root)), None)
        if owner_root is None:
            raise ValueError(f"Refusing to archive SavedCar outside a detected profile SavedCars folder: {path}")
        inspection = _validate_saved_car(path, guid)
        source = path.resolve()
        try:
            relative = source.relative_to(ace_root)
        except ValueError as exc:
            raise ValueError(f"SavedCar path is outside the selected Saved Games ACE root: {source}") from exc
        validated.append((source, owner_root, relative, inspection))

    if not validated:
        return result

    vault_root = Path(vault_session_root).expanduser().resolve() / "SavedCars"
    planned: list[dict[str, Any]] = []
    for source, owner_root, relative, inspection in validated:
        planned.append({
            "source": source,
            "ownerRoot": owner_root,
            "relative": relative,
            "inspection": inspection,
            "target": _compact_archive_target(vault_root, relative, guid),
            "size": source.stat().st_size,
            "sha256": _sha256(source),
        })
    _preflight_archive_targets(planned, workspace_root=vault_root)

    moved: list[dict[str, Any]] = []
    try:
        for row in planned:
            source = Path(row["source"])
            owner_root = Path(row["ownerRoot"])
            relative = Path(row["relative"])
            inspection = row["inspection"]
            target = Path(row["target"])
            target.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(source), str(target))
            moved.append({
                "source": str(source),
                "target": str(target),
                "relativeSavedGamesPath": str(relative).replace("\\", "/"),
                "profileSavedCarsRoot": str(owner_root),
                "filename": source.name,
                "size": int(row["size"]),
                "sha256": str(row["sha256"]),
                "carGuid": guid,
                "carModel": str(inspection.get("carModel") or ""),
                "archiveLayout": "compact_v1",
            })
    except Exception:
        rollback_archived_saved_cars({"moved": moved})
        raise

    result["status"] = "archived"
    result["moved"] = moved
    result["movedCount"] = len(moved)
    return result


def rollback_archived_saved_cars(archive: dict[str, Any]) -> dict[str, Any]:
    restored: list[dict[str, Any]] = []
    errors: list[str] = []
    for row in reversed(archive.get("moved") if isinstance(archive.get("moved"), list) else []):
        source = Path(str(row.get("source") or "")).expanduser()
        target = Path(str(row.get("target") or "")).expanduser()
        try:
            if not target.exists():
                continue
            if source.exists():
                raise FileExistsError(f"SavedCar rollback target already exists: {source}")
            source.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(target), str(source))
            restored.append({"source": str(target), "target": str(source)})
        except Exception as exc:
            errors.append(str(exc))
    return {"restored": restored, "restoredCount": len(restored), "errors": errors}


def restore_saved_cars(
    archive: dict[str, Any],
    *,
    saved_games_ace_root: str | Path | None = None,
) -> dict[str, Any]:
    rows = archive.get("moved") if isinstance(archive.get("moved"), list) else []
    roots = _saved_car_roots(saved_games_ace_root)
    restored: list[dict[str, Any]] = []
    skipped: list[dict[str, Any]] = []
    try:
        for row in rows:
            source = Path(str(row.get("target") or "")).expanduser()
            target = Path(str(row.get("source") or "")).expanduser()
            guid = official_saved_car_state.normalize_guid(row.get("carGuid") or archive.get("carGuid"))
            owner_root = next((root for root in roots if _is_within(target, root)), None)
            if owner_root is None:
                raise ValueError(f"Refusing to restore SavedCar outside a detected profile SavedCars folder: {target}")
            if target.exists():
                inspection = _validate_saved_car(target, guid)
                skipped.append({
                    "source": str(source),
                    "target": str(target),
                    "reason": "valid_saved_car_already_present",
                    "archiveSourceExists": source.is_file(),
                    "sha256": _sha256(target),
                    "carModel": str(inspection.get("carModel") or ""),
                })
                continue
            if not source.exists() or not source.is_file():
                raise FileNotFoundError(f"Archived SavedCar is missing: {source}")
            _validate_saved_car(source, guid)
            expected_hash = str(row.get("sha256") or "").lower()
            source_hash = _sha256(source)
            if expected_hash and source_hash != expected_hash:
                raise ValueError(f"Archived SavedCar checksum changed: {source}")
            target.parent.mkdir(parents=True, exist_ok=True)
            temp = target.with_name(target.name + ".liverylab-restore-tmp")
            try:
                shutil.copy2(source, temp)
                os.replace(temp, target)
            finally:
                if temp.exists():
                    temp.unlink()
            _validate_saved_car(target, guid)
            restored.append({
                "source": str(source),
                "target": str(target),
                "created": True,
                "sha256": _sha256(target),
                "carGuid": guid,
            })
    except Exception:
        rollback_restored_saved_cars({"restored": restored})
        raise
    return {
        "status": "restored" if restored else ("already_present" if skipped else "not_available"),
        "restored": restored,
        "restoredCount": len(restored),
        "skipped": skipped,
        "skippedCount": len(skipped),
    }


def rebase_archive_paths(archive: dict[str, Any], *, vault_root: str | Path) -> dict[str, Any]:
    managed_vault_root = Path(vault_root).expanduser().resolve()
    next_archive = dict(archive) if isinstance(archive, dict) else {}
    rows = archive.get("moved") if isinstance(archive, dict) and isinstance(archive.get("moved"), list) else []
    rebased_rows: list[dict[str, Any]] = []
    for raw_row in rows:
        if not isinstance(raw_row, dict):
            continue
        row = dict(raw_row)
        raw_target = str(row.get("target") or "").strip()
        target = Path(raw_target).expanduser() if raw_target else None
        if target is not None and _is_within(target, managed_vault_root):
            rebased_rows.append(row)
            continue
        expected_hash = str(row.get("sha256") or "").strip().lower()
        if target is None or not expected_hash:
            rebased_rows.append(row)
            continue
        parts = list(target.parts)
        root_index = next(
            (index for index, part in enumerate(parts) if part.lower() == managed_vault_root.name.lower()),
            None,
        )
        if root_index is None:
            rebased_rows.append(row)
            continue
        candidate = managed_vault_root.joinpath(*parts[root_index + 1 :])
        if (
            not _is_within(candidate, managed_vault_root)
            or not candidate.is_file()
            or _sha256(candidate).lower() != expected_hash
        ):
            rebased_rows.append(row)
            continue
        row["target"] = str(candidate)
        rebased_rows.append(row)
    next_archive["moved"] = rebased_rows
    next_archive["movedCount"] = len(rebased_rows)
    return next_archive


def archive_trust_status(
    archive: dict[str, Any],
    *,
    vault_root: str | Path,
    saved_games_ace_root: str | Path | None = None,
) -> dict[str, Any]:
    rows = archive.get("moved") if isinstance(archive.get("moved"), list) else []
    if not rows:
        return {"trusted": False, "reason": "archive has no SavedCar rows", "rowCount": 0, "errors": []}
    managed_vault_root = Path(vault_root).expanduser().resolve()
    saved_car_roots = _saved_car_roots(saved_games_ace_root)
    errors: list[str] = []
    for index, row in enumerate(rows):
        archived_copy = Path(str(row.get("target") or "")).expanduser()
        restore_target = Path(str(row.get("source") or "")).expanduser()
        if not str(row.get("target") or "").strip() or not _is_within(archived_copy, managed_vault_root):
            errors.append(f"row {index + 1}: archived copy is outside the current LiveryLab vault")
        if not str(row.get("source") or "").strip() or not any(
            _is_within(restore_target, root) for root in saved_car_roots
        ):
            errors.append(f"row {index + 1}: restore target is outside the current SavedCars folders")
    return {
        "trusted": not errors,
        "reason": "local archive paths verified" if not errors else errors[0],
        "rowCount": len(rows),
        "vaultRoot": str(managed_vault_root),
        "savedCarsRoots": [str(root) for root in saved_car_roots],
        "errors": errors,
    }


def find_latest_archive(vault_root: str | Path, car_guid: Any) -> dict[str, Any]:
    root = Path(vault_root).expanduser()
    guid = official_saved_car_state.normalize_guid(car_guid)
    if not guid or not root.exists() or not root.is_dir():
        return {}
    manifests = sorted(
        root.glob("*/deactivation_manifest.json"),
        key=lambda path: path.stat().st_mtime if path.exists() else 0,
        reverse=True,
    )
    for manifest_path in manifests:
        try:
            manifest = json.loads(manifest_path.read_text(encoding="utf-8-sig"))
        except Exception:
            continue
        if not isinstance(manifest, dict) or manifest.get("action") != "deactivate_official_external_livery":
            continue
        manifest_guid = official_saved_car_state.normalize_guid(manifest.get("carGuid"))
        saved_cars = manifest.get("savedCars") if isinstance(manifest.get("savedCars"), dict) else {}
        saved_cars = rebase_archive_paths(saved_cars, vault_root=root)
        archive_guid = official_saved_car_state.normalize_guid(saved_cars.get("carGuid"))
        if guid not in {manifest_guid, archive_guid}:
            continue
        rows = saved_cars.get("moved") if isinstance(saved_cars.get("moved"), list) else []
        if not rows or not any(Path(str(row.get("target") or "")).expanduser().is_file() for row in rows):
            continue
        return {
            "vaultSession": str(manifest_path.parent),
            "manifestPath": str(manifest_path),
            "createdAt": str(manifest.get("createdAt") or ""),
            "skinKey": str(manifest.get("skinKey") or ""),
            "skinId": str(manifest.get("skinId") or ""),
            "carGuid": guid,
            "savedCars": saved_cars,
        }
    return {}


def rollback_restored_saved_cars(restore: dict[str, Any]) -> dict[str, Any]:
    removed: list[str] = []
    errors: list[str] = []
    for row in reversed(restore.get("restored") if isinstance(restore.get("restored"), list) else []):
        target = Path(str(row.get("target") or "")).expanduser()
        try:
            if not target.exists():
                continue
            expected_hash = str(row.get("sha256") or "").lower()
            if expected_hash and _sha256(target) != expected_hash:
                raise ValueError(f"Refusing to roll back a SavedCar that changed after restore: {target}")
            target.unlink()
            removed.append(str(target))
        except Exception as exc:
            errors.append(str(exc))
    return {"removed": removed, "removedCount": len(removed), "errors": errors}
