from __future__ import annotations

import json
import shutil
import zipfile
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path, PurePosixPath
from typing import Any

from liverylab_core import export_sanitize, package_security


BACKUP_TYPE = "liverylab_update_backup"
MANIFEST_NAME = "liverylab_update_manifest.json"
BACKUP_VERSION = 1
PROJECT_ROOT_NAME = "LiveryProjects"
LIBRARY_ROOT_NAME = "LiveryLibrary"
OFFICIAL_PROJECT_ROOT_NAME = "OfficialLiveryProjects"
OFFICIAL_LIBRARY_ROOT_NAME = "OfficialLiveryLibrary"
USER_DATA_ROOT_NAME = "UserData"
SETTINGS_NAME = "settings.json"
SUPPORTED_IMPORT_ROOTS = {
    USER_DATA_ROOT_NAME,
    LIBRARY_ROOT_NAME,
    PROJECT_ROOT_NAME,
    OFFICIAL_PROJECT_ROOT_NAME,
    OFFICIAL_LIBRARY_ROOT_NAME,
}
LIBRARY_INCLUDE_NAMES = {"my_skins.json", "LocalSkins", "Numberplates", "TyreBrands", "Drivers", "GridPresets", "LayerStudioLibrary"}
PROJECT_INCLUDE_FILENAMES = {
    "ext_banner_c.png",
    "ext_skin_c.png",
    "ext_skin_cc.png",
    "ext_skin_cr.png",
    "ext_skin_d.png",
    "ext_skin_d_2.png",
    "ext_skin_drift_ao.png",
    "ext_skin_drift_d.png",
    "ext_skin_m.png",
    "ext_skin_r.png",
    "ext_skin_s.png",
    "ext_skin_uv.png",
    "exterior.meta.json",
    "livery_thumbnail.png",
    "preview_project.json",
    "driver_project.json",
}
OFFICIAL_PROJECT_INCLUDE_SUFFIXES = {".carfinalstatewithconsumable", ".json", ".png"}
PROJECT_WORK_EXTENSIONS = {".psd", ".psb", ".xcf", ".afphoto", ".kra", ".ai", ".svg"}
TRASH_DIR_NAMES = {"_trash", "trash", "liverylab_trash"}
RUNTIME_BACKUP_DIR_NAMES = {"_acevo_backups", "_backups", "_source_backups"}
RUNTIME_CACHE_DIR_NAMES = {"_cache", "previewcache"}
EXCLUDED_DIR_NAMES = TRASH_DIR_NAMES | RUNTIME_BACKUP_DIR_NAMES | RUNTIME_CACHE_DIR_NAMES


@dataclass(frozen=True)
class BackupRoots:
    user_data: Path
    livery_library: Path
    livery_projects: Path
    official_livery_library: Path | None = None
    official_livery_projects: Path | None = None

    def __post_init__(self) -> None:
        base = self.livery_projects.parent
        if self.official_livery_library is None:
            object.__setattr__(self, "official_livery_library", base / OFFICIAL_LIBRARY_ROOT_NAME)
        if self.official_livery_projects is None:
            object.__setattr__(self, "official_livery_projects", base / OFFICIAL_PROJECT_ROOT_NAME)


def _now_stamp() -> str:
    return datetime.now().strftime("%Y%m%d_%H%M%S")


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)):
        return [_json_ready(item) for item in value]
    return value


def _archive_name(root_name: str, path: Path, base: Path) -> str:
    rel = path.relative_to(base).as_posix()
    return f"{root_name}/{rel}" if rel else root_name


def _iter_files(root: Path):
    if not root.exists():
        return
    if root.is_file():
        yield root
        return
    for path in sorted(root.rglob("*"), key=lambda item: item.as_posix().lower()):
        if any(part.lower() in EXCLUDED_DIR_NAMES for part in path.parts):
            continue
        if export_sanitize.is_legacy_numberplate_bake_path(path):
            continue
        if path.is_file():
            yield path


def _project_file_allowed(path: Path) -> bool:
    if "layer-studio" in {str(part).lower() for part in path.parts}:
        return path.suffix.lower() in {".json", ".png"}
    return path.name.lower() in PROJECT_INCLUDE_FILENAMES


