// The hangman table.
//
// Same bargain as the blackjack table: the browser holds no game. It sends a
// letter, and the server answers with the board you're allowed to see — the
// phrase with the letters you haven't earned still blank — plus the script of
// what just happened. The phrase itself only ever arrives once the game is over
// and it no longer decides anything.
//
// The gallows is the meter. It counts your lives down and your winnings down at
// the same time, which is why a miss draws a limb *and* knocks the multiple back
// in the same beat: they are the same event, and showing them as one thing is
// the whole reason to bet on this rather than play it on paper.
(function () {
  "use strict";

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

  var FX = window.PeteFX;

  var boardEl   = root.querySelector("[data-board]");
  var gallowsEl = root.querySelector("[data-gallows]");
  var wrongEl   = root.querySelector("[data-wrong]");
  var wrongLbl  = root.querySelector("[data-wrong-label]");
  var multEl    = root.querySelector("[data-multiple]");
  var meterEl   = root.querySelector("[data-meter]");
  var standsEl  = root.querySelector("[data-stands]");
  var standsLbl = root.querySelector("[data-stands-label]");
  var livesEl   = root.querySelector("[data-lives]");
  var verdictEl = root.querySelector("[data-verdict]");

  var betting   = root.querySelector("[data-betting]");
  var guessing  = root.querySelector("[data-guessing]");
  var keysEl    = root.querySelector("[data-keyboard]");
  var betAmount = root.querySelector("[data-bet-amount]");
  var startBtn  = root.querySelector("[data-start]");
  var solveIn   = root.querySelector("[data-solve-input]");
  var solveBtn  = root.querySelector("[data-solve]");
  var msgEl     = root.querySelector("[data-table-msg]");
  var gameMsgEl = root.querySelector("[data-game-msg]");

  // The three places a chip can be, exactly as at the other table.
  var purseEl = document.querySelector("[data-chips]");
  var spotEl  = root.querySelector("[data-spot]");
  var houseEl = root.querySelector("[data-house]");

  // The bet spot, and the rule that comes with it: the number under the pile is a
  // readout of the pile, never the other way round.
  var spot = FX.spot({
    spot:  spotEl,
    stack: root.querySelector("[data-stack]"),
    total: root.querySelector("[data-spot-total]"),
  });

  var bet = 0;      // what you're building between games
  var busy = false;
  var game = null;  // the board as the server last described it
  var tier = "medium";

  var FLIP_MS = 320;
  var MISS_MS = 520;

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

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

  // renderBoard lays the phrase out as tiles. A tile is a letter you have to earn;
  // a space or a piece of punctuation is scaffolding and gets no tile, because a
  // row of blanks with the word breaks hidden is a puzzle about typography.
  //
  // Words are kept whole: the board wraps between words, never inside one.
  function renderBoard(cells) {
    boardEl.innerHTML = "";
    if (!cells) return;

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

    cells.forEach(function (c, i) {
      if (!c.slot && (c.ch === " " || c.ch === "")) {
        // A space: end the word and start the next one.
        if (word.childNodes.length) boardEl.appendChild(word);
        word = document.createElement("div");
        word.className = "pete-word";
        return;
      }
      var t = document.createElement("span");
      t.className = "pete-tile";
      t.dataset.at = String(i);
      if (!c.slot) {
        t.dataset.punct = "1"; // an exclamation mark is not a blank to fill
        t.textContent = c.ch;
      } else {
        t.dataset.up = c.ch ? "1" : "0";
        t.textContent = c.ch || "";
      }
      word.appendChild(t);
    });
    if (word.childNodes.length) boardEl.appendChild(word);
  }

  // turnUp flips the tiles a hit just earned, one after the other so a letter that
  // appears three times reads as three finds rather than one repaint.
  function turnUp(at, ch) {
    if (!at || !at.length) return Promise.resolve();
    at.forEach(function (i, n) {
      var t = boardEl.querySelector('.pete-tile[data-at="' + i + '"]');
      if (!t) return;
      // One note per tile, climbing: a letter that turns up three times should
      // sound like it's worth three times as much, because it is.
      FX.sfx("tick", { delay: pace(n * 90) / 1000, v: n });
      setTimeout(function () {
        // Left as it comes: the tile is uppercased in CSS, and doing it here too
        // would mean the resume path (which paints the phrase's own casing) and
        // this one put different text in the same tile.
        t.textContent = ch;
        t.dataset.up = "1";
        t.classList.add("pete-tile-hit");
      }, pace(n * 90));
    });
    FX.sfx("flip");
    return wait(FLIP_MS + (at.length - 1) * 90);
  }

  // ---- the gallows -----------------------------------------------------------

  // drawGallows shows the first n parts. Each one draws itself in along its own
  // length rather than fading up — the difference between a limb being *drawn* and
  // a limb appearing is the whole character of the game.
  function drawGallows(n, animateLast) {
    var parts = gallowsEl.querySelectorAll("[data-part]");
    parts.forEach(function (p, i) {
      var on = i < n;
      var was = p.dataset.on === "1";
      p.dataset.on = on ? "1" : "0";
      if (on && !was && animateLast && i === n - 1 && !reduced) {
        // Restart the draw-in animation on the part that was just earned.
        p.classList.remove("pete-part-draw");
        void p.getBoundingClientRect();
        p.classList.add("pete-part-draw");
      } else if (on && !animateLast) {
        p.classList.remove("pete-part-draw");
      }
    });
  }

  function shake() {
    if (reduced) return;
    gallowsEl.classList.remove("pete-shake");
    void gallowsEl.getBoundingClientRect();
    gallowsEl.classList.add("pete-shake");
  }

  // ---- the meter -------------------------------------------------------------

  function renderMeter(v) {
    if (!v) {
      multEl.textContent = "—";
      standsEl.textContent = "—";
      standsLbl.textContent = "if you get it";
      livesEl.textContent = "";
      meterEl.dataset.cold = "0";
      return;
    }
    multEl.textContent = v.multiple.toFixed(2) + "×";
    standsEl.textContent = (v.stands || 0).toLocaleString();
    standsLbl.textContent = v.phase === "done" ? "was on it" : "if you get it";
    livesEl.textContent = v.lives + (v.lives === 1 ? " life left" : " lives left");
    // The meter goes cold once the multiple is down at its floor: from here a win
    // hands back the stake and nothing more.
    meterEl.dataset.cold = v.multiple <= 1.001 ? "1" : "0";
  }

  // knock ticks the multiple down to its new value, so the number falls rather
  // than simply being different.
  function knock(v) {
    if (reduced) { renderMeter(v); return; }
    var from = parseFloat(multEl.textContent) || v.multiple;
    var to = v.multiple;
    var t0 = performance.now();
    meterEl.dataset.hit = "1";
    setTimeout(function () { meterEl.dataset.hit = "0"; }, 400);

    (function step(now) {
      var p = Math.min(1, (now - t0) / 380);
      var eased = 1 - Math.pow(1 - p, 3);
      multEl.textContent = (from + (to - from) * eased).toFixed(2) + "×";
      if (p < 1) requestAnimationFrame(step);
      else renderMeter(v);
    })(t0);
  }

  // ---- the keyboard ----------------------------------------------------------

  var ROWS = ["qwertyuiop", "asdfghjkl", "zxcvbnm", "0123456789"];

  function buildKeys() {
    keysEl.innerHTML = "";
    ROWS.forEach(function (row, r) {
      var line = document.createElement("div");
      line.className = "pete-key-row";
      if (r === 3) line.dataset.digits = "1";
      row.split("").forEach(function (ch) {
        var b = document.createElement("button");
        b.type = "button";
        b.className = "pete-key";
        b.dataset.key = ch;
        b.textContent = ch.toUpperCase();
        b.addEventListener("click", function () { guessLetter(ch); });
        line.appendChild(b);
      });
      keysEl.appendChild(line);
    });
  }

  // paintKeys marks every letter that's been tried, and how it went. A key that
  // has been spent should look spent — otherwise you spend it again.
  function paintKeys(v) {
    var tried = (v && v.tried) || [];
    var wrong = (v && v.wrong) || [];
    keysEl.querySelectorAll(".pete-key").forEach(function (b) {
      var ch = b.dataset.key;
      var used = tried.indexOf(ch) !== -1;
      b.disabled = used || !v || v.phase !== "playing";
      b.dataset.state = !used ? "" : wrong.indexOf(ch) !== -1 ? "miss" : "hit";
    });
  }

  function renderWrong(v) {
    wrongEl.innerHTML = "";
    var wrong = (v && v.wrong) || [];
    // A wrong *solve* is recorded as a miss with no letter on it — it cost a life
    // and it's on the gallows, but there's no key to grey out for it.
    var letters = wrong.filter(function (c) { return c !== "·"; });
    wrongLbl.classList.toggle("hidden", letters.length === 0);
    letters.forEach(function (ch) {
      var s = document.createElement("span");
      s.className = "pete-missed";
      s.textContent = ch.toUpperCase();
      wrongEl.appendChild(s);
    });
  }

  // ---- the money on the felt -------------------------------------------------
  // The spot is PeteFX's, the same one every other table in the room bets onto: a
  // chip has to behave the same way in both rooms or it isn't a chip, it's a
  // widget.

  function stake(amount, from) {
    return spot.pour(from || purseEl, amount);
  }

  function settleChips(final) {
    var payout = final.payout || 0;
    var back = payout - final.bet;

    if (payout <= 0) {
      // The house takes it. The stack goes to the rack and doesn't come back.
      return spot.sweep(houseEl, final.bet, { gap: 45, lift: 0.6, fade: true });
    }
    // Paid into the spot beside your stake, then the whole lot swept home.
    return spot
      .pour(houseEl, back, { gap: 60 })
      .then(function () { return wait(back > 0 ? 380 : 200); })
      .then(function () { return spot.sweep(purseEl, payout, { gap: 40, lift: 0.8 }); });
  }

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

  var VERDICTS = {
    solved: "Got it! 🎉",
    filled: "That's the lot!",
    hung:   "Hung.",
  };

  function verdict(v) {
    var text = VERDICTS[v.outcome] || "";
    if (!text) { verdictEl.classList.add("hidden"); return; }
    if (v.outcome === "hung" && v.phrase) text = "Hung. It was “" + v.phrase + "”.";
    if (v.net > 0) text += "  +" + v.net.toLocaleString();
    else if (v.net < 0) text += "  " + v.net.toLocaleString();
    verdictEl.textContent = text;
    verdictEl.classList.remove("hidden");

    // A phrase guessed outright keeps the confetti — the one call you make on your
    // own rather than by grinding the alphabet — and any win at all makes it rain.
    if (v.outcome === "solved" && v.net > 0) { FX.burst(verdictEl, { count: 30 }); FX.moneyRain({ count: 28 }); }
    else if (v.net > 0) FX.moneyRain();
    else if (v.net < 0) FX.sfx("lose");
  }

  function setPhase(v) {
    game = v;
    var live = !!v && v.phase === "playing";
    betting.classList.toggle("hidden", live);
    guessing.classList.toggle("hidden", !live);
    paintKeys(v);
    if (!live && solveIn) solveIn.value = "";
    if (!v || !v.outcome) verdictEl.classList.add("hidden");
  }

  // paint puts a board up with no animation: the resume path, after a reload or a
  // redeploy. Your stake is still on the spot because the server still has it.
  function paint(v) {
    if (!v) {
      renderBoard(null);
      drawGallows(0, false);
      renderWrong(null);
      renderMeter(null);
      spot.render(0);
      setPhase(null);
      return;
    }
    renderBoard(v.cells);
    drawGallows((v.wrong || []).length, false);
    renderWrong(v);
    renderMeter(v);
    spot.render(v.phase === "done" ? 0 : v.bet);
    setPhase(v);
  }

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

  // play walks the server's events. Same rule as the other table: on a live game
  // the money is already right (your stake left your pile when you pressed Play,
  // and it's on the spot), but on a settling one the chip bar is held back until
  // the chips have physically come home.
  function play(view, money) {
    var events = view.hang_events || [];
    var final = view.hangman;
    var settles = !!final && final.phase === "done";
    var chain = Promise.resolve();

    if (!settles) money();

    if (final && final.bet > spot.amount) {
      var extra = final.bet - spot.amount;
      chain = chain.then(function () { return stake(extra); });
    }

    events.forEach(function (e) {
      chain = chain.then(function () {
        switch (e.kind) {
          case "start":
            verdictEl.classList.add("hidden");
            renderBoard(final && final.cells);
            drawGallows(0, false);
            renderWrong(null);
            renderMeter(final);
            return;

          case "hit":
            return turnUp(e.at, e.letter);

          case "miss":
            // The limb, the shake and the multiple falling are one event, because
            // they are one event: this is what a wrong guess costs, all of it.
            drawGallows(countMisses(events, e), true);
            shake();
            FX.sfx("bad");
            if (final) knock(final);
            return wait(MISS_MS);

          case "solve":
            return wait(220);

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

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

      renderWrong(final);
      if (!settles) {
        renderMeter(final);
        setPhase(final);
        return;
      }

      // Over: the board goes fully face up (the server has finally sent the
      // phrase), then the money moves, and only then does the bar catch up.
      guessing.classList.add("hidden");
      renderBoard(final.cells);
      renderMeter(final);
      verdict(final);
      return settleChips(final)
        .then(money)
        .then(function () { return standing(final.bet); })
        .then(function () { setPhase(final); });
    });
  }

  // countMisses works out how many limbs should be on the gallows by the time
  // this miss has been played — the misses already on the board when the request
  // went out, plus every miss in this batch up to and including this one.
  function countMisses(events, upTo) {
    var before = game ? (game.wrong || []).length : 0;
    var n = 0;
    for (var i = 0; i < events.length; i++) {
      if (events[i].kind === "miss") n++;
      if (events[i] === upTo) break;
    }
    return before + n;
  }

  // standing puts the stake back on the spot for the next phrase, the way the
  // blackjack table leaves your bet up.
  function standing(amount) {
    var money = window.PeteGames.view();
    if (!amount || !money || money.chips < amount) {
      bet = 0;
      showBet();
      return;
    }
    bet = amount;
    showBet();
    return stake(amount);
  }

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

  function send(path, body, where) {
    if (busy) return;
    busy = true;
    say("", null, where);
    return window.PeteGames.post(path, body)
      .then(function (view) {
        return play(view, function () { window.PeteGames.apply(view); });
      })
      .catch(function (err) {
        say(err.message, "bad", where);
        return window.PeteGames.refresh().then(function (v) {
          if (v && !v.hangman) spot.render(0);
        });
      })
      .then(function () { busy = false; });
  }

  function guessLetter(ch) {
    if (busy || !game || game.phase !== "playing") return;
    if ((game.tried || []).indexOf(ch) !== -1) return;
    send("/api/games/hangman/guess", { letter: ch }, gameMsgEl);
  }

  // ---- betting ----------------------------------------------------------------

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

  function pickTier(slug) {
    tier = slug;
    root.querySelectorAll("[data-tier]").forEach(function (b) {
      b.dataset.on = b.dataset.tier === slug ? "1" : "0";
    });
  }

  root.querySelectorAll("[data-tier]").forEach(function (b) {
    b.addEventListener("click", function () {
      if (busy) return;
      pickTier(b.dataset.tier);
    });
  });

  // 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();

      var target = bet;
      spot.amount = bet;
      FX.fly(btn, 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 || !spot.amount) { bet = 0; showBet(); return; }
      spot.sweep(purseEl, null, { gap: 40, lift: 0.7 });
      bet = 0;
      showBet();
    });
  }

  if (startBtn) {
    startBtn.addEventListener("click", function () {
      if (bet <= 0) { say("Put something on it first.", "bad"); return; }
      send("/api/games/hangman/start", { bet: bet, tier: tier });
    });
  }

  function solve() {
    if (busy || !game || game.phase !== "playing") return;
    var attempt = (solveIn.value || "").trim();
    if (!attempt) { say("Say what it is, then.", "bad", gameMsgEl); return; }
    send("/api/games/hangman/guess", { solve: attempt }, gameMsgEl);
  }

  if (solveBtn) solveBtn.addEventListener("click", solve);
  if (solveIn) {
    solveIn.addEventListener("keydown", function (e) {
      if (e.key === "Enter") { e.preventDefault(); solve(); }
    });
  }

  // Type a letter to guess it — but not while you're typing a solution into the
  // box, which is the whole reason this checks what has focus.
  document.addEventListener("keydown", function (e) {
    if (e.metaKey || e.ctrlKey || e.altKey) return;
    if (/^(input|textarea|select)$/i.test(e.target.tagName || "")) return;
    if (!game || game.phase !== "playing" || busy) return;
    var ch = (e.key || "").toLowerCase();
    if (!/^[a-z0-9]$/.test(ch)) return;
    e.preventDefault();
    guessLetter(ch);
  });

  buildKeys();
  pickTier(tier);

  var resumed = false;
  window.PeteGames.onUpdate(function (v) {
    if (!resumed) {
      resumed = true;
      if (v.hangman) {
        paint(v.hangman);
        if (v.hangman.phase === "done") verdict(v.hangman);
      } else {
        paint(null);
      }
    }
    showBet();
  });
})();
