"""Apply a published Blockheadian release with Minecraft stopped."""

import argparse
import hashlib
import json
import re
import shutil
import tempfile
import time
import uuid
from pathlib import Path
from urllib.parse import urlsplit
from urllib.request import Request, urlopen


MANIFEST = "https://updates.playblockheads.com/server.json"
MANAGED_PATH = re.compile(
    r"(?:mods/[A-Za-z0-9._+\-]+\.jar|"
    r"config/(?:humble(?:-blocks)?\.properties|iceandfire-common\.toml|"
    r"iceandfire/[a-z0-9_]+\.json))"
)


def request(url):
    if urlsplit(url).scheme != "https":
        raise ValueError("Expected an HTTPS download")
    return urlopen(Request(url, headers={"User-Agent": "BlockheadianServerUpdater/1.0"}), timeout=60)


def validate_files(files):
    paths = set()
    for item in files:
        path = item["path"]
        if not MANAGED_PATH.fullmatch(path) or path.casefold() in paths:
            raise ValueError(f"Invalid or duplicate managed path {path}")
        if not re.fullmatch(r"[a-f0-9]{128}", item["sha512"]):
            raise ValueError(f"Invalid SHA-512 for {path}")
        paths.add(path.casefold())
    return files


def apply(root, world, manifest):
    state_path = root / ".blockheadian-release.json"
    files = validate_files(manifest["files"])
    if not any(item["path"].startswith("mods/console-workshop-fabric-") for item in files):
        raise ValueError("Release is missing Console Workshop")
    if (manifest["minecraft"], manifest["fabric"]) != ("1.21.1", "0.19.5"):
        raise ValueError("This updater requires Minecraft 1.21.1 and Fabric Loader 0.19.5")

    prior = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else None
    previous = validate_files(prior["files"]) if prior else []
    affected = sorted({item["path"] for item in files + previous})
    for name in affected:
        target = root / name
        if not target.resolve().is_relative_to(root) or target.is_symlink():
            raise ValueError(f"Managed path points outside the server or is a symlink: {name}")
    incoming = {item["path"] for item in files}
    previous_paths = {item["path"] for item in previous}
    mods = root / "mods"
    extras = {f"mods/{jar.name}" for jar in mods.glob("*.jar")} - incoming - previous_paths
    if extras:
        raise ValueError("Review untracked mod JARs before updating: " + ", ".join(sorted(extras)))
    if prior == manifest and all((root / item["path"]).is_file() for item in files):
        print(f"Already on {manifest['version']}")
        return

    with tempfile.TemporaryDirectory(prefix=".blockheadian-stage-", dir=root) as directory:
        stage = Path(directory)
        for item in files:
            path = stage / item["path"]
            path.parent.mkdir(parents=True, exist_ok=True)
            digest = hashlib.sha512()
            with request(item["url"]) as response, path.open("wb") as output:
                while data := response.read(1024 * 1024):
                    output.write(data)
                    digest.update(data)
            if digest.hexdigest() != item["sha512"]:
                raise ValueError(f"Download hash mismatch: {item['path']}")

        backup_name = time.strftime("%Y%m%d-%H%M%S", time.gmtime()) + "-" + uuid.uuid4().hex[:8]
        backup = root / ".blockheadian-backups" / backup_name
        backup.mkdir(parents=True)
        if world.exists():
            shutil.copytree(world, backup / "world")
        if prior is not None:
            shutil.copy2(state_path, backup / state_path.name)
        for name in affected:
            target = root / name
            if target.is_file():
                saved = backup / name
                saved.parent.mkdir(parents=True, exist_ok=True)
                shutil.copy2(target, saved)

        applied = []
        try:
            for name in affected:
                target = root / name
                target.parent.mkdir(parents=True, exist_ok=True)
                if name in incoming:
                    (stage / name).replace(target)
                else:
                    target.unlink(missing_ok=True)
                applied.append(name)
            temp_state = stage / state_path.name
            temp_state.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
            temp_state.replace(state_path)
        except BaseException:
            for name in reversed(applied):
                saved = backup / name
                if saved.is_file():
                    shutil.copy2(saved, root / name)
                else:
                    (root / name).unlink(missing_ok=True)
            raise

    print(f"Applied {manifest['version']}. Backup: {backup}")


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("server_directory", type=Path)
    parser.add_argument("--world", default="world", help="Game Panel world folder name")
    parser.add_argument("--manifest", default=MANIFEST)
    args = parser.parse_args()
    root = args.server_directory.resolve(strict=True)
    world = (root / args.world).resolve()
    if world.parent != root or not root.is_dir():
        parser.error("Supply the server directory and a world folder inside it")
    with request(args.manifest) as response:
        manifest = json.load(response)
    lock = root / ".blockheadian-update.lock"
    lock.mkdir()
    try:
        if (world / "session.lock").is_file():
            # Java holds a POSIX advisory record lock while the world is loaded.
            import fcntl

            with (world / "session.lock").open("r+b") as session:
                fcntl.lockf(session, fcntl.LOCK_EX | fcntl.LOCK_NB)
                apply(root, world, manifest)
        else:
            apply(root, world, manifest)
    finally:
        lock.rmdir()


if __name__ == "__main__":
    main()
