#!/usr/bin/env python3
"""
Генератор .torrent для полной установки клиента WoW.

Список файлов и исключения совпадают с generate-client-manifest.py.
Можно взять пути из client-manifest.json, чтобы торрент совпадал с HTTP/CDN.

Использование:
  python generate-client-torrent.py <client-dir>
  python generate-client-torrent.py <client-dir> --manifest client-manifest.json --update-manifest
  python generate-client-torrent.py <client-dir> --use-mktorrent   # если установлен mktorrent

Примеры:
  python generate-client-torrent.py /path/to/WoW \\
    --web-seed https://dl.neix.ru/client/ \\
    --name "AshenOrder Client 3.3.5" \\
    -o ashenorder-client.torrent

  # Когда появится трекер:
  python generate-client-torrent.py /path/to/WoW \\
    --announce https://tracker.example.com/announce \\
    --web-seed https://dl.neix.ru/client/

  python generate-client-torrent.py /path/to/WoW \\
    --manifest client-manifest.json \\
    --torrent-url https://dl.neix.ru/client/ashenorder-client.torrent \\
    --update-manifest
"""

from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path

DEFAULT_CLIENT_BASE_URL = "https://dl.neix.ru/client/"
DEFAULT_TORRENT_URL = "https://dl.neix.ru/client/ashenorder-client.torrent"
DEFAULT_OUTPUT = Path(__file__).resolve().parent.parent / "ashenorder-client.torrent"
DEFAULT_MANIFEST = Path(__file__).resolve().parent.parent / "client-manifest.json"
DEFAULT_PIECE_LENGTH = 1 << 22  # 4 MiB, как в docs/torrent_download.md


def _load_manifest_module():
    spec_path = Path(__file__).with_name("generate-client-manifest.py")
    spec = importlib.util.spec_from_file_location("generate_client_manifest", spec_path)
    if spec is None or spec.loader is None:
        raise RuntimeError(f"Не удалось загрузить {spec_path}")
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def bencode(value) -> bytes:
    if isinstance(value, bool):
        raise TypeError("bool запрещён в bencode")
    if isinstance(value, int):
        return b"i" + str(value).encode("ascii") + b"e"
    if isinstance(value, bytes):
        return str(len(value)).encode("ascii") + b":" + value
    if isinstance(value, str):
        return bencode(value.encode("utf-8"))
    if isinstance(value, list):
        return b"l" + b"".join(bencode(item) for item in value) + b"e"
    if isinstance(value, dict):
        parts = [b"d"]
        for key in sorted(value.keys()):
            if not isinstance(key, (bytes, str)):
                raise TypeError(f"ключ dict должен быть str/bytes: {key!r}")
            parts.append(bencode(key))
            parts.append(bencode(value[key]))
        parts.append(b"e")
        return b"".join(parts)
    raise TypeError(f"неподдерживаемый тип: {type(value)!r}")


def path_to_torrent_parts(rel_path: str) -> list[bytes]:
    return [part.encode("utf-8") for part in rel_path.split("/") if part]


def collect_files_from_manifest(manifest: dict, include_patches: bool) -> list[dict]:
    mod = _load_manifest_module()
    known = mod.collect_known_files(manifest) if include_patches else {}
    if not include_patches:
        for item in manifest.get("client", {}).get("files", []):
            known[item["path"]] = item
    files = sorted(known.values(), key=lambda item: item["path"])
    if not files:
        raise ValueError("В манифесте нет файлов для торрента")
    return files


def resolve_file_entries(
    client_dir: Path,
    manifest_path: Path | None,
    include_patches: bool,
    scan_progress,
) -> list[tuple[str, Path, int]]:
    mod = _load_manifest_module()

    if manifest_path is not None:
        with manifest_path.open("r", encoding="utf-8") as stream:
            manifest = json.load(stream)
        manifest_files = collect_files_from_manifest(manifest, include_patches)
        entries: list[tuple[str, Path, int]] = []
        for item in manifest_files:
            rel = item["path"]
            full = client_dir / rel
            if not full.is_file():
                raise FileNotFoundError(f"Файл из манифеста не найден: {rel}")
            size = full.stat().st_size
            expected = item.get("size")
            if isinstance(expected, int) and expected != size:
                print(
                    f"  ⚠ Размер не совпадает с манифестом: {rel} "
                    f"(диск {size}, манифест {expected})",
                    file=sys.stderr,
                )
            entries.append((rel, full, size))
        return entries

    scanned = mod.scan_files(client_dir, on_progress=scan_progress)
    return [(item["path"], client_dir / item["path"], item["size"]) for item in scanned]


