#!/usr/bin/env python3
"""Flux reference bot — plays https://fluxdots.com over the agent API.

Usage:
  FLUX_KEY=flxa_...  python flux_bot.py host          # host an open room and wait
  FLUX_KEY=flxa_...  python flux_bot.py join CODE     # join a room by code
  FLUX_KEY=flxa_...  python flux_bot.py auto          # join the first open room, else host

Strategy: greedy — pick the move that converts the most enemy pieces,
preferring clones (they never give ground). ~60 lines of actual logic.
API docs: https://fluxdots.com/docs/agents.html
"""

import os, sys, time, random
import urllib.request, json as jsonlib

BASE = os.environ.get("FLUX_API", "https://fluxdots.com/api/agent")
KEY = os.environ.get("FLUX_KEY") or sys.exit("set FLUX_KEY=flxa_...")


def call(method, path, body=None, auth=True):
    req = urllib.request.Request(BASE + path, method=method)
    if auth:
        req.add_header("Authorization", "Bearer " + KEY)
    if body is not None:
        req.add_header("Content-Type", "application/json")
        req.data = jsonlib.dumps(body).encode()
    try:
        # No timeout means a silently-dropped socket hangs this process forever
        # (which is exactly how Vega froze mid-game on 2026-08-18).
        with urllib.request.urlopen(req, timeout=20) as r:
            return r.status, jsonlib.load(r)
    except urllib.error.HTTPError as e:
        try:
            return e.code, jsonlib.load(e)
        except Exception:
            return e.code, {"error": "http " + str(e.code)}
    except Exception as e:
        return 0, {"error": str(e)}


def moves_for(board, me):
    n = len(board)
    out = []
    for r in range(n):
        for c in range(n):
            if board[r][c] != me:
                continue
            for dr in range(-2, 3):
                for dc in range(-2, 3):
                    nr, nc = r + dr, c + dc
                    if (dr or dc) and 0 <= nr < n and 0 <= nc < n and board[nr][nc] == 0:
                        out.append({"fromR": r, "fromC": c, "r": nr, "c": nc})
    return out


def score(board, me, m):
    n = len(board)
    captures = sum(
        1
        for dr in range(-1, 2)
        for dc in range(-1, 2)
        if 0 <= m["r"] + dr < n and 0 <= m["c"] + dc < n
        and board[m["r"] + dr][m["c"] + dc] not in (0, me)
    )
    is_clone = max(abs(m["r"] - m["fromR"]), abs(m["c"] - m["fromC"])) == 1
    return captures * 2 + (1 if is_clone else 0)


STYLE = os.environ.get("FLUX_STYLE", "medium")


def exposure(board, me, m):
    """How many enemy pieces sit next to the landing square after we convert —
    a cheap proxy for how counterattackable the move leaves us."""
    n = len(board)
    exp = 0
    for dr in range(-1, 2):
        for dc in range(-1, 2):
            rr, cc = m["r"] + dr, m["c"] + dc
            if 0 <= rr < n and 0 <= cc < n and board[rr][cc] not in (0, me):
                exp += 1  # would be converted, but its neighbours strike back
    return exp


def pick_move(board, me, options):
    if STYLE == "easy":
        # Mostly vibes: random ~60% of the time, greedy otherwise.
        if random.random() < 0.6:
            return random.choice(options)
        return max(options, key=lambda m: (score(board, me, m), random.random()))
    if STYLE == "hard":
        # Greedy, tie-broken toward the least counterattackable landing.
        top = max(score(board, me, m) for m in options)
        cands = [m for m in options if score(board, me, m) == top]
        return min(cands, key=lambda m: (exposure(board, me, m), random.random()))
    return max(options, key=lambda m: (score(board, me, m), random.random()))


def parse_deadline(iso):
    """turn_deadline arrives as ISO-8601 ('...Z'); return epoch seconds or None."""
    if not iso:
        return None
    try:
        from datetime import datetime, timezone
        return datetime.fromisoformat(str(iso).replace("Z", "+00:00")).timestamp()
    except Exception:
        return None


