/*
  Texas Hold 'em (React, no build step)
  ══════════════════════════════════════════════════════════════════════════════
  Four-handed no-limit against three bots. The engine covers the full betting
  round — blinds, folds, checks, calls, raises with a real minimum-raise rule,
  all-ins and side pots — and a seven-card evaluator picks each player's best
  five. Your stack and record survive a reload via localStorage.

  The domain logic (evaluator, table state, bots, reducer) is duplicated in
  game.js so the unit tests in test/game.test.js can load it without a browser.
  ══════════════════════════════════════════════════════════════════════════════
*/

// ── Domain: Card constants ───────────────────────────────────────────────────
const SUITS       = ["S", "H", "D", "C"];
const SUIT_SYMBOL = { S: "♠", H: "♥", D: "♦", C: "♣" };
const SUIT_COLOR  = { S: "black", C: "black", H: "red", D: "red" };
const RANKS       = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
const RANK_LABEL  = {
  1: "A", 2: "2",  3: "3", 4: "4", 5: "5",  6: "6",  7: "7",
  8: "8", 9: "9", 10: "10", 11: "J", 12: "Q", 13: "K",
};

// Aces play high everywhere except the wheel (A-2-3-4-5), handled in score5.
const RANK_WORD = {
  2: "two",  3: "three", 4: "four",  5: "five",  6: "six",   7: "seven",
  8: "eight", 9: "nine", 10: "ten", 11: "jack", 12: "queen", 13: "king",
  14: "ace",
};

// ── Domain: Table rules ──────────────────────────────────────────────────────
const START_STACK = 500;
const SMALL_BLIND = 5;
const BIG_BLIND   = 10;
const BOT_NAMES   = ["Ada", "Rex", "Nina"];
const MAX_RAISES  = 4;    // caps bot re-raising wars on a single street
const LOG_LIMIT   = 40;

// ── Domain: Deck ─────────────────────────────────────────────────────────────
function makeDeck() {
  let id = 1;
  const cards = [];
  for (const suit of SUITS)
    for (const rank of RANKS)
      cards.push({ id: id++, suit, rank });
  return cards;
}

function shuffle(arr) {
  const a = arr.slice();
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

function deepClone(obj) {
  return JSON.parse(JSON.stringify(obj));
}

function formatMoney(n) {
  return "$" + Math.round(n);
}

// ── Domain: Hand evaluation ──────────────────────────────────────────────────
// A hand scores as [category, ...tiebreakers], compared left to right, so
// [2, 14, 8, 3] (aces and eights) beats [2, 13, 12, 5] (kings and queens).
const HIGH_CARD = 0, PAIR = 1, TWO_PAIR = 2, TRIPS = 3, STRAIGHT = 4,
      FLUSH = 5, FULL_HOUSE = 6, QUADS = 7, STRAIGHT_FLUSH = 8;

function rankValue(card) {
  return card.rank === 1 ? 14 : card.rank;
}

function score5(cards) {
  const vals  = cards.map(rankValue).sort((a, b) => b - a);
  const suits = cards.map((c) => c.suit);
  const flush = suits.every((s) => s === suits[0]);

  const counts = {};
  for (const v of vals) counts[v] = (counts[v] || 0) + 1;
  // Ranks ordered by how many of them there are, then by value.
  const groups = Object.keys(counts).map(Number)
    .sort((a, b) => counts[b] - counts[a] || b - a);

  let straightHigh = 0;
  if (groups.length === 5) {
    if (vals[0] - vals[4] === 4)               straightHigh = vals[0];
    else if (vals[0] === 14 && vals[1] === 5)  straightHigh = 5;   // the wheel
  }

  if (flush && straightHigh)  return [STRAIGHT_FLUSH, straightHigh];
  if (counts[groups[0]] === 4) return [QUADS, groups[0], groups[1]];
  if (counts[groups[0]] === 3 && counts[groups[1]] === 2)
                               return [FULL_HOUSE, groups[0], groups[1]];
  if (flush)                   return [FLUSH].concat(vals);
  if (straightHigh)            return [STRAIGHT, straightHigh];
  if (counts[groups[0]] === 3) return [TRIPS].concat(groups);
  if (counts[groups[0]] === 2 && counts[groups[1]] === 2)
                               return [TWO_PAIR].concat(groups);
  if (counts[groups[0]] === 2) return [PAIR].concat(groups);
  return [HIGH_CARD].concat(vals);
}

function compareScores(a, b) {
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    const x = a[i] || 0, y = b[i] || 0;
    if (x !== y) return x > y ? 1 : -1;
  }
  return 0;
}