def hash_pieces(
    entries: list[tuple[str, Path, int]],
    piece_length: int,
    on_piece=None,
) -> tuple[bytes, int]:
    hasher = hashlib.sha1()
    pieces: list[bytes] = []
    total_size = sum(size for _, _, size in entries)
    processed = 0
    buffer = bytearray()

    for _, file_path, size in entries:
        with file_path.open("rb") as stream:
            while True:
                need = piece_length - len(buffer)
                chunk = stream.read(need if need else piece_length)
                if not chunk:
                    break
                buffer.extend(chunk)
                processed += len(chunk)
                while len(buffer) >= piece_length:
                    piece = bytes(buffer[:piece_length])
                    del buffer[:piece_length]
                    pieces.append(hashlib.sha1(piece).digest())
                    if on_piece:
                        on_piece(len(pieces), processed, total_size)

    if buffer:
        if len(buffer) < piece_length:
            buffer.extend(b"\x00" * (piece_length - len(buffer)))
        pieces.append(hashlib.sha1(bytes(buffer)).digest())
        if on_piece:
            on_piece(len(pieces), total_size, total_size)

    return b"".join(pieces), total_size


def build_info_dict(
    root_name: str,
    entries: list[tuple[str, Path, int]],
    piece_length: int,
    pieces: bytes,
    private: bool,
) -> dict:
    info: dict = {
        b"name": root_name.encode("utf-8"),
        b"piece length": piece_length,
        b"pieces": pieces,
    }
    if len(entries) == 1:
        info[b"length"] = entries[0][2]
    else:
        info[b"files"] = [
            {
                b"length": size,
                b"path": path_to_torrent_parts(rel),
            }
            for rel, _, size in entries
        ]
    if private:
        info[b"private"] = 1
    return info


def normalize_base_url(url: str) -> str:
    return url.rstrip("/") + "/"


def resolve_web_seeds(
    explicit: list[str] | None,
    manifest_path: Path | None,
) -> list[str]:
    seeds: list[str] = []
    if explicit:
        seeds.extend(explicit)
    elif manifest_path is not None and manifest_path.is_file():
        with manifest_path.open("r", encoding="utf-8") as stream:
            manifest = json.load(stream)
        base = manifest.get("client", {}).get("base_url")
        if isinstance(base, str) and base.strip():
            seeds.append(normalize_base_url(base.strip()))
    if not seeds:
        seeds.append(DEFAULT_CLIENT_BASE_URL)
    # Уникальные URL с завершающим /
    seen: set[str] = set()
    result: list[str] = []
    for seed in seeds:
        normalized = normalize_base_url(seed.strip())
        if normalized not in seen:
            seen.add(normalized)
            result.append(normalized)
    return result


def create_torrent_bytes(
    root_name: str,
    entries: list[tuple[str, Path, int]],
    piece_length: int,
    private: bool,
    comment: str | None,
    announce: str | None = None,
    web_seeds: list[str] | None = None,
    on_piece=None,
) -> tuple[bytes, int, str]:
    if not announce and not web_seeds:
        raise ValueError("Нужен хотя бы --web-seed или --announce")

    pieces, total_size = hash_pieces(entries, piece_length, on_piece=on_piece)
    info = build_info_dict(root_name, entries, piece_length, pieces, private)

    meta: dict = {
        b"creation date": int(time.time()),
        b"created by": b"AshenOrder generate-client-torrent.py",
        b"info": info,
    }
    if announce:
        meta[b"announce"] = announce.encode("utf-8")
    if web_seeds:
        meta[b"url-list"] = [seed.encode("utf-8") for seed in web_seeds]
    if comment:
        meta[b"comment"] = comment.encode("utf-8")

    torrent = bencode(meta)
    info_hash = hashlib.sha1(bencode(info)).hexdigest()
    return torrent, total_size, info_hash


