from __future__ import annotations

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

from liverylab_core import (
    advanced_content_setup,
    game_launcher,
    official_external_liveries,
    official_saved_cars,
    package_state,
    project_capabilities,
    runtime_profiles,
    saved_games_paths,
    workflow_scopes,
)


TARGET_OFFICIAL = runtime_profiles.RUNTIME_OFFICIAL
TARGET_ADVANCED = runtime_profiles.RUNTIME_ADVANCED
KNOWN_TARGETS = {TARGET_OFFICIAL, TARGET_ADVANCED}
RUNTIME_PROFILES_DIRNAME = "RuntimeProfiles"
SAVEDCARS_DIRNAME = "savedcars"
THUMBNAILS_DIRNAME = "thumbnails"
SWITCH_HISTORY_DIRNAME = "switch-history"
MANIFEST_FILENAME = "manifest.json"
MANIFEST_FORMAT_VERSION = 1


def _clean_target(value: Any) -> str:
    target = str(value or "").strip().lower().replace("-", "_").replace(" ", "_")
    if target in {"official", "official_external", "official_runtime"}:
        return TARGET_OFFICIAL
    if target in {"advanced", "legacy", "advanced_extracted", "advanced_runtime"}:
        return TARGET_ADVANCED
    raise ValueError("Runtime switch target must be official or advanced")


def _path_key(value: Any) -> str:
    return str(value or "").strip().replace("\\", "/").rstrip("/").casefold()


def package_update_conflict_guidance(
    package: dict[str, Any],
    advanced_setup: dict[str, Any] | None = None,
) -> dict[str, Any]:
    detected = bool(
        package.get("mixedPackageState")
        or (package.get("activeExists") and package.get("disabledExists"))
    )
    result = {
        "detected": detected,
        "manualResolutionSupported": False,
        "obsoleteDisabledPath": "",
        "activePath": str(package.get("activePath") or ""),
        "managedByLiveryLab": False,
        "matchesLastExtraction": False,
        "reason": "",
    }
    if not detected:
        return result

    disabled = [dict(row) for row in package.get("disabledPackages") or [] if isinstance(row, dict)]
    if len(disabled) != 1:
        result["reason"] = "The obsolete package cannot be identified because more than one renamed package is present."
        return result

    disabled_row = disabled[0]
    disabled_path = str(disabled_row.get("path") or "").strip()
    manifest = package.get("manifest") if isinstance(package.get("manifest"), dict) else {}
    managed_by_liverylab = bool(
        manifest.get("action") == "disabled_content_package"
        and not manifest.get("restoredAt")
        and _path_key(manifest.get("disabledPath")) == _path_key(disabled_path)
    )

    setup = advanced_setup if isinstance(advanced_setup, dict) else {}
    setup_manifest = setup.get("manifest") if isinstance(setup.get("manifest"), dict) else {}
    recorded = setup_manifest.get("package") if isinstance(setup_manifest.get("package"), dict) else {}
    matches_last_extraction = bool(
        recorded
        and int(recorded.get("size") or -1) == int(disabled_row.get("size") or -2)
        and int(recorded.get("mtimeNs") or -1) == int(disabled_row.get("mtimeNs") or -2)
    )

    result.update(
        {
            "manualResolutionSupported": bool(disabled_path and (managed_by_liverylab or matches_last_extraction)),
            "obsoleteDisabledPath": disabled_path if managed_by_liverylab or matches_last_extraction else "",
            "activePath": str((package.get("activePackage") or {}).get("path") or package.get("activePath") or ""),
            "managedByLiveryLab": managed_by_liverylab,
            "matchesLastExtraction": matches_last_extraction,
            "activePackage": dict(package.get("activePackage") or {}),
            "obsoleteDisabledPackage": disabled_row,
            "reason": (
                "The single renamed package is the package previously disabled by LiveryLab."
                if managed_by_liverylab or matches_last_extraction
                else "LiveryLab could not prove which package is obsolete. Resolve the files manually before continuing."
            ),
        }
    )
    return result


def _runtime_profiles_root(user_data_root: str | Path) -> Path:
    return Path(user_data_root).expanduser() / RUNTIME_PROFILES_DIRNAME


def vault_paths(user_data_root: str | Path) -> dict[str, Any]:
    root = _runtime_profiles_root(user_data_root)
    return {
        "root": str(root),
        "official": {
            "root": str(root / TARGET_OFFICIAL),
            "savedCars": str(root / TARGET_OFFICIAL / SAVEDCARS_DIRNAME),
            "thumbnails": str(root / TARGET_OFFICIAL / THUMBNAILS_DIRNAME),
            "manifest": str(root / TARGET_OFFICIAL / MANIFEST_FILENAME),
        },
        "advanced": {
            "root": str(root / TARGET_ADVANCED),
            "savedCars": str(root / TARGET_ADVANCED / SAVEDCARS_DIRNAME),
            "thumbnails": str(root / TARGET_ADVANCED / THUMBNAILS_DIRNAME),
            "manifest": str(root / TARGET_ADVANCED / MANIFEST_FILENAME),
        },
        "switchHistory": str(root / SWITCH_HISTORY_DIRNAME),
    }


def empty_manifest(owner: str) -> dict[str, Any]:
    clean_owner = _clean_target(owner)
    return {
        "formatVersion": MANIFEST_FORMAT_VERSION,
        "ownerRuntime": clean_owner,
        "items": [],
        "updatedAt": "",
        "notes": [
            "Managed by LiveryLab runtime switching.",
            "SavedCars are stored outside AC EVO folders and restored only to their recorded original paths.",
        ],
    }


def _read_manifest(path: Path) -> dict[str, Any]:
    if not path.exists() or not path.is_file():
        return {}
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except Exception:
        return {}
    return data if isinstance(data, dict) else {}


def _write_json_atomic(path: Path, data: dict[str, Any]) -> Path:
    path.parent.mkdir(parents=True, exist_ok=True)
    temp = path.with_name(f"{path.name}.tmp")
    temp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
    temp.replace(path)
    return path


def _file_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 _file_record(path: Path) -> dict[str, Any]:
    try:
        stat = path.stat()
        return {
            "path": str(path),
            "exists": True,
            "size": int(stat.st_size),
            "modifiedAt": datetime.fromtimestamp(stat.st_mtime).isoformat(timespec="seconds"),
            "sha256": _file_sha256(path) if path.is_file() else "",
        }
    except Exception:
        return {"path": str(path), "exists": False, "size": 0, "modifiedAt": "", "sha256": ""}


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


def game_running_status() -> dict[str, Any]:
    running = game_launcher.is_game_running()
    return {
        "running": running,
        "processName": game_launcher.GAME_EXE_NAME,
        "blocker": "Assetto Corsa EVO is running. Close the game before switching runtime profiles." if running else "",
    }


def require_game_closed() -> dict[str, Any]:
    status = game_running_status()
    if status.get("running"):
        raise RuntimeError(status["blocker"])
    return status


def _stored_manifest_items(user_data_root: str | Path, owner: str) -> list[dict[str, Any]]:
    manifest = _load_owner_manifest(user_data_root, owner)
    items = manifest.get("items")
    if isinstance(items, list):
        return [dict(item) for item in items if isinstance(item, dict)]
    return []