// Best five-card hand out of five, six or seven cards.
function evaluateHand(cards) {
  if (cards.length < 5) throw new Error("need at least five cards to evaluate");

  let bestScore = null;
  let bestCards = null;

  if (cards.length === 5) {
    bestScore = score5(cards);
    bestCards = cards.slice();
  } else {
    const n = cards.length;
    for (let a = 0; a < n - 4; a++)
    for (let b = a + 1; b < n - 3; b++)
    for (let c = b + 1; c < n - 2; c++)
    for (let d = c + 1; d < n - 1; d++)
    for (let e = d + 1; e < n; e++) {
      const combo = [cards[a], cards[b], cards[c], cards[d], cards[e]];
      const score = score5(combo);
      if (bestScore === null || compareScores(score, bestScore) > 0) {
        bestScore = score;
        bestCards = combo;
      }
    }
  }

  return {
    score:    bestScore,
    cards:    bestCards,
    category: bestScore[0],
    label:    describeScore(bestScore),
  };
}

function plural(value) {
  const word = RANK_WORD[value];
  return word === "six" ? "sixes" : word + "s";
}

function describeScore(score) {
  const t = score.slice(1);
  switch (score[0]) {
    case STRAIGHT_FLUSH: return t[0] === 14 ? "Royal flush"
                                            : "Straight flush, " + RANK_WORD[t[0]] + " high";
    case QUADS:          return "Four of a kind, " + plural(t[0]);
    case FULL_HOUSE:     return "Full house, " + plural(t[0]) + " over " + plural(t[1]);
    case FLUSH:          return "Flush, " + RANK_WORD[t[0]] + " high";
    case STRAIGHT:       return "Straight, " + RANK_WORD[t[0]] + " high";
    case TRIPS:          return "Three of a kind, " + plural(t[0]);
    case TWO_PAIR:       return "Two pair, " + plural(t[0]) + " and " + plural(t[1]);
    case PAIR:           return "Pair of " + plural(t[0]);
    default:             return capitalize(RANK_WORD[t[0]]) + " high";
  }
}

function capitalize(word) {
  return word.charAt(0).toUpperCase() + word.slice(1);
}

// ── Domain: Seats & initial state ────────────────────────────────────────────
function makeSeat(id, name, isHuman, stack) {
  return {
    id, name, isHuman,
    stack,
    hole:       [],
    folded:     false,
    allIn:      false,
    committed:  0,     // chips in front of the seat on this street
    totalBet:   0,     // chips in from this seat over the whole hand
    hasActed:   false, // acted since the last bet or raise
    lastAction: "",
  };
}

function emptyStats() {
  return { hands: 0, won: 0, showdowns: 0 };
}

// `saved` (optional) restores the player's stack and record from localStorage.
function initialState(saved) {
  const stack = saved && typeof saved.stack === "number" && saved.stack >= BIG_BLIND
    ? saved.stack
    : START_STACK;

  const seats = [makeSeat(0, "You", true, stack)];
  BOT_NAMES.forEach((name, i) => seats.push(makeSeat(i + 1, name, false, START_STACK)));

  const state = {
    deck:       [],
    seats,
    button:     seats.length - 1,   // startHand moves it, so hand 1 is on seat 0
    board:      [],
    pot:        0,
    currentBet: 0,
    minRaise:   BIG_BLIND,
    raises:     0,
    toAct:      0,
    street:     "preflop",          // preflop | flop | turn | river | showdown
    phase:      "acting",           // acting | runout | handover | busted
    reveal:     false,
    results:    null,
    log:        [],
    handNo:     0,
    stats:      (saved && saved.stats) || emptyStats(),
  };
  return startHand(state);
}

// ── Domain: Logging ──────────────────────────────────────────────────────────
function log(s, line) {
  s.log.push(line);
  if (s.log.length > LOG_LIMIT) s.log.shift();
  return s;
}

// ── Domain: Chip movement ────────────────────────────────────────────────────
function commit(seat, amount) {
  const paid = Math.min(amount, seat.stack);
  seat.stack     -= paid;
  seat.committed += paid;
  seat.totalBet  += paid;
  if (seat.stack === 0) seat.allIn = true;
  return paid;
}

// Chips wagered but not yet swept into the pot.
function streetChips(s) {
  return s.seats.reduce((n, seat) => n + seat.committed, 0);
}

function totalPot(s) {
  return s.pot + streetChips(s);
}

// ── Domain: Betting helpers (shared by the UI, the bots and the tests) ───────
function seatToAct(s) {
  return s.seats[s.toAct] || null;
}

function callAmount(s) {
  const seat = seatToAct(s);
  if (!seat) return 0;
  return Math.min(s.currentBet - seat.committed, seat.stack);
}

function canCheck(s) {
  const seat = seatToAct(s);
  return s.phase === "acting" && !!seat && seat.committed === s.currentBet;
}

function canCall(s) {
  return s.phase === "acting" && callAmount(s) > 0;
}

// A raise is only possible with chips beyond a call.
function canRaise(s) {
  const seat = seatToAct(s);
  if (s.phase !== "acting" || !seat) return false;
  return seat.stack > s.currentBet - seat.committed;
}

function maxRaiseTo(s) {
  const seat = seatToAct(s);
  return seat ? seat.committed + seat.stack : 0;
}

