#!/usr/bin/env python3
"""
draft_server.py -- LAN relay for the "Betta Bring It" draft board.

Run this INSTEAD of `python -m http.server 8777`. It does two jobs:

  1. Serves the static files (draftboard.html, board.html, pick.html, lobby.html, players.json, ...)
     exactly like the old file server.
  2. Bridges phone clients to the control browser so managers can draft from their own devices:
       * holds a MIRROR of the draft state (the control browser pushes it on every change),
       * holds the CLAIMS registry (which device owns which team),
       * holds a pick-request MAILBOX (phone asks -> control browser ratifies -> result comes back).

Design guarantees:
  * The control browser stays the single brain / single writer. This server never runs draft
    logic; it only relays. So there is no split-brain and phones can never double-pick
    (the control browser ratifies every request through its normal doPick, checking turn +
    availability + staleness).
  * Privileged endpoints (/push /timer /pending /resolve /claims /release /bootstrap) are
    LOOPBACK-ONLY, so only the control browser on the host PC can call them. Phones (LAN IPs)
    can only reach the player endpoints. => Open the control/board/pick screens via
    http://localhost:8777 ON THE HOST PC; phones use http://<reserved-ip>:8777/lobby.html
  * State + claims are written to disk atomically on every change, so a crash / reboot recovers.

Stdlib only. No dependencies. Python 3.7+.
"""
import http.server, json, os, secrets, socket, sys, threading, time
from urllib.parse import urlparse, parse_qs

ROOT = os.path.dirname(os.path.abspath(__file__))
PORT = int(os.environ.get("PORT") or (sys.argv[1] if len(sys.argv) > 1 else 8777))
STATE_FILE = os.path.join(ROOT, "server_state.json")   # durable: mirror + timer + claims
GUIDE_FILE = os.path.join(ROOT, "guide_data.json")     # curated draft-guide intel (operator only)
GSTATS_FILE = os.path.join(ROOT, "guide_stats.json")   # tiers/projections/history intel (operator only)
JOIN_PIN_DEFAULT = "2026"     # players type this to enter the lobby (change here or in Settings)
ADMIN_PIN = os.environ.get("ADMIN_PIN") or "2060"      # unlocks the operator Draft Guide (deliberately NOT the shared 3133 Settings PIN)
PRIVATE_FILES = {"/server_state.json", "/server_state.json.tmp", "/guide_data.json",
                 "/guide_stats.json", "/.fantasypros.env"}  # never served statically
CONN_WINDOW = 12              # seconds since last-seen to still count a phone as "connected"
RESULT_TTL = 120             # seconds to keep a pick result around for the phone to read
QUEUE_TTL = 90               # seconds before an unratified pick request is dropped (control died mid-pick)

_lock = threading.RLock()
_data = {
    "state":   None,   # mirror of the draft state (pushed by the control browser)
    "timer":   None,   # {remaining, running, on}
    "claims":  {},     # teamId(str) -> {token, deviceId, label, lastSeen}
    "queue":   [],     # pending pick requests [{reqId, teamId, playerId, forOverall, ts}]
    "results": {},     # reqId -> {status, reason, ts}   (ephemeral)
    "gtokens": [],     # valid Draft Guide tokens (issued via /guide_auth with the admin PIN)
}


def _now():
    return time.time()


_lan_ip = None
def get_lan_ip():
    """This PC's primary LAN IP (the interface with the default route) — for the phone URL / QR."""
    global _lan_ip
    if _lan_ip is None:
        s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        try:
            s.connect(("8.8.8.8", 80))   # no packets sent; just picks the outbound interface
            _lan_ip = s.getsockname()[0]
        except Exception:
            _lan_ip = "127.0.0.1"
        finally:
            try:
                s.close()
            except Exception:
                pass
    return _lan_ip


def _load_disk():
    try:
        with open(STATE_FILE, encoding="utf-8") as f:
            d = json.load(f)
        _data["state"] = d.get("state")
        _data["timer"] = d.get("timer")
        _data["claims"] = d.get("claims") or {}
        _data["gtokens"] = d.get("gtokens") or []
        print(f"  recovered {len(_data['claims'])} team claim(s) and last state from disk")
    except FileNotFoundError:
        pass
    except Exception as e:
        print(f"  !! could not read {STATE_FILE}: {e}")


def _save_disk():
    """Atomically persist the durable bits (temp file -> fsync -> rename)."""
    tmp = STATE_FILE + ".tmp"
    payload = {"state": _data["state"], "timer": _data["timer"], "claims": _data["claims"],
               "gtokens": _data["gtokens"]}
    try:
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(payload, f)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, STATE_FILE)
    except Exception as e:
        print(f"  !! could not write {STATE_FILE}: {e}")


