#!/usr/bin/env python3
"""
Генератор манифеста клиента WoW (nested: client + patches).

Использование:
  python generate-client-manifest.py <client-dir> [--version VER] [--base-url URL] [--output FILE]
  python generate-client-manifest.py <client-dir> --manifest FILE --update-manifest
  python generate-client-manifest.py <patch-dir> --add-patch PATCH_ID [--patch-url URL]
  python generate-client-manifest.py <patch-dir> --add-patch PATCH_ID --manifest FILE --update-manifest

Режим --update-manifest:
  Обновляет существующий манифест вместо полной перегенерации.
  Для клиента: добавляет/обновляет файлы в client.files; с --scan-full-client — удаляет отсутствующие.
  Для патча: заменяет запись с тем же id в patches[] (не дублирует).

Режим --add-patch (по умолчанию):
  <patch-dir> — каталог ТОЛЬКО с файлами патча (как на CDN).
  Сравниваются только отсканированные файлы; удаления не вычисляются.

  Для diff всего клиента с удалениями — флаг --scan-full-client и путь к полной установке.

Примеры:
  python generate-client-manifest.py /path/to/WoW
  python generate-client-manifest.py client --manifest ../client-manifest.json --update-manifest
  python generate-client-manifest.py /path/to/patches/patch-001 --add-patch patch-001
  python generate-client-manifest.py /path/to/patches/patch-002 --add-patch patch-002 \\
    --manifest client-manifest.json --update-manifest
"""

from __future__ import annotations

import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path

DEFAULT_VERSION = "3.3.5.12340"
DEFAULT_BASE_URL = "https://dl.neix.ru/client/"
DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "client-manifest.json"

SKIP_FILES = {"Wow.exe.ppdb"}  # PortProton, не относится к клиенту
SKIP_FILE_SUFFIXES = (".dltmp",)
SKIP_DIRS = {"sound", "WTF", "Cache", "Errors", "GLCache", "Logs"}
SKIP_PATH_PATTERNS = ("Interface/AddOns", "Data/ruRU/Documentation")
PATCH_FILE_KEYS = ("files_added", "files_updated", "files")

CHUNK_SIZE = 64 * 1024 * 1024


def utc_now() -> str:
    return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")


def parse_build(version: str) -> int:
    try:
        return int(version.rsplit(".", 1)[-1])
    except (ValueError, IndexError):
        return 12340


def normalize_rel_path(path: Path) -> str:
    return path.as_posix()


def should_skip_dir(rel_path: str, dir_name: str) -> bool:
    if dir_name in SKIP_DIRS:
        return True
    return any(
        rel_path == pattern or rel_path.endswith("/" + pattern)
        for pattern in SKIP_PATH_PATTERNS
    )


def compute_sha256(file_path: Path) -> str:
    digest = hashlib.sha256()
    with file_path.open("rb") as stream:
        while True:
            chunk = stream.read(CHUNK_SIZE)
            if not chunk:
                break
            digest.update(chunk)
    return digest.hexdigest()


def scan_files(client_dir: Path, on_progress=None) -> list[dict]:
    if not client_dir.is_dir():
        raise FileNotFoundError(f"Каталог не найден: {client_dir}")

    files: list[dict] = []
    count = 0

    for entry in sorted(client_dir.rglob("*")):
        if not entry.is_file():
            continue

        rel = entry.relative_to(client_dir)
        rel_str = normalize_rel_path(rel)

        if any(part.startswith(".") for part in rel.parts):
            continue
        if ".launcher_download" in rel.parts:
            continue
        if entry.name in SKIP_FILES:
            continue
        if any(entry.name.endswith(suffix) for suffix in SKIP_FILE_SUFFIXES):
            continue

        if any(part in SKIP_DIRS for part in rel.parts[:-1]):
            continue
        if any(
            rel_str == pattern or rel_str.startswith(pattern + "/")
            for pattern in SKIP_PATH_PATTERNS
        ):
            continue

        try:
            stats = entry.stat()
            file_hash = compute_sha256(entry)
            files.append(
                {
                    "path": rel_str,
                    "hash": f"sha256:{file_hash}",
                    "size": stats.st_size,
                    "updated_at": datetime.fromtimestamp(
                        stats.st_mtime, tz=timezone.utc
                    )
                    .isoformat()
                    .replace("+00:00", "Z"),
                }
            )
            count += 1
            if on_progress:
                on_progress(count, rel_str)
        except OSError as exc:
            print(f"  ⚠ Ошибка: {rel_str} — {exc}", file=sys.stderr)

    files.sort(key=lambda item: item["path"])
    return files