// The smallest legal raise, or an all-in when the stack cannot cover it.
function minRaiseTo(s) {
  return Math.min(s.currentBet + s.minRaise, maxRaiseTo(s));
}

function activeSeats(s) {
  return s.seats.filter((seat) => !seat.folded);
}

// Seats that can still put chips in.
function contenders(s) {
  return s.seats.filter((seat) => !seat.folded && !seat.allIn);
}

function nextActor(s, from) {
  for (let i = 1; i <= s.seats.length; i++) {
    const idx  = (from + i) % s.seats.length;
    const seat = s.seats[idx];
    if (!seat.folded && !seat.allIn) return idx;
  }
  return from;
}

// First seat left of the button that can act — the postflop lead.
function firstAfterButton(s) {
  return nextActor(s, s.button);
}

function isBettingComplete(s) {
  const live = contenders(s);
  if (live.length === 0) return true;
  return live.every((seat) => seat.hasActed && seat.committed === s.currentBet);
}

// ── Domain: Hand setup ───────────────────────────────────────────────────────
function startHand(state) {
  const s = state;
  s.handNo++;
  s.deck       = shuffle(makeDeck());
  s.board      = [];
  s.pot        = 0;
  s.currentBet = 0;
  s.minRaise   = BIG_BLIND;
  s.raises     = 0;
  s.street     = "preflop";
  s.reveal     = false;
  s.results    = null;
  s.log        = [];

  for (const seat of s.seats) {
    seat.hole       = [];
    seat.folded     = false;
    seat.allIn      = false;
    seat.committed  = 0;
    seat.totalBet   = 0;
    seat.hasActed   = false;
    seat.lastAction = "";
    // The bots always buy back in; the player uses New Game.
    if (!seat.isHuman && seat.stack < BIG_BLIND) {
      seat.stack = START_STACK;
      log(s, seat.name + " buys back in for " + formatMoney(START_STACK) + ".");
    }
  }

  if (s.seats[0].stack < BIG_BLIND) {
    s.phase = "busted";
    return log(s, "You are out of chips.");
  }

  s.button = (s.button + 1) % s.seats.length;
  const sb = s.seats[(s.button + 1) % s.seats.length];
  const bb = s.seats[(s.button + 2) % s.seats.length];

  commit(sb, SMALL_BLIND);
  commit(bb, BIG_BLIND);
  s.currentBet = Math.max(sb.committed, bb.committed);
  sb.lastAction = "Small blind";
  bb.lastAction = "Big blind";

  for (let round = 0; round < 2; round++)
    for (let i = 1; i <= s.seats.length; i++)
      s.seats[(s.button + i) % s.seats.length].hole.push(s.deck.pop());

  s.toAct = nextActor(s, (s.button + 2) % s.seats.length);   // left of the big blind
  s.phase = "acting";
  return log(s, "Hand #" + s.handNo + " — " + sb.name + " posts " + formatMoney(SMALL_BLIND)
                + ", " + bb.name + " posts " + formatMoney(BIG_BLIND) + ".");
}

// ── Domain: Actions ──────────────────────────────────────────────────────────
function doFold(s) {
  const seat = seatToAct(s);
  seat.folded     = true;
  seat.hasActed   = true;
  seat.lastAction = "Fold";
  log(s, seat.name + " folds.");
  return afterAction(s);
}

function doCheck(s) {
  const seat = seatToAct(s);
  seat.hasActed   = true;
  seat.lastAction = "Check";
  log(s, seat.name + " checks.");
  return afterAction(s);
}

function doCall(s) {
  const seat = seatToAct(s);
  const paid = commit(seat, s.currentBet - seat.committed);
  seat.hasActed   = true;
  seat.lastAction = seat.allIn ? "All in " + formatMoney(paid) : "Call " + formatMoney(paid);
  log(s, seat.name + (seat.allIn ? " calls all in for " : " calls ") + formatMoney(paid) + ".");
  return afterAction(s);
}

// `to` is the total this seat will have committed on this street.
function doRaise(s, to) {
  const seat   = seatToAct(s);
  const wasBet = s.currentBet > 0;
  const target = Math.max(Math.min(to, maxRaiseTo(s)), minRaiseTo(s));
  const size   = target - s.currentBet;
  commit(seat, target - seat.committed);

  // An all-in that is smaller than a full raise does not reopen the betting.
  const fullRaise = size >= s.minRaise;
  if (fullRaise) {
    s.minRaise = size;
    for (const other of s.seats)
      if (other !== seat && !other.folded && !other.allIn) other.hasActed = false;
  }

  s.currentBet    = Math.max(s.currentBet, seat.committed);
  s.raises++;
  seat.hasActed   = true;
  seat.lastAction = seat.allIn ? "All in " + formatMoney(seat.committed)
                  : (wasBet ? "Raise to " : "Bet ") + formatMoney(seat.committed);
  log(s, seat.name + (seat.allIn ? " moves all in for "
                    : wasBet ? " raises to " : " bets ")
         + formatMoney(seat.committed) + ".");
  return afterAction(s);
}

