#!/usr/bin/env python3

from __future__ import annotations

import configparser
import base64
import hashlib
import hmac
import io
import json
import math
import os
import random
import re
import secrets
import socket
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import webbrowser
import wave
from html import escape as html_escape
from pathlib import Path
from urllib.parse import urlparse
from threading import Timer

from flask import Flask, Response, abort, g, jsonify, redirect, render_template_string, request, url_for

def bundle_dir() -> Path:
    if getattr(sys, "frozen", False):
        return Path(getattr(sys, "_MEIPASS", Path(sys.executable).resolve().parent))
    return Path(__file__).resolve().parent


def writable_app_dir() -> Path:
    configured = (
        os.environ.get("BLAME_MACHINE_DATA_DIR", "").strip()
        or os.environ.get("DECISION_ARCADE_DATA_DIR", "").strip()
    )
    if configured:
        return Path(configured).expanduser()
    if getattr(sys, "frozen", False):
        return Path.home() / ".blame_machine"
    return Path(__file__).resolve().parent


BUNDLE_DIR = bundle_dir()
APP_DIR = writable_app_dir()
APP_DIR.mkdir(parents=True, exist_ok=True, mode=0o700)
try:
    APP_DIR.chmod(0o700)
except PermissionError:
    # Bind-mounted directories may be managed by the host instead.
    pass
DEFAULT_PORT = 5000
DEFAULT_LAN_HOST = "0.0.0.0"
DEFAULT_BROWSER_HOST = "127.0.0.1"
DEFAULT_PUBLIC_URL = ""
IDENTITY_USER_HEADER = os.environ.get("IDENTITY_USER_HEADER", "X-Access-User").strip() or "X-Access-User"
IDENTITY_EMAIL_HEADER = os.environ.get("IDENTITY_EMAIL_HEADER", "X-Access-Email").strip() or "X-Access-Email"
IDENTITY_NAME_HEADER = os.environ.get("IDENTITY_NAME_HEADER", "X-Access-Name").strip() or "X-Access-Name"
IDENTITY_SECRET_HEADER = os.environ.get("IDENTITY_SECRET_HEADER", "X-Access-Proxy-Secret").strip() or "X-Access-Proxy-Secret"
IDENTITY_SHARED_SECRET = os.environ.get("IDENTITY_SHARED_SECRET", "").strip()
LOCAL_USER = os.environ.get("LOCAL_USER", "local").strip() or "local"
GUEST_COOKIE_NAME = os.environ.get("BLAME_MACHINE_GUEST_COOKIE", "blame_machine_guest").strip() or "blame_machine_guest"
GUEST_COOKIE_MAX_AGE = max(3600, int(os.environ.get("BLAME_MACHINE_GUEST_COOKIE_MAX_AGE", str(60 * 60 * 24 * 365)).strip() or str(60 * 60 * 24 * 365)))
TURNSTILE_SITE_KEY = os.environ.get("TURNSTILE_SITE_KEY", "").strip()
TURNSTILE_SECRET_KEY = os.environ.get("TURNSTILE_SECRET_KEY", "").strip()
TURNSTILE_ACTION = os.environ.get("TURNSTILE_ACTION", "start_play").strip() or "start_play"
TURNSTILE_COOKIE_NAME = os.environ.get("TURNSTILE_COOKIE_NAME", "blame_machine_human").strip() or "blame_machine_human"
TURNSTILE_COOKIE_MAX_AGE = max(300, int(os.environ.get("TURNSTILE_COOKIE_MAX_AGE", str(60 * 60 * 12)).strip() or str(60 * 60 * 12)))
TURNSTILE_VERIFY_TIMEOUT = max(1.0, float(os.environ.get("TURNSTILE_VERIFY_TIMEOUT", "5").strip() or "5"))
AUDIO_DEBUG_ENABLED = (
    os.environ.get("BLAME_MACHINE_AUDIO_DEBUG", "").strip().lower() in {"1", "true", "yes", "on"}
)
BUILD_LABEL = os.environ.get("APP_COMMIT", "").strip()[:7].lower()
SOCIAL_DESCRIPTION = "Blame me for bad decisions."
APPLE_TOUCH_ICON_PATH = BUNDLE_DIR / "apple-touch-icon.png"
SOCIAL_CARD_PNG_PATH = BUNDLE_DIR / "social-card.png"

app = Flask(__name__)

GLOBAL_WILDCARDS = [
    "pick none of these",
    "reroll later",
    "ask a friend",
    "do something completely different",
    "sleep on it",
    "flip a coin and trust your reaction",
    "focus on yourself for now",
    "take a break and come back to it",
]

CATEGORY_DEFAULT_WILDCARDS = {
    "dessert": [
        "switch it up with a cupcake",
        "plot twist: brownie",
        "go rogue with cookies",
        "wildcard says cheesecake",
    ],
    "beverage": [
        "surprise yourself with something fizzy",
        "plot twist: water first",
        "switch it up with lemonade",
        "wildcard says iced coffee",
    ],
    "task": [
        "do the easiest win first",
        "start with the most annoying thing",
        "take a 20-minute reset, then decide",
        "clean something small first",
    ],
    "car": [
        "test drive both first",
        "keep the money today",
        "sleep on it",
        "check insurance before deciding",
    ],
}

BANNED_WORDS_PATH = BUNDLE_DIR / "banned_words.txt"
ALLOWED_WORDS_PATH = BUNDLE_DIR / "allowed_words.txt"
COIN_PRIMER_MP3_PATH = BUNDLE_DIR / "coin-primer.mp3"
COIN_PRIMER_MP3 = COIN_PRIMER_MP3_PATH.read_bytes() if COIN_PRIMER_MP3_PATH.exists() else b""
FAST_REROLL_SECONDS = 10
RESULT_RETURN_SECONDS = 60
WILDCARD_CHANCE = 0.2
RECENT_HISTORY_LIMIT = 3
CATEGORY_NAME_RE = re.compile(r"^[^\[\]\r\n]{1,80}$")

CHOICE_TEMPLATES = [
    "you should totally go with {choice}",
    "you'd be nuts to choose anything but {choice}",
    "{choice} is the move",
    "come on now, it's obviously {choice}",
    "trust the machine: {choice}",
    "{choice} and don't overthink it",
    "the cabinet has spoken: {choice}",
    "all signs point to {choice}",
    "save yourself the spiral and pick {choice}",
    "this has {choice} written all over it",
    "don't make it weird, just pick {choice}",
    "the joystick says {choice}",
    "c'mon, sweetheart, it's {choice}",
    "ay, quit stalling, go with {choice}",
    "use your head for once and pick {choice}",
    "listen, pal, {choice} is the answer",
    "you're really gonna argue with {choice}?",
    "forget the drama, take {choice}",
    "be serious, you're taking {choice}",
    "quit playin' and go with {choice}",
    "that's the move, kid: {choice}",
    "don't overcook it, pick {choice}",
    "oh brother, it's obviously {choice}",
    "what are we even doing here? it's {choice}",
    "yeah, no, you're picking {choice}",
    "please. the answer is {choice}",
]

CLEAR_ARCADE_DESCRIPTIONS = [
    "WIPE MY CATEGORIES BEFORE ANYONE SEES THIS.",
    "RESET MY ARCADE. DON'T ASK QUESTIONS.",
    "START OVER LIKE NONE OF THIS HAPPENED.",
]

BASE_STYLES = """
  <style>
    :root {
      --bg: #050505;
      --green: #39ff14;
      --pink: #ff2bd6;
      --cyan: #00eaff;
      --yellow: #ffe600;
      --panel: #0d0d0d;
      --red: #ff5c5c;
      --title-shell-width: 760px;
      --title-bar-min-height: 152px;
      --title-shell-max-height: min(1080px, calc(100vh - env(safe-area-inset-top, 0px) - 64px));
    }

    * { box-sizing: border-box; }

    [hidden] {
      display: none !important;
    }

    body {
      margin: 0;
      background:
        radial-gradient(circle at top, rgba(0,234,255,0.10), transparent 30%),
        radial-gradient(circle at bottom, rgba(255,43,214,0.08), transparent 30%),
        var(--bg);
      color: var(--green);
      font-family: "Courier New", "Lucida Console", monospace;
      padding: calc(env(safe-area-inset-top, 0px) + 16px) 18px 18px;
      min-height: 100vh;
      text-transform: uppercase;
      letter-spacing: 0.04em;
    }

    .wrap {
      position: relative;
      max-width: 920px;
      margin: 0 auto;
    }

    .title {
      text-align: center;
      color: var(--yellow);
      font-size: 20px;
      line-height: 1.6;
      text-shadow: 0 0 8px rgba(255,230,0,0.6);
      margin: 16px 0 28px;
    }

    .sub {
      text-align: center;
      color: var(--cyan);
      font-size: 11px;
      margin-bottom: 24px;
      line-height: 1.8;
    }

    .player-banner {
      text-align: center;
      color: rgba(0,234,255,0.88);
      font-size: 9px;
      line-height: 1.8;
      margin: 18px 0 4px;
      text-shadow: 0 0 6px rgba(0,234,255,0.16);
      word-break: break-word;
      opacity: 0.82;
    }

    .player-banner-subtle {
      color: rgba(0,234,255,0.72);
      font-size: 8px;
      margin-top: 0;
      margin-bottom: 0;
      text-shadow: none;
      opacity: 0.72;
    }

    .corner-button {
      position: fixed;
      top: calc(env(safe-area-inset-top, 0px) + 14px);
      width: auto;
      min-width: 0;
      min-height: 44px;
      padding: 10px 12px;
      font-size: 10px;
      line-height: 1.2;
      z-index: 10;
      background: transparent;
      border: none;
      box-shadow: none;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      display: inline-flex;
      align-items: center;
      justify-content: center;
    }

    .sound-toggle {
      right: 14px;
      color: var(--pink);
      text-shadow: 0 0 8px rgba(255,43,214,0.35);
      font-size: 11px;
    }

    .exit-toggle {
      left: 14px;
      color: var(--cyan);
      text-shadow: 0 0 8px rgba(0,234,255,0.35);
      font-size: 11px;
    }

    .sound-toggle:hover,
    .sound-toggle:focus-visible,
    .exit-toggle:hover,
    .exit-toggle:focus-visible {
      background: transparent;
      color: var(--yellow);
      text-shadow: 0 0 8px rgba(255,230,0,0.45);
    }

    .card {
      background: var(--panel);
      border: 3px solid var(--green);
      box-shadow: 0 0 18px rgba(57,255,20,0.18);
      padding: 18px;
      margin: 18px 0;
    }

    .footer-commit {
      margin: 12px auto 0;
      min-height: 1.4em;
      text-align: center;
      font-size: 9px;
      line-height: 1.4;
      color: rgba(0, 234, 255, 0.52);
      opacity: 0.72;
      letter-spacing: 0.12em;
    }

    .row {
      margin-bottom: 18px;
    }

    label {
      display: block;
      font-size: 11px;
      margin-bottom: 10px;
      color: var(--cyan);
      line-height: 1.6;
      text-align: center;
    }

    .center-label {
      text-align: center;
    }

    .prompt-copy {
      display: block;
      text-align: center;
      font-size: 11px;
      color: var(--cyan);
      line-height: 1.6;
    }

    input, textarea, select {
      width: 100%;
      background: #000;
      color: var(--green);
      border: 3px solid var(--green);
      padding: 12px;
      font-family: "Courier New", "Lucida Console", monospace;
      font-size: 11px;
      line-height: 1.6;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      outline: none;
    }

    textarea {
      resize: vertical;
      min-height: 110px;
    }

    input::placeholder, textarea::placeholder {
      color: #7aff7a;
      opacity: 0.6;
      text-transform: uppercase;
    }

    button, .button-link {
      width: 100%;
      display: inline-block;
      text-align: center;
      background: #000;
      color: var(--pink);
      border: 3px solid var(--pink);
      padding: 14px;
      font-family: "Courier New", "Lucida Console", monospace;
      font-size: 11px;
      line-height: 1.4;
      cursor: pointer;
      text-decoration: none;
      box-shadow: 0 0 14px rgba(255,43,214,0.18);
    }

    button:hover, .button-link:hover {
      background: var(--pink);
      color: #000;
    }

    .small {
      color: #9befff;
      font-size: 9px;
      line-height: 1.8;
    }

    .access-box {
      text-align: center;
    }

    .access-box summary {
      list-style: none;
      cursor: pointer;
      color: var(--cyan);
      font-size: 10px;
      line-height: 1.7;
      text-align: center;
      outline: none;
    }

    .access-box summary::-webkit-details-marker {
      display: none;
    }

    .access-box[open] summary {
      color: var(--yellow);
    }

    .access-box-body {
      margin-top: 14px;
    }

    .access-box code {
      display: inline-block;
      margin-top: 8px;
      padding: 8px 10px;
      border: 2px solid var(--cyan);
      background: rgba(0,234,255,0.08);
      color: var(--cyan);
      font-family: "Courier New", "Lucida Console", monospace;
      font-size: 9px;
      line-height: 1.6;
      word-break: break-all;
    }

    .choices {
      display: flex;
      flex-wrap: wrap;
      gap: 10px;
      margin-top: 12px;
      justify-content: center;
    }

    .pill {
      border: 2px solid var(--cyan);
      color: var(--cyan);
      padding: 10px 12px;
      font-size: 9px;
      line-height: 1.6;
      background: rgba(0,234,255,0.06);
    }

    .result-box {
      border: 3px solid var(--yellow);
      padding: 28px 18px;
      box-shadow: 0 0 18px rgba(255,230,0,0.20);
      background: rgba(255,230,0,0.05);
      text-align: center;
    }

    .result-label {
      color: var(--yellow);
      font-size: 10px;
      margin-bottom: 14px;
      line-height: 1.6;
    }

    .result-text {
      color: var(--yellow);
      font-size: 22px;
      line-height: 1.8;
      text-shadow: 0 0 8px rgba(255,230,0,0.5);
      word-break: break-word;
      margin-bottom: 14px;
    }

    .meta {
      color: #9befff;
      font-size: 9px;
      line-height: 1.8;
      margin-top: 12px;
    }

    .result-return {
      text-align: center;
    }

    .result-actions form:first-child button {
      background: rgba(255,230,0,0.12);
      color: var(--yellow);
      border-color: var(--yellow);
      box-shadow: 0 0 16px rgba(255,230,0,0.20);
      text-shadow: 0 0 8px rgba(255,230,0,0.24);
    }

    .result-actions form:first-child button:hover,
    .result-actions form:first-child button:focus-visible {
      background: var(--yellow);
      color: #000;
    }

    .error-card {
      border-color: var(--red);
      color: var(--red);
      text-align: center;
    }

    .wildcard {
      color: var(--red);
      animation: blink 1s steps(2, start) infinite;
      margin-top: 12px;
      font-size: 10px;
      line-height: 1.6;
    }

    .button-stack > * {
      margin-top: 12px;
    }

    .hero-card {
      width: min(var(--title-shell-width), 100%);
      min-height: var(--title-bar-min-height);
      max-height: var(--title-shell-max-height);
      margin: 0 auto;
      text-align: center;
      padding: 0;
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }

    .title-bar {
      flex: 0 0 auto;
      min-height: var(--title-bar-min-height);
      padding: 42px 22px 14px;
      border-bottom: 2px solid rgba(0, 234, 255, 0.35);
      background:
        linear-gradient(180deg, rgba(0, 234, 255, 0.05), rgba(13, 13, 13, 0.96) 65%),
        rgba(13, 13, 13, 0.96);
    }

    .title-bar .title {
      margin: 0 0 10px;
    }

    .title-bar .sub {
      margin: 0;
    }

    .title-content {
      flex: 1 1 auto;
      min-height: 0;
      overflow-y: auto;
      padding: 18px 22px 24px;
      display: grid;
      align-content: start;
      gap: 18px;
      scrollbar-color: rgba(0, 234, 255, 0.65) rgba(0, 0, 0, 0.2);
    }

    .hero-copy {
      color: var(--cyan);
      font-size: 11px;
      line-height: 1.8;
      margin: 0;
    }

    .hero-actions {
      display: flex;
      flex-direction: column;
      gap: 12px;
      max-width: 320px;
      margin: 14px auto 0;
    }

    .quiet-link {
      display: inline-block;
      text-align: center;
      color: var(--cyan);
      font-size: 10px;
      line-height: 1.8;
      text-decoration: none;
      padding: 8px 10px;
    }

    .hero-play-button {
      color: var(--yellow);
      border-color: var(--cyan);
      background: rgba(0, 234, 255, 0.08);
      text-shadow: 0 0 8px rgba(255,230,0,0.35);
      box-shadow:
        0 0 14px rgba(0,234,255,0.22),
        0 0 20px rgba(255,43,214,0.12);
      animation: playPulse 4.2s steps(1, end) infinite;
    }

    .hero-play-button:hover,
    .hero-play-button:focus-visible {
      background: var(--yellow);
      border-color: var(--yellow);
      color: #000;
      box-shadow: 0 0 16px rgba(255,230,0,0.32);
      animation-play-state: paused;
    }

    .hero-play-button.is-loading {
      cursor: progress;
      animation: none;
      background: rgba(255, 230, 0, 0.14);
      border-color: var(--yellow);
      color: var(--yellow);
      box-shadow: 0 0 16px rgba(255,230,0,0.22);
    }

    .topic-tuner {
      display: grid;
      grid-template-columns: 74px 1fr 74px;
      gap: 10px;
      align-items: center;
    }

    .topic-tuner-button {
      width: 100%;
      min-width: 0;
      min-height: 58px;
      padding: 14px 0;
      color: var(--cyan);
      border-color: var(--cyan);
      box-shadow: 0 0 14px rgba(0,234,255,0.18);
      font-size: 16px;
    }

    .topic-tuner-button:hover {
      background: var(--cyan);
      color: #000;
    }

    .topic-tuner-button:disabled {
      border-color: rgba(0,234,255,0.14);
      color: rgba(0,234,255,0.18);
      box-shadow: none;
      cursor: default;
      background: #000;
      pointer-events: none;
      text-shadow: none;
      opacity: 0.65;
    }

    .topic-tuner-button:disabled:hover {
      background: #000;
      color: rgba(0,234,255,0.18);
    }

    .topic-tuner-display {
      min-height: 58px;
      display: flex;
      align-items: center;
      justify-content: center;
      text-align: center;
      padding: 10px 12px;
      border: 3px solid var(--green);
      background: #000;
      color: var(--green);
      font-size: 11px;
      line-height: 1.5;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      word-break: break-word;
      box-shadow: inset 0 0 12px rgba(57,255,20,0.08);
    }

    .topic-tuner-meta {
      margin-top: 10px;
      text-align: center;
      color: #9befff;
      font-size: 9px;
      line-height: 1.7;
      letter-spacing: 0.08em;
    }

    .quiet-link:hover {
      color: var(--yellow);
      text-shadow: 0 0 6px rgba(255,230,0,0.4);
    }

    .input-helper {
      margin-top: 10px;
      text-align: center;
      color: var(--cyan);
      font-size: 10px;
      line-height: 1.8;
      opacity: 0.92;
    }

    .status {
      margin-top: 18px;
      color: var(--yellow);
      font-size: 11px;
      line-height: 1.7;
      text-align: left;
      white-space: pre-wrap;
    }

    .back-link {
      margin-top: 20px;
      text-align: center;
    }

    .turnstile-overlay {
      position: fixed;
      inset: 0;
      padding: 20px;
      background: rgba(5, 5, 5, 0.92);
      display: flex;
      align-items: center;
      justify-content: center;
      z-index: 40;
    }

    .turnstile-card {
      width: min(100%, 460px);
      text-align: center;
    }

    .turnstile-copy {
      margin-bottom: 16px;
      color: var(--yellow);
      line-height: 1.7;
      font-size: 11px;
    }

    .turnstile-slot {
      display: flex;
      justify-content: center;
      margin: 14px 0 10px;
      min-height: 70px;
    }

    .turnstile-help {
      color: var(--cyan);
      font-size: 10px;
      line-height: 1.7;
      margin-top: 10px;
    }

    @keyframes blink {
      50% { opacity: 0.25; }
    }

    @keyframes playPulse {
      0%, 49% {
        color: var(--yellow);
        border-color: var(--cyan);
        background: rgba(0, 234, 255, 0.08);
        text-shadow: 0 0 8px rgba(255,230,0,0.35);
        box-shadow:
          0 0 14px rgba(0,234,255,0.22),
          0 0 20px rgba(255,43,214,0.12);
      }
      50%, 100% {
        color: var(--cyan);
        border-color: var(--pink);
        background: rgba(255, 43, 214, 0.16);
        text-shadow: 0 0 8px rgba(0,234,255,0.35);
        box-shadow:
          0 0 14px rgba(255,43,214,0.22),
          0 0 20px rgba(0,234,255,0.12);
      }
    }

    @media (prefers-reduced-motion: reduce) {
      .hero-play-button,
      .wildcard {
        animation: none;
      }
    }

    @media (max-width: 640px) {
      .title { font-size: 16px; }
      .result-text { font-size: 16px; }
      button, .button-link { font-size: 11px; }
      label, .prompt-copy { font-size: 13px; line-height: 1.7; }
      .small, .meta, .pill { font-size: 10px; }
      input, textarea, select {
        font-size: 16px;
        line-height: 1.5;
      }
      body {
        padding: calc(env(safe-area-inset-top, 0px) + 8px) 14px 14px;
      }
      .card {
        padding: 14px;
        margin: 14px 0;
      }
      .topic-tuner {
        grid-template-columns: 68px 1fr 68px;
        gap: 10px;
      }
      .topic-tuner-display {
        min-height: 56px;
        font-size: 12px;
      }
      .topic-tuner-button {
        min-height: 56px;
        font-size: 18px;
      }
      .result-box {
        padding: 22px 14px;
      }
      .hero-card {
        max-height: calc(100vh - env(safe-area-inset-top, 0px) - 28px);
      }
      .title-bar {
        padding: 42px 16px 12px;
      }
      .title-content {
        padding: 16px;
      }
      .sound-toggle {
        right: 10px;
        font-size: 10px;
      }
      .exit-toggle {
        left: 10px;
        font-size: 10px;
      }
    }
  </style>
"""