def try_mktorrent(
    client_dir: Path,
    output: Path,
    announce: str | None,
    web_seeds: list[str],
    root_name: str,
    piece_length: int,
) -> bool:
    if shutil.which("mktorrent") is None:
        return False
    if not announce:
        print(
            "  ℹ mktorrent требует --announce; без трекера используйте встроенный генератор",
            file=sys.stderr,
        )
        return False

    piece_exp = 0
    value = piece_length
    while value > 1 and value % 2 == 0:
        piece_exp += 1
        value //= 2
    if 2**piece_exp != piece_length:
        print("  ⚠ mktorrent поддерживает только степени двойки — встроенный генератор", file=sys.stderr)
        return False

    cmd = [
        "mktorrent",
        "-a",
        announce,
        "-n",
        root_name,
        "-l",
        str(piece_exp),
        "-o",
        str(output),
        str(client_dir),
    ]
    print("🔧 Запуск mktorrent:", " ".join(cmd))
    subprocess.run(cmd, check=True)
    if web_seeds:
        print(
            "  ⚠ mktorrent не добавляет web-seed (url-list); "
            "для HTTP-зеркала без трекера используйте встроенный генератор",
            file=sys.stderr,
        )
    return True


def update_manifest_torrent(
    manifest_path: Path,
    torrent_url: str,
    display_name: str,
    total_size: int,
    torrent_file: Path,
) -> None:
    mod = _load_manifest_module()
    manifest = mod.load_manifest(manifest_path) or {}
    manifest["torrent"] = {
        "url": torrent_url,
        "name": display_name,
        "total_size": total_size,
        "file_size": torrent_file.stat().st_size,
        "updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
    }
    if "metadata" in manifest and isinstance(manifest["metadata"], dict):
        manifest["metadata"]["updated_at"] = datetime.now(timezone.utc).isoformat().replace(
            "+00:00", "Z"
        )
    mod.save_manifest(manifest_path, manifest)
    print(f"📝 Секция torrent обновлена в {manifest_path}")


def progress_scan(count: int, rel_path: str) -> None:
    print(f"\r  Сканирование: {count} — {rel_path[:60]}", end="", flush=True)


def progress_pieces(done: int, processed: int, total: int) -> None:
    if total <= 0:
        return
    pct = processed * 100 // total
    print(f"\r  Хеширование кусков: {done} pieces, {pct}%", end="", flush=True)


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description="Генератор .torrent для клиента WoW (совместим с AshenOrder Launcher)"
    )
    parser.add_argument("client_dir", type=Path, help="Каталог установки клиента (Wow.exe, Data/…)")
    parser.add_argument(
        "-o",
        "--output",
        type=Path,
        default=DEFAULT_OUTPUT,
        help=f"Путь к .torrent (по умолчанию: {DEFAULT_OUTPUT.name})",
    )
    parser.add_argument(
        "--announce",
        default=None,
        help="URL BitTorrent-трекера (необязательно; пока нет своего трекера — не указывайте)",
    )
    parser.add_argument(
        "--web-seed",
        dest="web_seeds",
        action="append",
        default=None,
        help=(
            "HTTP web-seed (BEP 19), откуда качаются файлы. "
            f"По умолчанию: client.base_url из манифеста или {DEFAULT_CLIENT_BASE_URL}"
        ),
    )
    parser.add_argument(
        "--name",
        default=None,
        help="Имя корневой папки в торренте (по умолчанию — имя каталога client_dir)",
    )
    parser.add_argument(
        "--display-name",
        default=None,
        help="Человекочитаемое имя для манифеста (torrent.name)",
    )
    parser.add_argument(
        "--piece-length",
        type=int,
        default=DEFAULT_PIECE_LENGTH,
        help=f"Размер куска в байтах (по умолчанию: {DEFAULT_PIECE_LENGTH})",
    )
    parser.add_argument(
        "--manifest",
        type=Path,
        default=None,
        help="Брать список файлов из client-manifest.json вместо полного сканирования",
    )
    parser.add_argument(
        "--include-patches",
        action="store_true",
        help="Включить файлы из patches[] манифеста (вместе с client.files)",
    )
    parser.add_argument(
        "--torrent-url",
        default=DEFAULT_TORRENT_URL,
        help=f"Публичный URL .torrent для манифеста (по умолчанию: {DEFAULT_TORRENT_URL})",
    )
    parser.add_argument(
        "--update-manifest",
        action="store_true",
        help="Записать секцию torrent в --manifest (или client-manifest.json)",
    )
    parser.add_argument(
        "--private",
        action="store_true",
        help="Приватный торрент (private=1)",
    )
    parser.add_argument(
        "--comment",
        default="AshenOrder WoW 3.3.5 client",
        help="Комментарий в .torrent",
    )
    parser.add_argument(
        "--use-mktorrent",
        action="store_true",
        help="Использовать mktorrent, если установлен (быстрее на больших каталогах)",
    )
    return parser