// ── Domain: Round flow ───────────────────────────────────────────────────────
function afterAction(s) {
  const live = activeSeats(s);
  if (live.length === 1) return awardUncontested(s, live[0]);
  if (isBettingComplete(s)) return endStreet(s);
  s.toAct = nextActor(s, s.toAct);
  return s;
}

function collectBets(s) {
  for (const seat of s.seats) {
    s.pot         += seat.committed;
    seat.committed = 0;
    seat.hasActed  = false;
  }
  s.currentBet = 0;
  s.minRaise   = BIG_BLIND;
  s.raises     = 0;
}

function endStreet(s) {
  collectBets(s);
  if (s.street === "river") return showdown(s);

  dealStreet(s);
  // With at most one seat still able to bet, the rest of the board just runs out.
  if (contenders(s).length < 2) {
    s.phase = "runout";
    return s;
  }
  s.toAct = firstAfterButton(s);
  s.phase = "acting";
  return s;
}

function dealStreet(s) {
  s.deck.pop();   // burn card, as at a real table
  if (s.street === "preflop") {
    s.board.push(s.deck.pop(), s.deck.pop(), s.deck.pop());
    s.street = "flop";
  } else if (s.street === "flop") {
    s.board.push(s.deck.pop());
    s.street = "turn";
  } else if (s.street === "turn") {
    s.board.push(s.deck.pop());
    s.street = "river";
  }
  for (const seat of s.seats) seat.lastAction = "";
  return log(s, capitalize(s.street) + ": " + s.board.map(cardName).join(" "));
}

function cardName(card) {
  return RANK_LABEL[card.rank] + SUIT_SYMBOL[card.suit];
}

// ── Domain: Pots ─────────────────────────────────────────────────────────────
// One pot per all-in level: everyone who put in at least that much contributes,
// and only those who did not fold can win it.
function buildPots(seats) {
  const levels = [];
  for (const seat of seats)
    if (seat.totalBet > 0 && levels.indexOf(seat.totalBet) === -1) levels.push(seat.totalBet);
  levels.sort((a, b) => a - b);

  const pots = [];
  let previous = 0;
  for (const level of levels) {
    const contributors = seats.filter((seat) => seat.totalBet >= level);
    const amount   = (level - previous) * contributors.length;
    const eligible = contributors.filter((seat) => !seat.folded).map((seat) => seat.id);
    previous = level;
    if (amount === 0) continue;

    // Fold successive levels together while the same players can win them.
    const last = pots[pots.length - 1];
    if (last && String(last.eligible) === String(eligible)) last.amount += amount;
    else pots.push({ amount, eligible });
  }
  return pots;
}

// Split a pot, giving odd chips to the first winner left of the button.
function splitPot(s, amount, winnerIds) {
  const share  = Math.floor(amount / winnerIds.length);
  const awards = {};
  for (const id of winnerIds) awards[id] = share;

  let odd = amount - share * winnerIds.length;
  for (let i = 1; odd > 0; i++) {
    const id = s.seats[(s.button + i) % s.seats.length].id;
    if (winnerIds.indexOf(id) !== -1) { awards[id]++; odd--; }
  }
  return awards;
}

// ── Domain: Hand endings ─────────────────────────────────────────────────────
function awardUncontested(s, winner) {
  collectBets(s);
  const amount = s.pot;
  winner.stack += amount;

  s.results = {
    showdown: false,
    winners:  [{ id: winner.id, name: winner.name, amount, label: "" }],
    hands:    {},
  };
  s.pot    = 0;
  s.phase  = "handover";
  s.street = "showdown";
  s.stats.hands++;
  if (winner.isHuman) s.stats.won++;
  return log(s, winner.name + " wins " + formatMoney(amount) + " — everyone else folded.");
}

function showdown(s) {
  s.street = "showdown";
  s.reveal = true;

  const hands = {};
  for (const seat of activeSeats(s))
    hands[seat.id] = evaluateHand(seat.hole.concat(s.board));

  const pots    = buildPots(s.seats);
  const totals  = {};   // seat id → chips won
  const winners = [];

  for (const pot of pots) {
    let best = null;
    let ids  = [];
    for (const id of pot.eligible) {
      const cmp = best === null ? 1 : compareScores(hands[id].score, best);
      if (cmp > 0)       { best = hands[id].score; ids = [id]; }
      else if (cmp === 0) ids.push(id);
    }
    const awards = splitPot(s, pot.amount, ids);
    for (const id of ids) totals[id] = (totals[id] || 0) + awards[id];
  }

  for (const seat of s.seats) {
    const amount = totals[seat.id] || 0;
    if (!amount) continue;
    seat.stack += amount;
    winners.push({ id: seat.id, name: seat.name, amount, label: hands[seat.id].label });
  }

  for (const seat of activeSeats(s))
    log(s, seat.name + " shows " + seat.hole.map(cardName).join(" ")
           + " — " + hands[seat.id].label + ".");
  for (const w of winners)
    log(s, w.name + " wins " + formatMoney(w.amount) + " with " + w.label.toLowerCase() + ".");

  s.results = {
    showdown: true,
    winners,
    hands: Object.keys(hands).reduce((acc, id) => {
      acc[id] = { label: hands[id].label, cards: hands[id].cards.map((c) => c.id) };
      return acc;
    }, {}),
  };
  s.pot   = 0;
  s.phase = "handover";
  s.stats.hands++;
  s.stats.showdowns++;
  if (winners.some((w) => w.id === 0)) s.stats.won++;
  return s;
}