MUSIC_UI = """
  <button type="button" id="exit-game" class="corner-button exit-toggle">EXIT</button>
  <button type="button" id="sound-toggle" class="corner-button sound-toggle">MUTE</button>
"""

MUSIC_SCRIPT = """
  <script>
    (() => {
      const storageKey = "blameMachineSoundEnabled";
      const enteredKey = "blameMachineEntered";
      const hubOriginKey = "blameMachineHubOrigin";
      const hubReturnKey = "blameMachineHubReturnUrl";
      const toggle = document.getElementById("sound-toggle");
      const exitButton = document.getElementById("exit-game");
      if (!toggle || !exitButton) return;

      let ctx = null;
      let master = null;
      let timer = null;
      let step = 0;
      let started = false;
      let htmlDecks = [];
      let htmlDeckIndex = 0;
      let htmlCrossfadeTimer = null;
      let soundEnabled = window.localStorage.getItem(storageKey) !== "0";
      let enteredGame = window.location.pathname === "/game" && window.sessionStorage.getItem(enteredKey) === "1";
      let scene = "home";
      let primed = false;
      let primePromise = null;
      let primerAudio = null;
      let recoveryArmed = false;
      let recovering = false;
      let bootingAudio = false;
      let bootstrapped = false;
      let bootstrapSilence = false;
      const primerGain = 0.51;
      const htmlLoopGain = 0.55;
      const htmlCrossfadeMs = 20;
      const defaultHubUrl = "https://games.irq9.net/";
      const initialUrl = new URL(window.location.href);
      let enteredFromHub = initialUrl.searchParams.get("arcade_entry") === "hub";
      let returnUrl = window.sessionStorage.getItem(hubReturnKey) || defaultHubUrl;
      try {
        const requestedReturn = initialUrl.searchParams.get("return_url");
        if (requestedReturn) {
          const parsedReturn = new URL(requestedReturn, window.location.href);
          if (parsedReturn.hostname === "games.irq9.net") returnUrl = parsedReturn.toString();
        }
      } catch (_error) {}
      try {
        enteredFromHub = enteredFromHub || (document.referrer ? new URL(document.referrer).hostname === "games.irq9.net" : false);
      } catch (_error) {}
      enteredFromHub = enteredFromHub || window.sessionStorage.getItem(hubOriginKey) === "1";
      if (enteredFromHub) {
        window.sessionStorage.setItem(hubOriginKey, "1");
        window.sessionStorage.setItem(hubReturnKey, returnUrl);
      }
      window.blameMachineEnteredFromHub = enteredFromHub;
      window.blameMachineHubReturnUrl = returnUrl;

      const cleanUrl = new URL(window.location.href);
      if (cleanUrl.searchParams.has("arcade_entry") || cleanUrl.searchParams.has("arcade_shell")) {
        cleanUrl.searchParams.delete("arcade_entry");
        cleanUrl.searchParams.delete("arcade_shell");
        cleanUrl.searchParams.delete("return_url");
        window.history.replaceState({}, "", `${cleanUrl.pathname}${cleanUrl.search}${cleanUrl.hash}`);
      }

      const stepMs = 360;
      const sceneGains = {
        home: 0.35,
        category: 0.30,
        thinking: 0.26,
        result: 0.32,
      };
      const homePattern = [
        { bass: 220.0, lead: 659.25 },
        { bass: 220.0, lead: 783.99 },
        { bass: 261.63, lead: 880.0 },
        { bass: 220.0, lead: 783.99 },
        { bass: 293.66, lead: 698.46 },
        { bass: 261.63, lead: 783.99 },
        { bass: 220.0, lead: 1046.5 },
        { bass: 196.0, lead: 783.99 }
      ];
      const categoryPattern = [
        { bass: 196.0, lead: 392.0 },
        { bass: 196.0, lead: 440.0 },
        { bass: 220.0, lead: 392.0 },
        { bass: 196.0, lead: 523.25 },
        { bass: 196.0, lead: 440.0 },
        { bass: 174.61, lead: 392.0 },
        { bass: 196.0, lead: 349.23 },
        { bass: 220.0, lead: 392.0 }
      ];
      const thinkingPattern = [
        { bass: 196.0, lead: 523.25, pulse: 659.25 },
        { bass: 196.0, lead: 587.33, pulse: 689.25 },
        { bass: 220.0, lead: 523.25, pulse: 719.25 },
        { bass: 196.0, lead: 659.25, pulse: 749.25 },
        { bass: 196.0, lead: 587.33, pulse: 659.25 },
        { bass: 174.61, lead: 523.25, pulse: 689.25 },
        { bass: 196.0, lead: 493.88, pulse: 719.25 },
        { bass: 220.0, lead: 523.25, pulse: 749.25 }
      ];
      const resultPattern = [
        { bass: 246.94, lead: 523.25, sparkle: 1046.5 },
        { bass: 246.94, lead: 659.25, sparkle: 1174.66 },
        { bass: 293.66, lead: 783.99, sparkle: 1318.51 },
        { bass: 246.94, lead: 659.25, sparkle: 1174.66 },
        { bass: 329.63, lead: 880.0, sparkle: 1396.91 },
        { bass: 293.66, lead: 783.99, sparkle: 1318.51 },
        { bass: 246.94, lead: 987.77, sparkle: 1567.98 },
        { bass: 220.0, lead: 783.99, sparkle: 1318.51 }
      ];

      function traceAudio(message) {
        fetch('/audio-debug/log', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          credentials: 'same-origin',
          keepalive: true,
          body: JSON.stringify({
            message: `MAIN ${message}`,
            ctxState: ctx ? ctx.state : 'none',
            ctxTime: ctx ? Number(ctx.currentTime.toFixed(3)) : null,
            htmlPaused: primerAudio ? primerAudio.paused : null,
            htmlReadyState: primerAudio ? primerAudio.readyState : null,
            htmlCurrentTime: primerAudio ? Number(primerAudio.currentTime.toFixed(3)) : null,
            ua: navigator.userAgent
          })
        }).catch(() => {});
      }

      function currentSceneGain() {
        return sceneGains[scene] ?? sceneGains.home;
      }

      function ensureContext() {
        if (ctx) return ctx;
        const AudioCtx = window.AudioContext || window.webkitAudioContext;
        ctx = new AudioCtx({ latencyHint: "interactive", sampleRate: 44100 });
        master = ctx.createGain();
        master.gain.value = soundEnabled ? currentSceneGain() : 0.0;
        master.connect(ctx.destination);
        ctx.onstatechange = () => {
          traceAudio(`statechange ${ctx.state}`);
        };
        traceAudio(`create state=${ctx.state} sr=${ctx.sampleRate}`);
        return ctx;
      }

      function kickstartContext() {
        if (!ctx) return;
        try {
          const buffer = ctx.createBuffer(1, 22050, 22050);
          const source = ctx.createBufferSource();
          source.buffer = buffer;
          source.connect(ctx.destination);
          source.start(0);
          traceAudio("kickstart");
        } catch (error) {
          traceAudio(`kickstart failed ${(error && error.message) || error || 'unknown'}`);
        }
      }

      function ensurePrimerAudio() {
        if (primerAudio) return primerAudio;
        primerAudio = new Audio("/audio/coin-primer.mp3?ts=" + Date.now());
        primerAudio.preload = "auto";
        primerAudio.playsInline = true;
        primerAudio.volume = primerGain;
        primerAudio.addEventListener("play", () => traceAudio("primer play"));
        primerAudio.addEventListener("ended", () => {
          traceAudio("primer ended");
          if (enteredGame && soundEnabled && ctx && ctx.state === "running") {
            kickstartContext();
            applyGain();
            traceAudio("primer ended handoff ok");
          }
        });
        primerAudio.addEventListener("error", () => {
          traceAudio(`primer error ${primerAudio?.error?.message || primerAudio?.error?.code || 'unknown'}`);
        });
        return primerAudio;
      }

      async function playPrimer(options = {}) {
        const waitForEnd = options.waitForEnd !== false;
        const holdMs = Math.max(0, Number(options.holdMs || 0));
        const volume = typeof options.volume === "number" ? options.volume : primerGain;
        const audio = ensurePrimerAudio();
        audio.muted = false;
        audio.volume = volume;
        try {
          audio.pause();
          audio.currentTime = 0;
          await audio.play();
          traceAudio("primer play request ok");
          if (waitForEnd) {
            await new Promise((resolve) => {
              let settled = false;
              const finish = (label) => {
                if (settled) return;
                settled = true;
                audio.removeEventListener("ended", onEnded);
                audio.removeEventListener("error", onError);
                window.clearTimeout(fallbackTimer);
                traceAudio(`primer wait ${label}`);
                resolve();
              };
              const onEnded = () => finish("ended");
              const onError = () => finish("error");
              const naturalFallbackMs = Math.max(1200, Math.round(((audio.duration || 0.9) * 1000) + 250));
              const waitMs = holdMs > 0 ? Math.max(naturalFallbackMs, holdMs) : naturalFallbackMs;
              const fallbackTimer = window.setTimeout(() => finish("timeout"), waitMs);
              audio.addEventListener("ended", onEnded, { once: true });
              audio.addEventListener("error", onError, { once: true });
            });
          }
        } catch (error) {
          traceAudio(`primer play failed ${(error && error.message) || error || 'unknown'}`);
        }
      }

      function applyGain() {
        if (master) {
          master.gain.value = soundEnabled ? currentSceneGain() : 0.0;
        }
        htmlDecks.forEach((audio, index) => {
          audio.volume = index === htmlDeckIndex ? htmlTargetGain() : 0.0;
        });
      }

      function setLabel() {
        exitButton.hidden = !!document.querySelector(".hero-card") && !enteredFromHub;
        toggle.hidden = false;
        toggle.textContent = soundEnabled ? "MUTE" : "UNMUTE";
      }

      function pulse(kind, freq, duration, gainValue, detune = 0) {
        if (!ctx || !master) return;
        const osc = ctx.createOscillator();
        const gain = ctx.createGain();
        osc.type = kind;
        osc.frequency.setValueAtTime(freq, ctx.currentTime);
        osc.detune.setValueAtTime(detune, ctx.currentTime);
        gain.gain.setValueAtTime(0.0001, ctx.currentTime);
        gain.gain.exponentialRampToValueAtTime(gainValue, ctx.currentTime + 0.01);
        gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + duration);
        osc.connect(gain);
        gain.connect(master);
        osc.start();
        osc.stop(ctx.currentTime + duration + 0.02);
      }

      function currentPattern() {
        if (scene === "thinking") return thinkingPattern;
        if (scene === "result") return resultPattern;
        if (scene === "category") return categoryPattern;
        return homePattern;
      }

      function currentLoopSrc(label) {
        const loopPath = scene === "category" || scene === "thinking" || scene === "result"
          ? "/audio/loop-category.wav"
          : "/audio/loop-home.wav";
        return `${loopPath}?deck=${label}&ts=${Date.now()}`;
      }

      function makeHtmlDeck(label) {
        const audio = new Audio(currentLoopSrc(label));
        audio.preload = "auto";
        audio.loop = false;
        audio.playsInline = true;
        audio.volume = 0.0;
        audio.addEventListener("play", () => traceAudio(`html deck ${label} play`));
        audio.addEventListener("pause", () => traceAudio(`html deck ${label} pause`));
        audio.addEventListener("ended", () => traceAudio(`html deck ${label} ended`));
        audio.addEventListener("error", () => {
          traceAudio(`html deck ${label} error ${audio?.error?.message || audio?.error?.code || 'unknown'}`);
        });
        return audio;
      }

      function ensureHtmlDecks() {
        if (htmlDecks.length === 2) return htmlDecks;
        htmlDecks = [makeHtmlDeck("A"), makeHtmlDeck("B")];
        return htmlDecks;
      }

      function waitForHtmlDeckReady(audio) {
        if (Number.isFinite(audio.duration) && audio.duration > 0 && audio.readyState >= 1) {
          return Promise.resolve();
        }
        return new Promise((resolve) => {
          let settled = false;
          const finish = () => {
            if (settled) return;
            settled = true;
            audio.removeEventListener("loadedmetadata", finish);
            audio.removeEventListener("canplaythrough", finish);
            audio.removeEventListener("error", finish);
            window.clearTimeout(fallbackTimer);
            resolve();
          };
          const fallbackTimer = window.setTimeout(finish, 1500);
          audio.addEventListener("loadedmetadata", finish, { once: true });
          audio.addEventListener("canplaythrough", finish, { once: true });
          audio.addEventListener("error", finish, { once: true });
          try {
            audio.load();
          } catch (_) {
            finish();
          }
        });
      }

      function fadeHtmlAudio(audio, from, to, durationMs) {
        const startedAt = performance.now();
        audio.volume = Math.max(0, Math.min(1, from));
        function tick(now) {
          const progress = Math.min(1, (now - startedAt) / Math.max(1, durationMs));
          const eased = progress < 0.5
            ? 2 * progress * progress
            : 1 - Math.pow(-2 * progress + 2, 2) / 2;
          audio.volume = Math.max(0, Math.min(1, from + ((to - from) * eased)));
          if (progress < 1) window.requestAnimationFrame(tick);
        }
        window.requestAnimationFrame(tick);
      }

      function stopHtmlLoop() {
        if (htmlCrossfadeTimer) {
          window.clearTimeout(htmlCrossfadeTimer);
          htmlCrossfadeTimer = null;
        }
        htmlDecks.forEach((audio) => {
          try {
            const currentVolume = Number.isFinite(audio.volume) ? audio.volume : 0.0;
            if (!audio.paused && currentVolume > 0.0) {
              fadeHtmlAudio(audio, currentVolume, 0.0, 45);
              window.setTimeout(() => {
                try {
                  audio.pause();
                  audio.currentTime = 0;
                  audio.volume = 0.0;
                } catch (_) {}
              }, 55);
            } else {
              audio.pause();
              audio.currentTime = 0;
              audio.volume = 0.0;
            }
          } catch (_) {}
        });
      }

      function resetHtmlDecks() {
        stopHtmlLoop();
        htmlDecks = [];
        htmlDeckIndex = 0;
      }

      function htmlTargetGain() {
        return soundEnabled && started && !bootstrapSilence ? htmlLoopGain : 0.0;
      }

      function scheduleHtmlCrossfade() {
        if (!started || !soundEnabled) return;
        if (htmlCrossfadeTimer) window.clearTimeout(htmlCrossfadeTimer);
        const decks = ensureHtmlDecks();
        const current = decks[htmlDeckIndex];
        const durationMs = Number.isFinite(current.duration) && current.duration > 0
          ? current.duration * 1000
          : 2880;
        const nextDelay = Math.max(120, durationMs - htmlCrossfadeMs);
        htmlCrossfadeTimer = window.setTimeout(async () => {
          if (!started || !soundEnabled) return;
          const nextIndex = (htmlDeckIndex + 1) % 2;
          const next = decks[nextIndex];
          try {
            next.pause();
            next.currentTime = 0;
            next.volume = 0.0;
            await next.play();
            fadeHtmlAudio(next, 0.0, htmlTargetGain(), htmlCrossfadeMs);
            fadeHtmlAudio(current, current.volume, 0.0, htmlCrossfadeMs);
            window.setTimeout(() => {
              if (started) {
                try {
                  current.pause();
                  current.currentTime = 0;
                } catch (_) {}
              }
            }, htmlCrossfadeMs + 40);
            htmlDeckIndex = nextIndex;
            traceAudio(`html xfade deck ${nextIndex === 0 ? "A" : "B"}`);
            scheduleHtmlCrossfade();
          } catch (error) {
            traceAudio(`html xfade failed ${(error && error.message) || error || 'unknown'}`);
          }
        }, nextDelay);
      }

      async function startHtmlLoop() {
        const decks = ensureHtmlDecks();
        await Promise.all(decks.map((audio) => waitForHtmlDeckReady(audio)));
        const first = decks[htmlDeckIndex];
        try {
          first.pause();
          first.currentTime = 0;
          first.volume = htmlTargetGain();
          await first.play();
          traceAudio("html loop running");
          scheduleHtmlCrossfade();
        } catch (error) {
          traceAudio(`html loop failed ${(error && error.message) || error || 'unknown'}`);
        }
      }

      function playStep() {
        if (!ctx || ctx.state !== "running" || !soundEnabled) {
          traceAudio(`step skipped state=${ctx ? ctx.state : 'none'} enabled=${soundEnabled}`);
          return;
        }
        const pattern = currentPattern();
        const item = pattern[step % pattern.length];
        if (scene === "thinking") {
          pulse("triangle", item.bass, 0.20, 0.10);
          pulse("triangle", item.bass * 2, 0.14, 0.07);
          pulse("triangle", item.lead, 0.18, 0.06);
          pulse("square", item.pulse || 659.25, 0.07, 0.045);
        } else if (scene === "result") {
          pulse("triangle", item.bass, 0.20, 0.10);
          pulse("sawtooth", item.bass * 2, 0.10, 0.07);
          pulse("triangle", item.lead, 0.18, 0.09);
          pulse("square", item.sparkle || 1046.5, 0.05, 0.05);
        } else if (scene === "category") {
          pulse("triangle", item.bass, 0.20, 0.11);
          pulse("triangle", item.bass * 2, 0.14, 0.08);
          pulse("triangle", item.lead, 0.16, 0.07);
        } else {
          pulse("triangle", item.bass, 0.22, 0.12);
          pulse("square", item.bass * 2, 0.08, 0.08);
          pulse("sawtooth", item.lead, 0.15, 0.10, step % 2 === 0 ? 3 : -3);
          if (step % 4 === 3) {
            pulse("square", 1320, 0.04, 0.05);
          }
        }
        step = (step + 1) % pattern.length;
      }

      function startTimerOnly() {
        if (timer) {
          window.clearInterval(timer);
        }
        timer = window.setInterval(playStep, stepMs);
      }

      async function startLoop() {
        ensureContext();
        if (bootstrapSilence && master) {
          master.gain.value = 0.0;
        } else {
          applyGain();
        }
        traceAudio(`startLoop pre state=${ctx.state} started=${started} enabled=${soundEnabled}`);
        if (ctx.state !== "running") {
          try {
            await ctx.resume();
            traceAudio(`resume ok state=${ctx.state}`);
          } catch (error) {
            traceAudio(`resume failed ${(error && error.message) || error || 'unknown'}`);
          }
        }
        kickstartContext();
        if (started) {
          traceAudio("startLoop ignored already-running");
          return;
        }
        started = true;
        await startHtmlLoop();
        traceAudio("loop running");
      }

      async function runBootstrapWarmup() {
        traceAudio("bootstrap begin");
        bootstrapSilence = true;
        try {
          ensureContext();
          if (ctx.state !== "running") {
            try {
              await ctx.resume();
              traceAudio(`bootstrap resume ok state=${ctx.state}`);
            } catch (error) {
              traceAudio(`bootstrap resume failed ${(error && error.message) || error || 'unknown'}`);
            }
          }
          kickstartContext();
          await playPrimer({ waitForEnd: false, holdMs: 180, volume: 0.0 });
          await rebuildAudioContext("bootstrap");
          await startLoop();
          await new Promise((resolve) => window.setTimeout(resolve, 220));
          await stopMusic();
          if (primerAudio) {
            try {
              primerAudio.pause();
              primerAudio.currentTime = 0;
            } catch (_) {}
          }
          traceAudio("bootstrap done");
        } finally {
          bootstrapSilence = false;
          applyGain();
        }
      }

      async function stopMusic() {
        if (timer) {
          window.clearInterval(timer);
          timer = null;
        }
        stopHtmlLoop();
        started = false;
        traceAudio("stop");
        setLabel();
      }

      function setScene(nextScene) {
        const previousScene = scene;
        scene = nextScene || "home";
        traceAudio(`scene ${scene}`);
        applyGain();
        if (started && scene !== previousScene) {
          resetHtmlDecks();
          startHtmlLoop();
        }
      }

      async function rebuildAudioContext(reason) {
        traceAudio(`rebuild begin ${reason}`);
        const shouldResumeLoop = started;
        if (timer) {
          window.clearInterval(timer);
          timer = null;
        }
        if (ctx) {
          try {
            await ctx.close();
            traceAudio("rebuild close ok");
          } catch (error) {
            traceAudio(`rebuild close failed ${(error && error.message) || error || 'unknown'}`);
          }
        }
        ctx = null;
        master = null;
        ensureContext();
        if (ctx.state !== "running") {
          try {
            await ctx.resume();
            traceAudio(`rebuild resume ok state=${ctx.state}`);
          } catch (error) {
            traceAudio(`rebuild resume failed ${(error && error.message) || error || 'unknown'}`);
          }
        }
        kickstartContext();
        applyGain();
        if (shouldResumeLoop && soundEnabled && ctx.state === "running") {
          started = true;
          playStep();
          startTimerOnly();
          traceAudio("rebuild loop restored");
        }
      }

      function armRecovery(reason) {
        if (recoveryArmed) return;
        recoveryArmed = true;
        traceAudio(`recovery armed ${reason}`);
      }

      async function recoverAudio(reason) {
        if (!ctx || recovering) return;
        recovering = true;
        traceAudio(`recover begin ${reason} state=${ctx.state}`);
        try {
          if (ctx.state !== "running") {
            try {
              await ctx.resume();
              traceAudio(`recover resume ok state=${ctx.state}`);
            } catch (error) {
              traceAudio(`recover resume failed ${(error && error.message) || error || 'unknown'}`);
            }
          } else {
            try {
              await ctx.suspend();
              traceAudio(`recover suspend ok state=${ctx.state}`);
            } catch (error) {
              traceAudio(`recover suspend failed ${(error && error.message) || error || 'unknown'}`);
            }
            try {
              await ctx.resume();
              traceAudio(`recover re-resume ok state=${ctx.state}`);
            } catch (error) {
              traceAudio(`recover re-resume failed ${(error && error.message) || error || 'unknown'}`);
            }
          }
          kickstartContext();
          applyGain();
          if (started && soundEnabled && ctx.state === "running" && !timer) {
            playStep();
            startTimerOnly();
            traceAudio("recover loop timer restored");
          }
          if (ctx.state !== "running") {
            armRecovery(`${reason} state=${ctx.state}`);
          }
        } finally {
          recovering = false;
        }
      }

      async function updateSoundState(nextEnabled) {
        soundEnabled = nextEnabled;
        window.localStorage.setItem(storageKey, soundEnabled ? "1" : "0");
        applyGain();
        if (!soundEnabled) {
          traceAudio("sound disabled");
          setLabel();
          return;
        }
        traceAudio("sound enabled");
        if (enteredGame) {
          await startLoop();
        }
        setLabel();
      }

      function playWonkCue() {
        if (!soundEnabled || !ctx || ctx.state !== "running") return;
        pulse("sawtooth", 330.0, 0.18, 0.10);
        window.setTimeout(() => {
          if (!ctx || !soundEnabled) return;
          pulse("sawtooth", 246.94, 0.22, 0.09);
        }, 150);
      }

      function playCoinCue() {
        if (!soundEnabled || !ctx || ctx.state !== "running") return;
        pulse("square", 1760.0, 0.06, 0.08);
        window.setTimeout(() => {
          if (!ctx || !soundEnabled) return;
          pulse("square", 1318.51, 0.10, 0.09);
        }, 50);
      }

      function playRevealCue() {
        if (!soundEnabled || !ctx || ctx.state !== "running") return;
        pulse("triangle", 523.25, 0.16, 0.11);
        window.setTimeout(() => {
          if (!ctx || !soundEnabled) return;
          pulse("triangle", 659.25, 0.18, 0.12);
        }, 100);
        window.setTimeout(() => {
          if (!ctx || !soundEnabled) return;
          pulse("triangle", 783.99, 0.24, 0.14);
        }, 220);
      }

      window.blameMachineAudio = {
        setScene,
        playRevealCue,
        playWonkCue,
        playCoinCue,
        setEntered(nextEntered) {
          enteredGame = !!nextEntered;
          traceAudio(`setEntered ${enteredGame}`);
          if (enteredGame) {
            window.sessionStorage.setItem(enteredKey, "1");
          } else {
            stopHtmlLoop();
            started = false;
            window.sessionStorage.removeItem(enteredKey);
            primed = false;
            primePromise = null;
            if (primerAudio) {
              try {
                primerAudio.pause();
                primerAudio.currentTime = 0;
              } catch (_) {}
            }
          }
          setLabel();
        },
        refreshControls() {
          setLabel();
        },
        async setEnabled(nextEnabled) {
          traceAudio(`setEnabled ${!!nextEnabled}`);
          await updateSoundState(!!nextEnabled);
        },
        async primeStart(options = {}) {
          const waitForPrimerEnd = options.waitForPrimerEnd !== false;
          const primerHoldMs = Math.max(0, Number(options.primerHoldMs || 0));
          soundEnabled = true;
          window.localStorage.setItem(storageKey, "1");
          if (primePromise) return primePromise;
          primed = true;
          primePromise = (async () => {
            traceAudio(`primeStart begin waitForPrimerEnd=${waitForPrimerEnd} primerHoldMs=${primerHoldMs}`);
            ensureContext();
            if (ctx.state !== "running") {
              try {
                await ctx.resume();
                traceAudio(`primeStart resume ok state=${ctx.state}`);
              } catch (error) {
                traceAudio(`primeStart resume failed ${(error && error.message) || error || 'unknown'}`);
              }
            }
            kickstartContext();
            await playPrimer({ waitForEnd: waitForPrimerEnd, holdMs: primerHoldMs });
            // Start the synth loop immediately after coin drop, while the context is still live.
            await startLoop();
            traceAudio("primeStart done");
          })();
          return primePromise;
        },
        async pause() {
          await stopMusic();
        },
        async enableAndStart(options = {}) {
          const skipPrimer = !!options.skipPrimer;
          const suppressPrimer = !!options.suppressPrimer;
          const waitForPrimerEnd = options.waitForPrimerEnd !== false;
          const primerHoldMs = Math.max(0, Number(options.primerHoldMs || 0));
          traceAudio(`enableAndStart begin primed=${primed} skipPrimer=${skipPrimer} suppressPrimer=${suppressPrimer} waitForPrimerEnd=${waitForPrimerEnd} primerHoldMs=${primerHoldMs}`);
          bootingAudio = true;
          try {
            soundEnabled = true;
            window.localStorage.setItem(storageKey, "1");
            ensureContext();
            if (ctx.state !== "running") {
              try {
                await ctx.resume();
                traceAudio(`enableAndStart early resume ok state=${ctx.state}`);
              } catch (error) {
                traceAudio(`enableAndStart early resume failed ${(error && error.message) || error || 'unknown'}`);
              }
            }
            kickstartContext();
            if (skipPrimer || suppressPrimer) {
              // Context is already armed above; hub-origin launches already played the cabinet coin.
            } else {
              await playPrimer({ waitForEnd: waitForPrimerEnd, holdMs: primerHoldMs });
            }
            bootstrapped = true;
            kickstartContext();
            await startLoop();
            traceAudio(`enableAndStart done started=${started}`);
          } finally {
            bootingAudio = false;
          }
        },
      };

      toggle.addEventListener("pointerdown", (event) => {
        event.stopPropagation();
      });

      toggle.addEventListener("keydown", (event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.stopPropagation();
        }
      });

      toggle.addEventListener("click", async (event) => {
        event.preventDefault();
        if (!soundEnabled) {
          await updateSoundState(true);
          return;
        }
        await updateSoundState(false);
      });

      window.addEventListener("blame-machine-state", (event) => {
        const detail = event.detail || {};
        setScene(detail.scene || "home");
      });
      document.addEventListener("pointerdown", async (event) => {
        if (bootingAudio) return;
        if (!enteredGame || !soundEnabled || started) return;
        if (event.target?.closest?.("#sound-toggle, #exit-game")) return;
        traceAudio("pointerdown restart hook");
        try {
          await startLoop();
        } catch (_error) {
          // Keep the interaction flowing; subsequent taps can retry.
        }
      }, { passive: true, capture: true });
      window.addEventListener("touchend", async () => {
        if (!recoveryArmed) return;
        recoveryArmed = false;
        await rebuildAudioContext("touchend");
      }, { passive: true, capture: true });
      document.addEventListener("visibilitychange", async () => {
        traceAudio(`visibility ${document.visibilityState}`);
        if (document.visibilityState === "visible" && ctx) {
          await recoverAudio("visibility");
        }
      });
      setLabel();
    })();
  </script>
"""

