from __future__ import annotations

import hashlib
import json
import os
import shutil
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Iterable

from liverylab_core import legacy_migration, official_preview_storage, project_types, skin_policy


PLAN_VERSION = 1
MANIFEST_VERSION = 1
MANIFEST_TYPE = "liverylab_legacy_workspace_migration"
CONFIRM_TOKEN = "MIGRATE_LEGACY_WORKSPACE"
TARGET_ADVANCED = "advanced"
TARGET_OFFICIAL = "official"
MIGRATION_TARGETS = {TARGET_ADVANCED, TARGET_OFFICIAL}
WORKSPACE_ROOT_NAMES = ("LiveryLibrary", "LiveryProjects", "LiveryPackageExport")
MANAGED_PATH_ROOT_NAMES = (*legacy_migration.MIGRATION_ROOT_NAMES,)
IGNORED_TARGET_FILENAMES = {"readme.txt", ".gitkeep"}
COPY_EXCLUDED_DIR_NAMES = legacy_migration.EXCLUDED_SCAN_DIR_NAMES | {
    "backups",
    "cleanup_backups",
    "gamefilebackups",
    "liverylab_trash",
    "packagestate",
    "runtimeprofiles",
    "savedcarsquarantine",
    "trash",
    "_acevo_backups",
    "_backups",
    "_gamefile_quarantine",
    "_source_backups",
    "_trash",
}
TARGET_PRESERVE_SETTING_KEYS = {
    "default_root_folder",
    "official_external_suppressed_source_ids",
    "texconv_path",
    "workflow_scope",
}
ProgressCallback = Callable[[dict[str, Any]], None]
FailureHook = Callable[[str], None]
CopyProgressCallback = Callable[[dict[str, Any]], None]


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


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


def _read_json(path: Path, fallback: Any) -> Any:
    try:
        return json.loads(path.read_text(encoding="utf-8-sig"))
    except Exception:
        return fallback


def _write_json(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(_json_ready(payload), indent=2, ensure_ascii=False), encoding="utf-8")


def _atomic_write_json(path: Path, payload: Any) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    temporary = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
    try:
        _write_json(temporary, payload)
        os.replace(temporary, path)
    finally:
        temporary.unlink(missing_ok=True)


def _emit(
    progress: ProgressCallback | None,
    phase: str,
    message: str,
    percent: int | float,
    **details: Any,
) -> None:
    if progress is None:
        return
    try:
        progress(
            {
                "phase": phase,
                "message": message,
                "percent": round(max(0.0, min(100.0, float(percent))), 1),
                **details,
            }
        )
    except Exception:
        pass


def _is_ignored_copy_dir(name: str) -> bool:
    return str(name or "").strip().lower() in COPY_EXCLUDED_DIR_NAMES


def _iter_files(root: Path) -> Iterable[Path]:
    if not root.is_dir():
        return
    stack = [root]
    while stack:
        current = stack.pop()
        try:
            with os.scandir(current) as entries:
                ordered = sorted(entries, key=lambda item: item.name.lower(), reverse=True)
                for entry in ordered:
                    path = Path(entry.path)
                    try:
                        if entry.is_dir(follow_symlinks=False):
                            if not _is_ignored_copy_dir(entry.name):
                                stack.append(path)
                        elif entry.is_file(follow_symlinks=False):
                            yield path
                    except OSError:
                        continue
        except OSError:
            continue


def _sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        while True:
            block = handle.read(1024 * 1024)
            if not block:
                break
            digest.update(block)
    return digest.hexdigest()


def _inventory_root(root: Path, *, ignored_relative_paths: set[str] | None = None) -> dict[str, Any]:
    rows: list[tuple[str, int, int]] = []
    total_bytes = 0
    ignored = {str(value).strip().lower() for value in (ignored_relative_paths or set()) if str(value).strip()}
    for path in _iter_files(root) or []:
        rel = path.relative_to(root).as_posix()
        if rel.lower() in ignored:
            continue
        stat = path.stat()
        rows.append((rel, int(stat.st_size), int(stat.st_mtime_ns)))
        total_bytes += int(stat.st_size)
    fingerprint = hashlib.sha256()
    for rel, size, mtime_ns in sorted(rows, key=lambda row: row[0].lower()):
        fingerprint.update(f"{rel}\0{size}\0{mtime_ns}\n".encode("utf-8"))
    return {
        "path": str(root),
        "exists": root.is_dir(),
        "fileCount": len(rows),
        "bytes": total_bytes,
        "fingerprint": fingerprint.hexdigest(),
    }


def advanced_copy_fingerprint(target_root: str | Path) -> str:
    root = Path(target_root).expanduser().resolve()
    digest = hashlib.sha256()
    for name in WORKSPACE_ROOT_NAMES:
        ignored = {"my_skins.json"} if name == "LiveryLibrary" else set()
        inventory = _inventory_root(root / name, ignored_relative_paths=ignored)
        digest.update(f"{name}\0{inventory['fingerprint']}\n".encode("utf-8"))
    return digest.hexdigest()


