#!/usr/bin/env python3
"""RigRentals launcher — pick a game, get a rig, play it.

Packaged as a real desktop app (.exe on Windows, .app on macOS, AppImage on
Linux) by .github/workflows/launcher.yml. What it does is small and specific:

  * shows the game catalogue, which the site already generates (library.json),
    so the launcher can never offer a game the rigs cannot run
  * opens checkout with the right rig tier, the right storage add-on and the
    game slug already chosen -- the tier/storage pair is where people get it
    wrong and end up with a 150 GB game on a 50 GB disk
  * watches the order after payment and says what the rig is actually doing
  * connects when it is ready, launching Moonlight directly if it is installed

Deliberately NOT a payment window. Card and crypto checkout stays in the
browser on our own domain, where the customer can see the URL. An app that
collected payment details itself would be indistinguishable from the thing
people are right to be scared of, and it would put us inside PCI scope for no
benefit.

Architecture note: this serves a local page to the default browser instead of
drawing a native window. It means one UI for all three platforms, no GUI
toolkit to bundle (so the binaries stay small), and it reuses the site's own
styling. The page talks only to this local server; the server does the calls
out to gamingrentals.store, which avoids CORS entirely and keeps every
outbound request in one auditable place.

Stdlib only, on purpose: a pip dependency would mean a supply-chain risk in a
signed binary we hand to customers, and a build that breaks when PyPI hiccups.
"""

import http.server
import json
import os
import pathlib
import secrets
import shutil
import socket
import subprocess
import sys
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser

SITE = os.environ.get("RR_SITE", "https://gamingrentals.store").rstrip("/")
VERSION = "1.0.0"
UA = f"RigRentalsLauncher/{VERSION}"

# Only ever opened in the customer's browser, and only ever on our own domain.
# Without this check the local server is an "open any URL" service that any
# page in the same browser could drive.
ALLOWED_HOSTS = {urllib.parse.urlparse(SITE).hostname or "gamingrentals.store"}

HERE = pathlib.Path(getattr(sys, "_MEIPASS", pathlib.Path(__file__).resolve().parent))
UI_FILE = HERE / "launcher-ui.html"

# Per-run secret in the URL. The page is on 127.0.0.1, which any other process
# on the machine can also reach, and /api/open launches a browser -- worth a
# token even for a local-only tool.
TOKEN = secrets.token_urlsafe(24)


def config_dir():
    if sys.platform == "win32":
        base = os.environ.get("APPDATA") or os.path.expanduser("~")
    elif sys.platform == "darwin":
        base = os.path.expanduser("~/Library/Application Support")
    else:
        base = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
    d = pathlib.Path(base) / "RigRentals"
    d.mkdir(parents=True, exist_ok=True)
    return d


CONFIG = config_dir() / "launcher.json"
CACHE = config_dir() / "library.json"


def load_state():
    try:
        return json.loads(CONFIG.read_text())
    except Exception:
        return {"orders": []}


def save_state(state):
    try:
        CONFIG.write_text(json.dumps(state, indent=2))
    except Exception:
        pass  # A launcher that cannot remember is still a working launcher.


def fetch(path, timeout=20):
    req = urllib.request.Request(SITE + path, headers={"User-Agent": UA})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read().decode("utf-8"))


def catalogue():
    """The game list, cached on disk.

    The cache is not an optimisation. A launcher that shows an empty screen
    because the network blipped looks broken, and the catalogue changes a few
    times a month at most -- a slightly stale list is strictly better than no
    list, so the cache is also the offline fallback.
    """
    try:
        data = fetch("/library.json")
        if data.get("games"):
            try:
                CACHE.write_text(json.dumps(data))
            except Exception:
                pass
            return data, "live"
    except Exception:
        pass
    try:
        return json.loads(CACHE.read_text()), "cached"
    except Exception:
        return {"games": [], "blocked": [], "beta": []}, "unavailable"


def moonlight_bin():
    """Moonlight, if it is installed. Optional: noVNC needs nothing at all."""
    for name in ("moonlight", "moonlight-qt", "Moonlight"):
        p = shutil.which(name)
        if p:
            return p
    for p in (
        r"C:\Program Files\Moonlight Game Streaming\Moonlight.exe",
        r"C:\Program Files (x86)\Moonlight Game Streaming\Moonlight.exe",
        "/Applications/Moonlight.app/Contents/MacOS/Moonlight",
    ):
        if os.path.exists(p):
            return p
    return None


