#!/usr/bin/env python3
"""Minimal headless launcher for a NeoForge client with a packwiz pack.

Resolves the vanilla client, installs NeoForge, installs the pack with
packwiz-installer, then execs the game with --quickPlaySingleplayer.
"""

import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.error
import urllib.request
import zipfile
from concurrent.futures import ThreadPoolExecutor

MOJANG_MANIFEST = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
NEOFORGE_MAVEN = "https://maven.neoforged.net/releases/net/neoforged/neoforge"
PACKWIZ_BOOTSTRAP = (
    "https://github.com/packwiz/packwiz-installer-bootstrap/releases/"
    "download/v0.0.3/packwiz-installer-bootstrap.jar"
)


def log(msg):
    print(f"[launcher] {msg}", flush=True)


def fetch(url, dest):
    tmp = dest + ".part"
    os.makedirs(os.path.dirname(dest) or ".", exist_ok=True)
    last_err = None
    for attempt in range(3):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "aeroslop-pack-test"})
            with urllib.request.urlopen(req, timeout=120) as r, open(tmp, "wb") as f:
                shutil.copyfileobj(r, f)
            os.replace(tmp, dest)
            return
        except urllib.error.HTTPError as e:
            if e.code in (403, 404):
                raise
            last_err = e
        except (urllib.error.URLError, TimeoutError, OSError) as e:
            last_err = e
        time.sleep(2)
    raise last_err


def get_json(url):
    req = urllib.request.Request(url, headers={"User-Agent": "aeroslop-pack-test"})
    with urllib.request.urlopen(req, timeout=120) as r:
        return json.load(r)


def sha1_file(path):
    h = hashlib.sha1()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def rule_allows(rules, os_name):
    allowed = True
    for rule in rules or []:
        os_rule = rule.get("os") or {}
        name = os_rule.get("name")
        matches = name is None or name == os_name
        if rule.get("action") == "disallow" and matches:
            allowed = False
        elif rule.get("action", "allow") == "allow" and not matches:
            allowed = False
    return allowed


def ensure_artifact(game, artifact):
    path = os.path.join(game, "libraries", artifact["path"])
    if os.path.exists(path):
        return path
    fetch(artifact["url"], path)
    if "sha1" in artifact and sha1_file(path) != artifact["sha1"]:
        raise RuntimeError(f"sha1 mismatch for {artifact['path']}")
    return path


def ensure_vanilla(game, mc_version):
    versions_dir = os.path.join(game, "versions", mc_version)
    json_path = os.path.join(versions_dir, f"{mc_version}.json")
    jar_path = os.path.join(versions_dir, f"{mc_version}.jar")

    manifest = get_json(MOJANG_MANIFEST)
    entry = next(v for v in manifest["versions"] if v["id"] == mc_version)
    vanilla = get_json(entry["url"])

    if not os.path.exists(json_path):
        os.makedirs(versions_dir, exist_ok=True)
        with open(json_path, "w") as f:
            json.dump(vanilla, f)
    launcher_profiles = os.path.join(game, "launcher_profiles.json")
    if not os.path.exists(launcher_profiles):
        with open(launcher_profiles, "w") as f:
            json.dump(
                {
                    "profiles": {
                        "test": {
                            "created": "2020-01-01T00:00:00.000Z",
                            "lastVersionId": mc_version,
                            "name": "test",
                            "type": "custom",
                        }
                    },
                    "settings": {"enableSnapshots": False},
                },
                f,
            )
    if not os.path.exists(jar_path):
        client = vanilla["downloads"]["client"]
        fetch(client["url"], jar_path)
        if sha1_file(jar_path) != client["sha1"]:
            raise RuntimeError("sha1 mismatch for client jar")
    client_extra = os.path.join(versions_dir, "client-extra")
    if not os.path.exists(client_extra):
        shutil.copyfile(jar_path, client_extra)
    return vanilla


