// The blackjack table.
//
// The browser holds no game. It sends intents — deal, hit, stand, double, split
// — and the server answers with the cards you're allowed to see plus the *script*
// of how they got there: one event per card off the shoe, in the order the shoe
// gave them up. This file's job is to play that script back at a human speed
// rather than snapping the finished hand into place.
//
// Which is also why the hole card works the way it does: the server sends a
// "dealer_hole" event with no card attached, because while you are still acting
// it hasn't told anyone what that card is. It arrives with the reveal.
//
// The money is animated on the same principle. Every number the server sends is
// also a movement: a stake is chips leaving your pile and landing on the spot, a
// payout is chips coming out of the house's rack, a loss is your stack being
// taken away. Nothing about the money changes on this table without something
// crossing the felt to make it change — including the count in the chip bar,
// which is deliberately not updated until the chips that justify it have landed.
//
// Since split, "the spot" is not one place. A hand has a spot, and a split makes
// a second hand with a second bet on a second spot — two bets that win and lose
// separately, which is the whole point of splitting and has to look like it.
(function () {
  "use strict";

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

  var FX = window.PeteFX;

  var dealerEl  = root.querySelector("[data-dealer]");
  var dTotalEl  = root.querySelector("[data-dealer-total]");
  var dLabelEl  = root.querySelector("[data-dealer-label]");
  var verdictEl = root.querySelector("[data-verdict]");
  var betting   = root.querySelector("[data-betting]");
  var actions   = root.querySelector("[data-actions]");
  var betAmount = root.querySelector("[data-bet-amount]");
  var dealBtn   = root.querySelector("[data-deal]");
  var msgEl     = root.querySelector("[data-table-msg]");

  // Where the chips live: your pile (in the bar above), a spot in front of each
  // hand, and the house's rack on the felt.
  var purseEl = document.querySelector("[data-chips]");
  var houseEl = root.querySelector("[data-house]");
  var handsEl = root.querySelector("[data-hands]");
  var handTpl = root.querySelector("[data-hand-template]");

  var bet = 0;       // what you're building between hands
  var base = 0;      // what one hand of the last deal cost, for standing it back up
  var busy = false;  // a request is in flight, or cards are still landing
  var hand = null;   // the deal as the server last described it

  var DEAL_MS = 380;  // one card's flight, and the gap before the next
  var FLIP_MS = 450;
  var BEAT_MS = 600;  // the dealer thinking before they draw out
  var SPLIT_MS = 340; // the card lifting out of the hand it's leaving

  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) {
    if (!text) { msgEl.classList.add("hidden"); return; }
    msgEl.textContent = text;
    msgEl.classList.remove("hidden");
    msgEl.style.color = tone === "bad" ? "#cc3d4a" : "";
  }

  // ---- drawing --------------------------------------------------------------
  //
  // The deck itself — the faces, the pips, the flip — is PeteCards, shared with
  // every other table in the room. Here a card is always dealt out of the shoe
  // and always lands with a degree or two of tilt on it, which are this table's
  // two opinions about a card and the only ones it has.

  var CARDS = window.PeteCards;

  function cardEl(face) { return CARDS.el(face); }
  var turnOver = CARDS.turnOver;

  // ---- the hands -------------------------------------------------------------
  //
  // A box per hand: its chips, its cards, its total, its own verdict. There is
  // always at least one, and before a deal that one is where you build your bet —
  // so the chips you stack are already sitting on the hand they're about to buy.

  var hands = [];

  function makeHand(at) {
    var el = handTpl.content.firstElementChild.cloneNode(true);
    var spotEl = el.querySelector("[data-spot]");
    var box = {
      el: el,
      spotEl: spotEl, // where a chip flies to, as opposed to what counts it
      cards: el.querySelector("[data-cards]"),
      total: el.querySelector("[data-total]"),
      verdict: el.querySelector("[data-hand-outcome]"),
      ranks: [], // what's in it, for a running total while the cards are landing
      spot: FX.spot({
        spot: spotEl,
        stack: el.querySelector("[data-stack]"),
        total: el.querySelector("[data-spot-total]"),
      }),
    };
    if (at === undefined || at >= hands.length) {
      handsEl.appendChild(el);
      hands.push(box);
    } else {
      handsEl.insertBefore(el, hands[at].el);
      hands.splice(at, 0, box);
    }
    handsEl.dataset.count = hands.length;
    return box;
  }

  // reset tears the row down to n empty hands. It does not preserve chips: a
  // caller that has some to keep (a deal, whose stake is already on the spot)
  // reads the amount first and puts it back.
  function reset(n) {
    handsEl.innerHTML = "";
    hands = [];
    for (var i = 0; i < (n || 1); i++) makeHand();
  }

  function ensure(n) { while (hands.length < n) makeHand(); }

  // The bet you build between hands is hand zero's.
  function betSpot() { return hands[0].spot; }

  // live marks the hand you're being asked about, and dims the ones you aren't.
  //
  // -1 means nobody is acting — the dealer is drawing, or the deal is over — and
  // that is *not* the same as every hand being inactive: a settled hand is the
  // thing you are reading, so it goes back to full brightness rather than sitting
  // there greyed out. Hence the empty string: it matches neither CSS rule.
  function live(i) {
    hands.forEach(function (h, k) {
      h.el.dataset.live = i < 0 ? "" : k === i ? "1" : "0";
    });
  }

  // The running total under a hand, worked out from the cards you can see. The
  // server sends totals too, but only with the finished state — and a hand you're
  // playing needs to know what it's holding *now*, not once it's over. It's a
  // readout, never a decision: every ruling is the server's.
  var TEN = { K: 10, Q: 10, J: 10 };
  function totalOf(ranks) {
    var total = 0, aces = 0;
    ranks.forEach(function (r) {
      if (r === "A") { aces++; total += 11; }
      else total += TEN[r] || parseInt(r, 10) || 0;
    });
    while (total > 21 && aces > 0) { total -= 10; aces--; }
    return { total: total, soft: aces > 0 };
  }

  function showTotal(box) {
    if (!box.ranks.length) { box.total.classList.add("hidden"); return; }
    var v = totalOf(box.ranks);
    box.total.textContent = v.total + (v.soft ? " (soft)" : "");
    box.total.classList.remove("hidden");
  }

  var SHORT = {
    blackjack: "Blackjack", win: "Won", dealer_bust: "Won",
    lose: "Lost", bust: "Bust", push: "Push",
  };

  // A hand's own result, on the hand. With one hand this says the same thing as
  // the pill above it and is a bit redundant; with four it is the only way to
  // read what happened, because "you win" is not true of all of them.
  function showHandVerdict(box, h) {
    var text = h && SHORT[h.outcome];
    if (!text) { box.verdict.classList.add("hidden"); return; }
    box.verdict.textContent = text;
    box.verdict.classList.remove("hidden");
  }

  // ---- the money on the felt -------------------------------------------------

  // settleChips is what the felt does about the outcome, after the cards have
  // finished telling you what it is. Each hand is paid on its own, in order, left
  // to right — because each hand *is* its own bet, and a split that lost one and
  // won the other has to be watchable as exactly that.
  function settleChips(final) {
    var chain = Promise.resolve();
    (final.hands || []).forEach(function (h, i) {
      chain = chain.then(function () {
        var box = hands[i];
        if (!box) return;
        var payout = h.payout || 0;

        if (payout <= 0) {
          // The house takes it. The stack goes to the rack and doesn't come back.
          return box.spot.sweep(houseEl, h.bet, { gap: 45, lift: 0.6, fade: true });
        }
        // The house pays first, into the spot beside the stake, so you watch the
        // winnings arrive on top of the bet that earned them.
        var back = payout - h.bet;
        return box.spot
          .pour(houseEl, back, { gap: 60 })
          .then(function () { return wait(back > 0 ? 340 : 160); })
          .then(function () { return box.spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
      });
    });
    return chain;
  }

  // While the hole card is down, the dealer's total is only what's showing — so
  // say so, rather than printing a number that quietly means something else.
  function dealerTotal(v) {
    if (v.dealer && v.dealer.length) {
      dTotalEl.textContent = v.hole ? v.dealer_total + " showing" : String(v.dealer_total);
      dTotalEl.classList.remove("hidden");
    } else {
      dTotalEl.classList.add("hidden");
    }
  }

  // paint puts a deal on the felt with no animation. This is the resume path: you
  // reloaded, or Pete restarted, and your cards are simply there — including the
  // stakes, which are still on their spots because the server still has them.
  function paint(v) {
    dealerEl.innerHTML = "";
    if (!v) { reset(1); setPhase(null); return; }

    reset(v.hands.length || 1);
    v.hands.forEach(function (h, i) {
      var box = hands[i];
      (h.cards || []).forEach(function (c) {
        box.cards.appendChild(cardEl(c));
        box.ranks.push(c.rank);
      });
      box.spot.render(v.phase === "done" ? 0 : h.bet);
      showTotal(box);
      showHandVerdict(box, v.phase === "done" ? h : null);
    });

    v.dealer.forEach(function (c) { dealerEl.appendChild(cardEl(c)); });
    if (v.hole) dealerEl.appendChild(cardEl(null));
    dealerTotal(v);
    setPhase(v);
  }

  var VERDICTS = {
    blackjack:   "Blackjack! 🎉",
    win:         "You win!",
    dealer_bust: "Dealer busts. You win!",
    lose:        "Dealer takes it.",
    bust:        "Bust.",
    push:        "Push — your bet comes back.",
  };

  function verdict(v) {
    var text = VERDICTS[v.outcome] || "";
    // Across several hands "you win" is a claim about the money, not about the
    // cards: you can win one, lose one, and still be up. The pill reports the
    // deal; the badge on each hand reports the hand.
    if ((v.hands || []).length > 1) {
      text = v.net > 0 ? "You're up on the deal." : v.net < 0 ? "Down on the deal." : "All square.";
    }
    if (!text) { verdictEl.classList.add("hidden"); return; }
    if (v.net > 0) text += "  +" + v.net.toLocaleString();
    else if (v.net < 0) text += "  " + v.net.toLocaleString();
    verdictEl.textContent = text;
    verdictEl.classList.remove("hidden");

    // Every win gets it to rain, because a win should feel like one. A natural is
    // rarer and pays 3:2, so it keeps the confetti on top of the money as well.
    if (v.outcome === "blackjack") { FX.burst(verdictEl, { count: 34 }); FX.moneyRain({ count: 30 }); }
    else if (v.net > 0) FX.moneyRain();
    else if (v.net < 0) FX.sfx("lose");
    else FX.sfx("push");
  }

  // setPhase swaps the controls: bet between hands, act during one.
  function setPhase(v) {
    hand = v;
    var acting = !!v && v.phase === "player";
    betting.classList.toggle("hidden", acting);
    actions.classList.toggle("hidden", !acting);

    if (acting) {
      var dbl = actions.querySelector('[data-move="double"]');
      var spl = actions.querySelector('[data-move="split"]');
      if (dbl) dbl.disabled = !v.can_double;
      if (spl) spl.disabled = !v.can_split;
      live(v.active || 0);
    } else {
      live(-1);
    }
    if (!acting) verdictEl.classList.toggle("hidden", !(v && v.outcome));
  }

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

  // play walks the server's events, one card at a time. It is deliberately the
  // only thing that renders during a hand: the final state is applied at the end,
  // so what you watch and what the server says can't disagree halfway through.
  //
  // `money` is the one exception, and it's a deliberate one. On a hand that is
  // still running, the chip bar is right immediately — your stake left your pile
  // when you pressed Deal, and it's sitting on the spot where you can see it. On
  // a hand that *settles*, the bar is left alone until the chips have physically
  // come home, because a counter that pays you before the dealer has turned over
  // is a counter that has told you the ending.
  function play(view, money) {
    var events = view.events || [];
    var final = view.hand;
    var settles = !!final && final.phase === "done";
    var hole = null;   // the face-down card element, once one has been dealt
    var chain = Promise.resolve();
    var drew = false;  // has the dealer drawn since the reveal?

    if (!settles) money();

    // A deal whose bet was typed rather than stacked (you kept last hand's number
    // and just pressed Deal) has chips to put down before the first card does.
    // Everything else that costs money — a double, a split — announces itself as
    // an event, and pays for itself there.
    if (final && final.hands && final.hands.length && events.length && events[0].kind === "deal") {
      var want = final.hands[0].bet;
      var have = betSpot().amount;
      if (want > have) {
        chain = chain.then(function () { return betSpot().pour(purseEl, want - have); });
      }
    }

    events.forEach(function (e) {
      chain = chain.then(function () {
        var box;
        switch (e.kind) {
          case "deal":
            // Clear the felt, but not the stake: those chips are yours, they are
            // already on the spot, and they are what this deal is riding on.
            var staked = betSpot().amount;
            dealerEl.innerHTML = "";
            reset(1);
            betSpot().render(staked);
            verdictEl.classList.add("hidden");
            FX.sfx("shuffle");
            return;

          case "player_card":
            ensure(e.hand + 1);
            box = hands[e.hand];
            box.cards.appendChild(cardEl(e.card));
            box.ranks.push(e.card.rank);
            showTotal(box);
            live(e.hand);
            FX.sfx("deal");
            return wait(DEAL_MS);

          case "split":
            // The second card lifts out of the hand it was in and becomes a hand
            // of its own, and the bet that follows it is a second bet: the same
            // chips again, out of your pile, onto a spot that didn't exist a
            // moment ago.
            var src = hands[e.hand];
            var moved = src.cards.lastElementChild;
            var stake = src.spot.amount;
            var fresh = makeHand(e.hand + 1);
            if (moved) moved.classList.add("bj-splitting");
            FX.sfx("deal", { v: 1 });
            return wait(SPLIT_MS).then(function () {
              if (moved) {
                moved.classList.remove("bj-splitting");
                fresh.cards.appendChild(moved);
              }
              fresh.ranks.push(src.ranks.pop());
              showTotal(src);
              showTotal(fresh);
              return fresh.spot.pour(purseEl, stake);
            });

          case "double":
            // The stake goes down again on that hand, and only that hand.
            box = hands[e.hand];
            return box.spot.pour(purseEl, box.spot.amount);

          case "dealer_card":
            // The dealer takes a moment before the first card they draw out.
            // Card, card, card with no breath in between is a machine dealing;
            // the pause is the only thing on this table that plays as suspense.
            var beat = drew ? Promise.resolve() : think();
            drew = true;
            live(-1);
            return beat.then(function () {
              dealerEl.appendChild(cardEl(e.card));
              FX.sfx("deal", { v: 1 });
              return wait(DEAL_MS);
            });

          case "dealer_hole":
            hole = cardEl(null);
            dealerEl.appendChild(hole);
            FX.sfx("deal", { v: 2 });
            return wait(DEAL_MS);

          case "reveal":
            // The hole card turns over. Its face is in the final hand — this is
            // the first moment the server has been willing to say what it was.
            live(-1);
            if (!hole) hole = dealerEl.querySelector('.pete-card[data-face="down"]');
            if (hole && final && final.dealer && final.dealer[1]) {
              turnOver(hole, final.dealer[1]);
            }
            return wait(FLIP_MS);

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

    return chain.then(function () {
      if (!final) { paint(null); money(); return; }

      dealerTotal(final);
      if (!settles) { setPhase(final); return; }

      // The deal is over: nothing is on offer while the money is moving. The
      // actions go now, and Deal comes back at the far end.
      actions.classList.add("hidden");
      live(-1);
      (final.hands || []).forEach(function (h, i) {
        if (hands[i]) showHandVerdict(hands[i], h);
      });
      verdict(final);
      // The chips move, and the bar catches up with them when they arrive. The
      // betting controls come back last, once the felt is clear: offering Deal
      // over a table that is still being paid out invites a click the table then
      // has to refuse.
      return settleChips(final)
        .then(money)
        .then(function () { return standing(base || unitBet(final.hands[0])); })
        .then(function () { setPhase(final); });
    });
  }

  // standing leaves your bet up for the next hand, the way you would at a table:
  // one hand's stake goes straight back on the spot. It costs nothing — chips on
  // the spot are a proposal until you press Deal — and it's what keeps the number
  // in the panel honest, because otherwise a settled hand leaves "your bet: 300"
  // printed over an empty spot.
  //
  // It's one hand's worth, not the whole deal's: a split cost you four hundred,
  // and standing four hundred back up on one spot would be betting a stranger's
  // money on your behalf.
  // unitBet is what one hand of a deal actually cost, read back off a settled
  // hand. It exists for the reload: `base` only knows what you pressed Deal with,
  // and if you reloaded mid-hand nobody pressed Deal. A doubled hand carries twice
  // the stake it was dealt with, so standing its bet back up would quietly put you
  // on double the number you thought you were playing.
  function unitBet(h) {
    if (!h) return 0;
    return h.doubled ? h.bet / 2 : h.bet;
  }

  function standing(amount) {
    var money = window.PeteGames.view();
    if (!amount || !money || money.chips < amount) {
      bet = 0;
      showBet();
      return;
    }
    bet = amount;
    showBet();
    return betSpot().pour(purseEl, amount);
  }

  // think is the dealer's beat: a pause with something to look at, so it reads as
  // deliberation rather than as the page having hung.
  function think() {
    if (reduced || !dLabelEl) return wait(0);
    dLabelEl.classList.add("pete-dealer-think");
    return wait(BEAT_MS).then(function () {
      dLabelEl.classList.remove("pete-dealer-think");
    });
  }

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

  function send(path, body) {
    if (busy) return;
    busy = true;
    say("");
    return window.PeteGames.post(path, body)
      .then(function (view) {
        // play() decides *when* the money lands; see the note on it.
        return play(view, function () { window.PeteGames.apply(view); });
      })
      .catch(function (err) {
        say(err.message, "bad");
        // Whatever we thought was on the felt, the server is the authority on it.
        return window.PeteGames.refresh().then(function (v) {
          if (v && !v.hand) { reset(1); setPhase(null); }
          else if (v && v.hand) paint(v.hand);
        });
      })
      .then(function () { busy = false; });
  }

  // ---- betting --------------------------------------------------------------
  //
  // A bet is built by putting chips on the spot, one at a time, and it is those
  // chips the deal then rides on — the number under the pile is a readout of the
  // pile, not the other way round.

  function showBet() {
    betAmount.textContent = bet.toLocaleString();
    var money = window.PeteGames.view();
    if (dealBtn) dealBtn.disabled = bet <= 0 || !money || money.chips < bet;
  }

  // Scoped to buttons: the bare [data-chip] spans in the corner are the house's
  // rack, and the house is not betting.
  root.querySelectorAll("button[data-chip]").forEach(function (btn) {
    btn.addEventListener("click", function () {
      if (busy) return;
      var d = parseInt(btn.dataset.chip, 10);
      var money = window.PeteGames.view();
      if (money && bet + d > money.chips) {
        say("You haven't got that many chips.", "bad");
        return;
      }
      bet += d;
      showBet();

      // The chip you clicked is the chip that flies: same colour, same size, off
      // the button and onto the felt. The pile only grows once it gets there —
      // but the spot's total moves now, so a Deal pressed mid-flight still knows
      // the chip is on its way and doesn't put a second one down.
      var target = bet;
      var spot = betSpot();
      spot.amount = bet;
      FX.fly(btn, hands[0].spotEl, { denom: d }).then(function () {
        if (bet >= target) spot.render(target); // unless Clear got there first
      });
    });
  });

  var clearBtn = root.querySelector("[data-bet-clear]");
  if (clearBtn) {
    clearBtn.addEventListener("click", function () {
      if (busy || !betSpot().amount) { bet = 0; showBet(); return; }
      betSpot().sweep(purseEl, null, { gap: 40, lift: 0.7 });
      bet = 0;
      showBet();
    });
  }

  if (dealBtn) {
    dealBtn.addEventListener("click", function () {
      if (bet <= 0) { say("Put something on it first.", "bad"); return; }
      base = bet; // one hand's worth, which is what a split doubles and a stand puts back
      send("/api/games/blackjack/deal", { bet: bet });
    });
  }

  root.querySelectorAll("[data-move]").forEach(function (btn) {
    btn.addEventListener("click", function () {
      send("/api/games/blackjack/move", { move: btn.dataset.move });
    });
  });

  document.addEventListener("keydown", function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    if (/^(input|textarea|select)$/i.test((e.target.tagName || ""))) return;
    if (!hand || hand.phase !== "player" || busy) return;

    var move = { h: "hit", s: "stand", d: "double", p: "split" }[e.key.toLowerCase()];
    if (!move) return;
    if (move === "double" && !hand.can_double) return;
    if (move === "split" && !hand.can_split) return;
    e.preventDefault();
    send("/api/games/blackjack/move", { move: move });
  });

  // The money bar owns the first fetch; the table picks up whatever it found,
  // including a deal left sitting on the felt by a reload or a redeploy.
  reset(1);
  var resumed = false;
  window.PeteGames.onUpdate(function (v) {
    if (!resumed) {
      resumed = true;
      if (v.hand) paint(v.hand);
      if (v.hand && v.hand.phase === "done") verdict(v.hand);
    }
    showBet();
  });
})();
