Raw Source
ZukoXZoku / Blutopia BON Giveaway

// ==UserScript==
// @name         Blutopia BON Giveaway
// @namespace    https://openuserjs.org/users/Nums
// @description  Enables the functionality to become poor
// @version      4.2.1
// @updateURL    https://openuserjs.org/meta/Nums/Blutopia_BON_Giveaway.meta.js
// @downloadURL  https://openuserjs.org/install/Nums/Blutopia_BON_Giveaway.user.js
// @icon         https://ptpimg.me/0aq853.gif
// @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.xyz/
// @match        https://onlyencodes.cc/
// @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
// @jacksaw - creating the original script base
// @Coasty - collaborating with jacksaw
// @TheEther - Integration with Aither + some additional features
// @ahoimate - got BON gifting API polling working + added new commands
// @ruckus612 - fixed BON gift bug
// @ZukoXZuko - added some 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 = 15; // central location to update max allowable number of winners
    const MAX_REMINDERS = 6; //maximum number of reminders allowed
    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.xyz': '1',
        'onlyencodes.cc': '1'
    };

    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"];
    const hostCommands = ["addtime", "removetime", "reminder", "addbon", "end", "winners", "naughty"];
    const uploadCxExtras = ["ruckus", "ick", "corigins", "ahoimate", "lejosh", "suckur"];
    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 CHAT_VARIANT; //detect if using old Vue chatbox or new Alpine
    let syncing = false;
    let OT_USER_ID = null;
    let OT_CHATROOM_ID = null;
    let OT_CSRF_TOKEN = null;

    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

    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">
          <img src="https://ptpimg.me/0aq853.gif" width="20px"/>
          ${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"
          pattern="[0-9,]*"
          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" required id="winnersNum" min="1" max="10" 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"
          >
          <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>
  </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 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>

</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: 260px;
}
.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, giveawayForm,
        resetButton, closeButton, startButton, toggleAllButton, settingsBtn, commandsBtn, settingsMenu, commandsMenu, byCount, byInterval,
        remNumInput, reminderEvery;
    // 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 full width with fixed layout */
#giveawayFrame #entriesTable {
  width: 100% !important;
  border-collapse: collapse;
  table-layout: fixed;
}