def _official_project_file_allowed(path: Path) -> bool:
    return path.suffix.lower() in OFFICIAL_PROJECT_INCLUDE_SUFFIXES


def _project_file_skip_reason(path: Path) -> str:
    if path.suffix.lower() in PROJECT_WORK_EXTENSIONS:
        return "editable source file"
    return "not a LiveryLab-managed project file"


def _add_file(zf: zipfile.ZipFile, path: Path, archive_name: str, report: dict[str, Any]) -> None:
    if path.suffix.lower() == ".json":
        try:
            original = json.loads(path.read_text(encoding="utf-8"))
            normalized_archive_name = str(archive_name).replace("\\", "/").lower()
            if normalized_archive_name.endswith("/gridpresets/grid_presets.json"):
                cleaned = original
            else:
                cleaned = export_sanitize.clean_json_payload_for_archive(archive_name, original, portable_paths=False)
            cleaned = export_sanitize.clean_portable_managed_paths(cleaned)
        except Exception:
            cleaned = None
        if cleaned is not None and cleaned != original:
            data = json.dumps(_json_ready(cleaned), indent=2, ensure_ascii=False).encode("utf-8")
            zf.writestr(archive_name, data)
            report["includedFiles"] += 1
            report["includedBytes"] += len(data)
            return
    zf.write(path, archive_name)
    report["includedFiles"] += 1
    try:
        report["includedBytes"] += path.stat().st_size
    except OSError:
        pass


def _validate_official_project_saved_cars(roots: BackupRoots) -> None:
    registry_path = roots.livery_library / "my_skins.json"
    try:
        registry = json.loads(registry_path.read_text(encoding="utf-8"))
    except FileNotFoundError:
        return
    except Exception as exc:
        raise ValueError(f"My Liveries registry could not be checked before update backup: {exc}") from exc
    entries = registry.get("skins") if isinstance(registry, dict) else registry
    for entry in entries if isinstance(entries, list) else []:
        if not isinstance(entry, dict) or str(entry.get("project_type") or "").strip().lower() != "official_external_livery":
            continue
        details = entry.get("official_external") if isinstance(entry.get("official_external"), dict) else {}
        generated = details.get("generatedSavedCar") if isinstance(details.get("generatedSavedCar"), dict) else {}
        files = entry.get("files") if isinstance(entry.get("files"), dict) else {}
        relative_text = str(generated.get("relativePath") or files.get("saved_car") or "").strip()
        if not relative_text:
            continue
        local_text = str(entry.get("local_root") or "").strip()
        local_root = Path(local_text).expanduser() if local_text else (
            roots.official_livery_projects / str(entry.get("car_model") or "car") / str(entry.get("skin_id") or "skin")
        )
        relative = Path(*[part for part in relative_text.replace("\\", "/").split("/") if part])
        target = local_root / relative
        try:
            target.resolve().relative_to(local_root.resolve())
        except Exception as exc:
            raise ValueError(f"Official project SavedCar path is unsafe: {relative_text}") from exc
        if not target.is_file():
            raise FileNotFoundError(f"Update backup blocked: Official project SavedCar is missing: {target}")


