#!/usr/bin/env python3
"""Synchronize a Stardew Mods directory from the staged manifest before launch."""

from __future__ import annotations

import argparse
import hashlib
import json
import os
import re
from pathlib import Path
import shutil
import stat
import subprocess
import sys
import tempfile
from urllib.error import HTTPError, URLError
from urllib.parse import quote, urljoin
from urllib.request import urlopen
import uuid
import zipfile


DEFAULT_FEED = "https://stardew.thetorg.org/"
STATE_FILE = ".stardew-modsync-manifest.json"
UPDATE_CONTROL_FILE = "thors-fjord-update.txt"
DISABLE_REMOTE_UPDATE = "disable remote update"


def remote_update_disabled(game_dir: Path) -> bool:
    """Return whether the game-root control file disables remote updates."""
    control_path = game_dir / UPDATE_CONTROL_FILE
    try:
        mode = " ".join(control_path.read_text(encoding="utf-8-sig").split()).casefold()
    except (OSError, UnicodeError):
        return False
    return mode == DISABLE_REMOTE_UPDATE


def launch_command(raw_command: list[str], game_dir: Path) -> int:
    command = raw_command[1:] if raw_command and raw_command[0] == "--" else raw_command
    if not command:
        return 0
    try:
        return subprocess.call(command, cwd=game_dir)
    except OSError as exc:
        print(f"couldn't launch the installed game: {exc}", file=sys.stderr)
        return 1


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        for chunk in iter(lambda: stream.read(1024 * 1024), b""):
            digest.update(chunk)
    return digest.hexdigest()


def fetch(feed: str, relative: str, destination: Path) -> None:
    local_feed = Path(feed).expanduser()
    if local_feed.is_dir():
        shutil.copy2(local_feed / Path(relative), destination)
        return
    url = urljoin(feed.rstrip("/") + "/", quote(relative, safe="/"))
    try:
        with urlopen(url, timeout=60) as response:
            with destination.open("wb") as output:
                shutil.copyfileobj(response, output)
    except (HTTPError, URLError) as exc:
        raise ValueError(f"couldn't download {relative}: {exc}") from exc


def safe_name(value: object, label: str) -> str:
    if not isinstance(value, str) or not value or value in {".", ".."}:
        raise ValueError(f"invalid {label}")
    if Path(value).name != value or "/" in value or "\\" in value or value.startswith("."):
        raise ValueError(f"unsafe {label}: {value!r}")
    return value


def validate_manifest(raw: object) -> tuple[dict[str, dict], dict[str, list[Path]], str, bool]:
    if not isinstance(raw, dict) or raw.get("schemaVersion") != 1 or raw.get("hashAlgorithm") != "sha256":
        raise ValueError("unsupported manifest")
    entries: dict[str, dict] = {}
    for entry in raw.get("mods", []):
        if not isinstance(entry, dict):
            raise ValueError("invalid manifest mod entry")
        name = safe_name(entry.get("sourceDirectory"), "mod directory")
        zip_name = safe_name(entry.get("zip"), "zip name")
        if re.fullmatch(r"[A-Za-z0-9]+\.zip", zip_name) is None or entry.get("path") != f"Mods/{zip_name}":
            raise ValueError(f"inconsistent manifest path for {name}")
        digest = entry.get("sha256")
        size = entry.get("bytes")
        if (
            not isinstance(digest, str)
            or len(digest) != 64
            or any(character not in "0123456789abcdef" for character in digest)
            or not isinstance(size, int)
            or isinstance(size, bool)
            or size < 0
            or name in entries
        ):
            raise ValueError(f"invalid manifest metadata for {name}")
        entries[name] = entry

    preservation = raw.get("preservation", {})
    if not isinstance(preservation, dict):
        raise ValueError("invalid preservation rules")
    paths: dict[str, list[Path]] = {}
    for raw_path in preservation.get("paths", []):
        if not isinstance(raw_path, str):
            raise ValueError("invalid preservation path")
        path = Path(raw_path)
        if path.is_absolute() or len(path.parts) < 2 or any(part in {".", ".."} for part in path.parts):
            raise ValueError(f"unsafe preservation path: {raw_path!r}")
        paths.setdefault(path.parts[0], []).append(Path(*path.parts[1:]))
    suffix = preservation.get("saveDataSuffix", "_SaveData.save")
    paired = preservation.get("preservePairedJson", True)
    if not isinstance(suffix, str) or not suffix or not isinstance(paired, bool):
        raise ValueError("invalid preservation rules")
    return entries, paths, suffix, paired