def _safe_segment(value: Any, fallback: str = "item") -> str:
    raw = str(value or "").strip()
    cleaned = "".join(ch if ch.isalnum() or ch in {"-", "_", "."} else "_" for ch in raw).strip("._")
    return cleaned[:96] or fallback


def _profile_key_from_saved_car_row(row: dict[str, Any]) -> str:
    root = Path(str(row.get("root") or "SavedCars")).expanduser()
    profile = root.parent.parent
    profile_name = _safe_segment(profile.name, "profile")[:24]
    digest = hashlib.sha1(str(profile).strip().lower().encode("utf-8", errors="replace")).hexdigest()[:8]
    return f"{profile_name}-{digest}"


def _saved_car_store_name(row: dict[str, Any]) -> str:
    profile_key = _profile_key_from_saved_car_row(row)
    guid = official_saved_cars.normalize_guid(row.get("carGuid") or row.get("guid") or row.get("filenameGuid") or row.get("filename") or row.get("path") or "")
    if guid:
        return f"{profile_key}__{guid}{official_external_liveries.SAVED_CAR_SUFFIX}"
    filename = _safe_segment(row.get("filename") or Path(str(row.get("path") or "savedcar")).name, "savedcar")
    return f"{profile_key}__{filename[:64]}"


def _thumbnail_store_name(row: dict[str, Any], store_name: str, thumbnail: Path) -> str:
    guid = official_saved_cars.normalize_guid(row.get("carGuid") or row.get("guid") or row.get("filenameGuid") or row.get("filename") or row.get("path") or "")
    suffix = "".join(thumbnail.suffixes) or thumbnail.suffix or ".thumb"
    if guid:
        return f"{_profile_key_from_saved_car_row(row)}__{guid}{suffix}"
    return f"{Path(store_name).stem[:80]}__{_safe_segment(thumbnail.name, 'thumbnail')[:40]}"


def saved_car_storage_plan(row: dict[str, Any], owner: str, user_data_root: str | Path) -> dict[str, Any]:
    clean_owner = _clean_target(owner)
    paths = vault_paths(user_data_root)[clean_owner]
    store_name = _saved_car_store_name(row)
    stored_path = Path(str(paths["savedCars"])) / store_name
    thumbnail_originals = _saved_car_thumbnail_paths(row)
    thumbnail_files = [
        {
            "originalPath": str(thumbnail),
            "storedPath": str(Path(str(paths["thumbnails"])) / _thumbnail_store_name(row, store_name, thumbnail)),
            "name": thumbnail.name,
            "suffix": "".join(thumbnail.suffixes) or thumbnail.suffix,
        }
        for thumbnail in thumbnail_originals
    ]
    first_thumbnail = thumbnail_files[0] if thumbnail_files else {}
    return {
        "ownerRuntime": clean_owner,
        "carGuid": row.get("carGuid") or row.get("guid") or "",
        "carModel": row.get("carModel") or "",
        "filename": row.get("filename") or "",
        "originalPath": row.get("path") or "",
        "originalRoot": row.get("root") or "",
        "storedPath": str(stored_path),
        "thumbnailOriginalPath": first_thumbnail.get("originalPath") or "",
        "thumbnailStoredPath": first_thumbnail.get("storedPath") or "",
        "thumbnailFiles": thumbnail_files,
        "restoreEligible": bool(row.get("path")),
        "reason": row.get("runtimeOwnerReason") or "",
        "markers": row.get("markers") or [],
        "officialMatches": row.get("officialMatches") or [],
        "advancedMatches": row.get("advancedMatches") or [],
    }


def _saved_games_ace_root_from_saved_car(row: dict[str, Any]) -> Path:
    root = Path(str(row.get("root") or row.get("path") or "")).expanduser()
    for parent in [root, *root.parents]:
        if parent.name.lower() == "ace" and parent.parent.name.lower() == "saved games":
            return parent
    return saved_games_paths.ace_root()


def _thumbnail_candidates_for_guid(ace_root: Path, guid: str) -> list[Path]:
    clean_guid = str(guid or "").strip().lower()
    if not clean_guid:
        return []
    root = ace_root / "SavedCars" / "Thumbnails"
    return [
        root / f"{clean_guid}.texture",
        root / f"{clean_guid}.texturemips",
        root / f"{clean_guid}.png",
    ]


def _saved_car_thumbnail_paths(row: dict[str, Any]) -> list[Path]:
    ace_root = _saved_games_ace_root_from_saved_car(row)
    guid = official_saved_cars.normalize_guid(row.get("carGuid") or row.get("guid") or row.get("filenameGuid") or "")
    return [candidate for candidate in _thumbnail_candidates_for_guid(ace_root, guid) if candidate.exists() and candidate.is_file()]


def _thumbnail_file_plans(data: dict[str, Any]) -> list[dict[str, str]]:
    rows = data.get("thumbnailFiles") if isinstance(data.get("thumbnailFiles"), list) else []
    plans = [
        {
            "originalPath": str(row.get("originalPath") or ""),
            "storedPath": str(row.get("storedPath") or ""),
            "name": str(row.get("name") or ""),
            "suffix": str(row.get("suffix") or ""),
        }
        for row in rows
        if isinstance(row, dict) and str(row.get("originalPath") or "").strip() and str(row.get("storedPath") or "").strip()
    ]
    if plans:
        return plans
    original = str(data.get("thumbnailOriginalPath") or "").strip()
    stored = str(data.get("thumbnailStoredPath") or "").strip()
    if not original or not stored:
        return []
    return [{
        "originalPath": original,
        "storedPath": stored,
        "name": Path(original).name,
        "suffix": "".join(Path(original).suffixes) or Path(original).suffix,
    }]


def _manifest_item_status(item: dict[str, Any]) -> dict[str, Any]:
    stored_path = Path(str(item.get("storedPath") or ""))
    original_path = Path(str(item.get("originalPath") or ""))
    return {
        **item,
        "storedExists": stored_path.exists() and stored_path.is_file(),
        "originalExists": original_path.exists() and original_path.is_file(),
        "restoreEligible": bool(item.get("originalPath")) and stored_path.exists() and stored_path.is_file() and not original_path.exists(),
    }


def _runtime_vault_relative_suffix(path: Path, owner: str, kind: str) -> tuple[str, ...] | None:
    parts = list(path.parts)
    lower = [part.lower() for part in parts]
    expected = [RUNTIME_PROFILES_DIRNAME.lower(), owner.lower(), kind.lower()]
    for index in range(0, len(parts) - len(expected) + 1):
        if lower[index : index + len(expected)] == expected:
            suffix = tuple(parts[index + len(expected) :])
            return suffix or None
    return None


def _rebase_runtime_vault_file(
    raw_path: Any,
    *,
    current_root: Path,
    owner: str,
    kind: str,
    record: dict[str, Any] | None,
) -> Path | None:
    text = str(raw_path or "").strip()
    if not text:
        return None
    path = Path(text).expanduser()
    if _path_is_within(path, current_root):
        return path
    expected_hash = str((record or {}).get("sha256") or "").strip().lower()
    suffix = _runtime_vault_relative_suffix(path, owner, kind)
    if not suffix or not expected_hash:
        return None
    candidate = current_root.joinpath(*suffix)
    if not _path_is_within(candidate, current_root) or not candidate.is_file():
        return None
    if _file_sha256(candidate).lower() != expected_hash:
        return None
    return candidate