APP_SHELL_SCRIPT = """
  <script>
    (() => {
      const parser = new DOMParser();

      async function loadIntoShell(url, options = {}, push = true) {
        const response = await window.fetch(url, {
          credentials: "same-origin",
          redirect: "follow",
          ...options
        });

        const html = await response.text();
        const doc = parser.parseFromString(html, "text/html");
        const nextWrap = doc.querySelector(".wrap");
        const currentWrap = document.querySelector(".wrap");

        if (!nextWrap || !currentWrap) {
          window.location.assign(url);
          return;
        }

        currentWrap.replaceWith(nextWrap);

        if (doc.title) {
          document.title = doc.title;
        }

        if (push) {
          const nextUrl = new URL(response.url || url, window.location.origin);
          window.history.pushState({}, "", nextUrl.pathname + nextUrl.search);
        }

        window.scrollTo({ top: 0, behavior: "auto" });
      }

      document.addEventListener("click", async (event) => {
        const link = event.target.closest('a.button-link[href]');
        if (!link) return;

        const href = link.getAttribute("href");
        if (!href || !href.startsWith("/")) return;

        event.preventDefault();
        try {
          await loadIntoShell(link.href, { method: "GET" });
        } catch (_error) {
          window.location.assign(link.href);
        }
      });

      document.addEventListener("submit", async (event) => {
        const form = event.target;
        if (!(form instanceof HTMLFormElement)) return;

        const action = form.getAttribute("action") || window.location.pathname;
        if (!action.startsWith("/")) return;

        event.preventDefault();
        const method = (form.getAttribute("method") || "GET").toUpperCase();

        try {
          if (method === "GET") {
            const formUrl = new URL(action, window.location.origin);
            const params = new URLSearchParams(new FormData(form));
            formUrl.search = params.toString();
            await loadIntoShell(formUrl.toString(), { method: "GET" });
            return;
          }

          await loadIntoShell(new URL(action, window.location.origin).toString(), {
            method,
            body: new FormData(form)
          });
        } catch (_error) {
          form.submit();
        }
      });

      window.addEventListener("popstate", async () => {
        try {
          await loadIntoShell(window.location.href, { method: "GET" }, false);
        } catch (_error) {
          window.location.reload();
        }
      });
    })();
  </script>
"""