def load_manifest(path: Path) -> dict | None:
    if not path.is_file():
        return None
    with path.open("r", encoding="utf-8") as stream:
        return json.load(stream)


def save_manifest(path: Path, manifest: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("w", encoding="utf-8") as stream:
        json.dump(manifest, stream, ensure_ascii=False, indent=2)
        stream.write("\n")


def summarize_client(client: dict) -> None:
    total_size = sum(item["size"] for item in client.get("files", []))
    client["total_size"] = total_size
    client["total_files"] = len(client.get("files", []))


def merge_files_removed(existing: list | None, new_paths: list[str]) -> list[str]:
    merged: set[str] = set()
    for item in existing or []:
        if isinstance(item, str) and item:
            merged.add(item)
    merged.update(path for path in new_paths if path)
    return sorted(merged)


def clear_readded_from_removed(client: dict, paths: list[str]) -> None:
    removed = client.get("files_removed")
    if not removed:
        return
    readd = set(paths)
    client["files_removed"] = [path for path in removed if path not in readd]


def detect_missing_files_on_disk(client_dir: Path, file_entries: list[dict]) -> list[str]:
    missing: list[str] = []
    for item in file_entries:
        rel = item.get("path")
        if not rel:
            continue
        if not (client_dir / rel).is_file():
            missing.append(rel)
    return missing


def collect_patch_entry_paths(patch: dict) -> set[str]:
    paths: set[str] = set()
    for key in PATCH_FILE_KEYS:
        for item in patch.get(key, []):
            if isinstance(item, dict) and item.get("path"):
                paths.add(item["path"])
    for item in patch.get("files_removed", []):
        if isinstance(item, str) and item:
            paths.add(item)
    return paths


def progress_writer() -> callable:
    def write(count: int, rel_path: str) -> None:
        print(f"\r  Обработано файлов: {count} — {rel_path[:60]}", end="", flush=True)

    return write


def find_patch_index(manifest: dict, patch_id: str) -> int | None:
    variants = _patch_id_variants(patch_id)
    for index, patch in enumerate(manifest.get("patches", [])):
        pid = patch.get("id", "")
        if pid == patch_id or pid in variants:
            return index
    return None


def remove_patch_by_id(manifest: dict, patch_id: str) -> bool:
    index = find_patch_index(manifest, patch_id)
    if index is None:
        return False
    manifest.setdefault("patches", []).pop(index)
    return True


def update_client_manifest(
    client_dir: Path,
    version: str,
    base_url: str | None,
    output: Path,
    scan_full_client: bool,
) -> None:
    print("\n🔄 Обновление манифеста клиента")
    print(f"📂 Каталог: {client_dir}")
    print(f"📋 Манифест: {output}")
    print(f"📋 Версия: {version}")
    if scan_full_client:
        print("📋 Режим: полное сканирование (с удалениями)")
    else:
        print("📋 Режим: добавление/обновление + проверка отсутствующих на диске")
    print()

    existing = load_manifest(output)
    if not existing:
        print(
            f"❌ Манифест не найден: {output}. Сначала создайте полный манифест "
            f"или укажите существующий файл в --manifest.",
            file=sys.stderr,
        )
        sys.exit(1)

    started = datetime.now(timezone.utc)
    new_files = scan_files(client_dir, on_progress=progress_writer())
    print()

    old_by_path = {
        item["path"]: item for item in existing.get("client", {}).get("files", [])
    }
    new_by_path = {item["path"]: item for item in new_files}

    files_added: list[dict] = []
    files_updated: list[dict] = []
    files_removed: list[str] = []

    for path, new_file in new_by_path.items():
        old_file = old_by_path.get(path)
        if old_file is None:
            files_added.append(new_file)
            continue
        if (
            old_file.get("hash") != new_file["hash"]
            or old_file.get("size") != new_file["size"]
        ):
            files_updated.append(new_file)

    files_removed = detect_missing_files_on_disk(client_dir, list(old_by_path.values()))
    if scan_full_client:
        for path in old_by_path:
            if path not in new_by_path and path not in files_removed:
                files_removed.append(path)
    files_removed = sorted(set(files_removed))

    readded_paths = [item["path"] for item in files_added + files_updated]

    if not files_added and not files_updated and not files_removed:
        print("ℹ️  Изменений не обнаружено — манифест не изменён.")
        return

    merged = dict(old_by_path)
    for item in files_added + files_updated:
        merged[item["path"]] = item

    client = existing.setdefault("client", {})
    if base_url:
        client["base_url"] = base_url.rstrip("/") + "/"
    client["files"] = sorted(merged.values(), key=lambda item: item["path"])

    if files_removed:
        client["files_removed"] = merge_files_removed(
            client.get("files_removed"), files_removed
        )
        removed_set = set(files_removed)
        client["files"] = [
            item for item in client["files"] if item["path"] not in removed_set
        ]

    if readded_paths:
        clear_readded_from_removed(client, readded_paths)

    summarize_client(client)

    metadata = existing.setdefault("metadata", {})
    metadata["version"] = version
    metadata["build"] = parse_build(version)
    metadata.setdefault("created_at", utc_now())
    metadata["updated_at"] = utc_now()

    save_manifest(output, existing)

    elapsed = (datetime.now(timezone.utc) - started).total_seconds()
    print(f"\n✅ Манифест обновлён: {output}")
    print(f"📥 Добавлено файлов: {len(files_added)}")
    print(f"🔄 Обновлено файлов: {len(files_updated)}")
    print(f"🗑 Удалено файлов: {len(files_removed)}")
    if client.get("files_removed"):
        print(f"📋 Всего в client.files_removed: {len(client['files_removed'])}")
    print(f"📊 Всего в client.files: {client['total_files']}")
    print(f"📦 Размер client: {client['total_size'] / (1024 ** 3):.2f} ГБ")
    print(f"⏱ Время: {elapsed:.2f}с")

    if files_removed:
        print("\n🗑 Удалённые файлы:")
        for rel_path in files_removed:
            print(f"   - {rel_path}")


def generate_full_manifest(
    client_dir: Path,
    version: str,
    base_url: str,
    output: Path,
    mirrors: list[str] | None,
) -> None:
    print("\n📦 Генерация полного манифеста клиента")
    print(f"📂 Каталог: {client_dir}")
    print(f"📋 Версия: {version}")
    print(f"🌐 Base URL: {base_url}\n")

    started = datetime.now(timezone.utc)
    files = scan_files(client_dir, on_progress=progress_writer())
    print()

    client = {
        "base_url": base_url.rstrip("/") + "/",
        "files": files,
        "files_removed": [],
    }
    summarize_client(client)

    manifest: dict = {
        "metadata": {
            "version": version,
            "build": parse_build(version),
            "created_at": utc_now(),
            "updated_at": utc_now(),
        },
        "client": client,
        "patches": [],
    }

    if mirrors:
        manifest["mirrors"] = mirrors

    save_manifest(output, manifest)

    elapsed = (datetime.now(timezone.utc) - started).total_seconds()
    print(f"\n✅ Манифест сохранён: {output}")
    print(f"📊 Файлов: {client['total_files']}")
    print(f"📦 Размер: {client['total_size'] / (1024 ** 3):.2f} ГБ")
    print(f"⏱ Время: {elapsed:.2f}с")


def _patch_id_variants(patch_id: str) -> set[str]:
    """patch-001 ↔ patches-001 и имя каталога на диске."""
    variants = {patch_id}
    if patch_id.startswith("patch-"):
        variants.add("patches-" + patch_id[len("patch-") :])
    elif patch_id.startswith("patches-"):
        variants.add("patch-" + patch_id[len("patches-") :])
    return variants


def detect_patch_path_prefix(
    files: list[dict],
    patch_id: str,
    scan_dir: Path,
    strip_prefix: str | None,
    auto_strip: bool,
) -> str | None:
    """Префикс каталога патча в path, который нужно убрать (уже есть в base_url)."""
    if not files or not auto_strip:
        return strip_prefix.strip("/") if strip_prefix else None

    if strip_prefix:
        return strip_prefix.strip("/")

    variants = _patch_id_variants(patch_id)
    variants.add(scan_dir.name)

    # Все файлы под одним верхним каталогом — patch-001/Data/...
    top_levels: set[str] = set()
    for item in files:
        parts = item["path"].split("/", 1)
        top_levels.add(parts[0] if len(parts) > 1 else "")

    if len(top_levels) == 1:
        segment = top_levels.pop()
        if segment and segment in variants:
            return segment

    # Явный префикс patch_id в каждом пути
    for candidate in variants:
        needle = candidate + "/"
        if all(item["path"].startswith(needle) for item in files):
            return candidate

    return None


def strip_path_prefix(files: list[dict], prefix: str | None) -> tuple[list[dict], int]:
    """Убирает patch-001/ из path → пути относительно корня игры (Data/...)."""
    if not prefix:
        return files, 0

    needle = prefix.rstrip("/") + "/"
    stripped: list[dict] = []
    count = 0

    for item in files:
        path = item["path"]
        if path.startswith(needle):
            path = path[len(needle) :]
            count += 1
        stripped.append({**item, "path": path})

    stripped.sort(key=lambda x: x["path"])
    return stripped, count


def collect_known_files(manifest: dict) -> dict[str, dict]:
    known: dict[str, dict] = {}

    for item in manifest.get("client", {}).get("files", []):
        known[item["path"]] = item

    for patch in manifest.get("patches", []):
        for key in PATCH_FILE_KEYS:
            for item in patch.get(key, []):
                if isinstance(item, dict) and "path" in item:
                    known[item["path"]] = item

    return known


def patch_file_paths(manifest: dict) -> set[str]:
    paths: set[str] = set()
    for patch in manifest.get("patches", []):
        for key in PATCH_FILE_KEYS:
            for item in patch.get(key, []):
                if isinstance(item, dict) and item.get("path"):
                    paths.add(item["path"])
    return paths


def purge_path_from_previous_patches(path: str, manifest: dict) -> None:
    """Убрать устаревшую запись из старых патчей в манифесте."""
    for patch in manifest.get("patches", []):
        for key in PATCH_FILE_KEYS:
            if not isinstance(patch.get(key), list):
                continue
            patch[key] = [f for f in patch[key] if f.get("path") != path]


def classify_patch_changes(
    new_files: list[dict],
    known_files: dict[str, dict],
    existing: dict,
    scan_full_client: bool,
) -> tuple[list[dict], list[dict], list[str]]:
    """
    Изменённые файлы патчей: files_removed (старый) + files_added (новый).
    Файлы базового client — files_updated.
    """
    patch_paths = patch_file_paths(existing)
    files_added: list[dict] = []
    files_updated: list[dict] = []
    files_removed: list[str] = []

    for new_file in new_files:
        path = new_file["path"]
        old = known_files.get(path)
        if old is None:
            files_added.append(new_file)
            continue

        if old.get("hash") == new_file["hash"] and old.get("size") == new_file["size"]:
            continue

        if path in patch_paths:
            files_removed.append(path)
            files_added.append(new_file)
            purge_path_from_previous_patches(path, existing)
        else:
            files_updated.append(new_file)

    new_paths = {f["path"] for f in new_files}
    for new_file in list(files_added):
        for old_path, old in known_files.items():
            if old_path == new_file["path"] or old_path in new_paths:
                continue
            if old_path not in patch_paths:
                continue
            if old.get("hash") == new_file["hash"] and old_path not in files_removed:
                files_removed.append(old_path)
                purge_path_from_previous_patches(old_path, existing)

    if scan_full_client:
        for path in known_files:
            if path not in new_paths and path not in files_removed:
                files_removed.append(path)

    return files_added, files_updated, sorted(set(files_removed))


def apply_removals_to_manifest(existing: dict, files_removed: list[str]) -> None:
    if not files_removed:
        return
    removed_set = set(files_removed)
    client = existing.setdefault("client", {})
    client["files_removed"] = merge_files_removed(client.get("files_removed"), files_removed)
    client["files"] = [
        item for item in client.get("files", []) if item["path"] not in removed_set
    ]
    for prev_patch in existing.get("patches", []):
        for key in PATCH_FILE_KEYS:
            if isinstance(prev_patch.get(key), list):
                prev_patch[key] = [
                    item for item in prev_patch[key] if item.get("path") not in removed_set
                ]


def generate_patch(
    client_dir: Path,
    patch_id: str,
    version: str,
    patch_base_url: str,
    output: Path,
    scan_full_client: bool = False,
    strip_prefix: str | None = None,
    auto_strip: bool = True,
    replace_existing: bool = False,
) -> None:
    mode_label = "полный клиент (с удалениями)" if scan_full_client else "только файлы патча"
    print(f"\n🔧 Генерация патча {patch_id}")
    print(f"📂 Каталог: {client_dir}")
    print(f"📋 Режим: {mode_label}")
    print(f"🌐 Base URL: {patch_base_url}\n")

    existing = load_manifest(output)
    if not existing:
        print(
            f"❌ Манифест не найден: {output}. Сначала создайте полный манифест.",
            file=sys.stderr,
        )
        sys.exit(1)

    previous_patch_paths: set[str] = set()
    previous_files_removed: list[str] = []
    patch_index = find_patch_index(existing, patch_id)
    if patch_index is not None:
        old_patch = existing["patches"][patch_index]
        previous_patch_paths = collect_patch_entry_paths(old_patch)
        previous_files_removed = list(old_patch.get("files_removed", []))

    if replace_existing and patch_index is not None:
        remove_patch_by_id(existing, patch_id)
        print(f"ℹ️  Заменён существующий патч «{patch_id}» в манифесте")
    elif patch_index is not None:
        print(
            f"❌ Патч «{patch_id}» уже есть в манифесте. "
            f"Используйте --update-manifest для замены.",
            file=sys.stderr,
        )
        sys.exit(1)

    started = datetime.now(timezone.utc)
    new_files = scan_files(client_dir, on_progress=progress_writer())
    print()

    if not new_files:
        print("❌ В каталоге не найдено файлов для патча.", file=sys.stderr)
        sys.exit(1)

    if not scan_full_client:
        prefix = detect_patch_path_prefix(
            new_files, patch_id, client_dir, strip_prefix, auto_strip
        )
        new_files, stripped_count = strip_path_prefix(new_files, prefix)
        if prefix and stripped_count:
            print(
                f"ℹ️  Убран префикс «{prefix}/» из {stripped_count} путей "
                f"(в манифесте: Data/..., на CDN: .../patches/{patch_id}/Data/...)"
            )
        elif prefix and not stripped_count:
            print(f"ℹ️  Префикс «{prefix}/» не найден в путях — оставлены как есть")

    known_files = collect_known_files(existing)

    files_added, files_updated, files_removed = classify_patch_changes(
        new_files, known_files, existing, scan_full_client
    )

    new_paths = {item["path"] for item in new_files}
    if previous_patch_paths:
        for path in sorted(previous_patch_paths - new_paths):
            if path not in files_removed:
                files_removed.append(path)
        for path in detect_missing_files_on_disk(
            client_dir, [{"path": rel} for rel in previous_patch_paths]
        ):
            if path not in files_removed:
                files_removed.append(path)

    files_removed = merge_files_removed(previous_files_removed, files_removed)
    readded_paths = [item["path"] for item in files_added + files_updated]
    files_removed = [path for path in files_removed if path not in readded_paths]

    if files_removed:
        print(
            f"ℹ️  В files_removed: {len(files_removed)} "
            f"(отсутствуют на диске или сняты с патча)"
        )
    elif not scan_full_client:
        print(
            "ℹ️  Удаления базового client.files не вычисляются (каталог = только патч). "
            "Для diff всего клиента — --scan-full-client."
        )

    patch_files = sorted(files_added + files_updated, key=lambda item: item["path"])
    patch_size = sum(item["size"] for item in patch_files)

    if not patch_files and not files_removed:
        print("❌ Нет изменений относительно манифеста — патч пустой.", file=sys.stderr)
        sys.exit(1)

    apply_removals_to_manifest(existing, files_removed)

    patch = {
        "id": patch_id,
        "version": version,
        "created_at": utc_now(),
        "base_url": patch_base_url.rstrip("/") + "/",
        "total_size": patch_size,
        "total_files": len(patch_files),
        "files_added": sorted(files_added, key=lambda item: item["path"]),
        "files_updated": sorted(files_updated, key=lambda item: item["path"]),
        "files_removed": files_removed,
        "files": patch_files,
    }

    metadata = existing.setdefault("metadata", {})
    metadata["version"] = version
    metadata["build"] = parse_build(version)
    metadata["updated_at"] = utc_now()

    summarize_client(existing["client"])
    existing.setdefault("patches", []).append(patch)

    save_manifest(output, existing)

    elapsed = (datetime.now(timezone.utc) - started).total_seconds()
    print(f"\n✅ Патч {patch_id} сохранён в манифест")
    print(f"📥 Добавлено файлов: {len(files_added)}")
    print(f"🔄 Обновлено файлов: {len(files_updated)}")
    print(f"🗑 Удалено файлов: {len(files_removed)}")
    print(f"📦 Размер патча: {patch_size / (1024 ** 2):.1f} МБ")
    print(f"⏱ Время: {elapsed:.2f}с")

    if files_removed:
        print("\n🗑 Удалённые файлы:")
        for rel_path in files_removed:
            print(f"   - {rel_path}")


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Генерация nested-манифеста клиента WoW (client + patches)."
    )
    parser.add_argument(
        "client_dir",
        type=Path,
        help="Каталог клиента (полный манифест) или каталог с файлами патча (--add-patch)",
    )
    parser.add_argument(
        "--version",
        default=DEFAULT_VERSION,
        help=f"Версия клиента (по умолчанию: {DEFAULT_VERSION})",
    )
    parser.add_argument(
        "--base-url",
        default=DEFAULT_BASE_URL,
        help=f"CDN base URL для базового клиента (по умолчанию: {DEFAULT_BASE_URL})",
    )
    parser.add_argument(
        "--output",
        type=Path,
        default=None,
        help=f"Путь к JSON-манифесту (по умолчанию: {DEFAULT_OUTPUT})",
    )
    parser.add_argument(
        "--manifest",
        type=Path,
        default=None,
        help="Путь к существующему client-manifest.json (алиас --output, для --update-manifest)",
    )
    parser.add_argument(
        "--update-manifest",
        action="store_true",
        help="Обновить существующий манифест (client.files или заменить патч с тем же id)",
    )
    parser.add_argument(
        "--mirrors",
        nargs="*",
        default=None,
        help="Дополнительные CDN-зеркала (origin или URL с путём)",
    )
    parser.add_argument(
        "--add-patch",
        metavar="PATCH_ID",
        help="Добавить патч к существующему манифесту (например: patch-002)",
    )
    parser.add_argument(
        "--scan-full-client",
        action="store_true",
        help="Сканировать полную установку и вычислять удалённые файлы (иначе — только каталог патча)",
    )
    parser.add_argument(
        "--patch-url",
        default=None,
        help="Base URL патча (по умолчанию: https://dl.neix.ru/patches/<PATCH_ID>/)",
    )
    parser.add_argument(
        "--strip-prefix",
        default=None,
        metavar="PREFIX",
        help="Убрать префикс из path (например patch-001). По умолчанию определяется автоматически",
    )
    parser.add_argument(
        "--no-strip-prefix",
        action="store_true",
        help="Не убирать префикс каталога патча из path",
    )
    return parser