def _join_pin():
    st = _data["state"] or {}
    return st.get("joinPin") or JOIN_PIN_DEFAULT


def _conns_summary():
    """teamId(int) -> connected(bool) for anyone to read."""
    t = _now()
    return {int(tid): (t - c.get("lastSeen", 0) < CONN_WINDOW) for tid, c in _data["claims"].items()}


def _touch(token):
    """Bump last-seen for the claim holding this token; return its teamId(str) or None."""
    if not token:
        return None
    for tid, c in _data["claims"].items():
        if c.get("token") == token:
            c["lastSeen"] = _now()
            return tid
    return None


def _team_list():
    teams = (_data["state"] or {}).get("teams", []) or []
    claimed = set(_data["claims"].keys())
    return [{"id": t["id"], "name": t.get("name", ""), "mgr": t.get("mgr", ""),
             "claimed": str(t["id"]) in claimed} for t in teams]


def _order_team(st, overall):
    """Snake math mirror of orderForPick(): the teamId on the clock at `overall`, or None."""
    teams = (st or {}).get("teams", []) or []
    n = len(teams)
    if n == 0:
        return None
    rnd = (overall - 1) // n + 1
    idx = (overall - 1) % n
    if st.get("snake", True) and rnd % 2 == 0:
        idx = n - 1 - idx
    return teams[idx]["id"]


_proxy_warned = {}
def _warn_proxy_admin(who):
    """Someone reached an admin endpoint through a proxy (tailscale serve/funnel). Denied - say so."""
    now = _now()
    if now - _proxy_warned.get(who, 0) < 60:
        return
    _proxy_warned[who] = now
    print(f"  !! blocked an ADMIN request that arrived through a proxy (client {who}). "
          f"Open the control screen directly on this PC at http://localhost:{PORT}/draftboard.html")


_https_warned = {}
def _warn_https(ip):
    """One friendly line per device per minute instead of a wall of tracebacks."""
    now = _now()
    if now - _https_warned.get(ip, 0) < 60:
        return
    _https_warned[ip] = now
    print(f"  !! {ip} tried HTTPS. Tell them to use  http://{get_lan_ip()}:{PORT}/lobby.html  "
          f"(http://, not https://)")


# ---- mid-draft news/injury refresh (war room button; host PC has internet) ----
_refresh = {"running": False, "doneAt": 0, "ok": None, "msg": ""}

def _do_refresh():
    try:
        import concurrent.futures as cf
        import fetch_players as fp          # module import runs no network (main() is guarded)
        path = os.path.join(ROOT, "players.json")
        with open(path, encoding="utf-8") as f:
            data = json.load(f)
        players = data["players"]
        # 1) injuries: re-pull the 32 rosters -> pid -> current status
        inj_map = {}
        tdata = fp.get(fp.TEAMS_URL)
        for t in tdata["sports"][0]["leagues"][0]["teams"]:
            team = t["team"]
            try:
                rdata = fp.get(fp.ROSTER_URL.format(tid=team["id"]))
            except Exception:
                continue
            for grp in rdata.get("athletes", []):
                for a in grp.get("items", []):
                    inj = ""
                    for j in (a.get("injuries") or []):
                        s = (j.get("status") or "").strip()
                        if s:
                            inj = s
                            break
                    inj_map[str(a.get("id"))] = inj
            time.sleep(0.1)
        for p in players:
            pid = str(p.get("id"))
            if pid in inj_map:
                p["inj"] = inj_map[pid]
        # 2) news: threaded re-pull for ADP-ranked players
        targets = [p for p in players if p.get("adp", 999.9) < 900 and str(p["id"]).isdigit()]
        with cf.ThreadPoolExecutor(max_workers=8) as ex:
            futs = {ex.submit(fp.player_news, p["id"]): p for p in targets}
            for fut in cf.as_completed(futs):
                res = fut.result()
                if res:
                    futs[fut]["news"], futs[fut]["newsDate"] = res["news"], res["newsDate"]
        data["generated"] = time.strftime("%Y-%m-%d %H:%M:%S")
        tmp = path + ".tmp2"
        with open(tmp, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=1)
            f.flush()
            os.fsync(f.fileno())
        os.replace(tmp, path)
        _refresh["ok"] = True
        _refresh["msg"] = f"news refreshed for {len(targets)} ranked players · {sum(1 for p in players if p.get('inj'))} injury flags"
    except Exception as e:
        _refresh["ok"] = False
        _refresh["msg"] = str(e)
    finally:
        _refresh["running"] = False
        _refresh["doneAt"] = _now()