// ── Domain: Bots ─────────────────────────────────────────────────────────────
// Preflop strength uses Bill Chen's formula; postflop uses the made hand plus a
// bonus for flush and open-ended straight draws. Both land on a 0–1 scale.
function chenScore(hole) {
  const a = Math.max(rankValue(hole[0]), rankValue(hole[1]));
  const b = Math.min(rankValue(hole[0]), rankValue(hole[1]));
  const points = { 14: 10, 13: 8, 12: 7, 11: 6 };

  let score = points[a] || a / 2;
  if (a === b) score = Math.max(score * 2, 5);                 // pair
  if (hole[0].suit === hole[1].suit) score += 2;               // suited

  const gap = a - b - 1;
  if (a !== b) {
    if (gap === 1)      score -= 1;
    else if (gap === 2) score -= 2;
    else if (gap === 3) score -= 4;
    else if (gap >= 4)  score -= 5;
    if (gap <= 1 && a < 12) score += 1;                        // connected, low
  }
  return Math.ceil(score);
}

function hasFlushDraw(cards) {
  const bySuit = {};
  for (const c of cards) bySuit[c.suit] = (bySuit[c.suit] || 0) + 1;
  return Object.keys(bySuit).some((suit) => bySuit[suit] === 4);
}

function hasOpenEnder(cards) {
  const vals = [];
  for (const c of cards) {
    const v = rankValue(c);
    if (vals.indexOf(v) === -1) vals.push(v);
    if (v === 14 && vals.indexOf(1) === -1) vals.push(1);      // wheel end
  }
  vals.sort((a, b) => a - b);
  for (let i = 0; i <= vals.length - 4; i++)
    if (vals[i + 3] - vals[i] === 3) return true;              // four in a row
  return false;
}

function handStrength(s, seat) {
  if (s.board.length === 0)
    return Math.max(0, Math.min(1, (chenScore(seat.hole) - 2) / 14));

  const cards = seat.hole.concat(s.board);
  const best  = evaluateHand(cards);
  const boardHigh = Math.max.apply(null, s.board.map(rankValue));

  let strength;
  switch (best.category) {
    case STRAIGHT_FLUSH: strength = 1;    break;
    case QUADS:          strength = 0.98; break;
    case FULL_HOUSE:     strength = 0.95; break;
    case FLUSH:          strength = 0.9;  break;
    case STRAIGHT:       strength = 0.86; break;
    case TRIPS:          strength = 0.78; break;
    case TWO_PAIR:       strength = 0.65; break;
    case PAIR:
      strength = best.score[1] > boardHigh  ? 0.55      // overpair
               : best.score[1] === boardHigh ? 0.48     // top pair
               : 0.3;
      break;
    default:
      strength = best.score[1] === 14 ? 0.15 : 0.08;
  }

  if (s.street !== "river") {
    if (hasFlushDraw(cards)) strength += 0.12;
    if (hasOpenEnder(cards)) strength += 0.1;
  }

  // Playing the board: the hole cards add nothing, so anyone can tie.
  if (s.board.length === 5 && compareScores(best.score, evaluateHand(s.board).score) === 0)
    strength = Math.min(strength, 0.3);

  return Math.max(0, Math.min(1, strength));
}

// Returns the action a bot would take: { type, amount? }
function botDecision(s) {
  const seat   = seatToAct(s);
  const toCall = callAmount(s);
  const pot    = totalPot(s);
  const luck   = Math.random();
  const power  = handStrength(s, seat) * (0.9 + Math.random() * 0.25);

  const raiseTo = (fraction) => {
    const target = s.currentBet + Math.max(s.minRaise, Math.round(pot * fraction));
    return Math.max(minRaiseTo(s), Math.min(target, maxRaiseTo(s)));
  };
  const mayRaise = canRaise(s) && s.raises < MAX_RAISES;

  if (toCall === 0) {
    if (mayRaise && power > 0.62)               return { type: "raise", amount: raiseTo(0.6) };
    if (mayRaise && power > 0.45 && luck < 0.3) return { type: "raise", amount: raiseTo(0.4) };
    if (mayRaise && power < 0.25 && luck < 0.1) return { type: "raise", amount: raiseTo(0.5) };
    return { type: "check" };
  }

  // Facing a bet: compare hand strength against the price of a call.
  const potOdds = toCall / (pot + toCall);
  if (mayRaise && power > 0.82 && luck < 0.6)  return { type: "raise", amount: raiseTo(0.75) };
  if (power >= potOdds + 0.12)                 return { type: "call" };
  if (toCall <= BIG_BLIND && power > 0.3)      return { type: "call" };
  return { type: "fold" };
}