def validate_archive(path: Path, mod_name: str) -> None:
    with zipfile.ZipFile(path) as archive:
        infos = archive.infolist()
        if not infos:
            raise ValueError(f"empty archive: {path.name}")
        for info in infos:
            name = info.filename.replace("\\", "/")
            parts = [part for part in name.split("/") if part]
            if not parts or parts[0] != mod_name or any(part in {".", ".."} for part in parts):
                raise ValueError(f"unsafe archive entry in {path.name}: {info.filename!r}")
            mode = info.external_attr >> 16
            if stat.S_ISLNK(mode):
                raise ValueError(f"symlink archive entry in {path.name}: {info.filename!r}")


def load_state(path: Path) -> dict[str, str]:
    if not path.is_file():
        return {}
    try:
        raw = json.loads(path.read_text(encoding="utf-8"))
        mods = raw.get("mods", {})
        if isinstance(mods, dict) and all(isinstance(k, str) and isinstance(v, str) for k, v in mods.items()):
            return mods
    except (OSError, ValueError):
        pass
    return {}


def preservation_files(
    live_root: Path,
    declared: list[Path],
    save_suffix: str,
    preserve_paired_json: bool,
) -> set[Path]:
    preserved: set[Path] = set()
    if not live_root.is_dir():
        return preserved
    for relative in declared:
        target = live_root / relative
        if target.is_symlink():
            raise ValueError(f"refusing symlinked player-state path: {target}")
        if target.is_file():
            preserved.add(relative)
        elif target.is_dir():
            for path in target.rglob("*"):
                if path.is_symlink():
                    raise ValueError(f"refusing symlinked player-state path: {path}")
                if path.is_file():
                    preserved.add(path.relative_to(live_root))
    for save_file in live_root.rglob(f"*{save_suffix}"):
        if save_file.is_symlink():
            raise ValueError(f"refusing symlinked player-state path: {save_file}")
        if save_file.is_file():
            relative = save_file.relative_to(live_root)
            preserved.add(relative)
            if preserve_paired_json:
                stem = save_file.name[: -len(save_suffix)]
                paired = save_file.with_name(f"{stem}.json")
                if paired.is_file() and not paired.is_symlink():
                    preserved.add(paired.relative_to(live_root))
    return preserved


def remove_transaction_if_safe(transaction: Path) -> None:
    """Remove a completed transaction, but never erase a retained backup after rollback failure."""
    backup = transaction / "old"
    if backup.is_dir() and any(backup.iterdir()):
        print(f"mod sync recovery backup retained at: {transaction}", file=sys.stderr)
        return
    try:
        shutil.rmtree(transaction)
    except OSError as exc:
        print(f"couldn't remove completed mod sync transaction {transaction}: {exc}", file=sys.stderr)


