edge2020 / BONGIVEAWAY2

// ==UserScript==
// @namespace    https://openuserjs.org/users/jacksaw
// @name         BONGIVEAWAY2
// @description  Enables the functionality to become poor
// @version      4.1.0
// @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 jacksaw
// @collaborator Coasty
// ==/OpenUserJS==

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

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

// ───────────────────────────────────────────────────────────
// 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 = 10; // 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
};

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 INVIS = [
    '\u200B', // zero-width space
    '\u200C', // zero-width non-joiner
    '\u200D', // zero-width joiner
    '\u2060', // word-joiner
    '\uFEFF', // zero-width NBSP
    '\u180E', // Mongolian vowel separator
    '\u2061' // function application
];

const HOMOG = {
    a:['a','а'], e:['e','℮'],
    i:['i','і'], o:['o','ο','о']
};
function homograph(word){
    return [...word].map(c=>HOMOG[c]?.[Math.random()*HOMOG[c].length|0] ?? c).join('');
}

const LS_SUPPRESS = "giveaway-suppressEntryReplies";
const currentHost = window.location.hostname;
const chatroomId = currentHost === 'onlyencodes.cc' ? '1' : (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;

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">
          <i class="fa-solid fa-gifts" style="padding:5px;"></i>
          ${SCRIPT_NAME}
          <small style="color:#aaa;margin-left:8px;font-size:0.8em;">v${SCRIPT_VERSION}</small>
        </h4>
      </div>
      <div class="button-right">
        <button id="resetButton"   class="form__button form__button--text giveaway-btn" style="background-color:#b32525;">
          <i class="fa-solid fa-rotate-right"></i> Reset
        </button>
        <button id="giveawaySettingsBtn" class="form__button form__button--text giveaway-btn" style="background-color:#ff6400;">
          <i class="fa-solid fa-gear"></i> Settings
        </button>
        <button id="commandsButton" class="form__button form__button--text giveaway-btn" style="background-color:#ff9600;">
          <i class="fa-solid fa-list"></i> Commands
        </button>
        <button id="closeButton"    class="form__button form__button--text giveaway-btn" style="background-color:#4e595f;">
          <i class="fa-solid fa-xmark"></i>
        </button>
      </div>
    </div>
  </header>

  <!-- ───────────── MAIN BODY (scrollable) ───────────── -->
  <div class="panel__body" id="giveaway_body">
    <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" style="display:flex;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"
     style="display:flex;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>

  <!-- winners -->
  <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%;max-width:400px;">
          <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
          id="startButton"
          class="form__button form__button--filled"
          style="background-color:#02B008;"
        >
          Start
        </button>
      </p>
    </form>

    <h2 id="countdownHeader" class="panel__heading--centered" hidden></h2>

    <div id="entriesWrapper" class="data-table-wrapper" hidden>
      <table id="entriesTable" class="data-table">
        <thead><tr><th>User</th><th>Entry #</th></tr></thead>
        <tbody></tbody>
      </table>
    </div>
  </div><!-- /panel__body -->

  <!-- ───────────── FLOATING MENUS (siblings of body) ───────────── -->

  <!-- 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(`
  ${settingsMenuStyle}
  ${commandsMenuStyle}

  /* Silence <h1> inside <section> console warning */
  #giveawayFrame h1.panel__heading--centered{font-size:1.5em;margin:0.25em 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 = resetGiveaway;

    closeButton = document.getElementById("closeButton");
    closeButton.onclick = 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;

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

    CHAT_VARIANT = detectChatVariant(); //detect if using old Vue chatbox or new Alpine

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

    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 () { return "Giveaway in progress"; };

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

function resetGiveaway() {
    entriesWrapper.hidden = true
    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();
}

function stopGiveaway() {
    // ── 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) {
    ensureVariant()

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

    let author = "";
    if (CHAT_VARIANT === "alpine") {
        // ignore the hidden “Unknown” span
        author = getAuthor(messageNode);
    } else {
        author = messageNode.querySelector(".user-tag")?.textContent.trim() || "";
    }

    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) {
    // 1. Alpine / double-span approach
    const spans = msgNode.querySelectorAll(".user-tag__link span");
    for (const s of spans) {
        const name = s.textContent.trim();
        if (name && name !== "Unknown") return name;
    }

    // 2. Legacy markup fallback
    const legacy = msgNode.querySelector(".user-tag")?.textContent.trim();
    return legacy || "";
}

// ───────────────────────────────────────────────────────────
// 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 msg =
              `[color=#d85e27]${safeAuthor}[/color] has entered with ` +
              `the number [color=#DC3D1D][b]${number}[/b][/color]!`;
        sendMessage(msg);
    }
}

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

function updateEntries() {
    let tableHTML = "<thead><tr><th>User</th><th>Entry #</th></tr></thead><tbody>";
    numberEntries.forEach((entry, author) => {
        const fancyName = fancyNames.get(author) || author;
        tableHTML += `<tr><td>${fancyName}</td><td>${entry}</td></tr>`;
    });
    document.getElementById("entriesTable").innerHTML = tableHTML + "</tbody>";
}

// ───────────────────────────────────────────────────────────
// 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) {
    return fancyName.includes('title="Administrator"') || fancyName.includes('title="Moderator"') || fancyName.includes('title="Admin"');
}

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

        let randomNum;
        const current = new Set(numberEntries.values());
        if (current.size >= giveawayData.totalEntries) {
            sendMessage("All numbers are taken — no free numbers left!");
            return;
        }
        do {
            randomNum = Math.floor(Math.random() * (giveawayData.endNum - giveawayData.startNum + 1)) + giveawayData.startNum;
        } while (current.has(randomNum));

        addNewEntry(author, fancyName, randomNum);
        sendMessage(`[color=#d85e27]${safeAuthor}[/color] has entered with the number [color=#DC3D1D][b]${randomNum}[/b][/color]!`);
    },

    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 freeNumbers = [];
        for (let k = giveawayData.startNum; k <= giveawayData.endNum; k++) {
            if (![...numberEntries.values()].includes(k)) freeNumbers.push(k);
        }
        const sample = freeNumbers.sort(() => Math.random() - 0.5).slice(0, 5);
        sendMessage(`Free numbers: ${sample.join(", ")}.`);
    },

    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 (!isHostOrAdmin(author, fancyName, giveawayData.host)) return;
        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() {
    sendMessage(
        `Commands are ` +
        `![color=#E50E68][b]random[/b][/color] - ` +
        `![color=#E50E68][b]time[/b][/color] - ` +
        `![color=#E50E68][b]free[/b][/color] - ` +
        `![color=#E50E68][b]number[/b][/color] - ` +
        `![color=#E50E68][b]lucky[/b][/color] - ` +
        `![color=#E50E68][b]bon[/b][/color] - ` +
        `![color=#E50E68][b]range[/b][/color] - ` +
        `![color=#E50E68][b]rig[/b][/color] - ` +
        `![color=#E50E68][b]unrig[/b][/color] - ` +
        `![color=#E50E68][b]entries[/b][/color] - ` +
        `![color=#E50E68][b]help[/b][/color] - ` +
        `![color=#E50E68][b]commands[/b][/color].`
    );
}

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) {
        var 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(
                n => `[color=#1DDC5D][b]${sanitizeNick(n)}[/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 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);
}

function sendMessage(messageStr) {
    if (!DEBUG_SETTINGS.disable_chat_output) {
        chatbox.value = messageStr;

        ensureVariant()

        if (CHAT_VARIANT === "alpine") {
            chatbox.dispatchEvent(new KeyboardEvent("keydown", { key: `Enter` }));
        } else {
            chatbox.dispatchEvent(new KeyboardEvent("keydown", { keyCode: 13 }));
        }
    }
    if (DEBUG_SETTINGS.log_chat_messages) console.log(messageStr);
}

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) {
    const numbers = Array.from(numberEntries.values()).sort((a, b) => a - b);
    numbers.push(giveawayData.endNum + 1);
    let bestGap = 0;
    let lucky = 0;
    let pastNum = giveawayData.startNum - 1;

    numbers.forEach(currentNum => {
        const gap = currentNum - pastNum;
        if (gap > bestGap) {
            lucky = Math.floor(gap / 2) + pastNum;
            bestGap = gap;
        }
        pastNum = currentNum;
    });

    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 detectChatVariant() {
    // 1 ⟶ no messages yet, keep trying later
    const firstMsg = document.querySelector(".chatroom__messages .chatbox-message");
    if (!firstMsg) return "unknown";

    /*  Alpine markup always adds at least one of the Alpine-x attributes
      (x-text / x-show / x-html / x-bind / …) somewhere inside the message.
      Legacy markup never has those.                                    */
    const hasAlpineAttr = firstMsg.querySelector("[x-text],[x-show],[x-html],[x-bind]");

    // extra fallback – if Alpine is bundled globally it exposes window.Alpine
    return hasAlpineAttr || window.Alpine ? "alpine" : "legacy";
}

function ensureVariant () {
    if (!CHAT_VARIANT || CHAT_VARIANT === "unknown") {
        CHAT_VARIANT = detectChatVariant();
    }
}

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

        // Find the author for this message
        let author = "";
        // Try to extract author name as used in your script (adjust if needed)
        if (CHAT_VARIANT === "alpine") {
            author = getAuthor(msgNode);
        } else {
            author = msgNode.querySelector(".user-tag")?.textContent.trim() || "";
        }

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

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