def main() -> int:
    args = build_parser().parse_args()
    client_dir = args.client_dir.expanduser().resolve()
    output = args.output.expanduser().resolve()
    manifest_path = args.manifest.expanduser().resolve() if args.manifest else None

    if args.update_manifest and manifest_path is None:
        manifest_path = DEFAULT_MANIFEST.resolve()

    if not client_dir.is_dir():
        print(f"❌ Каталог не найден: {client_dir}", file=sys.stderr)
        return 1

    root_name = args.name or client_dir.name or "client"
    display_name = args.display_name or f"AshenOrder Client {root_name}"
    web_seeds = resolve_web_seeds(args.web_seeds, manifest_path)

    print("\n🧲 Генерация .torrent")
    print(f"📂 Каталог: {client_dir}")
    print(f"📛 Имя в торренте: {root_name}")
    if args.announce:
        print(f"📡 Трекер: {args.announce}")
    else:
        print("📡 Трекер: не задан (режим web-seed + DHT)")
    print(f"🌐 Web-seed: {', '.join(web_seeds)}")
    print(f"🧩 Размер куска: {args.piece_length} байт")
    if manifest_path:
        print(f"📋 Манифест: {manifest_path}")
    print()

    started = time.time()

    try:
        if args.use_mktorrent and manifest_path is None:
            output.parent.mkdir(parents=True, exist_ok=True)
            if try_mktorrent(
                client_dir,
                output,
                args.announce,
                web_seeds,
                root_name,
                args.piece_length,
            ):
                total_size = sum(
                    p.stat().st_size for p in client_dir.rglob("*") if p.is_file()
                )
                elapsed = time.time() - started
                print(f"\n✅ Торрент сохранён (mktorrent): {output}")
                print(f"📦 Размер содержимого: ~{total_size / (1024 ** 3):.2f} ГБ")
                print(f"📄 Размер .torrent: {output.stat().st_size} байт")
                print(f"⏱ Время: {elapsed:.1f}с")
                if args.update_manifest and manifest_path:
                    update_manifest_torrent(
                        manifest_path,
                        args.torrent_url,
                        display_name,
                        total_size,
                        output,
                    )
                return 0
            print("  ℹ mktorrent недоступен — встроенный генератор\n")

        entries = resolve_file_entries(
            client_dir,
            manifest_path,
            args.include_patches,
            progress_scan,
        )
        print(f"\n  Файлов в торренте: {len(entries)}")

        torrent_bytes, total_size, info_hash = create_torrent_bytes(
            root_name=root_name,
            entries=entries,
            piece_length=args.piece_length,
            private=args.private,
            comment=args.comment,
            announce=args.announce,
            web_seeds=web_seeds,
            on_piece=progress_pieces,
        )
        print()

        output.parent.mkdir(parents=True, exist_ok=True)
        output.write_bytes(torrent_bytes)

        elapsed = time.time() - started

        print(f"✅ Торрент сохранён: {output}")
        print(f"📊 Файлов: {len(entries)}")
        print(f"📦 Размер содержимого: {total_size / (1024 ** 3):.2f} ГБ")
        print(f"📄 Размер .torrent: {len(torrent_bytes)} байт")
        print(f"🔑 Info hash: {info_hash}")
        print(f"⏱ Время: {elapsed:.1f}с")

        if args.update_manifest and manifest_path:
            update_manifest_torrent(
                manifest_path,
                args.torrent_url,
                display_name,
                total_size,
                output,
            )

    except FileNotFoundError as exc:
        print(f"\n❌ {exc}", file=sys.stderr)
        return 1
    except subprocess.CalledProcessError as exc:
        print(f"\n❌ mktorrent завершился с ошибкой: {exc}", file=sys.stderr)
        return 1
    except KeyboardInterrupt:
        print("\n❌ Прервано пользователем", file=sys.stderr)
        return 130

    return 0


if __name__ == "__main__":
    sys.exit(main())
