// The hold'em table.
//
// Same bargain as every other table in the room: the browser holds no game. It
// sends one move, and what comes back is that move *and every bot action behind
// it* — plus whatever streets that finished and whatever the pot did — as a
// script of events. So a round trip here can be a whole hand: shove all-in and
// the flop, turn, river, showdown and payout all arrive in one response, and this
// file's job is to play them back slowly enough that you can watch it happen to
// you.
//
// The one thing here that no other table has is a *second seat with money on it*.
// Everywhere else the spot is a singleton, because there was only ever you and
// the house. Here every seat has its own, chips move from a seat to its spot and
// from every spot into the pot, and out of the pot to whoever won it. PeteFX.spot
// still owns the rule that the number under a pile is a readout of that pile, so
// none of that arithmetic lives in here.
//
// And the browser never learns a bot's hand. Their cards are backs until a
// showdown turns them over, because a showdown is the only time a player at a
// real table would be looking at them.
(function () {
  "use strict";

  var root = document.querySelector("[data-holdem]");
  if (!root) return;

  var FX = window.PeteFX;
  var Cards = window.PeteCards;

  var seatsEl   = root.querySelector("[data-seats]");
  var youEl     = root.querySelector("[data-you]");
  var boardEl   = root.querySelector("[data-board]");
  var potStack  = root.querySelector("[data-pot-stack]");
  var potTotal  = root.querySelector("[data-pot-total]");
  var sideEl    = root.querySelector("[data-side]");
  var blindsEl  = root.querySelector("[data-blinds]");
  var tableName = root.querySelector("[data-table-name]");
  var verdictEl = root.querySelector("[data-verdict]");
  var houseEl   = root.querySelector("[data-house]");

  var acting    = root.querySelector("[data-acting]");
  var between   = root.querySelector("[data-between]");
  var sitting   = root.querySelector("[data-sitting]");

  var foldBtn   = root.querySelector('[data-move="fold"]');
  var checkBtn  = root.querySelector('[data-move="check"]');
  var callBtn   = root.querySelector('[data-move="call"]');
  var raiseBtn  = root.querySelector('[data-move="raise"]');
  var callAmt   = root.querySelector("[data-call-amount]");
  var raiseRow  = root.querySelector("[data-raise-row]");
  var slider    = root.querySelector("[data-raise-slider]");
  var raiseTo   = root.querySelector("[data-raise-to]");
  var raiseLbl  = root.querySelector("[data-raise-label]");
  var raiseVerb = root.querySelector("[data-raise-verb]");

  var dealBtn   = root.querySelector("[data-deal]");
  var topupBtn  = root.querySelector("[data-topup]");
  var leaveBtn  = root.querySelector("[data-leave]");
  var stackEl   = root.querySelector("[data-table-stack]");
  var boughtEl  = root.querySelector("[data-bought-in]");
  var rakeEl    = root.querySelector("[data-session-rake]");

  var chatSection = root.querySelector("[data-chat]");
  var chatLog     = root.querySelector("[data-chat-log]");
  var chatSeen    = {}; // chat line ids already on the rail, to drop echoed duplicates
  var chatForm    = root.querySelector("[data-chat-form]");
  var chatInput   = root.querySelector("[data-chat-input]");
  var lobbyWrap   = root.querySelector("[data-lobby-wrap]");
  var lobbyEl     = root.querySelector("[data-lobby]");

  var sitBtn    = root.querySelector("[data-sit]");
  var buySlider = root.querySelector("[data-buyin-slider]");
  var buyLabel  = root.querySelector("[data-buyin]");
  var buyNote   = root.querySelector("[data-buyin-note]");
  var botsNote  = root.querySelector("[data-bots-note]");
  var gameMsg   = root.querySelector("[data-game-msg]");
  var betweenMsg = root.querySelector("[data-between-msg]");
  var tableMsg  = root.querySelector("[data-table-msg]");

  var view = null;    // the table, as the server last described it
  var me = 0;         // which seat is yours — no longer always zero, at a shared table
  var busy = false;
  var pendingSync = false; // a pushed frame arrived mid-animation; re-render when free
  var stream = null;  // the open EventSource, when seated
  var seatEls = [];   // one per seat: { root, cards, plate, stack, spot }
  var shown = [];     // what each seat's stack label currently reads
  var pot = null;     // the middle pile, a PeteFX.spot

  var tierBtns = Array.prototype.slice.call(root.querySelectorAll("[data-tier]"));
  var botBtns  = Array.prototype.slice.call(root.querySelectorAll("[data-bot-count]"));
  var tier = null;
  var bots = 2;
  var buyIn = 0;

  function money(n) { return (n || 0).toLocaleString(); }

  function say(el, text, tone) {
    if (!el) return;
    if (!text) { el.classList.add("hidden"); return; }
    el.textContent = text;
    el.classList.remove("hidden");
    el.style.color = tone === "bad" ? "#cc3d4a" : "";
  }

  // ---- building the felt ----------------------------------------------------

  // seat builds one player: their cards, their name and stack, and the spot their
  // bet sits on. Bots go in the row along the top; you get your own, bigger.
  function seat(s, i, mine) {
    var wrap = document.createElement("div");
    wrap.className = "pete-seat";
    wrap.dataset.seat = i;
    wrap.dataset.state = s.state;

    var cards = document.createElement("div");
    cards.className = "pete-seat-cards";

    var plate = document.createElement("div");
    plate.className = "pete-seat-plate";
    var name = document.createElement("span");
    name.className = "pete-seat-name";
    name.textContent = s.name;
    var stack = document.createElement("span");
    stack.className = "pete-seat-stack";
    stack.textContent = money(s.stack);
    plate.appendChild(name);
    plate.appendChild(stack);

    // The button, the blinds. It hangs off the name plate rather than the seat,
    // because the seat's corner is a different place for you than for a bot — your
    // bet spot is above your cards and theirs is below — and a badge that floats
    // over an empty betting circle reads as a bug.
    if (s.pos) {
      var pos = document.createElement("span");
      pos.className = "pete-seat-pos";
      pos.dataset.pos = s.pos;
      pos.textContent = s.pos;
      plate.appendChild(pos);
    }

    var spotEl = document.createElement("div");
    spotEl.className = "pete-spot pete-seat-spot";
    var pile = document.createElement("div");
    pile.className = "pete-stack";
    var total = document.createElement("span");
    // The shared class, not one of our own: it hangs the number *below* the ring,
    // which is what keeps the chips from landing on top of it.
    total.className = "pete-spot-total";
    spotEl.appendChild(pile);
    spotEl.appendChild(total);

    // Your bet sits between you and the board, so it goes above your cards; a
    // bot's sits between them and the board, so it goes below theirs.
    if (mine) {
      wrap.appendChild(spotEl);
      wrap.appendChild(cards);
      wrap.appendChild(plate);
    } else {
      wrap.appendChild(cards);
      wrap.appendChild(plate);
      wrap.appendChild(spotEl);
    }

    var api = FX.spot({ spot: spotEl, stack: pile, total: total });
    api.render(s.bet);

    paintCards(cards, s, mine);
    return { root: wrap, cards: cards, plate: plate, stackEl: stack, spot: api };
  }

  // paintCards puts the two cards in front of a seat. A bot's are backs, unless
  // the server has actually sent us faces — which it only ever does at a showdown.
  function paintCards(el, s, mine) {
    el.innerHTML = "";
    if (s.state === "out") return;
    var faces = s.cards || [];
    var n = faces.length ? faces.length : (s.state === "folded" ? 0 : 2);
    for (var i = 0; i < n; i++) {
      el.appendChild(Cards.el(faces[i] || null, { deal: false, tilt: !mine }));
    }
  }

  function render(v) {
    view = v;
    me = v.your_seat || 0;

    // The seats along the top, and you underneath.
    seatsEl.innerHTML = "";
    youEl.innerHTML = "";
    seatEls = [];
    shown = [];
    v.seats.forEach(function (s, i) {
      var mine = i === me;
      var built = seat(s, i, mine);
      seatEls[i] = built;
      shown[i] = s.stack;
      (mine ? youEl : seatsEl).appendChild(built.root);
      built.root.dataset.turn = (v.phase === "betting" && v.to_act === i) ? "1" : "0";
    });

    // The board.
    boardEl.innerHTML = "";
    (v.board || []).forEach(function (c) {
      boardEl.appendChild(Cards.el(c, { deal: false, tilt: false }));
    });

    // The pot. Its chips live in a box of their own — see .pete-poker-pot-pile —
    // so the number underneath stays readable.
    pot = FX.spot({ spot: potStack.parentNode, stack: potStack, total: null });
    pot.render(v.pot);
    potTotal.textContent = money(v.pot);
    blindsEl.textContent = v.tier.sb + "/" + v.tier.bb;
    tableName.textContent = v.tier.name;
    if (v.side && v.side.length > 1) {
      sideEl.textContent = v.side.map(function (n, i) {
        return (i === 0 ? "main " : "side ") + money(n);
      }).join(" · ");
      sideEl.classList.remove("hidden");
    } else {
      sideEl.classList.add("hidden");
    }

    stackEl.textContent = money(v.stack);
    boughtEl.textContent = money(v.bought_in);
    rakeEl.textContent = money(v.rake);

    panels();
  }

  // panels decides which of the three bars is showing: the one that acts, the one
  // between hands, or the one you sit down from.
  function panels() {
    // A session you have got up from is not a live one: the felt still shows the
    // last hand, but the table you sit down at is the one that's open to you.
    var live = !!view && view.phase !== "done";
    sitting.classList.toggle("hidden", live);
    acting.classList.toggle("hidden", !live || view.phase !== "betting" || view.to_act !== me);
    between.classList.toggle("hidden", !live || view.phase !== "handover");
    if (chatSection) chatSection.classList.toggle("hidden", !live);
    if (!live) return;

    if (view.phase === "betting" && view.to_act === me) {
      checkBtn.classList.toggle("hidden", !view.can_check);
      callBtn.classList.toggle("hidden", view.can_check);
      callAmt.textContent = money(view.owed);

      raiseRow.classList.toggle("hidden", !view.can_raise);
      if (view.can_raise) {
        slider.min = view.min_raise_to;
        slider.max = view.max_raise_to;
        slider.step = Math.max(1, view.tier.bb / 2);
        slider.value = Math.min(view.max_raise_to, view.min_raise_to);
        // "Bet" when nobody has, "Raise to" when somebody has. It is the same
        // move and the same button, but calling a bet a raise is how you tell a
        // player who has never played that this table is confused.
        raiseVerb.textContent = view.owed > 0 ? "Raise to" : "Bet";
        showRaise();
      }
    }
    if (view.phase === "handover") {
      // The table has room for max_topup, but the button must not promise chips the
      // wallet cannot cover — clicking it would only earn a refusal.
      var wallet = window.PeteGames.view();
      var can = Math.min(view.max_topup, wallet ? wallet.chips : 0);
      topupBtn.disabled = can <= 0;
      topupBtn.dataset.amount = can;
      topupBtn.textContent = can > 0 ? "Top up " + money(can) : "Top up";
    }
  }

  function showRaise() {
    var to = Number(slider.value);
    raiseTo.textContent = money(to);
    raiseLbl.textContent = money(to);
  }

  // ---- playing the script ---------------------------------------------------

  var STREETS = { flop: 1, turn: 1, river: 1 };

  function wait(ms) {
    return new Promise(function (r) { setTimeout(r, FX.reduced ? 0 : ms); });
  }

  // play walks the events the server sent, one beat at a time, and only then
  // re-renders the table it ended on. Everything the player is meant to see
  // happening happens here; render() is the state it settles into.
  function play(events, final) {
    var chain = Promise.resolve();
    if (!events || !events.length) {
      // Nothing to animate. Either settle into the table we ended on, or — if the
      // session closed and storage cleared it — clear the felt.
      if (final) render(final); else render0();
      return chain;
    }

    events.forEach(function (e) {
      chain = chain.then(function () { return beat(e, final); });
    });
    return chain.then(function () {
      // A hand can be the last one — a bust closes the table, so there is no state
      // to settle into. Play it out and say what it did anyway (the seats are still
      // on the felt from the previous render), then clear.
      verdict(events, final);
      if (final) render(final); else render0();
    });
  }

  function beat(e, final) {
    var s = seatEls[e.seat];

    switch (e.kind) {
      case "hand":
        // A new deal: clear the felt before anything lands on it.
        boardEl.innerHTML = "";
        verdictEl.classList.add("hidden");
        seatEls.forEach(function (x) { x.spot.render(0); x.cards.innerHTML = ""; });
        pot.render(0);
        potTotal.textContent = "0";
        sideEl.classList.add("hidden");
        FX.sfx("shuffle");
        return wait(140);

      case "rebuy":
        if (s) { shown[e.seat] = e.total; FX.count(s.stackEl, e.total); }
        return wait(220);

      case "blind":
        if (!s) return;
        moveStack(e.seat, -e.amount);
        return s.spot.pour(s.plate, e.amount);

      case "hole":
        // Two cards to everybody, round the table, as they are actually dealt.
        return dealHoles(final);

      case "action":
        return action(e, final);

      case "flop":
      case "turn":
      case "river":
        return street(e);

      case "pot":
        // The bets in front of the seats have been swept in. Nothing else in the
        // script says so, and the pot is about to be paid out of.
        return collect(e.amount);

      case "uncalled":
        if (!s) return;
        return s.spot.sweep(s.plate).then(function () { moveStack(e.seat, e.amount); });

      case "show":
        if (!s) return;
        paintCards(s.cards, { state: "active", cards: e.cards }, e.seat === me);
        flash(s.root);
        return wait(420);

      case "rake":
        // The house takes its cut out of the pot, in front of you, so it is a
        // thing that visibly happens rather than a number that quietly differs.
        return pot.sweep(houseEl, e.amount).then(function () {
          potTotal.textContent = money(pot.amount);
          return wait(160);
        });

      case "win":
        if (!s) return;
        return pot.sweep(s.plate, e.amount, { gap: 55 }).then(function () {
          potTotal.textContent = money(pot.amount);
          moveStack(e.seat, e.amount);
          if (e.seat === me && e.amount > 0) {
            FX.burst(s.plate, { count: 18 });
            // A real haul makes it rain; a blind-steal doesn't, or poker would be
            // raining money every thirty seconds. Twenty big blinds is a pot.
            if (view && view.tier && e.amount >= 20 * view.tier.bb) FX.moneyRain({ count: 24 });
          }
          return wait(260);
        });

      case "end":
        return wait(280);
    }
    return Promise.resolve();
  }

  // dealHoles puts two cards in front of everyone still in the hand. Yours land
  // face up; theirs land as backs, because that is all that came over the wire.
  function dealHoles(final) {
    var chain = Promise.resolve();
    for (var round = 0; round < 2; round++) {
      (function (round) {
        chain = chain.then(function () {
          var beats = [];
          final.seats.forEach(function (s, i) {
            if (s.state === "out") return;
            var built = seatEls[i];
            if (!built) return;
            var face = (i === me && s.cards) ? s.cards[round] : null;
            var card = Cards.el(face, { deal: true, tilt: i !== me });
            built.cards.appendChild(card);
            FX.sfx("deal", { delay: 0.07 * i, v: i });
            beats.push(wait(70 * i));
          });
          return Promise.all(beats).then(function () { return wait(180); });
        });
      })(round);
    }
    return chain;
  }

  // action animates one seat doing one thing.
  function action(e, final) {
    var s = seatEls[e.seat];
    if (!s) return Promise.resolve();

    if (e.text === "fold") {
      s.root.dataset.state = "folded";
      s.cards.dataset.mucked = "1";
      FX.sfx("card", { v: 2 }); // cards going into the muck
      return wait(320);
    }
    if (e.text === "check") {
      flash(s.root);
      FX.sfx("blip"); // a knuckle on the table
      return wait(320);
    }
    // call, raise, allin: chips leave their stack for their spot.
    if (!e.amount) return wait(200);
    moveStack(e.seat, -e.amount);
    return s.spot.pour(s.plate, e.amount).then(function () {
      if (e.text === "allin") flash(s.root);
      return wait(180);
    });
  }

  // collect sweeps every seat's bet into the middle. The total is worked out up
  // front rather than accumulated as the chips land, because the sweeps run at the
  // same time and would otherwise race each other into the pot's counter.
  function collect(total) {
    var moved = 0;
    var sweeps = seatEls.map(function (s) {
      if (!s || s.spot.amount <= 0) return Promise.resolve();
      moved += s.spot.amount;
      return s.spot.sweep(potStack.parentNode, s.spot.amount, { gap: 30 });
    });
    if (!moved) {
      if (total != null) { pot.render(total); potTotal.textContent = money(total); }
      return Promise.resolve();
    }
    var to = total != null ? total : pot.amount + moved;
    return Promise.all(sweeps).then(function () {
      pot.render(to);
      potTotal.textContent = money(to);
      return wait(200);
    });
  }

  // street sweeps the bets in, then turns the cards.
  function street(e) {
    return collect(e.amount).then(function () {
      // The board turns one card at a time, even the flop. Three cards appearing
      // at once is a screenshot; three cards appearing in a row is a flop.
      var chain = Promise.resolve();
      (e.cards || []).forEach(function (c, i) {
        chain = chain.then(function () {
          boardEl.appendChild(Cards.el(c, { deal: true, tilt: false }));
          FX.sfx("card", { v: i });
          return wait(240);
        });
      });
      return chain;
    }).then(function () {
      return wait(200);
    });
  }

  // moveStack keeps a seat's stack label honest *while the chips are moving*. The
  // authoritative number is always the server's, and render() puts it back at the
  // end of the script — but a stack that only updates then would sit unchanged
  // through the whole hand and then jump, which reads as the table correcting
  // itself rather than as chips being paid.
  function moveStack(i, delta) {
    var s = seatEls[i];
    if (!s) return;
    shown[i] = Math.max(0, (shown[i] || 0) + delta);
    FX.count(s.stackEl, shown[i]);
  }

  function flash(el) {
    el.animate(
      [{ transform: "scale(1)" }, { transform: "scale(1.06)" }, { transform: "scale(1)" }],
      { duration: 320, easing: "ease-out" }
    );
  }

  // verdict says what the hand did to you, once, in words.
  function verdict(events, final) {
    var won = 0, showed = false, busted = false;
    events.forEach(function (e) {
      if (e.kind === "win" && e.seat === me) won += e.amount;
      if (e.kind === "show") showed = true;
      if (e.kind === "bust" && e.seat === me) busted = true;
    });
    if (busted) {
      show("You're out of chips. Sit down again when you're ready.", "lose");
      FX.sfx("lose");
      return;
    }
    if (!final || !events.some(function (e) { return e.kind === "end"; })) return;

    var mine = final.seats[me];
    if (won > 0) {
      // The pot coming your way already burst (and so already cheered) back in
      // the "win" event. This is only the words.
      show(showed
        ? "You win " + money(won) + " with " + article(handName(events)) + "."
        : "They folded. You take " + money(won) + ".", "win");
    } else if (mine.state === "folded") {
      show("Folded.", "lose");
    } else {
      show("No good this time.", "lose");
      FX.sfx("lose");
    }

    function show(text, tone) {
      verdictEl.textContent = text;
      verdictEl.dataset.tone = tone;
      verdictEl.classList.remove("hidden");
    }
  }

  function handName(events) {
    var mine = events.filter(function (e) { return e.kind === "show" && e.seat === me; })[0];
    return mine && mine.text ? mine.text : "the best hand";
  }

  // "You win 975 with straight" is not a sentence. Most hands take an article and
  // the counted ones don't.
  function article(desc) {
    if (/^(two pair|three of a kind|four of a kind|high card|the best hand)$/.test(desc)) return desc;
    return "a " + desc;
  }

  // ---- talking to the table --------------------------------------------------

  // lock is busy, said out loud on the buttons.
  //
  // send() drops a click that arrives while a move is in flight, and it is right
  // to: the board on screen during a script is a board the server has already
  // moved past. But the *between-hands* buttons — Deal, Leave, Top up — stayed
  // enabled through the whole deal animation, so clicking Leave while the cards
  // were still flying did nothing at all: no move, no message, no reason given.
  // (The action buttons never had this problem; panels() hides the whole row when
  // it isn't your turn.) A button that looks alive and does nothing has lied to
  // you, so the lock lives on the buttons and not only in the variable.
  //
  // Top up keeps its own rule — it is dead when the wallet cannot cover it — and
  // panels() owns that, so this only ever adds a reason to be disabled.
  function lock(on) {
    [foldBtn, checkBtn, callBtn, raiseBtn, dealBtn, leaveBtn].forEach(function (b) {
      if (b) b.disabled = on;
    });
    if (topupBtn) topupBtn.disabled = on || Number(topupBtn.dataset.amount || 0) <= 0;
  }

  function send(body, msgEl) {
    if (busy) return Promise.resolve();
    busy = true;
    lock(true);
    say(msgEl, "");
    // Whatever the last hand said about itself stops being true the moment you do
    // something. Only the "hand" beat used to clear this, so a verdict could linger
    // over a hand it had nothing to do with.
    verdictEl.classList.add("hidden");
    return window.PeteGames
      .post("/api/games/holdem/move", body)
      .then(function (v) {
        window.PeteGames.apply(v);
        // No table came back: the session ended inside this move — a bust closes a
        // solo table. play() animates the last hand (its "bust" beat says the words)
        // and clears the felt.
        return play(v.holdem_events, v.holdem || null);
      })
      .catch(function (err) { say(msgEl, err.message, "bad"); })
      .then(function () { busy = false; lock(false); flushSync(); });
  }

  // flushSync applies a table frame that arrived while a hand was animating. The
  // felt is now settled, so the held re-render will not clobber a script.
  function flushSync() {
    if (pendingSync) { pendingSync = false; sync(); }
  }

  // leave gets you up from the table. It is its own endpoint, not a move: the
  // chips cross back and the felt may close behind you, neither of which is a play
  // in a hand. The felt clears and the stack you took is reported from what was in
  // front of you a moment ago.
  function leave() {
    if (busy) return;
    busy = true;
    lock(true);
    var stack = view ? view.stack : 0;
    verdictEl.classList.add("hidden");
    window.PeteGames
      .post("/api/games/holdem/leave", {})
      .then(function (v) {
        window.PeteGames.apply(v);
        render0();
        say(tableMsg, stack > 0
          ? money(stack) + " back on your stack. Sit down again when you're ready."
          : "");
      })
      .catch(function (err) { say(betweenMsg, err.message, "bad"); })
      .then(function () { busy = false; lock(false); flushSync(); });
  }

  // render0 is the table with nobody at it.
  function render0() {
    view = null;
    unseated();
    seatsEl.innerHTML = "";
    youEl.innerHTML = "";
    boardEl.innerHTML = "";
    potStack.innerHTML = "";
    potTotal.textContent = "0";
    sideEl.classList.add("hidden");
    panels();
    syncSit();
    loadLobby();
  }

  if (foldBtn)  foldBtn.addEventListener("click", function () { send({ move: "fold" }, gameMsg); });
  if (checkBtn) checkBtn.addEventListener("click", function () { send({ move: "check" }, gameMsg); });
  if (callBtn)  callBtn.addEventListener("click", function () { send({ move: "call" }, gameMsg); });
  if (raiseBtn) raiseBtn.addEventListener("click", function () {
    var to = Number(slider.value);
    // Sliding all the way to the top is shoving, and the table would rather be
    // told that than be told to raise to exactly everything you have.
    send(to >= view.max_raise_to ? { move: "allin" } : { move: "raise", to: to }, gameMsg);
  });

  if (slider) slider.addEventListener("input", showRaise);
  root.querySelectorAll("[data-raise-preset]").forEach(function (b) {
    b.addEventListener("click", function () {
      var which = b.dataset.raisePreset;
      var to;
      // A pot-sized raise is: call what's owed, then bet what the pot would then be.
      // So the total is twice what you owe, plus the pot as it stands.
      if (which === "max") to = view.max_raise_to;
      else to = 2 * view.owed + view.pot * Number(which);
      to = Math.max(view.min_raise_to, Math.min(view.max_raise_to, Math.round(to)));
      slider.value = to;
      showRaise();
    });
  });

  if (dealBtn)  dealBtn.addEventListener("click", function () { send({ move: "deal" }, betweenMsg); });
  if (leaveBtn) leaveBtn.addEventListener("click", function () { leave(); });
  if (topupBtn) topupBtn.addEventListener("click", function () {
    send({ move: "topup", amount: Number(topupBtn.dataset.amount || 0) }, betweenMsg);
  });

  // ---- sitting down ----------------------------------------------------------

  function pickTier(btn) {
    tier = btn;
    tierBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
    var min = Number(btn.dataset.min), max = Number(btn.dataset.max), bb = Number(btn.dataset.bb);
    buySlider.min = min;
    buySlider.max = max;
    buySlider.step = bb;
    buySlider.value = Math.min(max, Math.max(min, 50 * bb)); // fifty big blinds, the default anybody sensible picks
    syncSit();
  }

  function pickBots(btn) {
    bots = Number(btn.dataset.botCount);
    botBtns.forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
    syncSit();
  }

  function syncSit() {
    if (!tier) return;
    buyIn = Number(buySlider.value);
    var bb = Number(tier.dataset.bb);
    buyLabel.textContent = money(buyIn);
    buyNote.textContent = Math.round(buyIn / bb) + " big blinds. Short is fewer decisions; deep is more of them.";
    botsNote.textContent = bots === 1
      ? "Heads up. The bots know this game best when there's only one of them."
      : bots + " bots. More opponents, and a hand has to be better to be worth playing.";

    var chips = window.PeteGames.view();
    sitBtn.disabled = !chips || chips.chips < buyIn;
    say(tableMsg, sitBtn.disabled ? "You need " + money(buyIn) + " chips to sit at this table." : "");
  }

  tierBtns.forEach(function (b) { b.addEventListener("click", function () { pickTier(b); }); });
  botBtns.forEach(function (b) { b.addEventListener("click", function () { pickBots(b); }); });
  if (buySlider) buySlider.addEventListener("input", syncSit);

  if (sitBtn) sitBtn.addEventListener("click", function () {
    if (busy || !tier) return;
    busy = true;
    say(tableMsg, "");
    window.PeteGames
      .post("/api/games/holdem/sit", {
        tier: tier.dataset.tier,
        bots: bots,
        buyin: Number(buySlider.value),
      })
      .then(function (v) {
        window.PeteGames.apply(v);
        render(v.holdem);
        seated();
        // A table with nobody dealt in yet is a table waiting for you to say go.
        say(betweenMsg, "You're in. Deal when you're ready.");
      })
      .catch(function (err) { say(tableMsg, err.message, "bad"); })
      .then(function () { busy = false; });
  });

  // ---- the live table --------------------------------------------------------
  //
  // Poker is where the room stops being just you and the house: other people are
  // at the felt, and what they do has to reach you without your asking. That is
  // one EventSource. The server pushes a nudge when the table changes and a line
  // when somebody speaks; the nudge is not the state (a hole card must never ride
  // a frame that fans to the whole table), so on a nudge we refetch our own
  // seat's view, which is authoritative and already redacted.

  // seated opens the stream and loads the rail. Called the moment you sit down.
  function seated() {
    loadChat();
    connectLive();
  }

  // unseated tears it down. Called when the felt clears — you got up, or busted.
  function unseated() {
    disconnectLive();
    if (chatLog) chatLog.innerHTML = "";
    chatSeen = {};
  }

  function connectLive() {
    if (stream || !window.EventSource) return;
    stream = new EventSource("/api/games/holdem/stream");
    stream.onmessage = function (ev) {
      var msg;
      try { msg = JSON.parse(ev.data); } catch (e) { return; }
      if (msg.type === "chat") addChat(msg.line);
      else if (msg.type === "table") sync();
    };
    // The opening nudge (event: sync) just says "come and look".
    stream.addEventListener("sync", function () { sync(); });
    // EventSource reconnects itself on a dropped connection. The one case we don't
    // want it retrying is a table that has closed (the stream 409s) — but by then
    // we have called unseated() and closed it ourselves.
  }

  function disconnectLive() {
    if (stream) { stream.close(); stream = null; }
  }

  // sync re-renders the felt from the authoritative table, but never mid-animation
  // — a frame that lands while a hand is playing is held and applied once the
  // script finishes, or it would repaint the table out from under it.
  function sync() {
    if (busy) { pendingSync = true; return; }
    window.PeteGames.refresh().then(function (v) {
      if (!v) return;
      if (v.holdem) render(v.holdem); else { render0(); }
    });
  }

  // ---- the rail --------------------------------------------------------------

  function loadChat() {
    fetch("/api/games/holdem/chat", { headers: { "Accept": "application/json" } })
      .then(function (res) { return res.ok ? res.json() : null; })
      .then(function (data) {
        if (!data || !chatLog) return;
        chatLog.innerHTML = "";
        chatSeen = {};
        (data.chat || []).forEach(addChat);
      })
      .catch(function () {});
  }

  function addChat(line) {
    if (!chatLog || !line) return;
    // Your own line comes back twice — once from the POST that sent it, once
    // echoed over your own stream — so drop any id already on the rail.
    if (line.id) {
      if (chatSeen[line.id]) return;
      chatSeen[line.id] = true;
    }
    var row = document.createElement("div");
    row.className = "pete-chat-line" + (line.mine ? " pete-chat-mine" : "");
    var who = document.createElement("span");
    who.className = "pete-chat-who";
    who.textContent = line.name;
    var body = document.createElement("span");
    body.className = "pete-chat-body";
    body.textContent = line.body;
    row.appendChild(who);
    row.appendChild(body);
    chatLog.appendChild(row);
    chatLog.scrollTop = chatLog.scrollHeight;
  }

  if (chatForm) chatForm.addEventListener("submit", function (e) {
    e.preventDefault();
    var body = (chatInput.value || "").trim();
    if (!body) return;
    chatInput.value = "";
    window.PeteGames
      .post("/api/games/holdem/say", { body: body })
      .then(function (line) { addChat(line); })
      .catch(function () { chatInput.value = body; });
  });

  // ---- the lobby -------------------------------------------------------------
  //
  // The tables other people have open, each with a seat going spare. Joining
  // takes the buy-in the slider is set to, clamped to what that table allows.

  function loadLobby() {
    if (!lobbyEl) return;
    fetch("/api/games/holdem/tables", { headers: { "Accept": "application/json" } })
      .then(function (res) { return res.ok ? res.json() : null; })
      .then(function (data) {
        var tables = (data && data.tables) || [];
        renderLobby(tables);
      })
      .catch(function () {});
  }

  function renderLobby(tables) {
    lobbyEl.innerHTML = "";
    lobbyWrap.classList.toggle("hidden", tables.length === 0);
    tables.forEach(function (t) {
      var stakes = tierBtns.filter(function (b) { return b.dataset.tier === t.tier; })[0];
      var btn = document.createElement("button");
      btn.type = "button";
      btn.className = "flex items-center justify-between gap-3 rounded-2xl border-2 border-[color:var(--ink)]/10 p-3 text-left hover:bg-[color:var(--ink)]/5 transition";
      var label = document.createElement("span");
      label.className = "font-display font-bold";
      label.textContent = stakes ? stakes.querySelector(".font-display").textContent : t.tier;
      var seats = document.createElement("span");
      seats.className = "text-xs font-semibold text-[color:var(--ink)]/50 tabular-nums";
      seats.textContent = t.humans + "/" + t.seats + " seated";
      btn.appendChild(label);
      btn.appendChild(seats);
      btn.addEventListener("click", function () { join(t, stakes); });
      lobbyEl.appendChild(btn);
    });
  }

  function join(t, stakes) {
    if (busy) return;
    // Buy in for what the slider says, but only what this table allows.
    var buyIn = buyIn0(stakes);
    busy = true;
    say(tableMsg, "");
    window.PeteGames
      .post("/api/games/holdem/sit", { table: t.id, buyin: buyIn })
      .then(function (v) {
        window.PeteGames.apply(v);
        render(v.holdem);
        seated();
        say(betweenMsg, "You're in. The next hand deals when the table is ready.");
      })
      .catch(function (err) { say(tableMsg, err.message, "bad"); })
      .then(function () { busy = false; });
  }

  // buyIn0 is a legal buy-in for a table you are joining: fifty big blinds, or as
  // close as the table's range allows.
  function buyIn0(stakes) {
    if (!stakes) return Number(buySlider.value);
    var min = Number(stakes.dataset.min), max = Number(stakes.dataset.max), bb = Number(stakes.dataset.bb);
    var want = Number(buySlider.value);
    if (stakes === tier) return Math.min(max, Math.max(min, want)); // same table: honour the slider
    return Math.min(max, Math.max(min, 50 * bb));
  }

  // ---- boot ------------------------------------------------------------------

  window.PeteGames.onUpdate(function () { if (!view) syncSit(); });

  pickTier(tierBtns[0]);
  pickBots(botBtns[1]);

  window.PeteGames.refresh().then(function (v) {
    if (v && v.holdem) { render(v.holdem); seated(); }
    else { render0(); loadLobby(); }
  });
})();
