#!/usr/bin/env python3
# AltekOT Launcher for Linux - CLI port of launcher/launcher.ps1 (same update protocol).
# Lives inside the AltekOT install dir next to the otclient binary.
#
# Usage:
#   ./altekot-launcher            update (if needed) then play
#   ./altekot-launcher --check    update only, don't launch
#   ./altekot-launcher --repair   hash-verify every file, redownload bad ones
#   ./altekot-launcher --hd on|off  enable/disable the HD painterly pack (+2.8 GB)
#   ./altekot-launcher --play     skip the update check, just launch
import hashlib
import json
import os
import shutil
import stat
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor

UPDATE_URLS = [
    "http://altekot.duckdns.org:8081/updates",
    "http://127.0.0.1/updates",  # fallback: the host PC can't reach its own public IP (hairpin NAT)
]
CLIENT_DIR = os.path.dirname(os.path.abspath(__file__))
BINARY = os.path.join(CLIENT_DIR, "otclient")
HD_CHOICE = os.path.join(CLIENT_DIR, "hd_choice.txt")
CACHE_PATH = os.path.join(CLIENT_DIR, "local_manifest.json")
INCOMPLETE_FLAG = os.path.join(CLIENT_DIR, "install_incomplete.flag")
WORKERS = 12
# Windows-only trees in the shared manifest (the exe/dlls and the WPF launcher)
SKIP_PREFIXES = ("Release/", "launcher/")

C_GOLD = "\033[33m"
C_GREEN = "\033[32m"
C_RED = "\033[31m"
C_RESET = "\033[0m"


def say(msg, color=C_GOLD):
    print(f"{color}[AltekOT]{C_RESET} {msg}", flush=True)