def install_neoforge(game, java, mc_version, neoforge_version):
    installer = os.path.join(game, f"neoforge-{neoforge_version}-installer.jar")
    if not os.path.exists(installer):
        fetch(f"{NEOFORGE_MAVEN}/{neoforge_version}/neoforge-{neoforge_version}-installer.jar", installer)
    subprocess.run(
        [java, "-jar", installer, "--installClient", game],
        check=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
    )
    profile = os.path.join(game, "versions", f"neoforge-{neoforge_version}", f"neoforge-{neoforge_version}.json")
    with open(profile) as f:
        return json.load(f)


def resolve_libraries(game, libraries):
    artifacts = []
    for lib in libraries:
        if not rule_allows(lib.get("rules"), "linux"):
            continue
        name = lib.get("name", "")
        downloads = lib.get("downloads") or {}
        is_native_entry = name.endswith(":natives-linux")
        classifier_natives = downloads.get("classifiers", {}).get("natives-linux")
        if is_native_entry or classifier_natives:
            jar = downloads.get("artifact") if is_native_entry else classifier_natives
            if jar:
                jar_path = ensure_artifact(game, jar)
                out_dir = os.path.join(game, "natives")
                os.makedirs(out_dir, exist_ok=True)
                with zipfile.ZipFile(jar_path) as z:
                    z.extractall(out_dir)
            continue
        artifact = downloads.get("artifact")
        if artifact:
            artifacts.append(ensure_artifact(game, artifact))
    return artifacts


def assets_source_ok(source, vanilla):
    index_id = vanilla["assetIndex"]["id"]
    try:
        with open(os.path.join(source, "indexes", f"{index_id}.json")) as f:
            index = json.load(f)
        objects_dir = os.path.join(source, "objects")
        for p, e in index["objects"].items():
            path = os.path.join(objects_dir, e["hash"][:2], e["hash"])
            if not os.path.exists(path) or os.path.getsize(path) != e["size"]:
                return False
        return True
    except OSError:
        return False


def download_assets(game, vanilla):
    index = get_json(vanilla["assetIndex"]["url"])
    indexes_dir = os.path.join(game, "assets", "indexes")
    os.makedirs(indexes_dir, exist_ok=True)
    index_path = os.path.join(indexes_dir, f"{vanilla['assetIndex']['id']}.json")
    if not os.path.exists(index_path):
        with open(index_path, "w") as f:
            json.dump(index, f)
    objects = index["objects"]
    objects_dir = os.path.join(game, "assets", "objects")

    def ensure(obj_path, entry):
        h = entry["hash"]
        dest = os.path.join(objects_dir, h[:2], h)
        if os.path.exists(dest) and os.path.getsize(dest) == entry["size"]:
            return
        os.makedirs(os.path.dirname(dest), exist_ok=True)
        fetch(f"https://resources.download.minecraft.net/{h[:2]}/{h}", dest)

    log(f"downloading {len(objects)} assets")
    done = 0
    with ThreadPoolExecutor(max_workers=8) as pool:
        futures = [pool.submit(ensure, p, e) for p, e in objects.items()]
        for f in futures:
            f.result()
            done += 1
            if done % 500 == 0:
                log(f"assets: {done}/{len(objects)}")


def install_packwiz(game, java, pack_url):
    bootstrap = os.path.join(game, "packwiz-installer-bootstrap.jar")
    if not os.path.exists(bootstrap):
        fetch(PACKWIZ_BOOTSTRAP, bootstrap)
    subprocess.run(
        [java, "-jar", bootstrap, "--side", "client", pack_url],
        cwd=game,
        check=True,
    )


def substitute(s, vars_map):
    for k, v in vars_map.items():
        s = s.replace("${" + k + "}", v)
    return s


