#!/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
    return subprocess.call(command, cwd=game_dir) if command else 0


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
    # Manifest paths are filesystem-style names and may contain spaces,
    # brackets, or other characters which urllib refuses in a raw URL.
    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 size < 0
            or name in entries
        ):
            raise ValueError(f"invalid manifest metadata for {name}")
        entries[name] = entry

    preservation = raw.get("preservation", {})
    paths: dict[str, list[Path]] = {}
    for raw_path in preservation.get("paths", []):
        path = Path(raw_path)
        if path.is_absolute() or len(path.parts) < 2 or ".." 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 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)
    parser.add_argument("--dry-run", 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()
    if remote_update_disabled(game_dir):
        print(
            f"Remote mod update disabled by {game_dir / UPDATE_CONTROL_FILE}; "
            "using the installed mod set."
        )
        return launch_command(args.launch, game_dir)

    mods_dir = game_dir / "Mods"
    if not mods_dir.is_dir() 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 0

        archives: dict[str, Path] = {}
        for name in changed:
            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

        transaction = mods_dir / f".modsync-transaction-{uuid.uuid4().hex}"
        staged = transaction / "new"
        backup = transaction / "old"
        staged.mkdir(parents=True)
        backup.mkdir()
        moved_old: list[str] = []
        installed_new: list[str] = []
        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)
        except Exception:
            for name in installed_new:
                target = mods_dir / name
                if target.exists():
                    shutil.rmtree(target)
            for name in reversed(moved_old):
                old = backup / name
                if old.exists():
                    os.replace(old, mods_dir / name)
            raise
        finally:
            if transaction.exists():
                shutil.rmtree(transaction)

    print("Mod sync complete.")
    return launch_command(args.launch, game_dir)


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, ValueError, zipfile.BadZipFile) as exc:
        print(f"mod sync failed: {exc}", file=sys.stderr)
        raise SystemExit(1)
