// The UNO table, gone multiplayer.
//
// Same bargain as hold'em: the browser holds no game. You sit down with a stack,
// and every hand each seat antes into a pot the winner takes. One move goes up and
// what comes back is that move *and every bot turn it handed off to*, as a script
// of events this file plays back slowly enough to follow. Other people are at the
// felt, so a stream keeps your view live while they play; a nudge on it means the
// table changed, and you refetch your own seat's redacted view.
//
// Two rules carried over, both load-bearing: the browser never learns another
// seat's hand (it gets a count), and the chip bar only moves when chips actually
// cross the border — at sit-down and get-up. A pot won mid-session moves inside
// the engine and never touches your purse; it moves your *stack*, which is the
// number on the rail.
(function () {
  "use strict";

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

  var FX = window.PeteFX;
  var G = window.PeteGames;

  var seatsEl   = root.querySelector("[data-seats]");
  var handEl    = root.querySelector("[data-hand]");
  var deckEl    = root.querySelector("[data-deck]");
  var deckCntEl = root.querySelector("[data-deck-count]");
  var discardEl = root.querySelector("[data-discard]");
  var colourEl  = root.querySelector("[data-colour]");
  var turnEl    = root.querySelector("[data-turn-label]");
  var countEl   = root.querySelector("[data-count-label]");
  var verdictEl = root.querySelector("[data-verdict]");
  var potEl     = root.querySelector("[data-pot]");
  var stackEl   = root.querySelector("[data-stack]");
  var tableEl   = root.querySelector("[data-table-name]");
  var feltEl    = root.querySelector(".pete-uno");

  var wildEl    = root.querySelector("[data-wild]");
  var gateEl    = root.querySelector("[data-unogate]");
  var callBtn   = root.querySelector("[data-uno-call]");
  var timerEl   = root.querySelector("[data-uno-timer]");
  var billEl    = root.querySelector("[data-bill]");

  var actingEl  = root.querySelector("[data-acting]");
  var betweenEl = root.querySelector("[data-between]");
  var sittingEl = root.querySelector("[data-sitting]");
  var chatEl    = root.querySelector("[data-chat]");

  var drawBtn   = root.querySelector("[data-draw]");
  var passBtn   = root.querySelector("[data-pass]");
  var takeBtn   = root.querySelector("[data-take]");
  var takeN     = root.querySelector("[data-take-n]");
  var hintEl    = root.querySelector("[data-hint]");
  var dealBtn   = root.querySelector("[data-deal]");
  var leaveBtn  = root.querySelector("[data-leave]");
  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 tableStackEl = root.querySelector("[data-table-stack]");
  var boughtInEl   = root.querySelector("[data-bought-in]");
  var anteNoteEl   = root.querySelector("[data-ante-note]");

  var gameMsgEl  = root.querySelector("[data-game-msg]");
  var betweenMsg = root.querySelector("[data-between-msg]");
  var tableMsg   = root.querySelector("[data-table-msg]");

  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 lobbyEl   = root.querySelector("[data-lobby]");
  var lobbyWrap = root.querySelector("[data-lobby-wrap]");

  var busy = false;
  var game = null;    // the table as the server last described it (a v.uno)
  var me = 0;         // your seat at the table
  var tier = null;    // the selected tier button, when sitting
  var pendingWild = -1;
  var played = -1;
  var stream = null;
  var pendingSync = false;

  var CALL_MS = 2600;
  var gate = null;
  var gateTimer = 0;

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

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

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

  // ---- drawing the cards -----------------------------------------------------

  var FACES = {
    "skip":        { mid: "🚫",    corner: "🚫" },
    "reverse":     { mid: "⇄",     corner: "⇄" },
    "+2":          { mid: "+2",    corner: "+2" },
    "wild":        { mid: "★",     corner: "★" },
    "+4":          { mid: "+4",    corner: "+4" },
    "skip all":    { mid: "SKIP ALL",    corner: "⊘⊘", size: "words" },
    "discard all": { mid: "DISCARD ALL", corner: "≡",  size: "words" },
    "rev +4":      { mid: "⇄+4",   corner: "⇄4", size: "mid" },
    "+6":          { mid: "+6",    corner: "+6" },
    "+10":         { mid: "+10",   corner: "+10", size: "mid" },
    "roulette":    { mid: "?",     corner: "?" },
  };

  function faceOf(c) { return FACES[c.value] || { mid: c.value, corner: c.value }; }

  var DRAWS = { "+2": true, "+4": true, "+6": true, "+10": true, "rev +4": true };

  function card(c, opts) {
    opts = opts || {};
    var f = faceOf(c);
    var el = document.createElement(opts.button ? "button" : "div");
    if (opts.button) el.type = "button";
    el.className = "pete-uno-card";
    el.dataset.c = c.wild ? "wild" : c.color;

    var face = document.createElement("span");
    face.className = "pete-uno-face";

    var oval = document.createElement("span");
    oval.className = "pete-uno-oval";
    if (f.size) oval.dataset.size = f.size;
    oval.textContent = f.mid;
    face.appendChild(oval);

    ["tl", "br"].forEach(function (at) {
      var corner = document.createElement("span");
      corner.className = "pete-uno-corner";
      corner.dataset.at = at;
      corner.textContent = f.corner;
      corner.setAttribute("aria-hidden", "true");
      face.appendChild(corner);
    });

    if (c.wild) {
      var wheel = document.createElement("span");
      wheel.className = "pete-uno-wheel";
      face.appendChild(wheel);
      if (c.color && c.color !== "wild") el.dataset.named = c.color;
    }
    if (c.wild && DRAWS[c.value]) el.dataset.glow = "1";

    el.appendChild(face);
    el.setAttribute("aria-label", label(c));
    return el;
  }

  var SPOKEN = {
    "+2": "draw two", "+4": "draw four", "+6": "draw six", "+10": "draw ten",
    "wild": "wild", "skip": "skip", "reverse": "reverse",
    "skip all": "skip everyone", "discard all": "discard all",
    "rev +4": "reverse and draw four", "roulette": "colour roulette",
  };

  function label(c) {
    var v = SPOKEN[c.value] || c.value;
    if (c.wild) {
      var name = c.value === "wild" ? "wild" : "wild " + v;
      return name + (c.color && c.color !== "wild" ? ", played as " + c.color : "");
    }
    return c.color + " " + v;
  }

  function back() {
    var el = document.createElement("div");
    el.className = "pete-uno-card pete-uno-card-back";
    var b = document.createElement("span");
    b.className = "pete-uno-back";
    el.appendChild(b);
    return el;
  }

  // ---- the board -------------------------------------------------------------

  var FAN = 8;

  function live(v) { return !!v && (v.phase === "play" || v.phase === "drawn" || v.phase === "stack"); }

  function renderSeats(v) {
    seatsEl.innerHTML = "";
    if (!v) return;
    var catchable = {};
    (v.catchable || []).forEach(function (i) { catchable[i] = true; });

    v.seats.forEach(function (s, i) {
      if (i === me) return; // you are the hand at the bottom, not a seat up here
      var el = document.createElement("div");
      el.className = "pete-uno-seat";
      el.dataset.seat = String(i);
      el.dataset.turn = v.turn === i && live(v) ? "1" : "0";
      if (s.uno) el.dataset.uno = "1";
      if (s.out) el.dataset.out = "1";

      if (catchable[i]) {
        var got = document.createElement("button");
        got.type = "button";
        got.className = "pete-uno-catch";
        got.dataset.catch = String(i);
        got.textContent = "Catch!";
        got.setAttribute("aria-label", "Catch " + s.name + " — one card, never called");
        got.addEventListener("click", function (e) { e.stopPropagation(); catchSeat(i); });
        el.appendChild(got);
      }

      var fan = document.createElement("div");
      fan.className = "pete-uno-fan";
      var show = Math.min(s.cards, FAN);
      for (var n = 0; n < show; n++) {
        var b = back();
        b.style.setProperty("--i", n);
        b.style.setProperty("--n", show);
        fan.appendChild(b);
      }
      el.appendChild(fan);

      var name = document.createElement("p");
      name.className = "pete-uno-name";
      name.textContent = s.name + (s.bot ? "" : " ●");
      el.appendChild(name);

      var count = document.createElement("p");
      count.className = "pete-uno-count";
      count.dataset.count = "";
      var stk = s.stack != null ? " · " + fmt(s.stack) : "";
      count.textContent = (s.out ? "Buried" : s.cards + (s.cards === 1 ? " card" : " cards")) + stk;
      el.appendChild(count);

      seatsEl.appendChild(el);
    });
  }

  function seatEl(i) { return seatsEl.querySelector('[data-seat="' + i + '"]'); }

  function seatAnchor(i) {
    if (i === me) return handEl;
    var el = seatEl(i);
    return el ? el.querySelector(".pete-uno-fan") : deckEl;
  }

  var shownHand = [];
  function key(c) { return c.color + "/" + c.value; }

  function paintHand(cards, playable, clickable) {
    cards = cards || [];
    var pool = shownHand.map(key);
    handEl.innerHTML = "";
    var fresh = 0;
    cards.forEach(function (c, i) {
      var el = card(c, { button: true });
      el.dataset.at = String(i);
      var ok = !!clickable && !!playable[i];
      el.dataset.on = ok ? "1" : "0";
      el.disabled = !ok || busy;
      if (ok) el.addEventListener("click", function () { pick(i, c); });
      var was = pool.indexOf(key(c));
      if (was >= 0) { pool.splice(was, 1); el.dataset.settled = "1"; }
      else { el.style.setProperty("--i", fresh++); }
      handEl.appendChild(el);
    });
    shownHand = cards.slice();
  }

  function renderHand(v) {
    if (!v || !v.hand) { handEl.innerHTML = ""; shownHand = []; return; }
    var playable = {};
    (v.playable || []).forEach(function (i) { playable[i] = true; });
    paintHand(v.hand, playable, live(v) && v.turn === me);
  }

  function showHand(cards) {
    if (!cards) return;
    paintHand(cards, {}, false);
    countEl.textContent = cards.length + (cards.length === 1 ? " card — UNO!" : " cards");
  }

  function renderPiles(v) {
    discardEl.innerHTML = "";
    if (!v) { deckCntEl.textContent = "0"; colourEl.textContent = ""; if (feltEl) feltEl.dataset.c = ""; return; }
    deckCntEl.textContent = String(v.deck || 0);
    var dealt = (v.deck || 0) > 0 || (v.hand && v.hand.length) || (v.seats || []).some(function (s) { return s.cards > 0; });
    if (dealt && v.top && v.top.value) {
      var top = card(v.top);
      top.classList.add("pete-uno-top");
      discardEl.appendChild(top);
      colourEl.textContent = v.color;
      colourEl.dataset.c = v.color;
      if (feltEl) feltEl.dataset.c = v.color;
    } else {
      colourEl.textContent = "";
      if (feltEl) feltEl.dataset.c = "";
    }
    pending(live(v) ? (v.pending || 0) : 0, v);
  }

  // Who the running stack lands on next: during a stack phase that's whoever's
  // turn it is. Fall back to the view the caller passed, then to the live game.
  function pendingTarget(v) {
    var src = v || game;
    if (!src) return "you";
    if (src.turn === me) return "you";
    var s = (src.seats && src.seats[src.turn]) || null;
    return (s && s.name) || "them";
  }

  function pending(n, v) {
    if (billEl) {
      billEl.textContent = n > 0 ? "+" + n + " on " + pendingTarget(v) : "";
      billEl.classList.toggle("hidden", n <= 0);
    }
    if (takeN) takeN.textContent = String(n);
  }

  function renderRail(v) {
    if (potEl) potEl.textContent = fmt(v ? v.pot : 0);
    if (stackEl) stackEl.textContent = v ? fmt(v.stack) : "—";
    if (tableEl) tableEl.textContent = v && v.tier ? v.tier.name : "";
    if (tableStackEl) tableStackEl.textContent = fmt(v ? v.stack : 0);
    if (boughtInEl) boughtInEl.textContent = fmt(v ? v.bought_in : 0);
    if (anteNoteEl) anteNoteEl.textContent = fmt(v && v.tier ? v.tier.ante : 0);
  }

  function renderTurn(v) {
    if (!v || !live(v)) { turnEl.textContent = ""; countEl.textContent = ""; return; }
    var yours = v.turn === me;
    var who = yours ? "Your turn" : (v.seats[v.turn] || {}).name + " is thinking…";
    if (yours && v.phase === "drawn") who = "Play it, or keep it";
    if (yours && v.phase === "stack") who = "+" + (v.pending || 0) + " — stack it, or take it";
    turnEl.textContent = who;
    turnEl.dataset.you = yours ? "1" : "0";
    var n = (v.hand || []).length;
    countEl.textContent = n + (n === 1 ? " card — UNO!" : " cards");
  }

  // ---- phases ----------------------------------------------------------------

  function verdict(v) {
    if (!v || v.winner == null) { verdictEl.classList.add("hidden"); return; }
    var text = "";
    var ante = (v.seats[me] && v.seats[me].ante) || 0;
    if (v.outcome === "tie") {
      text = "Nobody went out — antes back.";
    } else if (v.winner === me) {
      var net = ((v.seats[me] && v.seats[me].won) || 0) - ante;
      var howYou = (v.seats[me] && v.seats[me].cards === 0) ? "You went out! 🎉" : "Last one standing! 🎉";
      text = howYou + (net > 0 ? "  +" + fmt(net) : "");
    } else if (v.winner >= 0) {
      var w = v.seats[v.winner] || {};
      text = (w.name || "They") + " took the pot." + (ante > 0 ? "  −" + fmt(ante) : "");
    }
    if (!text) { verdictEl.classList.add("hidden"); return; }
    verdictEl.textContent = text;
    verdictEl.classList.remove("hidden");
    if (v.winner === me && v.outcome !== "tie") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
    else if (v.winner >= 0 && v.winner !== me) FX.sfx("lose");
  }

  var HINTS = {
    play:  "Click a card that lights up. Nothing lights up? Draw one.",
    drawn: "You drew a card you can play. Play it, or keep it and pass.",
    stack: "Only draw cards will do here. Answer it, and it lands on somebody else — or take the lot.",
  };

  function controls() {
    var lv = live(game);
    var yours = lv && game.turn === me;
    var drawn = lv && game.phase === "drawn";
    var stack = lv && game.phase === "stack";
    handEl.querySelectorAll(".pete-uno-card").forEach(function (b) {
      b.disabled = busy || !yours || b.dataset.on !== "1";
    });
    seatsEl.querySelectorAll("[data-catch]").forEach(function (b) { b.disabled = busy || !yours; });
    if (drawBtn) drawBtn.disabled = busy || !yours || drawn || stack;
    if (passBtn) passBtn.disabled = busy || !yours;
    if (takeBtn) takeBtn.disabled = busy || !yours;
    if (deckEl) deckEl.disabled = busy || !yours || drawn || stack;
    if (dealBtn) dealBtn.disabled = busy;
    if (leaveBtn) leaveBtn.disabled = busy;
  }

  function setPhase(v) {
    game = v;
    var seated = !!v;
    var lv = live(v);
    var yours = lv && v.turn === me;
    var drawn = lv && v.phase === "drawn";
    var stack = lv && v.phase === "stack";

    if (sittingEl) sittingEl.classList.toggle("hidden", seated);
    if (chatEl) chatEl.classList.toggle("hidden", !seated);
    if (actingEl) actingEl.classList.toggle("hidden", !yours);
    if (betweenEl) betweenEl.classList.toggle("hidden", !(seated && !lv));
    hideWild();
    hideGate();

    if (drawBtn) drawBtn.classList.toggle("hidden", drawn || stack);
    if (takeBtn) takeBtn.classList.toggle("hidden", !stack);
    var canPass = drawn && !(v && v.tier && v.tier.no_mercy);
    if (passBtn) passBtn.classList.toggle("hidden", !canPass);
    if (hintEl && yours) {
      hintEl.textContent = (v.catchable || []).length
        ? "Somebody's down to one card and never said so. Catch them before you play."
        : (HINTS[v.phase] || HINTS.play);
    }
    controls();
    if (!v || v.winner == null) verdictEl.classList.add("hidden");
  }

  function paint(v) {
    if (v && v.your_seat != null) me = v.your_seat;
    renderSeats(v);
    renderPiles(v);
    renderHand(v);
    renderRail(v);
    renderTurn(v);
    setPhase(v);
    if (v && !live(v) && v.winner != null) verdict(v);
  }

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

  function throwCard(node, from, to, opts) {
    opts = opts || {};
    return FX.flyNode(node, from, to, {
      duration: opts.duration || 380,
      lift: opts.lift == null ? 0.7 : opts.lift,
      spin: opts.spin == null ? FX.jitter((opts.index || 0) + 3, 14) : opts.spin,
      fromScale: opts.fromScale == null ? 0.9 : opts.fromScale,
      delay: opts.delay || 0,
      index: opts.index || 0,
      sound: opts.sound === undefined ? "card" : opts.sound,
    });
  }

  function bump(seat, left) {
    if (seat === me || left == null) return;
    var el = seatEl(seat);
    if (!el) return;
    var fan = el.querySelector(".pete-uno-fan");
    var count = el.querySelector("[data-count]");
    var buried = el.dataset.out === "1";
    if (count) {
      var stk = count.textContent.indexOf("·") >= 0 ? count.textContent.slice(count.textContent.indexOf("·")) : "";
      count.textContent = (buried ? "Buried" : left + (left === 1 ? " card" : " cards")) + (stk ? " " + stk : "");
    }
    if (!fan) return;
    var show = Math.min(left, FAN);
    while (fan.children.length > show) fan.removeChild(fan.lastChild);
    while (fan.children.length < show) fan.appendChild(back());
    Array.prototype.forEach.call(fan.children, function (b, i) {
      b.style.setProperty("--i", i);
      b.style.setProperty("--n", fan.children.length);
    });
    el.dataset.uno = left === 1 ? "1" : "0";
  }

  function spotlight(seat) {
    seatsEl.querySelectorAll(".pete-uno-seat").forEach(function (el) {
      el.dataset.turn = parseInt(el.dataset.seat, 10) === seat ? "1" : "0";
    });
  }

  function badge(seat, text, tone) {
    var host = seat === me ? handEl : seatEl(seat);
    if (!host || reduced) return Promise.resolve();
    var b = document.createElement("span");
    b.className = "pete-uno-badge";
    b.dataset.tone = tone || "";
    b.textContent = text;
    host.appendChild(b);
    return new Promise(function (r) { setTimeout(function () { b.remove(); r(); }, 900); });
  }

  // bury gives the mercy rule some ceremony. It rolls one of four send-offs and
  // drops it over the buried seat — or over your hand, if it's you going down —
  // with the noise that goes with it. Every piece is built from scratch and
  // clears itself, so a repaint that lands mid-fall just cuts it short. Resolves
  // on the dramatic beat, not on cleanup: the hand shouldn't wait for the dust.
  var BURIALS = ["rocks", "tomb", "zap", "coffin"];

  function bury(seat) {
    var host = seat === me ? handEl : seatEl(seat);
    if (!host) return Promise.resolve();

    var wrap = document.createElement("div");
    wrap.className = "pete-uno-bury";
    host.appendChild(wrap);

    // No theatre for reduced motion: a single static headstone marks the grave,
    // with the stone-drop thud so it isn't silent either.
    if (reduced) {
      FX.sfx("tombstone");
      var stone = document.createElement("div");
      stone.className = "pete-uno-tomb";
      stone.textContent = "🪦";
      wrap.appendChild(stone);
      setTimeout(function () { wrap.remove(); }, 1400);
      return Promise.resolve();
    }

    var kind = BURIALS[Math.floor(Math.random() * BURIALS.length)];
    wrap.dataset.kind = kind;

    if (kind === "rocks") {
      FX.sfx("rockslide");
      [
        { dx: "-1.15rem", dy: "0.55rem", rot: "-20deg", d: 0 },
        { dx: "0.95rem",  dy: "0.7rem",  rot: "24deg",  d: 70 },
        { dx: "-0.15rem", dy: "0.15rem", rot: "8deg",   d: 150 },
        { dx: "-1.5rem",  dy: "1.0rem",  rot: "-34deg", d: 220 },
        { dx: "1.45rem",  dy: "0.95rem", rot: "16deg",  d: 300 },
        { dx: "0.35rem",  dy: "1.05rem", rot: "-10deg", d: 360 },
      ].forEach(function (r) {
        var el = document.createElement("div");
        el.className = "pete-uno-rock";
        el.textContent = "🪨";
        el.style.setProperty("--dx", r.dx);
        el.style.setProperty("--dy", r.dy);
        el.style.setProperty("--rot", r.rot);
        el.style.setProperty("--d", r.d + "ms");
        wrap.appendChild(el);
      });
    } else if (kind === "zap") {
      FX.sfx("zap");
      var flash = document.createElement("div");
      flash.className = "pete-uno-zap-flash";
      wrap.appendChild(flash);
      for (var a = 0; a < 8; a++) {
        var bit = document.createElement("div");
        bit.className = "pete-uno-zap-bit";
        bit.style.setProperty("--ang", (a * 45) + "deg");
        wrap.appendChild(bit);
      }
    } else {
      // tomb or coffin: one heavy thing drops in and sets.
      FX.sfx(kind === "tomb" ? "tombstone" : "coffin");
      var mark = document.createElement("div");
      mark.className = kind === "tomb" ? "pete-uno-tomb" : "pete-uno-coffin";
      mark.textContent = kind === "tomb" ? "🪦" : "⚰️";
      wrap.appendChild(mark);
    }

    // The zap is gone in half a second; the graves linger before they clear.
    setTimeout(function () { wrap.remove(); }, kind === "zap" ? 650 : 1500);
    return new Promise(function (r) { setTimeout(r, kind === "zap" ? 480 : 640); });
  }

  // play walks the server's script for a move the acting player just made.
  function play(view) {
    var events = view.uno_events || [];
    var final = view.uno;
    if (final && final.your_seat != null) me = final.your_seat;
    var chain = Promise.resolve();

    events.forEach(function (e, n) {
      chain = chain.then(function () {
        switch (e.kind) {
          case "ante":
            return; // the antes are tallied into the pot the deal paints

          case "deal":
            FX.sfx("shuffle");
            for (var c = 0; c < 5; c++) FX.sfx("deal", { delay: 0.28 + c * 0.06, v: c });
            paint(final);
            return wait(320);

          case "play": {
            spotlight(e.seat);
            var node = card(e.card);
            var from = seatAnchor(e.seat);
            if (e.seat === me && played >= 0) {
              var liveEl = handEl.querySelector('.pete-uno-card[data-at="' + played + '"]');
              if (liveEl) liveEl.style.visibility = "hidden";
              played = -1;
            }
            bump(e.seat, e.left);
            return throwCard(node, from, discardEl, { index: n }).then(function () {
              discardEl.innerHTML = "";
              var top = card(e.card);
              top.classList.add("pete-uno-top", "pete-uno-land");
              discardEl.appendChild(top);
              if (e.color) { colourEl.textContent = e.color; colourEl.dataset.c = e.color; if (feltEl) feltEl.dataset.c = e.color; }
              showHand(e.hand);
              return wait(e.seat === me ? 120 : 300);
            });
          }

          case "draw":
          case "forced": {
            spotlight(e.seat);
            var to = seatAnchor(e.seat);
            var backs = [];
            for (var i = 0; i < Math.min(e.n, 4); i++) {
              backs.push(throwCard(back(), deckEl, to, { index: i, delay: i * 90, sound: "deal" }));
            }
            var punished = e.kind === "forced";
            if (punished) { FX.sfx("bad"); pending(0); badge(e.seat, "+" + e.n, "bad"); }
            return Promise.all(backs).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              if (e.seat === me && e.card) return wait(160);
              return wait(punished ? 380 : 180);
            });
          }

          case "caught": {
            spotlight(e.seat);
            var mine = e.seat === me;
            var caughtBy = (game && game.seats && game.seats[e.by]) || {};
            var whom = mine ? "Caught! " + (caughtBy.name || "They") + " saw it" : "Caught! +" + e.n;
            FX.sfx(mine ? "bad" : "catch");
            var lands = [];
            var at = seatAnchor(e.seat);
            for (var k = 0; k < e.n; k++) {
              lands.push(throwCard(back(), deckEl, at, { index: k, delay: k * 100, sound: "deal" }));
            }
            badge(e.seat, whom, "bad");
            return Promise.all(lands).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              return wait(520);
            });
          }

          case "miscall": {
            FX.sfx("bad");
            var wrong = [];
            for (var w = 0; w < e.n; w++) {
              wrong.push(throwCard(back(), deckEl, seatAnchor(e.seat), { index: w, delay: w * 100, sound: "deal" }));
            }
            badge(e.seat, "They called it. +" + e.n, "bad");
            return Promise.all(wrong).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              return wait(520);
            });
          }

          case "stack":
            pending(e.n, final);
            spotlight(e.seat);
            FX.sfx("bad");
            return badge(e.seat, "+" + e.n, "bad").then(function () { return wait(140); });

          case "skipall":
            return badge(e.seat, "Everyone skipped").then(function () { return wait(240); });

          case "discard": {
            if (e.seat === me && e.color) {
              handEl.querySelectorAll('.pete-uno-card[data-c="' + e.color + '"]').forEach(function (el) { el.style.visibility = "hidden"; });
            }
            bump(e.seat, e.left);
            var dumpFrom = seatAnchor(e.seat);
            var dumped = [];
            for (var d = 0; d < Math.min(e.n, 4); d++) {
              dumped.push(throwCard(back(), dumpFrom, discardEl, { index: d, delay: d * 70 }));
            }
            badge(e.seat, "−" + e.n + " " + (e.color || ""));
            return Promise.all(dumped).then(function () { showHand(e.hand); return wait(220); });
          }

          case "roulette": {
            spotlight(e.seat);
            var spinTo = seatAnchor(e.seat);
            var spun = [];
            for (var r = 0; r < Math.min(e.n, 4); r++) {
              spun.push(throwCard(back(), deckEl, spinTo, { index: r, delay: r * 80 }));
            }
            badge(e.seat, "Roulette +" + e.n, "bad");
            return Promise.all(spun).then(function () {
              bump(e.seat, e.left);
              showHand(e.hand);
              deckCntEl.textContent = String(Math.max(0, parseInt(deckCntEl.textContent, 10) - e.n));
              return wait(400);
            });
          }

          case "mercy": {
            var grave = seatEl(e.seat);
            if (grave) grave.dataset.out = "1";
            bump(e.seat, 0);
            showHand(e.hand);
            pending(0);
            // bury() rolls the send-off and makes its own noise; the badge rides
            // alongside it so you still see what count did them in.
            return Promise.all([
              bury(e.seat),
              badge(e.seat, "Buried on " + e.n, "bad"),
            ]).then(function () { return wait(300); });
          }

          case "skip":
            return badge(e.seat, "Skipped", "bad").then(function () { return wait(80); });

          case "reverse":
            if (feltEl) feltEl.dataset.rev = feltEl.dataset.rev === "1" ? "0" : "1";
            return badge(e.seat, "Reverse").then(function () { return wait(60); });

          case "uno":
            FX.sfx("uno");
            return badge(e.seat, "UNO!", "uno");

          case "reshuffle":
            deckCntEl.textContent = String(e.n);
            deckEl.classList.add("pete-uno-shuffle");
            FX.sfx("shuffle");
            return wait(420).then(function () { deckEl.classList.remove("pete-uno-shuffle"); });

          case "pass":
            spotlight(e.seat);
            return wait(140);

          case "settle":
          case "sit":
          case "leave":
            return;
        }
      });
    });

    return chain.then(function () {
      if (!final) { paint(null); return; }
      paint(final);
      if (!live(final) && final.winner != null) verdict(final);
    });
  }

  // ---- moves ------------------------------------------------------------------

  function pick(i, c) {
    if (busy || !live(game) || game.turn !== me) return;
    if (c.wild) { askColour(i); return; }
    offer(i, 0);
  }

  function offer(i, color) {
    if (game && (game.uno_at || []).indexOf(i) >= 0) { openGate(i, color); return; }
    move({ kind: "play", index: i, color: color });
  }

  function openGate(i, color) {
    gate = { index: i, color: color };
    gateEl.classList.remove("hidden");
    if (timerEl) {
      timerEl.style.animation = "none";
      void timerEl.offsetWidth;
      timerEl.style.animation = "";
      timerEl.style.animationDuration = CALL_MS + "ms";
    }
    if (callBtn) callBtn.focus();
    gateTimer = setTimeout(function () { shut(false); }, CALL_MS);
  }

  function shut(called) {
    if (!gate) return;
    var g = gate;
    gate = null;
    clearTimeout(gateTimer);
    gateTimer = 0;
    gateEl.classList.add("hidden");
    move({ kind: "play", index: g.index, color: g.color, uno: called });
  }

  function hideGate() {
    if (gateTimer) clearTimeout(gateTimer);
    gateTimer = 0;
    gate = null;
    if (gateEl) gateEl.classList.add("hidden");
  }

  if (callBtn) callBtn.addEventListener("click", function () { shut(true); });

  function catchSeat(seat) {
    if (busy || !live(game) || game.turn !== me) return;
    if ((game.catchable || []).indexOf(seat) < 0) return;
    move({ kind: "catch", seat: seat });
  }

  function askColour(i) { pendingWild = i; wildEl.classList.remove("hidden"); }
  function hideWild() { pendingWild = -1; if (wildEl) wildEl.classList.add("hidden"); }

  var COLOURS = { red: 1, blue: 2, yellow: 3, green: 4 };
  root.querySelectorAll("[data-colour-pick]").forEach(function (b) {
    b.addEventListener("click", function () {
      if (busy || pendingWild < 0) return;
      var i = pendingWild;
      var c = COLOURS[b.dataset.colourPick];
      hideWild();
      offer(i, c);
    });
  });
  var cancelWild = root.querySelector("[data-colour-cancel]");
  if (cancelWild) cancelWild.addEventListener("click", hideWild);

  function move(body) {
    played = body.kind === "play" ? body.index : -1;
    send("/api/games/uno/move", body, gameMsgEl);
  }

  if (drawBtn) drawBtn.addEventListener("click", function () { drawOne(); });
  if (deckEl) deckEl.addEventListener("click", function () { drawOne(); });
  if (passBtn) passBtn.addEventListener("click", function () {
    if (busy || !live(game) || game.turn !== me || game.phase !== "drawn") return;
    move({ kind: "pass" });
  });
  function drawOne() {
    if (busy || !game || game.phase !== "play" || game.turn !== me) return;
    move({ kind: "draw" });
  }
  if (takeBtn) takeBtn.addEventListener("click", function () {
    if (busy || !game || game.turn !== me || game.phase !== "stack") return;
    move({ kind: "take" });
  });

  if (dealBtn) dealBtn.addEventListener("click", function () {
    if (busy) return;
    move({ kind: "deal" });
  });
  if (leaveBtn) leaveBtn.addEventListener("click", function () {
    if (busy) return;
    busy = true;
    say("", null, betweenMsg);
    G.post("/api/games/uno/leave", {})
      .then(function (v) { G.apply(v); unseated(); paint(null); loadLobby(); })
      .catch(function (err) { say(err.message, "bad", betweenMsg); })
      .then(function () { busy = false; controls(); });
  });

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

  // send takes the table away for the whole move — the request and the script that
  // comes back with it — so a click mid-animation can't move against a board the
  // server has already left behind. busy comes off at the end of the script.
  function send(path, body, where) {
    if (busy) return;
    busy = true;
    say("", null, where);
    controls();
    return G.post(path, body)
      .then(function (view) {
        G.apply(view);
        return play(view);
      })
      .catch(function (err) {
        say(err.message, "bad", where);
        return G.refresh().then(function (v) {
          if (v && v.uno) paint(v.uno); else paint(null);
        });
      })
      .then(function () {
        busy = false;
        controls();
        if (pendingSync) { pendingSync = false; sync(); }
      });
  }

  // ---- the live stream -------------------------------------------------------

  function seated() { loadChat(); connectLive(); }
  function unseated() { disconnectLive(); if (chatLog) chatLog.innerHTML = ""; chatSeen = {}; }

  function connectLive() {
    if (stream || !window.EventSource) return;
    stream = new EventSource("/api/games/uno/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();
    };
    stream.addEventListener("sync", function () { sync(); });
  }
  function disconnectLive() { if (stream) { stream.close(); stream = null; } }

  // sync repaints from the authoritative table — a snapshot, no animation. Held
  // while a move of ours is animating, and applied once it finishes.
  function sync() {
    if (busy) { pendingSync = true; return; }
    G.refresh().then(function (v) {
      if (!v) return;
      if (v.uno) paint(v.uno); else { paint(null); }
    });
  }

  // ---- chat ------------------------------------------------------------------

  function loadChat() {
    fetch("/api/games/uno/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 = "";
    G.post("/api/games/uno/say", { body: body })
      .then(function (line) { addChat(line); })
      .catch(function () { chatInput.value = body; });
  });

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

  var DEFAULT_TABLE = { normal: "table", nomercy: "nm-table" };

  function pickRules(which, slug) {
    root.querySelectorAll("[data-rules]").forEach(function (b) { b.dataset.on = b.dataset.rules === which ? "1" : "0"; });
    root.querySelectorAll("[data-grid]").forEach(function (g) { g.classList.toggle("hidden", g.dataset.grid !== which); });
    root.querySelectorAll("[data-note]").forEach(function (p) { p.classList.toggle("hidden", p.dataset.note !== which); });
    var el = root.querySelector('[data-tier="' + (slug || DEFAULT_TABLE[which]) + '"]');
    if (el) pickTier(el);
  }
  root.querySelectorAll("[data-rules]").forEach(function (b) {
    b.addEventListener("click", function () { if (!busy) pickRules(b.dataset.rules); });
  });

  function pickTier(btn) {
    tier = btn;
    root.querySelectorAll("[data-tier]").forEach(function (b) { b.dataset.on = b === btn ? "1" : "0"; });
    syncSit();
  }
  root.querySelectorAll("[data-tier]").forEach(function (b) {
    b.addEventListener("click", function () { if (!busy) pickTier(b); });
  });

  // syncSit keeps the buy-in slider inside the table's range and the sit button
  // honest about whether you can afford the low end.
  function syncSit() {
    if (!tier || !buySlider) return;
    var min = Number(tier.dataset.min), max = Number(tier.dataset.max);
    buySlider.min = String(min);
    buySlider.max = String(max);
    buySlider.step = String(Math.max(1, Math.round((max - min) / 100)));
    var cur = Number(buySlider.value);
    if (!cur || cur < min || cur > max) buySlider.value = String(min);
    var money = G.view();
    var chips = money ? money.chips : 0;
    if (buyLabel) buyLabel.textContent = fmt(Number(buySlider.value));
    if (buyNote) buyNote.textContent = min + "–" + max + " · you have " + fmt(chips);
    if (sitBtn) sitBtn.disabled = busy || !tier || chips < Number(buySlider.value);
  }
  if (buySlider) buySlider.addEventListener("input", syncSit);

  if (sitBtn) sitBtn.addEventListener("click", function () {
    if (busy || !tier) return;
    busy = true;
    say("", null, tableMsg);
    G.post("/api/games/uno/sit", { tier: tier.dataset.tier, buyin: Number(buySlider.value) })
      .then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. Deal when you're ready.", null, betweenMsg); })
      .catch(function (err) { say(err.message, "bad", tableMsg); })
      .then(function () { busy = false; controls(); });
  });

  // ---- the lobby -------------------------------------------------------------

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

  function renderLobby(tables) {
    lobbyEl.innerHTML = "";
    if (lobbyWrap) lobbyWrap.classList.toggle("hidden", tables.length === 0);
    tables.forEach(function (t) {
      var stakes = root.querySelector('[data-tier="' + t.tier + '"]');
      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 lbl = document.createElement("span");
      lbl.className = "font-display font-bold";
      lbl.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(lbl);
      btn.appendChild(seats);
      btn.addEventListener("click", function () { joinTable(t, stakes); });
      lobbyEl.appendChild(btn);
    });
  }

  function joinTable(t, stakes) {
    if (busy) return;
    var min = stakes ? Number(stakes.dataset.min) : Number(buySlider.value);
    var max = stakes ? Number(stakes.dataset.max) : Number(buySlider.value);
    var buyIn = Math.min(max, Math.max(min, Number(buySlider.value)));
    busy = true;
    say("", null, tableMsg);
    G.post("/api/games/uno/sit", { table: t.id, buyin: buyIn })
      .then(function (v) { G.apply(v); paint(v.uno); seated(); say("You're in. The next hand deals when the table is ready.", null, betweenMsg); })
      .catch(function (err) { say(err.message, "bad", tableMsg); })
      .then(function () { busy = false; controls(); });
  }

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

  pickRules("normal");

  var resumed = false;
  G.onUpdate(function () {
    if (resumed) return;
    resumed = true;
    G.refresh().then(function (v) {
      if (v && v.uno) { paint(v.uno); seated(); }
      else { paint(null); loadLobby(); syncSit(); }
    });
  });
})();