// ── Domain: Reducer ──────────────────────────────────────────────────────────
function gameReducer(state, action) {
  const s = deepClone(state);

  switch (action.type) {

    case "RESET":
      return initialState();

    case "FOLD":
      if (s.phase !== "acting") return state;
      return doFold(s);

    case "CHECK":
      if (!canCheck(s)) return state;
      return doCheck(s);

    case "CALL":
      if (!canCall(s)) return state;
      return doCall(s);

    case "RAISE":
      if (!canRaise(s)) return state;
      return doRaise(s, action.amount);

    case "BOT_ACT": {
      if (s.phase !== "acting" || seatToAct(s).isHuman) return state;
      const move = botDecision(s);
      if (move.type === "fold")  return doFold(s);
      if (move.type === "check") return doCheck(s);
      if (move.type === "call")  return doCall(s);
      return doRaise(s, move.amount);
    }

    case "ADVANCE": {   // deal the next street while the board runs out
      if (s.phase !== "runout") return state;
      if (s.street === "river") return showdown(s);
      dealStreet(s);
      return s;
    }

    case "NEXT_HAND":
      if (s.phase !== "handover") return state;
      return startHand(s);

    default:
      return state;
  }
}

// ── UI: constants ────────────────────────────────────────────────────────────
const STORAGE_KEY = "holdem.v1";
const BOT_DELAY   = 850;   // ms a bot "thinks" before acting
const RUNOUT_DELAY = 900;  // ms between streets once everyone is all in

// ── UI: persistence ──────────────────────────────────────────────────────────
function loadSaved() {
  try {
    const raw = window.localStorage.getItem(STORAGE_KEY);
    return raw ? JSON.parse(raw) : null;
  } catch (err) {
    return null;   // private mode / storage disabled — just start fresh
  }
}

function save(game) {
  try {
    window.localStorage.setItem(
      STORAGE_KEY,
      JSON.stringify({ stack: game.seats[0].stack, stats: game.stats }),
    );
  } catch (err) { /* ignore */ }
}

// ── UI: helpers ──────────────────────────────────────────────────────────────
const STREET_LABEL = {
  preflop: "Pre-flop", flop: "Flop", turn: "Turn", river: "River", showdown: "Showdown",
};

function resultText(game) {
  if (!game.results) return "";
  return game.results.winners.map((w) =>
    (w.id === 0 ? "You win " : w.name + " wins ") + formatMoney(w.amount)
    + (w.label ? " with " + w.label.toLowerCase() : "")).join(" · ") + ".";
}

// The five cards making up a shown-down winner's hand, for highlighting.
function winningCardIds(game) {
  if (!game.results || !game.results.showdown) return [];
  return game.results.winners.reduce((ids, w) => {
    const shown = game.results.hands[w.id];
    return shown ? ids.concat(shown.cards) : ids;
  }, []);
}