def _source_inventory(source_root: Path) -> dict[str, Any]:
    roots = {name: _inventory_root(source_root / name) for name in WORKSPACE_ROOT_NAMES}
    settings = source_root / "UserData" / "settings.json"
    settings_stat = settings.stat() if settings.is_file() else None
    settings_row = {
        "path": str(settings),
        "exists": settings_stat is not None,
        "bytes": int(settings_stat.st_size) if settings_stat else 0,
        "mtimeNs": int(settings_stat.st_mtime_ns) if settings_stat else 0,
        "sha256": _sha256(settings) if settings_stat else "",
    }
    fingerprint = hashlib.sha256()
    for name, row in roots.items():
        fingerprint.update(f"{name}\0{row['fingerprint']}\n".encode("utf-8"))
    fingerprint.update(json.dumps(settings_row, sort_keys=True).encode("utf-8"))
    return {
        "roots": roots,
        "settings": settings_row,
        "totalFiles": sum(int(row["fileCount"]) for row in roots.values()) + (1 if settings_stat else 0),
        "totalBytes": sum(int(row["bytes"]) for row in roots.values()) + (int(settings_stat.st_size) if settings_stat else 0),
        "fingerprint": fingerprint.hexdigest(),
    }


def _registry_items(path: Path) -> list[dict[str, Any]]:
    raw = _read_json(path, {})
    if isinstance(raw, dict):
        raw = raw.get("my_skins", [])
    if not isinstance(raw, list):
        return []
    return [dict(item) for item in raw if isinstance(item, dict)]


def _target_bootstrap_json(path: Path) -> bool:
    name = path.name.lower()
    if name == "my_skins.json":
        return not _registry_items(path)
    if name == "library.json" and path.parent.name.lower() == "layerstudiolibrary":
        data = _read_json(path, {})
        return isinstance(data, dict) and not data.get("assets") and not data.get("groups")
    return False


def inspect_target_workspace(target_root: str | Path) -> dict[str, Any]:
    root = Path(target_root).expanduser().resolve()
    blockers: list[str] = []
    user_files: list[str] = []
    for root_name in WORKSPACE_ROOT_NAMES:
        candidate_root = root / root_name
        for path in _iter_files(candidate_root) or []:
            if path.name.lower() in IGNORED_TARGET_FILENAMES:
                continue
            if _target_bootstrap_json(path):
                continue
            user_files.append(str(path))
            if len(user_files) >= 20:
                break
        if len(user_files) >= 20:
            break
    if user_files:
        blockers.append(
            "The target already contains Advanced workspace data. "
            "Legacy migration currently requires an unused Advanced workspace."
        )

    completed_manifests: list[str] = []
    migration_root = root / "UserData" / "Migrations"
    if migration_root.is_dir():
        for path in sorted(migration_root.glob("*/migration_manifest.json")):
            manifest = _read_json(path, {})
            if isinstance(manifest, dict) and str(manifest.get("state") or "") == "local_workspace_migrated":
                completed_manifests.append(str(path))
    if completed_manifests:
        blockers.append("A completed legacy workspace migration already exists in the target installation.")

    return {
        "root": str(root),
        "ready": not blockers,
        "status": "ready" if not blockers else "blocked",
        "blockers": blockers,
        "userFiles": user_files,
        "completedMigrationManifests": completed_manifests,
    }


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


def normalize_migration_target(value: Any) -> str:
    clean = str(value or "").strip().lower().replace("-", "_")
    return clean if clean in MIGRATION_TARGETS else TARGET_ADVANCED


def inspect_migration_long_paths(source_root: str | Path, target_root: str | Path) -> dict[str, Any]:
    source = Path(source_root).expanduser().resolve()
    target = Path(target_root).expanduser().resolve()
    longest_source = ""
    longest_target = ""
    longest_length = 0
    for root_name in WORKSPACE_ROOT_NAMES:
        source_workspace = source / root_name
        for path in _iter_files(source_workspace) or []:
            try:
                relative = path.relative_to(source_workspace)
            except ValueError:
                continue
            candidate = target / root_name / relative
            candidate_length = len(str(candidate.absolute()))
            if candidate_length > longest_length:
                longest_length = candidate_length
                longest_source = str(path)
                longest_target = str(candidate)

    required_length = max(official_preview_storage.WINDOWS_LEGACY_MAX_PATH + 32, longest_length + 96)
    health = official_preview_storage.windows_long_paths_health()
    runtime = official_preview_storage.probe_runtime_long_path_support(
        target,
        required_path_length=required_length,
    )
    registry_ready = bool(health.get("enabled"))
    runtime_ready = bool(runtime.get("available"))
    ready = bool(registry_ready and runtime_ready)
    if os.name != "nt":
        ready = True
    return {
        "ready": ready,
        "status": "ready" if ready else "blocked",
        "registry": health,
        "runtime": runtime,
        "longestSourcePath": longest_source,
        "longestTargetPath": longest_target,
        "longestTargetPathLength": longest_length,
        "requiredPathLength": required_length,
        "helperFile": str(target / "Enable_Long_Windows_Paths.bat"),
        "googleQuery": "enable Win32 long paths Windows 10 Windows 11",
        "message": "Windows Long Path Support is ready." if ready else "Windows Long Path Support is required before migration can begin.",
    }