def create_update_backup(target_zip: str | Path, roots: BackupRoots, app_version: str) -> dict[str, Any]:
    _validate_official_project_saved_cars(roots)
    target = Path(target_zip).expanduser()
    if target.suffix.lower() != ".zip":
        target = target.with_suffix(".zip")
    target.parent.mkdir(parents=True, exist_ok=True)

    report: dict[str, Any] = {
        "includedFiles": 0,
        "includedBytes": 0,
        "skippedProjectFiles": 0,
        "skippedProjectBytes": 0,
        "skippedSamples": [],
        "includedRoots": [],
    }
    manifest: dict[str, Any] = {
        "type": BACKUP_TYPE,
        "backupVersion": BACKUP_VERSION,
        "appVersion": app_version,
        "createdAt": datetime.now().isoformat(timespec="seconds"),
        "includes": [],
        "excludes": [
            "LiveryLab_Trash",
            "_acevo_backups",
            "_cache",
            "PreviewCache",
            "_internal",
            "LiveryLab.exe",
            "WebView runtime cache",
            "editable project source files such as PSD, PSB, Krita and Affinity files",
        ],
        "projectFilePolicy": "Only known LiveryLab-managed project files are included. User work files must be backed up separately.",
    }

    with zipfile.ZipFile(target, "w", compression=zipfile.ZIP_DEFLATED) as zf:
        settings_path = roots.user_data / SETTINGS_NAME
        if settings_path.exists():
            _add_file(zf, settings_path, f"{USER_DATA_ROOT_NAME}/{SETTINGS_NAME}", report)
            manifest["includes"].append(f"{USER_DATA_ROOT_NAME}/{SETTINGS_NAME}")

        if roots.livery_library.exists():
            for child_name in sorted(LIBRARY_INCLUDE_NAMES):
                child = roots.livery_library / child_name
                if not child.exists():
                    continue
                manifest["includes"].append(f"{LIBRARY_ROOT_NAME}/{child_name}")
                for file_path in _iter_files(child):
                    _add_file(zf, file_path, _archive_name(LIBRARY_ROOT_NAME, file_path, roots.livery_library), report)

        if roots.livery_projects.exists():
            project_included = False
            for file_path in _iter_files(roots.livery_projects):
                if _project_file_allowed(file_path):
                    _add_file(zf, file_path, _archive_name(PROJECT_ROOT_NAME, file_path, roots.livery_projects), report)
                    project_included = True
                    continue
                report["skippedProjectFiles"] += 1
                try:
                    size = file_path.stat().st_size
                except OSError:
                    size = 0
                report["skippedProjectBytes"] += size
                if len(report["skippedSamples"]) < 25:
                    report["skippedSamples"].append(
                        {
                            "path": str(file_path.relative_to(roots.livery_projects)),
                            "size": size,
                            "reason": _project_file_skip_reason(file_path),
                        }
                    )
            if project_included:
                manifest["includes"].append(PROJECT_ROOT_NAME)

        if roots.official_livery_library.exists():
            manifest["includes"].append(OFFICIAL_LIBRARY_ROOT_NAME)
            for file_path in _iter_files(roots.official_livery_library):
                _add_file(zf, file_path, _archive_name(OFFICIAL_LIBRARY_ROOT_NAME, file_path, roots.official_livery_library), report)

        if roots.official_livery_projects.exists():
            official_project_included = False
            for file_path in _iter_files(roots.official_livery_projects):
                if _official_project_file_allowed(file_path):
                    _add_file(zf, file_path, _archive_name(OFFICIAL_PROJECT_ROOT_NAME, file_path, roots.official_livery_projects), report)
                    official_project_included = True
                    continue
                report["skippedProjectFiles"] += 1
                try:
                    report["skippedProjectBytes"] += file_path.stat().st_size
                except OSError:
                    pass
            if official_project_included:
                manifest["includes"].append(OFFICIAL_PROJECT_ROOT_NAME)

        manifest["report"] = report
        zf.writestr(MANIFEST_NAME, json.dumps(_json_ready(manifest), indent=2, ensure_ascii=False))

    return {
        "path": str(target),
        "folder": str(target.parent),
        "manifest": manifest,
        **report,
    }


def _safe_member_parts(name: str) -> tuple[str, ...] | None:
    pure = PurePosixPath(str(name).replace("\\", "/"))
    if pure.is_absolute():
        return None
    parts = tuple(part for part in pure.parts if part not in {"", "."})
    if not parts or any(part == ".." for part in parts):
        return None
    if parts[0] not in SUPPORTED_IMPORT_ROOTS:
        return None
    return parts


def _read_manifest(zf: zipfile.ZipFile) -> dict[str, Any]:
    try:
        data = json.loads(zf.read(MANIFEST_NAME).decode("utf-8"))
    except KeyError as exc:
        raise ValueError("Update backup manifest is missing") from exc
    except Exception as exc:
        raise ValueError("Update backup manifest could not be read") from exc
    if not isinstance(data, dict) or data.get("type") != BACKUP_TYPE:
        raise ValueError("Selected ZIP is not a LiveryLab update backup")
    return data