class Handler(http.server.BaseHTTPRequestHandler):
    server_version = UA

    def log_message(self, *a):
        pass  # No request log: it would be the only thing in the console.

    # ── helpers ─────────────────────────────────────────────────────────────
    def _send(self, code, body, ctype="application/json"):
        raw = body if isinstance(body, bytes) else str(body).encode("utf-8")
        self.send_response(code)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(raw)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        try:
            self.wfile.write(raw)
        except (BrokenPipeError, ConnectionResetError):
            pass

    def _json(self, code, obj):
        self._send(code, json.dumps(obj))

    def _authed(self, q):
        return secrets.compare_digest(q.get("t", [""])[0], TOKEN)

    # ── routes ──────────────────────────────────────────────────────────────
    def do_GET(self):
        u = urllib.parse.urlparse(self.path)
        q = urllib.parse.parse_qs(u.query)

        if u.path == "/":
            if not self._authed(q):
                return self._send(403, "Open this window from the launcher.", "text/plain")
            try:
                return self._send(200, UI_FILE.read_bytes(), "text/html; charset=utf-8")
            except Exception as e:
                return self._send(500, f"UI missing: {e}", "text/plain")

        if not self._authed(q):
            return self._json(403, {"error": "bad token"})

        if u.path == "/api/catalog":
            data, source = catalogue()
            data["source"] = source
            data["site"] = SITE
            return self._json(200, data)

        if u.path == "/api/order":
            oid = "".join(c for c in q.get("id", [""])[0] if c.isalnum())[:64]
            if not oid:
                return self._json(400, {"error": "no order id"})
            try:
                return self._json(200, fetch(f"/api/order-status?id={oid}"))
            except urllib.error.HTTPError as e:
                return self._json(e.code, {"error": f"order not found ({e.code})"})
            except Exception as e:
                return self._json(502, {"error": f"could not reach the site: {e}"})

        if u.path == "/api/recent":
            st = load_state()
            return self._json(200, {"orders": st.get("orders", [])[-8:],
                                    "moonlight": bool(moonlight_bin())})

        return self._json(404, {"error": "no such route"})

    def do_POST(self):
        u = urllib.parse.urlparse(self.path)
        q = urllib.parse.parse_qs(u.query)
        if not self._authed(q):
            return self._json(403, {"error": "bad token"})
        try:
            n = int(self.headers.get("Content-Length") or 0)
            body = json.loads(self.rfile.read(n) or b"{}")
        except Exception:
            body = {}

        if u.path == "/api/open":
            url = str(body.get("url") or "")
            host = urllib.parse.urlparse(url).hostname
            # Our own domain only. See ALLOWED_HOSTS above.
            if urllib.parse.urlparse(url).scheme != "https" or host not in ALLOWED_HOSTS:
                return self._json(400, {"error": "refused: not a gamingrentals.store link"})
            webbrowser.open(url)
            return self._json(200, {"ok": True})

        if u.path == "/api/remember":
            oid = "".join(c for c in str(body.get("id") or "") if c.isalnum())[:64]
            if not oid:
                return self._json(400, {"error": "no order id"})
            st = load_state()
            orders = [o for o in st.get("orders", []) if o.get("id") != oid]
            orders.append({"id": oid, "game": str(body.get("game") or "")[:64],
                           "saved": int(time.time())})
            st["orders"] = orders[-8:]
            save_state(st)
            return self._json(200, {"ok": True})

        if u.path == "/api/connect":
            return self._json(200, self._connect(body))

        return self._json(404, {"error": "no such route"})

    def _connect(self, body):
        """Hand the customer off to whatever their rig actually uses.

        noVNC is the browser viewer on the order page, so that is a browser
        open. Moonlight is a real client and gets launched with the host if it
        is installed -- and if it is not, saying so and pointing at the
        download is better than opening nothing.
        """
        oid = "".join(c for c in str(body.get("id") or "") if c.isalnum())[:64]
        if not oid:
            return {"error": "no order id"}
        try:
            o = fetch(f"/api/order-status?id={oid}")
        except urllib.error.HTTPError as e:
            if e.code == 404:
                return {"error": "we have no order with that ID — check it and try again"}
            return {"error": f"the site returned an error ({e.code})"}
        except Exception as e:
            return {"error": f"could not reach the site: {e}"}

        page = f"{SITE}/order?id={oid}"
        if o.get("status") != "active":
            return {"error": f"this rig is {o.get('status') or 'not ready'} — nothing to connect to yet",
                    "page": page}

        streaming = o.get("streaming") or "moonlight"
        if streaming in ("novnc", "parsec"):
            webbrowser.open(page)
            return {"ok": True, "how": streaming, "opened": page}

        host = (o.get("instance") or {}).get("public_ip") or (o.get("ssh") or {}).get("host")
        mb = moonlight_bin()
        if mb and host:
            try:
                subprocess.Popen([mb, "stream", host, "Desktop"],
                                 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
                return {"ok": True, "how": "moonlight", "host": host}
            except Exception as e:
                return {"error": f"Moonlight would not start: {e}", "page": page}
        if not mb:
            webbrowser.open(page)
            return {"ok": True, "how": "page",
                    "note": "Moonlight is not installed on this computer — the order page has the "
                            "browser viewer and the Moonlight download.", "opened": page}
        return {"ok": True, "how": "page", "opened": page,
                "note": "The rig has not reported an address yet. The order page has the live details."}


def free_port():
    s = socket.socket()
    s.bind(("127.0.0.1", 0))
    port = s.getsockname()[1]
    s.close()
    return port


def main():
    if not UI_FILE.exists():
        print(f"launcher-ui.html not found next to {HERE}", file=sys.stderr)
        return 2
    port = free_port()
    srv = http.server.ThreadingHTTPServer(("127.0.0.1", port), Handler)
    url = f"http://127.0.0.1:{port}/?t={urllib.parse.quote(TOKEN)}"
    threading.Thread(target=srv.serve_forever, daemon=True).start()
    print(f"RigRentals launcher {VERSION}")
    print(f"  {url}")
    webbrowser.open(url)
    try:
        while True:
            time.sleep(3600)
    except KeyboardInterrupt:
        return 0


if __name__ == "__main__":
    sys.exit(main() or 0)