GAME_SHELL_SCRIPT = """
  <script>
    (() => {
      const root = document.getElementById("app-root");
      if (!root) return;

      const stateUrl = "/api/state";
      const enteredKey = "blameMachineEntered";
      const backTrapKey = "blameMachineBackTrap";
      const inactivityResetMs = 300 * 1000;
      let resultReturnTimer = null;
      let resultCountdownTimer = null;
      let inactivityResetTimer = null;
      let titleActionLocked = false;
      let turnstileScriptPromise = null;
      let turnstileWidgetId = null;
      let turnstileOverlay = null;
      let turnstilePendingToken = null;
      let currentState = null;

      function armBackTrap() {
        if (window.history.state && window.history.state[backTrapKey]) return;
        window.history.replaceState({ [backTrapKey]: "root" }, "", window.location.href);
        window.history.pushState({ [backTrapKey]: "guard" }, "", window.location.href);
        window.addEventListener("popstate", () => {
          window.history.pushState({ [backTrapKey]: "guard" }, "", window.location.href);
        });
      }

      function escapeHtml(value) {
        return String(value ?? "")
          .replaceAll("&", "&amp;")
          .replaceAll("<", "&lt;")
          .replaceAll(">", "&gt;")
          .replaceAll('"', "&quot;")
          .replaceAll("'", "&#39;");
      }

      function renderErrorCard(state) {
        if (!state.error) return "";
        let message = "NICE TRY. THAT ENTRY CAN'T GO IN THE MACHINE.";
        if (state.error === "empty_category") {
          message = "THE CABINET NEEDS A TOPIC NAME FIRST.";
        } else if (state.error === "invalid") {
          message = "THAT TOPIC NAME WON'T FIT IN THE CABINET. TRY LETTERS, NUMBERS, OR SPACES.";
        } else if (state.error === "duplicate") {
          message = "BRUH! YOU ALREADY LOADED THAT...TRY AGAIN.";
        } else if (state.error === "duplicate_choices") {
          message = "THOSE CHOICES ARE TOO ALIKE. DISTINGUISH THEM SO THE MACHINE CAN TELL THEM APART.";
        } else if (state.error === "blocked") {
          message = "THAT CHOICE TRIPS THE MACHINE'S FILTER. TRY A DIFFERENT ENTRY.";
        } else if (state.error === "forbidden") {
          message = "THE MACHINE REJECTED THAT REQUEST. REFRESH THE PAGE AND TRY AGAIN.";
        } else if (state.error === "human_check_required") {
          message = "ONE QUICK HUMAN CHECK BEFORE THE CABINET TAKES REQUESTS.";
        }
        return `
          <div class="card error-card">
            ${message}
          </div>
        `;
      }

      function renderChoices(choices) {
        if (!choices.length) {
          return `<div class="pill">NO CHOICES YET</div>`;
        }
        return choices.map((item) => `<div class="pill">${escapeHtml(item)}</div>`).join("");
      }

      function renderPlayerRef(state) {
        if (!state.player_label) return "";
        return `
          <div class="player-banner">${escapeHtml(state.player_label)}</div>
        `;
      }

      function renderHome(state) {
        const topicCount = state.categories.length;
        const tunerDisabled = topicCount < 2 ? "disabled" : "";
        const categoryCard = state.categories.length ? `
          <div class="card">
            <form data-api="open-category">
              <div class="row">
                <label>PICK A TOPIC</label>
                <div class="topic-tuner">
                  <button type="button" class="topic-tuner-button" data-topic-shift="-1" aria-label="PREVIOUS TOPIC" ${tunerDisabled}>&lt;</button>
                  <div class="topic-tuner-display" data-topic-display>${escapeHtml(state.categories[0].replace(/\\b\\w/g, (c) => c.toUpperCase()))}</div>
                  <button type="button" class="topic-tuner-button" data-topic-shift="1" aria-label="NEXT TOPIC" ${tunerDisabled}>&gt;</button>
                </div>
                <div class="topic-tuner-meta" data-topic-meta>TOPIC 1 OF ${topicCount}</div>
                <input type="hidden" name="category" value="${escapeHtml(state.categories[0])}" data-topic-input>
                <div hidden>
                  ${state.categories.map((item) => `<span data-topic-option="${escapeHtml(item)}" data-topic-label="${escapeHtml(item.replace(/\\b\\w/g, (c) => c.toUpperCase()))}"></span>`).join("")}
                </div>
              </div>
              <button type="submit">OPEN TOPIC</button>
            </form>
          </div>
        ` : "";

        const accessCard = state.access_url ? `
          <details class="card access-box">
            <summary>PLAY ON ANOTHER DEVICE</summary>
            <div class="access-box-body">
              <div class="small">${escapeHtml(state.access_message)}</div>
              <code>${escapeHtml(state.access_url)}</code>
            </div>
          </details>
        ` : "";

        const label = state.categories.length
          ? "OR CREATE A NEW TOPIC"
          : "FIRST, WHAT TOPIC DO YOU NEED MY HELP WITH?";
        const button = state.categories.length ? "MAKE NEW TOPIC" : "LOAD THE MACHINE";

        const clearCard = state.categories.length ? `
          <div class="card" style="text-align:center;">
            <div class="prompt-copy">${escapeHtml(state.clear_arcade_description)}</div>
            <div class="button-stack" style="margin-top:18px;">
              <a class="button-link" href="#" data-nav="clear">CLEAR MY ARCADE</a>
            </div>
          </div>
        ` : "";

        return `
          <div class="title">★ BLAME MACHINE ★</div>
          <div class="sub">INSERT COIN. BLAME THE MACHINE.</div>
          ${renderErrorCard(state)}
          ${categoryCard}
          ${accessCard}
          <div class="card">
            <form data-api="category">
              <div class="row">
                <label class="center-label">${label}</label>
                <input name="category" placeholder="example: boy, bff, what should i do">
              </div>
              <button type="submit">${button}</button>
            </form>
          </div>
          ${clearCard}
          ${renderPlayerRef(state)}
        `;
      }

      function renderTitle() {
        window.blameMachineAudio?.setEntered?.(false);
        window.setTimeout(() => window.blameMachineAudio?.refreshControls?.(), 0);
        return `
          <div class="hero-card card">
            <div class="title-bar">
              <div class="title">★ BLAME MACHINE ★</div>
              <div class="sub">INSERT COIN. BLAME THE MACHINE.</div>
              <div class="hero-actions">
                <button type="button" class="hero-play-button" data-nav="start">PRESS PLAY</button>
                <a class="quiet-link" href="#" data-nav="silent">START IN SILENT MODE</a>
              </div>
            </div>
            <div class="title-content">
              <div class="hero-copy">
                READY PLAYER 1.<br>
                THE CABINET IS WARM.<br>
                TRY NOT TO BLAME THE MACHINE FOR WHAT HAPPENS NEXT.
              </div>
            </div>
          </div>
        `;
      }

      function renderClear(state) {
        return `
          <div class="title">★ CLEAR MY ARCADE ★</div>
          <div class="sub">LAST CHANCE TO SAVE THE RECEIPTS.</div>
          <div class="card" style="text-align:center;">
            <div class="prompt-copy">${escapeHtml(state.clear_arcade_description)}</div>
            <div class="button-stack" style="margin-top:18px;">
              <form data-api="clear">
                <button type="submit">YES, CLEAR MY ARCADE</button>
              </form>
              <a class="button-link" href="#" data-nav="home">NEVER MIND</a>
            </div>
          </div>
          ${renderPlayerRef(state)}
        `;
      }

      function renderCategory(state) {
        const editCard = state.mode === "edit" ? `
          <div class="card">
            <form data-api="save" data-category="${escapeHtml(state.category)}">
              <div class="row">
                <label>NOW TELL ME YOUR OPTIONS FOR ${escapeHtml(state.category_display).toUpperCase()} AND BE ENLIGHTENED.</label>
                <textarea name="choices" placeholder="pizza, tacos&#10;or one per line">${escapeHtml(state.choices)}</textarea>
                <div class="input-helper">COMMA-SEPARATED OR EACH IN A NEW LINE</div>
              </div>
              <button type="submit">PLAY!</button>
            </form>
            <div class="back-link">
              <a class="button-link" href="#" data-nav="home">← BACK TO ARCADE</a>
            </div>
          </div>
        ` : "";

        const readyCard = state.mode === "ready" ? `
          <div class="card">
            <div class="small">CURRENT CHOICES</div>
            <div class="choices">${renderChoices(state.choices_list)}</div>
            <div class="meta">UNIQUE PICKS: ${state.choice_count}</div>
            <div class="button-stack" style="margin-top:18px;">
              ${state.can_decide ? `
                <form data-api="decide" data-category="${escapeHtml(state.category)}">
                  <button type="submit">SPIN THE MACHINE</button>
                </form>
              ` : `
                <div class="meta">ONE CHOICE ISN'T A DECISION. GIVE ME AT LEAST TWO.</div>
              `}
              <a class="button-link" href="#" data-nav="edit" data-category="${escapeHtml(state.category)}">EDIT TOPIC</a>
              <a class="button-link" href="#" data-nav="home">← BACK TO ARCADE</a>
            </div>
          </div>
        ` : "";

        const resultCard = state.mode === "result" ? `
          <div class="card">
            <div class="result-box">
              <div class="result-label">DECISION</div>
              <div class="result-text">${escapeHtml(state.result)}</div>
              ${state.used_wildcard ? '<div class="wildcard">WILDCARD HIT</div>' : ""}
            </div>
            <div class="meta result-return" data-return-seconds="${state.auto_return_seconds}">RETURNING TO THE ARCADE IN ${state.auto_return_seconds} SECONDS.</div>
            <div class="button-stack result-actions" style="margin-top:18px;">
              <form data-api="decide" data-category="${escapeHtml(state.category)}">
                <button type="submit">REROLL</button>
              </form>
              <a class="button-link" href="#" data-nav="edit" data-category="${escapeHtml(state.category)}">EDIT TOPIC</a>
              <a class="button-link" href="#" data-nav="home">← BACK TO ARCADE</a>
            </div>
          </div>
        ` : "";

        return `
          <div class="title">★ ${escapeHtml(state.category_display).toUpperCase()} ★</div>
          <div class="sub">${escapeHtml(state.subtitle)}</div>
          ${renderErrorCard(state)}
          ${editCard}
          ${readyCard}
          ${resultCard}
          ${renderPlayerRef(state)}
        `;
      }

      function renderThinking(state) {
        return `
          <div class="title">★ ${escapeHtml(state.category_display).toUpperCase()} ★</div>
          <div class="sub">THE MACHINE IS THINKING...</div>
          <div class="card">
            <div class="result-box">
              <div class="result-label">PROCESSING</div>
              <div class="result-text">...</div>
              <div class="meta">DO NOT SHAKE THE CABINET.</div>
            </div>
          </div>
          ${renderPlayerRef(state)}
        `;
      }

      function clearInactivityReset() {
        if (inactivityResetTimer) {
          window.clearTimeout(inactivityResetTimer);
          inactivityResetTimer = null;
        }
      }

      function shouldUseInactivityReset(state) {
        if (!state || state.view === "home" || state.view === "title") return false;
        if (state.view === "category" && state.mode === "result") return false;
        return true;
      }

      async function returnToArcadeHome() {
        clearInactivityReset();
        window.history.pushState({}, "", "/game");
        await fetchState();
      }

      function armInactivityReset() {
        clearInactivityReset();
        if (!shouldUseInactivityReset(currentState)) return;
        inactivityResetTimer = window.setTimeout(async () => {
          await returnToArcadeHome();
        }, inactivityResetMs);
      }

      function noteActivity() {
        if (!inactivityResetTimer) return;
        armInactivityReset();
      }

      function render(state) {
        currentState = state;
        if (resultReturnTimer) {
          window.clearTimeout(resultReturnTimer);
          resultReturnTimer = null;
        }
        if (resultCountdownTimer) {
          window.clearInterval(resultCountdownTimer);
          resultCountdownTimer = null;
        }
        window.blameMachineAudio?.setEntered?.(true);
        window.dispatchEvent(new CustomEvent("blame-machine-state", { detail: state }));
        if (state.view === "clear") {
          root.innerHTML = renderClear(state);
          armInactivityReset();
          return;
        }
        if (state.view === "thinking") {
          root.innerHTML = renderThinking(state);
          armInactivityReset();
          return;
        }
        if (state.view === "category") {
          root.innerHTML = renderCategory(state);
          if (state.mode === "result" && Number(state.auto_return_seconds) > 0) {
            const returnNote = root.querySelector("[data-return-seconds]");
            let secondsLeft = Number(state.auto_return_seconds);
            if (returnNote instanceof HTMLElement) {
              const updateReturnNote = () => {
                const suffix = secondsLeft === 1 ? "" : "S";
                returnNote.textContent = `RETURNING TO THE ARCADE IN ${secondsLeft} SECOND${suffix}.`;
              };
              updateReturnNote();
              resultCountdownTimer = window.setInterval(() => {
                secondsLeft = Math.max(0, secondsLeft - 1);
                updateReturnNote();
              }, 1000);
            }
            resultReturnTimer = window.setTimeout(async () => {
              window.history.pushState({}, "", "/game");
              await fetchState();
            }, Number(state.auto_return_seconds) * 1000);
          }
          armInactivityReset();
          return;
        }
        root.innerHTML = renderHome(state);
        armInactivityReset();
      }

      function showTitle() {
        window.blameMachineAudio?.setEntered?.(false);
        root.innerHTML = renderTitle();
        wireTitleControls();
      }

      function markEntered() {
        window.sessionStorage.setItem(enteredKey, "1");
        window.blameMachineAudio?.setEntered?.(true);
      }

      async function fetchState(query = "") {
        const response = await fetch(`${stateUrl}${query}`, { credentials: "same-origin" });
        const state = await response.json();
        render(state);
        return state;
      }

      function closeTurnstileGate() {
        if (turnstileOverlay instanceof HTMLElement) {
          turnstileOverlay.remove();
        }
        turnstileOverlay = null;
        turnstilePendingToken = null;
      }

      function loadTurnstileScript() {
        if (window.turnstile) {
          return Promise.resolve(window.turnstile);
        }
        if (turnstileScriptPromise) {
          return turnstileScriptPromise;
        }
        turnstileScriptPromise = new Promise((resolve, reject) => {
          const script = document.createElement("script");
          script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
          script.async = true;
          script.defer = true;
          script.onload = () => resolve(window.turnstile);
          script.onerror = () => reject(new Error("turnstile_load_failed"));
          document.head.appendChild(script);
        });
        return turnstileScriptPromise;
      }

      async function ensureTurnstileToken(details) {
        if (!details.turnstile_site_key) {
          throw new Error("turnstile_missing_site_key");
        }
        if (turnstilePendingToken) {
          return turnstilePendingToken;
        }

        const turnstileApi = await loadTurnstileScript();
        if (!(turnstileOverlay instanceof HTMLElement)) {
          turnstileOverlay = document.createElement("div");
          turnstileOverlay.className = "turnstile-overlay";
          turnstileOverlay.innerHTML = `
            <div class="card turnstile-card">
              <div class="title">★ HUMAN CHECK ★</div>
              <div class="turnstile-copy">PROVE YOU'RE A REAL PERSON ONCE, THEN THE MACHINE LEAVES YOU ALONE FOR A WHILE.</div>
              <div class="turnstile-slot" id="turnstile-slot"></div>
              <div class="turnstile-help">IF THE CHALLENGE EXPIRES, THE CABINET WILL ASK AGAIN.</div>
            </div>
          `;
          document.body.appendChild(turnstileOverlay);
        }

        const slot = turnstileOverlay.querySelector("#turnstile-slot");
        if (!(slot instanceof HTMLElement)) {
          throw new Error("turnstile_slot_missing");
        }
        slot.innerHTML = "";

        turnstilePendingToken = await new Promise((resolve, reject) => {
          try {
            turnstileWidgetId = turnstileApi.render(slot, {
              sitekey: details.turnstile_site_key,
              action: details.turnstile_action || "start_play",
              theme: "dark",
              callback: (token) => resolve(token),
              "expired-callback": () => {
                turnstilePendingToken = null;
                if (turnstileWidgetId !== null) {
                  turnstileApi.reset(turnstileWidgetId);
                }
              },
              "error-callback": () => {
                turnstilePendingToken = null;
                reject(new Error("turnstile_failed"));
              },
            });
          } catch (error) {
            reject(error);
          }
        });

        return turnstilePendingToken;
      }

      async function postJson(url, payload, options = {}) {
        const outgoing = { ...payload };
        if (options.turnstileToken) {
          outgoing.turnstile_token = options.turnstileToken;
        }
        const response = await fetch(url, {
          method: "POST",
          credentials: "same-origin",
          headers: {
            "Content-Type": "application/json"
          },
          body: JSON.stringify(outgoing)
        });
        const state = await response.json();
        if (state.turnstile_required) {
          const token = await ensureTurnstileToken(state);
          return postJson(url, payload, { ...options, turnstileToken: token, retryingTurnstile: true });
        }
        closeTurnstileGate();
        if (options.renderResponse !== false) {
          render(state);
        }
        return state;
      }

      async function runTitleAction(action) {
        if (titleActionLocked) return;
        titleActionLocked = true;
        const startButton = root.querySelector("[data-nav='start']");
        const silentLink = root.querySelector("[data-nav='silent']");
        if (startButton instanceof HTMLButtonElement) {
          startButton.disabled = true;
          startButton.classList.add("is-loading");
          startButton.textContent = action === "silent" ? "ENTERING STEALTH..." : "WAKING CABINET...";
        }
        if (silentLink instanceof HTMLElement) {
          silentLink.setAttribute("aria-disabled", "true");
          silentLink.style.pointerEvents = "none";
          silentLink.style.opacity = "0.55";
        }
        window.setTimeout(() => {
          titleActionLocked = false;
        }, 500);

        if (action === "start") {
          try {
            await window.blameMachineAudio?.enableAndStart?.({
              skipPrimer: false,
              suppressPrimer: false,
              waitForPrimerEnd: true,
              primerHoldMs: 650,
            });
          } catch (_) {
            // Keep the UI moving even if audio startup is flaky on mobile.
          }
          markEntered();
          window.history.replaceState({}, "", "/game");
          await fetchState();
          return;
        }

        if (action === "silent") {
          markEntered();
          await window.blameMachineAudio?.setEnabled?.(false);
          window.history.replaceState({}, "", "/game");
          await fetchState();
        }
      }

      async function restoreEnteredSession() {
        window.blameMachineAudio?.setEntered?.(true);
        try {
          await fetchState(window.location.search);
        } catch (_error) {
          window.sessionStorage.removeItem(enteredKey);
          window.blameMachineAudio?.setEntered?.(false);
          showTitle();
          return;
        }
        try {
          await window.blameMachineAudio?.enableAndStart?.({ skipPrimer: true });
        } catch (_error) {
          // A later gameplay tap can still revive audio if iPhone declines to resume on reload.
        }
      }

      document.addEventListener("click", async (event) => {
        noteActivity();
        const tunerButton = event.target.closest("[data-topic-shift]");
        if (tunerButton) {
          event.preventDefault();
          const form = tunerButton.closest("form");
          const input = form?.querySelector("[data-topic-input]");
          const display = form?.querySelector("[data-topic-display]");
          const meta = form?.querySelector("[data-topic-meta]");
          if (
            !(form instanceof HTMLFormElement) ||
            !(input instanceof HTMLInputElement) ||
            !(display instanceof HTMLElement)
          ) {
            return;
          }
          const options = Array.from(form.querySelectorAll("[data-topic-option]")).map((node) => ({
            value: node.getAttribute("data-topic-option") || "",
            label: node.getAttribute("data-topic-label") || "",
          }));
          const fallbackOptions = options.length ? options : [];
          if (!fallbackOptions.length) {
            return;
          }
          const currentIndex = Math.max(0, fallbackOptions.findIndex((item) => item.value === input.value));
          const delta = Number(tunerButton.getAttribute("data-topic-shift") || "0");
          const nextIndex = (currentIndex + delta + fallbackOptions.length) % fallbackOptions.length;
          input.value = fallbackOptions[nextIndex].value;
          display.textContent = fallbackOptions[nextIndex].label;
          if (meta instanceof HTMLElement) {
            meta.textContent = `TOPIC ${nextIndex + 1} OF ${fallbackOptions.length}`;
          }
          return;
        }

        const link = event.target.closest("[data-nav]");
        if (link) {
          event.preventDefault();

          const nav = link.getAttribute("data-nav");
          if (nav === "home") {
            window.history.pushState({}, "", "/game");
            await fetchState();
            return;
          }
          if (nav === "clear") {
            window.history.pushState({}, "", "/game?view=clear");
            await fetchState("?view=clear");
            return;
          }
          if (nav === "edit") {
            const category = link.getAttribute("data-category");
            const query = `?view=category&category=${encodeURIComponent(category)}&mode=edit`;
            window.history.pushState({}, "", `/game${query}`);
            await fetchState(query);
            return;
          }
        }

        const exit = event.target.closest("#exit-game");
        if (!exit) return;
        event.preventDefault();
        if (document.querySelector(".hero-card")) {
          if (window.blameMachineEnteredFromHub === true) {
            window.sessionStorage.removeItem("blameMachineHubOrigin");
            window.sessionStorage.removeItem("blameMachineHubReturnUrl");
            window.location.href = window.blameMachineHubReturnUrl || "https://games.irq9.net/";
          }
          return;
        }
        await window.blameMachineAudio?.pause?.();
        window.blameMachineAudio?.setEntered?.(false);
        window.history.replaceState({}, "", "/game");
        showTitle();
      });

      document.addEventListener("submit", async (event) => {
        noteActivity();
        const form = event.target;
        if (!(form instanceof HTMLFormElement)) return;
        const action = form.getAttribute("data-api");
        if (!action) return;
        event.preventDefault();

        if (action === "open-category") {
          const category = String(new FormData(form).get("category") || "").trim();
          const query = `?view=category&category=${encodeURIComponent(category)}&mode=ready`;
          window.history.pushState({}, "", `/game${query}`);
          await fetchState(query);
          return;
        }

        if (action === "category") {
          const category = String(new FormData(form).get("category") || "").trim();
          const state = await postJson("/api/category", { category });
          if (state.view === "category") {
            const query = `?view=category&category=${encodeURIComponent(state.category)}&mode=${encodeURIComponent(state.mode)}`;
            window.history.pushState({}, "", `/game${query}`);
          }
          return;
        }

        if (action === "save") {
          const category = form.getAttribute("data-category");
          const choices = String(new FormData(form).get("choices") || "");
          const state = await postJson("/api/save", { category, choices });
          const query = `?view=category&category=${encodeURIComponent(state.category)}&mode=${encodeURIComponent(state.mode)}`;
          window.history.pushState({}, "", `/game${query}`);
          return;
        }

        if (action === "decide") {
          const category = form.getAttribute("data-category");
          const categoryDisplay = category.replace(/\\b\\w/g, (c) => c.toUpperCase());
          const holdingState = {
            view: "thinking",
            scene: "thinking",
            category,
            category_display: categoryDisplay,
            player_label: root.querySelector(".player-banner")?.textContent || "",
          };
          render(holdingState);
          const state = await postJson("/api/decide", { category }, { renderResponse: false });
          if (state.result_kind === "too_soon") {
            // Wonk (descending sawtooth) is intentional here — signals the machine rejecting a hasty reroll.
            window.blameMachineAudio?.playWonkCue?.();
            render(state);
          } else {
            await new Promise((resolve) => window.setTimeout(resolve, 3000));
            window.blameMachineAudio?.playRevealCue?.();
            render(state);
          }
          const query = `?view=category&category=${encodeURIComponent(state.category)}&mode=${encodeURIComponent(state.mode)}`;
          window.history.pushState({}, "", `/game${query}`);
          return;
        }

        if (action === "clear") {
          window.history.pushState({}, "", "/game");
          await postJson("/api/clear", {});
        }
      });

      window.addEventListener("popstate", async () => {
        if (window.location.pathname !== "/game" || window.sessionStorage.getItem(enteredKey) !== "1") {
          showTitle();
          return;
        }
        const url = new URL(window.location.href);
        const view = url.searchParams.get("view");
        const category = url.searchParams.get("category");
        const mode = url.searchParams.get("mode");
        const query = new URLSearchParams();
        if (view) query.set("view", view);
        if (category) query.set("category", category);
        if (mode) query.set("mode", mode);
        const suffix = query.toString() ? `?${query.toString()}` : "";
        await fetchState(suffix);
      });

      document.addEventListener("input", noteActivity, { capture: true });
      document.addEventListener("keydown", noteActivity, { capture: true });
      document.addEventListener("pointerdown", noteActivity, { passive: true, capture: true });
      document.addEventListener("touchstart", noteActivity, { passive: true, capture: true });

      armBackTrap();
      if (window.location.pathname === "/game" && window.sessionStorage.getItem(enteredKey) === "1") {
        restoreEnteredSession();
      } else {
        showTitle();
      }
      
      function wireTitleControls() {
        const startButton = root.querySelector("[data-nav='start']");
        const silentLink = root.querySelector("[data-nav='silent']");
        let startTriggered = false;
        let silentTriggered = false;

        async function handleStart(event) {
          if (event) event.preventDefault();
          if (startTriggered) return;
          startTriggered = true;
          await runTitleAction("start");
        }

        async function handleSilent(event) {
          if (event) event.preventDefault();
          if (silentTriggered) return;
          silentTriggered = true;
          await runTitleAction("silent");
        }

        if (startButton) {
          startButton.addEventListener("touchend", handleStart);
          startButton.addEventListener("click", handleStart);
        }

        if (silentLink) {
          silentLink.addEventListener("touchend", handleSilent);
          silentLink.addEventListener("click", handleSilent);
        }
      }

    })();
  </script>
"""