def _record_for_stored_path(records: list[dict[str, Any]], path: Any) -> dict[str, Any]:
    key = str(path or "").strip().lower()
    return next(
        (
            record
            for record in records
            if isinstance(record, dict) and str(record.get("path") or "").strip().lower() == key
        ),
        {},
    )


def rebase_runtime_manifest(manifest: dict[str, Any], user_data_root: str | Path, owner: str) -> dict[str, Any]:
    clean_owner = _clean_target(owner)
    paths = vault_paths(user_data_root)[clean_owner]
    saved_cars_root = Path(str(paths["savedCars"])).resolve()
    thumbnails_root = Path(str(paths["thumbnails"])).resolve()
    next_manifest = dict(manifest) if isinstance(manifest, dict) else empty_manifest(clean_owner)
    next_items: list[dict[str, Any]] = []
    for raw_item in next_manifest.get("items") or []:
        if not isinstance(raw_item, dict):
            continue
        item = dict(raw_item)
        stored_record = dict(item.get("storedRecord")) if isinstance(item.get("storedRecord"), dict) else {}
        rebased_saved_car = _rebase_runtime_vault_file(
            item.get("storedPath"),
            current_root=saved_cars_root,
            owner=clean_owner,
            kind=SAVEDCARS_DIRNAME,
            record=stored_record,
        )
        if rebased_saved_car is not None:
            item["storedPath"] = str(rebased_saved_car)
            if stored_record:
                stored_record["path"] = str(rebased_saved_car)
                item["storedRecord"] = stored_record

        thumbnail_records = [
            dict(record)
            for record in item.get("thumbnailStoredRecords") or []
            if isinstance(record, dict)
        ]
        legacy_thumbnail_record = (
            dict(item.get("thumbnailStoredRecord"))
            if isinstance(item.get("thumbnailStoredRecord"), dict)
            else {}
        )
        raw_thumbnail_files = item.get("thumbnailFiles") if isinstance(item.get("thumbnailFiles"), list) else []
        if not raw_thumbnail_files:
            legacy_original = str(item.get("thumbnailOriginalPath") or "").strip()
            legacy_stored = str(item.get("thumbnailStoredPath") or "").strip()
            if legacy_original and legacy_stored:
                raw_thumbnail_files = [{
                    "originalPath": legacy_original,
                    "storedPath": legacy_stored,
                    "name": Path(legacy_original).name,
                    "suffix": "".join(Path(legacy_original).suffixes) or Path(legacy_original).suffix,
                }]
        thumbnail_files: list[dict[str, Any]] = []
        for raw_thumbnail in raw_thumbnail_files:
            if not isinstance(raw_thumbnail, dict):
                continue
            thumbnail = dict(raw_thumbnail)
            old_stored_path = str(thumbnail.get("storedPath") or "")
            record = _record_for_stored_path(thumbnail_records, old_stored_path) or legacy_thumbnail_record
            rebased_thumbnail = _rebase_runtime_vault_file(
                old_stored_path,
                current_root=thumbnails_root,
                owner=clean_owner,
                kind=THUMBNAILS_DIRNAME,
                record=record,
            )
            if rebased_thumbnail is not None:
                thumbnail["storedPath"] = str(rebased_thumbnail)
                for stored_record_row in thumbnail_records:
                    if str(stored_record_row.get("path") or "").strip().lower() == old_stored_path.strip().lower():
                        stored_record_row["path"] = str(rebased_thumbnail)
                if str(legacy_thumbnail_record.get("path") or "").strip().lower() == old_stored_path.strip().lower():
                    legacy_thumbnail_record["path"] = str(rebased_thumbnail)
            thumbnail_files.append(thumbnail)
        item["thumbnailFiles"] = thumbnail_files
        if thumbnail_files:
            item["thumbnailStoredPath"] = str(thumbnail_files[0].get("storedPath") or "")
        if thumbnail_records:
            item["thumbnailStoredRecords"] = thumbnail_records
        if legacy_thumbnail_record:
            item["thumbnailStoredRecord"] = legacy_thumbnail_record
        next_items.append(item)
    next_manifest["items"] = next_items
    return next_manifest


def _manifest_path_for_owner(user_data_root: str | Path, owner: str) -> Path:
    paths = vault_paths(user_data_root)[_clean_target(owner)]
    return Path(str(paths["manifest"]))


def _load_owner_manifest(user_data_root: str | Path, owner: str) -> dict[str, Any]:
    clean_owner = _clean_target(owner)
    manifest_path = _manifest_path_for_owner(user_data_root, clean_owner)
    manifest = _read_manifest(manifest_path)
    if not manifest:
        return empty_manifest(clean_owner)
    manifest.setdefault("formatVersion", MANIFEST_FORMAT_VERSION)
    manifest.setdefault("ownerRuntime", clean_owner)
    manifest.setdefault("items", [])
    return rebase_runtime_manifest(manifest, user_data_root, clean_owner)


def _save_owner_manifest(user_data_root: str | Path, owner: str, manifest: dict[str, Any]) -> Path:
    clean_owner = _clean_target(owner)
    next_manifest = dict(manifest)
    next_manifest["formatVersion"] = MANIFEST_FORMAT_VERSION
    next_manifest["ownerRuntime"] = clean_owner
    next_manifest["updatedAt"] = datetime.now().isoformat(timespec="seconds")
    items = next_manifest.get("items")
    next_manifest["items"] = items if isinstance(items, list) else []
    return _write_json_atomic(_manifest_path_for_owner(user_data_root, clean_owner), next_manifest)


def _write_switch_history(user_data_root: str | Path, history: dict[str, Any]) -> Path:
    paths = vault_paths(user_data_root)
    history_root = Path(str(paths["switchHistory"]))
    stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
    target = history_root / f"{stamp}__{history.get('targetRuntime') or 'runtime'}__{'dry-run' if history.get('dryRun') else 'apply'}.json"
    index = 2
    while target.exists():
        target = history_root / f"{stamp}-{index}__{history.get('targetRuntime') or 'runtime'}__{'dry-run' if history.get('dryRun') else 'apply'}.json"
        index += 1
    payload = {
        "formatVersion": MANIFEST_FORMAT_VERSION,
        "createdAt": datetime.now().isoformat(timespec="seconds"),
        **history,
    }
    return _write_json_atomic(target, payload)


def _upsert_manifest_item(manifest: dict[str, Any], item: dict[str, Any]) -> dict[str, Any]:
    items = [dict(row) for row in manifest.get("items") or [] if isinstance(row, dict)]
    original_key = str(item.get("originalPath") or "").strip().lower()
    stored_key = str(item.get("storedPath") or "").strip().lower()
    replaced = False
    next_items: list[dict[str, Any]] = []
    for row in items:
        row_original = str(row.get("originalPath") or "").strip().lower()
        row_stored = str(row.get("storedPath") or "").strip().lower()
        if (original_key and row_original == original_key) or (stored_key and row_stored == stored_key):
            next_items.append({**row, **item})
            replaced = True
        else:
            next_items.append(row)
    if not replaced:
        next_items.append(item)
    next_manifest = dict(manifest)
    next_manifest["items"] = next_items
    return next_manifest