def synchronize(args: argparse.Namespace, game_dir: Path) -> None:
    mods_dir = game_dir / "Mods"
    if not mods_dir.is_dir() or mods_dir.is_symlink() or mods_dir == Path("/"):
        raise ValueError(f"unsafe or missing Mods directory: {mods_dir}")
    state_path = game_dir / STATE_FILE

    with tempfile.TemporaryDirectory(prefix="stardew-modsync-") as raw_downloads:
        downloads = Path(raw_downloads)
        manifest_path = downloads / "manifest.json"
        fetch(args.feed, "manifest.json", manifest_path)
        manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
        entries, declared_paths, save_suffix, paired_json = validate_manifest(manifest)
        prior = load_state(state_path)
        local_names = {
            path.name for path in mods_dir.iterdir() if path.is_dir() and not path.name.startswith(".")
        }
        removed = sorted(local_names - set(entries), key=str.casefold)
        changed = sorted(
            (
                name
                for name, entry in entries.items()
                if prior.get(name) != entry["sha256"] or not (mods_dir / name).is_dir()
            ),
            key=str.casefold,
        )
        print(f"Mod sync: {len(changed)} update(s), {len(removed)} removal(s), {len(entries) - len(changed)} current.")
        for name in removed:
            print(f"  REMOVE {name}")
        for name in changed:
            print(f"  UPDATE {name}")
        if args.dry_run:
            return

        archives: dict[str, Path] = {}
        download_names = sorted(entries, key=str.casefold) if args.validate_only else changed
        for name in download_names:
            entry = entries[name]
            archive_path = downloads / entry["zip"]
            fetch(args.feed, entry["path"], archive_path)
            if archive_path.stat().st_size != entry["bytes"] or sha256(archive_path) != entry["sha256"]:
                raise ValueError(f"download verification failed: {name}")
            validate_archive(archive_path, name)
            archives[name] = archive_path

        if args.validate_only:
            print(f"Mod sync validation complete: {len(archives)} archive(s) verified.")
            return

        transaction = mods_dir / f".modsync-transaction-{uuid.uuid4().hex}"
        staged = transaction / "new"
        backup = transaction / "old"
        quarantine = transaction / "failed-new"
        staged.mkdir(parents=True)
        backup.mkdir()
        quarantine.mkdir()
        moved_old: list[str] = []
        installed_new: list[str] = []
        committed = False
        retain_transaction = False
        pending_state: Path | None = None
        try:
            for name, archive_path in archives.items():
                with zipfile.ZipFile(archive_path) as archive:
                    archive.extractall(staged)
                new_root = staged / name
                if not new_root.is_dir():
                    raise ValueError(f"archive did not create expected directory: {name}")
                live_root = mods_dir / name
                for relative in preservation_files(
                    live_root, declared_paths.get(name, []), save_suffix, paired_json
                ):
                    destination = new_root / relative
                    destination.parent.mkdir(parents=True, exist_ok=True)
                    shutil.copy2(live_root / relative, destination)

            for name in [*removed, *changed]:
                live_root = mods_dir / name
                if live_root.exists():
                    os.replace(live_root, backup / name)
                    moved_old.append(name)
            for name in changed:
                os.replace(staged / name, mods_dir / name)
                installed_new.append(name)

            state = {
                "schemaVersion": 1,
                "manifestGeneratedAt": manifest.get("generatedAt"),
                "mods": {name: entry["sha256"] for name, entry in entries.items()},
            }
            pending_state = game_dir / f".{STATE_FILE}.{uuid.uuid4().hex}.tmp"
            pending_state.write_text(json.dumps(state, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
            os.replace(pending_state, state_path)
            pending_state = None
            committed = True
        except Exception as sync_error:
            rollback_errors: list[str] = []
            for name in reversed(installed_new):
                target = mods_dir / name
                if target.exists():
                    try:
                        os.replace(target, quarantine / name)
                    except OSError as exc:
                        rollback_errors.append(f"couldn't quarantine new {name}: {exc}")
            for name in reversed(moved_old):
                old = backup / name
                target = mods_dir / name
                if target.exists():
                    rollback_errors.append(f"couldn't restore {name}: destination still exists")
                elif not old.exists():
                    rollback_errors.append(f"couldn't restore {name}: backup is missing")
                else:
                    try:
                        os.replace(old, target)
                    except OSError as exc:
                        rollback_errors.append(f"couldn't restore {name}: {exc}")
            if rollback_errors:
                retain_transaction = True
                details = "; ".join(rollback_errors)
                raise RuntimeError(
                    f"mod sync failed and rollback was incomplete; recovery retained at {transaction}: {details}"
                ) from sync_error
            raise
        finally:
            if pending_state is not None:
                try:
                    pending_state.unlink(missing_ok=True)
                except OSError:
                    pass
            if committed:
                try:
                    shutil.rmtree(transaction)
                except OSError as exc:
                    print(f"couldn't remove completed mod sync transaction {transaction}: {exc}", file=sys.stderr)
            elif retain_transaction:
                print(f"mod sync recovery transaction retained at: {transaction}", file=sys.stderr)
            elif transaction.exists():
                remove_transaction_if_safe(transaction)

    print("Mod sync complete.")


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--feed", default=os.environ.get("STARDEW_MODSYNC_FEED", DEFAULT_FEED))
    parser.add_argument("--game-dir", type=Path, required=True)
    modes = parser.add_mutually_exclusive_group()
    modes.add_argument("--dry-run", action="store_true")
    modes.add_argument("--validate-only", action="store_true")
    parser.add_argument("launch", nargs=argparse.REMAINDER, help="command to launch after syncing")
    args = parser.parse_args()

    game_dir = args.game_dir.expanduser().resolve()
    explicit_mode = args.dry_run or args.validate_only or not args.launch
    if remote_update_disabled(game_dir):
        print(f"Remote mod update disabled by {game_dir / UPDATE_CONTROL_FILE}; using the installed mod set.")
        return 0 if explicit_mode else launch_command(args.launch, game_dir)

    try:
        synchronize(args, game_dir)
    except (OSError, ValueError, RuntimeError, json.JSONDecodeError, zipfile.BadZipFile) as exc:
        print(f"mod sync failed: {exc}", file=sys.stderr)
        if explicit_mode:
            return 1
        return launch_command(args.launch, game_dir)
    return 0 if explicit_mode else launch_command(args.launch, game_dir)


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