GAME_HTML_BODY = """
<body>
  <div class="wrap">
""" + MUSIC_UI + """
    <div id="app-root"></div>
  </div>
  <div class="footer-commit">""" + BUILD_LABEL + """</div>
""" + MUSIC_SCRIPT + GAME_SHELL_SCRIPT + """
</body>
</html>
"""


def current_public_base_url() -> str:
    configured = configured_public_url()
    if configured:
        return configured
    return request.url_root.strip().rstrip("/")


def absolute_app_url(path: str) -> str:
    return f"{current_public_base_url()}{path}"


def render_game_html() -> str:
    title = "Blame Machine"
    game_url = absolute_app_url("/game")
    social_image = absolute_app_url("/social-card.png")
    favicon = absolute_app_url("/favicon.svg")
    apple_touch_icon = absolute_app_url("/apple-touch-icon.png")
    head = f"""<!doctype html>
<html>
<head>
  <title>{html_escape(title)}</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="description" content="{html_escape(SOCIAL_DESCRIPTION)}">
  <meta name="theme-color" content="#050505">
  <meta name="apple-mobile-web-app-title" content="{html_escape(title)}">
  <meta property="og:type" content="website">
  <meta property="og:title" content="{html_escape(title)}">
  <meta property="og:description" content="{html_escape(SOCIAL_DESCRIPTION)}">
  <meta property="og:url" content="{html_escape(game_url)}">
  <meta property="og:image" content="{html_escape(social_image)}">
  <meta property="og:image:secure_url" content="{html_escape(social_image)}">
  <meta property="og:image:type" content="image/png">
  <meta property="og:image:width" content="1200">
  <meta property="og:image:height" content="630">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="{html_escape(title)}">
  <meta name="twitter:description" content="{html_escape(SOCIAL_DESCRIPTION)}">
  <meta name="twitter:image" content="{html_escape(social_image)}">
  <link rel="icon" type="image/svg+xml" href="{html_escape(favicon)}">
  <link rel="apple-touch-icon" href="{html_escape(apple_touch_icon)}">
  <link rel="shortcut icon" href="{html_escape(absolute_app_url('/favicon.ico'))}">
""" + BASE_STYLES + """
</head>
"""
    return head + GAME_HTML_BODY


def load_word_list(path: Path) -> set[str]:
    if not path.exists():
        return set()

    words = set()
    with path.open("r", encoding="utf-8") as handle:
        for line in handle:
            line = line.strip().lower()
            if not line or line.startswith("#"):
                continue
            words.add(line)
    return words