def _assert_saved_car_source(path: Path, original_root: Path) -> None:
    if not path.exists() or not path.is_file():
        raise FileNotFoundError(f"SavedCar file not found: {path}")
    if path.suffix.lower() != official_external_liveries.SAVED_CAR_SUFFIX:
        raise ValueError(f"Refusing to store non-SavedCar file: {path}")
    if not original_root.exists() or not original_root.is_dir():
        raise FileNotFoundError(f"SavedCars root not found: {original_root}")
    if not _path_is_within(path, original_root):
        raise ValueError(f"Refusing to store SavedCar outside its scanned SavedCars root: {path}")


def _assert_vault_target(path: Path, user_data_root: str | Path, owner: str, kind: str) -> None:
    profile_paths = vault_paths(user_data_root)[_clean_target(owner)]
    parent = Path(str(profile_paths[kind]))
    if not _path_is_within(path, parent):
        raise ValueError(f"Refusing to write runtime profile file outside LiveryLab vault: {path}")


def _assert_restore_target(path: Path, original_root: Path) -> None:
    if path.exists():
        raise FileExistsError(f"Restore target already exists: {path}")
    if not original_root.exists() or not original_root.is_dir():
        raise FileNotFoundError(f"SavedCars root not found: {original_root}")
    if not _path_is_within(path, original_root):
        raise ValueError(f"Refusing to restore SavedCar outside original SavedCars root: {path}")


def _validate_record_hash(path: Path, record: dict[str, Any] | None, label: str) -> None:
    expected = str((record or {}).get("sha256") or "").strip().lower()
    if not expected:
        return
    actual = _file_sha256(path).lower()
    if actual != expected:
        raise RuntimeError(f"{label} hash mismatch: {path}")


def _thumbnail_record_for_plan(item: dict[str, Any], plan: dict[str, str]) -> dict[str, Any]:
    records = item.get("thumbnailStoredRecords") if isinstance(item.get("thumbnailStoredRecords"), list) else []
    stored_key = str(plan.get("storedPath") or "").strip().lower()
    for record in records:
        if isinstance(record, dict) and str(record.get("path") or "").strip().lower() == stored_key:
            return record
    legacy = item.get("thumbnailStoredRecord") if isinstance(item.get("thumbnailStoredRecord"), dict) else {}
    if str(legacy.get("path") or "").strip().lower() == stored_key:
        return legacy
    return {}


def _rollback_moves(moves: list[dict[str, Any]]) -> list[str]:
    errors: list[str] = []
    for move in reversed(moves):
        source = Path(str(move.get("source") or "")).expanduser()
        target = Path(str(move.get("target") or "")).expanduser()
        try:
            if target.exists() and not source.exists():
                source.parent.mkdir(parents=True, exist_ok=True)
                shutil.move(str(target), str(source))
            elif target.exists() and source.exists():
                errors.append(f"Rollback conflict: both paths exist: {source} | {target}")
            elif not source.exists():
                errors.append(f"Rollback source and target are missing: {source} | {target}")
        except Exception as exc:
            errors.append(f"Rollback failed for {target} -> {source}: {exc}")
    return errors


def _path_snapshot(path: Path) -> dict[str, Any]:
    return {"path": str(path), "exists": path.is_file(), "data": path.read_bytes() if path.is_file() else b""}


def _restore_path_snapshot(snapshot: dict[str, Any]) -> None:
    path = Path(str(snapshot.get("path") or "")).expanduser()
    if snapshot.get("exists"):
        path.parent.mkdir(parents=True, exist_ok=True)
        temp = path.with_name(path.name + ".rollback-tmp")
        temp.write_bytes(bytes(snapshot.get("data") or b""))
        temp.replace(path)
    elif path.exists() and path.is_file():
        path.unlink()


def store_saved_car(row: dict[str, Any], owner: str, user_data_root: str | Path, *, dry_run: bool = False) -> dict[str, Any]:
    require_game_closed()
    clean_owner = _clean_target(owner)
    plan = saved_car_storage_plan(row, clean_owner, user_data_root)
    source = Path(str(plan.get("originalPath") or "")).expanduser()
    original_root = Path(str(plan.get("originalRoot") or source.parent)).expanduser()
    target = Path(str(plan.get("storedPath") or "")).expanduser()
    _assert_saved_car_source(source, original_root)
    _assert_vault_target(target, user_data_root, clean_owner, "savedCars")

    thumbnail_plans = _thumbnail_file_plans(plan)
    for thumbnail in thumbnail_plans:
        thumb_source = Path(thumbnail["originalPath"]).expanduser()
        thumb_target = Path(thumbnail["storedPath"]).expanduser()
        if not thumb_source.exists() or not thumb_source.is_file():
            raise FileNotFoundError(f"SavedCar thumbnail file not found: {thumb_source}")
        _assert_vault_target(thumb_target, user_data_root, clean_owner, "thumbnails")
        if thumb_target.exists():
            raise FileExistsError(f"Runtime profile thumbnail target already exists: {thumb_target}")

    item = {
        **plan,
        "storedAt": datetime.now().isoformat(timespec="seconds"),
        "storedRecord": _file_record(source),
        "thumbnailStoredRecord": _file_record(Path(thumbnail_plans[0]["originalPath"])) if thumbnail_plans else {},
        "thumbnailStoredRecords": [_file_record(Path(thumbnail["originalPath"])) for thumbnail in thumbnail_plans],
    }
    planned_moves = [
        {"source": str(source), "target": str(target), "kind": "savedCar"},
        *[
            {"source": thumbnail["originalPath"], "target": thumbnail["storedPath"], "kind": "thumbnail"}
            for thumbnail in thumbnail_plans
        ],
    ]
    if dry_run:
        return {"dryRun": True, "stored": False, "item": item, "moves": planned_moves}

    if target.exists():
        raise FileExistsError(f"Runtime profile vault target already exists: {target}")
    manifest_path = _manifest_path_for_owner(user_data_root, clean_owner)
    manifest_snapshot = _path_snapshot(manifest_path)
    moves: list[dict[str, Any]] = []
    try:
        for move in planned_moves:
            move_source = Path(move["source"])
            move_target = Path(move["target"])
            move_target.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(move_source), str(move_target))
            moves.append(move)
        item["storedRecord"] = _file_record(target)
        item["thumbnailStoredRecords"] = [_file_record(Path(thumbnail["storedPath"])) for thumbnail in thumbnail_plans]
        item["thumbnailStoredRecord"] = item["thumbnailStoredRecords"][0] if item["thumbnailStoredRecords"] else {}
        manifest = _upsert_manifest_item(_load_owner_manifest(user_data_root, clean_owner), item)
        manifest_path = _save_owner_manifest(user_data_root, clean_owner, manifest)
    except Exception as exc:
        rollback_errors = _rollback_moves(moves)
        try:
            _restore_path_snapshot(manifest_snapshot)
        except Exception as rollback_exc:
            rollback_errors.append(f"Manifest rollback failed: {rollback_exc}")
        detail = f"; rollback errors: {'; '.join(rollback_errors)}" if rollback_errors else ""
        raise RuntimeError(f"Failed to store SavedCar atomically: {exc}{detail}") from exc
    return {"dryRun": False, "stored": True, "ownerRuntime": clean_owner, "item": item, "moves": moves, "manifestPath": str(manifest_path)}