def create_workspace_migration_plan(
    source_path: str | Path,
    target_root: str | Path,
    *,
    target_version: str = "",
    migration_target: str = TARGET_ADVANCED,
) -> dict[str, Any]:
    target = Path(target_root).expanduser().resolve()
    selected_target = normalize_migration_target(migration_target)
    inspection = legacy_migration.inspect_legacy_install(source_path, target_root=target)
    source = Path(str(inspection.get("source", {}).get("root") or "")).resolve()
    blockers = list(inspection.get("blockers") or [])
    if _path_is_within(target, source) or _path_is_within(source, target):
        blockers.append("Source and target installations must be separate folders.")

    target_state = inspect_target_workspace(target)
    blockers.extend(str(item) for item in target_state.get("blockers", []))
    long_paths = inspect_migration_long_paths(source, target)
    if not long_paths.get("ready"):
        blockers.append("Windows Long Path Support must be enabled and pass the runtime write test before migration.")
    inventory = _source_inventory(source)
    target_inventory = _source_inventory(target)
    disk = shutil.disk_usage(target)
    required_bytes = int(inventory["totalBytes"]) + (128 * 1024 * 1024)
    if disk.free < required_bytes:
        blockers.append(
            f"Not enough free space for staging. Required: {required_bytes} bytes; available: {disk.free} bytes."
        )

    warnings = list(inspection.get("warnings") or [])
    warnings.append("AC EVO game files and SavedCars are not changed by this workspace migration step.")
    return {
        "planVersion": PLAN_VERSION,
        "planId": uuid.uuid4().hex,
        "createdAt": _now_iso(),
        "status": "ready" if not blockers else "blocked",
        "ready": not blockers,
        "confirmToken": CONFIRM_TOKEN,
        "migrationTarget": selected_target,
        "source": inspection.get("source") or {},
        "target": {
            "root": str(target),
            "version": str(target_version or ""),
            "state": target_state,
        },
        "liveries": inspection.get("liveries") or {},
        "features": inspection.get("features") or {},
        "inventory": inventory,
        "disk": {
            "freeBytes": int(disk.free),
            "requiredBytes": required_bytes,
        },
        "longPaths": long_paths,
        "sourceFingerprint": inventory["fingerprint"],
        "targetFingerprint": target_inventory["fingerprint"],
        "warnings": warnings,
        "blockers": blockers,
        "actions": [
            "Copy the Advanced LiveryLibrary, LiveryProjects and LiveryPackageExport folders through a verified staging area.",
            "Preserve complete project work files while excluding caches, trash, runtime backups and WebView data.",
            "Rebase portable LiveryLab paths to the new installation.",
            "Import all legacy Advanced liveries as inactive while preserving editable and read-only policy.",
            "Merge legacy Advanced settings without replacing current machine-local and Official settings.",
            "Write a local migration manifest for later runtime cleanup and Official conversion steps.",
            *( ["Automatically convert every migrated livery into an Official version after restart."] if selected_target == TARGET_OFFICIAL else [] ),
        ],
    }


def _copy_file_verified(
    source: Path,
    target: Path,
    *,
    progress: CopyProgressCallback | None = None,
) -> tuple[int, str]:
    target.parent.mkdir(parents=True, exist_ok=True)
    source_size = source.stat().st_size
    source_digest = hashlib.sha256()
    with source.open("rb") as source_handle, target.open("xb") as target_handle:
        while True:
            block = source_handle.read(1024 * 1024)
            if not block:
                break
            target_handle.write(block)
            source_digest.update(block)
            if progress is not None:
                progress({"stage": "copy", "bytesDelta": len(block), "fileCompleted": False})
    shutil.copystat(source, target)
    target_size = target.stat().st_size
    if source_size != target_size:
        raise IOError(f"Copied file size mismatch: {source}")
    source_hash = source_digest.hexdigest()
    target_digest = hashlib.sha256()
    with target.open("rb") as target_handle:
        while True:
            block = target_handle.read(1024 * 1024)
            if not block:
                break
            target_digest.update(block)
            if progress is not None:
                progress({"stage": "verify", "bytesDelta": len(block), "fileCompleted": False})
    if source_hash != target_digest.hexdigest():
        raise IOError(f"Copied file hash mismatch: {source}")
    if progress is not None:
        progress({"stage": "verify", "bytesDelta": 0, "fileCompleted": True})
    return int(source_size), source_hash


def _copy_root_verified(
    source: Path,
    target: Path,
    *,
    progress: CopyProgressCallback | None = None,
) -> dict[str, Any]:
    target.mkdir(parents=True, exist_ok=False)
    file_count = 0
    total_bytes = 0
    aggregate = hashlib.sha256()
    for path in _iter_files(source) or []:
        rel = path.relative_to(source)
        file_size = int(path.stat().st_size)

        def report(row: dict[str, Any]) -> None:
            if progress is not None:
                progress({**row, "relativePath": rel.as_posix(), "fileSize": file_size})

        size, digest = _copy_file_verified(path, target / rel, progress=report)
        file_count += 1
        total_bytes += size
        aggregate.update(f"{rel.as_posix()}\0{size}\0{digest}\n".encode("utf-8"))
    return {
        "source": str(source),
        "staged": str(target),
        "fileCount": file_count,
        "bytes": total_bytes,
        "contentSha256": aggregate.hexdigest(),
    }


