Nums / BON Giveaway BETA

// ==UserScript==
// @name         BON Giveaway BETA
// @namespace    https://openuserjs.org/users/jacksaw
// @description  Enables the functionality to become poor
// @version      5.0.0.49
// @updateURL    https://openuserjs.org/meta/Nums/BON_Giveaway_BETA.meta.js
// @downloadURL  https://openuserjs.org/install/Nums/BON_Giveaway_BETA.user.js
// @connect      openuserjs.org
// @grant        GM_xmlhttpRequest
// @license      GPL-3.0-or-later
// @match        https://oldtoons.world/
// @match        https://upload.cx/
// @match        https://aither.cc/
// @match        https://reelflix.cc/
// @match        https://onlyencodes.cc/
// @match        https://homiehelpdesk.net/
// @match        https://darkpeers.org/
// @match        https://lst.gg/
// @match        https://yu-scene.net/
// @run-at document-idle
// ==/UserScript==

// ==OpenUserJS==
// @author Nums
// ==/OpenUserJS==

//*****If the website is not listed as a match already. Please verify with tracker admins before using this script on their site.*****
//*****It is unlikely the bon gifting portion of the script will work on any site not in the default match list.*****

// Additional credits
// @TheEther - Integration with Aither + some additional features
// @Nums - added new commands, command spam detection, admin controls, multi-winners, refactored BON API polling + trying to keep the public version updated
// @ahoimate - got BON gifting API polling working + added new commands
// @ruckus612 - fixed BON gift bug
// @ZukoXZuko - added formatting to the giveaway menu