BANNED_WORDS = load_word_list(BANNED_WORDS_PATH)
ALLOWED_WORDS = load_word_list(ALLOWED_WORDS_PATH)
app.config["ACCESS_URL"] = None
app.config["ACCESS_MESSAGE"] = None
app.config["BROWSER_URL"] = None


def debug_beep_bytes() -> bytes:
    rate = 44100
    duration_seconds = 0.25
    frequency_hz = 1320
    buffer = io.BytesIO()

    with wave.open(buffer, "wb") as wav_file:
        wav_file.setnchannels(1)
        wav_file.setsampwidth(2)
        wav_file.setframerate(rate)
        frames: list[bytes] = []
        total = int(rate * duration_seconds)

        for index in range(total):
            if index < total * 0.8:
                envelope = 1.0
            else:
                envelope = max(0.0, (total - index) / (total * 0.2))
            sample = int(
                32767
                * 0.65
                * envelope
                * math.sin(2 * math.pi * frequency_hz * index / rate)
            )
            frames.append(struct.pack("<h", sample))

        wav_file.writeframes(b"".join(frames))

    return buffer.getvalue()


def debug_beep_data_uri() -> str:
    encoded = base64.b64encode(AUDIO_DEBUG_BEEP_WAV).decode("ascii")
    return f"data:audio/wav;base64,{encoded}"


def synth_wav_bytes(
    duration_seconds: float,
    events: list[dict[str, float | str]],
    sample_rate: int = 44100,
    master_gain: float = 0.7,
    sharpness: float = 1.0,
) -> bytes:
    total_frames = max(1, int(sample_rate * duration_seconds))
    buffer = io.BytesIO()
    frames: list[bytes] = []

    def osc_value(kind: str, phase: float) -> float:
        cycle = phase % 1.0
        if kind == "square":
            return 1.0 if cycle < 0.5 else -1.0
        if kind == "triangle":
            return 1.0 - 4.0 * abs(cycle - 0.5)
        if kind == "saw":
            return (2.0 * cycle) - 1.0
        return math.sin(2 * math.pi * cycle)

    for index in range(total_frames):
        t = index / sample_rate
        sample_value = 0.0

        for event in events:
            start = float(event.get("start", 0.0))
            end = float(event.get("end", duration_seconds))
            if t < start or t >= end:
                continue

            local = t - start
            span = max(end - start, 0.001)
            shaped = max(0.5, min(2.0, sharpness))
            attack = min(0.010 / (shaped * shaped), span * 0.18)
            release = min(0.050 / (shaped * shaped), span * 0.35)
            if local < attack:
                envelope = local / max(attack, 0.001)
            else:
                if shaped >= 1.0:
                    decay_rate = 2.0 + ((shaped - 1.0) * 10.0)
                    sustain_floor = max(0.12, 1.0 - ((shaped - 1.0) * 0.86))
                    envelope = sustain_floor + ((1.0 - sustain_floor) * math.exp(-decay_rate * (local / span)))
                else:
                    envelope = 1.0
            if end - t < release:
                envelope *= max(0.0, (end - t) / max(release, 0.001))

            freq = float(event.get("freq", 440.0))
            sweep_to = event.get("sweep_to")
            if sweep_to is not None:
                freq = freq + (float(sweep_to) - freq) * min(max(local / span, 0.0), 1.0)

            phase = local * freq
            amp = float(event.get("amp", 0.2))
            kind = str(event.get("wave", "sine"))
            transient_boost = 1.0
            if shaped > 1.0 and local < 0.018:
                transient_boost += (shaped - 1.0) * 0.45
            sample_value += osc_value(kind, phase) * amp * envelope * transient_boost

        sample_value = max(-1.0, min(1.0, sample_value * master_gain))
        frames.append(struct.pack("<h", int(sample_value * 32767)))

    with wave.open(buffer, "wb") as wav_file:
        wav_file.setnchannels(1)
        wav_file.setsampwidth(2)
        wav_file.setframerate(sample_rate)
        wav_file.writeframes(b"".join(frames))

    return buffer.getvalue()


def build_home_loop_wav() -> bytes:
    pitch = 0.85
    beat = 0.36
    pattern = [220.0, 220.0, 261.63, 220.0, 293.66, 261.63, 220.0, 196.0]
    arp = [659.25, 783.99, 880.0, 783.99, 698.46, 783.99, 1046.5, 783.99]
    events: list[dict[str, float | str]] = []
    for step, freq in enumerate(pattern):
        start = step * beat
        events.append({"start": start, "end": start + 0.22, "freq": freq * pitch, "wave": "triangle", "amp": 0.12})
        events.append({"start": start, "end": start + 0.08, "freq": freq * 2 * pitch, "wave": "square", "amp": 0.08})
        events.append({"start": start, "end": start + 0.15, "freq": arp[step] * pitch, "wave": "saw", "amp": 0.10})
        if step % 4 == 3:
            events.append({"start": start, "end": start + 0.04, "freq": 1320.0 * pitch, "wave": "square", "amp": 0.05})
    return synth_wav_bytes(beat * 8, events, master_gain=0.74, sharpness=2.0)


def build_category_loop_wav() -> bytes:
    beat = 60 / 96 / 2
    bass = [196.0, 196.0, 220.0, 196.0, 196.0, 174.61, 196.0, 220.0]
    lead = [392.0, 440.0, 392.0, 523.25, 440.0, 392.0, 349.23, 392.0]
    events: list[dict[str, float | str]] = []
    for step, freq in enumerate(bass):
        start = step * beat
        events.append({"start": start, "end": start + 0.20, "freq": freq, "wave": "triangle", "amp": 0.12})
        events.append({"start": start + 0.015, "end": start + 0.14, "freq": freq * 2, "wave": "triangle", "amp": 0.08})
        events.append({"start": start + 0.04, "end": start + 0.18, "freq": lead[step], "wave": "triangle", "amp": 0.07})
    return synth_wav_bytes(beat * 8, events, master_gain=0.72)


def build_coin_wav() -> bytes:
    return synth_wav_bytes(
        0.22,
        [
            {"start": 0.00, "end": 0.06, "freq": 1760.0, "sweep_to": 1620.0, "wave": "square", "amp": 0.35},
            {"start": 0.06, "end": 0.16, "freq": 1318.51, "sweep_to": 920.0, "wave": "square", "amp": 0.42},
        ],
        master_gain=0.9,
    )


def build_reveal_wav() -> bytes:
    return synth_wav_bytes(
        0.55,
        [
            {"start": 0.00, "end": 0.16, "freq": 523.25, "sweep_to": 659.25, "wave": "triangle", "amp": 0.18},
            {"start": 0.11, "end": 0.30, "freq": 659.25, "sweep_to": 783.99, "wave": "triangle", "amp": 0.22},
            {"start": 0.24, "end": 0.50, "freq": 783.99, "sweep_to": 1046.5, "wave": "triangle", "amp": 0.28},
        ],
        master_gain=0.95,
    )


def build_wonk_wav() -> bytes:
    return synth_wav_bytes(
        0.42,
        [
            {"start": 0.00, "end": 0.18, "freq": 330.0, "sweep_to": 220.0, "wave": "saw", "amp": 0.22},
            {"start": 0.16, "end": 0.38, "freq": 246.94, "sweep_to": 164.81, "wave": "saw", "amp": 0.20},
        ],
        master_gain=0.95,
    )


AUDIO_DEBUG_BEEP_WAV = debug_beep_bytes()
AUDIO_DEBUG_BEEP_DATA_URI = debug_beep_data_uri()
AUDIO_LOOP_HOME_WAV = build_home_loop_wav()
AUDIO_LOOP_CATEGORY_WAV = build_category_loop_wav()
AUDIO_COIN_WAV = build_coin_wav()
AUDIO_REVEAL_WAV = build_reveal_wav()
AUDIO_WONK_WAV = build_wonk_wav()


def slugify_identity(value: str) -> str:
    value = value.strip().lower()
    value = re.sub(r"[^a-z0-9._-]+", "-", value)
    value = re.sub(r"-{2,}", "-", value).strip("-")
    return value or "local"


def identity_storage_key(value: str) -> str:
    normalized = value.strip().lower().encode("utf-8")
    digest = hashlib.sha256(normalized).hexdigest()[:24]
    return f"user-{digest}"


def trusted_identity_headers_present() -> bool:
    if not IDENTITY_SHARED_SECRET:
        return False
    secret = request.headers.get(IDENTITY_SECRET_HEADER, "").strip()
    return bool(secret) and hmac.compare_digest(secret, IDENTITY_SHARED_SECRET)


def turnstile_enabled() -> bool:
    return bool(TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY)


def valid_guest_cookie_value(value: str) -> bool:
    return bool(re.fullmatch(r"[A-Za-z0-9_-]{24,128}", value or ""))


def ensure_guest_cookie_value() -> str:
    existing = request.cookies.get(GUEST_COOKIE_NAME, "").strip()
    if valid_guest_cookie_value(existing):
        return existing

    pending = getattr(g, "pending_guest_cookie", "").strip()
    if valid_guest_cookie_value(pending):
        return pending

    pending = secrets.token_urlsafe(24)
    g.pending_guest_cookie = pending
    return pending


def make_signed_value(secret_key: str, payload: str) -> str:
    digest = hmac.new(secret_key.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256).hexdigest()
    return digest


def issue_turnstile_clearance() -> str:
    expires_at = int(time.time()) + TURNSTILE_COOKIE_MAX_AGE
    nonce = secrets.token_urlsafe(12)
    payload = f"{expires_at}:{nonce}"
    signature = make_signed_value(TURNSTILE_SECRET_KEY, payload)
    return f"{payload}:{signature}"


def has_valid_turnstile_clearance() -> bool:
    if not turnstile_enabled():
        return False

    raw = request.cookies.get(TURNSTILE_COOKIE_NAME, "").strip()
    try:
        expires_raw, nonce, signature = raw.split(":", 2)
        expires_at = int(expires_raw)
    except ValueError:
        return False

    if expires_at <= int(time.time()):
        return False

    payload = f"{expires_at}:{nonce}"
    expected_signature = make_signed_value(TURNSTILE_SECRET_KEY, payload)
    return hmac.compare_digest(signature, expected_signature)


def client_ip() -> str:
    cf_ip = request.headers.get("CF-Connecting-IP", "").strip()
    if cf_ip:
        return cf_ip

    forwarded_for = request.headers.get("X-Forwarded-For", "").strip()
    if forwarded_for:
        return forwarded_for.split(",", 1)[0].strip()

    return request.remote_addr or ""


def verify_turnstile_token(token: str) -> tuple[bool, list[str]]:
    payload = {
        "secret": TURNSTILE_SECRET_KEY,
        "response": token,
        "remoteip": client_ip(),
    }
    encoded = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(
        "https://challenges.cloudflare.com/turnstile/v0/siteverify",
        data=encoded,
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=TURNSTILE_VERIFY_TIMEOUT) as response:
            result = json.loads(response.read().decode("utf-8"))
    except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError):
        return False, ["internal-error"]

    if not result.get("success"):
        return False, [str(code) for code in result.get("error-codes", [])]

    action = str(result.get("action", "") or "").strip()
    if TURNSTILE_ACTION and action and action != TURNSTILE_ACTION:
        return False, ["action-mismatch"]

    return True, []


def turnstile_required_response() -> Response:
    return (
        jsonify(
            {
                "turnstile_required": True,
                "error": "human_check_required",
                "turnstile_site_key": TURNSTILE_SITE_KEY,
                "turnstile_action": TURNSTILE_ACTION,
            }
        ),
        403,
    )


def current_identity() -> dict[str, str]:
    if trusted_identity_headers_present():
        user = request.headers.get(IDENTITY_USER_HEADER, "").strip()
        email = request.headers.get(IDENTITY_EMAIL_HEADER, "").strip()
        name = request.headers.get(IDENTITY_NAME_HEADER, "").strip()
        if user:
            login_value = email or user
            return {
                "user": user,
                "slug": slugify_identity(user),
                "storage_key": identity_storage_key(user),
                "email": email,
                "name": name,
                "label": f"You are logged in as: {login_value}",
                "source_message": "Private profile loaded through Access SSO.",
            }

    guest_cookie = ensure_guest_cookie_value()
    guest_label = f"GAME TOKEN: {guest_cookie[:8].upper()}"
    return {
        "user": f"guest-{guest_cookie[:12]}",
        "slug": slugify_identity(guest_label),
        "storage_key": identity_storage_key(f"guest:{guest_cookie}"),
        "email": "",
        "name": "",
        "label": guest_label,
        "source_message": "Public mode. This browser keeps its own arcade cabinet.",
    }


def config_path_for_identity(identity: dict[str, str]) -> Path:
    user_dir = APP_DIR / "users"
    user_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
    try:
        user_dir.chmod(0o700)
    except PermissionError:
        # Bind-mounted directories may be owned by the host.
        pass
    return user_dir / f"{identity['storage_key']}.ini"


def load_config(identity: dict[str, str]) -> configparser.ConfigParser:
    config = configparser.ConfigParser()
    ini_path = config_path_for_identity(identity)
    if ini_path.exists():
        config.read(ini_path)
    return config


