#!/usr/bin/env python3
"""RushChess GUI server.

Serves the web GUI and bridges it to UCI engines (RushChess + Stockfish
in gui/engine/). Pure stdlib - no dependencies.

Run:  python server.py        then open http://localhost:8642
"""
import json
import os
import subprocess
import sys
import threading
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer

if getattr(sys, "frozen", False):          # running as a PyInstaller exe
    HERE = os.path.dirname(os.path.abspath(sys.executable))
else:
    HERE = os.path.dirname(os.path.abspath(__file__))
EXT = ".exe" if os.name == "nt" else ""
PORT = 8642

# Bind host: 127.0.0.1 by default (local only). Set RUSHCHESS_HOST=0.0.0.0
# (or pass a 2nd/3rd arg) to expose it, e.g. behind a reverse proxy or on a
# dedicated port. When exposed, per-request search is clamped (below) so the
# public /api/go can't consume unbounded CPU on a shared box.
HOST = os.environ.get("RUSHCHESS_HOST", "127.0.0.1")
# "public" = reachable from the internet, whether by a public bind OR by being
# reverse-proxied (RUSHCHESS_PUBLIC=1). Public mode turns on search clamps.
_PUBLIC = (HOST not in ("127.0.0.1", "localhost", "::1")
           or os.environ.get("RUSHCHESS_PUBLIC") == "1")
MAX_MOVETIME = int(os.environ.get("RUSHCHESS_MAX_MOVETIME", "3000" if _PUBLIC else "600000"))
MAX_DEPTH = int(os.environ.get("RUSHCHESS_MAX_DEPTH", "18" if _PUBLIC else "99"))
MAX_THREADS = int(os.environ.get("RUSHCHESS_MAX_THREADS", "1" if _PUBLIC else "16"))
MAX_HASH = int(os.environ.get("RUSHCHESS_MAX_HASH", "16" if _PUBLIC else "2048"))
# when exposed, bound concurrent searches so the public endpoint can't starve
# a shared host's CPU (idle engine processes block on stdin at ~0 CPU)
_MAX_CONCURRENT = int(os.environ.get("RUSHCHESS_MAX_CONCURRENT", "2" if _PUBLIC else "64"))
_GO_SEM = threading.BoundedSemaphore(_MAX_CONCURRENT)


