#!/usr/bin/env python3
"""PROOF OF FIST - example Bot Arena bot (Python 3.8+, standard library only).

Your bot runs on YOUR machine and plays through the public API; the server never runs
user code. Create a bot at /developers to get its API key, then:

    python example_bot.py --url http://localhost:8000 --key fist_... --mode spar   # practice
    python example_bot.py --url http://localhost:8000 --key fist_... --mode arena  # ranked
    python example_bot.py --url http://localhost:8000 --key fist_... --mode house  # house runs

Duel protocol (best of 5, first to 3, ties replay):
    1. POST /api/duels/{id}/commit  {"commitment": sha256(f"{move}:{salt}")}
    2. wait until both sides have committed (GET /api/duels/{id})
    3. POST /api/duels/{id}/reveal  {"move": "rock", "salt": "<the salt>"}
Missing a commit/reveal deadline (15 s between bots) forfeits the round.

Edit `FrequencyStrategy.choose` - that's where the fun is.
"""

from __future__ import annotations

import argparse
import hashlib
import json
import random
import secrets
import sys
import time
import urllib.error
import urllib.request
from collections import Counter
from collections.abc import Callable
from typing import Any

MOVES = ("rock", "paper", "scissors")
BEATS = {"rock": "paper", "paper": "scissors", "scissors": "rock"}  # value beats key
Transport = Callable[[str, str, dict | None], dict[str, Any]]


class ApiError(Exception):
    def __init__(self, status: int, body: dict[str, Any]) -> None:
        super().__init__(f"{status}: {body.get('message') or body}")
        self.status, self.body = status, body


def urllib_transport(base_url: str, key: str) -> Transport:
    def call(method: str, path: str, body: dict | None = None) -> dict[str, Any]:
        data = json.dumps(body).encode() if body is not None else None
        req = urllib.request.Request(base_url.rstrip("/") + path, data=data, method=method)
        req.add_header("Authorization", f"Bearer {key}")
        req.add_header("Content-Type", "application/json")
        try:
            with urllib.request.urlopen(req, timeout=15) as resp:
                return json.load(resp)
        except urllib.error.HTTPError as exc:
            try:
                payload = json.load(exc)
            except ValueError:
                payload = {"message": exc.reason}
            raise ApiError(exc.code, payload) from None

    return call


class FistClient:
    def __init__(self, transport: Transport) -> None:
        self.call = transport

    def me(self) -> dict[str, Any]:
        return self.call("GET", "/api/me", None)

    def queue(self) -> dict[str, Any]:
        return self.call("POST", "/api/arena/queue", {})

    def spar(self) -> dict[str, Any]:
        return self.call("POST", "/api/duels/spar", {})["duel"]

    def duel(self, code: str) -> dict[str, Any]:
        return self.call("GET", f"/api/duels/{code}", None)["duel"]

    def commit(self, code: str, commitment: str) -> dict[str, Any]:
        return self.call("POST", f"/api/duels/{code}/commit", {"commitment": commitment})

    def reveal(self, code: str, move: str, salt: str) -> dict[str, Any]:
        return self.call("POST", f"/api/duels/{code}/reveal", {"move": move, "salt": salt})

    def start_run(self) -> dict[str, Any]:
        return self.call("POST", "/api/runs", {})["run"]

    def throw(self, run_id: str, move: str) -> dict[str, Any]:
        return self.call("POST", f"/api/runs/{run_id}/throw", {"move": move})

    def risk(self, run_id: str) -> dict[str, Any]:
        return self.call("POST", f"/api/runs/{run_id}/continue", {})

    def claim(self, run_id: str) -> dict[str, Any]:
        return self.call("POST", f"/api/runs/{run_id}/claim", {})


def commitment(move: str, salt: str) -> str:
    return hashlib.sha256(f"{move}:{salt}".encode()).hexdigest()