(function() {
    'use strict';

    // ───────────────────────────────────────────────────────────
    // SECTION 1: Global Constants and Configuration
    // ───────────────────────────────────────────────────────────
    const COMMAND_WINDOW_MS = 10000; // look back 10 seconds
    const MAX_COMMANDS_PER_WINDOW = 3; // allow 3 commands in that window
    const BASE_PENALTY_SECONDS = 30; // base lockout for exceeding (in seconds)
    const MAX_WINNERS = 30; // central location to update max allowable number of winners
    const MAX_REMINDERS = 6; //maximum number of reminders allowed


    // Persistent stats (saved in localStorage on this site)
    const STATS_LS_KEY = "bon-giveaway-stats";
    const STATS_VERSION = 1;
    const STATS_DEFAULT_TOP_N = 5;
    const STATS_MAX_TOP_N = 10;

    // Default text to populate the custom giveaway message field
    const DEFAULT_CUSTOM_MESSAGE = "BETA v5.0.0";

    let otherGiveawayIndicatorInterval = null;
    const OTHER_GIVEAWAY_WARNING_BG = "#ffeb3b"; // yellow

    const ENTRY_IGNORE_WINDOW_MS = 1000;

    const GENERAL_SETTINGS = {
        disable_random: false,
        disable_lucky: false,
        disable_free: false,
        suppress_entry_replies: false
    };

    const DEBUG_SETTINGS = {
        log_chat_messages: false,
        disable_chat_output: false,
        verify_extractor: false,
        verify_sendmessage: false,
        verify_cacheChatContext: false,
        suppressApiMessages: false // new flag to suppress API message sending
    };



    const SCRIPT_ID = 'bon-giveaway-update';
    const CHECK_EVERY_HOURS = 24;

    const CHATROOM_IDS = {
        'upload.cx': '11',
        'oldtoons.world': '4',
        'aither.cc': '4',
        'reelflix.cc': '1',
        'onlyencodes.cc': '1',
        'homiehelpdesk.net': '3',
        'darkpeers.org': '2',
        'lst.gg': '5',
        'yu-scene.net': '5'
    };

    const extractors = {
        alpine: extractorAlpine,
        onlyencodes: extractorOnlyEncodes,
        fallback: extractorFallback
    };

    const LS_SUPPRESS = "giveaway-suppressEntryReplies";
    const currentHost = window.location.hostname;
    const chatroomId = CHATROOM_IDS[currentHost] || '2';
    const chatboxID = "chatbox__messages-create";

    // only run the cooldown/spam‑detection logic on available commands
    const baseCommands = ["time", "entries", "help", "commands", "bon", "range", "gift", "random", "number", "free", "lucky", "rig", "unrig", "stats", "topwinners", "topsponsors", "toplosers"];
    const hostCommands = ["addtime", "removetime", "reminder", "addbon", "end", "winners", "naughty"];
    const uploadCxExtras = ["ruckus", "ick", "corigins", "lejosh", "suckur", "bloom", "dawg", "greglechin"];
    const validCommands = new Set([
        ...baseCommands,
        ...hostCommands,
        ...(window.location.hostname === "upload.cx" ? uploadCxExtras : [])
    ]);

    // ───────────────────────────────────────────────────────────
    // SECTION 2: Runtime State Variables
    // ───────────────────────────────────────────────────────────
    let giveawayStartTime;
    let sponsorsInterval;
    let observer;
    let giveawayData;
    let chatbox = null;
    let reminderRetryTimeout = null;
    let frameHeader;
    let OT_USER_ID = null;
    let OT_CHATROOM_ID = null;
    let OT_CSRF_TOKEN = null;
    let riggedMode = false; // fun cosmetic mode, does NOT affect fairness

    const userCooldown = new Map(); // author → timestamp(ms) when lockout ends
    const userCommandLog = new Map(); // author → [timestamps of recent commands]
    const numberEntries = new Map();
    const fancyNames = new Map();
    const naughtyWarned = new Set(); // Users that have already been warned this giveaway

    // Live stats tracking for the current giveaway (prevents double counting)
    const liveEnteredThisGiveaway = new Set(); // userKey
    const liveSponsorSeenThisGiveaway = new Set(); // sponsorKey (for sponsorCount once/giveaway)
    const liveSponsorTotalThisGiveaway = new Map();// sponsorKey -> running total


    // Winner payout / verification state (integrated into entries table)
    const winnerPayouts = new Map(); // lowercase author -> BON amount
    const winnerGiftStatus = new Map(); // lowercase author -> "pending" | "confirmed" | "failed"

    const regNum = /^-?\d+$/;
    const whitespace = document.createTextNode(" ");

    /* --- Naughty (exclusion) list ------------------------------------- */
    const NAUGHTY_KEY = "giveaway-naughty-list";
    const naughtySet = new Set(
        JSON.parse(localStorage.getItem(NAUGHTY_KEY) || "[]")
        .map(n => n.toLowerCase()) // store lowercase for case-insensitive match
    );
    function saveNaughty() {
        localStorage.setItem(NAUGHTY_KEY, JSON.stringify([...naughtySet]));
    }

    const coinsIcon = document.createElement("i");
    coinsIcon.setAttribute("class", "fas fa-coins");

    const goldCoins = document.createElement("i");
    goldCoins.setAttribute("class", "fas fa-coins");
    goldCoins.style.color = "#ffc00a";
    goldCoins.style.padding = "5px";

    const giveawayBTN = document.createElement("a");
    giveawayBTN.setAttribute("class", "form__button form__button--text");
    giveawayBTN.textContent = "Giveaway";
    giveawayBTN.prepend(coinsIcon.cloneNode(false));
    giveawayBTN.onclick = toggleMenu;

    // ───────────────────────────────────────────────────────────
    // SECTION 3: Script Metadata Parsing
    // ───────────────────────────────────────────────────────────
    const META = (() => {
        /* 1. Tampermonkey / Violentmonkey / classic Greasemonkey */
        if (typeof GM_info !== "undefined" && GM_info.script) {
            return GM_info.script;
        }

        /* 2. Greasemonkey 4 (GM.info) */
        if (typeof GM !== "undefined" && GM.info && GM.info.script) {
            return GM.info.script;
        }

        /* 3. Fallback: read our own source and regex the @version etc. */
        try {
            /* GM-3 keeps the original userscript text in the <script> tag it
       injects.  document.currentScript points to that tag.            */
            const src = document.currentScript?.textContent || "";
            const fetch = key => {
                const m = src.match(new RegExp(`@${key}\\s+([^\\n]+)`));
                return m ? m[1].trim() : "";
            };

            return {
                name:        fetch("name") || "BON Giveaway",
                updateURL:   fetch("updateURL") || "https://openuserjs.org/meta/Nums/Blutopia_BON_Giveaway.meta.js",
                version:     fetch("version") || "0.0.0"
            };
        } catch (e) {
            /* Last-ditch – never crash the script */
            return { name:"BON Giveaway", version:"0.0.0" };
        }
    })();

    const {
        name:        SCRIPT_NAME,
        updateURL:   SCRIPT_UPDATE_URL,
        version:     SCRIPT_VERSION
    } = META;

    /* — persistent “out-of-date” flag — */
    const UPDATE_KEY = `${SCRIPT_ID}-latestRemote`;
    const latestRemote = localStorage.getItem(UPDATE_KEY) || "";

    /*  If we already know a newer version exists, draw the badge immediately  */
    if (latestRemote && isNewer(latestRemote, SCRIPT_VERSION)) {
        /* frame isn’t on the page yet → retry until it is */
        waitForBadge(latestRemote);
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 4: UI Template Definitions
    // ───────────────────────────────────────────────────────────
    const frameHTML = `
<section
  id="giveawayFrame"
  class="panelV2"
  style="width:450px;height:90%;position:fixed;z-index:9999;inset:50px 150px auto auto;overflow:auto;border:1px solid black;"
  hidden
>
  <!-- HEADER -->
  <header class="panel__heading">
    <div class="button-holder no-space">
      <div class="button-left">
        <h4 class="panel__heading">
          <i class="fa-solid fa-gifts" style="padding:5px;"></i>
          ${SCRIPT_NAME}
          <small style="color:#aaa;margin-left:8px;font-size:0.8em;">v${SCRIPT_VERSION}</small>
        </h4>
      </div>
      <div class="button-right">
        <button id="resetButton" class="form__button form__button--text giveaway-btn" style="background-color:#b32525;">
          <i class="fa-solid fa-rotate-right"></i> Reset
        </button>
        <button id="giveawaySettingsBtn" class="form__button form__button--text giveaway-btn" style="background-color:#ff6400;">
          <i class="fa-solid fa-gear"></i> Settings
        </button>
        <button id="commandsButton" class="form__button form__button--text giveaway-btn" style="background-color:#ff9600;">
          <i class="fa-solid fa-list"></i> Commands
        </button>
        <button id="closeButton" class="form__button form__button--text giveaway-btn" style="background-color:#4e595f;">
          <i class="fa-solid fa-xmark"></i>
        </button>
      </div>
    </div>
  </header>

  <!-- MAIN BODY -->
  <div class="panel__body" id="giveaway_body" style="display:flex; flex-direction:column; gap:10px;">
    <h1 id="coinHeader" class="panel__heading--centered"></h1>

    <form class="form" id="giveawayForm" style="display:flex;flex-flow:column;align-items:center;">
      <p class="form__group" style="max-width:35%;">
<input
  class="form__text"
  required
  id="giveawayAmount"
  inputmode="numeric"
  type="text"
>

        <label class="form__label form__label--floating" for="giveawayAmount">
          Giveaway Amount
        </label>
      </p>

      <div class="panel__body flex-row" style="justify-content:center; gap:20px;">
        ${
    [
        ['startNum', '1'],
        ['endNum', '50']
    ]
    .map(
        ([id, val]) => `
              <p class="form__group" style="width:20%;">
                <input
                  class="form__text"
                  required
                  id="${id}"
                  pattern="-?\\d+"
                  value="${val}"
                  inputmode="numeric"
                  type="text"
                  maxlength="9"
                >
                <label class="form__label form__label--floating" for="${id}">
                  ${id === 'startNum' ? 'Start #' : 'End #'}
                </label>
              </p>`
    )
    .join('')
    }
      </div>

      <!-- Giveaway length / reminders / winners row -->
      <div class="panel__body flex-row" style="justify-content:center; flex-wrap:wrap; gap:20px;">
        <!-- giveaway length -->
        <p class="form__group" style="width:28%;">
          <input class="form__text" required id="timerNum" value="5" inputmode="numeric">
          <label class="form__label form__label--floating" for="timerNum">Time&nbsp;(min)</label>
        </p>

        <!-- reminders -->
        <p class="form__group" style="width:28%;">
          <input class="form__text" id="reminderNum" type="number" min="0" step="1" value="0" autocomplete="off">
          <label class="form__label form__label--floating"># Reminders</label>
        </p>

        <!-- cadence label -->
        <p class="form__group" style="width:28%;">
          <input class="form__text" id="reminderEvery" readonly tabindex="-1" style="cursor:default;">
          <label class="form__label form__label--floating">Every (min)</label>
        </p>
      </div>

      <!-- winners in its own row with top margin -->
<div class="panel__body" style="display:flex;justify-content:center; margin-top: 12px; width:100%;">
  <p class="form__group" style="width:28%;">
    <input
      class="form__text"
      type="number"
      id="winnersNum"
      min="1"
      max="${MAX_WINNERS}"
      step="1"
      value="1"
    >
    <label class="form__label form__label--floating" for="winnersNum"># Winners</label>
  </p>
</div>

      <div class="panel__body" style="display:flex;justify-content:center;gap:20px;">
        <p class="form__group" style="width:100%;">
          <input
            class="form__text"
            id="customMessage"
            type="text"
            maxlength="100"
            placeholder="Max 100 chars"
            value="${DEFAULT_CUSTOM_MESSAGE}"
          >
          <label class="form__label form__label--floating" for="customMessage">
            Custom Message
          </label>
        </p>
      </div>

      <p class="form__group" style="text-align:center;">
  <button
    type="button"
    id="startButton"
    class="form__button form__button--filled"
    style="background-color:#02B008;"
  >
    Start
  </button>
</p>
    </form>

    <!-- Countdown timer below the form, full width -->
    <h2 id="countdownHeader" class="panel__heading--centered" hidden
        style="display:block; width:100%; margin-top:10px; margin-bottom:10px; text-align:center;">
    </h2>

<!-- Entries table below the countdown -->
    <div id="entriesWrapper" class="data-table-wrapper" hidden
         style="width:100%; overflow-x:auto; margin-top:10px;">
      <table id="entriesTable" class="data-table" style="width:100%; border-collapse:collapse; table-layout:fixed;">
        <thead><tr><th>User</th><th>Entry #</th></tr></thead>
        <tbody></tbody>
      </table>
    </div>

    <!-- Winners / payout status -->
    <div id="winnersWrapper" class="data-table-wrapper" hidden
         style="width:100%; overflow-x:auto; margin-top:6px;">
      <table id="winnersTable" class="data-table" style="width:100%; border-collapse:collapse; table-layout:fixed;">
        <thead>
          <tr>
            <th>Winner</th>
            <th>Prize BON</th>
            <th>Gift</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </div>
  </div>

  <!-- SETTINGS MENU -->
  <div id="giveaway_settings_menu" class="giveaway_settings_menu" style="display:none">
    <div>
      <button type="button" id="toggleAllButton" class="form__button form__button--filled">
        Toggle all
      </button><br>
      ${['Random','Lucky','Free','Entry Replies'].map(label => `
        <p style="display:inline-block;width:150px;">${label}:</p>
        <input
          type="checkbox"
          id="${label.toLowerCase().replace(/ /g,'')}Toggle"
          style="width:15px;height:15px;cursor:pointer;"
          checked
        ><br>`).join('')}
    </div>
  </div>

  <!-- COMMANDS MENU -->
  <div id="giveaway_commands_menu" class="commands-menu" style="display:none">
    <ul class="commands-list">
      <li class="section-label">General&nbsp;Commands</li>
      <li><code>!time&nbsp;</code>        <span class="desc">Show remaining time</span></li>
      <li><code>!entries&nbsp;</code>     <span class="desc">List all entries</span></li>
      <li><code>!free&nbsp;</code>        <span class="desc">Show free numbers</span></li>
      <li><code>!number&nbsp;</code>      <span class="desc">Show your entry</span></li>
      <li><code>!random&nbsp;</code>      <span class="desc">Enter with a random #</span></li>
      <li><code>!lucky&nbsp;</code>       <span class="desc">Show lucky number</span></li>
      <li><code>!bon&nbsp;</code>         <span class="desc">Show pot amount</span></li>
      <li><code>!range&nbsp;</code>       <span class="desc">Show valid range</span></li>
      <li><code>!rig/!unrig&nbsp;</code>  <span class="desc">Toggle rigging (fun)</span></li>
      <li><code>!help&nbsp;</code>        <span class="desc">Show this list in chat</span></li>
      <li><code>!stats&nbsp;[user]</code>   <span class="desc">Show saved stats</span></li>
      <li><code>!topwinners&nbsp;[N]</code> <span class="desc">Top winners</span></li>
      <li><code>!topsponsors&nbsp;[N]</code><span class="desc">Top sponsors</span></li>
      <li><code>!toplosers&nbsp;[N]</code><span class="desc">Most losses</span></li>

      <li class="section-label">Host-Only&nbsp;Commands</li>
      <li class="full-span">
          <code>!time add&nbsp;N&nbsp;/&nbsp;remove&nbsp;N&nbsp;</code>
          <span class="desc">Adjust remaining minutes</span>
      </li>
      <li><code>!reminder&nbsp;</code>    <span class="desc">Send reminder msg</span></li>
      <li><code>!addbon&nbsp;</code>      <span class="desc">Add BON to pot</span></li>
      <li><code>!winners&nbsp;N</code>    <span class="desc">Set number of winners</span></li>
      <li><code>!end&nbsp;</code>         <span class="desc">End the giveaway</span></li>

      <li><code>!naughty&nbsp;</code>     <span class="desc">list/add/remove a user</span></li>
      <li class="naughty-alert">
        ⚠⚠ !naughty excludes users from the giveaway entirely ⚠⚠ ************************USE RESPONSIBLY************************
      </li>
    </ul>
  </div>

  <!-- RIGGED WATERMARK (only visible in rigged mode) -->
  <div class="rigged-watermark">RIGGED</div>
</section>
`;

    const baseMenuStyle = `
  background-color: #2C2C2C;
  color: #CCC;
  border-radius: 5px;
  position: absolute;
  top: 100px;
  right: 10px;
  z-index: 998;
  padding: 15px;
  overflow: auto;
  flex-direction: column;
  justify-content: center;
  box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
`;

    // Settings menu CSS styles
    const settingsMenuStyle = `
.giveaway_settings_menu {
  ${baseMenuStyle}
  width: 240px;
  max-height: 340px;
}
.giveaway_settings_menu > div {
  margin: 5px 0;
}`;

    // Commands menu CSS styles – shrink-wrap width, 2-column grid, section labels
    const commandsMenuStyle = `
.commands-menu{
  ${baseMenuStyle}
  width:max-content;
  max-width:425px;
  max-height:70vh;
}

/* ── compact two-column grid ───────────────────────────── */
.commands-menu .commands-list{
  list-style:none;
  padding:0;
  margin:0;
  display:grid;
  grid-template-columns:max-content 1fr;   /* code | description */
  column-gap:5px;
  row-gap:4px;
}

.commands-menu .full-span{
  grid-column: 1 / -1;      /* occupy the whole row */
 }

/* left column (command keyword) */
.commands-menu code{
  font-family:inherit;
  font-weight:600;
  color:#ffb84d;
  font-size:14px;
  white-space:nowrap;
}

/* right column (description) */
.commands-menu .desc{
  color:#d0d0d0;       /* dimmer grey */
  font-size:13px;
}

/* orange section headers that span both columns */
.commands-menu .section-label{
  grid-column:1 / -1;
  margin:6px 0 2px;
  font-size:14px;
  font-weight:700;
  color:#ffa200;
  border-bottom:1px solid #555;
}

/* full-width red banner for Naughty */
.commands-menu .naughty-alert{
  grid-column:1 / -1;    /* span both columns */
  background:#dc3d1d;
  color:#fff;
  font-size:13px;
  font-weight:600;
  padding:2px 6px;
  border-radius:4px;
  margin-top:2px;
}`;


    // ───────────────────────────────────────────────────────────
    // SECTION 5: Initialization and Bootstrapping
    // ───────────────────────────────────────────────────────────
    // Cache references for UI elements (populated in injectMenu)
    let giveawayFrame, coinHeader, countdownHeader,
        coinInput, startInput, endInput, timerInput, reminderInput, winnersInput, customMessageInput,
        entriesWrapper, winnersWrapper, giveawayForm, winnersTable,
        resetButton, closeButton, startButton, toggleAllButton, settingsBtn, commandsBtn, settingsMenu, commandsMenu,
        remNumInput, reminderEvery, rigBadge, rigToggleInput;
    // Inject the giveaway menu into the chat UI
    injectMenu();

    function injectMenu() {
        const chatbox_header = document.querySelector(`#chatbox_header div`);
        if (!chatbox_header) {
            setTimeout(injectMenu, 100);
            return;
        }

        addStyle(`
/* Keep vertical spacing tight */
#giveawayFrame .panel__body {
  gap: 2px !important;
  row-gap: 2px !important;
  margin-top: 2px !important;
  margin-bottom: 2px !important;
  padding-top: 0 !important;
  padding-bottom: 0 !important;
}

/* Specifically restore horizontal flex layout for the input rows */
#giveawayFrame .panel__body.flex-row {
  display: flex !important;
  flex-wrap: wrap !important;
  flex-direction: row !important;
  justify-content: center !important;
  gap: 20px !important; /* restore horizontal spacing */
}

/* Form groups still keep tight vertical margin */
#giveawayFrame .form__group {
  margin-top: 2px !important;
  margin-bottom: 2px !important;
  padding-top: 0 !important;
  padding-bottom: 0 !important;
}

#giveawayFrame .form__text {
  padding-top: 3px !important;
  padding-bottom: 3px !important;
  margin-top: 0 !important;
  margin-bottom: 0 !important;
}

#giveawayFrame label.form__label {
  margin-top: 0 !important;
  margin-bottom: 2px !important;
  line-height: 1.1 !important;
}

/* Countdown timer fix: force block and full width below form */
#giveawayFrame #countdownHeader {
  display: block !important;
  width: 100% !important;
  margin-top: 10px;
  margin-bottom: 10px;
  text-align: center;
}

/* Entries wrapper full width with horizontal scroll if needed */
#giveawayFrame #entriesWrapper {
  width: 100% !important;
  overflow-x: auto;
  margin-top: 10px;
}

/* Entries table: flex to content, but never shrink below wrapper width
   and allow it to grow wider (triggering horizontal scroll). */
#giveawayFrame #entriesTable {
  border-collapse: collapse;
  table-layout: auto !important; /* override inline table-layout:fixed */
  min-width: 100%;               /* fill the frame at minimum */
  width: auto;                   /* but can grow past it if needed */
}

/* General cell padding */
#giveawayFrame #entriesTable th,
#giveawayFrame #entriesTable td {
  padding: 4px 6px;
}

/* User column: grow with username up to a cap, then ellipsis. */
#giveawayFrame #entriesTable th:nth-child(1),
#giveawayFrame #entriesTable td:nth-child(1) {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 260px;   /* hard upper bound for username column */
}

/* Entry # column: flex to content, keep centered */
#giveawayFrame #entriesTable th:nth-child(2),
#giveawayFrame #entriesTable td:nth-child(2) {
  text-align: center;
  white-space: nowrap;
}

/* Prize BON column: flex to content, keep centered */
#giveawayFrame #entriesTable th:nth-child(3),
#giveawayFrame #entriesTable td:nth-child(3) {
  text-align: center;
  white-space: nowrap;
}

/* Gift status column: fixed-ish narrow width, centered */
#giveawayFrame #entriesTable th:nth-child(4),
#giveawayFrame #entriesTable td:nth-child(4) {
  width: 70px !important;
  text-align: center;
}

/* Gift verification row states (color only Entry # / Prize / Gift, not Username) */
#giveawayFrame #entriesTable tr.gift-pending > td:nth-child(2),
#giveawayFrame #entriesTable tr.gift-pending > td:nth-child(3),
#giveawayFrame #entriesTable tr.gift-pending > td:nth-child(4) {
  background-color: rgba(255, 235, 59, 0.20) !important; /* yellow-ish */
}

#giveawayFrame #entriesTable tr.gift-confirmed > td:nth-child(2),
#giveawayFrame #entriesTable tr.gift-confirmed > td:nth-child(3),
#giveawayFrame #entriesTable tr.gift-confirmed > td:nth-child(4) {
  background-color: rgba(76, 175, 80, 0.20) !important; /* green-ish */
}

#giveawayFrame #entriesTable tr.gift-failed > td:nth-child(2),
#giveawayFrame #entriesTable tr.gift-failed > td:nth-child(3),
#giveawayFrame #entriesTable tr.gift-failed > td:nth-child(4) {
  background-color: rgba(244, 67, 54, 0.20) !important; /* red-ish */
}

/* Animated spinner for "checking" gift status */
#giveawayFrame .gift-spinner {
  display: inline-block;
  width: 14px;
  height: 14px;
  border-radius: 50%;
  border: 2px solid rgba(255, 255, 255, 0.3);
  border-top-color: #ffeb3b; /* yellow-ish accent */
  animation: giftSpinnerSpin 0.8s linear infinite;
  box-sizing: border-box;
}

@keyframes giftSpinnerSpin {
  to {
    transform: rotate(360deg);
  }
}

/* Parent container vertical stacking with spacing */
#giveawayFrame #giveaway_body {
  display: flex !important;
  flex-direction: column !important;
  gap: 10px !important;
}

/* --- Improved vertical centering and layout for coinHeader --- */
#giveawayFrame #coinHeader.panel__heading--centered {
  margin-top: 14px !important;
  margin-bottom: 0 !important;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 1.5em;
  gap: 6px;
}

  ${settingsMenuStyle}
  ${commandsMenuStyle}

/* Silence <h1> inside <section> console warning */
#giveawayFrame h1.panel__heading--centered {
  font-size: 1.5em;
  margin: 0;
}

/* Start button warning when another giveaway is detected */
.bon-gStartConflict {
  background: #ffeb3b !important;
  color: #111 !important;
}


/* ───────── Rigged mode theming ───────── */

#giveawayFrame.rigged {
  border-color: #dc3d1d !important;
  box-shadow: 0 0 14px rgba(220, 61, 29, 0.75);
}

#giveawayFrame.rigged header.panel__heading {
  background: linear-gradient(90deg, #dc3d1d, #4e0000);
  color: #fff;
}

/* Watermark sits behind the content */
#giveawayFrame .rigged-watermark {
  position: absolute;
  inset: 0;
  pointer-events: none;
  display: none;              /* default hidden */
  align-items: center;
  justify-content: center;
  font-size: 4rem;
  font-weight: 900;
  opacity: 0.06;
  text-transform: uppercase;
  transform: rotate(-22deg);
  letter-spacing: 0.25em;
}

#giveawayFrame.rigged .rigged-watermark {
  display: flex;
  animation: rigWatermarkPulse 4s ease-in-out infinite;
}

/* Pulsing badge animation */
#riggedBadge.rigged-pulse {
  animation: rigPulse 1.2s ease-in-out infinite;
}

@keyframes rigPulse {
  0% {
    transform: scale(1);
    box-shadow: none;
  }
  50% {
    transform: scale(1.08);
    box-shadow: 0 0 8px rgba(220, 61, 29, 0.8);
  }
  100% {
    transform: scale(1);
    box-shadow: none;
  }
}

@keyframes rigWatermarkPulse {
  0% {
    opacity: 0.03;
    text-shadow: none;
    transform: rotate(-22deg) scale(1);
  }
  50% {
    opacity: 0.10;
    text-shadow: 0 0 10px rgba(220, 61, 29, 0.45);
    transform: rotate(-22deg) scale(1.03);
  }
  100% {
    opacity: 0.03;
    text-shadow: none;
    transform: rotate(-22deg) scale(1);
  }
}

#giveawayFrame.rigged #startButton {
  animation: rigStopPulse 1.5s ease-in-out infinite;
}

@keyframes rigStopPulse {
  0%   { transform: scale(1); }
  50%  { transform: scale(1.03); }
  100% { transform: scale(1); }
}
`, 'giveaway-styles');

        document.body.insertAdjacentHTML("beforeend", frameHTML);

        settingsMenu = document.getElementById('giveaway_settings_menu');
        commandsMenu = document.getElementById('giveaway_commands_menu');
        timerInput = document.getElementById("timerNum");
        remNumInput = document.getElementById("reminderNum");
        reminderEvery = document.getElementById("reminderEvery");

        giveawayFrame = document.getElementById('giveawayFrame');

        settingsBtn = giveawayFrame.querySelector('#giveawaySettingsBtn');
        commandsBtn = giveawayFrame.querySelector('#commandsButton');

        // Create / attach "RIGGED" badge next to the version
        const versionSmall = giveawayFrame.querySelector('header.panel__heading small');
        if (versionSmall) {
            rigBadge = document.createElement('span');
            rigBadge.id = 'riggedBadge';
            rigBadge.textContent = 'RIGGED';
            rigBadge.title = "Rigged mode is purely cosmetic… allegedly.";
            rigBadge.setAttribute("aria-label", "Rigged mode indicator");
            rigBadge.style.cssText = `

      margin-left: 8px;
      padding: 2px 6px;
      border-radius: 4px;
      background: #dc3d1d;
      color: #fff;
      font-size: 0.75em;
      font-weight: 700;
    `;
            rigBadge.hidden = true;
            versionSmall.insertAdjacentElement('afterend', rigBadge);
        }

        // Update both when either changes
        timerInput.addEventListener("input", syncReminderNumUI);
        remNumInput.addEventListener("input", syncReminderNumUI);

        // Call on init
        syncReminderNumUI();


        /* kick-start synchronisation so defaults line up on first render */
        remNumInput.dispatchEvent(new Event("input"));

        settingsBtn.addEventListener('click', e => {
            e.stopPropagation(); // don’t bubble to outside-click
            if (commandsMenu.classList.contains('open')) hardCloseCommands(); // close the other pane first

            const open = settingsMenu.classList.toggle('open');

            if (open) { // ---------- OPEN ----------
                settingsMenu.style.display = 'flex';
                settingsMenu.style.height = 'auto';
                settingsMenu.style.overflow = 'visible';
                document.addEventListener('click', handleOutsideClick);
            } else { // ---------- CLOSE ----------
                hardCloseSettings();
            }
        });

        chatbox_header.prepend(giveawayBTN);
        giveawayBTN.parentNode.insertBefore(whitespace, giveawayBTN.nextSibling);

        resetButton = document.getElementById("resetButton");
        resetButton.onclick = function () {
            if (giveawayData && giveawayData.timeLeft > 0) {
                if (window.confirm("Are you sure you want to reset the giveaway? This will clear all entries and cannot be undone.")) {
                    resetGiveaway();
                }
            } else {
                resetGiveaway();
            }
        };

        closeButton = document.getElementById("closeButton");
        closeButton.onclick = function () {
            // Check if a giveaway is active
            if (giveawayData && giveawayData.timeLeft > 0) {
                if (window.confirm("A giveaway is currently running. Are you sure you want to close the menu? This will NOT end the giveaway, but you may lose track of its progress.")) {
                    toggleMenu();
                }
            } else {
                toggleMenu();
            }
        };

        // Toggles
        const toggles = [
            ["randomToggle", "giveaway-disableRandom", "disable_random"],
            ["luckyToggle", "giveaway-disableLucky", "disable_lucky"],
            ["freeToggle", "giveaway-disableFree", "disable_free"],
            ["entryrepliesToggle", LS_SUPPRESS, "suppress_entry_replies", true]
        ];

        for (const [id, key, setting, invert = false] of toggles) {
            const el = document.getElementById(id);
            const stored = localStorage.getItem(key) === "true";
            el.checked = invert ? !stored : !stored;
            GENERAL_SETTINGS[setting] = invert ? stored : stored;

            el.addEventListener("change", () => {
                const newVal = invert ? !el.checked : !el.checked;
                GENERAL_SETTINGS[setting] = newVal;
                localStorage.setItem(key, String(newVal));
            });
        }

        // --- Rig mode toggle inside Settings menu ---
        const settingsInner = settingsMenu.querySelector('div');
        if (settingsInner) {
            const hr = document.createElement("hr");
            hr.style.margin = "8px 0";
            hr.style.border = "none";
            hr.style.borderTop = "1px solid #555";
            settingsInner.appendChild(hr);

            const rigLabel = document.createElement("label");
            rigLabel.style.display = "flex";
            rigLabel.style.alignItems = "center";
            rigLabel.style.gap = "6px";
            rigLabel.style.marginTop = "4px";
            rigLabel.style.marginBottom = "2px";

            rigToggleInput = document.createElement("input");
            rigToggleInput.type = "checkbox";
            rigToggleInput.id = "rigModeToggle";
            rigToggleInput.style.width = "15px";
            rigToggleInput.style.height = "15px";
            rigToggleInput.style.cursor = "pointer";

            const rigText = document.createElement("span");
            rigText.textContent = "Rigged mode (visual only)";

            rigLabel.appendChild(rigToggleInput);
            rigLabel.appendChild(rigText);
            settingsInner.appendChild(rigLabel);

            const rigHint = document.createElement("div");
            rigHint.style.fontSize = "11px";
            rigHint.style.color = "#aaa";
            rigHint.textContent = "(same as !rig / !unrig)";
            settingsInner.appendChild(rigHint);

            // Click handler: delegate to the same logic as !rig / !unrig
            rigToggleInput.addEventListener("change", () => {
                // current logged-in user (same way startGiveaway gets the host)
                const nameNode = document.getElementsByClassName("top-nav__username")[0];
                const hostName = nameNode?.children[0]?.textContent.trim() || "";

                const ctx = {
                    author: hostName,
                    fancyName: "",
                    args: [],
                    // if there is no active giveaway yet, fake a minimal object with host
                    giveawayData: giveawayData || { host: hostName },
                    safeAuthor: sanitizeNick(hostName),
                    safeHost: sanitizeNick(hostName)
                };

                if (rigToggleInput.checked && !riggedMode) {
                    COMMAND_HANDLERS.rig(ctx);
                } else if (!rigToggleInput.checked && riggedMode) {
                    COMMAND_HANDLERS.unrig(ctx);
                } else {
                    // nothing actually changed; just resync UI
                    updateRigToggleUI();
                }
            });

            // Initial state
            updateRigToggleUI();
        }

        coinHeader = document.getElementById("coinHeader");
        const hostBalance = readHostBalance();
        coinHeader.textContent = fmtBON(hostBalance);
        coinHeader.prepend(goldCoins.cloneNode(false));

        coinInput = document.getElementById("giveawayAmount");

        // remove formatting while editing
        coinInput.addEventListener('focus', () => {
            coinInput.value = coinInput.value.replace(/[^0-9]/g, '');
        });

        // add locale formatting on blur if it’s a valid integer
        coinInput.addEventListener('blur', () => {
            const raw = coinInput.value.replace(/[^0-9]/g, '');
            if (/^\d+$/.test(raw)) {
                coinInput.value = parseInt(raw, 10).toLocaleString();
            }
        });

        startInput = document.getElementById("startNum");
        endInput = document.getElementById("endNum");
        winnersInput = document.getElementById("winnersNum");
        customMessageInput = document.getElementById("customMessage");
        giveawayForm = document.getElementById("giveawayForm");
        startButton = document.getElementById("startButton");

        startButton.onclick = startGiveaway;
        startButton.title = "Start the giveaway";

        startOtherGiveawayIndicator();
        updateStartButtonConflictIndicator();

        toggleAllButton = document.getElementById("toggleAllButton");
        toggleAllButton.onclick = toggleAll;

        countdownHeader = document.getElementById("countdownHeader");
        entriesWrapper = document.getElementById("entriesWrapper");
        winnersWrapper = document.getElementById("winnersWrapper");
        winnersTable = document.getElementById("winnersTable");
        giveawayForm = document.getElementById("giveawayForm");

        document.body.appendChild(giveawayFrame);

        // Draggable panel
        frameHeader = giveawayFrame.querySelector('header.panel__heading');
        frameHeader.style.cursor = 'move';
        frameHeader.style.userSelect = 'none';

        let isDragging = false, dragOffsetX = 0, dragOffsetY = 0;

        frameHeader.addEventListener('mousedown', e => {
            isDragging = true;
            const rect = giveawayFrame.getBoundingClientRect();
            dragOffsetX = e.clientX - rect.left;
            dragOffsetY = e.clientY - rect.top;
            giveawayFrame.style.left = rect.left + 'px';
            giveawayFrame.style.top = rect.top + 'px';
            giveawayFrame.style.right = 'auto';
            giveawayFrame.style.bottom = 'auto';
        });

        document.addEventListener('mousemove', e => {
            if (!isDragging) return;
            const maxX = window.innerWidth - giveawayFrame.offsetWidth;
            const maxY = window.innerHeight - giveawayFrame.offsetHeight;
            giveawayFrame.style.left = Math.max(0, Math.min(maxX, e.clientX - dragOffsetX)) + 'px';
            giveawayFrame.style.top = Math.max(0, Math.min(maxY, e.clientY - dragOffsetY)) + 'px';
        });

        document.addEventListener('mouseup', () => {
            isDragging = false;
        });

        const cmdMenu = document.getElementById('giveaway_commands_menu');
        commandsBtn.addEventListener('click', (e)=>{
            e.stopPropagation(); // don’t trigger outside-click

            /* -------- close the Settings panel if it’s showing -------- */
            if (settingsMenu.classList.contains('open')) hardCloseSettings();

            const open = cmdMenu.classList.toggle('open'); // flip the flag

            if (open) { // ── OPEN
                cmdMenu.style.display = 'flex';
                cmdMenu.style.height = 'auto';
                cmdMenu.style.overflow = 'visible';
                document.addEventListener('click', handleOutsideClick);
            } else { // ── HARD CLOSE
                hardCloseCommands();
                document.removeEventListener('click', handleOutsideClick);
            }
        });

        timerInput.addEventListener("input", reminderAutoScaling);
        startInput.addEventListener("input", entryRangeValidation);
        endInput.addEventListener("input", entryRangeValidation);
        winnersInput.addEventListener("input", winnersValidation);

        reminderAutoScaling();
    }

    function toggleMenu() {
        giveawayFrame.hidden = !giveawayFrame.hidden;
        if (!giveawayFrame.hidden) updateStartButtonConflictIndicator();
    }

    // --- update check -------------------------------------------------
    (async () => {
        const last = +localStorage.getItem(`${SCRIPT_ID}-lastCheck`) || 0;
        const now = Date.now();
        if (now - last < CHECK_EVERY_HOURS * 3_600_000) return;

        GM_xmlhttpRequest({
            method: 'GET',
            url:    SCRIPT_UPDATE_URL,
            onload: res => {
                if (res.status !== 200) return console.warn('update-check HTTP', res.status);
                const m = res.responseText.match(/@version\s+([0-9.]+)/);
                if (!m) return console.warn('update-check: version tag not found');
                const remote = m[1].trim();

                if (isNewer(remote, SCRIPT_VERSION)) {
                    localStorage.setItem(UPDATE_KEY, remote); // 💾  persist
                    waitForBadge(remote); // 🎟  show badge
                } else {
                    localStorage.removeItem(UPDATE_KEY); // ✅ up-to-date
                }
            },
            onerror:  err => console.error('update-check failed', err),
            ontimeout:() => console.error('update-check timed out')
        });

        localStorage.setItem(`${SCRIPT_ID}-lastCheck`, String(now));
    })();



    // Utility function to compare semantic versions
    function isNewer(remote, local) {
        const r = remote.split('.').map(Number);
        const l = local .split('.').map(Number);
        const len = Math.max(r.length, l.length);
        for (let i = 0; i < len; i++) {
            const a = r[i] || 0;
            const b = l[i] || 0;
            if (a !== b) return a > b;
        }
        return false;
    }

    // Attempts to insert the "Update available" badge into the header
    function showBadge(remoteVer) {
        // the <small> that holds “v3.0.0”
        const versionTag = document.querySelector('#giveawayFrame header.panel__heading small');

        if (!versionTag) return false; // frame not rendered yet
        // prevent duplicates
        if (versionTag.parentElement.querySelector('.bon-gUpdateBadge')) return true;

        const badge = document.createElement('a');
        badge.className = 'bon-gUpdateBadge';
        badge.href = SCRIPT_UPDATE_URL.replace('.meta.js', '.user.js');
        badge.target = '_blank';
        badge.style.cssText = `
    background:#DC3D1D;color:#fff;border-radius:4px;padding:2px 6px;
    font-size:12px;margin-left:6px;text-decoration:none;cursor:pointer;
  `;
        badge.textContent = 'Update available';
        badge.title = `New version ${remoteVer} is available – click to install`;
        versionTag.appendChild(badge);
        return true;
    }

    // Tries to add the badge once per second until successful
    function waitForBadge(remote) {
        const id = setInterval(() => {
            if (showBadge(remote)) clearInterval(id);
        }, 1000);
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 6: Giveaway Lifecycle
    // ───────────────────────────────────────────────────────────
    function startGiveaway() {
        window.selectedFancyNameMethod = detectBestExtractor();
        clearWinnersStatusUI();

        if (!giveawayForm.checkValidity()) {
            giveawayForm.reportValidity();
            return;
        }

        // Normalize and validate the giveaway amount separately so we tolerate
        // locale-specific separators like ' . , non-breaking spaces, etc.
        const rawAmount = coinInput.value;
        const cleanValue = rawAmount.replace(/[^0-9]/g, '');
        if (!cleanValue) {
            window.alert("Please enter a valid numeric giveaway amount.");
            return;
        }
        const amountInt = parseInt(cleanValue, 10);
        if (!Number.isFinite(amountInt) || amountInt <= 0) {
            window.alert("Please enter a giveaway amount greater than zero.");
            return;
        }

        // --- block/confirm if someone else already has a giveaway running ---
        const myName =
              document.getElementsByClassName("top-nav__username")[0]?.children[0]?.textContent.trim() || "";

        const other = findOtherActiveGiveawayInChat(myName, 250);
        if (other) {
            const endsIn =
                  other.endsAt && other.endsAt > Date.now()
            ? parseTime(other.endsAt - Date.now())
            : "";

            const extra = endsIn ? ` (ends in ~${endsIn})` : "";
            const ok = window.confirm(
                `Another giveaway appears to be running, hosted by "${other.host}"${extra}.\n\nStart yours anyway?`
            );
            if (!ok) return;
        }

        if (sponsorsInterval) { clearInterval(sponsorsInterval); sponsorsInterval = null; }
        if (observer) { observer.disconnect(); observer = null; }

        if (chatbox == null) {
            chatbox = document.querySelector(`#${chatboxID}`);
        }

        cacheChatContext();

        startButton.disabled = true;
        coinInput.disabled = true;
        startInput.disabled = true;
        endInput.disabled = true;
        timerInput.disabled = true;
        customMessageInput.disabled = true;
        winnersInput.disabled = true;
        remNumInput.disabled = true;
        reminderEvery.disabled = true;

        //startButton.parentElement.hidden = true;
        entriesWrapper.hidden = false;

        let totalTimeMin = Number(timerInput.value);
        let totalTimeMs = totalTimeMin * 60000;
        let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalTimeMin)[0]);
        if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0;
        const schedule = getReminderSchedule(totalTimeMin, reminderNum);
        const cadenceSec = (reminderNum > 0) ? totalTimeMin * 60 / (reminderNum + 1) : 0;
        let winnersNum = parseInt(winnersInput.value, 10);

        giveawayData = {
            host: document.getElementsByClassName("top-nav__username")[0].children[0].textContent.trim(),
            amount: amountInt,
            startNum: parseInt(startInput.value, 10),
            endNum: parseInt(endInput.value, 10),
            totalEntries: parseInt(endInput.value, 10) - parseInt(startInput.value, 10) + 1,
            winningNumber: null,
            totalSeconds: totalTimeMs / 1000,
            timeLeft: totalTimeMs / 1000,
            endTs: Date.now() + (totalTimeMs),
            winnersNum,
            customMessage: customMessageInput.value,
            hostAdded: amountInt,
            reminderSchedule : schedule,
            reminderNum      : schedule.length,
            reminderFreqSec  : cadenceSec, // <- kept for legacy helpers
            nextReminderSec  : cadenceSec, // <- ditto (first reminder ETA)
            sponsorContribs: {},
            sponsors: [],
        };

        updateRigToggleUI();

        const currentBon = readHostBalance();

        if (currentBon < giveawayData.amount) {
            window.alert(`GIVEAWAY ERROR: The amount entered (${giveawayData.amount}), is above your current BON (${currentBon}). You may need to refresh the page to update your BON amount.`);
            resetGiveaway();
        }
        else {
            giveawayData.winningNumber = getRandomInt(giveawayData.startNum, giveawayData.endNum);

            window.onbeforeunload = function (e) {
                e.preventDefault();
                e.returnValue = "";
                return "";
            };

            let introMessage = `I am hosting a giveaway for [b][color=#ffc00a]${giveawayData.amount.toLocaleString()} BON[/color][/b]. ` +
                `Up to [b][color=#5DE2E7]${giveawayData.winnersNum} ${giveawayData.winnersNum === 1 ? 'winner' : 'winners'}[/color][/b] will be selected. ` +
                `Entries will be open for [b][color=#1DDC5D]${parseTime(totalTimeMs)}[/color][/b]. ` +
                `To enter, submit a whole number [b]between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive.[/b] ` +
                `[b][color=#5DE2E7]${giveawayData.customMessage} [/color][/b]\n` +
                `✨[b][color=#FB4F4F]Gifting BON to the host will add to the pot![/color][/b]✨`;

            if (riggedMode) {
                introMessage += `\n[color=#FF4F9A][b]RIGGED MODE ENGAGED![/b][/color] ` +
                    `[i][color=#FF9AE6]Visual flair only — the math is still fair... probably.[/color][/i] 😈`;
            }

            sendMessage(introMessage);

            // Start the ignore window *and* sponsor tracking right after the intro
            giveawayStartTime = new Date();

            if (window.__activeTracker) window.__activeTracker = null;
            let tracker = new SponsorTracker({ chatroomId, giveawayStartTime, giveawayData });
            window.__activeTracker = tracker;
            tracker.poll().catch(console.error);
            sponsorsInterval = setInterval(() => tracker.poll(), 10_000);

            if (observer) {
                startObserver();
            } else {
                addObserver(giveawayData);
            }

            giveawayData.countdownTimerID = countdownTimer(countdownHeader, giveawayData);

            giveawayData.potUpdater = setInterval(() => {
                coinHeader.innerHTML = `${fmtBON(cleanPotString(giveawayData.amount))} BON`;
                coinHeader.prepend(goldCoins.cloneNode(false));
            }, 5000);

            // Start button → Stop button wiring stays the same...
        }

        // ** TOGGLE BUTTON TO STOP **
        startButton.textContent = "Stop";
        startButton.style.backgroundColor = "#b32525"; // red to indicate Stop
        startButton.title = "This will end the giveaway and send gifts to the winners";
        startButton.disabled = false;
        startButton.onclick = () => {
            endGiveaway();
        };
    }

    function resetGiveaway() {
        entriesWrapper.hidden = true;
        clearWinnersStatusUI();

        countdownHeader.textContent = "";
        countdownHeader.hidden = true;
        startButton.parentElement.hidden = false;

        coinInput.disabled = false;
        startInput.disabled = false;
        endInput.disabled = false;
        timerInput.disabled = false;
        customMessageInput.disabled = false;
        winnersInput.disabled = false;
        remNumInput.disabled = false;
        reminderEvery.disabled = false;

        giveawayForm.reset()

        updateEntries()

        // ——— restore host’s balance display ———
        const hostBalance = readHostBalance();

        // update the header
        coinHeader.textContent = fmtBON(hostBalance);
        coinHeader.prepend(goldCoins.cloneNode(false));

        stopGiveaway();
        // ** RESET BUTTON TO START **
        startButton.textContent = "Start";
        startButton.style.backgroundColor = "#02B008"; // green for Start
        startButton.title = "Start the giveaway";
        startButton.onclick = startGiveaway;
        startButton.disabled = false;
        updateStartButtonConflictIndicator();
    }

    function stopGiveaway() {
        startButton.disabled = true; //prevents stop button from being clicked once giveaway has ended

        // ── timers ──
        if (giveawayData?.countdownTimerID) clearInterval(giveawayData.countdownTimerID);
        if (giveawayData?.potUpdater) clearInterval(giveawayData.potUpdater);
        if (sponsorsInterval) {
            clearInterval(sponsorsInterval);
            sponsorsInterval = null;
        }
        if (window.__activeTracker) window.__activeTracker = null;

        if (observer) { observer.disconnect(); observer = null; }

        if (reminderRetryTimeout) { clearTimeout(reminderRetryTimeout); reminderRetryTimeout = null; }

        // ── growing maps / sets ──
        numberEntries.clear();
        fancyNames.clear();
        userCooldown.clear();
        userCommandLog.clear();
        naughtyWarned.clear();
        liveEnteredThisGiveaway.clear();
        liveSponsorSeenThisGiveaway.clear();
        liveSponsorTotalThisGiveaway.clear();


        // reset rigged visuals for next giveaway
        riggedMode = false;
        if (rigBadge) {
            rigBadge.hidden = true;
            rigBadge.classList.remove('rigged-pulse');
        }
        if (giveawayFrame) {
            giveawayFrame.classList.remove('rigged');
        }

        // ── global event listeners ──
        document.removeEventListener("click", handleOutsideClick);

        giveawayData = null;
        window.onbeforeunload = null;

        updateRigToggleUI();
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 7: Chat Observation + Parsing
    // ───────────────────────────────────────────────────────────
    function addObserver(giveawayData) {
        observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    parseMessage(node);
                }
            }
        });
        startObserver();
    }

    function startObserver() {
        const messageList = document.querySelector(".chatroom__messages");
        if (messageList) {
            observer.observe(messageList, { childList: true });
        }
    }

    function parseMessage(messageNode) {
        // Use global currentHost so other sites keep identical behavior
        const host = currentHost;

        let messageContentElement;

        if (host === "lst.gg") {
            // LST uses .bbcode-rendered for the actual chat text
            messageContentElement =
                messageNode.querySelector(".bbcode-rendered") ||
                messageNode.querySelector(".chatbox-message__content");
        } else {
            // Existing behavior for every other site
            messageContentElement = messageNode.querySelector(".chatbox-message__content");
        }

        if (!messageContentElement) return; // system/bot messages — skip

        let author = getAuthor(messageNode);
        let messageContent = "";
        let fancyName = "";

        try {
            messageContent = messageContentElement.textContent?.trim() || "";
        } catch (e) { messageContent = ""; }

        try {
            const userTag = messageNode.querySelector(".user-tag");
            fancyName = userTag ? userTag.outerHTML : "";
        } catch (e) { fancyName = ""; }

        // Special handling for [IRC:username] messages on onlyencodes
        if (host === "onlyencodes.cc") {
            const ircMatch = messageContent.match(/^\[IRC:([^\]]+)\]\s*(.*)$/i);
            if (ircMatch) {
                const ircUser = ircMatch[1].trim();
                messageContent = ircMatch[2].trim();

                if (regNum.test(messageContent)) {
                    handleEntryMessage(parseInt(messageContent, 10), ircUser, fancyName, giveawayData);
                } else if (messageContent.startsWith("!")) {
                    handleGiveawayCommands(ircUser, messageContent, fancyName, giveawayData);
                }
                return;
            }
        }

        // Standard message parsing
        if (regNum.test(messageContent)) {
            handleEntryMessage(parseInt(messageContent, 10), author, fancyName, giveawayData);
        } else if (messageContent.startsWith("!")) {
            handleGiveawayCommands(author, messageContent, fancyName, giveawayData);
        }
    }

    function getAuthor(msgNode) {
        // Try Alpine markup: find first visible span in .user-tag__link
        const alpineSpan = msgNode.querySelector('.user-tag__link span[x-show]');
        if (alpineSpan && alpineSpan.textContent.trim() && alpineSpan.offsetParent !== null) {
            return alpineSpan.textContent.trim();
        }
        // Fallback: any visible .user-tag__link span (covers edge cases)
        const visibleSpan = Array.from(msgNode.querySelectorAll('.user-tag__link span'))
        .find(span => span.offsetParent !== null && span.textContent.trim() && span.textContent.trim() !== 'Unknown');
        if (visibleSpan) return visibleSpan.textContent.trim();

        return ''; // couldn't find username, should not happen in Alpine
    }

    function extractorAlpine(msgNode) {
        try {
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: start", msgNode);
            const userTag = msgNode.querySelector('address.user-tag');
            if (!userTag) {
                if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: no userTag found");
                return null;
            }

            const userLink = userTag.querySelector('a.user-tag__link');
            if (!userLink) {
                if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: no userLink found");
                return null;
            }

            const userSpan = Array.from(userLink.querySelectorAll('span'))
            .find(span => span.offsetParent !== null && span.textContent.trim().length > 0);
            const usernameText = userSpan ? userSpan.textContent.trim() : '';
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: usernameText =", usernameText);

            const userTagStyles = getComputedStyle(userTag);
            const bgImage = userTagStyles.backgroundImage;
            const bgRepeat = userTagStyles.backgroundRepeat;
            const bgPosition = userTagStyles.backgroundPosition;
            const bgSize = userTagStyles.backgroundSize;

            let backgroundStyle = '';
            if (bgImage && bgImage !== 'none') {
                const url = bgImage.slice(5, -2);
                backgroundStyle =
                    `background-image: url('${url}'); ` +
                    `background-repeat: ${bgRepeat}; ` +
                    `background-position: ${bgPosition}; ` +
                    `background-size: ${bgSize}; `;
                if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: backgroundStyle =", backgroundStyle);
            }

            const color = getComputedStyle(userLink).color;
            const wrapperStyle = `${backgroundStyle} padding-left: 20px; display: inline-block;`;
            const linkStyle = `color: ${color};`;

            const classes = Array.from(userLink.classList).filter(c => c !== 'user-tag__link');
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: classes =", classes);

            const html = `<address class="user-tag" style="${wrapperStyle}">
            <a href="${userLink.href}" class="user-tag__link ${classes.join(' ')}" style="${linkStyle}">${usernameText}</a>
        </address>`;

            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorAlpine: html generated");
            return html;

        } catch (err) {
            if (DEBUG_SETTINGS.verify_extractor) console.error("extractorAlpine: error", err);
            return null;
        }
    }

    function extractorOnlyEncodes(msgNode) {
        try {
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: start", msgNode);
            const userTag = msgNode.querySelector('address.user-tag');
            if (!userTag) {
                if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: no userTag found");
                return null;
            }

            const userLink = userTag.querySelector('a.user-tag__link');
            if (!userLink) {
                if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: no userLink found");
                return null;
            }

            const userSpan = userLink.querySelector('span');
            const usernameText = userSpan ? userSpan.textContent.trim() : '';
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: usernameText =", usernameText);

            const userTagStyles = getComputedStyle(userTag);
            const bgImage = userTagStyles.backgroundImage;
            const bgRepeat = userTagStyles.backgroundRepeat;
            const bgPosition = userTagStyles.backgroundPosition;
            const bgSize = userTagStyles.backgroundSize;

            let backgroundStyle = '';
            if (bgImage && bgImage !== 'none') {
                const url = bgImage.slice(5, -2);
                backgroundStyle =
                    `background-image: url('${url}'); ` +
                    `background-repeat: ${bgRepeat}; ` +
                    `background-position: ${bgPosition}; ` +
                    `background-size: ${bgSize}; `;
                if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: backgroundStyle =", backgroundStyle);
            }

            const color = getComputedStyle(userLink).color;
            const wrapperStyle = `${backgroundStyle} padding-left: 20px; display: inline-block;`;
            const linkStyle = `color: ${color};`;

            const classes = Array.from(userLink.classList).filter(c => c !== 'user-tag__link');
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: classes =", classes);

            const html = `<address class="user-tag" style="${wrapperStyle}">
            <a href="${userLink.href}" class="user-tag__link ${classes.join(' ')}" style="${linkStyle}">${usernameText}</a>
        </address>`;

            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorOnlyEncodes: html generated");
            return html;

        } catch (err) {
            if (DEBUG_SETTINGS.verify_extractor) console.error("extractorOnlyEncodes: error", err);
            return null;
        }
    }

    function extractorFallback(msgNode) {
        try {
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorFallback: start", msgNode);
            const usernameSpan = msgNode.querySelector('.user-tag__link span[x-show]');
            const userName = usernameSpan?.textContent.trim() || "UnknownUser";
            if (DEBUG_SETTINGS.verify_extractor) console.debug("extractorFallback: userName =", userName);
            return `<span style="color:#d85e27; font-weight:bold;">${userName}</span>`;
        } catch (err) {
            if (DEBUG_SETTINGS.verify_extractor) console.error("extractorFallback: error", err);
            return "UnknownUser";
        }
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 8: Entry Management
    // ───────────────────────────────────────────────────────────
    function handleEntryMessage(number, author, fancyName, giveawayData) {
        // Safety: no active giveaway
        if (!giveawayData) return;

        // Silently ignore ultra-fast entries right after the giveaway starts.
        // This filters out auto-join scripts without punishing or warning anyone.
        if (isWithinEntryIgnoreWindow()) {
            return;
        }

        // --- Naughty list hard-block (except host & staff) -----------
        const isHost = author === giveawayData.host;
        const isModerator = isAdmin(fancyName);

        if (naughtySet.has(author.toLowerCase()) && !isHost && !isModerator) {
            if (!naughtyWarned.has(author)) {
                sendMessage(
                    `[color=#d85e27]${sanitizeNick(author)}[/color], ` +
                    `you are on the [b]naughty list[/b] and may not ` +
                    `enter the giveaway or use its commands.`
                );
                naughtyWarned.add(author);
            }
            return;
        }

        // ── Spam detection: treat number entries like commands ──
        // (shares the same window + cooldown as !time / !free / etc.)
        if (applyCooldown(author)) {
            // User is in cooldown or just got locked out; ignore this entry
            return;
        }

        // sanitize the raw author names to avoid IRC pings
        const safeAuthor = sanitizeNick(author);

        // Precompute suggestion text for any duplicate cases
        const suggestion = formatFreeNumberSuggestion(giveawayData);

        for (let [msgAuthor, msgValue] of numberEntries.entries()) {
            const safeOther = sanitizeNick(msgAuthor);

            if (msgAuthor === author) {
                const repeatMessage =
                      `Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#32cd53]you[/color] already entered with number [color=#DC3D1D][b]${msgValue}[/b][/color]!`
                sendMessage(repeatMessage);
                return;
            } else if (msgValue === number) {
                const repeatMessage =
                      `🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#32cd53]${safeOther}[/color] already entered with number [color=#DC3D1D][b]${number}[/b][/color]!` +
                      suggestion;
                sendMessage(repeatMessage);
                return;
            }
        }

        if (number < giveawayData.startNum || number > giveawayData.endNum) {
            const outOfBoundsMessage =
                  `🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but the number [color=#DC3D1D][b]${number}[/b][/color] is outside of the given range! Enter a number between [color=#DC3D1D][b]${giveawayData.startNum}[/b] and [b]${giveawayData.endNum}[/b][/color]!`;
            sendMessage(outOfBoundsMessage);
            return;
        }

        if (!numberEntries.has(author)) {
            // when you actually add them, you still store the real author internally
            addNewEntry(author, fancyName, number);
        }

        if (!GENERAL_SETTINGS.suppress_entry_replies) {
            const timeLeftStr = parseTime(giveawayData.timeLeft * 1000);
            const rigHint = rigNote("(entry logged under [b]highly suspicious[/b] conditions) 😈");

            const msg =
                  `[color=#d85e27]${safeAuthor}[/color] has entered with ` +
                  `the number [color=#DC3D1D][b]${number}[/b][/color]! ` +
                  `Time remaining: [b][color=#1DDC5D]${timeLeftStr}[/color][/b].` +
                  rigHint;
            sendMessage(msg);
        }
    }

    function addNewEntry(author, fancyName, number) {
        numberEntries.set(author, number);
        fancyNames.set(author, fancyName);

        recordLiveEntry(author); // ✅ live stats update

        updateEntries();
    }

    function updateEntries() {
        if (!window.selectedFancyNameMethod) {
            window.selectedFancyNameMethod = detectBestExtractor();
        }

        const extractor = extractors[window.selectedFancyNameMethod];
        const messageNodesByUser = getMessageNodesByUser(window.selectedFancyNameMethod);

        let tableHTML = "<thead><tr><th>User</th><th>Entry #</th></tr></thead><tbody>";

        numberEntries.forEach((entry, author) => {
            const msgNode = messageNodesByUser[author];
            let fancyNameHTML = "";
            try {
                if (msgNode) {
                    fancyNameHTML = extractor(msgNode);
                } else {
                    fancyNameHTML = sanitizeNick(author);
                }
            } catch {
                fancyNameHTML = sanitizeNick(author);
            }

            if (!fancyNameHTML) fancyNameHTML = sanitizeNick(author);

            tableHTML += `<tr data-entry-key="${encodeURIComponent(author.toLowerCase())}">
                 <td>${fancyNameHTML}</td>
                 <td>${entry}</td>
              </tr>`;
        });

        document.getElementById("entriesTable").innerHTML = tableHTML + "</tbody>";
    }

    function getMessageNodesByUser(selectedFancyNameMethod) {
        const map = {};
        document.querySelectorAll('.chatbox-message').forEach(node => {
            let user = null;
            if (selectedFancyNameMethod === "onlyencodes") {
                user = node.querySelector('address.user-tag > a.user-tag__link > span')?.textContent.trim();
            } else {
                user = node.querySelector('.user-tag__link span[x-show]')?.textContent.trim();
            }
            if (user) map[user] = node;
        });
        return map;
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 9: Sponsorhip Polling and Parsing
    // ───────────────────────────────────────────────────────────

    // Parse a BON gift chat message into { gifter, recipient, amount }
    function parseGiftMessage(html) {
        const doc = new DOMParser().parseFromString(html, "text/html");
        const links = Array.from(doc.querySelectorAll("a"));
        const text = doc.body.textContent || "";
        const m = text.match(/has gifted\s*([\d.]+)\s*BON/i);

        return m && links.length >= 2
            ? {
            gifter: links[0].textContent.trim(),
            recipient: links[1].textContent.trim(),
            amount: parseFloat(m[1])
        }
        : {};
    }

    class SponsorTracker {
        /** @param {{chatroomId:string, giveawayStartTime:Date, giveawayData:Object}} opts */
        constructor({ chatroomId, giveawayStartTime, giveawayData }) {
            this.chatroomId = chatroomId;
            this.giveawayStartTs = giveawayStartTime.getTime();
            this.data = giveawayData;

            this.lastMsgId = 0; // API cursor
            this.processedIds = new Set(); // de-dupe
            this.buffer = []; // gifts waiting to be announced
        }

        /* ---- poll for any chat messages since last cursor ---- */
        async fetchNew() {
            const url = new URL(`/api/chat/messages/${this.chatroomId}`, location.origin);
            if (this.lastMsgId) url.searchParams.set("after_id", this.lastMsgId);

            const res = await fetch(url, { credentials: "include" });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);

            return (await res.json()).data;
        }

        /* ---- called by the 10-second timer ---- */
        async poll() {
            let messages;
            try {
                messages = await this.fetchNew();
            } catch (e) {
                if (DEBUG_SETTINGS.log_chat_messages) console.error("Sponsor API error:", e);
                return;
            }

            const isLst = location.hostname === "lst.gg";

            /* — filter new, unprocessed gift messages — */
            const gifts = messages.filter(m => {
                if (this.processedIds.has(m.id)) return false;
                if (Date.parse(m.created_at) <= this.giveawayStartTs) return false;

                const msgText = m.message || "";

                if (isLst) {
                    // LST: don't rely on bot flags, just gift text + time
                    return msgText.includes("gifted");
                }

                // Existing behavior for other sites
                const botName = (m.bot?.name || "").toLowerCase();
                const isSystemBot = !!m.bot?.is_systembot || botName.includes("oe+");
                return isSystemBot && msgText.includes("has gifted");
            });

            // advance cursor for all messages, like before
            for (const m of messages) {
                if (m.id > this.lastMsgId) this.lastMsgId = m.id;
            }

            /* parse & buffer gifts */
            for (const msg of gifts) {
                this.processedIds.add(msg.id);

                const { gifter, recipient, amount } = this.parseGiftMsg(msg.message);
                if (!gifter || recipient !== this.data.host) continue; // only count gifts to the host

                this.buffer.push({ gifter, amount });
                this.applyGift(gifter, amount); // update totals immediately
            }

            /* send ONE summary line if anything new arrived */
            if (this.buffer.length) this.flushBuffer();
        }

        /* ---- pull gifter / recipient / amount from the HTML blob ---- */
        parseGiftMsg(html) {
            return parseGiftMessage(html);
        }

        /* ---- update pot + per-sponsor running totals ---- */
        applyGift(gifter, amount) {
            this.data.amount += amount;
            this.data.sponsorContribs[gifter] =
                (this.data.sponsorContribs[gifter] || 0) + amount;

            if (!this.data.sponsors.includes(gifter)) {
                this.data.sponsors.push(gifter);
            }

            recordLiveSponsorGift(gifter, amount); // ✅ live sponsor stats update
        }

        /* ---- build a single chat line & clear buffer ---- */
        flushBuffer() {
            const grouped = this.buffer.reduce((acc, { gifter, amount }) => {
                acc[gifter] = (acc[gifter] || 0) + amount;
                return acc;
            }, {});

            const sponsorNames = Object.keys(grouped);
            const deltaTotal = this.buffer.reduce((s, g) => s + g.amount, 0).toLocaleString();
            const potTotal = Number(cleanPotString(this.data.amount)).toLocaleString();

            let msg;
            if (sponsorNames.length === 1) {
                /* single sponsor */
                const g = sponsorNames[0];
                const amt = grouped[g].toLocaleString();
                msg =
                    `✨ [color=#1DDC5D][b]${g}[/b][/color] is sponsoring ` +
                    `[color=#DC3D1D][b]${amt}[/b][/color] additional BON! ` +
                    `Total pot is now [b][color=#ffc00a]${potTotal} BON[/color][/b]`;
            } else {
                /* multiple sponsors in this batch */
                const parts = sponsorNames.map(
                    g =>
                    `[color=#1DDC5D][b]${g}[/b][/color] ` + // green name
                    `([color=#DC3D1D][b]${grouped[g].toLocaleString()}[/b][/color])` // red amount
                );

                msg =
                    `✨ ${parts.join(", ")} have just added ` +
                    `[color=#DC3D1D][b]${deltaTotal} BON[/b][/color]! ` +
                    `Total pot is now [b][color=#ffc00a]${potTotal} BON[/color][/b]`;
            }

            sendMessage(msg);
            this.buffer.length = 0; // clear the batch
        }
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 10: Command Handling
    // ───────────────────────────────────────────────────────────
    function handleGiveawayCommands(author, messageContent, fancyName, giveawayData) {
        // Fast-exit when it’s not a command
        if (!messageContent.startsWith("!")) return;

        // Parse command + args first
        const args = messageContent.slice(1).trim().split(/\s+/);
        const command = (args.shift() || "").toLowerCase();

        // Early-ignore window for *entry* commands right after giveaway starts.
        // This ensures auto-join scripts are dropped before naughty/cooldown logic.
        const isEntryCommand =
              command === "random"; // add more here later if you introduce other entry commands

        if (isEntryCommand && isWithinEntryIgnoreWindow()) {
            return;
        }

        // --- Naughty list hard-block (but allow host & admins)
        const isHost = giveawayData && author === giveawayData.host;
        const isModerator = isAdmin(fancyName);

        if (naughtySet.has(author.toLowerCase()) && !isHost && !isModerator) {
            if (!naughtyWarned.has(author)) {
                sendMessage(
                    `[color=#d85e27]${sanitizeNick(author)}[/color], ` +
                    `you are on the [b]naughty list[/b] and may not ` +
                    `enter the giveaway or use its commands.`
                );
                naughtyWarned.add(author);
            }
            return; // everyone else is still blocked
        }

        if (!validCommands.has(command)) return; // Unsupported

        if (applyCooldown(author)) return; // Spammer – ignored

        const handler = COMMAND_HANDLERS[command];
        if (!handler) return; // No handler defined (or gated-out)

        handler({
            author,
            fancyName,
            args,
            giveawayData,
            safeAuthor: sanitizeNick(author),
            safeHost: sanitizeNick(giveawayData ? giveawayData.host : "")
        });
    }

    /** Rate‑limit users – returns `true` when the caller must be ignored. */
    function applyCooldown(author) {
        const now = Date.now();
        const lockoutExpires = userCooldown.get(author) || 0;
        if (now < lockoutExpires) return true;

        const log = (userCommandLog.get(author) || []).filter(ts => now - ts < COMMAND_WINDOW_MS);
        log.push(now);
        userCommandLog.set(author, log);

        if (log.length > MAX_COMMANDS_PER_WINDOW) {
            const excess = log.length - MAX_COMMANDS_PER_WINDOW;
            const penaltySec = BASE_PENALTY_SECONDS * excess;
            userCooldown.set(author, now + penaltySec * 1000);
            userCommandLog.delete(author);

            sendMessage(`[color=red][b]Spamming detected! ${sanitizeNick(author)} locked out for ${penaltySec} seconds.[/b][/color]`);
            return true;
        }
        return false;
    }

    function isAdmin(fancyName) {
        if (!fancyName) return false;
        try {
            const div = document.createElement('div');
            div.innerHTML = fancyName;
            const a = div.querySelector('a.user-tag__link');
            if (!a) return false;
            const title = a.getAttribute('title')?.toLowerCase() || '';
            return title.includes('leader') || title.includes('onlyguardians') || title.includes('administrator') || title.includes('admin') || title.includes('moderator') || title.includes('mod');
        } catch {
            return false;
        }
    }

    const COMMAND_HANDLERS = {
        /* Public commands */
        time(ctx) {
            const { args, author, fancyName, giveawayData } = ctx;

            const addMinutes = hostAdjustTime(+1);
            const removeMinutes = hostAdjustTime(-1);

            // no args  → show countdown
            if (args.length === 0) {
                sendMessage(
                    `Time left: [b][color=#1DDC5D]${parseTime(
                        giveawayData.timeLeft * 1000
                    )}[/color][/b] ⏳`
                );
                return;
            }

            const action = (args[0] || "").toLowerCase(); // "add" / "remove"
            const minutes = parseFloat(args[1]);
            const isPriv = author === giveawayData.host || isAdmin(fancyName);

            if (!isPriv) return; // silently ignore non-host/non-admin

            if (action !== "add" && action !== "remove") {
                sendMessage("[color=red]Usage:[/color] !time add|remove <minutes>");
                return;
            }

            if (isNaN(minutes) || minutes <= 0) {
                sendMessage("[color=red]Usage:[/color] !time add|remove <minutes>");
                return;
            }

            // Pass only the minutes to the shared adjuster
            const ctxWithArg = { ...ctx, args: [String(minutes)] };

            if (action === "add") {
                addMinutes(ctxWithArg);
            } else {
                removeMinutes(ctxWithArg);
            }
        },

        entries({ giveawayData }) {
            const taken = numberEntries.size;
            const total = giveawayData.totalEntries;
            const free = total - taken;

            if (taken === 0) {
                sendMessage(`[b]No entries yet! ${total} numbers available.[/b]`);
                return;
            }

            // Sort by entry number (ascending)
            const list = Array.from(numberEntries.entries())
            .sort(([, numA], [, numB]) => numA - numB)
            .map(([user, num]) =>
                 `[color=#d85e27][b]${sanitizeNick(user)}[/b][/color]: [b]${num}[/b]`
                );

            sendMessage(
                `📋 Entries – ${taken}/${total} ` +
                `[b]([color=#1DDC5D]${free} free[/color][/b]): ${list.join(", ")}`
            );
        },

        help: showHelp,
        commands: showHelp,

        stats(ctx) {
            const target = (ctx.args[0] || ctx.author || "").trim();
            const stats = loadGiveawayStats();
            const key = normUserKey(target);
            const rec = key && stats.users ? stats.users[key] : null;
            if (!rec) {
                sendMessage(`No saved stats yet for ${safeNameForChat(target)}.`);
                return;
            }
            const entered = rec.entered || 0;
            const wins = rec.wins || 0;
            const losses = rec.losses || 0;
            const wr = entered ? ((wins / entered) * 100).toFixed(1) : "0.0";

            // If the giveaway host calls !stats (for themselves), also show how much they've given away (host pot only; excludes sponsors).
            const isHostCaller = !!(ctx.giveawayData && normUserKey(ctx.author) === normUserKey(ctx.giveawayData.host));
            const isSelfQuery = !ctx.args[0] || normUserKey(target) === normUserKey(ctx.author);

            const parts = [
                `Entered: [b]${fmtBON(entered)}[/b]`,
                `Wins: [b][color=#1DDC5D]${fmtBON(wins)}[/color][/b]`,
                `Losses: [b][color=#d85e27]${fmtBON(losses)}[/color][/b]`,
                `Winrate: [b]${wr}%[/b]`
            ];
            if (rec.totalWon) parts.push(`Total won: [b][color=#ffc00a]${fmtBON(rec.totalWon)} BON[/color][/b]`);
            if (rec.biggestWin) parts.push(`Biggest win: [b][color=#ffc00a]${fmtBON(rec.biggestWin)} BON[/color][/b]`);
            if (rec.sponsoredTotal) parts.push(`Sponsored: [b][color=#00abff]${fmtBON(rec.sponsoredTotal)} BON[/color][/b]`);
            if (rec.hosted) parts.push(`Hosted: [b]${fmtBON(rec.hosted)}[/b]`);

            if (isHostCaller && isSelfQuery) {
                parts.push(`Given away: [b][color=#ffc00a]${fmtBON(rec.hostedTotal || 0)} BON[/color][/b]`);
            }

            sendMessage(`📊 Stats for [b]${safeNameForChat(rec.name || target)}[/b] — ` + parts.join(" | "));
        },

        topwinners(ctx) {
            const n = Math.min(STATS_MAX_TOP_N, Math.max(1, parseInt(ctx.args[0] || STATS_DEFAULT_TOP_N, 10) || STATS_DEFAULT_TOP_N));
            const rows = getLeaderboardRows(
                (a, b) => (b.wins - a.wins) || (b.totalWon - a.totalWon) || (b.entered - a.entered),
                n,
                u => (u.wins || 0) > 0
            );
            if (!rows.length) {
                sendMessage("No winner stats saved yet.");
                return;
            }
            const out = rows.map((u, i) => `${i + 1}) ${safeNameForChat(u.name)} ([color=#1DDC5D]${fmtBON(u.wins)}W[/color], ${fmtBON(u.totalWon)} BON)`);
            sendMessage(`🏆 Top winners: ` + out.join(" | "));
        },

        topsponsors(ctx) {
            const n = Math.min(STATS_MAX_TOP_N, Math.max(1, parseInt(ctx.args[0] || STATS_DEFAULT_TOP_N, 10) || STATS_DEFAULT_TOP_N));
            const rows = getLeaderboardRows(
                (a, b) => (b.sponsoredTotal - a.sponsoredTotal) || (b.sponsorCount - a.sponsorCount),
                n,
                u => (u.sponsoredTotal || 0) > 0
            );
            if (!rows.length) {
                sendMessage("No sponsor stats saved yet.");
                return;
            }
            const out = rows.map((u, i) => `${i + 1}) ${safeNameForChat(u.name)} ([color=#00abff]${fmtBON(u.sponsoredTotal)} BON[/color], ${fmtBON(u.sponsorCount)}x)`);
            sendMessage(`💸 Top sponsors: ` + out.join(" | "));
        },

        toplosers(ctx) {
            const n = Math.min(STATS_MAX_TOP_N, Math.max(1, parseInt(ctx.args[0] || STATS_DEFAULT_TOP_N, 10) || STATS_DEFAULT_TOP_N));
            const rows = getLeaderboardRows(
                (a, b) => (b.losses - a.losses) || (b.entered - a.entered) || (a.wins - b.wins),
                n,
                u => (u.losses || 0) > 0
            );
            if (!rows.length) {
                sendMessage("No loser stats saved yet.");
                return;
            }
            const out = rows.map((u, i) => {
                const entered = u.entered || 0;
                const wins = u.wins || 0;
                const losses = u.losses || 0;
                const wr = entered ? ((wins / entered) * 100).toFixed(1) : "0.0";
                return `${i + 1}) ${safeNameForChat(u.name)} ([color=#d85e27]${fmtBON(losses)}L[/color] / ${fmtBON(entered)} entered, ${wr}% WR)`;
            });
            sendMessage(`💀 Top losers: ` + out.join(" | "));
        },

        gift({ safeHost }) {
            sendMessage(`To send a gift type: /gift ${safeHost} amount message`);
        },

        bon({ giveawayData }) {
            const rigTag = rigNote("(pot size [b]carefully curated[/b] by our rigging department)");
            sendMessage(
                `Giveaway Amount: [b][color=#FFB700]${giveawayData.amount.toLocaleString()}[/color][/b]` +
                rigTag
            );
        },

        range({ giveawayData }) {
            const rigTag = rigNote("(this range has been [b]pre-approved[/b] for maximum riggability)");
            sendMessage(
                `Numbers between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive are valid.` +
                rigTag
            );
        },

        lucky({ safeAuthor, giveawayData }) {
            // Safety: no active giveaway
            if (!giveawayData) {
                sendMessage("There is no active giveaway right now.");
                return;
            }

            if (GENERAL_SETTINGS.disable_lucky) {
                sendMessage(
                    `🚫 Sorry [color=#d85e27]${safeAuthor}[/color], ` +
                    `[color=#999999]!lucky[/color] has been disabled for this giveaway.`
                );
                return;
            }

            const luckyNum = getLuckyNumber(giveawayData);
            const rigHint = rigNote("(approved by the Official Rigging Committee™) ✅");

            sendMessage(
                `The current giveaway lucky number is: ` +
                `[b][color=#1DDC5D]${luckyNum}[/color][/b].` +
                rigHint
            );
        },

        rig(ctx) {
            const { author, fancyName, giveawayData: ctxGiveawayData } = ctx;
            if (!ctxGiveawayData) return;
            if (!isHostOrAdmin(author, fancyName, ctxGiveawayData.host)) return;

            // Only treat as "active giveaway" if the real global giveawayData is set
            const hasActiveGiveaway = !!giveawayData;

            if (!riggedMode) {
                riggedMode = true;
                if (rigBadge) {
                    rigBadge.hidden = false;
                    rigBadge.classList.add('rigged-pulse');
                }
                if (giveawayFrame) {
                    giveawayFrame.classList.add('rigged');
                }

                updateRigToggleUI();

                // Only announce in chat if a giveaway is actually running
                if (hasActiveGiveaway) {
                    sendMessage(
                        `[color=#FF4F9A][b]RIGGED MODE ENGAGED![/b][/color] ` +
                        `[i][color=#FF9AE6]Visual flair only — the math is still fair... probably.[/color][/i] 😈`
                    );
                }
            } else {
                if (hasActiveGiveaway) {
                    sendMessage(
                        `[color=#FF4F9A][b]RIGGED MODE is already active![/b][/color]`
                    );
                }
            }
        },

        unrig(ctx) {
            const { author, fancyName, giveawayData: ctxGiveawayData } = ctx;
            if (!ctxGiveawayData) return;
            if (!isHostOrAdmin(author, fancyName, ctxGiveawayData.host)) return;

            const hasActiveGiveaway = !!giveawayData;

            if (riggedMode) {
                riggedMode = false;
                if (rigBadge) {
                    rigBadge.hidden = true;
                    rigBadge.classList.remove('rigged-pulse');
                }
                if (giveawayFrame) {
                    giveawayFrame.classList.remove('rigged');
                }

                updateRigToggleUI();

                if (hasActiveGiveaway) {
                    sendMessage(
                        `[color=#32cd53][b]Rigged mode disabled.[/b][/color] ` +
                        `[i][color=#A0E7AF]Back to boring, fully transparent fairness.[/color][/i] 😒`
                    );
                }
            } else {
                if (hasActiveGiveaway) {
                    sendMessage(
                        `[color=#32cd53][b]Rigged mode isn&#39;t enabled.[/b][/color]`
                    );
                }
            }
        },

        random(ctx) {
            const { author, safeAuthor, fancyName, giveawayData } = ctx;

            if (GENERAL_SETTINGS.disable_random) {
                sendMessage(`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#999999]!random[/color] has been disabled for this giveaway.`);
                return;
            }
            const userNumber = numberEntries.get(author);
            if (userNumber !== undefined) {
                sendMessage(`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], but [color=#32cd53]you[/color] already entered with number [color=#DC3D1D][b]${userNumber}[/b][/color]!`);
                return;
            }

            const takenNumbers = new Set(numberEntries.values());
            const availableNumbers = [];
            for (let n = giveawayData.startNum; n <= giveawayData.endNum; ++n) {
                if (!takenNumbers.has(n)) availableNumbers.push(n);
            }
            if (availableNumbers.length === 0) {
                sendMessage("All numbers are taken — no free numbers left!");
                return;
            }
            const randomNum = availableNumbers[Math.floor(Math.random() * availableNumbers.length)];

            addNewEntry(author, fancyName, randomNum);
            const timeLeftStr = parseTime(giveawayData.timeLeft * 1000);
            const rigHint = rigNote("(chosen by our [b]totally unbiased[/b] chaos engine)");
            sendMessage(
                `[color=#d85e27]${safeAuthor}[/color] has entered with the number ` +
                `[color=#DC3D1D][b]${randomNum}[/b][/color]! Time remaining: ` +
                `[b][color=#1DDC5D]${timeLeftStr}[/color][/b].` +
                rigHint
            );
        },

        number({ author, safeAuthor }) {
            const userNumber = numberEntries.get(author);
            if (userNumber !== undefined) {
                sendMessage(`[color=#d85e27]${safeAuthor}[/color] your number is [color=#DC3D1D][b]${userNumber}[/b][/color]`);
            } else {
                sendMessage(`[color=#d85e27]${safeAuthor}[/color] you are not currently in the giveaway.`);
            }
        },

        free({ safeAuthor, giveawayData }) {
            if (GENERAL_SETTINGS.disable_free) {
                sendMessage(`🚫 Sorry [color=#d85e27]${safeAuthor}[/color], !free disabled`);
                return;
            }

            const sample = getFreeNumberSample(giveawayData, 5);

            if (!sample.length) {
                sendMessage("There are no free numbers left!");
                return;
            }

            const rigHint = rigNote("(these are some [b]suspiciously good[/b] numbers, trust me...) 😏");
            sendMessage(`Free numbers: ${sample.join(", ")}.` + rigHint);
        },

        /* Fun commands for upload.cx */
        suckur: funUpload("Placeholder™"),
        ruckus: funUpload("Suckur!"),
        ick: funUpload(`WillWa loves the [b][color=BLUE]B[/color][color=#FFFFFF]R[/color][color=#C8102E]I[/color][color=#FFFFFF]T[/color][color=#C8102E]I[/color][color=BLUE]S[/color][color=#FFFFFF]H[/color]`),
        corigins: funUpload("🦅 🇺🇸 🦅 🇺🇸 🦅 🇺🇸"),
        lejosh: funUpload("🥖 🇫🇷 🥖 🇫🇷 🥖 🇫🇷"),
        bloom: funUpload("🫎 🇨🇦 🫎 🇨🇦 🫎 🇨🇦"),
        dawg: funUpload("🐑 🏴 🐑 🏴 🐑 🏴"),
        greglechin: funUpload("🦘 🇦🇺 🦘 🇦🇺 🦘 🇦🇺"),

        /* Host + Admin commands */
        addbon: hostAddBon,

        reminder(ctx) {
            if (ctx.author === ctx.giveawayData.host) sendReminder();
        },

        winners(ctx) {
            const {author, fancyName, args, giveawayData} = ctx;
            if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;
            const newCount = parseInt(ctx.args[0], 10);
            if (isNaN(newCount) || newCount < 1 || newCount > MAX_WINNERS) {
                sendMessage(`[color=red]Usage:[/color] !winners 1‑${MAX_WINNERS}`);
                return;
            }
            ctx.giveawayData.winnersNum = newCount;
            winnersInput.value = newCount;
            sendMessage(`Number of winners set to [color=#1DDC5D][b]${newCount}[/b][/color].`);
        },

        addtime: hostAdjustTime(+1),
        removetime: hostAdjustTime(-1),

        naughty(ctx) {
            const {author, fancyName, args, giveawayData} = ctx;
            if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;

            const sub = (args.shift() || "").toLowerCase();
            const target = (args.shift() || "");

            const key = target.toLowerCase(); // key we store/match on

            switch (sub) {
                case "add": {
                    if (!key) { sendMessage("[color=red]Usage:[/color] !naughty add username"); return; }

                    if (key === giveawayData.host.toLowerCase()) {
                        sendMessage(
                            `[color=red][b]The host can't be added to the naughty list![/b][/color]`
                        );
                        return;
                    }
                    naughtySet.add(key); // save in LS
                    saveNaughty();

                    // remove any existing entry (fast exact, then fallback loop)
                    let removed = numberEntries.delete(key); // exact-case fast path
                    if (!removed) {
                        for (const user of numberEntries.keys()) {
                            if (user.toLowerCase() === key) {
                                numberEntries.delete(user);
                                fancyNames.delete(user);
                                removed = true;
                                break;
                            }
                        }
                    }
                    if (removed) updateEntries(); // refresh table only when needed

                    sendMessage(`👮 [color=#FFDE59]${fmtUserList([target])} added to the naughty list and removed from the giveaway.[/color]`);
                    break;
                }


                case "remove":
                    if (!key) { sendMessage("[color=red]Usage:[/color] !naughty remove username"); return; }
                    naughtySet.delete(key); saveNaughty();
                    sendMessage(`🥳 [color=#7DDA58]${fmtUserList([target])} removed from the naughty list![/color]`);
                    break;

                case "list":
                    sendMessage(naughtySet.size
                                ? `[color=#FFDE59]Naughty list: [b]${fmtUserList([...naughtySet])}[/b][/color]`
                                : "Naughty list is empty.");
                    break;

                default:
                    sendMessage("[color=red]Usage:[/color] !naughty (add|remove|list) username");
            }
        },


        end(ctx) {
            const {author, fancyName, args, giveawayData} = ctx;
            // If host, always allow
            if (author === giveawayData.host) {
                endGiveaway();
                return;
            }
            // If admin (not host), must specify whose to end
            if (isAdmin(fancyName)) {
                if (!args.length || args[0] !== giveawayData.host) {
                    sendMessage(`[color=red]Admins must specify whose giveaway to end. Example: !end ${sanitizeNick(giveawayData.host)}[/color]`);
                    return;
                }
                endGiveaway();
            }
        }
    };

    function isHostOrAdmin(author, fancyName, host) {
        return author === host || isAdmin(fancyName);
    }

    function showHelp() {
        const COMMANDS = [
            { name: "random", setting: "disable_random" },
            { name: "time", setting: "disable_time" },
            { name: "free", setting: "disable_free" },
            { name: "number", setting: "disable_number" },
            { name: "lucky", setting: "disable_lucky" },
            { name: "bon", setting: "disable_bon" },
            { name: "range", setting: "disable_range" },
            { name: "entries", setting: "disable_entries" },
            { name: "stats", setting: null },
            { name: "topwinners", setting: null },
            { name: "topsponsors", setting: null },
            { name: "toplosers", setting: null },
            { name: "help", setting: null },
            { name: "commands", setting: null },
        ];

        function fmt(cmd, isDisabled) {
            if (isDisabled) {
                // Use strikethrough and gray
                return `![color=#888888][s][b]${cmd}[/b][/s][/color]`;
            }
            // Enabled formatting
            return `![color=#E50E68][b]${cmd}[/b][/color]`;
        }

        const helpText = "Commands are " + COMMANDS.map(({ name, setting }) =>
                                                        fmt(name, setting && GENERAL_SETTINGS[setting])
                                                       ).join(" - ") + ".";

        sendMessage(helpText);
    }


    function funUpload(text) {
        return () => {
            if (window.location.hostname === "upload.cx") sendMessage(text);
        };
    }

    function hostAddBon({ author, args, giveawayData }) {
        if (author !== giveawayData.host) return;
        const raw = args[0];
        const clean = String(raw ?? "").replace(/[^0-9]/g, "");
        const amount = parseInt(clean, 10);

        if (!Number.isFinite(amount) || amount <= 0) {
            sendMessage("[b][color=red]Invalid usage.[/color] Example: !addbon 100[/b]");
            return;
        }

        giveawayData.amount += amount;

        // ✅ host-only tracking (excludes sponsors)
        giveawayData.hostAdded = (giveawayData.hostAdded || 0) + amount;

        sendMessage(`The host is adding [color=#DC3D1D][b]${amount.toLocaleString()}[/b][/color] BON to the pot! The total is now: [b][color=#ffc00a]${Number(cleanPotString(giveawayData.amount)).toLocaleString()} BON[/color][/b]`);
    }

    function rebuildSchedule() {
        const totalMin = (giveawayData.endTs - Date.now()) / 60000;
        // Use the UI value for number of reminders (clamp if needed)
        let reminderNum = Math.min(Number(remNumInput.value), getReminderLimits(totalMin)[0]);
        if (isNaN(reminderNum) || reminderNum < 0) reminderNum = 0;
        giveawayData.reminderSchedule = getReminderSchedule(totalMin, reminderNum);
        giveawayData.reminderNum = reminderNum;
        // Frequency fields (legacy helpers)
        giveawayData.reminderFreqSec = (reminderNum > 0) ? totalMin * 60 / (reminderNum + 1) : 0;
        giveawayData.nextReminderSec = giveawayData.reminderFreqSec;
        remNumInput.value = reminderNum;
    }

    function hostAdjustTime(sign) {
        // sign = +1 for !addtime / !time add, -1 for !removetime / !time remove
        return ({ author, fancyName, args, giveawayData }) => {
            if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;

            const mins = parseFloat(args[0]);
            if (isNaN(mins) || mins <= 0) {
                sendMessage(
                    "[color=red]Usage:[/color] !time add|remove <minutes> or !addtime|!removetime <minutes>"
                );
                return;
            }

            const deltaMs = sign * mins * 60_000;
            giveawayData.endTs += deltaMs; // move the deadline

            rebuildSchedule(); // rebuild reminder schedule

            giveawayData.timeLeft = Math.max(
                Math.ceil((giveawayData.endTs - Date.now()) / 1000),
                0
            );
            countdownHeader.textContent = parseTime(giveawayData.endTs - Date.now());

            const verb = sign > 0 ? "Added" : "Removed";
            const prep = sign > 0 ? "to" : "from";

            sendMessage(
                `${verb} [color=#DC3D1D][b]${mins}[/b][/color] minute${mins === 1 ? "" : "s"} ${prep} the giveaway. ` +
                `New time left: [b][color=#1DDC5D]${parseTime(
                    giveawayData.endTs - Date.now()
                )}[/color][/b].`
            );
        };
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 11: Winner Selection and Payouts
    // ───────────────────────────────────────────────────────────
    function endGiveaway() {
        // no entries → no winners
        if (numberEntries.size == 0) {
            const emptyMessage = `Unfortunately, no one has entered the giveaway, so no one wins!`
            sendMessage(emptyMessage)
        } else {
            // 1) sponsors shout-out
            if (giveawayData.sponsors.length > 0) {
                const safe = giveawayData.sponsors.map(sponsor => {
                    const amount = giveawayData.sponsorContribs[sponsor] || 0;
                    return `[color=#1DDC5D][b]${sanitizeNick(sponsor)}[/b][/color] ([color=#ffc00a][b]${amount.toLocaleString()} BON[/b][/color])`;
                });
                const sponsorsMessage =
                      `Thank you to all the sponsors! 🥳 ` + safe.join(", ");
                sendMessage(sponsorsMessage);
            }

            // 2) build and sort entries by closeness to winningNumber
            const entries = Array.from(numberEntries.entries())
            .map(([author, guess], idx) => ({
                author,
                guess,
                gap:   Math.abs(guess - giveawayData.winningNumber),
                order: idx
            }))
            .sort((a, b) => a.gap - b.gap || a.order - b.order);

            // Detect and announce ties
            const ties = entries.filter(e => e.gap === entries[0].gap);
            if (ties.length > 1) {
                const tieMessage = ties.map(e => `[b][color=#DC3D1D]${e.author}[/color][/b]`).join(", ");
                sendMessage(`⚠️ We have a tie between ${tieMessage}! [b][color=#DC3D1D]${entries[0].author}[/color][/b] wins the tie-breaker as their entry was submitted first!`);
            }

            // 3) pick top N winners
            const N = Math.min(giveawayData.winnersNum, entries.length);
            const winners = entries.slice(0, N);

            // 4) compute weight-based payouts
            //    weight for rank i (0-based) is (N - i)
            const weights = winners.map((_, i) => N - i);
            const totalWeight = weights.reduce((sum, w) => sum + w, 0);

            // raw amounts, floored to integers
            let allocated = winners.map((_, i) =>
                                        Math.floor(giveawayData.amount * weights[i] / totalWeight)
                                       );
            // fix any rounding‐leftover by giving it to 1st place
            const sumAllocated = allocated.reduce((s, x) => s + x, 0);
            const leftover = giveawayData.amount - sumAllocated;
            if (leftover > 0) {
                allocated[0] += leftover;
            }

            // Save giveaway outcome stats to localStorage (per-site)
            try {
                recordGiveawayStats(giveawayData, winners, allocated, numberEntries);
            } catch (e) { /* ignore stats errors */ }

            // Initialize winners / payout status UI so we can tick boxes as gifts are confirmed
            initWinnersStatusUI(winners, allocated);

            // 5) announce winners summary
            const winNum = giveawayData.winningNumber;

            //hard-coded emoji “podium”
            const podium = ["🥇", "🥈", "🥉", "🏅", "🎖️"];

            //build the tail: 6th, 7th, … up to the larger of N or MAX_WINNERS
            const need = Math.max(giveawayData.winnersNum, MAX_WINNERS) - podium.length;
            const tail = Array.from({ length: need }, (_, i) => {
                const n = i + podium.length + 1;
                const s = (n % 10 === 1 && n % 100 !== 11) ? "st" :
                (n % 10 === 2 && n % 100 !== 12) ? "nd" :
                (n % 10 === 3 && n % 100 !== 13) ? "rd" : "th";
                return `${n}${s}`; // "6th" … "15th"
            });

            //final list
            const medals = podium.concat(tail);

            const rigTag = rigNote("(Rigged mode was active, but winners were still chosen [b]fairly[/b]… allegedly.) 👀 ");

            const header =
                  `🏆 The winning number was [b][color=#1DDC5D]${winNum}[/color][/b]. ` +
                  `Congratulations to ` +
                  (winners.length === 1
                   ? ` `
                   : `these [b][color=#5DE2E7]${winners.length} winners[/color][/b]!`
                  ) +
                  rigTag;

            if (winners.length === 1) {
                // single‐winner public message
                const w = winners[0];
                const diff = Math.abs(w.guess - winNum);
                const prize = allocated[0].toLocaleString();
                sendMessage(
                    `${header}[b][color=#DC3D1D]${w.author}[/color][/b]! ` +
                    `You guessed [color=#1DDC5D][b]${w.guess}[/b][/color] [color=#FB4F4F](off by ${diff})[/color] ` +
                    `and will receive [b][color=#FFC00A]${prize} BON[/color][/b]!`
                );
            } else {
                // multi‐winner public message
                const lines = winners.map((w, i) => {
                    const diff = Math.abs(w.guess - winNum);
                    const prize = allocated[i].toLocaleString();
                    const medal = medals[i] || `${i+1}.`;
                    return `${medal} [b][color=#DC3D1D]${w.author}[/color][/b]: ` +
                        `[color=#1DDC5D][b]${w.guess}[/b][/color] ([color=#FB4F4F]${diff}[/color]) ` +
                        `[color=#FFC00A][b]${prize} BON[/b][/color]`;
                });

                sendMessage(
                    header + ' ' + lines.join(', '));
            }

            // 6) send the gifts
            if (winners.length === 1) {
                // single‐winner gift message
                const w = winners[0];
                const amt = allocated[0];
                giftBon(
                    w.author,
                    amt,
                    `🎉 You won! Enjoy your ${amt} BON!`
                );
            } else {
                // -- multi-winner gift messages -----------------------------
                winners.forEach((w, i) => {
                    const placeText = ordinal(i + 1); // 1st, 2nd, 3rd…
                    giftBon(
                        w.author,
                        allocated[i],
                        `🎉 Congratulations on placing ${placeText}!`
                    );
                });
            }

            // 6b) Verify that the gifts actually show up in chat via the API
            verifyWinnerGifts(winners, allocated, giveawayData.host);
        }

        // 7) clean up timers & state
        stopGiveaway();
    }

    function clearWinnersStatusUI() {
        winnerPayouts.clear();
        winnerGiftStatus.clear();

        const table = document.getElementById("entriesTable");
        if (!table) return;

        // Reset back to the basic two-column header.
        // Body will be repopulated by updateEntries() as entries arrive.
        table.innerHTML =
            "<thead><tr><th>User</th><th>Entry #</th></tr></thead><tbody></tbody>";
    }

    function initWinnersStatusUI(winners, allocated) {
        winnerPayouts.clear();
        winnerGiftStatus.clear();

        if (!Array.isArray(winners) || !Array.isArray(allocated) || !winners.length) {
            return;
        }

        const table = document.getElementById("entriesTable");
        if (!table) return;

        const thead = table.querySelector("thead");
        const tbody = table.querySelector("tbody");
        if (!thead || !tbody) return;

        const headerRow = thead.querySelector("tr");
        if (!headerRow) return;

        // If we're still in the plain 2-column mode, extend the header
        if (headerRow.children.length === 2) {
            const thPrize = document.createElement("th");
            thPrize.textContent = "Prize";
            const thGift = document.createElement("th");
            thGift.textContent = "Gift Status";
            headerRow.appendChild(thPrize);
            headerRow.appendChild(thGift);
        }

        // Build a lookup from entry number -> { author, prize }
        const byGuess = new Map();
        winners.forEach((w, idx) => {
            if (!w || typeof w.author !== "string") return;
            const prize = allocated[idx];
            if (!prize || prize <= 0) return;
            byGuess.set(w.guess, { author: w.author, prize });
        });

        Array.from(tbody.rows).forEach(row => {
            const cells = row.children;
            if (cells.length < 2) return;

            const entryNum = parseInt(cells[1].textContent, 10);
            const info = byGuess.get(entryNum);

            const prizeCell = document.createElement("td");
            const giftCell = document.createElement("td");
            giftCell.style.textAlign = "center";

            if (info) {
                const key = info.author.toLowerCase();
                winnerPayouts.set(key, info.prize);
                winnerGiftStatus.set(key, "pending");

                prizeCell.textContent = info.prize.toLocaleString();
                giftCell.innerHTML = `<span class="gift-spinner" title="Checking gift status…"></span>`;

                row.dataset.winnerKey = encodeURIComponent(key);
                row.classList.add("gift-pending");
            } else {
                // Non-winners still get empty cells so the table stays aligned
                prizeCell.textContent = "";
                giftCell.textContent = "";
            }

            row.appendChild(prizeCell);
            row.appendChild(giftCell);
        });
    }

    function getWinnerRowByRecipient(recipientName) {
        if (!recipientName) return null;
        const key = encodeURIComponent(String(recipientName).toLowerCase());

        const table = document.getElementById("entriesTable");
        if (!table) return null;

        return table.querySelector(`tbody tr[data-winner-key="${key}"]`);
    }

    function markWinnerGiftConfirmed(recipientName) {
        const row = getWinnerRowByRecipient(recipientName);
        if (!row) return;

        row.classList.remove("gift-pending", "gift-failed");
        row.classList.add("gift-confirmed");

        const key = recipientName.toLowerCase();
        winnerGiftStatus.set(key, "confirmed");

        const cells = row.children;
        if (cells.length >= 4) {
            cells[3].textContent = "✓";
        }
    }

    function markWinnerGiftFailed(recipientName) {
        const row = getWinnerRowByRecipient(recipientName);
        if (!row) return;

        row.classList.remove("gift-pending", "gift-confirmed");
        row.classList.add("gift-failed");

        const key = recipientName.toLowerCase();
        winnerGiftStatus.set(key, "failed");

        const cells = row.children;
        if (cells.length >= 4) {
            cells[3].textContent = "⚠";
        }
    }

    /**
     * After gifts are sent, poll the chat API a few times to confirm
     * that the expected host→winner gift messages appeared.
     * If we can't confirm them, warn in chat that gifting may have failed.
     *
     * @param {Array<{author:string}>} winners
     * @param {number[]} allocated
     * @param {string} hostName
     */
    function verifyWinnerGifts(winners, allocated, hostName) {
        try {
            if (!winners || !winners.length) return;

            const hostLower = (hostName || "").toLowerCase();
            if (!hostLower) return;

            // recipient -> expected amount
            const expected = new Map();
            winners.forEach((w, idx) => {
                const rec = w.author;
                const amt = allocated[idx];
                if (!rec || !amt || amt <= 0) return;
                // If a user somehow appears twice, keep the larger expected amount
                const prev = expected.get(rec) || 0;
                expected.set(rec, Math.max(prev, amt));
            });

            if (!expected.size) return;

            const maxAttempts = 5;
            const delayMs = 3000;
            let attempts = 0;

            async function checkOnce() {
                attempts++;

                try {
                    const url = new URL(`/api/chat/messages/${chatroomId}`, location.origin);
                    const res = await fetch(url, { credentials: "include" });
                    if (res.ok) {
                        const payload = await res.json();
                        const messages = Array.isArray(payload.data) ? payload.data : [];

                        for (const m of messages) {
                            const gift = parseGiftMessage(m.message);
                            if (!gift || !gift.gifter || !gift.recipient) continue;

                            if (gift.gifter.toLowerCase() !== hostLower) continue;

                            const expectedAmt = expected.get(gift.recipient);
                            if (!expectedAmt) continue;

                            // Be tolerant of any minor float oddities
                            if (Math.round(gift.amount) === Math.round(expectedAmt)) {
                                // Mark this winner as successfully paid in the UI
                                markWinnerGiftConfirmed(gift.recipient);
                                expected.delete(gift.recipient);
                            }
                        }
                    }
                } catch (e) {
                    // swallow – we'll just warn at the end if we never see the messages
                }

                if (expected.size === 0 || attempts >= maxAttempts) {
                    if (expected.size > 0) {
                        const missing = Array.from(expected.keys());

                        // Mark missing ones as failed (red row)
                        missing.forEach(name => markWinnerGiftFailed(name));

                        sendMessage(
                            `[color=#ff4f4f][b]Warning:[/b][/color] ` +
                            `Some giveaway gifts could not be confirmed. ` +
                            `Please manually verify BON for: ${missing.map(sanitizeNick).join(", ")}.`
                        );
                    }
                    return;
                }

                setTimeout(checkOnce, delayMs);
            }

            // Give the server a moment to emit the gift messages before first check
            setTimeout(checkOnce, 2000);
        } catch (e) {
            // Never let verification break the script
        }
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 12: Utility Functions
    // ───────────────────────────────────────────────────────────

    function startOtherGiveawayIndicator() {
        if (otherGiveawayIndicatorInterval) return;
        otherGiveawayIndicatorInterval = setInterval(updateStartButtonConflictIndicator, 2000);
    }

    function updateStartButtonConflictIndicator() {
        // Only bother when the UI is open and we're not currently running our own giveaway
        if (!giveawayFrame || giveawayFrame.hidden) return;
        if (!startButton || giveawayData) return;
        if ((startButton.textContent || "").trim() !== "Start") return;

        const myName =
              document.getElementsByClassName("top-nav__username")[0]?.children[0]?.textContent.trim() || "";

        const other = findOtherActiveGiveawayInChat(myName, 250); // bump scan depth if you want

        // Cache default title once
        if (!startButton.dataset.defaultTitle) {
            startButton.dataset.defaultTitle = startButton.title || "Start the giveaway";
        }

        startButton.classList.toggle("bon-gStartConflict", Boolean(other));
        startButton.title = other
            ? `Another giveaway by "${other.host}" appears active.`
        : startButton.dataset.defaultTitle;
    }

    // Returns true when we're in the "just started" window where entry attempts
    // should be silently ignored (to catch ultra-fast auto-joiners).
    function isWithinEntryIgnoreWindow() {
        if (!giveawayStartTime) return false;
        const elapsed = Date.now() - giveawayStartTime.getTime();
        return elapsed >= 0 && elapsed < ENTRY_IGNORE_WINDOW_MS;
    }

    // Return a small random sample of free numbers in the current range
    // (used by both !free and the "number already taken" messages)
    function getFreeNumberSample(giveawayData, sampleSize = 5) {
        if (!giveawayData) return [];

        const taken = new Set(numberEntries.values());
        const startNum = giveawayData.startNum;
        const endNum = giveawayData.endNum;
        const totalSlots = endNum - startNum + 1;

        if (totalSlots <= 0) return [];

        // Same optimization as !free: for huge ranges with very few taken numbers
        if (totalSlots > 100000 && taken.size / totalSlots < 0.01) {
            const sample = new Set();
            let attempts = 0, maxAttempts = 1000;

            while (sample.size < sampleSize && attempts < maxAttempts) {
                attempts++;
                const candidate = Math.floor(Math.random() * totalSlots) + startNum;
                if (!taken.has(candidate)) sample.add(candidate);
            }

            const result = [...sample];
            result.sort((a, b) => a - b);
            return result;
        }

        // Normal case: build an array of all free numbers and shuffle a subset
        const freeNumbers = [];
        for (let k = startNum; k <= endNum; k++) {
            if (!taken.has(k)) freeNumbers.push(k);
        }
        if (!freeNumbers.length) return [];

        const actualSampleSize = Math.min(sampleSize, freeNumbers.length);
        // Fisher–Yates style partial shuffle
        for (let i = 0; i < actualSampleSize; i++) {
            const j = i + Math.floor(Math.random() * (freeNumbers.length - i));
            [freeNumbers[i], freeNumbers[j]] = [freeNumbers[j], freeNumbers[i]];
        }

        const result = freeNumbers.slice(0, actualSampleSize);
        result.sort((a, b) => a - b);
        return result;
    }

    // Nicely format "here are some free numbers you can try…" text.
    // Respects the "Free" toggle: if !free is disabled, this returns an empty string.
    function formatFreeNumberSuggestion(giveawayData) {
        if (!giveawayData || GENERAL_SETTINGS.disable_free) return "";

        const sample = getFreeNumberSample(giveawayData, 5);
        if (!sample.length) {
            return " There are no free numbers left!";
        }

        const rigHint = rigNote("(these are some [b]suspiciously good[/b] numbers, trust me...) 😏");
        return ` Here are some free numbers you can try: [b][color=#1DDC5D]${sample.join(", ")}[/color][/b].` + rigHint;
    }

    function detectBestExtractor() {
        const hostname = location.hostname.toLowerCase();

        if (hostname.includes("onlyencodes.cc")) {
            return "onlyencodes";
        }

        const messages = document.querySelectorAll('.chatbox-message');
        for (const [key, extractor] of Object.entries(extractors)) {
            if (key === "onlyencodes" || key === "fallback") continue;
            for (const msgNode of messages) {
                if (extractor(msgNode)) {
                    return key;
                }
            }
        }

        return "fallback";
    }

    function getRandomInt(min, max) {
        min = Math.ceil(min);
        max = Math.floor(max);
        return Math.floor(Math.random() * (max - min + 1)) + min;
    }

    function sendReminder() {
        if (!shouldSendReminder(giveawayData)) {
            // Try again in 15 seconds if still eligible
            if (!reminderRetryTimeout) {
                reminderRetryTimeout = setTimeout(() => {
                    reminderRetryTimeout = null;
                    sendReminder();
                }, 15000);
            }
            return;
        }
        // Clear retry timer if any
        if (reminderRetryTimeout) {
            clearTimeout(reminderRetryTimeout);
            reminderRetryTimeout = null;
        }

        const rigLine = rigNote("(Rigged mode is currently enabled, but the math is [b]definitely[/b] still legit) 😉");
        const msg =
              `There is an ongoing giveaway for ` +
              `[b][color=#ffc00a]${Number(cleanPotString(giveawayData.amount)).toLocaleString()} BON[/color][/b]. ` +
              `Up to [b][color=#5DE2E7]${giveawayData.winnersNum} ${giveawayData.winnersNum === 1 ? 'winner' : 'winners'}[/color][/b] will be selected. ` +
              `Time left: [b][color=#1DDC5D]${parseTime(giveawayData.timeLeft*1000)}[/color][/b]. ` +
              `To enter, submit a whole number [b]between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive.[/b] ` +
              `[b][color=#5DE2E7]${giveawayData.customMessage} [/color][/b]\n` +
              `✨[b][color=#FB4F4F]Gifting BON to the host will add to the pot![/color][/b]✨` +
              rigLine;
        sendMessage(msg);
    }

    // ───────────── HTTP-based BON gifting helper ─────────────

    /**
     * Try to send BON using the site's HTTP gift endpoint.
     * If anything fails, we silently fall back to the legacy /gift chat command.
     */
    function giftBon(recipient, amount, messageText) {
        const safeRecipient = (recipient || "").trim();
        const safeAmount = Math.max(1, Math.floor(Number(amount) || 0));
        const safeMessage = (messageText || "").trim();

        if (!safeRecipient || !safeAmount) {
            return; // nothing to do
        }

        const csrfMeta = document.querySelector('meta[name="csrf-token"]');
        const csrfToken = csrfMeta && csrfMeta.content ? csrfMeta.content : null;

        const isLst = location.hostname === "lst.gg";

        // Try to infer /users/<slug>/gifts from any visible "/users/" link
        let giftUrl = null;
        const userLink = Array.from(document.querySelectorAll('a[href*="/users/"]'))
        .find(a => a.offsetParent !== null);

        if (userLink) {
            try {
                const url = new URL(userLink.href, location.origin);
                const parts = url.pathname.split("/").filter(Boolean);
                const idx = parts.indexOf("users");
                if (idx !== -1 && parts[idx + 1]) {
                    const slug = parts[idx + 1];
                    if (isLst) {
                        giftUrl = location.origin + "/users/" + slug + "/gifts/bon";
                    } else {
                        giftUrl = location.origin + "/users/" + slug + "/gifts";
                    }
                }
            } catch (e) {
                giftUrl = null;
            }
        }

        // If we can't resolve the HTTP endpoint or token, fall back immediately
        if (!csrfToken || !giftUrl) {
            const fallbackCmd = safeMessage
            ? `/gift ${safeRecipient} ${safeAmount} ${safeMessage}`
            : `/gift ${safeRecipient} ${safeAmount}`;
            sendMessage(fallbackCmd);
            return;
        }

        const formData = new FormData();
        formData.append("_token", csrfToken);
        formData.append("recipient_username", safeRecipient);
        formData.append("bon", String(safeAmount));
        formData.append("message", safeMessage);

        try {
            fetch(giftUrl, {
                method: "POST",
                credentials: "same-origin",
                body: formData
            }).then(function (resp) {
                // If the HTTP request fails or returns non-2xx, use /gift as a backup.
                if (!resp || !resp.ok) {
                    const fallbackCmd = safeMessage
                    ? `/gift ${safeRecipient} ${safeAmount} ${safeMessage}`
                    : `/gift ${safeRecipient} ${safeAmount}`;
                    sendMessage(fallbackCmd);
                }
            }).catch(function () {
                const fallbackCmd = safeMessage
                ? `/gift ${safeRecipient} ${safeAmount} ${safeMessage}`
                : `/gift ${safeRecipient} ${safeAmount}`;
                sendMessage(fallbackCmd);
            });
        } catch (e) {
            const fallbackCmd = safeMessage
            ? `/gift ${safeRecipient} ${safeAmount} ${safeMessage}`
            : `/gift ${safeRecipient} ${safeAmount}`;
            sendMessage(fallbackCmd);
        }
    }

    async function sendMessage(messageStr) {
        // Obfuscate "giveaway" in all messages except the intro announcement
        if (!(messageStr.includes("I am hosting a giveaway for") &&
              messageStr.includes("To enter, submit a whole number"))) {
            messageStr = obfuscateGiveaway(messageStr);
        }

        if (DEBUG_SETTINGS.verify_sendmessage) {
            console.debug("sendMessage: caching chat context if needed");
        }

        // If cache is missing, try to refresh
        if (!OT_USER_ID || !OT_CHATROOM_ID || !OT_CSRF_TOKEN) cacheChatContext();

        // --- Attempt API POST ---
        if (!DEBUG_SETTINGS.disable_chat_output && !DEBUG_SETTINGS.suppressApiMessages) {
            try {
                // Sanity check
                if (OT_USER_ID && OT_CHATROOM_ID && OT_CSRF_TOKEN) {
                    if (DEBUG_SETTINGS.verify_sendmessage) {
                        console.debug("sendMessage: sending API message:", messageStr);
                    }

                    const apiUrl = `/api/chat/messages`;
                    const payload = {
                        bot_id: null,
                        chatroom_id: Number(OT_CHATROOM_ID),
                        message: messageStr,
                        receiver_id: null,
                        save: true,
                        targeted: 0,
                        user_id: Number(OT_USER_ID)
                    };

                    const resp = await fetch(apiUrl, {
                        method: "POST",
                        credentials: "include",
                        headers: {
                            "Content-Type": "application/json",
                            "X-CSRF-TOKEN": OT_CSRF_TOKEN,
                            "X-Requested-With": "XMLHttpRequest"
                        },
                        body: JSON.stringify(payload)
                    });

                    const respText = await resp.text();
                    if (resp.ok) {
                        if (DEBUG_SETTINGS.log_chat_messages) {
                            console.log(`API send: ${messageStr}`);
                        }
                        if (DEBUG_SETTINGS.verify_sendmessage) {
                            console.debug("sendMessage: API message sent successfully");
                        }
                        return;
                    } else {
                        try {
                            const error = JSON.parse(respText);
                            console.error("API error", error);
                        } catch (e) {
                            console.error("API error (raw):", respText);
                        }
                        throw new Error("API send failed");
                    }
                }
            } catch (e) {
                if (DEBUG_SETTINGS.log_chat_messages) {
                    console.warn("API send failed, falling back to chatbox method:", e);
                }
                if (DEBUG_SETTINGS.verify_sendmessage) {
                    console.debug("sendMessage: API send failed, falling back to chatbox method");
                }
            }
        }

        // ---- Fallback to legacy chatbox method ----
        if (!DEBUG_SETTINGS.disable_chat_output && chatbox) {
            if (DEBUG_SETTINGS.log_chat_messages) {
                console.log(`Fallback send (chatbox): ${messageStr}`);
            }
            if (DEBUG_SETTINGS.verify_sendmessage) {
                console.debug("sendMessage: sending message via chatbox fallback");
            }

            const originalValue = chatbox.value;
            chatbox.value = messageStr;
            chatbox.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter" }));

            setTimeout(() => {
                chatbox.value = originalValue;
                if (DEBUG_SETTINGS.verify_sendmessage) {
                    console.debug("sendMessage: restored chatbox original value");
                }
            }, 50);
        }
    }

    function countdownTimer (display, giveawayData) {
        display.hidden = false;

        const timerID = setInterval(() => {
            const now = Date.now();
            const msLeft = giveawayData.endTs - now;
            giveawayData.timeLeft = Math.max(Math.ceil(msLeft / 1000), 0);

            // update MM:SS
            const m = Math.floor(giveawayData.timeLeft / 60);
            const s = giveawayData.timeLeft % 60;
            display.textContent = String(m).padStart(2, "0") + ":" + String(s).padStart(2, "0");

            // finish conditions
            if (giveawayData.timeLeft === 0) return endGiveaway();
            if (numberEntries.size === giveawayData.totalEntries) {
                sendMessage(`All [b][color=#ffc00a]${giveawayData.totalEntries}[/color][/b] slot(s) filled! Ending early with ` +
                            `[b][color=#1DDC5D]${parseTime(msLeft)}[/color][/b] remaining!`);
                return endGiveaway();
            }

            // automatic reminders (based on time *remaining* until end)
            const msToNext = nextReminderMs(giveawayData.reminderSchedule, msLeft);
            if (msToNext !== null && msToNext <= 1000) {
                // Consume this slot so retries (if any) don't double-send.
                if (giveawayData.reminderSchedule && giveawayData.reminderSchedule.length) {
                    giveawayData.reminderSchedule.shift();
                }
                sendReminder();
            }
        }, 1000);

        return timerID;
    }


    // Inserts a zero-width space after the first character
    function sanitizeNick(nick) {
        if (typeof nick !== "string" || nick.length < 2) return nick;
        return nick[0] + "\u200B" + nick.slice(1);
    }

    function obfuscateGiveaway(text) {
        return text.replace(/giveaway/gi, match => {
            return match[0] + "\u200B" + match.slice(1); // g + zero-width + iveaway
        });
    }

    // ───────────────────────────────
    // Persistent stats (localStorage)
    // ───────────────────────────────

    function loadGiveawayStats() {
        try {
            const raw = localStorage.getItem(STATS_LS_KEY);
            if (!raw) return { v: STATS_VERSION, users: {} };
            const parsed = JSON.parse(raw);
            if (!parsed || typeof parsed !== "object") return { v: STATS_VERSION, users: {} };

            // basic migrate/shape guard
            const users = (parsed.users && typeof parsed.users === "object") ? parsed.users : {};
            return {
                v: STATS_VERSION,
                users,
                updatedAt: parsed.updatedAt || 0
            };
        } catch (e) {
            return { v: STATS_VERSION, users: {} };
        }
    }

    function saveGiveawayStats(stats) {
        try {
            stats.v = STATS_VERSION;
            stats.updatedAt = Date.now();
            localStorage.setItem(STATS_LS_KEY, JSON.stringify(stats));
        } catch (e) {
            // ignore quota / JSON errors
        }
    }

    function normUserKey(name) {
        return String(name || "").trim().toLowerCase();
    }

    function getOrCreateUserStats(stats, username) {
        const key = normUserKey(username);
        if (!key) return null;

        if (!stats.users[key]) {
            stats.users[key] = {
                name: String(username || "").trim() || key,
                entered: 0,
                wins: 0,
                losses: 0,
                totalWon: 0,
                biggestWin: 0,
                sponsoredTotal: 0,
                sponsorCount: 0,
                biggestSponsor: 0,
                hosted: 0,
                hostedTotal: 0,
                lastSeenAt: 0
            };
        } else if (username) {
            // keep most recently seen casing
            stats.users[key].name = String(username).trim() || stats.users[key].name;
        }
        return stats.users[key];
    }

    function recordLiveEntry(username) {
        const key = normUserKey(username);
        if (!key) return;
        if (liveEnteredThisGiveaway.has(key)) return;

        liveEnteredThisGiveaway.add(key);

        const stats = loadGiveawayStats();
        const rec = getOrCreateUserStats(stats, username);
        if (!rec) return;

        rec.entered = (rec.entered || 0) + 1;
        rec.lastSeenAt = Date.now();

        saveGiveawayStats(stats);
    }

    function recordLiveSponsorGift(gifter, amount) {
        const key = normUserKey(gifter);
        const delta = Math.max(0, Math.floor(Number(amount) || 0));
        if (!key || !delta) return;

        // track running total for "biggestSponsor" per giveaway
        const prevTotal = liveSponsorTotalThisGiveaway.get(key) || 0;
        const nowTotal = prevTotal + delta;
        liveSponsorTotalThisGiveaway.set(key, nowTotal);

        const stats = loadGiveawayStats();
        const rec = getOrCreateUserStats(stats, gifter);
        if (!rec) return;

        rec.sponsoredTotal = (rec.sponsoredTotal || 0) + delta;

        // Count “how many giveaways they sponsored” once per giveaway
        if (!liveSponsorSeenThisGiveaway.has(key)) {
            liveSponsorSeenThisGiveaway.add(key);
            rec.sponsorCount = (rec.sponsorCount || 0) + 1;
        }

        // biggestSponsor = biggest total they added in any single giveaway
        rec.biggestSponsor = Math.max(rec.biggestSponsor || 0, nowTotal);

        rec.lastSeenAt = Date.now();
        saveGiveawayStats(stats);
    }

    function recordGiveawayStats(giveawayData, winners, allocated, entriesMap) {
        if (!giveawayData) return;

        const stats = loadGiveawayStats();
        const now = Date.now();

        // Host tracking
        const hostRec = getOrCreateUserStats(stats, giveawayData.host);
        if (hostRec) {
            hostRec.hosted += 1;
            const potTotal = Math.max(0, Math.floor(Number(giveawayData.amount) || 0));

            // Prefer explicit hostAdded (new behavior)
            let hostOnly = giveawayData.hostAdded;
            hostOnly = Number.isFinite(hostOnly) ? Math.max(0, Math.floor(hostOnly)) : null;

            // Back-compat fallback for older giveaways that don’t have hostAdded saved
            if (hostOnly === null) {
                const sponsorTotal = giveawayData.sponsorContribs
                ? Object.values(giveawayData.sponsorContribs).reduce((sum, v) => sum + (Math.floor(Number(v) || 0)), 0)
                : 0;
                hostOnly = Math.max(0, potTotal - sponsorTotal);
            }

            hostRec.hostedTotal += hostOnly;

            hostRec.lastSeenAt = now;
        }

        // Sponsors (per giveaway; uses sponsorContribs totals)
        if (giveawayData.sponsorContribs && typeof giveawayData.sponsorContribs === "object") {
            for (const [sponsor, amt] of Object.entries(giveawayData.sponsorContribs)) {
                const finalTotal = Math.max(0, Math.floor(Number(amt) || 0));
                if (!sponsor || !finalTotal) continue;

                const sKey = normUserKey(sponsor);
                const alreadyCounted = liveSponsorTotalThisGiveaway.get(sKey) || 0;
                const delta = Math.max(0, finalTotal - alreadyCounted);

                const rec = getOrCreateUserStats(stats, sponsor);
                if (!rec) continue;

                if (delta > 0) rec.sponsoredTotal += delta;

                if (!liveSponsorSeenThisGiveaway.has(sKey)) {
                    rec.sponsorCount += 1;
                }

                rec.biggestSponsor = Math.max(rec.biggestSponsor || 0, finalTotal);
                rec.lastSeenAt = now;
            }
        }

        // Winners + payouts
        const winKeySet = new Set((winners || []).map(w => normUserKey(w.author)));
        const payoutByKey = new Map();

        (winners || []).forEach((w, i) => {
            const key = normUserKey(w.author);
            const pay = Math.max(0, Math.floor(Number((allocated || [])[i]) || 0));
            if (!key) return;
            payoutByKey.set(key, (payoutByKey.get(key) || 0) + pay);
        });

        // Participants
        const participants = entriesMap ? Array.from(entriesMap.keys()) : [];
        participants.forEach(name => {
            const rec = getOrCreateUserStats(stats, name);
            if (!rec) return;

            const uKey = normUserKey(name);

            if (!liveEnteredThisGiveaway.has(uKey)) {
                rec.entered += 1;
            }

            if (winKeySet.has(uKey)) {
                rec.wins += 1;
                const pay = payoutByKey.get(uKey) || 0;
                rec.totalWon += pay;
                rec.biggestWin = Math.max(rec.biggestWin || 0, pay);
            } else {
                rec.losses += 1;
            }
            rec.lastSeenAt = now;
        });

        saveGiveawayStats(stats);
    }

    function getLeaderboardRows(sorter, topN, filterFn) {
        const stats = loadGiveawayStats();
        const users = Object.values(stats.users || {})
        .filter(u => u && typeof u === "object")
        .filter(u => (filterFn ? filterFn(u) : true))
        .sort(sorter);

        return users.slice(0, topN);
    }

    function fmtBON(value) {
        if (typeof value === "number") {
            return Math.max(0, Math.floor(value)).toLocaleString();
        }
        const digitsOnly = String(value ?? "").replace(/[^\d]/g, "");
        const n = parseInt(digitsOnly || "0", 10);
        return (Number.isNaN(n) ? 0 : n).toLocaleString();
    }

    function safeNameForChat(name) {
        return sanitizeNick(String(name || "").trim());
    }


    // Small helper for rig-mode suffixes
    function rigNote(inner) {
        if (!riggedMode) return "";

        // Extract trailing emoji(s) or punctuation like "😈", "👀", "😏"
        // This catches anything NOT in parentheses.
        const match = inner.match(/^(.*?)(\s*[^\w\s\)\(]+)?$/);
        const text = match[1].trim(); // "(entry logged ... conditions)"
        const trailing = (match[2] || "").trim(); // "😈" or "👀" or empty

        return ` [i][color=#FF4F9A]${text}[/color][/i]${trailing ? " " + trailing : ""}`;
    }

    function updateRigToggleUI() {
        if (!rigToggleInput) return;

        rigToggleInput.disabled = false;
        rigToggleInput.checked = !!riggedMode;
        rigToggleInput.title = riggedMode
            ? "Rigged mode is ON. Click to disable."
        : "Rigged mode is OFF. Click to enable.";
    }

    function fmtUserList(arr) {
        return arr.map(n => `[b]${sanitizeNick(n)}[/b]`).join(", ");
    }

    // Safely read the host's BON balance from the page, regardless of locale separators
    function readHostBalance() {
        try {
            const points = document.getElementsByClassName("ratio-bar__points")[0];
            if (!points || !points.firstElementChild) return 0;
            const raw = points.firstElementChild.textContent || "";
            // remove everything that isn't a digit: spaces, commas, dots, apostrophes, etc.
            const digitsOnly = raw.replace(/[^\d]/g, "");
            const n = parseInt(digitsOnly, 10);
            return Number.isNaN(n) ? 0 : n;
        } catch {
            return 0;
        }
    }

    function ordinal(n){
        const rem100 = n % 100;
        if (rem100 >= 11 && rem100 <= 13) return `${n}th`;
        switch (n % 10){
            case 1: return `${n}st`;
            case 2: return `${n}nd`;
            case 3: return `${n}rd`;
            default: return `${n}th`;
        }
    }

    function getLuckyNumber(giveawayData) {
        // Convert taken numbers to sorted array
        const taken = Array.from(numberEntries.values());
        taken.sort((a, b) => a - b);

        // Append one after the max so we always catch the final gap
        taken.push(giveawayData.endNum + 1);

        let bestGap = 0;
        let lucky = giveawayData.startNum;
        let prev = giveawayData.startNum - 1;

        for (const current of taken) {
            const gap = current - prev;
            if (gap > bestGap) {
                // Find the middle number in the gap
                lucky = Math.floor((prev + current) / 2);
                bestGap = gap;
            }
            prev = current;
        }

        // Clamp lucky to the valid range
        if (lucky < giveawayData.startNum) lucky = giveawayData.startNum;
        if (lucky > giveawayData.endNum) lucky = giveawayData.endNum;

        return lucky;
    }


    function cleanPotString(giveawayPotAmount) {
        if (giveawayPotAmount % 1 == 0) {
            return giveawayPotAmount
        } else {
            return giveawayPotAmount.toFixed(2)
        }
    }

    function parseTime(ms) {
        const hours = Math.floor(ms / 3600000);
        const minutes = Math.floor((ms % 3600000) / 60000);
        const seconds = Math.floor((ms % 60000) / 1000);
        const parts = [];
        if (hours) parts.push(`${hours} hour${hours > 1 ? 's' : ''}`);
        if (minutes) parts.push(`${minutes} minute${minutes > 1 ? 's' : ''}`);
        if (seconds) parts.push(`${seconds} second${seconds > 1 ? 's' : ''}`);
        return parts.join(", ");
    }

    function parseDurationTextToMs(str) {
        if (!str) return null;
        let ms = 0;
        let found = false;
        const re = /(\d+)\s*(hour|minute|second)s?/gi;
        let m;
        while ((m = re.exec(str)) !== null) {
            found = true;
            const n = parseInt(m[1], 10);
            const unit = m[2].toLowerCase();
            if (unit === "hour") ms += n * 3600000;
            else if (unit === "minute") ms += n * 60000;
            else if (unit === "second") ms += n * 1000;
        }
        return found ? ms : null;
    }

    function getChatMsgText(msgNode) {
        const isLst = location.hostname === "lst.gg";
        const raw = (
            (isLst
             ? msgNode.querySelector(".bbcode-rendered")?.textContent
             : msgNode.querySelector(".chatbox-message__content")?.textContent
            ) || ""
        );

        // Remove zero-width obfuscation chars so regex/includes work reliably
        return raw.replace(/[\u200B\u200C\u200D\uFEFF]/g, "").trim();
    }

    function getChatMsgTimestampMs(msgNode) {
        // Most UNIT3D themes include an ISO datetime on a <time> element.
        const timeEl =
              msgNode.querySelector("time[datetime]") ||
              msgNode.querySelector(".chatbox-message__time time[datetime]") ||
              msgNode.querySelector("time[title]");

        if (!timeEl) return null;

        const raw = timeEl.getAttribute("datetime") || timeEl.getAttribute("title");
        if (!raw) return null;

        const ms = Date.parse(raw);
        return Number.isFinite(ms) ? ms : null;
    }

    // Detect an active giveaway hosted by someone else in recent chat.
    // Returns: { host, endsAt, type, snippet } or null
    function findOtherActiveGiveawayInChat(myName, scanLimit = 250) {
        const myKey = String(myName || "").trim().toLowerCase();
        const messages = Array.from(document.querySelectorAll(".chatbox-message"));
        const startIdx = Math.max(0, messages.length - scanLimit);

        const endedHosts = new Set();
        const now = Date.now();

        for (let i = messages.length - 1; i >= startIdx; i--) {
            const node = messages[i];
            const author = (getAuthor(node) || "").trim();
            if (!author) continue;

            const authorKey = author.toLowerCase();
            const text = getChatMsgText(node); // make sure this strips \u200B etc. (your earlier fix)
            if (!text) continue;

            // If we see an end message from that author, treat earlier giveaway msgs as finished.
            if (
                /The winning number was/i.test(text) ||
                /Unfortunately, no one has entered the giveaway/i.test(text)
            ) {
                endedHosts.add(authorKey);
                continue;
            }

            // Ignore our own messages
            if (authorKey === myKey) continue;

            // Ignore authors we already saw ending a giveaway (in the scanned window)
            if (endedHosts.has(authorKey)) continue;

            // Marker phrases from your script
            const looksLikeThisScript =
                  text.includes("Gifting BON to the host will add to the pot") ||
                  /I am hosting a giveaway for/i.test(text) ||
                  /There is an ongoing giveaway for/i.test(text);

            if (!looksLikeThisScript) continue;

            const ts = getChatMsgTimestampMs(node);

            // --- REMINDER: strongest signal because it includes "Time left: ..." ---
            if (/There is an ongoing giveaway for/i.test(text) && /Time left:/i.test(text)) {
                const m = text.match(/Time left:\s*(.+?)\./i);
                const leftMs = m ? parseDurationTextToMs(m[1]) : null;

                // If we can compute an end time, use it
                if (ts && leftMs != null) {
                    const endsAt = ts + leftMs;
                    if (endsAt > now) {
                        return { host: author, endsAt, type: "reminder", snippet: text };
                    }
                    continue; // expired
                }

                // ✅ FALLBACK: can’t parse time, but it looks like an active reminder
                return { host: author, endsAt: null, type: "reminder_unparsed", snippet: text };
            }

            // --- INTRO: next strongest signal because it includes duration ---
            if (/I am hosting a giveaway for/i.test(text) && /Entries will be open for/i.test(text)) {
                const durMatch = text.match(/Entries will be open for\s*(.+?)\./i);
                const durMs = durMatch ? parseDurationTextToMs(durMatch[1]) : null;

                // If we can compute an end time, use it
                if (ts && durMs != null) {
                    const endsAt = ts + durMs;
                    if (endsAt > now) {
                        return { host: author, endsAt, type: "intro", snippet: text };
                    }
                    continue; // expired
                }

                // ✅ FALLBACK: can’t parse time, but it looks like an active intro
                return { host: author, endsAt: null, type: "intro_unparsed", snippet: text };
            }

            // If it's our script marker (pot line) but not intro/reminder, still treat as “likely active”
            // (This protects you if you ever change wording in the future.)
            if (text.includes("Gifting BON to the host will add to the pot")) {
                return { host: author, endsAt: null, type: "marker_unparsed", snippet: text };
            }
        }

        return null;
    }

    function totalMinutes () {
        const t = parseFloat(timerInput.value);
        return isNaN(t) || t <= 0 ? 0 : t;
    }

    function nextReminderMs(schedule, msLeft) {
        if (!schedule || !schedule.length) return null;

        // Drop reminders we've clearly passed (more than ~1s behind us)
        // e.g. if the tab was suspended or the host adjusted the end time.
        while (schedule.length && msLeft < schedule[0] - 1000) {
            schedule.shift();
        }
        if (!schedule.length) return null;

        // Next upcoming reminder triggers when msLeft shrinks down to schedule[0].
        // msToNext is positive before we reach it, ~0 around the tick it fires,
        // and negative if we're a little bit late.
        return msLeft - schedule[0];
    }

    // Returns [maxReminders, minInterval (in min)]
    function getReminderLimits(totalMinutes) {
        const MIN_INTERVAL = 5; // 5 min between reminders
        if (totalMinutes < MIN_INTERVAL) return [0, null];
        let max = Math.floor(totalMinutes / MIN_INTERVAL);
        return [max, MIN_INTERVAL];
    }

    // Returns [N reminders] timestamps (ms before end) evenly spaced
    function getReminderSchedule(totalMinutes, numReminders) {
        if (numReminders < 1) return [];
        const interval = totalMinutes / (numReminders + 1);
        return Array.from({length: numReminders}, (_,i) =>
                          Math.round((totalMinutes - (i + 1) * interval) * 60_000)
                         );
    }

    function shouldSendReminder(giveawayData) {
        const isLst = location.hostname === "lst.gg";

        // Get the chat messages container (adjust selector as needed for your site)
        const messages = Array.from(document.querySelectorAll('.chatbox-message'));

        // Only look at the last 7 messages
        for (let i = messages.length - 1; i >= Math.max(messages.length - 7, 0); i--) {
            const msgNode = messages[i];

            const author = getAuthor(msgNode);

            let text = "";
            if (isLst) {
                text = msgNode.querySelector(".bbcode-rendered")?.textContent || "";
            } else {
                text = msgNode.querySelector(".chatbox-message__content")?.textContent || "";
            }

            // Does it contain your unique reminder marker?
            if (
                author === giveawayData.host &&
                text.includes("Gifting BON to the host will add to the pot")
            ) {
                // Recent visible reminder by host exists
                return false;
            }
        }
        // No visible recent reminder from host found
        return true;
    }

    // Live sync reminder number field with allowed max/min and show interval
    function syncReminderNumUI() {
        if (!giveawayForm) return;
        const totMin = totalMinutes();
        const [maxRem, minInterval] = getReminderLimits(totMin);

        remNumInput.max = maxRem;
        remNumInput.min = 0;

        // Clamp to allowed range
        if (Number(remNumInput.value) > maxRem) remNumInput.value = maxRem;
        if (Number(remNumInput.value) < 0) remNumInput.value = 0;

        // Show interval in "Every" field
        if (Number(remNumInput.value) > 0) {
            const interval = totMin / (Number(remNumInput.value) + 1);
            reminderEvery.value = interval.toFixed(2).replace(/\.00$/,"") + " min";
        } else {
            reminderEvery.value = "–";
        }
        const label = giveawayForm.querySelector('label[for="reminderNum"]');
        if (label) {
            label.textContent = "# Reminders" + (maxRem ? ` (max ${maxRem})` : '');
        }
    }

    function cacheChatContext() {
        OT_USER_ID = null;
        OT_CHATROOM_ID = null;
        OT_CSRF_TOKEN = null;

        if (DEBUG_SETTINGS.verify_cacheChatContext) {
            console.debug("cacheChatContext: starting cache refresh");
        }

        // Try oldtoons (#chatbody[x-data]) first
        const section = document.querySelector('section#chatbody[x-data]');
        if (section) {
            try {
                const raw = section.getAttribute('x-data');
                if (DEBUG_SETTINGS.verify_cacheChatContext) {
                    console.debug("cacheChatContext: found x-data attribute:", raw);
                }
                // Extract the substring 'JSON.parse(...)' from raw
                const jsonParseMatch = raw.match(/JSON\.parse\((['"])([\s\S]*?)\1\)/);
                if (jsonParseMatch) {
                    const jsonParseString = jsonParseMatch[0]; // entire JSON.parse('...') call
                    if (DEBUG_SETTINGS.verify_cacheChatContext) {
                        console.debug("cacheChatContext: extracted JSON.parse substring:", jsonParseString);
                    }
                    try {
                        // Evaluate JSON.parse(...) directly
                        const jsonData = eval(jsonParseString);
                        if (jsonData) {
                            OT_USER_ID = Number(jsonData.id);
                            OT_CHATROOM_ID = Number(jsonData.chatroom_id);
                        }
                    } catch (e) {
                        if (DEBUG_SETTINGS.verify_cacheChatContext) {
                            console.debug("cacheChatContext: error evaluating JSON.parse string", e);
                        }
                    }
                } else {
                    if (DEBUG_SETTINGS.verify_cacheChatContext) {
                        console.debug("cacheChatContext: JSON.parse(...) pattern not found in x-data");
                    }
                }
            } catch (e) {
                if (DEBUG_SETTINGS.verify_cacheChatContext) {
                    console.debug("cacheChatContext: error reading x-data attribute", e);
                }
            }
        }

        // If not found, try onlyencodes method
        if (!OT_USER_ID || !OT_CHATROOM_ID) {
            if (DEBUG_SETTINGS.verify_cacheChatContext) {
                console.debug("cacheChatContext: falling back to onlyencodes method");
            }
            const oeSection = document.querySelector('section.panelV2.blocks__top-torrents[wire\\:snapshot]');
            if (oeSection) {
                try {
                    const snap = oeSection.getAttribute('wire:snapshot');
                    if (snap) {
                        const obj = JSON.parse(snap);
                        if (DEBUG_SETTINGS.verify_cacheChatContext) {
                            console.debug("cacheChatContext: found wire:snapshot attribute:", snap);
                        }
                        // The user info is in obj.data.user, second item in array
                        const userArray = obj.data?.user;
                        if (Array.isArray(userArray) && userArray.length > 1 && userArray[1].key) {
                            OT_USER_ID = Number(userArray[1].key);
                            if (DEBUG_SETTINGS.verify_cacheChatContext) {
                                console.debug("cacheChatContext: extracted OT_USER_ID from wire:snapshot:", OT_USER_ID);
                            }
                        }
                    }
                } catch (e) {
                    if (DEBUG_SETTINGS.verify_cacheChatContext) {
                        console.debug("cacheChatContext: error parsing wire:snapshot JSON", e);
                    }
                }
            }
            // For onlyencodes, chatroom_id is always 1
            if (window.location.hostname === "onlyencodes.cc") {
                OT_CHATROOM_ID = 1;
            }
        }

        // CSRF token
        const xsrfToken = document.querySelector('meta[name=csrf-token]')?.content ||
              window?.CSRF_TOKEN ||
              (document.cookie.match(/XSRF-TOKEN=([^;]+)/)?.[1] || "");
        OT_CSRF_TOKEN = xsrfToken ? decodeURIComponent(xsrfToken) : "";

        if (DEBUG_SETTINGS.verify_cacheChatContext) {
            console.debug("cacheChatContext: final OT_CSRF_TOKEN =", OT_CSRF_TOKEN ? "[token present]" : "[token missing]");
        }
    }

    // ───────────────────────────────────────────────────────────
    // SECTION 13: Menu Field Scaling and Validation
    // ───────────────────────────────────────────────────────────
    function reminderAutoScaling() {
        const totMin = totalMinutes();
        const [maxRem] = getReminderLimits(totMin);

        // Only auto-set if the reminders field isn't focused or is empty/zero
        // (so we don't overwrite intentional user edits)
        if (
            document.activeElement !== remNumInput ||
            remNumInput.value === "" ||
            remNumInput.value == "0"
        ) {
            remNumInput.value = maxRem;
        }

        syncReminderNumUI();
    }

    function entryRangeValidation() {
        const startVal = startInput.value.trim();
        const endVal = endInput.value.trim();

        // Allow optional negative sign followed by digits (no letters)
        const integerRegex = /^-?\d+$/;

        // Clear previous custom validity messages
        startInput.setCustomValidity("");
        endInput.setCustomValidity("");

        // Check for any letters in the inputs
        const lettersRegex = /[A-Za-z]/;

        if (lettersRegex.test(startVal) || lettersRegex.test(endVal)) {
            startInput.setCustomValidity("Letters are not allowed—please enter valid integers.");
            endInput.setCustomValidity("Letters are not allowed—please enter valid integers.");
            return false;
        }

        // Ensure the inputs match the integer pattern
        if (
            !integerRegex.test(startVal) ||
            !integerRegex.test(endVal)
        ) {
            startInput.setCustomValidity("Please enter valid integers (e.g., -5, 0, 10).");
            endInput.setCustomValidity("Please enter valid integers (e.g., -5, 0, 10).");
            return false;
        }

        const startNum = parseInt(startVal, 10);
        const endNum = parseInt(endVal, 10);

        // Check for NaN just in case
        if (isNaN(startNum) || isNaN(endNum)) {
            startInput.setCustomValidity("Please enter numbers only.");
            endInput.setCustomValidity("Please enter numbers only.");
            return false;
        }

        // Ensure start is not greater than end
        if (startNum > endNum) {
            endInput.setCustomValidity("End # should be greater than or equal to Start #.");
            return false;
        }

        return true;
    }

    function winnersValidation() {
        winnersInput.setCustomValidity("");
        const val = parseInt(winnersInput.value, 10);
        if (isNaN(val) || val < 1 || val > MAX_WINNERS) {
            winnersInput.setCustomValidity(`Please choose between 1 and ${MAX_WINNERS} winners.`);
            winnersInput.reportValidity();
            return false;
        }
        return true;
    }

    function toggleAll() {
        const newDisabled = !GENERAL_SETTINGS.disable_random;

        GENERAL_SETTINGS.disable_random = newDisabled;
        GENERAL_SETTINGS.disable_lucky = newDisabled;
        GENERAL_SETTINGS.disable_free = newDisabled;
        GENERAL_SETTINGS.suppress_entry_replies = newDisabled;

        document.getElementById("randomToggle").checked = !newDisabled;
        document.getElementById("luckyToggle").checked = !newDisabled;
        document.getElementById("freeToggle").checked = !newDisabled;
        document.getElementById("entryrepliesToggle").checked = !newDisabled;

        localStorage.setItem("giveaway-disableRandom", String(newDisabled));
        localStorage.setItem("giveaway-disableLucky", String(newDisabled));
        localStorage.setItem("giveaway-disableFree", String(newDisabled));
        localStorage.setItem("giveaway-suppressEntryReplies", String(newDisabled))
    }

    // Outside-click: if you click anywhere that's not inside a menu or on its button, close both
    function handleOutsideClick(event) {
        const insideSettings = settingsMenu.contains(event.target) || settingsBtn.contains(event.target);
        const insideCommands = commandsMenu.contains(event.target) || commandsBtn.contains(event.target);

        if (!insideSettings && !insideCommands) {
            settingsMenu.classList.remove('open');
            settingsMenu.style.display = 'none';
            hardCloseCommands();
            document.removeEventListener('click', handleOutsideClick);
        }
    }

    function hardCloseCommands() {
        commandsMenu.classList.remove('open');
        commandsMenu.style.display = 'none'; // keep it hidden
    }

    function hardCloseSettings () {
        settingsMenu.classList.remove('open');
        settingsMenu.style.display = 'none';
        document.removeEventListener('click', handleOutsideClick);
    }

    function addStyle(css, id) {
        const style = document.createElement("style");
        style.id = id;
        style.textContent = css;
        document.head.appendChild(style);
    }
})();