/* 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;
}
`, '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');

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

        coinHeader = document.getElementById("coinHeader");
        coinHeader.textContent = fmtBON(document.getElementsByClassName("ratio-bar__points")[0].firstElementChild.textContent.trim());
        coinHeader.prepend(goldCoins.cloneNode(false));

        coinInput = document.getElementById("giveawayAmount");
        // remove formatting while editing
        coinInput.addEventListener('focus', () => {
            coinInput.value = coinInput.value.replace(/,/g, '');
        });
        // add commas on blur if it’s a valid integer
        coinInput.addEventListener('blur', () => {
            const raw = coinInput.value.replace(/,/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");

        startButton = document.getElementById("startButton");
        startButton.onclick = startGiveaway;
        startButton.title = "Start the giveaway";

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

        countdownHeader = document.getElementById("countdownHeader");
        entriesWrapper = document.getElementById("entriesWrapper");
        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;
    }

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

        if (!giveawayForm.checkValidity()) {
            giveawayForm.reportValidity();
            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);
        const cleanValue = coinInput.value.replace(/,/g, '');

        giveawayData = {
            host: document.getElementsByClassName("top-nav__username")[0].children[0].textContent.trim(),
            amount: parseInt(cleanValue, 10),
            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: parseInt(cleanValue, 10),
            reminderSchedule : schedule,
            reminderNum      : schedule.length,
            reminderFreqSec  : cadenceSec, // <‑ kept for legacy helpers
            nextReminderSec  : cadenceSec, // <‑ ditto (first reminder ETA)
            sponsorContribs: {},
            sponsors: []
        };

        giveawayStartTime = new Date();

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

        const currentBon = parseInt(document.querySelector('.ratio-bar__points').textContent.replace(/[\s,]/g,'') ,10);

        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 "";
            };

            const 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]✨`

            sendMessage(introMessage);

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

            // ** 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
        countdownHeader.textContent = "";
        countdownHeader.hidden = true
        startButton.parentElement.hidden = false

        startButton.disabled = 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 ———
        // re‐read balance from the page
        const rawText = document
        .getElementsByClassName("ratio-bar__points")[0]
        .firstElementChild.textContent
        .trim()
        .replace(/[\s,]/g,'');
        const hostBalance = parseInt(rawText, 10) || 0;

        // update the header
        coinHeader.textContent = hostBalance.toLocaleString();
        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;
    }

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

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

        giveawayData = null;
        window.onbeforeunload = null;
    }

    // ───────────────────────────────────────────────────────────
    // 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) {
        const isBot = !messageNode.querySelector(".chatbox-message__content");

        if (isBot) {
            const messageContent = messageNode.querySelector(".chatbox-message__header div")?.textContent.trim() || "";
            // No action required – handled by SponsorTracker
            return;
        }

        const author = getAuthor(messageNode);

        const messageContent = messageNode.querySelector(".chatbox-message__content")?.textContent.trim() || "";
        const fancyName = messageNode.querySelector(".user-tag")?.outerHTML || "";

        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) {
        // --- 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;
        }

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

        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]! Please try another number!`;
                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 msg =
                  `[color=#d85e27]${safeAuthor}[/color] has entered with ` +
                  `the number [color=#DC3D1D][b]${number}[/b][/color]! ` +
                  `Time remaining: [b][color=#1DDC5D]${timeLeftStr}[/color][/b].`;
            sendMessage(msg);
        }
    }

    function addNewEntry(author, fancyName, number) {
        numberEntries.set(author, number);
        fancyNames.set(author, fancyName);
        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><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
    // ───────────────────────────────────────────────────────────
    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;
            }

            /* — filter new, unprocessed, systembot gift messages — */
            const gifts = messages.filter(m =>
                                          (m.bot?.is_systembot || m.bot?.name?.toLowerCase().includes("oe+")) &&
                                          m.message.includes("has gifted") &&
                                          Date.parse(m.created_at) > this.giveawayStartTs &&
                                          !this.processedIds.has(m.id)
                                         );

            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) {
            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])
            }
            : {};
        }

        /* ---- 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);
        }

        /* ---- 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;

        // --- 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
        }

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

        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.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 (legacy behaviour)
            if (args.length === 0) {
                sendMessage(
                    `Time left: [b][color=#1DDC5D]${parseTime(
                        giveawayData.timeLeft * 1000
                    )}[/color][/b] ⏳`
                );
                return;
            }
            // host / admin modifiers
            const action = args[0].toLowerCase();
            const minutes = parseFloat(args[1]);
            const isPriv = author === giveawayData.host || isAdmin(fancyName);

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

            // Pass ONLY the minutes as the first arg for add/remove
            let ctxWithArg = { ...ctx, args: [args[1]] };

            if (action === "add") addMinutes(ctxWithArg);
            if (action === "remove") 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;
            }

            const list = Array.from(numberEntries.entries()).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,

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

        bon({ giveawayData }) {
            sendMessage(`Giveaway Amount: [b][color=#FFB700]${giveawayData.amount.toLocaleString()}[/color][/b]`);
        },

        range({ giveawayData }) {
            sendMessage(`Numbers between [color=#DC3D1D]${giveawayData.startNum} and ${giveawayData.endNum}[/color] inclusive are valid.`);
        },

        rig() {
            sendMessage(`[color=#DC3D1D][b]Giveaway is now rigged[/b][/color]`);
        },

        unrig() {
            sendMessage(`[color=#DC3D1D][b]No, the giveaway will be rigged[/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);
            sendMessage(`[color=#d85e27]${safeAuthor}[/color] has entered with the number [color=#DC3D1D][b]${randomNum}[/b][/color]! Time remaining: [b][color=#1DDC5D]${timeLeftStr}[/color][/b].`);
        },

        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 taken = new Set(numberEntries.values());
            const startNum = giveawayData.startNum;
            const endNum = giveawayData.endNum;
            const totalSlots = endNum - startNum + 1;
            const sampleSize = 5;

            // Use random sampling if range is large and few are taken
            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];
                sendMessage(
                    result.length > 0
                    ? `Free numbers: ${result.join(", ")}.`
                    : "There are no free numbers left!"
                );
                return;
            }

            // Fallback to array method for normal cases
            const freeNumbers = [];
            for (let k = startNum; k <= endNum; k++) {
                if (!taken.has(k)) freeNumbers.push(k);
            }

            const actualSampleSize = Math.min(sampleSize, freeNumbers.length);
            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 sample = freeNumbers.slice(0, actualSampleSize);

            sendMessage(
                sample.length > 0
                ? `Free numbers: ${sample.join(", ")}.`
                : "There are no free numbers left!"
            );
        },

        lucky({ giveawayData }) {
            if (GENERAL_SETTINGS.disable_lucky) {
                sendMessage(`🚫 Sorry [color=#d85e27]${sanitizeNick(giveawayData.host)}[/color], but [color=#999999]!lucky[/color] has been disabled for this giveaway.`);
                return;
            }
            sendMessage(`The current giveaway lucky number is: [b][color=#1DDC5D]${getLuckyNumber(giveawayData)}[/color][/b].`);
        },

        /* Fun commands for upload.cx */
        suckur: funUpload("Placeholder™"),
        ruckus: funUpload("Sucker!"),
        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("🦅 🇺🇸 🦅 🇺🇸 🦅 🇺🇸"),
        ahoimate: funUpload("🦜 🏴‍☠️ 🦜 🏴‍☠️ 🦜 🏴‍☠️"),
        lejosh: 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: "rig", setting: "disable_rig" },
            { name: "unrig", setting: "disable_unrig" },
            { name: "entries", setting: "disable_entries" },
            { 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 amount = parseFloat(args[0]);
        if (isNaN(amount) || amount <= 0) {
            sendMessage("[b][color=red]Invalid usage.[/color] Example: !addbon 100[/b]");
            return;
        }
        giveawayData.amount += 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, ‑1 for !removetime
        return ({ author, fancyName, args, giveawayData }) => {
            if (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;
            const mins = parseFloat(args[0]);
            if (isNaN(mins)) {
                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(); // top‑off / trim to new cadence

            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].`);
        };
    }

    function announce(sign, mins, now, endTs) {
        const verb = sign > 0 ? "Added" : "Removed";
        const prep = sign > 0 ? "to" : "from";
        countdownHeader.textContent = parseTime(endTs - now);
        sendMessage(`${verb} [color=#DC3D1D][b]${mins}[/b][/color] minute${mins===1?"":"s"} ${prep} the giveaway. ` +
                    `New time left: [b][color=#1DDC5D]${parseTime(endTs - 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;
            }

            // 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 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]!`
                  );

            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];
                sendMessage(
                    `/gift ${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…
                    sendMessage(
                        `/gift ${w.author} ${allocated[i]} ` +
                        `🎉 Congratulations on placing ${placeText}!`
                    );
                });
            }
        }

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

    // ───────────────────────────────────────────────────────────
    // SECTION 12: Utility Functions
    // ───────────────────────────────────────────────────────────
    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 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]✨`;
        sendMessage(msg);
    }

    async function sendMessage(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) {
        const startMs = Date.now(); // absolute start‑stamp
        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
            const msElapsed = (giveawayData.endTs - startMs) - msLeft;
            const msToNext = nextReminderMs(giveawayData.reminderSchedule, msElapsed);
            if (msToNext !== null && msToNext <= 1000) 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 fmtUserList(arr) {
        return arr.map(n => `[b]${sanitizeNick(n)}[/b]`).join(", ");
    }

    /* ── pretty-print a BON amount ── */
    function fmtBON(value) {
        // Accept string or number, strip spaces, coerce to Number, then local-format
        return Number(String(value).replace(/\s+/g, '')).toLocaleString();
    }

    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 totalMinutes () {
        const t = parseFloat(timerInput.value);
        return isNaN(t) || t <= 0 ? 0 : t;
    }

    function nextReminderMs(schedule, now) {
        while(schedule.length && now>=schedule[schedule.length-1]) schedule.pop();
        return schedule.length? schedule[schedule.length-1]-now : null;
    }

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

            // Extract the text content
            const 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);
    }
})();