def inspect_update_backup(path: str | Path) -> dict[str, Any]:
    archive = Path(path).expanduser()
    if not archive.is_file():
        raise FileNotFoundError(f"Update backup not found: {archive}")
    with zipfile.ZipFile(archive, "r") as zf:
        package_security.validate_zip_archive(zf, required_manifest=MANIFEST_NAME)
        package_security.validate_archive_top_level_structure(
            zf,
            allowed_top_level=SUPPORTED_IMPORT_ROOTS | {MANIFEST_NAME},
            required_files={MANIFEST_NAME},
            label="Update backup",
        )
        manifest = _read_manifest(zf)
        files = [info for info in zf.infolist() if not info.is_dir() and info.filename != MANIFEST_NAME]
        roots = sorted({PurePosixPath(info.filename).parts[0] for info in files if PurePosixPath(info.filename).parts})
        return {
            "path": str(archive),
            "manifest": manifest,
            "fileCount": len(files),
            "totalBytes": sum(int(info.file_size or 0) for info in files),
            "roots": roots,
        }


def _target_for_parts(parts: tuple[str, ...], roots: BackupRoots) -> Path:
    root_name = parts[0]
    if root_name == USER_DATA_ROOT_NAME:
        base = roots.user_data
    elif root_name == LIBRARY_ROOT_NAME:
        base = roots.livery_library
    elif root_name == PROJECT_ROOT_NAME:
        base = roots.livery_projects
    elif root_name == OFFICIAL_LIBRARY_ROOT_NAME:
        base = roots.official_livery_library
    elif root_name == OFFICIAL_PROJECT_ROOT_NAME:
        base = roots.official_livery_projects
    else:
        raise ValueError(f"Unsupported backup root: {root_name}")
    return package_security.assert_target_within_roots(
        base.joinpath(*parts[1:]),
        (roots.user_data, roots.livery_library, roots.livery_projects, roots.official_livery_library, roots.official_livery_projects),
        label="update backup import target",
    )


def _member_allowed(parts: tuple[str, ...]) -> bool:
    if any(part.lower() in EXCLUDED_DIR_NAMES for part in parts):
        return False
    if parts[0] == USER_DATA_ROOT_NAME:
        return len(parts) == 2 and parts[1] == SETTINGS_NAME
    if parts[0] == LIBRARY_ROOT_NAME:
        return len(parts) >= 2 and parts[1] in LIBRARY_INCLUDE_NAMES
    if parts[0] == PROJECT_ROOT_NAME:
        return _project_file_allowed(Path(*parts[1:]))
    if parts[0] == OFFICIAL_LIBRARY_ROOT_NAME:
        return len(parts) >= 2
    if parts[0] == OFFICIAL_PROJECT_ROOT_NAME:
        return len(parts) >= 2 and _official_project_file_allowed(Path(*parts[1:]))
    return False


def _portable_root_targets(roots: BackupRoots) -> dict[str, Path]:
    return {
        USER_DATA_ROOT_NAME.lower(): roots.user_data,
        LIBRARY_ROOT_NAME.lower(): roots.livery_library,
        PROJECT_ROOT_NAME.lower(): roots.livery_projects,
        OFFICIAL_LIBRARY_ROOT_NAME.lower(): roots.official_livery_library,
        OFFICIAL_PROJECT_ROOT_NAME.lower(): roots.official_livery_projects,
    }


def _rebase_portable_managed_paths(value: Any, roots: BackupRoots) -> Any:
    if isinstance(value, dict):
        return {str(key): _rebase_portable_managed_paths(item, roots) for key, item in value.items()}
    if isinstance(value, list):
        return [_rebase_portable_managed_paths(item, roots) for item in value]
    if not isinstance(value, str):
        return value
    portable = export_sanitize.portable_managed_path(value)
    normalized = portable.replace("\\", "/")
    parts = [part for part in normalized.split("/") if part]
    if not parts:
        return value
    target_root = _portable_root_targets(roots).get(parts[0].lower())
    if target_root is None:
        return value
    return str(target_root.joinpath(*parts[1:]))


def _import_member_bytes(zf: zipfile.ZipFile, info: zipfile.ZipInfo, target: Path, roots: BackupRoots) -> int:
    raw = zf.read(info)
    if target.suffix.lower() != ".json":
        target.write_bytes(raw)
        return len(raw)
    try:
        data = json.loads(raw.decode("utf-8-sig"))
    except Exception:
        target.write_bytes(raw)
        return len(raw)
    rebased = _rebase_portable_managed_paths(data, roots)
    encoded = json.dumps(_json_ready(rebased), indent=2, ensure_ascii=False).encode("utf-8")
    target.write_bytes(encoded)
    return len(encoded)