def _restore_target_bootstrap_files(target_root: Path, staged_root: Path) -> int:
    restored = 0
    if not target_root.is_dir():
        return restored
    for path in _iter_files(target_root) or []:
        if path.name.lower() not in IGNORED_TARGET_FILENAMES:
            continue
        rel = path.relative_to(target_root)
        destination = staged_root / rel
        destination.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(path, destination)
        restored += 1
    return restored


def _rebase_path_string(value: str, target_root: Path) -> str:
    text = str(value or "")
    if not text:
        return text
    try:
        path = Path(text).expanduser()
    except (OSError, ValueError):
        return text
    if not path.is_absolute():
        return text
    parts = list(path.parts)
    for index, part in enumerate(parts):
        matching_root = next((name for name in MANAGED_PATH_ROOT_NAMES if part.lower() == name.lower()), "")
        if not matching_root:
            continue
        suffix = parts[index + 1 :]
        return str(target_root / matching_root / Path(*suffix)) if suffix else str(target_root / matching_root)
    return text


def _rebase_json_value(value: Any, target_root: Path) -> Any:
    if isinstance(value, dict):
        return {str(key): _rebase_json_value(item, target_root) for key, item in value.items()}
    if isinstance(value, list):
        return [_rebase_json_value(item, target_root) for item in value]
    if isinstance(value, str):
        return _rebase_path_string(value, target_root)
    return value


def _rewrite_staged_json_paths(root: Path, target_root: Path) -> dict[str, Any]:
    rewritten = 0
    unreadable: list[str] = []
    for path in _iter_files(root) or []:
        if path.suffix.lower() != ".json":
            continue
        try:
            original = json.loads(path.read_text(encoding="utf-8-sig"))
        except Exception:
            unreadable.append(str(path))
            continue
        updated = _rebase_json_value(original, target_root)
        if updated == original:
            continue
        _write_json(path, updated)
        rewritten += 1
    return {"rewrittenFiles": rewritten, "unreadableFiles": unreadable[:20]}


def _load_source_settings(source_root: Path) -> dict[str, Any]:
    value = _read_json(source_root / "UserData" / "settings.json", {})
    return dict(value) if isinstance(value, dict) else {}


def _target_game_root(settings: dict[str, Any], target_root: Path) -> str:
    raw = str(settings.get("default_root_folder") or "").strip()
    if not raw:
        return ""
    try:
        candidate = Path(raw).expanduser().resolve()
    except (OSError, ValueError):
        return ""
    if _path_is_within(candidate, target_root):
        return ""
    return raw


def _merge_settings(source_root: Path, target_root: Path, migration_id: str, source_version: str) -> dict[str, Any]:
    source = _rebase_json_value(_load_source_settings(source_root), target_root)
    source = dict(source) if isinstance(source, dict) else {}
    target_path = target_root / "UserData" / "settings.json"
    target = _read_json(target_path, {})
    target = dict(target) if isinstance(target, dict) else {}
    source.pop("my_skins", None)

    merged = dict(target)
    for key, value in source.items():
        if (
            key in TARGET_PRESERVE_SETTING_KEYS
            or key.startswith("official_")
            or key.startswith("local_storage_cleanup_")
        ):
            continue
        merged[key] = value

    source_scopes = source.get("liveryCategoryScopes") if isinstance(source.get("liveryCategoryScopes"), dict) else {}
    target_scopes = target.get("liveryCategoryScopes") if isinstance(target.get("liveryCategoryScopes"), dict) else {}
    if source_scopes or target_scopes:
        merged_scopes = dict(target_scopes)
        for key, value in source_scopes.items():
            if key != "official":
                merged_scopes[key] = value
        merged["liveryCategoryScopes"] = merged_scopes

    merged["legacy_workspace_migration_id"] = migration_id
    merged["legacy_workspace_migration_source_version"] = source_version
    merged["legacy_workspace_migration_completed_at"] = _now_iso()
    if not _target_game_root(merged, target_root):
        merged.pop("default_root_folder", None)
    return merged


def _source_registry_entries(source_root: Path) -> list[dict[str, Any]]:
    entries = _registry_items(source_root / legacy_migration.REGISTRY_REL_PATH)
    if entries:
        return entries
    settings = _load_source_settings(source_root)
    raw = settings.get("my_skins") if isinstance(settings, dict) else []
    return [dict(item) for item in raw if isinstance(item, dict)] if isinstance(raw, list) else []


def _target_path_for_source_member(source_root: Path, target_root: Path, path: Path, root_name: str) -> Path:
    try:
        rel = path.resolve().relative_to((source_root / root_name).resolve())
        return target_root / root_name / rel
    except ValueError:
        return target_root / root_name