def restore_saved_car(item: dict[str, Any], user_data_root: str | Path, *, dry_run: bool = False) -> dict[str, Any]:
    require_game_closed()
    clean_owner = _clean_target(item.get("ownerRuntime"))
    source = Path(str(item.get("storedPath") or "")).expanduser()
    target = Path(str(item.get("originalPath") or "")).expanduser()
    original_root = Path(str(item.get("originalRoot") or target.parent)).expanduser()
    _assert_vault_target(source, user_data_root, clean_owner, "savedCars")
    if not source.exists() or not source.is_file():
        raise FileNotFoundError(f"Stored SavedCar not found: {source}")
    _validate_record_hash(source, item.get("storedRecord") if isinstance(item.get("storedRecord"), dict) else {}, "Stored SavedCar")
    _assert_restore_target(target, original_root)

    thumbnail_plans = _thumbnail_file_plans(item)
    thumbnail_conflicts = [thumbnail for thumbnail in thumbnail_plans if Path(thumbnail["originalPath"]).exists()]
    restorable_thumbnails = [] if thumbnail_conflicts else thumbnail_plans
    for thumbnail in restorable_thumbnails:
        thumb_source = Path(thumbnail["storedPath"]).expanduser()
        thumb_target = Path(thumbnail["originalPath"]).expanduser()
        _assert_vault_target(thumb_source, user_data_root, clean_owner, "thumbnails")
        if not thumb_source.exists() or not thumb_source.is_file():
            raise FileNotFoundError(f"Stored SavedCar thumbnail not found: {thumb_source}")
        _validate_record_hash(thumb_source, _thumbnail_record_for_plan(item, thumbnail), "Stored SavedCar thumbnail")

    planned_moves = [
        {"source": str(source), "target": str(target), "kind": "savedCar"},
        *[
            {"source": thumbnail["storedPath"], "target": thumbnail["originalPath"], "kind": "thumbnail"}
            for thumbnail in restorable_thumbnails
        ],
    ]

    if dry_run:
        return {"dryRun": True, "restored": False, "item": item, "moves": planned_moves, "thumbnailConflicts": thumbnail_conflicts}

    manifest_path = _manifest_path_for_owner(user_data_root, clean_owner)
    manifest_snapshot = _path_snapshot(manifest_path)
    moves: list[dict[str, Any]] = []
    try:
        for move in planned_moves:
            move_source = Path(move["source"])
            move_target = Path(move["target"])
            move_target.parent.mkdir(parents=True, exist_ok=True)
            shutil.move(str(move_source), str(move_target))
            moves.append(move)
        restored_item = {
            **item,
            "restoredAt": datetime.now().isoformat(timespec="seconds"),
            "restoredRecord": _file_record(target),
        }
        manifest = _upsert_manifest_item(_load_owner_manifest(user_data_root, clean_owner), restored_item)
        manifest_path = _save_owner_manifest(user_data_root, clean_owner, manifest)
    except Exception as exc:
        rollback_errors = _rollback_moves(moves)
        try:
            _restore_path_snapshot(manifest_snapshot)
        except Exception as rollback_exc:
            rollback_errors.append(f"Manifest rollback failed: {rollback_exc}")
        detail = f"; rollback errors: {'; '.join(rollback_errors)}" if rollback_errors else ""
        raise RuntimeError(f"Failed to restore SavedCar atomically: {exc}{detail}") from exc
    return {
        "dryRun": False,
        "restored": True,
        "ownerRuntime": clean_owner,
        "item": restored_item,
        "moves": moves,
        "thumbnailConflicts": thumbnail_conflicts,
        "manifestPath": str(manifest_path),
    }


def vault_status(user_data_root: str | Path) -> dict[str, Any]:
    paths = vault_paths(user_data_root)
    profiles: dict[str, Any] = {}
    for owner in (TARGET_OFFICIAL, TARGET_ADVANCED):
        profile_paths = paths[owner]
        manifest_path = Path(str(profile_paths["manifest"]))
        manifest = _load_owner_manifest(user_data_root, owner)
        items = [
            _manifest_item_status(dict(item))
            for item in manifest.get("items") or []
            if isinstance(item, dict)
        ]
        profiles[owner] = {
            "ownerRuntime": owner,
            "paths": profile_paths,
            "manifestExists": manifest_path.exists() and manifest_path.is_file(),
            "manifest": manifest,
            "items": items,
            "itemCount": len(items),
            "storedCount": sum(1 for item in items if item.get("storedExists")),
            "restoreEligibleCount": sum(1 for item in items if item.get("restoreEligible")),
            "originalConflictCount": sum(1 for item in items if item.get("originalExists")),
        }
    return {
        "readOnly": True,
        "paths": paths,
        "profiles": profiles,
        "formatVersion": MANIFEST_FORMAT_VERSION,
    }


def _path_name(value: Any) -> str:
    text = str(value or "").replace("/", "\\").strip().lower()
    return text


def _entry_car_id(entry: dict[str, Any]) -> str:
    car_content_path = str(entry.get("car_content_path") or entry.get("carContentPath") or "").strip()
    if car_content_path:
        return Path(car_content_path.replace("\\", "/")).name.strip().lower()
    car_model = str(entry.get("car_model") or entry.get("carModel") or "").strip().lower()
    if car_model:
        return car_model
    local_root = str(entry.get("local_root") or entry.get("localRoot") or "").strip()
    if local_root:
        parts = Path(local_root).parts
        if len(parts) >= 2:
            return str(parts[-2]).strip().lower()
    return ""


def _advanced_match_tokens(entry: dict[str, Any]) -> list[str]:
    if workflow_scopes.scope_for_entry(entry) != workflow_scopes.SCOPE_ADVANCED:
        return []
    skin_id = str(entry.get("skin_id") or entry.get("skinId") or "").strip().lower()
    car_id = _entry_car_id(entry)
    if not skin_id:
        return []
    tokens = {
        f"\\skins\\{skin_id}\\",
        f"\\skins\\{skin_id}.",
        f"\\presets\\{skin_id}",
        f"\\generated\\{skin_id}",
    }
    if car_id:
        tokens.update(
            {
                f"content\\cars\\{car_id}\\skins\\{skin_id}\\",
                f"content\\cars\\{car_id}\\skins\\{skin_id}.",
                f"content\\cars\\{car_id}\\presets\\{skin_id}",
            }
        )
    return sorted(tokens)


def _advanced_token_index(project_entries: list[dict[str, Any]]) -> list[dict[str, Any]]:
    rows: list[dict[str, Any]] = []
    for entry in project_entries:
        if not isinstance(entry, dict):
            continue
        tokens = _advanced_match_tokens(entry)
        if not tokens:
            continue
        rows.append(
            {
                "key": str(entry.get("key") or ""),
                "skinId": str(entry.get("skin_id") or entry.get("skinId") or ""),
                "displayName": str(entry.get("display_name") or entry.get("displayName") or entry.get("skin_id") or ""),
                "carId": _entry_car_id(entry),
                "tokens": tokens,
            }
        )
    return rows