def fetch_json(url, timeout=10):
    req = urllib.request.Request(
        f"{url}?t={int(time.time())}",
        headers={"Cache-Control": "no-cache", "Pragma": "no-cache"},
    )
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def sha256_of(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def hd_wanted():
    try:
        with open(HD_CHOICE) as f:
            return f.read().strip() == "1"
    except OSError:
        return False


def load_cache():
    try:
        with open(CACHE_PATH) as f:
            return json.load(f)
    except (OSError, ValueError):
        return {}  # corrupt cache = distrust everything, re-verify


def download_one(update_url, item, progress):
    local = os.path.join(CLIENT_DIR, *item["path"].split("/"))
    os.makedirs(os.path.dirname(local) or CLIENT_DIR, exist_ok=True)
    url = update_url + "/files/" + "/".join(
        urllib.parse.quote(seg) for seg in item["path"].split("/")
    )
    tmp = local + ".dl"
    ok = False
    for _ in range(3):  # retry transient failures
        try:
            with urllib.request.urlopen(url, timeout=60) as r, open(tmp, "wb") as f:
                shutil.copyfileobj(r, f)
            if os.path.getsize(tmp) == item["size"]:
                os.replace(tmp, local)
                ok = True
                break
        except Exception:
            time.sleep(0.5)
    if os.path.exists(tmp):
        os.remove(tmp)
    progress["done"] += 1
    if ok:
        progress["bytes"] += item["size"]
    else:
        progress["failed"] += 1
    return ok


def wanted_files(manifest, hd):
    for f in manifest["files"]:
        if f["path"].startswith(SKIP_PREFIXES):
            continue
        if f.get("hd") and not hd:
            continue
        yield f


def update_binary(update_url):
    """Self-update the Linux otclient binary via linux.json (published next to manifest.json)."""
    try:
        linux = fetch_json(f"{update_url}/linux.json")
    except Exception:
        return  # no linux manifest published (yet) - keep the binary we shipped with
    for item in linux.get("files", []):
        local = os.path.join(CLIENT_DIR, *item["path"].split("/"))
        if os.path.exists(local) and sha256_of(local) == item["sha256"]:
            continue
        say(f"updating {item['path']} ...")
        prog = {"done": 0, "bytes": 0, "failed": 0}
        # binary lives under /updates/linux/, not /updates/files/
        url = update_url + "/linux/" + urllib.parse.quote(item["path"])
        tmp = local + ".dl"
        try:
            with urllib.request.urlopen(url, timeout=120) as r, open(tmp, "wb") as f:
                shutil.copyfileobj(r, f)
            if sha256_of(tmp) == item["sha256"]:
                os.replace(tmp, local)
                os.chmod(local, os.stat(local).st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
                say(f"{item['path']} updated", C_GREEN)
            else:
                say(f"{item['path']} download hash mismatch - keeping current", C_RED)
        except Exception as e:
            say(f"{item['path']} update failed ({e}) - keeping current", C_RED)
        finally:
            if os.path.exists(tmp):
                os.remove(tmp)


def run_update(repair=False):
    manifest = None
    update_url = None
    for u in UPDATE_URLS:
        try:
            manifest = fetch_json(f"{u}/manifest.json")
            update_url = u
            break
        except Exception:
            continue
    if manifest is None:
        say("update server unreachable - playing with the files you have", C_RED)
        return True  # never brick PLAY over a failed check

    # A truncated/partial manifest (server mid-publish) must never be treated as authoritative.
    if not manifest.get("version") or len(manifest.get("files", [])) < 1000:
        say("manifest looks incomplete - skipping update, try again later", C_RED)
        return True

    update_binary(update_url)

    hd = hd_wanted()
    cache = load_cache()
    repair = repair or os.path.exists(INCOMPLETE_FLAG)
    if repair:
        say("verifying all files (repair mode)...")

    need = []
    for f in wanted_files(manifest, hd):
        local = os.path.join(CLIENT_DIR, *f["path"].split("/"))
        if not os.path.exists(local) or os.path.getsize(local) != f["size"]:
            need.append(f)
        elif repair:
            if sha256_of(local) != f["sha256"]:
                need.append(f)
        elif cache.get(f["path"]) and cache[f["path"]] != f["sha256"]:
            need.append(f)

    if need:
        total_gb = sum(f["size"] for f in need) / (1 << 30)
        say(f"downloading {len(need):,} files ({total_gb:.2f} GB)...")
        progress = {"done": 0, "bytes": 0, "failed": 0}
        t0 = time.time()
        with ThreadPoolExecutor(max_workers=WORKERS) as pool:
            futures = [pool.submit(download_one, update_url, f, progress) for f in need]
            while any(not fu.done() for fu in futures):
                time.sleep(0.5)
                secs = time.time() - t0
                mbps = (progress["bytes"] * 8 / (1 << 20)) / secs if secs > 0 else 0
                print(
                    f"\r  {progress['done']:,} / {len(need):,} files   "
                    f"{progress['bytes'] / (1 << 30):.2f} GB   {mbps:,.0f} Mbps ",
                    end="",
                    flush=True,
                )
        print()
        for f in need:
            local = os.path.join(CLIENT_DIR, *f["path"].split("/"))
            if os.path.exists(local) and os.path.getsize(local) == f["size"]:
                cache[f["path"]] = f["sha256"]
    else:
        for f in wanted_files(manifest, hd):
            cache[f["path"]] = f["sha256"]

    # FINAL VERIFY: only a fully-satisfied install may be declared complete - anything less
    # sets the incomplete flag, which forces a hash-verified repair pass on the next run.
    missing = sum(
        1
        for f in wanted_files(manifest, hd)
        if not os.path.exists(os.path.join(CLIENT_DIR, *f["path"].split("/")))
        or os.path.getsize(os.path.join(CLIENT_DIR, *f["path"].split("/"))) != f["size"]
    )
    if missing:
        with open(INCOMPLETE_FLAG, "w") as f:
            f.write(f"{missing} file(s) missing after update\n")
        say(f"UPDATE INCOMPLETE - {missing} file(s) still missing. Run again to retry.", C_RED)
        return False
    cache["__version"] = manifest["version"]
    with open(CACHE_PATH, "w") as f:
        json.dump(cache, f)
    if os.path.exists(INCOMPLETE_FLAG):
        os.remove(INCOMPLETE_FLAG)
    say(f"version {manifest['version']} - latest version", C_GREEN)
    return True


def check_libs():
    """Warn (with the apt line) if the binary's shared libraries are missing."""
    try:
        out = subprocess.run(["ldd", BINARY], capture_output=True, text=True, timeout=15).stdout
    except Exception:
        return
    if "not found" in out:
        missing = [l.split()[0] for l in out.splitlines() if "not found" in l]
        say(f"missing system libraries: {', '.join(missing)}", C_RED)
        say("on Ubuntu/Debian:  sudo apt install libgl1 libglu1-mesa libopenal1 libx11-6 libxcursor1 libxi6 libxinerama1 libxrandr2")
        sys.exit(1)


def play():
    if not os.path.exists(BINARY):
        say("otclient binary missing - reinstall from the website", C_RED)
        sys.exit(1)
    check_libs()
    if os.path.exists(INCOMPLETE_FLAG):
        say("WARNING: last update did not finish - sprites/items may show up wrong", C_RED)
    say("launching...", C_GREEN)
    os.chdir(CLIENT_DIR)
    os.execv(BINARY, [BINARY])


def main():
    args = sys.argv[1:]
    if "--hd" in args:
        i = args.index("--hd")
        val = args[i + 1] if i + 1 < len(args) else ""
        if val not in ("on", "off"):
            say("usage: --hd on|off", C_RED)
            sys.exit(2)
        with open(HD_CHOICE, "w") as f:
            f.write("1" if val == "on" else "0")
        say(f"HD sprites {val} - files sync on the next update check", C_GREEN)
        if val == "on":
            run_update()
        return
    if "--play" in args:
        play()
    ok = run_update(repair="--repair" in args)
    if "--check" in args or "--repair" in args:
        sys.exit(0 if ok else 1)
    play()


if __name__ == "__main__":
    main()