class Engine:
    """One UCI engine process, serialized behind a lock (RushChess is
    single-threaded and only reads input between searches)."""

    def __init__(self, key, path, default_opts=None):
        self.key = key
        self.path = path
        self.lock = threading.Lock()
        self.proc = None
        self.name = key
        self.options = {}            # currently-set UCI options
        self.default_opts = default_opts or {}
        self.supported = set()       # option names the engine advertises

    def available(self):
        return os.path.exists(self.path)

    def _start(self):
        self.proc = subprocess.Popen(
            [self.path], stdin=subprocess.PIPE, stdout=subprocess.PIPE,
            stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
            bufsize=1, cwd=os.path.dirname(self.path),
        )
        self.options = {}
        self.supported = set()
        self._send("uci")
        for line in self._read_until(("uciok",)):
            if line.startswith("id name "):
                self.name = line[8:].strip()
            elif line.startswith("option name "):
                self.supported.add(line.split(" name ", 1)[1].split(" type ")[0].strip())
        self._set_options(self.default_opts)
        self._ready()

    def _ensure(self):
        if self.proc is None or self.proc.poll() is not None:
            self._start()

    def _send(self, line):
        self.proc.stdin.write(line + "\n")
        self.proc.stdin.flush()

    def _read_until(self, prefixes):
        lines = []
        while True:
            line = self.proc.stdout.readline()
            if not line:
                raise RuntimeError(f"{self.key} engine terminated unexpectedly")
            line = line.strip()
            lines.append(line)
            if any(line.startswith(p) for p in prefixes):
                return lines

    def _ready(self):
        self._send("isready")
        self._read_until(("readyok",))

    def _set_options(self, opts):
        changed = False
        for name, value in opts.items():
            if name not in self.supported or self.options.get(name) == value:
                continue
            self._send(f"setoption name {name} value {value}")
            self.options[name] = value
            changed = True
        if changed:
            self._ready()

    def newgame(self):
        with self.lock:
            self._ensure()
            self._send("ucinewgame")
            self._ready()

    def go(self, fen=None, moves=None, movetime=None, depth=None,
           clock=None, options=None):
        """Search a position; returns bestmove + parsed info (per multipv)."""
        with self.lock:
            self._ensure()
            self._set_options(options or {})
            pos = "position " + ("fen " + fen if fen else "startpos")
            if moves:
                pos += " moves " + " ".join(moves)
            self._send(pos)
            if clock:
                self._send("go wtime %d btime %d winc %d binc %d" % (
                    max(1, int(clock.get("wtime", 1000))),
                    max(1, int(clock.get("btime", 1000))),
                    int(clock.get("winc", 0)), int(clock.get("binc", 0))))
            elif depth:
                self._send(f"go depth {int(depth)}")
            else:
                self._send(f"go movetime {int(movetime or 1000)}")
            lines = {}               # multipv index -> info dict
            best = None
            for line in self._read_until(("bestmove",)):
                if line.startswith("info ") and " pv " in line:
                    info = self._parse_info(line)
                    lines[info.get("multipv", 1)] = info
                elif line.startswith("bestmove"):
                    parts = line.split()
                    best = parts[1] if len(parts) > 1 else None
            result = {"bestmove": best, "engine": self.name}
            if 1 in lines:
                result.update(lines[1])
            result["lines"] = [lines[k] for k in sorted(lines)]
            return result

    @staticmethod
    def _parse_info(line):
        toks = line.split()
        out = {}
        i = 0
        while i < len(toks):
            t = toks[i]
            if t in ("depth", "nodes", "nps", "time", "multipv"):
                out[t] = int(toks[i + 1]); i += 2
            elif t == "score":
                kind, val = toks[i + 1], int(toks[i + 2])
                out["scoreCp" if kind == "cp" else "scoreMate"] = val
                i += 3
            elif t == "pv":
                out["pv"] = toks[i + 1:]; break
            else:
                i += 1
        return out


ENGINE_DEFS = {
    "rush": {"path": os.path.join(HERE, "engine", "rushchess" + EXT), "opts": {}},
    "stockfish": {"path": os.path.join(HERE, "engine", "stockfish" + EXT),
                  "opts": {"Threads": 4, "Hash": 256}},
}

# any other UCI engine dropped into gui/engine/ is registered automatically
# (key = executable name), so users can battle arbitrary engines. Windows:
# match *.exe. POSIX: match executable files with no dotted extension.
_engine_dir = os.path.join(HERE, "engine")
_SKIP_EXT = (".cpp", ".nnue", ".txt", ".md", ".json", ".pb", ".gz", ".bin", ".so", ".dll")
if os.path.isdir(_engine_dir):
    for _f in sorted(os.listdir(_engine_dir)):
        _path = os.path.join(_engine_dir, _f)
        if not os.path.isfile(_path):
            continue
        if EXT:                                   # Windows
            if not _f.lower().endswith(EXT):
                continue
        else:                                     # POSIX
            if not os.access(_path, os.X_OK):
                continue
            if _f.lower().endswith(_SKIP_EXT):
                continue
        _stem = os.path.splitext(_f)[0].lower()
        if _stem in ("rushchess", "stockfish") or _stem.startswith("rushchess"):
            continue
        _key = _stem[:24]
        if _key not in ENGINE_DEFS:
            ENGINE_DEFS[_key] = {"path": _path, "opts": {}}

INSTANCES = {}          # "engine:instance" -> Engine
INSTANCES_LOCK = threading.Lock()


def pick(key, instance="a"):
    key = key if key in ENGINE_DEFS else "rush"
    iid = f"{key}:{instance or 'a'}"
    with INSTANCES_LOCK:
        if iid not in INSTANCES:
            d = ENGINE_DEFS[key]
            INSTANCES[iid] = Engine(key, d["path"], default_opts=dict(d["opts"]))
        return INSTANCES[iid]


def engine_available(key):
    return os.path.exists(ENGINE_DEFS[key]["path"]) if key in ENGINE_DEFS else False