def _advanced_matches(row: dict[str, Any], token_index: list[dict[str, Any]]) -> list[dict[str, Any]]:
    refs = [_path_name(item) for item in (row.get("contentRefs") or [])]
    refs.extend(_path_name(item) for item in (row.get("materialRefs") or []))
    haystack = "\n".join(refs)
    if not haystack:
        return []
    matches: list[dict[str, Any]] = []
    for item in token_index:
        tokens = [str(token or "") for token in item.get("tokens") or []]
        hit = next((token for token in tokens if token and token in haystack), "")
        if not hit:
            continue
        matches.append(
            {
                "source": "liverylab_advanced",
                "key": item.get("key") or "",
                "displayName": item.get("displayName") or "",
                "skinId": item.get("skinId") or "",
                "carId": item.get("carId") or "",
                "matchedToken": hit,
            }
        )
    return matches


def _classify_saved_car(row: dict[str, Any], token_index: list[dict[str, Any]]) -> dict[str, Any]:
    markers = [dict(item) for item in row.get("markers") or [] if isinstance(item, dict)]
    official_markers = [
        marker
        for marker in markers
        if str(marker.get("source") or "") in {"external_livery_json", "liverylab_project", "liverylab_library"}
    ]
    advanced_markers = _advanced_matches(row, token_index)
    owner = "unknown"
    confidence = "none"
    reason = "No LiveryLab-owned runtime reference was found."
    if official_markers and not advanced_markers:
        owner = TARGET_OFFICIAL
        confidence = "high"
        reason = "SavedCar GUID matches an official ExternalLiveries manifest or official LiveryLab entry."
    elif advanced_markers and not official_markers:
        owner = TARGET_ADVANCED
        confidence = "high"
        reason = "SavedCar content references match a known Advanced LiveryLab skin."
    elif official_markers and advanced_markers:
        owner = "conflict"
        confidence = "high"
        reason = "SavedCar matches both official and advanced ownership signals."
    return {
        **row,
        "runtimeOwner": owner,
        "runtimeOwnerConfidence": confidence,
        "runtimeOwnerReason": reason,
        "officialMatches": official_markers,
        "advancedMatches": advanced_markers,
    }


def _package_action(target: str, package: dict[str, Any]) -> str:
    active = bool(package.get("activeExists"))
    disabled = bool(package.get("disabledExists"))
    if active and disabled:
        return "conflict"
    if target == TARGET_OFFICIAL:
        return "none" if active else ("ambiguous" if package.get("disabledAmbiguous") else ("restore" if disabled else "missing"))
    if target == TARGET_ADVANCED:
        return "disable" if active else "none"
    return "none"


def _saved_car_plan(target: str, classified: list[dict[str, Any]], user_data_root: str | Path) -> dict[str, Any]:
    other = TARGET_ADVANCED if target == TARGET_OFFICIAL else TARGET_OFFICIAL
    to_store = [row for row in classified if row.get("runtimeOwner") == other]
    to_restore = [_manifest_item_status(item) for item in _stored_manifest_items(user_data_root, target)]
    unknown = [row for row in classified if row.get("runtimeOwner") == "unknown"]
    conflicts = [row for row in classified if row.get("runtimeOwner") == "conflict"]
    already_target = [row for row in classified if row.get("runtimeOwner") == target]
    return {
        "targetRuntime": target,
        "storeOwner": other,
        "restoreOwner": target,
        "toStore": [
            {
                **row,
                "storagePlan": saved_car_storage_plan(row, other, user_data_root),
            }
            for row in to_store
        ],
        "toStoreCount": len(to_store),
        "toRestore": to_restore,
        "toRestoreCount": len(to_restore),
        "restoreEligibleCount": sum(1 for item in to_restore if item.get("restoreEligible")),
        "restoreConflictCount": sum(1 for item in to_restore if item.get("originalExists")),
        "alreadyTarget": already_target,
        "alreadyTargetCount": len(already_target),
        "unknown": unknown,
        "unknownCount": len(unknown),
        "conflicts": conflicts,
        "conflictCount": len(conflicts),
    }