def _migrated_registry_entries(
    source_root: Path,
    target_root: Path,
    migration_id: str,
    source_version: str,
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
    migrated: list[dict[str, Any]] = []
    manifest_rows: list[dict[str, Any]] = []
    target_settings = _read_json(target_root / "UserData" / "settings.json", {})
    target_settings = target_settings if isinstance(target_settings, dict) else {}
    target_game_root = _target_game_root(target_settings, target_root)
    for source_entry in _source_registry_entries(source_root):
        policy = skin_policy.policy_for_skin(source_entry)
        source_local = legacy_migration._entry_local_root(source_root, source_entry)
        source_project = legacy_migration._entry_project_root(source_root, source_entry)
        target_local = _target_path_for_source_member(source_root, target_root, source_local, "LiveryLibrary")
        target_project = _target_path_for_source_member(source_root, target_root, source_project, "LiveryProjects")
        original_active = source_entry.get("active") is not False
        advanced_only_features = [
            label
            for label, keys in legacy_migration.ADVANCED_ONLY_ENTRY_FIELDS.items()
            if any(bool(source_entry.get(key)) for key in keys)
        ]
        entry = _rebase_json_value(dict(source_entry), target_root)
        entry["local_root"] = str(target_local)
        if target_game_root:
            entry["game_root"] = target_game_root
        else:
            entry.pop("game_root", None)
        entry.pop("ace_snapshot_preview", None)
        entry["active"] = False
        entry["project_type"] = project_types.PROJECT_TYPE_ADVANCED_EXTRACTED_CONTENT
        entry["origin"] = str(policy.get("origin") or skin_policy.ORIGIN_UNKNOWN)
        entry["project_state"] = str(policy.get("projectState") or skin_policy.PROJECT_STATE_NONE)
        entry["legacy_workspace_migration"] = {
            "migrationId": migration_id,
            "sourceVersion": source_version,
            "sourceRoot": str(source_root),
            "originalActive": original_active,
            "migratedAt": _now_iso(),
        }
        migrated.append(entry)
        manifest_rows.append(
            {
                "key": str(entry.get("key") or ""),
                "carModel": str(entry.get("car_model") or ""),
                "skinId": str(entry.get("skin_id") or ""),
                "displayName": str(entry.get("display_name") or entry.get("skin_id") or ""),
                "origin": entry["origin"],
                "projectState": entry["project_state"],
                "editable": policy.get("editable") is True,
                "readOnly": policy.get("readOnly") is True,
                "originalActive": original_active,
                "targetActive": False,
                "sourceLocalRoot": str(source_local),
                "targetLocalRoot": str(target_local),
                "sourceProjectRoot": str(source_project),
                "targetProjectRoot": str(target_project),
                "localRootExists": source_local.is_dir(),
                "projectRootExists": source_project.is_dir(),
                "advancedOnlyFeatures": advanced_only_features,
                "officialConversionStatus": "incomplete"
                if not (source_local.is_dir() and (source_project.is_dir() if policy.get("editable") is True else True))
                else ("ready_with_feature_loss" if advanced_only_features else "ready"),
            }
        )
    return migrated, manifest_rows


def _write_registry_and_skin_metadata(
    staged_library: Path,
    target_root: Path,
    entries: list[dict[str, Any]],
    target_version: str,
) -> dict[str, Any]:
    payload = {
        "formatVersion": 1,
        "app": {"name": "LiveryLab", "version": target_version},
        "updatedAt": _now_iso(),
        "my_skins": entries,
    }
    registry = staged_library / "my_skins.json"
    _write_json(registry, payload)
    metadata_written = 0
    for entry in entries:
        target_local = Path(str(entry.get("local_root") or ""))
        try:
            rel = target_local.resolve().relative_to((target_root / "LiveryLibrary").resolve())
        except ValueError:
            continue
        staged_local = staged_library / rel
        if not staged_local.is_dir():
            continue
        _write_json(
            staged_local / "liverylab_skin.json",
            {
                "formatVersion": 1,
                "app": {"name": "LiveryLab", "version": target_version},
                "updatedAt": _now_iso(),
                "skin": entry,
            },
        )
        metadata_written += 1
    return {"registryPath": str(registry), "entryCount": len(entries), "metadataWritten": metadata_written}


def _failure(failure_hook: FailureHook | None, step: str) -> None:
    if failure_hook is not None:
        failure_hook(step)


def _rollback_commit(
    target_root: Path,
    work_root: Path,
    committed_roots: list[str],
    root_backups: dict[str, Path | None],
    settings_backup: Path | None,
    settings_existed: bool,
) -> list[str]:
    errors: list[str] = []
    failed_root = work_root / "failed_payload"
    for name in reversed(committed_roots):
        target = target_root / name
        backup = root_backups.get(name)
        try:
            if target.exists():
                failed_root.mkdir(parents=True, exist_ok=True)
                target.replace(failed_root / name)
            if backup is not None and backup.exists():
                backup.replace(target)
        except Exception as exc:
            errors.append(f"{name}: {exc}")

    settings = target_root / "UserData" / "settings.json"
    try:
        if settings_backup is not None and settings_backup.is_file():
            shutil.copy2(settings_backup, settings)
        elif not settings_existed:
            settings.unlink(missing_ok=True)
    except Exception as exc:
        errors.append(f"settings.json: {exc}")
    return errors


def apply_workspace_migration(
    plan: dict[str, Any],
    *,
    confirm_token: str,
    progress: ProgressCallback | None = None,
    failure_hook: FailureHook | None = None,
) -> dict[str, Any]:
    if str(confirm_token or "") != CONFIRM_TOKEN:
        raise PermissionError("Legacy workspace migration confirmation is required.")
    if not isinstance(plan, dict) or not str(plan.get("planId") or ""):
        raise ValueError("Legacy workspace migration plan is missing or invalid.")

    source_root = Path(str(plan.get("source", {}).get("root") or "")).resolve()
    target_root = Path(str(plan.get("target", {}).get("root") or "")).resolve()
    target_version = str(plan.get("target", {}).get("version") or "")
    _emit(progress, "revalidate", "Revalidating source and target...", 3, stageLabel="Safety check")
    migration_target = normalize_migration_target(plan.get("migrationTarget"))
    current = create_workspace_migration_plan(
        source_root,
        target_root,
        target_version=target_version,
        migration_target=migration_target,
    )
    if current.get("sourceFingerprint") != plan.get("sourceFingerprint"):
        raise RuntimeError("The old LiveryLab workspace changed after inspection. Inspect it again before migrating.")
    if current.get("targetFingerprint") != plan.get("targetFingerprint"):
        raise RuntimeError("The current LiveryLab workspace changed after inspection. Inspect the old installation again before migrating.")
    if current.get("ready") is not True:
        raise RuntimeError("Legacy workspace migration is blocked:\n" + "\n".join(str(item) for item in current.get("blockers", [])))

    migration_id = str(plan["planId"])
    work_root = target_root / "_tmp" / "legacy_migration" / migration_id
    if work_root.exists():
        raise FileExistsError(f"Legacy migration work folder already exists: {work_root}")
    stage_root = work_root / "staging"
    backup_root = work_root / "target_backup"
    stage_root.mkdir(parents=True)
    backup_root.mkdir(parents=True)
    started_at = _now_iso()
    _write_json(
        work_root / "migration_manifest.json",
        {
            "formatVersion": MANIFEST_VERSION,
            "type": MANIFEST_TYPE,
            "migrationId": migration_id,
            "state": "staging",
            "startedAt": started_at,
            "source": plan.get("source") or {},
            "target": plan.get("target") or {},
        },
    )

    copy_results: dict[str, Any] = {}
    rebase_results: dict[str, Any] = {}
    touched_roots: list[str] = []
    root_backups: dict[str, Path | None] = {}
    settings_path = target_root / "UserData" / "settings.json"
    settings_existed = settings_path.is_file()
    settings_backup: Path | None = None
    try:
        root_inventory = plan.get("inventory", {}).get("roots", {})
        if not isinstance(root_inventory, dict):
            root_inventory = {}
        total_copy_files = sum(
            int(row.get("fileCount") or 0)
            for row in root_inventory.values()
            if isinstance(row, dict)
        )
        total_copy_bytes = sum(
            int(row.get("bytes") or 0)
            for row in root_inventory.values()
            if isinstance(row, dict)
        )
        total_copy_work = max(1, total_copy_bytes * 2)
        copy_work_done = 0
        copied_bytes = 0
        completed_files = 0
        last_progress_emit = 0.0

        _emit(
            progress,
            "staging",
            "Preparing the verified staging area...",
            6,
            stageLabel="Staging",
            current=0,
            total=total_copy_files,
            totalFiles=total_copy_files,
            filesWritten=0,
            bytesProcessed=0,
            totalBytes=total_copy_bytes,
        )
        for index, name in enumerate(WORKSPACE_ROOT_NAMES, start=1):
            source = source_root / name
            if not source.is_dir():
                continue
            _emit(
                progress,
                "copy",
                f"Copying {name}...",
                8 + (copy_work_done / total_copy_work) * 52,
                stageLabel=f"Copying {name}",
                currentRoot=name,
                current=completed_files,
                total=total_copy_files,
                totalFiles=total_copy_files,
                filesWritten=completed_files,
                bytesProcessed=copied_bytes,
                totalBytes=total_copy_bytes,
                path=name,
            )

            def report_copy(row: dict[str, Any]) -> None:
                nonlocal copy_work_done, copied_bytes, completed_files, last_progress_emit
                delta = max(0, int(row.get("bytesDelta") or 0))
                stage = str(row.get("stage") or "copy")
                copy_work_done += delta
                if stage == "copy":
                    copied_bytes += delta
                if row.get("fileCompleted") is True:
                    completed_files += 1
                now = time.monotonic()
                should_emit = row.get("fileCompleted") is True or now - last_progress_emit >= 0.1
                if not should_emit:
                    return
                last_progress_emit = now
                relative = str(row.get("relativePath") or "")
                action = "Verifying" if stage == "verify" else "Copying"
                _emit(
                    progress,
                    "copy",
                    f"{action} {name}...",
                    8 + (min(copy_work_done, total_copy_work) / total_copy_work) * 52,
                    stageLabel=f"{action} {name}",
                    copyStage=stage,
                    currentRoot=name,
                    current=completed_files,
                    total=total_copy_files,
                    totalFiles=total_copy_files,
                    filesWritten=completed_files,
                    bytesProcessed=min(copied_bytes, total_copy_bytes),
                    totalBytes=total_copy_bytes,
                    path=f"{name}/{relative}" if relative else name,
                )

            staged = stage_root / name
            result = _copy_root_verified(source, staged, progress=report_copy)
            result["targetBootstrapFilesRestored"] = _restore_target_bootstrap_files(target_root / name, staged)
            copy_results[name] = result
            if name in {"LiveryLibrary", "LiveryProjects"}:
                rebase_results[name] = _rewrite_staged_json_paths(staged, target_root)

        _emit(
            progress,
            "metadata",
            "Preparing inactive Advanced library metadata...",
            64,
            stageLabel="Preparing library metadata",
            current=completed_files,
            total=total_copy_files,
            totalFiles=total_copy_files,
            filesWritten=completed_files,
            bytesProcessed=total_copy_bytes,
            totalBytes=total_copy_bytes,
        )
        source_version = str(plan.get("source", {}).get("version") or "unknown")
        entries, entry_manifest = _migrated_registry_entries(
            source_root,
            target_root,
            migration_id,
            source_version,
        )
        staged_library = stage_root / "LiveryLibrary"
        if not staged_library.exists():
            staged_library.mkdir(parents=True)
            copy_results["LiveryLibrary"] = {
                "source": str(source_root / "LiveryLibrary"),
                "staged": str(staged_library),
                "fileCount": 0,
                "bytes": 0,
                "contentSha256": "",
                "targetBootstrapFilesRestored": _restore_target_bootstrap_files(target_root / "LiveryLibrary", staged_library),
            }
        registry_result = _write_registry_and_skin_metadata(staged_library, target_root, entries, target_version)

        merged_settings = _merge_settings(source_root, target_root, migration_id, source_version)
        staged_settings = stage_root / "UserData" / "settings.json"
        _write_json(staged_settings, merged_settings)
        _failure(failure_hook, "after_staging")

        target_state = inspect_target_workspace(target_root)
        if target_state.get("ready") is not True:
            raise RuntimeError("The target Advanced workspace changed during staging. No migrated data was committed.")
        if _source_inventory(target_root).get("fingerprint") != plan.get("targetFingerprint"):
            raise RuntimeError("The target LiveryLab files changed during staging. No migrated data was committed.")

        staged_names = [name for name in WORKSPACE_ROOT_NAMES if (stage_root / name).exists()]
        _emit(
            progress,
            "commit",
            "Committing the migrated workspace...",
            78,
            stageLabel="Committing workspace",
            current=0,
            total=len(staged_names) + 2,
            totalFiles=total_copy_files,
            filesWritten=completed_files,
            bytesProcessed=total_copy_bytes,
            totalBytes=total_copy_bytes,
        )
        committed_count = 0
        for name in WORKSPACE_ROOT_NAMES:
            staged = stage_root / name
            if not staged.exists():
                continue
            target = target_root / name
            backup = backup_root / name
            if target.exists():
                target.replace(backup)
                root_backups[name] = backup
            else:
                root_backups[name] = None
            touched_roots.append(name)
            staged.replace(target)
            committed_count += 1
            _emit(
                progress,
                "commit",
                f"Committed {name}.",
                78 + (committed_count / max(1, len(staged_names) + 2)) * 16,
                stageLabel=f"Committed {name}",
                current=committed_count,
                total=len(staged_names) + 2,
                totalFiles=total_copy_files,
                filesWritten=completed_files,
                bytesProcessed=total_copy_bytes,
                totalBytes=total_copy_bytes,
                path=name,
            )
            _failure(failure_hook, f"after_commit_root:{name}")

        settings_path.parent.mkdir(parents=True, exist_ok=True)
        if settings_existed:
            settings_backup = backup_root / "UserData" / "settings.json"
            settings_backup.parent.mkdir(parents=True, exist_ok=True)
            shutil.copy2(settings_path, settings_backup)
        os.replace(staged_settings, settings_path)
        committed_count += 1
        _emit(
            progress,
            "commit",
            "Committed current settings.",
            78 + (committed_count / max(1, len(staged_names) + 2)) * 16,
            stageLabel="Committed settings",
            current=committed_count,
            total=len(staged_names) + 2,
            totalFiles=total_copy_files,
            filesWritten=completed_files,
            bytesProcessed=total_copy_bytes,
            totalBytes=total_copy_bytes,
            path="UserData/settings.json",
        )
        _failure(failure_hook, "after_commit_settings")

        completed_at = _now_iso()
        queue_items = [
            {
                "sourceKey": str(item.get("key") or ""),
                "carModel": str(item.get("carModel") or ""),
                "skinId": str(item.get("skinId") or ""),
                "displayName": str(item.get("displayName") or ""),
                "packageMode": "editable_project" if item.get("editable") else "read_only",
                "advancedOnlyFeatures": list(item.get("advancedOnlyFeatures") or []),
                "sourceStatus": str(item.get("officialConversionStatus") or "ready"),
                "status": "pending",
                "attempts": 0,
            }
            for item in entry_manifest
        ]
        official_queue = (
            {
                "state": "pending",
                "createdAt": completed_at,
                "updatedAt": completed_at,
                "items": queue_items,
                "summary": {
                    "total": len(queue_items),
                    "pending": len(queue_items),
                    "converted": 0,
                    "warnings": sum(1 for item in queue_items if item.get("advancedOnlyFeatures")),
                    "failed": 0,
                },
            }
            if migration_target == TARGET_OFFICIAL
            else {"state": "not_requested", "items": []}
        )
        final_manifest = {
            "formatVersion": MANIFEST_VERSION,
            "type": MANIFEST_TYPE,
            "migrationId": migration_id,
            "state": "local_workspace_migrated",
            "startedAt": started_at,
            "completedAt": completed_at,
            "source": plan.get("source") or {},
            "target": {"root": str(target_root), "version": target_version},
            "migrationTarget": migration_target,
            "sourceFingerprint": plan.get("sourceFingerprint") or "",
            "advancedCopyFingerprint": advanced_copy_fingerprint(target_root),
            "copy": copy_results,
            "pathRebase": rebase_results,
            "registry": registry_result,
            "settings": {
                "sourcePath": str(source_root / "UserData" / "settings.json"),
                "targetPath": str(settings_path),
                "merged": True,
            },
            "liveries": entry_manifest,
            "policy": {
                "allTargetEntriesInactive": True,
                "readOnlyPreserved": True,
                "advancedAndOfficialRemainSeparate": True,
            },
            "runtimeCleanup": {"state": "not_started"},
            "officialMigrationQueue": official_queue,
            "advancedCopyCleanup": {"state": "pending" if migration_target == TARGET_OFFICIAL else "not_requested"},
            "sourceInstallationModified": False,
            "gameFilesModified": False,
        }
        manifest_path = target_root / "UserData" / "Migrations" / migration_id / "migration_manifest.json"
        _atomic_write_json(manifest_path, final_manifest)
        _emit(
            progress,
            "commit",
            "Migration manifest saved.",
            98,
            stageLabel="Finalizing migration",
            current=len(staged_names) + 2,
            total=len(staged_names) + 2,
            totalFiles=total_copy_files,
            filesWritten=completed_files,
            bytesProcessed=total_copy_bytes,
            totalBytes=total_copy_bytes,
            path="UserData/Migrations/migration_manifest.json",
        )
        _failure(failure_hook, "after_manifest")
        _emit(
            progress,
            "complete",
            "Advanced workspace migrated. Restart LiveryLab to continue.",
            100,
            stageLabel="Migration complete",
            current=total_copy_files,
            total=total_copy_files,
            totalFiles=total_copy_files,
            filesWritten=total_copy_files,
            bytesProcessed=total_copy_bytes,
            totalBytes=total_copy_bytes,
        )
        shutil.rmtree(work_root, ignore_errors=True)
        return {
            "status": "completed",
            "migrationId": migration_id,
            "manifestPath": str(manifest_path),
            "sourceRoot": str(source_root),
            "targetRoot": str(target_root),
            "liveryCount": len(entries),
            "editableCount": sum(1 for item in entry_manifest if item.get("editable")),
            "readOnlyCount": sum(1 for item in entry_manifest if item.get("readOnly")),
            "allInactive": True,
            "sourceInstallationModified": False,
            "gameFilesModified": False,
            "restartRequired": True,
            "migrationTarget": migration_target,
            "officialConversionPending": migration_target == TARGET_OFFICIAL,
        }
    except Exception as exc:
        manifest_path = target_root / "UserData" / "Migrations" / migration_id / "migration_manifest.json"
        manifest_path.unlink(missing_ok=True)
        try:
            manifest_path.parent.rmdir()
        except OSError:
            pass
        rollback_errors = _rollback_commit(
            target_root,
            work_root,
            touched_roots,
            root_backups,
            settings_backup,
            settings_existed,
        )
        failure_manifest = {
            "formatVersion": MANIFEST_VERSION,
            "type": MANIFEST_TYPE,
            "migrationId": migration_id,
            "state": "failed_rolled_back" if not rollback_errors else "failed_rollback_incomplete",
            "startedAt": started_at,
            "failedAt": _now_iso(),
            "error": str(exc),
            "rollbackErrors": rollback_errors,
            "source": plan.get("source") or {},
            "target": plan.get("target") or {},
        }
        _write_json(work_root / "migration_failure.json", failure_manifest)
        detail = f"Legacy workspace migration failed and was rolled back: {exc}"
        if rollback_errors:
            detail += "\nRollback warnings:\n" + "\n".join(rollback_errors)
        detail += f"\nDiagnostic files: {work_root}"
        raise RuntimeError(detail) from exc