class FrequencyStrategy:
    """Predict the opponent from what they played after their previous move (a 1st-order
    Markov guess, falling back to overall frequency), then throw what beats it. 20% of the
    time play uniformly at random, so we can't be exploited too hard ourselves."""

    def __init__(self, rng: random.Random | None = None, explore: float = 0.2) -> None:
        self.rng = rng or random.SystemRandom()
        self.explore = explore

    def choose(self, history: list[tuple[str, str]]) -> str:
        """history = [(my_move, their_move), ...] for every decided or tied round so far."""
        if not history or self.rng.random() < self.explore:
            return self.rng.choice(MOVES)
        theirs = [t for _, t in history]
        follow = Counter(b for a, b in zip(theirs, theirs[1:]) if a == theirs[-1])  # noqa: B905, RUF007 (py3.8)
        counts = follow if follow else Counter(theirs)
        predicted = counts.most_common(1)[0][0]
        return BEATS[predicted]


def play_duel(
    client: FistClient,
    code: str,
    strategy: FrequencyStrategy,
    poll: float = 0.4,
    sleep: Callable[[float], None] = time.sleep,
    log: Callable[[str], None] = print,
) -> dict[str, Any]:
    secret: dict[int, tuple[str, str]] = {}
    while True:
        duel = client.duel(code)
        if duel["status"] != "active":
            return duel
        side, other = duel["you"], "b" if duel["you"] == "a" else "a"
        history = [
            (r[f"move_{side}"], r[f"move_{other}"])
            for r in duel["rounds"]
            if r["outcome"] in ("a", "b", "tie")
        ]
        rnd = duel["rounds"][-1]
        n = rnd["round"]
        try:
            if not rnd["committed"][side]:
                move, salt = strategy.choose(history), secrets.token_hex(16)
                secret[n] = (move, salt)
                client.commit(code, commitment(move, salt))
                continue
            if rnd["committed"][other] and not rnd["you_revealed"] and n in secret:
                client.reveal(code, *secret[n])
                log(f"  round {n}: played {secret[n][0]}")
                continue
        except ApiError as exc:
            if exc.status != 409:  # 409 = the state moved on under us; just re-read it
                raise
        sleep(poll)


def describe(duel: dict[str, Any]) -> str:
    me = duel["you"]
    if duel["status"] != "finished":
        return f"duel {duel['id']} {duel['status']}"
    result = "draw" if duel["winner"] is None else "WON" if duel["winner"] == me else "lost"
    return f"duel {duel['id']}: {result} {duel['wins']['a']}-{duel['wins']['b']}"


def run_arena(
    client: FistClient,
    games: int,
    strategy: FrequencyStrategy,
    sleep: Callable[[float], None] = time.sleep,
) -> None:
    for _ in range(games):
        print("queueing for a ranked match…")
        while True:
            res = client.queue()
            if res["status"] == "matched":
                break
            sleep(1.0)
        final = play_duel(client, res["duel"]["id"], strategy, sleep=sleep)
        print(describe(final))


def run_house(client: FistClient, games: int, stop_at: int = 4) -> None:
    """House runs are pure chance (the house is provably uniform), so bank early."""
    for _ in range(games):
        run = client.start_run()
        while run["status"] in ("active", "deciding"):
            if run["status"] == "deciding":
                run = (
                    client.claim(run["id"]) if run["cleared"] >= stop_at else client.risk(run["id"])
                )["run"]
                continue
            time.sleep(0.3)  # the server enforces a minimum spacing between throws
            run = client.throw(run["id"], random.choice(MOVES))["run"]
        tier = run["claimable_tier"]["material"] if run["status"] == "claimed" else None
        print(f"run {run['id']}: {run['status']}" + (f" - {tier}" if tier else ""))


def main(argv: list[str] | None = None) -> int:
    p = argparse.ArgumentParser(description="PROOF OF FIST example bot")
    p.add_argument("--url", default="http://localhost:8000")
    p.add_argument("--key", required=True, help="your bot's API key (fist_...)")
    p.add_argument("--mode", choices=("arena", "spar", "house"), default="spar")
    p.add_argument("--games", type=int, default=3)
    a = p.parse_args(argv)
    client = FistClient(urllib_transport(a.url, a.key))
    me = client.me()["account"]
    print(f"{me['username']} ({me['short_address']}) ready - mode {a.mode}")
    strategy = FrequencyStrategy()
    if a.mode == "arena":
        run_arena(client, a.games, strategy)
    elif a.mode == "spar":
        for _ in range(a.games):
            print(describe(play_duel(client, client.spar()["id"], strategy)))
    else:
        run_house(client, a.games)
    return 0


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