def build_launch(profile, vanilla, game, classpath, mc_version, neoforge_version, world, java, assets_dir):
    version_name = f"neoforge-{neoforge_version}"
    uuid = "f6d2cd16c9b74c9b9a16e8e8b1c0e3d4"  # arbitrary fixed uuid for the test player
    args = profile.get("arguments", vanilla.get("arguments", {}))
    jvm_args = list(args.get("jvm", []))
    game_args = list(args.get("game", []))

    vars_map = {
        "classpath": classpath,
        "classpath_separator": ":",
        "natives_directory": os.path.join(game, "natives"),
        "library_directory": os.path.join(game, "libraries"),
        "game_directory": game,
        "game_assets": assets_dir,
        "assets_root": assets_dir,
        "assets_index_name": vanilla["assetIndex"]["id"],
        "auth_player_name": "Player",
        "auth_uuid": uuid.replace("-", ""),
        "auth_access_token": "0",
        "auth_session": "token:0:" + uuid.replace("-", ""),
        "auth_xuid": "0",
        "user_type": "legacy",
        "user_properties": "{}",
        "version_name": version_name,
        "version_type": "release",
        "launcher_name": "aeroslop-pack-test",
        "launcher_version": "0.1.0",
        "clientid": "aeroslop-pack-test",
        "resolution_width": "640",
        "resolution_height": "480",
    }

    jvm_args = [
        f"-Djava.library.path={os.path.join(game, 'natives')}",
        f"-XX:ErrorFile={os.path.join(game, 'hs_err_pid%p.log')}",
        *jvm_args,
    ]
    jvm_args = [substitute(a, vars_map) for a in jvm_args]
    game_args = [substitute(a, vars_map) for a in game_args]
    game_args += [
        "--username",
        "Player",
        "--version",
        version_name,
        "--gameDir",
        game,
        "--assetsDir",
        assets_dir,
        "--assetIndex",
        vanilla["assetIndex"]["id"],
        "--uuid",
        uuid.replace("-", ""),
        "--accessToken",
        "0",
        "--userType",
        "legacy",
        "--versionType",
        "release",
        "--width",
        "640",
        "--height",
        "480",
    ]

    main_class = profile.get("mainClass") or vanilla["mainClass"]
    return [java, "-Xmx4G", "-cp", classpath, *jvm_args, main_class, *game_args]


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--game-dir", required=True)
    ap.add_argument("--assets-source", default=None)
    ap.add_argument("--mc-version", required=True)
    ap.add_argument("--neoforge-version", required=True)
    ap.add_argument("--pack-url", required=True)
    ap.add_argument("--world", required=True)
    ap.add_argument("--java", default="java")
    ap.add_argument("--memory", default="4G")
    args = ap.parse_args()

    game = os.path.abspath(args.game_dir)
    os.makedirs(game, exist_ok=True)
    os.chdir(game)

    log(f"ensuring vanilla {args.mc_version}")
    vanilla = ensure_vanilla(game, args.mc_version)

    log(f"installing neoforge {args.neoforge_version}")
    profile = install_neoforge(game, args.java, args.mc_version, args.neoforge_version)

    libraries = (vanilla.get("libraries") or []) + (profile.get("libraries") or [])
    log(f"resolving {len(libraries)} libraries")
    artifacts = list(dict.fromkeys(resolve_libraries(game, libraries)))

    assets_dir = os.path.join(game, "assets")
    if args.assets_source and assets_source_ok(args.assets_source, vanilla):
        assets_dir = args.assets_source
        log(f"reusing assets from {args.assets_source}")
    else:
        log("downloading assets")
        download_assets(game, vanilla)

    for root, _dirs, files in os.walk(os.path.join(game, "natives")):
        if root == os.path.join(game, "natives"):
            continue
        for f in files:
            if f.endswith(".so"):
                src = os.path.join(root, f)
                dst = os.path.join(game, "natives", f)
                if not os.path.exists(dst):
                    shutil.move(src, dst)

    log("installing pack via packwiz-installer")
    install_packwiz(game, args.java, args.pack_url)

    classpath = ":".join(
        artifacts
        + [
            os.path.join(game, "versions", args.mc_version, "client-extra"),
            os.path.join(
                game, "versions", f"neoforge-{args.neoforge_version}", f"neoforge-{args.neoforge_version}.jar"
            ),
        ]
    )
    cmd = build_launch(
        profile, vanilla, game, classpath, args.mc_version, args.neoforge_version, args.world, args.java, assets_dir
    )
    cmd[1] = args.memory if args.memory.startswith("-Xmx") else f"-Xmx{args.memory}"
    log("launching game")
    os.execvp(cmd[0], cmd)


if __name__ == "__main__":
    main()