def save_config(config: configparser.ConfigParser, identity: dict[str, str]) -> None:
    ini_path = config_path_for_identity(identity)
    fd = os.open(ini_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
    with os.fdopen(fd, "w", encoding="utf-8") as handle:
        config.write(handle)
    try:
        ini_path.chmod(0o600)
    except PermissionError:
        # Bind-mounted files may be owned by the host.
        pass


def normalize_for_filter(text: str) -> str:
    text = text.lower().strip()
    text = re.sub(r"[^a-z0-9\s]", "", text)
    text = re.sub(r"\s+", " ", text)
    return text


def is_blocked_entry(text: str) -> bool:
    normalized = normalize_for_filter(text)
    if not normalized:
        return False
    if normalized in ALLOWED_WORDS:
        return False
    return normalized in BANNED_WORDS


def norm(text: str) -> str:
    return text.strip().lower()


def titleize(text: str) -> str:
    return text.strip().title()


def parse_csv(raw: str) -> list[str]:
    items = re.split(r"[\n,]+", raw)
    return [item.strip() for item in items if item.strip()]


def dedupe_choices(items: list[str]) -> list[str]:
    seen: set[str] = set()
    unique: list[str] = []

    for item in items:
        key = norm(item)
        if key in seen:
            continue
        seen.add(key)
        unique.append(item.strip())

    return unique


def duplicate_choices(items: list[str]) -> list[str]:
    seen: set[str] = set()
    duplicate_keys: set[str] = set()
    duplicates: list[str] = []

    for item in items:
        key = norm(item)
        if key in seen:
            if key not in duplicate_keys:
                duplicates.append(item.strip())
                duplicate_keys.add(key)
            continue
        seen.add(key)

    return duplicates


def category_wildcards(category: str) -> list[str]:
    return CATEGORY_DEFAULT_WILDCARDS.get(category, GLOBAL_WILDCARDS)


def random_wildcard(category: str) -> str:
    return random.choice(category_wildcards(category))


def funny_choice_result(choice: str) -> str:
    return random.choice(CHOICE_TEMPLATES).format(choice=choice)


def random_clear_arcade_description() -> str:
    return random.choice(CLEAR_ARCADE_DESCRIPTIONS)


def is_valid_category_name(category: str) -> bool:
    return bool(category) and bool(CATEGORY_NAME_RE.fullmatch(category))


def ensure_category(config: configparser.ConfigParser, category: str) -> None:
    if category not in config:
        config[category] = {}

    config[category].setdefault("choices", "")
    config[category].setdefault("last_choice", "")
    config[category].setdefault("last_ts", "0")
    config[category].setdefault("recent_choices", "")


def last_pick_info(config: configparser.ConfigParser, category: str) -> tuple[str, float]:
    ensure_category(config, category)
    last_choice = config[category].get("last_choice", "").strip()
    last_ts_raw = config[category].get("last_ts", "0").strip()

    try:
        last_ts = float(last_ts_raw)
    except ValueError:
        last_ts = 0.0

    return last_choice, last_ts


def recent_choice_history(config: configparser.ConfigParser, category: str) -> list[str]:
    ensure_category(config, category)
    return [norm(item) for item in parse_csv(config[category].get("recent_choices", ""))]


def update_recent_history(config: configparser.ConfigParser, category: str, choice: str) -> None:
    history = recent_choice_history(config, category)
    normalized_choice = norm(choice)
    history = [item for item in history if item != normalized_choice]
    history.insert(0, normalized_choice)
    trimmed = history[:RECENT_HISTORY_LIMIT]
    config[category]["recent_choices"] = ", ".join(trimmed)


def choice_pool(choices: list[str], recent_history: list[str]) -> list[str]:
    if len(choices) <= 1:
        return choices

    available = [choice for choice in choices if norm(choice) not in recent_history]
    return available or choices


def category_context(config: configparser.ConfigParser, category: str) -> dict:
    ensure_category(config, category)
    choices = config[category].get("choices", "")
    choices_list = parse_csv(choices)
    return {
        "category": category,
        "category_display": titleize(category),
        "choices": choices,
        "choices_list": choices_list,
        "choice_count": len(choices_list),
        "can_decide": len(choices_list) >= 2,
        "wildcard_percent": int(WILDCARD_CHANCE * 100),
    }


def category_subtitle(mode: str) -> str:
    subtitles = {
        "edit": "LOAD THE MACHINE.",
        "ready": "LOCKED IN. READY TO SPIN.",
        "result": "THE ARCADE HAS SPOKEN.",
    }
    return subtitles.get(mode, "DECIDE SOMETHING.")


def home_state(identity: dict[str, str], error: str | None = None) -> dict:
    config = load_config(identity)
    return {
        "view": "home",
        "scene": "home",
        "player_label": identity["label"],
        "player_source_message": identity["source_message"],
        "categories": sorted(config.sections()),
        "error": error,
        "access_url": app.config.get("ACCESS_URL"),
        "access_message": app.config.get("ACCESS_MESSAGE"),
        "clear_arcade_description": random_clear_arcade_description(),
    }


def clear_state(identity: dict[str, str]) -> dict:
    return {
        "view": "clear",
        "scene": "home",
        "player_label": identity["label"],
        "player_source_message": identity["source_message"],
        "clear_arcade_description": random_clear_arcade_description(),
    }


def category_state(
    identity: dict[str, str],
    category: str,
    mode: str,
    result: str | None = None,
    used_wildcard: bool = False,
    error: str | None = None,
    result_meta: str | None = None,
) -> dict:
    config = load_config(identity)
    category = norm(category)
    ctx = category_context(config, category)
    ctx["view"] = "category"
    ctx["scene"] = "result" if mode == "result" else "category"
    ctx["mode"] = mode
    ctx["result"] = result
    ctx["used_wildcard"] = used_wildcard
    ctx["error"] = error
    ctx["result_meta"] = result_meta
    ctx["result_kind"] = "normal"
    ctx["auto_return_seconds"] = RESULT_RETURN_SECONDS if mode == "result" else 0
    ctx["subtitle"] = category_subtitle(mode)
    ctx["player_label"] = identity["label"]
    ctx["player_source_message"] = identity["source_message"]
    return ctx


def payload_value(name: str, default: str = "") -> str:
    data = request.get_json(silent=True)
    if isinstance(data, dict) and name in data:
        value = data.get(name, default)
        return "" if value is None else str(value)
    return request.form.get(name, default)


def expected_request_origin() -> str:
    proto = request.headers.get("X-Forwarded-Proto", "").strip()
    host = request.headers.get("X-Forwarded-Host", "").strip()
    if proto and host:
        return f"{proto}://{host}".rstrip("/")
    return request.host_url.rstrip("/")


def origin_host_matches(origin_like: str, expected_origin: str) -> bool:
    if not origin_like:
        return False
    try:
        parsed_origin = urlparse(origin_like)
        parsed_expected = urlparse(expected_origin)
    except ValueError:
        return False
    return bool(parsed_origin.netloc) and parsed_origin.netloc == parsed_expected.netloc


@app.after_request
def disable_shell_caching(response):
    if request.path in {"/", "/game"} or request.path.startswith("/api/"):
        response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
        response.headers["Pragma"] = "no-cache"
        response.headers["Expires"] = "0"

    pending_guest_cookie = getattr(g, "pending_guest_cookie", "").strip()
    if pending_guest_cookie:
        response.set_cookie(
            GUEST_COOKIE_NAME,
            pending_guest_cookie,
            max_age=GUEST_COOKIE_MAX_AGE,
            httponly=True,
            secure=bool(request.is_secure or request.headers.get("X-Forwarded-Proto", "").strip() == "https"),
            samesite="Lax",
            path="/",
        )

    pending_turnstile_cookie = getattr(g, "pending_turnstile_cookie", "").strip()
    if pending_turnstile_cookie:
        response.set_cookie(
            TURNSTILE_COOKIE_NAME,
            pending_turnstile_cookie,
            max_age=TURNSTILE_COOKIE_MAX_AGE,
            httponly=True,
            secure=bool(request.is_secure or request.headers.get("X-Forwarded-Proto", "").strip() == "https"),
            samesite="Lax",
            path="/",
        )
    return response


def same_origin_api_request() -> bool:
    expected_origin = expected_request_origin()
    origin = request.headers.get("Origin", "").strip().rstrip("/")
    referer = request.headers.get("Referer", "").strip()
    fetch_site = request.headers.get("Sec-Fetch-Site", "").strip().lower()

    if origin:
        return origin == expected_origin or origin_host_matches(origin, expected_origin)
    if referer:
        return (
            referer == expected_origin
            or referer.startswith(expected_origin + "/")
            or origin_host_matches(referer, expected_origin)
        )
    return fetch_site in {"", "same-origin", "same-site"}


def audio_debug_allowed() -> bool:
    return AUDIO_DEBUG_ENABLED


def bounded_log_value(value: object, limit: int = 240) -> str:
    text = str(value or "").replace("\r", " ").replace("\n", " ")
    return text[:limit]


def svg_response(markup: str, cache_seconds: int = 3600) -> Response:
    response = Response(markup, mimetype="image/svg+xml")
    response.headers["Cache-Control"] = f"public, max-age={cache_seconds}"
    return response


def favicon_svg_markup() -> str:
    return """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Blame Machine">
  <rect width="64" height="64" rx="10" fill="#050505"/>
  <rect x="4" y="4" width="56" height="56" rx="8" fill="none" stroke="#39ff14" stroke-width="4"/>
  <text x="32" y="27" text-anchor="middle" font-family="Courier New, monospace" font-size="18" fill="#ffe600">BM</text>
  <circle cx="18" cy="45" r="4" fill="#ff2bd6"/>
  <circle cx="32" cy="45" r="4" fill="#00eaff"/>
  <circle cx="46" cy="45" r="4" fill="#39ff14"/>
</svg>"""


def social_card_svg_markup() -> str:
    return """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 630" role="img" aria-label="Blame Machine social card">
  <defs>
    <radialGradient id="glowTop" cx="50%" cy="0%" r="70%">
      <stop offset="0%" stop-color="#00eaff" stop-opacity="0.22"/>
      <stop offset="55%" stop-color="#00eaff" stop-opacity="0"/>
    </radialGradient>
    <radialGradient id="glowBottom" cx="50%" cy="100%" r="70%">
      <stop offset="0%" stop-color="#ff2bd6" stop-opacity="0.16"/>
      <stop offset="60%" stop-color="#ff2bd6" stop-opacity="0"/>
    </radialGradient>
    <filter id="softGlow">
      <feGaussianBlur stdDeviation="8" result="blur"/>
      <feMerge>
        <feMergeNode in="blur"/>
        <feMergeNode in="SourceGraphic"/>
      </feMerge>
    </filter>
  </defs>
  <rect width="1200" height="630" fill="#050505"/>
  <rect width="1200" height="630" fill="url(#glowTop)"/>
  <rect width="1200" height="630" fill="url(#glowBottom)"/>
  <rect x="42" y="42" width="1116" height="546" rx="16" fill="none" stroke="#39ff14" stroke-width="6"/>
  <text x="600" y="175" text-anchor="middle" font-family="Courier New, monospace" font-size="78" fill="#ffe600" filter="url(#softGlow)">★ BLAME MACHINE ★</text>
  <text x="600" y="286" text-anchor="middle" font-family="Courier New, monospace" font-size="36" fill="#00eaff">INSERT COIN. BLAME THE MACHINE.</text>
  <text x="600" y="372" text-anchor="middle" font-family="Courier New, monospace" font-size="34" fill="#ff2bd6">BLAME ME FOR BAD DECISIONS.</text>
  <rect x="265" y="430" width="670" height="92" rx="10" fill="rgba(0,0,0,0.0)" stroke="#ffe600" stroke-width="4"/>
  <text x="600" y="486" text-anchor="middle" font-family="Courier New, monospace" font-size="28" fill="#ffe600">THE JOYSTICK SAYS BURGERS.</text>
</svg>"""


@app.before_request
def protect_api_posts():
    if request.method != "POST" or not request.path.startswith("/api/"):
        return None
    if same_origin_api_request():
        if (
            turnstile_enabled()
            and not trusted_identity_headers_present()
            and request.path in {"/api/category", "/api/save", "/api/decide", "/api/clear"}
            and not has_valid_turnstile_clearance()
        ):
            token = payload_value("turnstile_token").strip()
            if not token:
                return turnstile_required_response()

            verified, error_codes = verify_turnstile_token(token)
            if not verified:
                app.logger.warning(
                    "TURNSTILE_REJECT path=%r ip=%r errors=%r ua=%r",
                    request.path,
                    client_ip(),
                    error_codes,
                    request.headers.get("User-Agent", ""),
                )
                return turnstile_required_response()

            g.pending_turnstile_cookie = issue_turnstile_clearance()
        return None

    identity = current_identity()
    app.logger.warning(
        "API_POST_REJECT path=%r expected_origin=%r origin=%r referer=%r sec_fetch_site=%r ua=%r",
        request.path,
        expected_request_origin(),
        request.headers.get("Origin", ""),
        request.headers.get("Referer", ""),
        request.headers.get("Sec-Fetch-Site", ""),
        request.headers.get("User-Agent", ""),
    )
    return jsonify(home_state(identity, error="forbidden")), 403


@app.route("/")
@app.route("/game")
def home():
    return render_template_string(render_game_html())


@app.route("/favicon.svg")
def favicon_svg():
    return svg_response(favicon_svg_markup(), cache_seconds=86400)


@app.route("/favicon.ico")
def favicon_ico():
    return redirect(url_for("favicon_svg"), code=302)


@app.route("/social-card.svg")
def social_card():
    return svg_response(social_card_svg_markup(), cache_seconds=86400)


@app.route("/apple-touch-icon.png")
def apple_touch_icon():
    if APPLE_TOUCH_ICON_PATH.exists():
        response = Response(APPLE_TOUCH_ICON_PATH.read_bytes(), mimetype="image/png")
        response.headers["Cache-Control"] = "public, max-age=86400"
        return response
    return redirect(url_for("favicon_svg"), code=302)


@app.route("/social-card.png")
def social_card_png():
    if SOCIAL_CARD_PNG_PATH.exists():
        response = Response(SOCIAL_CARD_PNG_PATH.read_bytes(), mimetype="image/png")
        response.headers["Cache-Control"] = "public, max-age=86400"
        return response
    return redirect(url_for("social_card"), code=302)


@app.route("/api/state", methods=["GET"])
def api_state():
    identity = current_identity()
    view = request.args.get("view", "").strip().lower()
    category = norm(request.args.get("category", ""))
    mode = request.args.get("mode", "ready").strip().lower()

    if view == "clear":
        return jsonify(clear_state(identity))

    if view == "category" and category:
        if not is_valid_category_name(category):
            return jsonify(home_state(identity, error="invalid")), 400
        if mode not in {"edit", "ready", "result"}:
            mode = "ready"
        return jsonify(category_state(identity, category, mode))

    return jsonify(home_state(identity))


@app.route("/api/category", methods=["POST"])
def api_category():
    identity = current_identity()
    config = load_config(identity)
    category = norm(payload_value("category"))

    if not category:
        return jsonify(home_state(identity, error="empty_category")), 400
    if not is_valid_category_name(category):
        return jsonify(home_state(identity, error="invalid")), 400
    if is_blocked_entry(category):
        return jsonify(home_state(identity, error="blocked")), 400
    if config.has_section(category):
        return jsonify(home_state(identity, error="duplicate")), 409

    ensure_category(config, category)
    save_config(config, identity)
    return jsonify(category_state(identity, category, mode="edit"))


@app.route("/api/save", methods=["POST"])
def api_save():
    identity = current_identity()
    config = load_config(identity)
    category = norm(payload_value("category"))
    if not is_valid_category_name(category):
        return jsonify(home_state(identity, error="invalid")), 400
    ensure_category(config, category)

    raw_choices = payload_value("choices").strip()
    parsed_choices = parse_csv(raw_choices)
    duplicates = duplicate_choices(parsed_choices)
    if duplicates:
        return jsonify(category_state(identity, category, mode="edit", error="duplicate_choices"))
    choices = dedupe_choices(parsed_choices)

    for choice in choices:
        if is_blocked_entry(choice):
            return jsonify(category_state(identity, category, mode="edit", error="blocked"))

    config[category]["choices"] = ", ".join(choices)
    save_config(config, identity)
    return jsonify(category_state(identity, category, mode="ready"))


@app.route("/api/decide", methods=["POST"])
def api_decide():
    identity = current_identity()
    config = load_config(identity)
    category = norm(payload_value("category"))
    if not is_valid_category_name(category):
        return jsonify(home_state(identity, error="invalid")), 400
    ensure_category(config, category)

    choices = parse_csv(config[category].get("choices", ""))
    now = time.time()

    if len(choices) < 2:
        return jsonify(
            category_state(
                identity,
                category,
                mode="result",
                result="ONE CHOICE? REALLY?",
                used_wildcard=False,
                result_meta="COME BACK WHEN YOU'VE GIVEN THE MACHINE AN ACTUAL DECISION.",
            )
        )

    last_choice, last_ts = last_pick_info(config, category)
    if last_choice and (now - last_ts) <= FAST_REROLL_SECONDS:
        state = category_state(
                identity,
                category,
                mode="result",
                result=f"{last_choice} - did you think i'd change my mind so quickly!",
                used_wildcard=False,
                result_meta=f"FAST REROLL LOCK: {FAST_REROLL_SECONDS} SECONDS",
            )
        state["result_kind"] = "too_soon"
        return jsonify(state)

    use_wildcard = random.random() < WILDCARD_CHANCE
    if use_wildcard:
        state = category_state(
                identity,
                category,
                mode="result",
                result=random_wildcard(category),
                used_wildcard=True,
                result_meta=f"{len(choices)} PICKS IN THE MACHINE",
            )
        state["result_kind"] = "wildcard"
        return jsonify(state)

    recent_history = recent_choice_history(config, category)
    available_choices = choice_pool(choices, recent_history)
    selected = random.choice(available_choices)
    config[category]["last_choice"] = selected
    config[category]["last_ts"] = str(now)
    update_recent_history(config, category, selected)
    save_config(config, identity)

    meta = f"{len(choices)} UNIQUE PICKS | AVOIDING LAST {min(len(recent_history), RECENT_HISTORY_LIMIT)}"
    state = category_state(
            identity,
            category,
            mode="result",
            result=funny_choice_result(selected),
            used_wildcard=False,
            result_meta=meta,
        )
    state["result_kind"] = "normal"
    return jsonify(state)


@app.route("/api/clear", methods=["POST"])
def api_clear():
    identity = current_identity()
    ini_path = config_path_for_identity(identity)
    if ini_path.exists():
        ini_path.unlink()
    return jsonify(home_state(identity))


@app.route("/sound.wav")
def sound_wav():
    return Response(
        AUDIO_DEBUG_BEEP_WAV,
        mimetype="audio/wav",
        headers={
            "Content-Disposition": "inline; filename=blame-machine-debug.wav",
            "Cache-Control": "no-store, max-age=0",
        },
    )


@app.route("/audio/coin-primer.mp3")
def audio_coin_primer():
    if not COIN_PRIMER_MP3:
        return Response(status=404)
    return Response(
        COIN_PRIMER_MP3,
        mimetype="audio/mpeg",
        headers={
            "Content-Disposition": "inline; filename=blame-machine-coin-primer.mp3",
            "Cache-Control": "no-store, max-age=0",
        },
    )


@app.route("/audio/loop-home.wav")
def audio_loop_home():
    return Response(
        AUDIO_LOOP_HOME_WAV,
        mimetype="audio/wav",
        headers={"Cache-Control": "public, max-age=300"},
    )


@app.route("/audio/loop-category.wav")
def audio_loop_category():
    return Response(
        AUDIO_LOOP_CATEGORY_WAV,
        mimetype="audio/wav",
        headers={"Cache-Control": "public, max-age=300"},
    )


@app.route("/audio/cue-coin.wav")
def audio_cue_coin():
    return Response(
        AUDIO_COIN_WAV,
        mimetype="audio/wav",
        headers={"Cache-Control": "public, max-age=300"},
    )


@app.route("/audio/cue-reveal.wav")
def audio_cue_reveal():
    return Response(
        AUDIO_REVEAL_WAV,
        mimetype="audio/wav",
        headers={"Cache-Control": "public, max-age=300"},
    )


@app.route("/audio/cue-wonk.wav")
def audio_cue_wonk():
    return Response(
        AUDIO_WONK_WAV,
        mimetype="audio/wav",
        headers={"Cache-Control": "public, max-age=300"},
    )


@app.route("/sound")
def sound_page():
    if not audio_debug_allowed():
        return ("Not found", 404)
    return render_template_string(
        """<!doctype html>
<html>
<head>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Blame Machine Sound File</title>
  <style>
    body {
      margin: 0;
      min-height: 100vh;
      background: #050505;
      color: #ffe600;
      font-family: "Courier New", "Lucida Console", monospace;
      padding: 24px;
    }
    .wrap {
      max-width: 720px;
      margin: 0 auto;
    }
    .card {
      background: #0d0d0d;
      border: 3px solid #39ff14;
      box-shadow: 0 0 18px rgba(57,255,20,0.18);
      padding: 24px;
      margin-top: 24px;
      text-align: center;
    }
    .title {
      color: #ffe600;
      font-size: 22px;
      line-height: 1.6;
      text-align: center;
      margin: 12px 0;
      text-shadow: 0 0 8px rgba(255,230,0,0.55);
    }
    .sub {
      color: #00eaff;
      text-align: center;
      line-height: 1.8;
      margin-bottom: 18px;
    }
    .build {
      color: #00eaff;
      text-align: center;
      font-size: 11px;
      opacity: 0.85;
      margin-bottom: 16px;
    }
    audio {
      width: 100%;
      margin: 20px 0;
    }
    a {
      color: #ff2bd6;
      text-decoration: none;
    }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="title">★ BLAME MACHINE SOUND FILE ★</div>
    <div class="build">""" + BUILD_LABEL + """</div>
    <div class="sub">THIS IS THE RAW WAV TEST. NO WEB AUDIO. JUST A FILE.</div>
    <div class="card">
      <audio controls autoplay playsinline preload="auto" src=\"""" + url_for("sound_wav") + """?ts=""" + str(int(time.time())) + """\"></audio>
      <div><a href=\"""" + url_for("sound_wav") + """?ts=""" + str(int(time.time())) + """\">OPEN RAW SOUND FILE</a></div>
    </div>
  </div>
</body>
</html>"""
    )


@app.route("/audio-debug")
def audio_debug():
    if not audio_debug_allowed():
        abort(404)
    return render_template_string("""<!doctype html>
<html>
<head>
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Blame Machine Audio Debug</title>
  <style>
    :root {
      --bg: #050505;
      --green: #39ff14;
      --pink: #ff2bd6;
      --cyan: #00eaff;
      --yellow: #ffe600;
      --panel: #0d0d0d;
    }
    * { box-sizing: border-box; }
    body {
      margin: 0;
      min-height: 100vh;
      padding: 16px;
      background:
        radial-gradient(circle at top, rgba(0,234,255,0.10), transparent 30%),
        radial-gradient(circle at bottom, rgba(255,43,214,0.08), transparent 30%),
        var(--bg);
      color: var(--green);
      font-family: "Courier New", "Lucida Console", monospace;
    }
    .wrap {
      max-width: 760px;
      margin: 0 auto;
    }
    .card {
      background: var(--panel);
      border: 3px solid var(--green);
      box-shadow: 0 0 18px rgba(57,255,20,0.18);
      padding: 18px;
      margin: 18px 0;
    }
    .title {
      text-align: center;
      color: var(--yellow);
      font-size: 20px;
      line-height: 1.6;
      text-shadow: 0 0 8px rgba(255,230,0,0.6);
      margin: 16px 0 8px;
    }
    .build {
      text-align: center;
      color: var(--cyan);
      font-size: 8px;
      line-height: 1.6;
      margin: 0 0 18px;
      opacity: 0.85;
      letter-spacing: 0.04em;
    }
    .sub {
      text-align: center;
      color: var(--cyan);
      font-size: 11px;
      line-height: 1.8;
      margin-bottom: 16px;
    }
    .button-stack {
      display: grid;
      gap: 12px;
    }
    button {
      width: 100%;
      background: #000;
      color: var(--pink);
      border: 3px solid var(--pink);
      padding: 14px;
      font-family: "Courier New", "Lucida Console", monospace;
      font-size: 11px;
      line-height: 1.4;
      cursor: pointer;
      box-shadow: 0 0 14px rgba(255,43,214,0.18);
    }
    button:hover, button:focus-visible {
      background: var(--pink);
      color: #000;
    }
    .secondary {
      color: var(--cyan);
      border-color: var(--cyan);
      box-shadow: 0 0 14px rgba(0,234,255,0.18);
    }
    .secondary:hover, .secondary:focus-visible {
      background: var(--cyan);
      color: #000;
    }
    .tail, .state {
      white-space: pre-wrap;
      font-size: 10px;
      line-height: 1.8;
    }
    .tail { color: var(--yellow); }
    .state { color: #9befff; }
    .label {
      color: var(--yellow);
      font-size: 10px;
      margin-bottom: 10px;
      line-height: 1.6;
    }
    @media (max-width: 640px) {
      body { padding: 12px; }
      .title { font-size: 16px; }
      .sub, .tail, .state, button { font-size: 10px; }
    }
  </style>
</head>
<body>
  <div class="wrap">
    <div class="card">
      <div class="title">★ BLAME MACHINE AUDIO DEBUG ★</div>
      <div class="build">""" + BUILD_LABEL + """</div>
      <div class="sub">
        A/B AUDIO HARNESS.<br>
        SAME HOST. SAME CONTAINER. NO GAME FLOW.
      </div>
      <div class="button-stack">
        <button id="btn7">PLAY WAV</button>
        <button id="btn8" class="secondary">PLAY JS BEEP</button>
        <button id="btn6" class="secondary">RESET PAGE STATE</button>
      </div>
    </div>

    <div class="card">
      <div class="label">EVENT TAIL</div>
      <div id="tail" class="tail">READY</div>
    </div>

    <div class="card">
      <div class="label">LIVE STATE</div>
      <div id="state" class="state">ctx: none</div>
    </div>
  </div>

  <script>
    let ctx = null;
    let htmlAudio = null;
    const tail = document.getElementById('tail');
    const state = document.getElementById('state');
    const lines = ['READY'];
    const htmlAudioSrc = """ + repr(url_for("sound_wav")) + """ + '?ts=' + Date.now();

    function postServerLog(message) {
      const payload = {
        message,
        ctxState: ctx ? ctx.state : 'none',
        ctxTime: ctx ? Number(ctx.currentTime.toFixed(3)) : null,
        htmlPaused: htmlAudio ? htmlAudio.paused : null,
        htmlReadyState: htmlAudio ? htmlAudio.readyState : null,
        htmlCurrentTime: htmlAudio ? Number(htmlAudio.currentTime.toFixed(3)) : null,
        ua: navigator.userAgent
      };
      fetch('/audio-debug/log', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        keepalive: true,
        body: JSON.stringify(payload)
      }).catch(() => {});
    }

    function stamp(message) {
      const now = new Date();
      const hh = String(now.getHours()).padStart(2, '0');
      const mm = String(now.getMinutes()).padStart(2, '0');
      const ss = String(now.getSeconds()).padStart(2, '0');
      lines.push(`${hh}:${mm}:${ss} ${message}`);
      while (lines.length > 14) lines.shift();
      tail.textContent = lines.join('\\n');
      postServerLog(message);
      refreshState();
    }

    function refreshState() {
      if (!ctx) {
        state.textContent = [
          'ctx: none',
          `html.paused: ${htmlAudio ? htmlAudio.paused : 'n/a'}`,
          `html.readyState: ${htmlAudio ? htmlAudio.readyState : 'n/a'}`,
          `html.currentTime: ${htmlAudio ? htmlAudio.currentTime.toFixed(3) : 'n/a'}`
        ].join('\\n');
        return;
      }
      state.textContent = [
        `ctx.state: ${ctx.state}`,
        `sampleRate: ${ctx.sampleRate}`,
        `currentTime: ${ctx.currentTime.toFixed(3)}`,
        `baseLatency: ${ctx.baseLatency ?? 'n/a'}`,
        `outputLatency: ${ctx.outputLatency ?? 'n/a'}`,
        `html.paused: ${htmlAudio ? htmlAudio.paused : 'n/a'}`,
        `html.readyState: ${htmlAudio ? htmlAudio.readyState : 'n/a'}`,
        `html.currentTime: ${htmlAudio ? htmlAudio.currentTime.toFixed(3) : 'n/a'}`
      ].join('\\n');
    }

    function ensureHtmlAudio() {
      if (htmlAudio) return htmlAudio;
      htmlAudio = new Audio(htmlAudioSrc);
      htmlAudio.preload = 'auto';
      htmlAudio.playsInline = true;
      htmlAudio.addEventListener('play', () => stamp('HTML PLAY'));
      htmlAudio.addEventListener('pause', () => stamp('HTML PAUSE'));
      htmlAudio.addEventListener('ended', () => stamp('HTML ENDED'));
      htmlAudio.addEventListener('error', () => {
        stamp(`HTML ERROR ${htmlAudio?.error?.message || htmlAudio?.error?.code || 'unknown'}`);
      });
      return htmlAudio;
    }

    function createContext() {
      if (!ctx) {
        const AudioCtx = window.AudioContext || window.webkitAudioContext;
        ctx = new AudioCtx();
        ctx.onstatechange = () => {
          stamp(`STATECHANGE -> ${ctx.state}`);
        };
        stamp(`CREATE OK state=${ctx.state} sr=${ctx.sampleRate}`);
      }
      refreshState();
      return ctx;
    }

    async function resumeContext() {
      const audio = createContext();
      stamp(`RESUME BEFORE ${audio.state}`);
      try {
        await audio.resume();
        stamp(`RESUME AFTER ${audio.state}`);
      } catch (error) {
        stamp(`RESUME ERR ${error?.message || error}`);
      }
      refreshState();
      return audio;
    }

    function playPair(lowFreq, highFreq, gainValue = 0.9, duration = 0.45) {
      if (!ctx) {
        stamp('PLAY ERR no context');
        return;
      }
      const g = ctx.createGain();
      g.gain.value = gainValue;
      g.connect(ctx.destination);

      const low = ctx.createOscillator();
      low.type = 'triangle';
      low.frequency.value = lowFreq;
      low.connect(g);

      const high = ctx.createOscillator();
      high.type = 'square';
      high.frequency.value = highFreq;
      high.connect(g);

      low.start();
      high.start();
      low.stop(ctx.currentTime + duration);
      high.stop(ctx.currentTime + duration);
      stamp(`PLAY low=${lowFreq} high=${highFreq} state=${ctx.state}`);
      refreshState();
    }

    async function playHtmlAudio() {
      const audio = ensureHtmlAudio();
      audio.pause();
      audio.currentTime = 0;
      try {
        await audio.play();
        stamp('HTML PLAY REQUEST OK');
      } catch (error) {
        stamp(`HTML PLAY ERR ${error?.message || error}`);
      }
      refreshState();
    }

    document.getElementById('btn7').addEventListener('click', async () => {
      stamp('WAV START');
      await playHtmlAudio();
    });

    document.getElementById('btn8').addEventListener('click', async () => {
      stamp('JS BEEP START');
      await resumeContext();
      playPair(440, 880, 0.8, 0.4);
    });

    document.getElementById('btn6').addEventListener('click', () => {
      stamp('RESET PAGE REQUEST');
      window.location.href = '/audio-debug?ts=' + Date.now();
    });

    document.addEventListener('touchstart', () => {
      stamp('DOC TOUCHSTART');
    }, { passive: true, capture: true });

    document.addEventListener('pointerdown', () => {
      stamp('DOC POINTERDOWN');
    }, { passive: true, capture: true });

    window.addEventListener('pageshow', () => {
      stamp('PAGESHOW');
    });

    document.addEventListener('visibilitychange', () => {
      stamp(`VISIBILITY ${document.visibilityState}`);
    });

    window.setInterval(refreshState, 500);
    refreshState();
  </script>
</body>
</html>""");


@app.route("/audio-debug/log", methods=["POST"])
def audio_debug_log():
    if not audio_debug_allowed():
        abort(404)
    if not same_origin_api_request():
        return ("", 403)
    data = request.get_json(silent=True) or {}
    print(
        "AUDIO_DEBUG "
        f"client={request.headers.get('CF-Connecting-IP') or request.remote_addr!r} "
        f"message={bounded_log_value(data.get('message'))!r} "
        f"ctx_state={data.get('ctxState')!r} "
        f"ctx_time={data.get('ctxTime')!r} "
        f"html_paused={data.get('htmlPaused')!r} "
        f"html_ready={data.get('htmlReadyState')!r} "
        f"html_time={data.get('htmlCurrentTime')!r} "
        f"ua={bounded_log_value(data.get('ua'), limit=180)!r}",
        flush=True,
    )
    return ("", 204)


def open_browser() -> None:
    browser_url = app.config.get("BROWSER_URL")
    if browser_url:
        webbrowser.open(browser_url)


def local_ip_address() -> str:
    candidates: list[str] = []

    try:
        hostname_ips = socket.gethostbyname_ex(socket.gethostname())[2]
        candidates.extend(hostname_ips)
    except OSError:
        pass

    probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        # This does not require external internet access; it only asks the OS
        # which interface would route outward if one exists.
        probe.connect(("10.255.255.255", 1))
        candidates.append(probe.getsockname()[0])
    except OSError:
        pass
    finally:
        probe.close()

    for candidate in candidates:
        if candidate and not candidate.startswith("127."):
            return candidate

    return "127.0.0.1"


def configured_port() -> int:
    raw_port = os.environ.get("PORT", str(DEFAULT_PORT)).strip()
    try:
        return int(raw_port)
    except ValueError:
        return DEFAULT_PORT


def configured_public_url() -> str:
    return os.environ.get("PUBLIC_URL", DEFAULT_PUBLIC_URL).strip().rstrip("/")


def should_open_browser() -> bool:
    if os.environ.get("BLAME_MACHINE_NO_BROWSER", "").strip() == "1":
        return False
    return not bool(configured_public_url())


def configure_access() -> tuple[str, str, str]:
    port = configured_port()
    public_url = configured_public_url()
    ip_address = local_ip_address()

    if public_url:
        browser_url = public_url
        access_url = public_url
        access_message = "OPEN THIS FROM ANY PHONE, ANYWHERE."
    else:
        browser_url = f"http://{DEFAULT_BROWSER_HOST}:{port}"
        if ip_address == "127.0.0.1":
            access_url = browser_url
            access_message = "NO LAN DETECTED. PLAY ON THIS COMPUTER."
        else:
            access_url = f"http://{ip_address}:{port}"
            access_message = "PLAY ON THIS COMPUTER OR A PHONE/TABLET ON THE SAME WI-FI."

    app.config["ACCESS_URL"] = access_url
    app.config["ACCESS_MESSAGE"] = access_message
    app.config["BROWSER_URL"] = browser_url
    return ip_address, access_url, access_message


def print_access_banner(ip_address: str, access_url: str, access_message: str) -> None:
    print("")
    print("Blame Machine is running.")
    print(f"Open on this computer: {app.config['BROWSER_URL']}")
    print(access_message)
    print(f"Access URL: {access_url}")
    print("")


if __name__ == "__main__":
    ip_address, access_url, access_message = configure_access()
    print_access_banner(ip_address, access_url, access_message)
    if should_open_browser():
        Timer(1.0, open_browser).start()
    app.run(host=DEFAULT_LAN_HOST, port=configured_port(), debug=False, use_reloader=False)