def resolve_manifest_path(args: argparse.Namespace) -> Path:
    if args.manifest is not None:
        return args.manifest.expanduser().resolve()
    if args.output is not None:
        return args.output.expanduser().resolve()
    return DEFAULT_OUTPUT


def main() -> int:
    parser = build_parser()
    args = parser.parse_args()

    client_dir = args.client_dir.expanduser().resolve()
    output = resolve_manifest_path(args)

    if args.update_manifest and args.manifest is None and args.output is None:
        output = DEFAULT_OUTPUT.resolve()

    try:
        if args.add_patch:
            patch_id = args.add_patch
            patch_url = args.patch_url or f"https://dl.neix.ru/patches/{patch_id}/"
            generate_patch(
                client_dir=client_dir,
                patch_id=patch_id,
                version=args.version,
                patch_base_url=patch_url,
                output=output,
                scan_full_client=args.scan_full_client,
                strip_prefix=args.strip_prefix,
                auto_strip=not args.no_strip_prefix,
                replace_existing=args.update_manifest,
            )
        elif args.update_manifest:
            update_client_manifest(
                client_dir=client_dir,
                version=args.version,
                base_url=args.base_url,
                output=output,
                scan_full_client=args.scan_full_client,
            )
        else:
            generate_full_manifest(
                client_dir=client_dir,
                version=args.version,
                base_url=args.base_url,
                output=output,
                mirrors=args.mirrors,
            )
    except FileNotFoundError as exc:
        print(f"❌ {exc}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("\n❌ Прервано пользователем", file=sys.stderr)
        return 130

    return 0


if __name__ == "__main__":
    raise SystemExit(main())