def _copy_to_backup(target: Path, backup_root: Path, roots: BackupRoots) -> None:
    if not target.exists():
        return
    for root_name, base in (
        (USER_DATA_ROOT_NAME, roots.user_data),
        (LIBRARY_ROOT_NAME, roots.livery_library),
        (PROJECT_ROOT_NAME, roots.livery_projects),
        (OFFICIAL_LIBRARY_ROOT_NAME, roots.official_livery_library),
        (OFFICIAL_PROJECT_ROOT_NAME, roots.official_livery_projects),
    ):
        try:
            rel = target.relative_to(base)
        except ValueError:
            continue
        destination = backup_root / root_name / rel
        destination.parent.mkdir(parents=True, exist_ok=True)
        if target.is_dir():
            if destination.exists():
                return
            shutil.copytree(target, destination)
        else:
            shutil.copy2(target, destination)
        return


def _remove_target(target: Path) -> None:
    if not target.exists():
        return
    if target.is_dir():
        shutil.rmtree(target)
    else:
        target.unlink()


def import_update_backup(path: str | Path, roots: BackupRoots) -> dict[str, Any]:
    archive = Path(path).expanduser()
    if not archive.is_file():
        raise FileNotFoundError(f"Update backup not found: {archive}")

    backup_root = roots.user_data / "update_import_backups" / _now_stamp()
    report: dict[str, Any] = {
        "importedFiles": 0,
        "importedBytes": 0,
        "skippedFiles": 0,
        "skippedSamples": [],
        "backupFolder": str(backup_root),
    }
    reset_targets: set[Path] = set()
    project_file_targets: set[Path] = set()

    with zipfile.ZipFile(archive, "r") as zf:
        package_security.validate_zip_archive(zf, required_manifest=MANIFEST_NAME)
        package_security.validate_archive_top_level_structure(
            zf,
            allowed_top_level=SUPPORTED_IMPORT_ROOTS | {MANIFEST_NAME},
            required_files={MANIFEST_NAME},
            label="Update backup",
        )
        manifest = _read_manifest(zf)
        members: list[tuple[zipfile.ZipInfo, tuple[str, ...], Path]] = []
        for info in zf.infolist():
            if info.is_dir() or info.filename == MANIFEST_NAME:
                continue
            parts = _safe_member_parts(info.filename)
            if parts is None or not _member_allowed(parts):
                report["skippedFiles"] += 1
                if len(report["skippedSamples"]) < 25:
                    report["skippedSamples"].append({"path": info.filename, "reason": "unsupported or unsafe backup entry"})
                continue
            target = _target_for_parts(parts, roots)
            members.append((info, parts, target))
            if parts[0] == USER_DATA_ROOT_NAME:
                reset_targets.add(target)
            elif parts[0] == LIBRARY_ROOT_NAME and len(parts) >= 2:
                reset_targets.add(roots.livery_library / parts[1])
            elif parts[0] == PROJECT_ROOT_NAME:
                project_file_targets.add(target)
            elif parts[0] == OFFICIAL_LIBRARY_ROOT_NAME:
                reset_targets.add(roots.official_livery_library)
            elif parts[0] == OFFICIAL_PROJECT_ROOT_NAME:
                project_file_targets.add(target)

        for target in sorted(reset_targets, key=lambda item: len(item.parts), reverse=True):
            _copy_to_backup(target, backup_root, roots)
            _remove_target(target)

        for target in sorted(project_file_targets):
            _copy_to_backup(target, backup_root, roots)

        for info, _parts, target in members:
            target.parent.mkdir(parents=True, exist_ok=True)
            imported_size = _import_member_bytes(zf, info, target, roots)
            report["importedFiles"] += 1
            report["importedBytes"] += imported_size

    if not backup_root.exists():
        backup_root.mkdir(parents=True, exist_ok=True)
    (backup_root / "import_manifest.json").write_text(
        json.dumps({"source": str(archive), "manifest": manifest, "report": report}, indent=2, ensure_ascii=False),
        encoding="utf-8",
    )

    return {
        "path": str(archive),
        "folder": str(archive.parent),
        "manifest": manifest,
        **report,
    }