// ── UI: App ──────────────────────────────────────────────────────────────────
function App() {
  const [game, dispatch] = React.useReducer(
    gameReducer, null, () => initialState(loadSaved()),
  );
  const [raiseOpen, setRaiseOpen] = React.useState(false);
  const [raiseTo, setRaiseTo]     = React.useState(0);

  const actor  = seatToAct(game);
  const myTurn = game.phase === "acting" && !!actor && actor.isHuman;
  const pot    = totalPot(game);

  // Only save between hands, when no chips are sitting out on the table.
  React.useEffect(() => {
    if (game.phase === "handover" || game.phase === "busted") save(game);
  }, [game.phase, game.handNo]);

  // Bots act on a timer, and the board runs itself out once nobody can bet.
  React.useEffect(() => {
    if (game.phase === "acting" && actor && !actor.isHuman) {
      const t = setTimeout(() => dispatch({ type: "BOT_ACT" }), BOT_DELAY);
      return () => clearTimeout(t);
    }
    if (game.phase === "runout") {
      const t = setTimeout(() => dispatch({ type: "ADVANCE" }), RUNOUT_DELAY);
      return () => clearTimeout(t);
    }
    return undefined;
  }, [game]);

  React.useEffect(() => { if (!myTurn) setRaiseOpen(false); }, [myTurn]);

  // ── Actions ─────────────────────────────────────────────────────────────────
  const lo = minRaiseTo(game);
  const hi = maxRaiseTo(game);
  const toCall = callAmount(game);

  const openRaise = () => {
    const potRaise = game.currentBet + Math.round(pot * 0.5);
    setRaiseTo(Math.max(lo, Math.min(potRaise, hi)));
    setRaiseOpen(true);
  };

  const confirmRaise = () => {
    dispatch({ type: "RAISE", amount: raiseTo });
    setRaiseOpen(false);
  };

  const nextHand = () => dispatch({ type: "NEXT_HAND" });

  const newGame = () => {
    if (window.confirm("Start over with " + formatMoney(START_STACK) + " and clear your record?"))
      dispatch({ type: "RESET" });
  };

  // ── Keyboard shortcuts ──────────────────────────────────────────────────────
  React.useEffect(() => {
    const onKey = (e) => {
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      const key = e.key.toLowerCase();

      if (key === "enter" || key === " ") {
        if (game.phase === "handover") { e.preventDefault(); nextHand(); }
        else if (raiseOpen)            { e.preventDefault(); confirmRaise(); }
        return;
      }
      if (!myTurn) return;
      if (key === "f")                    dispatch({ type: "FOLD" });
      if (key === "c")                    dispatch({ type: canCheck(game) ? "CHECK" : "CALL" });
      if (key === "r" && canRaise(game))  openRaise();
      if (key === "escape")               setRaiseOpen(false);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [game, myTurn, raiseOpen, raiseTo]);

  // ── Render ──────────────────────────────────────────────────────────────────
  // Once the hand is over the pot has been pushed, so show the amount won.
  const potShown = game.phase === "handover" && game.results
    ? game.results.winners.reduce((n, w) => n + w.amount, 0)
    : pot;

  const highlight = winningCardIds(game);
  const winnerIds = game.results ? game.results.winners.map((w) => w.id) : [];

  return (
    <div className="app">
      <header className="topbar">
        <h1>Texas Hold&nbsp;’em</h1>
        <div className="bank">
          <span className="blinds">{formatMoney(SMALL_BLIND)}/{formatMoney(BIG_BLIND)}</span>
          <span className="stack" title="Your chips">{formatMoney(game.seats[0].stack)}</span>
          <button className="ghost" onClick={newGame}>New Game</button>
        </div>
      </header>

      <main className="table">
        <div className="bots">
          {game.seats.slice(1).map((seat) => (
            <SeatView
              key={seat.id}
              seat={seat}
              game={game}
              highlight={highlight}
              isWinner={winnerIds.indexOf(seat.id) !== -1}
            />
          ))}
        </div>

        <div className="middle">
          <div className="pot">
            <span className="pot-label">{STREET_LABEL[game.street]}</span>
            <span className="pot-amount">Pot {formatMoney(potShown)}</span>
          </div>
          <div className="board">
            {game.board.map((card) => (
              <CardView key={card.id} card={card} faceUp
                        highlight={highlight.indexOf(card.id) !== -1} />
            ))}
            {Array.from({ length: 5 - game.board.length }).map((_, i) => (
              <div className="card-slot" key={"slot" + i} />
            ))}
          </div>
        </div>

        <SeatView
          seat={game.seats[0]}
          game={game}
          highlight={highlight}
          isWinner={winnerIds.indexOf(0) !== -1}
          big
        />
      </main>

      <p className={"message" + (game.phase === "handover" ? " message--result" : "")}>
        {game.phase === "handover" ? resultText(game)
         : game.phase === "busted" ? "You are out of chips."
         : myTurn                  ? "Your move."
         : actor                   ? actor.name + " is thinking…"
                                   : ""}
      </p>

      {/* ── Controls ──────────────────────────────────────────────────── */}
      <footer className="controls">
        {game.phase === "busted" && (
          <div className="control-row">
            <button className="primary" onClick={newGame}>New Game</button>
          </div>
        )}

        {game.phase === "handover" && (
          <div className="control-row">
            <button className="primary" onClick={nextHand}>Next Hand</button>
          </div>
        )}

        {myTurn && !raiseOpen && (
          <div className="control-row">
            <button onClick={() => dispatch({ type: "FOLD" })}>Fold</button>
            {canCheck(game)
              ? <button onClick={() => dispatch({ type: "CHECK" })}>Check</button>
              : <button onClick={() => dispatch({ type: "CALL" })}>
                  Call {formatMoney(toCall)}
                </button>}
            {canRaise(game) && (
              <button className="primary" onClick={openRaise}>
                {game.currentBet > 0 ? "Raise" : "Bet"}
              </button>
            )}
          </div>
        )}

        {myTurn && raiseOpen && (
          <RaisePanel
            lo={lo} hi={hi} pot={pot} currentBet={game.currentBet}
            value={raiseTo} onChange={setRaiseTo}
            onConfirm={confirmRaise} onCancel={() => setRaiseOpen(false)}
          />
        )}
      </footer>

      <ActionLog lines={game.log} />

      <div className="statusbar">
        <span>Hand {game.handNo} · won {game.stats.won} of {game.stats.hands}</span>
        <span>{game.stats.showdowns} showdowns</span>
      </div>

      <details className="rules">
        <summary>How to play &amp; shortcuts</summary>
        <ul>
          <li>No-limit Texas Hold ’em, four handed, blinds {formatMoney(SMALL_BLIND)}/{formatMoney(BIG_BLIND)}.</li>
          <li>Two cards each, then the flop, turn and river, with betting on every street.</li>
          <li>Make the best five-card hand from your two cards and the five on the board.</li>
          <li>A raise must be at least the size of the previous bet or raise, unless you are all in.</li>
          <li>Short stacks can only win the part of the pot they paid into — the rest becomes a side pot.</li>
          <li>The bots buy back in when they bust; you start over with New Game.</li>
          <li>Keys: <kbd>F</kbd> fold · <kbd>C</kbd> check or call · <kbd>R</kbd> raise · <kbd>Enter</kbd> next hand.</li>
        </ul>
      </details>
    </div>
  );
}

// ── UI: Raise panel ──────────────────────────────────────────────────────────
function RaisePanel({ lo, hi, pot, currentBet, value, onChange, onConfirm, onCancel }) {
  const clamp = (n) => Math.max(lo, Math.min(Math.round(n), hi));
  // Pot-based sizes land on whole chips; Min and All in stay exact.
  const sized = (n) => clamp(Math.round(n / SMALL_BLIND) * SMALL_BLIND);
  const presets = [
    { label: "Min",    amount: lo },
    { label: "½ Pot",  amount: sized(currentBet + pot * 0.5) },
    { label: "Pot",    amount: sized(currentBet + pot) },
    { label: "All in", amount: hi },
  ];

  return (
    <div className="raise-panel">
      <div className="control-row">
        {presets.map((preset) => (
          <button
            key={preset.label}
            className={"chip-btn" + (value === preset.amount ? " chip-btn--on" : "")}
            onClick={() => onChange(preset.amount)}
          >
            {preset.label}
          </button>
        ))}
      </div>
      <div className="control-row">
        <input
          type="range"
          min={lo} max={hi} step={SMALL_BLIND} value={value}
          aria-label="Raise amount"
          onChange={(e) => onChange(clamp(Number(e.target.value)))}
          disabled={lo === hi}
        />
      </div>
      <div className="control-row">
        <button className="ghost" onClick={onCancel}>Back</button>
        <button className="primary" onClick={onConfirm}>
          {value >= hi ? "All in " + formatMoney(value)
                       : (currentBet > 0 ? "Raise to " : "Bet ") + formatMoney(value)}
        </button>
      </div>
    </div>
  );
}

// ── UI: One seat ─────────────────────────────────────────────────────────────
function SeatView({ seat, game, highlight, isWinner, big }) {
  const isTurn   = game.phase === "acting" && game.toAct === seat.id;
  const onButton = game.button === seat.id;
  // Bots keep their cards face down until a showdown; folded hands stay hidden.
  const faceUp   = seat.isHuman || (game.reveal && !seat.folded);

  const classes = ["seat", big ? "seat--big" : "", isTurn ? "seat--turn" : "",
                   seat.folded ? "seat--folded" : "", isWinner ? "seat--winner" : ""]
    .filter(Boolean).join(" ");

  return (
    <div className={classes}>
      <div className="seat-head">
        <span className="seat-name">{seat.name}</span>
        {onButton && <span className="button-chip" title="Dealer button">D</span>}
        <span className="seat-stack">{formatMoney(seat.stack)}</span>
      </div>

      <div className="hole">
        {seat.hole.map((card) => (
          <CardView key={card.id} card={card} faceUp={faceUp}
                    highlight={faceUp && highlight.indexOf(card.id) !== -1} />
        ))}
      </div>

      <div className="seat-foot">
        {seat.committed > 0 && <span className="bet">{formatMoney(seat.committed)}</span>}
        {seat.lastAction && <span className="action">{seat.lastAction}</span>}
        {game.reveal && !seat.folded && game.results && game.results.hands[seat.id] && (
          <span className="made-hand">{game.results.hands[seat.id].label}</span>
        )}
      </div>
    </div>
  );
}

// ── UI: Action log ───────────────────────────────────────────────────────────
function ActionLog({ lines }) {
  const box = React.useRef(null);
  React.useEffect(() => {
    if (box.current) box.current.scrollTop = box.current.scrollHeight;
  }, [lines.length]);

  return (
    <div className="log" ref={box} aria-live="polite">
      {lines.map((line, i) => <p key={i}>{line}</p>)}
    </div>
  );
}

// ── UI: Card ─────────────────────────────────────────────────────────────────
function CardView({ card, faceUp, highlight }) {
  if (!faceUp) return <div className="card face-down" />;

  const color = SUIT_COLOR[card.suit];
  return (
    <div className={"card" + (highlight ? " card--win" : "")}
         title={RANK_LABEL[card.rank] + SUIT_SYMBOL[card.suit]}>
      <div className={"corner tl " + color}>
        <span className="rank">{RANK_LABEL[card.rank]}</span>
        <span className="suit">{SUIT_SYMBOL[card.suit]}</span>
      </div>
      <div className={"center-suit " + color}>{SUIT_SYMBOL[card.suit]}</div>
    </div>
  );
}

// ── Mount ────────────────────────────────────────────────────────────────────
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(<App />);