def preflight(
    target_runtime: str,
    *,
    game_root: str | Path | None = None,
    saved_games_ace_root: str | Path | None = None,
    package_manifest_root: str | Path | None = None,
    advanced_setup_manifest_root: str | Path | None = None,
    user_data_root: str | Path,
    project_entries: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
    target = _clean_target(target_runtime)
    requires_game_root_setup = not bool(str(game_root or "").strip())
    entries = [dict(item) for item in (project_entries or []) if isinstance(item, dict)]
    runtime = runtime_profiles.inspect_runtime_state(
        game_root,
        saved_games_ace_root=saved_games_ace_root,
        package_manifest_root=package_manifest_root,
    )
    package = runtime.get("package") if isinstance(runtime.get("package"), dict) else {}
    advanced_setup = advanced_content_setup.status(
        advanced_setup_manifest_root or package_manifest_root or user_data_root,
        game_root or runtime.get("gameRoot") or "",
        package,
    )
    token_index = _advanced_token_index(entries)
    saved_scan = official_saved_cars.scan_saved_cars(
        saved_games_ace_root=saved_games_ace_root,
        project_entries=entries,
        custom_only=False,
    )
    classified = [
        _classify_saved_car(row, token_index)
        for row in saved_scan.get("items") or []
        if isinstance(row, dict)
    ]
    saved_plan = _saved_car_plan(target, classified, user_data_root)
    action = _package_action(target, package)
    if requires_game_root_setup:
        action = "none"
    requires_advanced_setup = bool(target == TARGET_ADVANCED and advanced_setup.get("ready") is not True)
    package_update_conflict = package_update_conflict_guidance(package, advanced_setup)
    if requires_advanced_setup and action not in {"ambiguous", "conflict"}:
        action = "none"

    warnings: list[str] = []
    blockers: list[str] = []
    if action == "missing" and not requires_game_root_setup:
        blockers.append("content.kspkg is not active and no valid renamed content package was found to restore.")
    if action == "ambiguous":
        candidates = [Path(str(value)).name for value in package.get("disabledPaths", []) if str(value).strip()]
        detail = ", ".join(candidates)
        blockers.append(
            "Multiple valid renamed AC EVO content packages were found. "
            + (f"Keep only the package that should be restored: {detail}" if detail else "Keep only the package that should be restored.")
        )
    if action == "conflict":
        obsolete_path = str(package_update_conflict.get("obsoleteDisabledPath") or "")
        if package_update_conflict.get("manualResolutionSupported") and obsolete_path:
            blockers.append(
                "An AC EVO update package conflict was detected. Keep the active content.kspkg and manually delete only the old "
                f"LiveryLab-disabled package shown in the guided dialog: {obsolete_path}"
            )
        else:
            blockers.append(
                "Active and disabled AC EVO content packages were detected. This can happen when Steam updates AC EVO while content.kspkg is disabled. "
                "LiveryLab could not safely identify a single obsolete package and will not replace or delete any file."
            )
    if saved_plan["conflictCount"]:
        blockers.append("One or more SavedCars match both official and advanced ownership signals.")
    if saved_scan.get("errors"):
        warnings.extend(str(item) for item in saved_scan.get("errors") or [])
    if saved_plan["unknownCount"]:
        warnings.append("Unclassified SavedCars will be left untouched by the runtime switch.")
    if target == TARGET_OFFICIAL and action == "restore":
        warnings.append("Switching to Official runtime would restore content.kspkg; loose content remains untouched.")
    if target == TARGET_ADVANCED and action == "disable":
        warnings.append("Switching to Advanced runtime would disable content.kspkg so loose content is used.")
    if requires_advanced_setup:
        warnings.append(
            "Advanced full-content extraction is not complete. content.kspkg will remain active until Extract all finishes successfully."
        )
    if requires_game_root_setup:
        warnings.append("Select and save the AC EVO Game Folder before preparing the runtime.")
    if action == "conflict" and target == TARGET_ADVANCED:
        warnings.append("After resolving the duplicate, run the full content extraction again from the updated active package before enabling the Advanced runtime.")
    if action == "conflict" and target == TARGET_OFFICIAL:
        warnings.append("After resolving the duplicate, select the Official workflow again. The active content.kspkg will remain in use.")

    can_apply = not blockers and not requires_advanced_setup and not requires_game_root_setup
    return {
        "readOnly": True,
        "targetRuntime": target,
        "currentRuntime": runtime.get("runtime") or runtime_profiles.RUNTIME_UNKNOWN,
        "canApplyLater": can_apply,
        "status": "blocked" if blockers else ("setup_required" if requires_advanced_setup or requires_game_root_setup else ("changes_planned" if action != "none" or saved_plan["toStoreCount"] or saved_plan["restoreEligibleCount"] else "ready")),
        "packageAction": action,
        "requiresGameRootSetup": requires_game_root_setup,
        "requiresAdvancedContentSetup": requires_advanced_setup,
        "advancedContentSetup": advanced_setup,
        "packageUpdateConflict": package_update_conflict,
        "package": package,
        "runtime": runtime,
        "savedGamesAceRoot": saved_scan.get("savedGamesAceRoot") or str(saved_games_paths.ace_root()),
        "savedCars": {
            "roots": saved_scan.get("roots") or [],
            "totalSavedCarCount": saved_scan.get("totalSavedCarCount") or 0,
            "classified": classified,
            "classifiedCount": len(classified),
            "plan": saved_plan,
        },
        "vault": vault_paths(user_data_root),
        "advancedMatchTokenCount": sum(len(row.get("tokens") or []) for row in token_index),
        "warnings": warnings,
        "blockers": blockers,
        "notes": [
            "This preflight is read-only and does not rename content.kspkg or move SavedCars.",
            "CareerData SavedCars are intentionally ignored; only ProfileData/OpenData/SavedCars is scanned.",
            "Unknown SavedCars are reported but not planned for storage or restore.",
            "Advanced runtime package changes are deferred until full-content extraction is confirmed complete.",
        ],
    }


def _run_package_action(action: str, game_root: str | Path | None, package_manifest_root: str | Path | None, *, dry_run: bool) -> dict[str, Any]:
    clean_action = str(action or "none").strip().lower()
    if clean_action in {"", "none"}:
        return {"action": "none", "dryRun": dry_run, "changed": False, "result": {}}
    if clean_action == "missing":
        raise FileNotFoundError("content.kspkg is missing and no valid renamed package is available to restore.")
    if not game_root:
        raise ValueError("AC EVO root is required for package runtime switching.")
    root = Path(game_root).expanduser()
    before = package_state.content_package_status(root, package_manifest_root)
    if dry_run:
        return {"action": clean_action, "dryRun": True, "changed": False, "before": before, "result": {}}
    if clean_action == "restore":
        result = package_state.restore_content_package(root, package_manifest_root)
    elif clean_action == "disable":
        result = package_state.disable_content_package(root, package_manifest_root)
    else:
        raise ValueError(f"Unsupported package action: {action}")
    return {
        "action": clean_action,
        "dryRun": False,
        "changed": bool(result.get("renamed")),
        "before": before,
        "result": result,
    }


def _apply_store_rows(rows: list[dict[str, Any]], owner: str, user_data_root: str | Path, *, dry_run: bool) -> list[dict[str, Any]]:
    results: list[dict[str, Any]] = []
    for row in rows:
        results.append(store_saved_car(row, owner, user_data_root, dry_run=dry_run))
    return results


def _apply_restore_rows(rows: list[dict[str, Any]], user_data_root: str | Path, *, dry_run: bool) -> list[dict[str, Any]]:
    results: list[dict[str, Any]] = []
    for row in rows:
        if not bool(row.get("restoreEligible")):
            results.append({"dryRun": dry_run, "restored": False, "skipped": True, "reason": "not restore eligible", "item": row})
            continue
        results.append(restore_saved_car(row, user_data_root, dry_run=dry_run))
    return results


def _restore_package_snapshot(
    snapshot: dict[str, Any],
    game_root: str | Path | None,
    package_manifest_root: str | Path | None,
) -> dict[str, Any]:
    if not game_root:
        return {"changed": False, "reason": "game root missing"}
    desired_active = bool(snapshot.get("activeExists"))
    desired_disabled = bool(snapshot.get("disabledExists"))
    current = package_state.content_package_status(game_root, package_manifest_root)
    if desired_active and not current.get("activeExists"):
        if not current.get("disabledExists"):
            raise RuntimeError("Cannot roll back content.kspkg: disabled package is missing")
        result = package_state.restore_content_package(game_root, package_manifest_root)
        return {"changed": bool(result.get("renamed")), "action": "restore", "result": result}
    if desired_disabled and not desired_active and current.get("activeExists"):
        result = package_state.disable_content_package(game_root, package_manifest_root)
        return {"changed": bool(result.get("renamed")), "action": "disable", "result": result}
    return {"changed": False, "action": "none", "result": current}


def _transaction_manifest_snapshots(
    user_data_root: str | Path,
    advanced_setup_manifest_root: str | Path | None = None,
) -> list[dict[str, Any]]:
    snapshots = [
        _path_snapshot(_manifest_path_for_owner(user_data_root, owner))
        for owner in (TARGET_OFFICIAL, TARGET_ADVANCED)
    ]
    if advanced_setup_manifest_root is not None:
        snapshots.append(_path_snapshot(advanced_content_setup.manifest_path(advanced_setup_manifest_root)))
    return snapshots


def _rollback_runtime_transaction(
    stored: list[dict[str, Any]],
    restored: list[dict[str, Any]],
    manifest_snapshots: list[dict[str, Any]],
    package_snapshot: dict[str, Any],
    game_root: str | Path | None,
    package_manifest_root: str | Path | None,
) -> dict[str, Any]:
    errors: list[str] = []
    package_result: dict[str, Any] = {}
    try:
        package_result = _restore_package_snapshot(package_snapshot, game_root, package_manifest_root)
    except Exception as exc:
        errors.append(f"Package rollback failed: {exc}")
    moves = [
        dict(move)
        for result in [*stored, *restored]
        if isinstance(result, dict)
        for move in (result.get("moves") or [])
        if isinstance(move, dict)
    ]
    errors.extend(_rollback_moves(moves))
    for snapshot in manifest_snapshots:
        try:
            _restore_path_snapshot(snapshot)
        except Exception as exc:
            errors.append(f"Manifest rollback failed: {exc}")
    return {"complete": not errors, "errors": errors, "package": package_result, "moveCount": len(moves)}


def apply_runtime_switch(
    target_runtime: str,
    *,
    game_root: str | Path | None = None,
    saved_games_ace_root: str | Path | None = None,
    package_manifest_root: str | Path | None = None,
    advanced_setup_manifest_root: str | Path | None = None,
    user_data_root: str | Path,
    project_entries: list[dict[str, Any]] | None = None,
    post_package_action: Callable[[dict[str, Any]], dict[str, Any] | None] | None = None,
    dry_run: bool = True,
    write_history: bool = True,
) -> dict[str, Any]:
    target = _clean_target(target_runtime)
    game_status = require_game_closed()
    before = preflight(
        target,
        game_root=game_root,
        saved_games_ace_root=saved_games_ace_root,
        package_manifest_root=package_manifest_root,
        advanced_setup_manifest_root=advanced_setup_manifest_root,
        user_data_root=user_data_root,
        project_entries=project_entries,
    )
    if before.get("blockers"):
        raise RuntimeError("Runtime switch is blocked:\n" + "\n".join(str(item) for item in before.get("blockers") or []))
    if before.get("requiresAdvancedContentSetup"):
        setup = before.get("advancedContentSetup") if isinstance(before.get("advancedContentSetup"), dict) else {}
        raise RuntimeError(
            "Advanced runtime switch is deferred until full-content extraction completes: "
            + str(setup.get("reason") or "Run Extract all in Advanced Setup first.")
        )

    plan = before.get("savedCars", {}).get("plan", {}) if isinstance(before.get("savedCars"), dict) else {}
    store_owner = str(plan.get("storeOwner") or "")
    store_rows = [dict(row) for row in plan.get("toStore") or [] if isinstance(row, dict)]
    restore_rows = [dict(row) for row in plan.get("toRestore") or [] if isinstance(row, dict)]

    setup_manifest_root = advanced_setup_manifest_root or package_manifest_root or user_data_root
    manifest_snapshots = [] if dry_run else _transaction_manifest_snapshots(user_data_root, setup_manifest_root)
    package_snapshot = dict(before.get("package") or {}) if isinstance(before.get("package"), dict) else {}
    stored: list[dict[str, Any]] = []
    restored: list[dict[str, Any]] = []
    package_result: dict[str, Any] = {"action": "none", "dryRun": dry_run, "changed": False, "result": {}}
    legacy_setup_record: dict[str, Any] = {}
    post_apply_result: dict[str, Any] = {}
    try:
        advanced_setup = before.get("advancedContentSetup") if isinstance(before.get("advancedContentSetup"), dict) else {}
        if (
            not dry_run
            and target == TARGET_OFFICIAL
            and str(before.get("packageAction") or "").strip().lower() == "restore"
            and advanced_setup.get("ready") is True
            and advanced_setup.get("legacy") is True
        ):
            legacy_setup_record = advanced_content_setup.record_legacy_ready(
                setup_manifest_root,
                game_root or advanced_setup.get("root") or "",
                package_snapshot,
            )
        # Garage first, package last. Every completed move is journaled for whole-switch rollback.
        for row in store_rows:
            stored.append(store_saved_car(row, store_owner, user_data_root, dry_run=dry_run))
        for row in restore_rows:
            if not bool(row.get("restoreEligible")):
                restored.append({"dryRun": dry_run, "restored": False, "skipped": True, "reason": "not restore eligible", "item": row})
                continue
            restored.append(restore_saved_car(row, user_data_root, dry_run=dry_run))
        package_result = _run_package_action(before.get("packageAction") or "none", game_root, package_manifest_root, dry_run=dry_run)
        if not dry_run and post_package_action is not None:
            callback_result = post_package_action(
                {
                    "targetRuntime": target,
                    "gameRoot": str(game_root or ""),
                    "before": before,
                    "stored": stored,
                    "restored": restored,
                    "packageResult": package_result,
                }
            )
            post_apply_result = dict(callback_result) if isinstance(callback_result, dict) else {}
        after = preflight(
            target,
            game_root=game_root,
            saved_games_ace_root=saved_games_ace_root,
            package_manifest_root=package_manifest_root,
            advanced_setup_manifest_root=advanced_setup_manifest_root,
            user_data_root=user_data_root,
            project_entries=project_entries,
        )
    except Exception as exc:
        if dry_run:
            raise
        rollback = _rollback_runtime_transaction(
            stored,
            restored,
            manifest_snapshots,
            package_snapshot,
            game_root,
            package_manifest_root,
        )
        failure_history = {
            "dryRun": False,
            "targetRuntime": target,
            "startedFromRuntime": before.get("currentRuntime") or runtime_profiles.RUNTIME_UNKNOWN,
            "finishedRuntime": before.get("currentRuntime") or runtime_profiles.RUNTIME_UNKNOWN,
            "gameClosed": not bool(game_status.get("running")),
            "packageAction": before.get("packageAction") or "none",
            "storedCount": sum(1 for item in stored if item.get("stored")),
            "restoredCount": sum(1 for item in restored if item.get("restored")),
            "packageChanged": bool(package_result.get("changed")),
            "advancedContentSetupRecord": legacy_setup_record,
            "failed": True,
            "error": str(exc),
            "rollback": rollback,
            "before": before,
            "stored": stored,
            "restored": restored,
            "packageResult": package_result,
        }
        if write_history:
            try:
                _write_switch_history(user_data_root, failure_history)
            except Exception:
                pass
        rollback_detail = "" if rollback.get("complete") else "; rollback errors: " + "; ".join(rollback.get("errors") or [])
        raise RuntimeError(f"Runtime switch failed and rollback {'completed' if rollback.get('complete') else 'was incomplete'}: {exc}{rollback_detail}") from exc
    history = {
        "dryRun": bool(dry_run),
        "targetRuntime": target,
        "startedFromRuntime": before.get("currentRuntime") or runtime_profiles.RUNTIME_UNKNOWN,
        "finishedRuntime": after.get("currentRuntime") or runtime_profiles.RUNTIME_UNKNOWN,
        "gameClosed": not bool(game_status.get("running")),
        "packageAction": before.get("packageAction") or "none",
        "storedCount": sum(1 for item in stored if item.get("stored")),
        "restoredCount": sum(1 for item in restored if item.get("restored")),
        "storeAttemptCount": len(stored),
        "restoreAttemptCount": len(restored),
        "packageChanged": bool(package_result.get("changed")),
        "advancedContentSetupRecord": legacy_setup_record,
        "before": before,
        "after": after,
        "stored": stored,
        "restored": restored,
        "packageResult": package_result,
        "postApplyResult": post_apply_result,
    }
    history_path = _write_switch_history(user_data_root, history) if write_history else None
    return {
        "dryRun": bool(dry_run),
        "targetRuntime": target,
        "status": "dry_run" if dry_run else "applied",
        "historyPath": str(history_path) if history_path else "",
        "before": before,
        "after": after,
        "stored": stored,
        "restored": restored,
        "packageResult": package_result,
        "advancedContentSetupRecord": legacy_setup_record,
        "postApplyResult": post_apply_result,
        "summary": {
            "storedCount": history["storedCount"],
            "restoredCount": history["restoredCount"],
            "packageChanged": history["packageChanged"],
            "finishedRuntime": history["finishedRuntime"],
        },
    }