class QuietServer(http.server.ThreadingHTTPServer):
    """Port scans, HTTPS attempts and phones dropping off Wi-Fi are NORMAL on a party LAN.
    Log one line instead of a 30-line traceback, so a real problem is still visible."""
    def handle_error(self, request, client_address):
        e = sys.exc_info()[1]
        if isinstance(e, (ConnectionResetError, ConnectionAbortedError, BrokenPipeError, TimeoutError)):
            return                      # client hung up; nothing to see
        print(f"  !! dropped a malformed request from {client_address[0]} ({type(e).__name__})")


class Handler(http.server.SimpleHTTPRequestHandler):
    def __init__(self, *a, **kw):
        super().__init__(*a, directory=ROOT, **kw)

    # ---- helpers ----
    # Privileged endpoints (/push /pending /claims /release /guide_* ...) are gated on this.
    # A reverse proxy such as `tailscale serve` / `funnel` connects to us FROM 127.0.0.1, so a
    # remote visitor would otherwise look local and could overwrite the draft. Tailscale (like any
    # HTTP proxy) adds X-Forwarded-* headers, so treat ANY forwarded request as remote.
    PROXY_HEADERS = ("X-Forwarded-For", "X-Forwarded-Proto", "X-Forwarded-Host",
                     "Forwarded", "X-Real-Ip", "Tailscale-Funnel-Request",
                     "Tailscale-User-Login", "Tailscale-User-Name")

    def _local(self):
        if self.client_address[0] not in ("127.0.0.1", "::1"):
            return False
        for h in self.PROXY_HEADERS:
            if self.headers.get(h):
                _warn_proxy_admin(self.headers.get("X-Forwarded-For") or "proxy")
                return False
        return True

    def _reply(self, code, obj):
        body = json.dumps(obj).encode("utf-8")
        self.send_response(code)
        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()
        try:
            self.wfile.write(body)
        except Exception:
            pass

    def _read(self):
        try:
            n = int(self.headers.get("Content-Length") or 0)
            return json.loads(self.rfile.read(n) or b"{}")
        except Exception:
            return {}

    def end_headers(self):
        # Never let phones cache the app HTML — always serve the latest lobby/board/pick/control.
        # NOTE: `self.path` does NOT exist when a request fails to parse (malformed line, or a
        # browser speaking HTTPS at this HTTP port), and end_headers() still runs for the error
        # response — so never assume it's there.
        p = (getattr(self, "path", "") or "").split("?")[0]
        if p.endswith(".html") or p == "/":
            self.send_header("Cache-Control", "no-store")
        super().end_headers()

    def send_error(self, code, message=None, explain=None):
        # A TLS ClientHello starts with byte 0x16 — that's a phone that tried https:// on our
        # http-only port. Answering with HTTP is pointless, so log a hint and drop it quietly.
        raw = getattr(self, "raw_requestline", b"") or b""
        if raw[:1] == b"":
            _warn_https(self.client_address[0])
            self.close_connection = True
            return
        try:
            super().send_error(code, message, explain)
        except Exception:
            self.close_connection = True

    def log_message(self, *a):
        pass  # keep the console quiet

    # ---- GET ----
    def do_GET(self):
        u = urlparse(self.path)
        path, q = u.path, parse_qs(u.query)

        if path in PRIVATE_FILES:      # claims tokens / guide intel must never be fetched as static files
            return self._reply(403, {"ok": False})

        if path == "/whoami":
            return self._reply(200, {"local": self._local()})

        if path == "/guide_data":      # operator Draft Guide intel — requires a guide token from /guide_auth
            g = (q.get("g") or [None])[0]
            with _lock:
                if not g or g not in _data["gtokens"]:
                    return self._reply(403, {"ok": False, "reason": "Guide locked — enter the PIN"})
            try:
                with open(GUIDE_FILE, encoding="utf-8") as f:
                    return self._reply(200, {"ok": True, "guide": json.load(f)})
            except FileNotFoundError:
                return self._reply(200, {"ok": True, "guide": None, "reason": "guide_data.json not found"})
            except Exception as e:
                return self._reply(200, {"ok": False, "reason": f"guide_data.json unreadable: {e}"})

        if path == "/guide_stats":     # tiers / projections / 4-year history — same gate as /guide_data
            g = (q.get("g") or [None])[0]
            with _lock:
                if not g or g not in _data["gtokens"]:
                    return self._reply(403, {"ok": False, "reason": "Guide locked — enter the PIN"})
            try:
                with open(GSTATS_FILE, encoding="utf-8") as f:
                    return self._reply(200, {"ok": True, "stats": json.load(f)})
            except FileNotFoundError:
                return self._reply(200, {"ok": True, "stats": None, "reason": "guide_stats.json not found — run fetch_guide_stats.py"})
            except Exception as e:
                return self._reply(200, {"ok": False, "reason": f"guide_stats.json unreadable: {e}"})

        if path == "/guide_refresh_status":
            g = (q.get("g") or [None])[0]
            with _lock:
                if not g or g not in _data["gtokens"]:
                    return self._reply(403, {"ok": False})
            return self._reply(200, {"ok": True, "running": _refresh["running"],
                                     "doneAt": _refresh["doneAt"], "success": _refresh["ok"], "msg": _refresh["msg"]})

        if path == "/lan":   # this PC's LAN IP + the lobby URL (for the phone QR code)
            ip = get_lan_ip()
            return self._reply(200, {"ip": ip, "port": PORT, "url": f"http://{ip}:{PORT}/lobby.html"})

        if path == "/state":
            tok = (q.get("token") or [None])[0]
            with _lock:
                me = None
                if tok is not None:
                    tid = _touch(tok)
                    me = {"valid": tid is not None, "teamId": int(tid) if tid else None}
                return self._reply(200, {
                    "ok": True, "state": _data["state"], "timer": _data["timer"],
                    "conns": _conns_summary(), "me": me,
                    "serverTime": _now(),
                })

        if path == "/teams":
            with _lock:
                return self._reply(200, {"ok": True, "teams": _team_list()})

        if path == "/pickresult":
            rid = (q.get("reqId") or [None])[0]
            with _lock:
                r = _data["results"].get(rid)
                if r:
                    return self._reply(200, r)
                if any(x["reqId"] == rid for x in _data["queue"]):
                    return self._reply(200, {"status": "pending"})
                return self._reply(200, {"status": "unknown"})

        # ---- privileged (loopback only) ----
        if path == "/pending":
            if not self._local():
                return self._reply(403, {"ok": False})
            with _lock:
                # Housekeeping on the control screen's 1 Hz poll: a request whose ratifier never
                # came back (control closed mid-pick) would otherwise sit in the queue forever and
                # block that team from re-submitting. Same for read results nobody collected.
                cut = _now() - QUEUE_TTL
                stale = [x for x in _data["queue"] if x["ts"] < cut]
                if stale:
                    _data["queue"] = [x for x in _data["queue"] if x["ts"] >= cut]
                    for x in stale:
                        _data["results"][x["reqId"]] = {"status": "rejected",
                                                        "reason": "Timed out — try again", "ts": _now()}
                rcut = _now() - RESULT_TTL
                for k in [k for k, v in _data["results"].items() if v["ts"] < rcut]:
                    del _data["results"][k]
                return self._reply(200, {"ok": True, "pending": list(_data["queue"])})

        if path == "/claims":
            if not self._local():
                return self._reply(403, {"ok": False})
            with _lock:
                t = _now()
                return self._reply(200, {"ok": True, "claims": [
                    {"teamId": int(tid), "deviceId": c.get("deviceId", ""), "label": c.get("label", ""),
                     "lastSeen": c.get("lastSeen", 0), "connected": (t - c.get("lastSeen", 0) < CONN_WINDOW)}
                    for tid, c in _data["claims"].items()]})

        return super().do_GET()  # static file

    # ---- POST ----
    def do_POST(self):
        path = urlparse(self.path).path
        b = self._read()

        if path == "/guide_auth":      # unlock the operator Draft Guide with the admin PIN
            with _lock:
                if str(b.get("pin") or "") != ADMIN_PIN:
                    return self._reply(200, {"ok": False, "reason": "Wrong PIN"})
                token = secrets.token_hex(12)
                _data["gtokens"] = (_data["gtokens"] + [token])[-8:]   # keep a handful of active devices
                _save_disk()
                return self._reply(200, {"ok": True, "gtoken": token})

        if path == "/guide_refresh":   # war-room button: re-pull news+injuries mid-draft (needs internet)
            with _lock:
                if b.get("g") not in _data["gtokens"]:
                    return self._reply(403, {"ok": False})
                if _refresh["running"]:
                    return self._reply(200, {"ok": True, "already": True})
                _refresh.update({"running": True, "ok": None, "msg": ""})
            threading.Thread(target=_do_refresh, daemon=True).start()
            return self._reply(200, {"ok": True, "started": True})

        if path == "/join":
            with _lock:
                if b.get("pin") != _join_pin():
                    return self._reply(200, {"ok": False, "reason": "Wrong join PIN"})
                return self._reply(200, {"ok": True, "teams": _team_list()})

        if path == "/claim":
            with _lock:
                if b.get("pin") != _join_pin():
                    return self._reply(200, {"ok": False, "reason": "Wrong join PIN"})
                tid = str(b.get("teamId"))
                teams = {str(t["id"]) for t in (_data["state"] or {}).get("teams", [])}
                if tid not in teams:
                    return self._reply(200, {"ok": False, "reason": "Unknown team"})
                dev = b.get("deviceId", "")
                existing = _data["claims"].get(tid)
                if existing and existing.get("deviceId") != dev \
                        and _now() - existing.get("lastSeen", 0) < CONN_WINDOW:
                    return self._reply(200, {"ok": False, "reason": "That team is already taken"})
                token = secrets.token_hex(8)
                _data["claims"][tid] = {"token": token, "deviceId": dev,
                                        "label": b.get("label", ""), "lastSeen": _now()}
                _save_disk()
                return self._reply(200, {"ok": True, "token": token, "teamId": int(tid)})

        if path == "/pick":
            with _lock:
                tok = b.get("token")
                tid = None
                for k, c in _data["claims"].items():
                    if c.get("token") == tok:
                        tid = k
                        c["lastSeen"] = _now()
                        break
                if tid is None:
                    return self._reply(200, {"ok": False, "reason": "Not signed in — re-join"})
                st = _data["state"] or {}
                if not st.get("draftLive"):
                    return self._reply(200, {"ok": False, "reason": "The draft hasn't started yet"})
                cur = len(st.get("picks", [])) + 1
                onclock = _order_team(st, cur)
                if onclock is not None and int(tid) != onclock:
                    return self._reply(200, {"ok": False, "reason": "Not your turn"})
                if any(x["teamId"] == tid for x in _data["queue"]):
                    return self._reply(200, {"ok": False, "reason": "Pick already submitted"})
                rid = secrets.token_hex(6)
                _data["queue"].append({"reqId": rid, "teamId": tid, "playerId": b.get("playerId"),
                                       "writein": b.get("writein"),   # {name, team, pos} for a not-in-the-data player
                                       "forOverall": b.get("forOverall"), "ts": _now()})
                return self._reply(200, {"ok": True, "reqId": rid})

        # ---- privileged (loopback only) ----
        if path == "/push":
            if not self._local():
                return self._reply(403, {"ok": False})
            with _lock:
                _data["state"] = b.get("state")
                _save_disk()
                return self._reply(200, {"ok": True})

        if path == "/timer":
            if not self._local():
                return self._reply(403, {"ok": False})
            with _lock:
                _data["timer"] = b.get("timer")
                return self._reply(200, {"ok": True})

        if path == "/resolve":
            if not self._local():
                return self._reply(403, {"ok": False})
            with _lock:
                rid = b.get("reqId")
                _data["queue"] = [x for x in _data["queue"] if x["reqId"] != rid]
                _data["results"][rid] = {"status": b.get("status", "rejected"),
                                         "reason": b.get("reason", ""), "ts": _now()}
                cut = _now() - RESULT_TTL
                for k in [k for k, v in _data["results"].items() if v["ts"] < cut]:
                    del _data["results"][k]
                return self._reply(200, {"ok": True})

        if path == "/release":
            if not self._local():
                return self._reply(403, {"ok": False})
            with _lock:
                tid = str(b.get("teamId"))
                if b.get("all"):
                    _data["claims"] = {}
                else:
                    _data["claims"].pop(tid, None)
                _save_disk()
                return self._reply(200, {"ok": True})

        return self._reply(404, {"ok": False})


def main():
    _load_disk()
    httpd = QuietServer(("0.0.0.0", PORT), Handler)
    print("=" * 64)
    print(f"  Betta Bring It — draft relay running on port {PORT}")
    print(f"  Control PC (this machine):  http://localhost:{PORT}/draftboard.html")
    print(f"                    TV board:  http://localhost:{PORT}/board.html")
    print(f"                     TV pick:  http://localhost:{PORT}/pick.html")
    print(f"  Players' phones:            http://{get_lan_ip()}:{PORT}/lobby.html")
    print(f"  Lobby join PIN: {_join_pin()}   (set 'joinPin' in Settings to change)")
    print("=" * 64)
    try:
        httpd.serve_forever()
    except KeyboardInterrupt:
        print("\n  stopped.")


if __name__ == "__main__":
    main()