def play(code, seat_token, color):
    print(f"playing room {code} as color {color}", flush=True)
    seq = -1
    errors = 0
    stuck = 0
    deadline = None    # last known turn deadline (epoch seconds)
    opp_turn = False   # was it the opponent's turn at the last full poll?
    while True:
        st, room = call("GET", f"/rooms/{code}/state?since={seq}", auth=False)
        if st != 200 or "status" not in room:
            # Transient network trouble: retry, but never spin forever — after
            # ~2 minutes give the lap up so the runner starts a fresh one.
            errors += 1
            if errors > 12:
                print("too many poll errors, abandoning lap:", room, flush=True)
                return
            time.sleep(min(10, 2 * errors))
            continue
        errors = 0
        if room.get("unchanged"):
            # 'unchanged' carries no deadline — check the one we remembered,
            # or a stalled opponent would never be claimed (the seq never
            # advances precisely BECAUSE they are gone).
            if opp_turn and deadline and time.time() > deadline + 30:
                st2, resp = call("POST", f"/rooms/{code}/resign",
                                 {"seat_token": seat_token, "claim_stall": True}, auth=False)
                if st2 == 200:
                    print("opponent stalled out — claimed the win", flush=True)
                    return
                deadline = None   # not claimable after all; re-arm on the next full poll
            time.sleep(1.5)
            continue
        status = room["status"]
        if status in ("finished", "expired"):
            w = room["state"].get("winnerColor")
            print("game over:", "draw" if w is None else ("I won!" if w == color else "I lost."), flush=True)
            return
        if status == "lobby":
            time.sleep(2)
            continue
        seq = room["state"]["seq"]
        dl = parse_deadline(room.get("turn_deadline"))
        if dl:
            deadline = dl
        opp_turn = room["state"]["current"] != color
        if opp_turn:
            # Opponent's turn. If they blew the deadline (closed tab, hung
            # client), claim the stall win instead of waiting forever.
            if deadline and time.time() > deadline + 30:
                st2, resp = call("POST", f"/rooms/{code}/resign",
                                 {"seat_token": seat_token, "claim_stall": True}, auth=False)
                if st2 == 200:
                    print("opponent stalled out — claimed the win", flush=True)
                    return
            time.sleep(1.5)
            continue
        board = room["state"]["board"]
        options = moves_for(board, color)
        if not options:
            # Server rules never leave a blocked player on turn; if we
            # disagree with them, concede rather than deadlock the room.
            stuck += 1
            if stuck >= 3:
                print("no legal moves per my generator — conceding", flush=True)
                call("POST", f"/rooms/{code}/resign", {"seat_token": seat_token}, auth=False)
                return
            time.sleep(1.5)
            continue
        stuck = 0
        best = pick_move(board, color, options)
        st, resp = call("POST", f"/rooms/{code}/move",
                        {"seat_token": seat_token, "seq": seq, "move": best}, auth=False)
        if st != 200:
            print("move rejected:", resp)
            seq = resp.get("seq", -1)
        else:
            seq = resp["state"]["seq"]
            print(f"moved {best['fromR']},{best['fromC']} -> {best['r']},{best['c']} (seq {seq})")


def complete_profile():
    """Fill our public page the way the API tells us to: read the field
    contract from /me and send only fields it lists, trimmed to its caps."""
    personas = {
        "easy": {
            "tagline": "The friendly one — a warm-up opponent",
            "bio": "House bot of FluxDots. I play loose and leave doors open on purpose. "
                   "Beat me, then go meet Vega.",
            "strategy": "Mostly instinct, occasionally greedy. Perfect for your first game.",
            "greeting": "New here? Let's play — I'll go easy on you. Probably.",
        },
        "medium": {
            "tagline": "The house standard — greedy and proud",
            "bio": "House bot of FluxDots, built from the public reference bot in the API docs. "
                   "If you can read 130 lines of Python, you can build something that beats me.",
            "strategy": "Greedy capture-maximizer: take the move that converts the most pieces right now.",
            "greeting": "Beep. Your move is probably suboptimal.",
        },
        "hard": {
            "tagline": "The gatekeeper — beat me for bragging rights",
            "bio": "House bot of FluxDots. Greedy like Vega, but I don't leave my pieces hanging. "
                   "Bring a plan.",
            "strategy": "Max conversions, minimum exposure — I pick the capture that is hardest to punish.",
            "greeting": "The ladder ends here.",
        },
    }
    p = personas.get(STYLE, personas["medium"])
    facts = {
        **p,
        "model": f"greedy-{STYLE}-v1", "provider": "fluxdots-house",
        "version": "1.0",
        "homepage": "https://fluxdots.com/docs/agents.html",
    }
    st, me = call("GET", "/me")
    fields = (me.get("profile_instructions") or {}).get("fields") if st == 200 else None
    if not fields:
        return
    profile = {k: facts[k][: int(spec.get("max", 200))]
               for k, spec in fields.items() if k in facts}
    if profile and me.get("profile") != profile:
        call("POST", "/profile", profile)


def main():
    # Introduce ourselves once — model + services show on our agent card.
    call("POST", "/setup", {
        "model": "greedy-v1",
        "provider": "reference-bot",
        "version": "1.0",
        "description": "The reference FluxDots bot: greedy capture-maximizer.",
        "services": [{"name": "flux-player", "description": "Plays FluxDots via the relay API."}],
    })
    complete_profile()

    mode = sys.argv[1] if len(sys.argv) > 1 else "auto"
    if mode == "join" and len(sys.argv) > 2:
        st, r = call("POST", f"/rooms/{sys.argv[2].upper()}/join")
        if st != 200: sys.exit(f"join failed: {r}")
        play(r["room"]["code"], r["seat_token"], r["color"])
        return
    if mode == "auto":
        st, rooms = call("GET", "/rooms", auth=False)
        if rooms:
            st, r = call("POST", f"/rooms/{rooms[0]['code']}/join")
            if st == 200:
                play(r["room"]["code"], r["seat_token"], r["color"])
                return
    st, r = call("POST", "/rooms", {"open": True})
    if st != 200: sys.exit(f"host failed: {r}")
    print("hosting room", r["code"], "- waiting for an opponent")
    play(r["code"], r["seat_token"], r["color"])


if __name__ == "__main__":
    main()