class Handler(SimpleHTTPRequestHandler):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, directory=HERE, **kwargs)

    def end_headers(self):  # local dev app: never serve stale JS/CSS
        self.send_header("Cache-Control", "no-cache")
        super().end_headers()

    def log_message(self, fmt, *args):  # quiet static-file spam
        if args and "/api/" in str(args[0]):
            sys.stderr.write("%s\n" % (fmt % args))

    def _json(self, obj, status=200):
        body = json.dumps(obj).encode()
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.send_header("Cache-Control", "no-store")
        self.end_headers()
        self.wfile.write(body)

    def do_GET(self):
        if self.path == "/api/status":
            names = {}
            with INSTANCES_LOCK:
                for iid, e in INSTANCES.items():
                    names.setdefault(iid.split(":")[0], e.name)
            def default_name(k):
                if k == "rush":
                    return "RushChess 1.0"
                if k == "stockfish":
                    return "Stockfish 17.1"
                return k.capitalize()   # custom engine, before handshake
            return self._json({
                "engines": {k: {"available": engine_available(k),
                                "name": names.get(k, default_name(k))}
                            for k in ENGINE_DEFS},
            })
        return super().do_GET()

    def do_POST(self):
        try:
            length = int(self.headers.get("Content-Length", 0))
            req = json.loads(self.rfile.read(length) or b"{}")
            if self.path == "/api/newgame":
                with INSTANCES_LOCK:
                    live = list(INSTANCES.values())
                for e in live:
                    if e.available() and e.proc is not None:
                        e.newgame()
                return self._json({"ok": True})
            if self.path == "/api/go":
                eng = pick(req.get("engine"), req.get("instance"))
                if not eng.available():
                    return self._json({"error": f"{eng.key} binary not found"}, 503)
                opts = {}
                if req.get("elo"):
                    opts["UCI_LimitStrength"] = "true"
                    opts["UCI_Elo"] = int(req["elo"])
                else:
                    opts["UCI_LimitStrength"] = "false"
                opts["MultiPV"] = min(int(req.get("multipv") or 1), 5)
                if req.get("threads"):
                    opts["Threads"] = min(int(req["threads"]), MAX_THREADS)
                if req.get("hash"):
                    opts["Hash"] = min(int(req["hash"]), MAX_HASH)
                # clamp search so a public endpoint can't burn unbounded CPU
                movetime = req.get("movetime")
                depth = req.get("depth")
                if movetime is not None:
                    movetime = min(int(movetime), MAX_MOVETIME)
                if depth is not None:
                    depth = min(int(depth), MAX_DEPTH)
                if not _GO_SEM.acquire(timeout=20):
                    return self._json({"error": "server busy"}, 503)
                try:
                    res = eng.go(
                        fen=req.get("fen"), moves=req.get("moves"),
                        movetime=movetime, depth=depth,
                        clock=req.get("clock"), options=opts,
                    )
                finally:
                    _GO_SEM.release()
                return self._json(res)
            return self._json({"error": "unknown endpoint"}, 404)
        except Exception as exc:  # keep the GUI alive on any engine hiccup
            return self._json({"error": str(exc)}, 500)


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else PORT
    # host precedence: 3rd CLI arg, else RUSHCHESS_HOST env, else localhost
    host = sys.argv[2] if len(sys.argv) > 2 and not sys.argv[2].startswith("-") else HOST
    server = ThreadingHTTPServer((host, port), Handler)
    shown = "localhost" if host in ("127.0.0.1", "localhost") else host
    url = f"http://{shown}:{port}"
    print(f"RushChess GUI  ->  {url}  (bind {host})")
    if host not in ("127.0.0.1", "localhost", "::1"):
        print(f"  public bind: search clamped to {MAX_MOVETIME}ms / depth {MAX_DEPTH} / {MAX_THREADS} threads")
    for k, d in ENGINE_DEFS.items():
        print(f"engine {k:10s}: {'found ' + d['path'] if engine_available(k) else 'NOT FOUND'}")
    def prewarm():  # handshake default instances so names/hash are ready
        for k in ENGINE_DEFS:
            if engine_available(k):
                try:
                    pick(k, "a").newgame()
                except Exception:
                    pass
    threading.Thread(target=prewarm, daemon=True).start()
    if "--no-browser" not in sys.argv:
        threading.Timer(0.4, lambda: webbrowser.open(url)).start()
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        pass


if __name__ == "__main__":
    main()
