Aniouek32 / PTT BON Giveaway

// ==UserScript==
// @name         PTT BON Giveaway
// @description  Giveaway BON dla PolishTorrent
// @version      2.0.37
// @author       Aniouek32
// @match        https://polishtorrent.top/*
// @grant        none
// @license      GPL-3.0-or-later
// @updateURL    https://openuserjs.org/meta/Aniouek32/PTT_BON_Giveaway.meta.js
// @downloadURL  https://openuserjs.org/install/Aniouek32/PTT_BON_Giveaway.user.js
// ==/UserScript==

// Tryby zabawy:
//
// Standard giveaway:
// - klasyczne zgłoszenia numerem na czacie
// - dostępne profile językowe
//
// Stawka większa niż pula BON (v2.0):
// - wejście tylko przez /gift HOST KWOTA numer
// - host nie bierze udziału w losowaniu
// - aktywny profil komunikatów: Sarkazm
//
// Komendy dla użytkowników:
//
// !time
// Pokazuje, ile czasu zostało do końca giveaway.
//
// !status
// Pokazuje skrócony status giveaway: pula, czas, zajęte/wolne numery i sponsorów.
//
// !random
// Losuje wolny numer i automatycznie zapisuje użytkownika do giveaway (tylko Standard).
//
// !number
// Pokazuje numer, z którym dany użytkownik już bierze udział.
//
// !lucky
// Pokazuje aktualnie „szczęśliwy” numer, wyliczony przez skrypt
// na podstawie największej luki między zajętymi numerami.
//
// !luckye
// Automatycznie zapisuje użytkownika na aktualny „szczęśliwy” numer (tylko Standard).
//
// !free
// Pokazuje kilka przykładowych wolnych numerów.
//
// !entries
// Pokazuje bieżące zgłoszenia: kto jaki numer zajął oraz ile numerów zostało wolnych.
//
// !sponsors
// Pokazuje sponsorów tego giveaway i ile BON dorzucił każdy z nich.
//
// !bon
// Pokazuje aktualną pulę BON w giveaway.
//
// !range
// Pokazuje zakres dozwolonych numerów, np. 1-50.
//
// !commands
// Wyświetla listę dostępnych komend.
//
// Komendy tylko dla hosta:
//
// !addbon 100
// Dodaje BON do puli giveaway.
//
// !removebon 100
// Usuwa BON z puli giveaway (tylko z BON dodanego później przez hosta).
//
// !winners 3
// Zmienia liczbę zwycięzców w trakcie trwania giveaway.
//
// !reminder
// Wysyła ręczne przypomnienie o giveaway na czat.
//
// !pause
// Wstrzymuje przyjmowanie nowych zgłoszeń (numery i autozapisy).
//
// !resume
// Wznawia przyjmowanie nowych zgłoszeń.
//
// !time add 5
// Dodaje 5 minut do czasu trwania giveaway.
//
// !time remove 5
// Odejmuje 5 minut od czasu trwania giveaway.

(function () {
    'use strict';

    const GENERAL_SETTINGS = {
        defaultMinsPerReminder: 5,
        minsPerReminderLimit: 3,
        entryPollMs: 2000,
        sponsorPollMs: 10000,
        entriesRoomId: '1',
        sponsorRoomId: '13',
        verifyAttempts: 5,
        verifyDelayMs: 5000,
    };

    const DEBUG_SETTINGS = {
        logChatMessages: false,
        disableChatOutput: false,
    };
	
	const COMMAND_WINDOW_MS = 10000;
    const MAX_COMMANDS_PER_WINDOW = 3;
    const BASE_PENALTY_SECONDS = 30;
    const MIN_ACTION_GAP_MS = 900;
    const ENTRY_FEEDBACK_COOLDOWN_MS = 8000;
    const STRIKE_WINDOW_MS = 10 * 60 * 1000;
    const MAX_STRIKE_MULTIPLIER = 8;

    const REPEAT_COMMAND_COOLDOWNS_MS = Object.freeze({
        time: 3000,
        status: 5000,
        entries: 5000,
        free: 7000,
        lucky: 7000,
        luckye: 7000,
        random: 7000,
        range: 5000,
        sponsors: 8000,
        bon: 5000,
        number: 5000,
        commands: 10000
    });

    const SPONSOR_ANNOUNCE = {
        mode: "digest",
        digestMs: 60000,
        immediateSingleMin: 500,
        flushMinTotal: 250,
        maxPendingEvents: 50,
        showTopN: 5,
        showMinPerUser: 0
    };

    function pickVariant(variants) {
        if (!Array.isArray(variants) || variants.length === 0) return "";
        return variants[Math.floor(Math.random() * variants.length)];
    }

    const CHAT_COPY_SARKAZM = Object.freeze({
        intro: ({ amount, totalTimeMs, winnersText, startNum, endNum, sponsorMessage }) =>
            pickVariant([
              `Uwaga, uwaga: odpalam giveaway za [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b]. Macie [b][color=green]${parseTime(totalTimeMs)}[/color][/b], więc bez paniki. W puli czeka [b][color=#5DE2E7]${winnersText}[/color][/b]. Wrzucaj liczbę [b]od [color=red]${startNum} do ${endNum}[/color][/b]. `,
              `Start zabawy: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b] na stole. Czas [b][color=green]${parseTime(totalTimeMs)}[/color][/b], zwycięzców [b][color=#5DE2E7]${winnersText}[/color][/b]. Celuj w zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `No dobra, jedziemy: giveaway za [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b]. Zegar tyka [b][color=green]${parseTime(totalTimeMs)}[/color][/b], nagrodzonych bedzie [b][color=#5DE2E7]${winnersText}[/color][/b]. Numer od [b][color=red]${startNum} do ${endNum}[/color][/b]. `,
              `Kto pierwszy ten lepszy: pula [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b], czas [b][color=green]${parseTime(totalTimeMs)}[/color][/b], liczba szczęśliwców [b][color=#5DE2E7]${winnersText}[/color][/b]. Gramy numerami [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Otwieram giveaway: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b] do zgarnięcia. Macie [b][color=green]${parseTime(totalTimeMs)}[/color][/b], zwycięzców [b][color=#5DE2E7]${winnersText}[/color][/b], zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `To nie cwiczenia: w puli lezy [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b]. Czas [b][color=green]${parseTime(totalTimeMs)}[/color][/b], a na mecie [b][color=#5DE2E7]${winnersText}[/color][/b]. Strzelaj [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Ruszamy z loteria dla zdecydowanych: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b], limit czasu [b][color=green]${parseTime(totalTimeMs)}[/color][/b], zwyciezcow [b][color=#5DE2E7]${winnersText}[/color][/b]. Zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Scena gotowa, RNG rozgrzane: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b] do podzialu. Masz [b][color=green]${parseTime(totalTimeMs)}[/color][/b], typuj od [b][color=red]${startNum} do ${endNum}[/color][/b]. `,
              `Wchodzimy w tryb "kto trafi, ten bierze": [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b], czas [b][color=green]${parseTime(totalTimeMs)}[/color][/b], nagrodzonych [b][color=#5DE2E7]${winnersText}[/color][/b]. Numery [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Dobra, bez lania wody: pula [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b], licznik [b][color=green]${parseTime(totalTimeMs)}[/color][/b], cel [b][color=#5DE2E7]${winnersText}[/color][/b]. Gramy na [b][color=red]${startNum}-${endNum}[/color][/b]. `
            ]) + sponsorMessage,

        reminder: ({ amount, timeLeftMs, winnersNum, startNum, endNum, sponsorMessage }) =>
            pickVariant([
              `Przypominajka dla spóźnialskich: giveaway trwa, pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b]. Zostało [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zwycięzców [b][color=#5DE2E7]${winnersNum}[/color][/b], zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Update dla tych z tylu: nadal gramy o [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b]. Czas do końca [b][color=green]${parseTime(timeLeftMs)}[/color][/b], liczba wygranych [b][color=#5DE2E7]${winnersNum}[/color][/b], numery [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Jeszcze żyje: giveaway dalej otwarty. Pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], zostało [b][color=green]${parseTime(timeLeftMs)}[/color][/b], wygrywa [b][color=#5DE2E7]${winnersNum}[/color][/b] osób. Zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Alarm dla maruderów: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b] nadal czeka. Czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zwycięzców [b][color=#5DE2E7]${winnersNum}[/color][/b], trafiasz liczbę z [b][color=red]${startNum} do ${endNum}[/color][/b]. `,
              `Szybki ping: giveaway aktywny, pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b]. Do końca [b][color=green]${parseTime(timeLeftMs)}[/color][/b], miejsc [b][color=#5DE2E7]${winnersNum}[/color][/b], przedział [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Jeszcze jest o co bic sie: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zwyciezcow [b][color=#5DE2E7]${winnersNum}[/color][/b], zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Przeglad sytuacji: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], zegar [b][color=green]${parseTime(timeLeftMs)}[/color][/b], wolne sloty uciekaja. Zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Krotki komunikat techniczny: giveaway nadal aktywny. W puli [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], do konca [b][color=green]${parseTime(timeLeftMs)}[/color][/b]. `,
              `Jesli miales dolaczyc "za chwile", to ta chwila wlasnie trwa: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], zakres [b][color=red]${startNum}-${endNum}[/color][/b]. `,
              `Przypomnienie kontrolne: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zwyciezcow [b][color=#5DE2E7]${winnersNum}[/color][/b]. `
            ]) + sponsorMessage,

        timeLeft: (timeLeftMs) => `Na zegarze zostało jeszcze [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        hostOnlyTime: () => "Ta komenda jest tylko dla hosta. Demokracja kończy się tutaj.",
        usageTime: () => "Użycie: !time add 5 albo !time remove 5",
        hostAddTime: (amount, timeLeftMs) => `Host dorzucił [b][color=green]${amount} min.[/color][/b]. Nowy czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        hostRemoveTime: (amount, timeLeftMs) => `Host skrócił o [b][color=red]${amount} min.[/color][/b]. Zostało: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        hostTimeEnded: () => "Host uciął czas do zera. Koniec imprezy.",

        status: ({ amount, timeLeftMs, taken, total, freeCount, sponsorCount, entriesState }) =>
            pickVariant([
              `Status w pigułce: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], numery [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], zgłoszenia [b]${entriesState}[/b].`,
              `Raport bojowy: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b] w puli, timer [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zajęte [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], tryb zgłoszeń [b]${entriesState}[/b].`,
              `Sytuacja na froncie: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], liczby [b]${taken}/${total}[/b], wolnych [b]${freeCount}[/b], sponsorów [b]${sponsorCount}[/b], zgłoszenia [b]${entriesState}[/b].`,
              `Stan gry: kasa [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], do końca [b][color=green]${parseTime(timeLeftMs)}[/color][/b], sloty [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], wpisy [b]${entriesState}[/b].`,
              `Telemetry: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], obłożenie [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], status zgłoszeń [b]${entriesState}[/b].`,
              `Snapshot: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b] w puli, [b][color=green]${parseTime(timeLeftMs)}[/color][/b] do konca, obsada [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], wpisy [b]${entriesState}[/b].`,
              `Kontrola lotu: budzet [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], timer [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zajete [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b].`,
              `Suchy raport: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], numery [b]${taken}/${total}[/b], zapas [b]${freeCount}[/b], status wpisow [b]${entriesState}[/b].`,
              `Panel stanu: kasa [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], odliczanie [b][color=green]${parseTime(timeLeftMs)}[/color][/b], sponsorzy [b]${sponsorCount}[/b], wolnych [b]${freeCount}[/b].`,
              `Wersja dla konkretu: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], [b]${taken}/${total}[/b] zajete, [b]${freeCount}[/b] wolne, wpisy [b]${entriesState}[/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`
            ]),

        alreadyJoined: (author, number) =>
            pickVariant([
              `Ej [color=#d85e27]${sanitizeNick(author)}[/color], już grasz numerem [color=red][b]${number}[/b][/color]. Drugi raz nie nalicza punktów za styl.`,
              `[color=#d85e27]${sanitizeNick(author)}[/color], ten pociąg już odjechał: masz [color=red][b]${number}[/b][/color].`,
              `Spokojnie [color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color] już jest twój. Bonusu za dubel brak.`,
              `[color=#d85e27]${sanitizeNick(author)}[/color], już widniejesz na liście z numerem [color=red][b]${number}[/b][/color].`,
              `Nie przyspieszysz RNG, [color=#d85e27]${sanitizeNick(author)}[/color]. Aktualny numer to [color=red][b]${number}[/b][/color].`,
              `Masz juz bilet, [color=#d85e27]${sanitizeNick(author)}[/color]: numer [color=red][b]${number}[/b][/color]. Kolejny nic nie zmieni.`,
              `[color=#d85e27]${sanitizeNick(author)}[/color], system pamieta: grasz [color=red][b]${number}[/b][/color]. Nadpisywanie marzen wylaczone.`,
              `Ten numer jest juz Twoj, [color=#d85e27]${sanitizeNick(author)}[/color]: [color=red][b]${number}[/b][/color]. Duble sa przereklamowane.`,
              `[color=#d85e27]${sanitizeNick(author)}[/color], juz jestes na liscie z [color=red][b]${number}[/b][/color]. RNG nie lubi spamu.`,
              `Bez nerwow, [color=#d85e27]${sanitizeNick(author)}[/color]. Aktywny numer to nadal [color=red][b]${number}[/b][/color].`
            ]),
        entriesPaused: (author) => `Stop-klatka, [color=#d85e27]${sanitizeNick(author)}[/color]. Host chwilowo zamroził zgłoszenia.`,
        noFreeForUser: (author) => `Przykro mi [color=#d85e27]${sanitizeNick(author)}[/color], ale wolnych numerów już brak. Magii też.`,
        joinedRandom: (author, num, timeLeftMs) => `[color=#d85e27]${sanitizeNick(author)}[/color] wskakuje z numerem [color=green][b]${num}[/b][/color]. Czas tyka: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        numberInfo: (author, number) => `[color=#d85e27]${sanitizeNick(author)}[/color], Twój numer to [color=red][b]${number}[/b][/color]. Pilnuj go jak oka w głowie.`,
        numberNone: (author) => `[color=#d85e27]${sanitizeNick(author)}[/color], na razie nie masz numeru w giveaway.`,
        luckyNow: (num) => `Aktualny szczęśliwy numer to [b][color=green]${num}[/color][/b]. Totolotek mode: ON.`,
        noLuckyCandidate: (author) => `Niestety [color=#d85e27]${sanitizeNick(author)}[/color], dziś algorytm nie ma sensownego "szczęśliwego" numeru.`,
        joinedLuckye: (author, num, timeLeftMs) => `[color=#d85e27]${sanitizeNick(author)}[/color] wchodzi przez [b]!luckye[/b] z numerem [color=green][b]${num}[/b][/color]. Czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        noFreeLeft: () => "Wolnych numerów brak. Plansza wyczyszczona.",
        freeCountOnly: (count) => `Wolnych numerów zostało: [b][color=green]${count}[/color][/b]. Jeszcze zdążysz.`,
        freeSample: (count, sample) => `Wolne numery (${count}): [b][color=green]${sample.join(", ")}[/color][/b]. Bierz, póki są.`,

        hostOnlyCmd: (cmd) => `Komenda ${cmd} jest tylko dla hosta. Nice try.`,
        usage: (example) => `Użycie: ${example}`,
        addBonBalanceError: (currentBon, newHostContribution) =>
          `Spokojnie, milionerem jeszcze nie jesteś. Masz [b][color=#ffc00a]${formatBon(currentBon)} BON[/color][/b], ` +
          `a wkład hosta skoczyłby do [b][color=red]${formatBon(newHostContribution)} BON[/color][/b].`,
        hostAddedBon: (amount, total) => `Host dorzuca [color=red][b]${cleanPotString(amount)}[/b][/color] BON. Pula rośnie do [b][color=#ffc00a]${cleanPotString(total)} BON[/color][/b].`,
        cannotRemoveNoExtra: () => "Nie ma czego odejmować. Możesz zdjąć tylko to, co dołożyłeś przez !addbon.",
        cannotRemoveMax: (maxAmount) => `Możesz odjąć maksymalnie [b][color=red]${cleanPotString(maxAmount)}[/color][/b] BON. Matematyka jest nieugięta.`,
        hostRemovedBon: (amount, total) => `Host zdejmuje [color=red][b]${cleanPotString(amount)}[/b][/color] BON z dodatkowego wkładu. Pula: [b][color=#ffc00a]${cleanPotString(total)} BON[/color][/b].`,
        hostOnlyWinners: () => "Tylko host może zmienić liczbę zwycięzców.",
        winnersSet: (count) => `Liczba zwycięzców ustawiona na [b][color=#5DE2E7]${count}[/color][/b]. Tak ma być, nie dyskutujemy.`,
        hostOnlyReminder: () => "Przypomnienie ręczne? Tylko host.",
        hostOnlyPause: () => "Wstrzymywać zgłoszenia może tylko host.",
        pausedAlready: () => "Zgłoszenia już są wstrzymane. Bardziej się nie da.",
        pausedNow: () => "Host zatrzymał nowe zgłoszenia. Chwila oddechu.",
        hostOnlyResume: () => "Wznawiać zgłoszenia może tylko host.",
        resumedAlready: () => "Zgłoszenia już działają. Wszystko pod kontrolą.",
        resumedNow: () => "Host wznowił zgłoszenia. Wracamy do gry.",
        commandsList: () => "Komendy: !time !status !random !number !lucky !luckye !free !entries !sponsors !bon !range !commands | host: !addbon !removebon !winners !reminder !pause !resume !time add/remove | Stawka większa niż pula BON: wejście [color=red][b]/gift HOST KWOTA numer[/b][/color].",
        sponsorHint: (host) => pickVariant([
            `[color=#999999][b]Każdy BON wysłany hostowi podczas giveaway automatycznie zwiększa pulę nagród.[/b][/color] [color=#ffc00a][b]Jak dorzucić BON:[/b][/color] [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color]`,
            `Jeśli chcesz napompować pulę, wyślij hostowi [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color]. Tak, to działa automatycznie.`,
            `Sponsor mode: ON. Każdy gift do [b]${sanitizeNick(host)}[/b] dokłada BON do puli. Format: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color].`,
            `Masz BON i gest? Rzuć [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color], a pula urośnie bez pytania o zgodę.`,
            `Kto dokłada do puli, ten bohater dnia. Komenda: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color].`,
            `Szybka ścieżka do większej puli: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color]. System sam to doliczy.`,
            `Portfel swędzi? Użyj [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color] i patrz, jak pula puchnie.`,
            `Wsparcie techniczne puli: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color]. Resztę zrobi automat.`,
            `Każdy BON wysłany do [b]${sanitizeNick(host)}[/b] podczas giveaway trafia do puli. Komenda: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color].`,
            `Ekonomia eventu w Twoich rękach: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color] i już dokładamy do nagród.`
        ]),

        entriesNone: () => "Na razie cisza, zero zgłoszeń.",
        entriesList: (size, total, freeCount, list) => `Zgłoszenia (${size}/${total}, wolne: ${freeCount}): ${list}`,
        sponsorsNone: () => "Sponsorów brak. Portfele dziś ostrożne.",
        sponsorsList: (totalSponsored, list) => `Sponsorzy (łącznie ${cleanPotString(totalSponsored)} BON): ${list}`,
        potNow: (amount) => `Aktualna pula: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b].`,
        rangeNow: (start, end) => `Zakres numerów: [b][color=red]${start} - ${end}[/color][/b].`,

        outOfRange: (author, number, start, end, freeSuggestion) =>
          `[color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color] jest poza zakresem. ` +
          `Celuj [b]od ${start} do ${end} (włącznie)[/b].` +
            freeSuggestion,
        numberTaken: (author, existingAuthor, number, freeSuggestion) =>
          `[color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color] już zgarnął [color=#32cd53]${sanitizeNick(existingAuthor)}[/color]. ` +
          `Spróbuj innego.` +
            freeSuggestion,
        entryJoined: (author, number, timeLeftMs) =>
            pickVariant([
              `[color=#d85e27]${sanitizeNick(author)}[/color] dołącza z numerem [color=red][b]${number}[/b][/color]. Do końca: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] wbija na [color=red][b]${number}[/b][/color]. Zegar pokazuje [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `Mamy nowego gracza: [color=#d85e27]${sanitizeNick(author)}[/color] bierze [color=red][b]${number}[/b][/color]. Czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] wskakuje na numer [color=red][b]${number}[/b][/color]. Pozostało [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] zajmuje [color=red][b]${number}[/b][/color]. Timer: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] melduje numer [color=red][b]${number}[/b][/color]. Zegar: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `Do listy dopisuje sie [color=#d85e27]${sanitizeNick(author)}[/color] z [color=red][b]${number}[/b][/color]. Do konca [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] lockuje slot [color=red][b]${number}[/b][/color]. Odliczanie pokazuje [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `Nowy wpis: [color=#d85e27]${sanitizeNick(author)}[/color] -> [color=red][b]${number}[/b][/color]. Czas do finiszu [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] rezerwuje [color=red][b]${number}[/b][/color]. Licznik nadal tyka: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`
            ]),

        noEntriesEnd: () => "Koniec giveaway i... nikt nie zagrał. Pula wraca do domu.",
        hostAmongWinners: (names) => `Host znalazł się wśród zwycięzców: ${names}.`,

        earlyFinish: (totalEntries, timeLeftMs) =>
            "Wszystkie [b][color=#ffc00a]" +
          `${totalEntries}[/color][/b] miejsca zajęte szybciej niż przewidywano. ` +
          `Formalnie zostało jeszcze [b][color=green]${parseTime(timeLeftMs)}[/color][/b], ale kończymy wcześniej.`,

        spamDetected: (author, penaltySec) =>
          `[color=red][b]Spam wykryty. ${sanitizeNick(author)} ma blokadę komend na ${penaltySec} sek.[/b][/color]`,

        sponsorDigest: ({ deltaTotalNum, sponsorCount, parts, othersCount, totalPot }) => {
            let message = pickVariant([
              `Sponsorzy dorzucili [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Wpadł świeży zrzut: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Ekonomia czatu nie śpi: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Portfele przemówiły: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Nowy boost puli: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Donacja wpadla: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Kolejny zastrzyk puli: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Rynek powiedzial "tak": [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Skarbiec dostal upgrade: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `,
              `Wplynal pakiet wsparcia: [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "graczy"}[/b]. `
            ]);

            if (parts.length) {
                message += parts.join(", ");
                if (othersCount > 0) {
                    message += `, [i]+${othersCount} więcej[/i]`;
                }
                message += ". ";
            }

            message += pickVariant([
              `Pula po zastrzyku wynosi [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Nowa suma na liczniku: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Bilans po tej akcji: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Aktualny stan skarbca: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Kasa po update: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Po tej rundzie licznik pokazuje [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Aktualizacja skarbca: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Nowy punkt odniesienia: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b] w puli.`,
              `Stan po ksiegowaniu: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`,
              `Pula po doliczeniu: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`
            ]);
            return message;
        },

        finishSummary: ({ winningNumber, winnersSummary, hostContributionText, sponsorTotalText, sponsors, hasTie }) => {
            let message = pickVariant([
              `Koniec zabawy: trafiony numer to [b][color=green]${winningNumber}[/color][/b]. Podium zgarniają: ${winnersSummary}. Host dorzucił [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Kurtyna w dół: zwycięski numer [b][color=green]${winningNumber}[/color][/b]. Wygrani: ${winnersSummary}. Wkład hosta [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `RNG zdecydowało: [b][color=green]${winningNumber}[/color][/b]. Na podium lądują: ${winnersSummary}. Host wrzucił [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Wynik końcowy: [b][color=green]${winningNumber}[/color][/b]. Topka tej rundy: ${winnersSummary}. Host dołożył [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Mamy finał: numer [b][color=green]${winningNumber}[/color][/b]. Wygrywają: ${winnersSummary}. Udział hosta: [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Final zamkniety: numer [b][color=green]${winningNumber}[/color][/b]. Na czele: ${winnersSummary}. Wklad hosta [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Wyrok RNG zapadl: [b][color=green]${winningNumber}[/color][/b]. Nagrody biora: ${winnersSummary}. Host dorzucil [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Domkniecie rundy: trafiony [b][color=green]${winningNumber}[/color][/b], podium: ${winnersSummary}. Udzial hosta [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Rachunek koncowy: [b][color=green]${winningNumber}[/color][/b]. Wygrani to ${winnersSummary}. Host wrzucil [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `,
              `Zamykamy temat: numer [b][color=green]${winningNumber}[/color][/b], zwyciezcy ${winnersSummary}. Dorzut hosta [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `
            ]);

            if (sponsors.length > 0) {
                const sponsorDetails = sponsors
                    .map(item => `[color=green][b]${sanitizeNick(item.name)}[/b][/color] - ${cleanPotString(item.amount)} BON`)
                    .join(", ");

                message += pickVariant([
                  `Sponsorzy dorzucili łącznie [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Portfele zapłakały, chat zadowolony. `,
                  `Sponsorzy dołożyli razem [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Ekipa finansowa dowiozła. `,
                  `Dodatkowy boost od sponsorów: [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Szacunek dla bankierów. `,
                  `Sponsorzy wrzucili sumarycznie [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Kultura finansowa na poziomie. `,
                  `Do puli od sponsorów weszło [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). To się nazywa wsparcie. `,
                  `Sponsorzy finalnie dolozyli [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Finansowanie domkniete. `,
                  `Bilans sponsorow: [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Robota zrobiona. `,
                  `Zaplecze finansowe dorzucilo [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Szacunek. `,
                  `Kontrybucja sponsorow: [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Pula mowi dziekuje. `,
                  `Zasilenie od sponsorow: [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] (${sponsorDetails}). Kasa dowieziona. `
                ]);
            } else {
                message += pickVariant([
                  `Dodatkowi sponsorzy odpalili klasyczne "może następnym razem". `,
                  `Sponsorzy tym razem zagrali w trybie oszczędnym. `,
                  `Sekcja sponsorów dzisiaj w trybie stealth. `,
                  `Nikt ekstra nie dorzucił do puli i żyjemy dalej. `,
                  `Bonus od sponsorów: 0 BON i solidne milczenie. `,
                  `Sponsorzy dzisiaj wybrali obserwacje zamiast transferow. `,
                  `Dodatkowy zrzut nie padl, portfele zostaly zamkniete. `,
                  `Sekcja wsparcia finansowego: online, ale bez ruchow. `,
                  `Nadprogramowego BON nie bylo, bo po co sobie ulatwiac. `,
                  `Bilans sponsorow na koncu: zero doplat i duzo spokoju. `
                ]);
            }

            if (hasTie) {
                message += pickVariant([
                  `Przy remisie wyższe miejsce bierze osoba, która była szybsza na starcie.`,
                  `Przy remisie liczy się kolejność zgłoszeń, bez sentymentów.`,
                  `W remisie wygrywa szybszy wpis. Proste zasady, proste życie.`,
                  `Jeśli remis, pierwszeństwo ma ten kto zgłosił się wcześniej.`,
                  `Remis rozstrzygamy czasem zgłoszenia: szybszy bierze wyższą pozycję.`,
                  `Gdy wpada remis, priorytet ma ten, kto zareagowal szybciej.`,
                  `Remisy tniemy timestampem: pierwsze zgloszenie ma wyzsze miejsce.`,
                  `W przypadku remisu decyduje kolejnosc wejsc, nie glosnosc komentarzy.`,
                  `Jesli roznica jest ta sama, wygrywa szybszy wpis i temat zamkniety.`,
                  `Przy rownym dystansie decyduje czas zgloszenia: kto pierwszy, ten wyzej.`
                ]);
            }
            return message;
        }
    });

    const CHAT_COPY_KLASYCZNY = Object.freeze({
        intro: ({ amount, totalTimeMs, winnersText, startNum, endNum, sponsorMessage }) =>
          `Hej! Startujemy giveaway o wartości [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b]. ` +
          `Zapisy otwarte przez [b][color=green]${parseTime(totalTimeMs)}[/color][/b]. ` +
          `Wyłonimy [b][color=#5DE2E7]${winnersText}[/color][/b]. ` +
          `Wpisz liczbę całkowitą [b]od [color=red]${startNum} do ${endNum}[/color] włącznie[/b]. ` +
            sponsorMessage,

        reminder: ({ amount, timeLeftMs, winnersNum, startNum, endNum, sponsorMessage }) =>
          `Trwa giveaway o wartości [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b]. ` +
          `Pozostały czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b]. ` +
          `Zwycięzców: [b][color=#5DE2E7]${winnersNum}[/color][/b]. ` +
          `Wpisz liczbę całkowitą [b]od [color=red]${startNum} do ${endNum}[/color] włącznie[/b]. ` +
            sponsorMessage,

        timeLeft: (timeLeftMs) => `Pozostały czas giveaway: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        hostOnlyTime: () => "Tylko host może zmieniać czas giveaway.",
        usageTime: () => "Użycie: !time add 5 lub !time remove 5",
        hostAddTime: (amount, timeLeftMs) => `Host dodał [b][color=green]${amount} min.[/color][/b]. Nowy pozostały czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        hostRemoveTime: (amount, timeLeftMs) => `Host odjął [b][color=red]${amount} min.[/color][/b]. Nowy pozostały czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        hostTimeEnded: () => "Host skrócił czas giveaway do zera. Kończymy teraz.",

        status: ({ amount, timeLeftMs, taken, total, freeCount, sponsorCount, entriesState }) =>
          `Status giveaway: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], ` +
          `czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], ` +
          `numery [b]${taken}/${total}[/b] (wolne: ${freeCount}), ` +
          `sponsorzy [b]${sponsorCount}[/b], zgłoszenia: [b]${entriesState}[/b].`,

        alreadyJoined: (author, number) => `Przepraszam [color=#d85e27]${sanitizeNick(author)}[/color], ale [color=#32cd53]już[/color] bierzesz udział z numerem [color=red][b]${number}[/b][/color]!`,
        entriesPaused: (author) => `Przepraszam [color=#d85e27]${sanitizeNick(author)}[/color], ale host chwilowo wstrzymał przyjmowanie nowych zgłoszeń.`,
        noFreeForUser: (author) => `Przepraszam [color=#d85e27]${sanitizeNick(author)}[/color], ale nie ma już wolnych numerów.`,
        joinedRandom: (author, num, timeLeftMs) => `[color=#d85e27]${sanitizeNick(author)}[/color] dołącza z numerem [color=green][b]${num}[/b][/color]! Pozostały czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        numberInfo: (author, number) => `[color=#d85e27]${sanitizeNick(author)}[/color], twój numer to [color=red][b]${number}[/b][/color].`,
        numberNone: (author) => `[color=#d85e27]${sanitizeNick(author)}[/color], obecnie nie bierzesz udziału w giveaway.`,
        luckyNow: (num) => `Aktualny szczęśliwy numer giveaway to: [b][color=green]${num}[/color][/b].`,
        noLuckyCandidate: (author) => `Przepraszam [color=#d85e27]${sanitizeNick(author)}[/color], ale nie ma już sensownego wolnego numeru do automatycznego wyboru.`,
        joinedLuckye: (author, num, timeLeftMs) => `[color=#d85e27]${sanitizeNick(author)}[/color] dołącza komendą [b]!luckye[/b] z numerem [color=green][b]${num}[/b][/color]! Pozostały czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        noFreeLeft: () => "Nie ma już wolnych numerów.",
        freeCountOnly: (count) => `Pozostało wolnych numerów: [b][color=green]${count}[/color][/b].`,
        freeSample: (count, sample) => `Wolne numery (${count}): [b][color=green]${sample.join(", ")}[/color][/b].`,

        hostOnlyCmd: (cmd) => `Tylko host może użyć komendy ${cmd}.`,
        usage: (example) => `Użycie: ${example}`,
        addBonBalanceError: (currentBon, newHostContribution) =>
          `Nie możesz dodać tyle BON. Według widocznego salda masz [b][color=#ffc00a]${formatBon(currentBon)} BON[/color][/b], ` +
          `a wkład hosta wzrósłby do [b][color=red]${formatBon(newHostContribution)} BON[/color][/b].`,
        hostAddedBon: (amount, total) => `Host dodaje [color=red][b]${cleanPotString(amount)}[/b][/color] BON do puli! Łączna pula wynosi teraz: [b][color=#ffc00a]${cleanPotString(total)} BON[/color][/b].`,
        cannotRemoveNoExtra: () => "Nie możesz już odjąć BON — możesz usuwać tylko BON dodany później przez !addbon.",
        cannotRemoveMax: (maxAmount) => `Możesz odjąć maksymalnie [b][color=red]${cleanPotString(maxAmount)}[/color][/b] BON.`,
        hostRemovedBon: (amount, total) => `Host usuwa [color=red][b]${cleanPotString(amount)}[/b][/color] BON z dodatkowego wkładu. Łączna pula wynosi teraz: [b][color=#ffc00a]${cleanPotString(total)} BON[/color][/b].`,
        hostOnlyWinners: () => "Tylko host może zmienić liczbę zwycięzców.",
        winnersSet: (count) => `Liczba zwycięzców została zmieniona na [b][color=#5DE2E7]${count}[/color][/b].`,
        hostOnlyReminder: () => "Tylko host może ręcznie wysłać przypomnienie.",
        hostOnlyPause: () => "Tylko host może wstrzymać zgłoszenia.",
        pausedAlready: () => "Zgłoszenia są już wstrzymane.",
        pausedNow: () => "Host wstrzymał przyjmowanie nowych zgłoszeń.",
        hostOnlyResume: () => "Tylko host może wznowić zgłoszenia.",
        resumedAlready: () => "Zgłoszenia są już aktywne.",
        resumedNow: () => "Host wznowił przyjmowanie nowych zgłoszeń.",
        commandsList: () => "Dostępne komendy: !time !status !random !number !lucky !luckye !free !entries !sponsors !bon !range !commands oraz dla hosta !addbon !removebon !winners !reminder !pause !resume !time add/remove. W trybie Stawka większa niż pula BON wejście tylko przez [color=red][b]/gift HOST KWOTA numer[/b][/color].",
        sponsorHint: (host) => `Każdy BON wysłany hostowi podczas giveaway automatycznie zwiększa pulę nagród. Jak dodać BON: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color].`,

        entriesNone: () => "Aktualnie nie ma jeszcze żadnych zgłoszeń.",
        entriesList: (size, total, freeCount, list) => `Zgłoszenia (${size}/${total}, wolne: ${freeCount}): ${list}`,
        sponsorsNone: () => "Aktualnie brak sponsorów w tym giveaway.",
        sponsorsList: (totalSponsored, list) => `Sponsorzy tego giveaway (łącznie ${cleanPotString(totalSponsored)} BON): ${list}`,
        potNow: (amount) => `Aktualna pula giveaway: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b].`,
        rangeNow: (start, end) => `Zakres numerów dla tego giveaway: [b][color=red]${start} - ${end}[/color][/b].`,

        outOfRange: (author, number, start, end, freeSuggestion) =>
          `Przepraszam [color=#d85e27]${sanitizeNick(author)}[/color], ale numer [color=red][b]${number}[/b][/color] jest poza zakresem! ` +
          `Spróbuj numeru [b]od ${start} do ${end} włącznie[/b]!` +
            freeSuggestion,
        numberTaken: (author, existingAuthor, number, freeSuggestion) =>
          `Przepraszam [color=#d85e27]${sanitizeNick(author)}[/color], ale [color=#32cd53]${sanitizeNick(existingAuthor)}[/color] już wybrał numer [color=red][b]${number}[/b][/color]! ` +
          `Spróbuj innego numeru!` +
            freeSuggestion,
        entryJoined: (author, number, timeLeftMs) =>
          `[color=#d85e27]${sanitizeNick(author)}[/color] dołącza z numerem [color=red][b]${number}[/b][/color]! ` +
          `Pozostały czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,

        noEntriesEnd: () => "Niestety nikt nie wziął udziału w giveaway, więc nikt nie wygrywa!",
        hostAmongWinners: (names) => `Host znalazł się wśród zwycięzców: ${names}.`,

        earlyFinish: (totalEntries, timeLeftMs) =>
            "Wszystkie [b][color=#ffc00a]" +
          `${totalEntries}[/color][/b] miejsca zostały zajęte! Giveaway kończy się wcześniej — pozostało jeszcze [b][color=green]` +
          `${parseTime(timeLeftMs)}[/color][/b]!`,

        spamDetected: (author, penaltySec) =>
          `[color=red][b]Spam wykryty. ${sanitizeNick(author)} ma blokadę komend na ${penaltySec} sek.[/b][/color]`,

        sponsorDigest: ({ deltaTotalNum, sponsorCount, parts, othersCount, totalPot }) => {
            let message =
              `Sponsorzy właśnie dorzucili [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] ` +
              `od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "osób"}[/b]! `;

            if (parts.length) {
                message += parts.join(", ");
                if (othersCount > 0) {
                    message += `, [i]+${othersCount} więcej[/i]`;
                }
                message += ". ";
            }

            message += `Łączna pula wynosi teraz [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`;
            return message;
        },

        finishSummary: ({ winningNumber, winnersSummary, hostContributionText, sponsorTotalText, sponsors, hasTie }) => {
            let message =
              `Zwycięski numer to [b][color=green]${winningNumber}[/color][/b]. ` +
              `Wygrywają: ${winnersSummary}. ` +
              `Host wrzucił [b][color=#ffc00a]${hostContributionText} BON[/color][/b]. `;

            if (sponsors.length > 0) {
                const sponsorDetails = sponsors
                    .map(item => `[color=green][b]${sanitizeNick(item.name)}[/b][/color] - ${cleanPotString(item.amount)} BON`)
                    .join(", ");

                message +=
                  `Sponsorzy dorzucili łącznie [b][color=#ffc00a]${sponsorTotalText} BON[/color][/b] ` +
                  `(${sponsorDetails}). `;
            } else {
                message += `Dodatkowi sponsorzy nie dorzucili nic do puli. `;
            }

            if (hasTie) {
                message += `Przy remisie wyższe miejsce bierze osoba, która zgłosiła się wcześniej.`;
            }
            return message;
        }
    });

    const CHAT_COPY_MEMICZNY = Object.freeze({
        ...CHAT_COPY_SARKAZM,
        intro: ({ amount, totalTimeMs, winnersText, startNum, endNum, sponsorMessage }) =>
          `[BOOT] Instancja giveaway wystartowa\u0142a z bud\u017cetem [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b]. ` +
          `[TIMER] Okno eventu: [b][color=green]${parseTime(totalTimeMs)}[/color][/b]. ` +
          `[QUEST] Sloty nagr\u00f3d: [b][color=#5DE2E7]${winnersText}[/color][/b]. ` +
          `Wbijaj numer [b]od [color=red]${startNum} do ${endNum}[/color] (w\u0142\u0105cznie)[/b]. ` +
            sponsorMessage,

        reminder: ({ amount, timeLeftMs, winnersNum, startNum, endNum, sponsorMessage }) =>
          `[EXPLORE] Event aktywny: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b]. ` +
          `Pozosta\u0142o [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zwyci\u0119zc\u00f3w: [b][color=#5DE2E7]${winnersNum}[/color][/b]. ` +
          `Zakres wej\u015bcia: [b]${startNum}-${endNum}[/b]. ` +
            sponsorMessage,

        timeLeft: (timeLeftMs) => `[CLOCK] Do ko\u0144ca sesji: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        status: ({ amount, timeLeftMs, taken, total, freeCount, sponsorCount, entriesState }) =>
          `[STATUS] Pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], ` +
          `sloty [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], tryb [b]${entriesState}[/b].`,

        alreadyJoined: (author, number) => `[LOCK] [color=#d85e27]${sanitizeNick(author)}[/color] ma ju\u017c przypisany numer [color=red][b]${number}[/b][/color]. Duplikat odrzucony przez silnik.`,
        entriesPaused: (author) => `[PAUSE] [color=#d85e27]${sanitizeNick(author)}[/color], rekrutacja wstrzymana przez admina eventu.`,
        noFreeForUser: (author) => `[CAP] [color=#d85e27]${sanitizeNick(author)}[/color], wszystkie sloty zaj\u0119te.`,
        joinedRandom: (author, num, timeLeftMs) => `[LOOT] [color=#d85e27]${sanitizeNick(author)}[/color] wylosowa\u0142 numer [color=green][b]${num}[/b][/color] (+RNG XP). Czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        numberInfo: (author, number) => `[ID] [color=#d85e27]${sanitizeNick(author)}[/color], tw\u00f3j token wej\u015bcia: [color=red][b]${number}[/b][/color].`,
        luckyNow: (num) => `[SEED] Aktualny lucky seed: [b][color=green]${num}[/color][/b].`,
        joinedLuckye: (author, num, timeLeftMs) => `[CRIT] [color=#d85e27]${sanitizeNick(author)}[/color] u\u017cy\u0142 [b]!luckye[/b] i wskoczy\u0142 na [color=green][b]${num}[/b][/color]. Timer: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,
        freeSample: (count, sample) => `[MAP] Wolne sloty (${count}): [b][color=green]${sample.join(", ")}[/color][/b].`,

        hostAddedBon: (amount, total) => `[UPGRADE] Host dorzuci\u0142 [color=red][b]${cleanPotString(amount)}[/b][/color] BON. Nowa pula: [b][color=#ffc00a]${cleanPotString(total)} BON[/color][/b].`,
        hostRemovedBon: (amount, total) => `[ROLLBACK] Host zdj\u0105\u0142 [color=red][b]${cleanPotString(amount)}[/b][/color] BON. Pula po rollbacku: [b][color=#ffc00a]${cleanPotString(total)} BON[/color][/b].`,
        winnersSet: (count) => `[CONFIG] Liczba zwyci\u0119zc\u00f3w ustawiona na [b][color=#5DE2E7]${count}[/color][/b].`,

        entriesNone: () => `[AFK] Brak zg\u0142osze\u0144. Serwer czeka na graczy.`,
        sponsorsNone: () => `[ECONOMY] Sponsor\u00f3w brak. Rynek w trybie oszcz\u0119dnym.`,
        sponsorsList: (totalSponsored, list) => `[GUILD] Sponsorzy (\u0142\u0105cznie ${cleanPotString(totalSponsored)} BON): ${list}`,
        potNow: (amount) => `[BANK] Aktualna pula: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b].`,
        rangeNow: (start, end) => `[ZONE] Dozwolony zakres numer\u00f3w: [b][color=red]${start}-${end}[/color][/b].`,
        sponsorHint: (host) => `[ECONOMY] Każdy transfer BON do [b]${sanitizeNick(host)}[/b] buffuje pulę giveaway. Użyj: [color=red][b]/gift ${copyableNick(host)} KWOTA wiadomość[/b][/color].`,

        outOfRange: (author, number, start, end, freeSuggestion) =>
          `[BOUNDARY] [color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color] poza map\u0105 (${start}-${end}).` +
            freeSuggestion,
        numberTaken: (author, existingAuthor, number, freeSuggestion) =>
          `[COLLISION] [color=#d85e27]${sanitizeNick(author)}[/color], slot [color=red][b]${number}[/b][/color] jest ju\u017c zaj\u0119ty przez [color=#32cd53]${sanitizeNick(existingAuthor)}[/color].` +
            freeSuggestion,
        entryJoined: (author, number, timeLeftMs) =>
          `[JOIN] [color=#d85e27]${sanitizeNick(author)}[/color] do\u0142\u0105czy\u0142 z numerem [color=red][b]${number}[/b][/color]. Pozosta\u0142y czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,

        noEntriesEnd: () => `[GAME OVER] Brak graczy, brak dropu, koniec sesji.`,
        earlyFinish: (totalEntries, timeLeftMs) =>
          `[VICTORY] Wszystkie [b][color=#ffc00a]${totalEntries}[/color][/b] sloty zaj\u0119te przed czasem. Na liczniku by\u0142o jeszcze [b][color=green]${parseTime(timeLeftMs)}[/color][/b].`,

        sponsorDigest: ({ deltaTotalNum, sponsorCount, parts, othersCount, totalPot }) => {
            let message =
              `[LOOT] Sponsorzy wrzucili [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "os\u00f3b"}[/b]. `;

            if (parts.length) {
                message += parts.join(", ");
                if (othersCount > 0) {
                    message += `, [i]+${othersCount} wi\u0119cej[/i]`;
                }
                message += ". ";
            }

            message += `[BANK++] Pula po dropie: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`;
            return message;
        }
    });



    const CHAT_COPY_SUPER_SARKAZM = Object.freeze({
        ...CHAT_COPY_SARKAZM,
        intro: ({ amount, totalTimeMs, winnersText, startNum, endNum, sponsorMessage }) =>
          `S\u0142uchamy uwa\u017cnie: giveaway za [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b] w\u0142a\u015bnie wystartowa\u0142. ` +
          `Masz [b][color=green]${parseTime(totalTimeMs)}[/color][/b], wi\u0119c je\u015bli planowa\u0142e\u015b "zaraz wracam", to nie wracaj za p\u00f3\u017ano. ` +
          `Liczba szcz\u0119\u015bliwc\u00f3w: [b][color=#5DE2E7]${winnersText}[/color][/b]. ` +
          `Strzelaj liczb\u0105 [b]od [color=red]${startNum} do ${endNum}[/color] (w\u0142\u0105cznie)[/b]. ` +
            sponsorMessage,

        status: ({ amount, timeLeftMs, taken, total, freeCount, sponsorCount, entriesState }) =>
          `Raport dla wytrwa\u0142ych: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], ` +
          `zaj\u0119te [b]${taken}/${total}[/b], wolne [b]${freeCount}[/b], sponsorzy [b]${sponsorCount}[/b], zg\u0142oszenia [b]${entriesState}[/b].`,

        alreadyJoined: (author, number) => `Spokojnie [color=#d85e27]${sanitizeNick(author)}[/color], ju\u017c grasz numerem [color=red][b]${number}[/b][/color]. Zach\u0142anno\u015b\u0107 nie zwi\u0119ksza szans.`,
        noFreeForUser: (author) => `Niestety [color=#d85e27]${sanitizeNick(author)}[/color], wolne numery sko\u0144czy\u0142y si\u0119 szybciej ni\u017c wym\u00f3wki na czacie.`,
        hostOnlyCmd: (cmd) => `Komenda ${cmd} jest tylko dla hosta. To nie casting na wsp\u00f3\u0142prowadz\u0105cego.`,
        addBonBalanceError: (currentBon, newHostContribution) =>
          `Pi\u0119kna pr\u00f3ba, ale saldo m\u00f3wi "nie". Masz [b][color=#ffc00a]${formatBon(currentBon)} BON[/color][/b], ` +
          `a po ruchu host mia\u0142by [b][color=red]${formatBon(newHostContribution)} BON[/color][/b].`,
        noEntriesEnd: () => `Koniec giveaway i... zero zg\u0142osze\u0144. Publiczno\u015b\u0107 najwyra\u017aniej by\u0142a dzi\u015b zaj\u0119ta oddychaniem.`,
        sponsorsNone: () => "Sponsor\u00f3w brak. Portfele dzi\u015b postanowi\u0142y udawa\u0107 kamie\u0144.",
        sponsorDigest: ({ deltaTotalNum, sponsorCount, parts, othersCount, totalPot }) => {
            let message =
              `Pad\u0142 zrzut [color=red][b]${cleanPotString(deltaTotalNum)} BON[/b][/color] od [b]${sponsorCount} ${sponsorCount === 1 ? "osoby" : "os\u00f3b"}[/b]. `;

            if (parts.length) {
                message += parts.join(", ");
                if (othersCount > 0) {
                    message += `, [i]+${othersCount} wi\u0119cej[/i]`;
                }
                message += ". ";
            }

            message += `Pula po turbo-zastrzyku: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b].`;
            return message;
        }
    });





        function getV2DynamicMinStartNote(minEntryFee, dynamicMinEnabled) {
        if (!dynamicMinEnabled) return "";

        return " " + pickVariant([
            `Tryb agresywny ON: minimalne wpisowe zawsze równa się aktualnej puli BON.`,
            `Zasada rundy: minimum wejścia = bieżąca pula, bez stałych kroków.`,
            `Dynamiczny próg agresywny: każda wpłata podnosi pulę, a tym samym kolejne minimum.`,
            `W tym wariancie następny gracz musi wejść na poziomie aktualnej puli.`,
            `Im większa pula po poprzednim wejściu, tym wyższe minimum dla kolejnej osoby.`,
            `Mechanika agresywna: wpisowe nie rośnie liniowo, tylko podąża za aktualną pulą.`,
            `Kto wrzuci ponad minimum, temu sam podbije próg dla następnych, bo minimum = pula.`,
            `Tu nie ma taryfy ulgowej: aktualna pula wyznacza minimalne wejście.`,
            `Szybki wzór: minimum teraz = aktualna pula BON na liczniku.`,
            `Wersja hard: kolejny udział wymaga co najmniej tyle, ile wynosi bieżąca pula.`
        ]);
    }
        function getV2DynamicMinReminderNote(currentMinEntryFee, dynamicMinEnabled, dynamicMinStep) {
        if (!dynamicMinEnabled) return "";

        return " " + pickVariant([
            `Przypomnienie: aktualne minimum to [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b] i jest równe bieżącej puli.`,
            `Ten tryb działa prosto: minimum = aktualna pula, teraz [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b].`,
            `Bieżący próg wejścia to [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b], bo tyle wynosi aktualna pula.`,
            `Jeśli ktoś wpłaci więcej, od razu podbije pulę i kolejne minimum. Obecnie [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b]`,
            `Tutaj próg nie ma stałego kroku, podąża za pulą na żywo. Obecnie [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b]`,
            `Im większa pula po ostatnim wejściu, tym wyższe minimum dla następnych. [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b]`,
            `Wariant hard: kolejny udział wymaga co najmniej bieżącej puli, teraz ${cleanPotString(currentMinEntryFee)} BON.`,
            `Szybki komunikat: minimum wejścia = pula, obecnie ${cleanPotString(currentMinEntryFee)} BON.`,
            `Agresywny scaling w toku: następny próg wynika bezpośrednio z aktualnej puli. [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b]`,
            `Nie ma liniowego +X: minimum to po prostu aktualna pula BON. [b][color=#ffc00a]${cleanPotString(currentMinEntryFee)} BON[/color][/b]`
        ]);
    }
    const V2_SARKAZM_COPY = Object.freeze({
        intro: ({ amount, hostContributionText, totalTimeMs, winnersText, startNum, endNum, minEntryFee, host, dynamicMinEnabled }) =>
            pickVariant([
              `Tryb [b]Stawka większa niż pula BON[/b] odpalony. Wejście kosztuje minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Uruchamiam [b]Stawka większa niż pula BON[/b]: wpisowe od [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Startujemy z [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Nowa runda [b]Stawka większa niż pula BON[/b]: tanio nie będzie. Minimalne wpisowe [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b], pula [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Tryb Stawka większa niż pula BON online: wejście przez [color=red][b]/gift ${copyableNick(host)} KWOTA numer[/b][/color], minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Dobra, gramy na twardo: [b]Stawka większa niż pula BON[/b], wpisowe od [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b], bez darmowych wejść. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Wersja [b]Stawka większa niż pula BON[/b] rusza: przynosisz BON i numer albo wracasz z kwitkiem. Minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)}[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Event [b]Stawka większa niż pula BON[/b] start. Portfel obowiązkowy: min. [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Włączam [b]Stawka większa niż pula BON[/b]. Numer zgłaszasz w gifcie, a nie na słowo honoru. Wpisowe min. [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Tryb [b]Stawka większa niż pula BON[/b]: host nie bierze udziału, gracze wchodzą tylko przez gift. Minimum wejścia: [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`,
              `Dzisiaj tryb [b]Stawka większa niż pula BON[/b]. Płać i graj: [color=red][b]/gift ${copyableNick(host)} KWOTA numer[/b][/color], min. [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Pula startowa: [b][color=#ffc00a]${formatBon(amount)} BON[/color][/b].`
            ]) + (String(hostContributionText) === cleanPotString(amount) ? "" : ` Wkład hosta: [b][color=#ffc00a]${hostContributionText} BON[/color][/b].`) + ` Czas: [b][color=green]${parseTime(totalTimeMs)}[/color][/b], wygrywa: [b][color=#5DE2E7]${winnersText}[/color][/b], zakres [b][color=red]${startNum}-${endNum}[/color][/b]. Dołączasz przez [color=red][b]/gift ${copyableNick(host)} KWOTA numer[/b][/color]` + getV2DynamicMinStartNote(minEntryFee, dynamicMinEnabled),

        reminder: ({ amount, timeLeftMs, winnersNum, startNum, endNum, minEntryFee, host, dynamicMinEnabled, dynamicMinStep }) =>
            pickVariant([
              `Przypomnienie Stawka większa niż pula BON: wejście jest płatne, a numer podajesz w treści gifta. Minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b].`,
              `Tryb Stawka większa niż pula BON nadal aktywny. Jeśli chcesz grać, wysyłasz gift z numerem. Minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b].`,
              `Szybki ping: numery z czatu nie działają w trybie Stawka większa niż pula BON. Liczy się tylko numer z treści gifta.`,
              `Dla spóźnionych: w trybie Stawka większa niż pula BON wejście jest płatne. Minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b].`,
              `Update rundy trybu Stawka większa niż pula BON: chcesz numer, wysyłasz gift z numerem. Inaczej system wzrusza ramionami.`,
              `Runda trwa, zasady bez zmian: gift + numer, minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b].`,
              `Check-in trybu Stawka większa niż pula BON: wpisowe od [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b], host poza losowaniem.`,
              `Jeśli jeszcze nie dołączyłeś: wyślij gift z numerem i gotowe.`,
              `Tryb Stawka większa niż pula BON przypomina o sobie: numer jest brany z treści gifta.`,
              `Tryb płatny w toku: minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b], zgłoszenia przez gift.`
            ]) + ` Wejście: [color=red][b]/gift ${copyableNick(host)} KWOTA numer[/b][/color]. Pula: [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas: [b][color=green]${parseTime(timeLeftMs)}[/color][/b], zwycięzców: [b][color=#5DE2E7]${winnersNum}[/color][/b], zakres [b][color=red]${startNum}-${endNum}[/color][/b].` + getV2DynamicMinReminderNote(minEntryFee, dynamicMinEnabled, dynamicMinStep),

        status: ({ amount, timeLeftMs, taken, total, freeCount, sponsorCount, entriesState, minEntryFee }) =>
            `Status trybu Stawka większa niż pula BON: pula [b][color=#ffc00a]${cleanPotString(amount)} BON[/color][/b], czas [b][color=green]${parseTime(timeLeftMs)}[/color][/b], numery [b]${taken}/${total}[/b] (wolne: ${freeCount}), wpłacających [b]${sponsorCount}[/b], zgłoszenia [b]${entriesState}[/b], wpisowe min. [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b].`,

        commandsList: (host, minEntryFee) =>
            `Tryb Stawka większa niż pula BON: wejście tylko przez [color=red][b]/gift ${copyableNick(host)} KWOTA numer[/b][/color] (minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]). Komendy: !time !status !number !lucky !free !entries !sponsors !bon !range !commands | host: !addbon !removebon !winners !reminder !pause !resume !time add/remove.`,

        entryViaGiftOnly: (author, host, minEntryFee) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], w trybie Stawka większa niż pula BON numer podajesz tylko przez [color=red][b]/gift ${copyableNick(host)} KWOTA numer[/b][/color]. Minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b].`,

        entryAccepted: ({ author, number, paid, timeLeftMs, totalPot, newMinEntryFee }) =>
            pickVariant([
              `[color=#d85e27]${sanitizeNick(author)}[/color] wszedł do trybu Stawka większa niż pula BON z numerem [color=red][b]${number}[/b][/color] i wrzucił [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] opłacił wejście: [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color], numer [color=red][b]${number}[/b][/color].`,
              `Mamy nową wpłatę od [color=#d85e27]${sanitizeNick(author)}[/color]: [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color], numer [color=red][b]${number}[/b][/color].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] kupił los w trybie Stawka większa niż pula BON za [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color], numer [color=red][b]${number}[/b][/color].`,
              `Wpłata zaksięgowana: [color=#d85e27]${sanitizeNick(author)}[/color] gra numerem [color=red][b]${number}[/b][/color] po wpłacie [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] dorzucił wpisowe [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color] i bierze numer [color=red][b]${number}[/b][/color].`,
              `Rejestruję gracza [color=#d85e27]${sanitizeNick(author)}[/color]: numer [color=red][b]${number}[/b][/color], koszt wejścia [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] melduje gotowość: BON [color=#ffc00a][b]${cleanPotString(paid)}[/b][/color], numer [color=red][b]${number}[/b][/color].`,
              `Tryb Stawka większa niż pula BON przyjął zgłoszenie: [color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color], wpłata [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color].`,
              `[color=#d85e27]${sanitizeNick(author)}[/color] wszedł poprawnie: [color=#ffc00a][b]${cleanPotString(paid)} BON[/b][/color] + numer [color=red][b]${number}[/b][/color].`
            ]) + ` Pula teraz: [b][color=#ffc00a]${cleanPotString(totalPot)} BON[/color][/b]. Do końca: [b][color=green]${parseTime(timeLeftMs)}[/color][/b]. Nowe minimum wejścia: [b][color=#ffc00a]${cleanPotString(newMinEntryFee)} BON[/color][/b].`,

        refundLow: (author, paid, minEntryFee) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], wpłata [b][color=#ffc00a]${cleanPotString(paid)} BON[/color][/b] jest poniżej minimum [b][color=#ffc00a]${cleanPotString(minEntryFee)} BON[/color][/b]. Robię refund.`,

        refundNoNumber: (author) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], nie widzę numeru w treści gifta. Wysyłaj: [color=red][b]/gift HOST KWOTA numer[/b][/color]. Robię refund.`,

        refundOutOfRange: (author, number, startNum, endNum) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color] jest poza zakresem [b]${startNum}-${endNum}[/b]. Refund poszedł.`,

        refundTaken: (author, number, existingAuthor) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], numer [color=red][b]${number}[/b][/color] jest już zajęty przez [color=#32cd53]${sanitizeNick(existingAuthor)}[/color]. Refund.`,

        refundPaused: (author) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], zgłoszenia są chwilowo wstrzymane. Wpłata wraca do nadawcy.`,

        refundAlreadyJoined: (author, currentNumber) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], już grasz numerem [color=red][b]${currentNumber}[/b][/color]. Dubla nie przyjmuję, refund wykonany.`,

        refundHost: (author) =>
            `[color=#d85e27]${sanitizeNick(author)}[/color], host w trybie Stawka większa niż pula BON nie bierze udziału. Wpłata została zwrócona.`
    });
    const CHAT_PROFILE_STORAGE_KEY = "pttBonGiveaway.chatProfile";
    const CHAT_PROFILE_LABELS = Object.freeze({
        sarkazm: "Sarkazm",
        memiczny: "Programistyczny RPG",
        superSarkazm: "Super Extra Sarkazm",
        klasyczny: "Klasyczny"
    });

    function sanitizeChatProfile(profile) {
        const value = String(profile || "").trim().toLowerCase();
        if (value === "memiczny") return "memiczny";
        if (value === "supersarkazm") return "superSarkazm";
        if (value === "supersark") return "superSarkazm";
        if (value === "klasyczny") return "klasyczny";
        return "sarkazm";
    }

    const DIRECT_EMOJI_BY_COPY_KEY = Object.freeze({
        intro: "\uD83C\uDF89",
        reminder: "\uD83D\uDCE2",
        timeLeft: "\u23F3",
        status: "\uD83D\uDCCA",
        alreadyJoined: "\uD83D\uDD12",
        entriesPaused: "\u23F8\uFE0F",
        noFreeForUser: "\uD83D\uDEAB",
        joinedRandom: "\uD83C\uDFB2",
        numberInfo: "\uD83D\uDD22",
        numberNone: "\uD83D\uDD22",
        luckyNow: "\u2728",
        noLuckyCandidate: "\u26A0\uFE0F",
        joinedLuckye: "\u2728",
        noFreeLeft: "\uD83D\uDEAB",
        freeCountOnly: "\uD83C\uDFB2",
        freeSample: "\uD83C\uDFB2",
        hostOnlyCmd: "\u26A0\uFE0F",
        usage: "\uD83D\uDCDC",
        addBonBalanceError: "\u26A0\uFE0F",
        hostAddedBon: "\uD83D\uDCB0\u2795",
        cannotRemoveNoExtra: "\u26A0\uFE0F",
        cannotRemoveMax: "\u26A0\uFE0F",
        hostRemovedBon: "\uD83D\uDCB0\u2796",
        hostOnlyWinners: "\u26A0\uFE0F",
        winnersSet: "\uD83C\uDFC6",
        hostOnlyReminder: "\u26A0\uFE0F",
        hostOnlyPause: "\u26A0\uFE0F",
        pausedAlready: "\u23F8\uFE0F",
        pausedNow: "\u23F8\uFE0F",
        hostOnlyResume: "\u26A0\uFE0F",
        resumedAlready: "\u25B6\uFE0F",
        resumedNow: "\u25B6\uFE0F",
        commandsList: "\uD83D\uDCDC",
        sponsorHint: "\uD83D\uDCB0",
        entriesNone: "\uD83E\uDDFE",
        entriesList: "\uD83E\uDDFE",
        sponsorsNone: "\uD83E\uDD1D",
        sponsorsList: "\uD83E\uDD1D",
        potNow: "\uD83D\uDCB0",
        rangeNow: "\uD83D\uDD22",
        outOfRange: "\u26A0\uFE0F",
        numberTaken: "\u26A0\uFE0F",
        entryJoined: "\u2705",
        noEntriesEnd: "\uD83D\uDE14",
        hostAmongWinners: "\uD83D\uDC51",
        earlyFinish: "\u2705",
        spamDetected: "\u26A0\uFE0F",
        sponsorDigest: "\uD83E\uDD1D",
        finishSummary: "\uD83C\uDFC6",
        entryViaGiftOnly: "\uD83D\uDCDC",
        entryAccepted: "\u2705",
        refundLow: "\u26A0\uFE0F",
        refundNoNumber: "\u26A0\uFE0F",
        refundOutOfRange: "\u26A0\uFE0F",
        refundTaken: "\u26A0\uFE0F",
        refundPaused: "\u23F8\uFE0F",
        refundAlreadyJoined: "\u26A0\uFE0F",
        refundHost: "\u26A0\uFE0F"
    });

    function prefixCopyEmoji(copyKey, messageValue) {
        const text = String(messageValue || "");
        const trimmed = text.trim();
        if (!trimmed) return text;
        if (trimmed.startsWith("/")) return text;

        const emoji = DIRECT_EMOJI_BY_COPY_KEY[copyKey];
        if (!emoji) return text;
        if (trimmed.startsWith(`${emoji} `)) return text;

        return `${emoji} ${text}`;
    }

    function withDirectEmoji(copyObject) {
        const wrapped = {};

        Object.keys(copyObject || {}).forEach((key) => {
            const value = copyObject[key];
            if (typeof value !== "function") {
                wrapped[key] = value;
                return;
            }

            wrapped[key] = (...args) => prefixCopyEmoji(key, value(...args));
        });

        return Object.freeze(wrapped);
    }

    const CHAT_COPY_SARKAZM_EMOJI = withDirectEmoji(CHAT_COPY_SARKAZM);
    const CHAT_COPY_MEMICZNY_EMOJI = withDirectEmoji(CHAT_COPY_MEMICZNY);
    const CHAT_COPY_SUPER_SARKAZM_EMOJI = withDirectEmoji(CHAT_COPY_SUPER_SARKAZM);
    const CHAT_COPY_KLASYCZNY_EMOJI = withDirectEmoji(CHAT_COPY_KLASYCZNY);
    const V2_SARKAZM_COPY_EMOJI = withDirectEmoji(V2_SARKAZM_COPY);
    function resolveChatCopy(profile) {
        const normalized = sanitizeChatProfile(profile);
        if (normalized === "memiczny") return CHAT_COPY_MEMICZNY_EMOJI;
        if (normalized === "superSarkazm") return CHAT_COPY_SUPER_SARKAZM_EMOJI;
        if (normalized === "klasyczny") return CHAT_COPY_KLASYCZNY_EMOJI;
        return CHAT_COPY_SARKAZM_EMOJI;
    }

    function readChatProfile() {
        try {
            return sanitizeChatProfile(localStorage.getItem(CHAT_PROFILE_STORAGE_KEY));
        } catch (error) {
            return "sarkazm";
        }
    }

    function persistChatProfile(profile) {
        try {
            localStorage.setItem(CHAT_PROFILE_STORAGE_KEY, sanitizeChatProfile(profile));
        } catch (error) {
        }
    }

    const GAME_MODE_STORAGE_KEY = "pttBonGiveaway.gameMode";
    const V2_DYNAMIC_MIN_STORAGE_KEY = "pttBonGiveaway.v2.dynamicMin";

    function sanitizeGameMode(mode) {
        const value = String(mode || "").trim().toLowerCase();
        if (value === "v2" || value === "v2.0" || value === "v2_0") return "v2_0";
        return "standard";
    }

    function readGameMode() {
        try {
            return sanitizeGameMode(localStorage.getItem(GAME_MODE_STORAGE_KEY));
        } catch (error) {
            return "standard";
        }
    }

    function persistGameMode(mode) {
        try {
            localStorage.setItem(GAME_MODE_STORAGE_KEY, sanitizeGameMode(mode));
        } catch (error) {
        }
    }

    function readV2DynamicMinEnabled() {
        try {
            const raw = String(localStorage.getItem(V2_DYNAMIC_MIN_STORAGE_KEY) || "").trim().toLowerCase();
            return raw === "1" || raw === "true";
        } catch (error) {
            return false;
        }
    }

    function persistV2DynamicMinEnabled(enabled) {
        try {
            localStorage.setItem(V2_DYNAMIC_MIN_STORAGE_KEY, enabled ? "1" : "0");
        } catch (error) {
        }
    }

    function getEffectiveChatProfile(profile = activeChatProfile, gameMode = activeGameMode) {
        const sanitizedProfile = sanitizeChatProfile(profile);
        if (sanitizeGameMode(gameMode) === "v2_0") {
            return "sarkazm";
        }
        return sanitizedProfile;
    }

    let activeGameMode = readGameMode();
    let activeChatProfile = readChatProfile();
    let activeV2DynamicMinEnabled = readV2DynamicMinEnabled();
    let CHAT_COPY = resolveChatCopy(getEffectiveChatProfile(activeChatProfile, activeGameMode));

    const apiHealth = {
        entries: { ok: null, lastAt: 0, lastError: "" },
        sponsors: { ok: null, lastAt: 0, lastError: "" }
    };

    const scriptMeta = getScriptMeta();

    let processedGiftMessages = new Set();
    let processedChatMessages = new Set();

    let giveawayStartTime = null;
    let sponsorsInterval = null;
    let entriesInterval = null;
    let chatbox = null;
    let giveawayData = null;

    let numberEntries = new Map();
    let numberTakenBy = new Map();
    let fancyNames = new Map();
	
	let sponsorDigestBuffer = [];
    let sponsorDigestWindowStartAt = 0;
    let resolvedEntriesRoomId = String(GENERAL_SETTINGS.entriesRoomId || "1");
    let resolvedSponsorRoomId = String(GENERAL_SETTINGS.sponsorRoomId || "13");
    let roomIdResolvePromise = null;
    let v2GiftHistoryCache = {
        host: "",
        fetchedAt: 0,
        rows: []
    };
    let v2GiftHistoryFetchPromise = null;
    let v2ConsumedGiftHistoryKeys = new Set();
	
	let commandsButton;
	let commandsMenu;
	let roomsButton;
	let roomsMenu;
	let optionsButton;
    let optionsCloseButton;
	let optionsMenu;
    let v2DynamicMinToggle;
	
	let statsWrapper;
	let statsPot;
	let statsHostBase;
	let statsHostExtra;
	let statsSponsors;
	let statsEntries;
	let statsFree;
	
	let restoreNotice;
	let restoreNoticeTimer = null;

    const userCooldown = new Map();
    const userCommandLog = new Map();
    const userLastActionAt = new Map();
    const userLastCommandAt = new Map();
    const userSpamStrikes = new Map();
    const userFeedbackCooldown = new Map();

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

    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;

const frameHTML = `
<section id="giveawayFrame" class="panelV2" style="width: 470px; height: 90%; position: fixed; z-index: 9999; inset: 50px 150px auto auto; overflow: auto; border-style: solid; border-width: 1px; border-color: black" hidden>
  <header class="panel__heading" style="padding:10px 12px 12px;">
    <div class="button-holder no-space" style="display:flex; flex-direction:column; align-items:center; justify-content:center; gap:10px;">
      <div class="button-left" style="display:flex; justify-content:center; width:100%;">
        <h4 class="panel__heading" style="margin:0; text-align:center; display:flex; align-items:center; justify-content:center; gap:8px; font-size:1.34rem; font-weight:900; letter-spacing:0.45px; color:#ffe6a8; text-shadow:0 1px 0 rgba(0,0,0,0.6), 0 0 12px rgba(255,192,10,0.2);">
          <i class="fas fa-coins" style="padding:0; color:#ffc00a; filter:drop-shadow(0 0 4px rgba(255,192,10,0.35));"></i>
          <span style="display:inline-block;">PTT BON Giveaway</span>
        </h4>
      </div>
      <div class="button-right" style="display:flex; justify-content:center; align-items:center; flex-wrap:wrap; gap:6px; width:100%;">
        <button id="resetButton" type="button" class="form__button form__button--text">
          Reset
        </button>
        <button id="commandsButton" type="button" class="form__button form__button--text">
          Komendy
        </button>
        <button id="roomsButton" type="button" class="form__button form__button--text">
          Status
        </button>
        <button id="optionsButton" type="button" class="form__button form__button--text">
          Opcje
        </button>
        <button id="closeButton" type="button" class="form__button form__button--text">
          X
        </button>
      </div>
    </div>
  </header>

  <div id="giveawayCommandsMenu" style="
    display: none;
    position: absolute;
    top: 55px;
    right: 10px;
    z-index: 10000;
    background: linear-gradient(180deg, #172029 0%, #111920 100%);
    color: #d8e3ea;
    border: 1px solid #35566a;
    border-radius: 10px;
    padding: 12px;
    min-width: 350px;
    box-shadow: 0 12px 26px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,255,255,0.04);
    font-size: 13px;
    line-height: 1.35;
  ">
    <div style="font-size:13px; font-weight:700; color:#9bb2bf; margin-bottom:8px; letter-spacing:0.2px; text-align:center;">Command Center</div>
    <div style="border:1px solid #2f5a71; border-radius:8px; padding:8px 10px; background:rgba(22,44,56,0.55);">
      <div style="font-size:12px; font-weight:700; color:#8fd8ff; margin-bottom:6px;">Komendy użytkowników</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!time</b> – pokazuje pozostały czas</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!status</b> – skrócony status giveaway</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!random</b> – losuje wolny numer (Standard)</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!number</b> – pokazuje twój numer</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!lucky</b> – pokazuje szczęśliwy numer</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!luckye</b> – zapisuje na szczęśliwy numer (Standard)</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!free</b> – pokazuje kilka wolnych numerów</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!entries</b> – pokazuje zgłoszenia</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!sponsors</b> – pokazuje sponsorów</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!bon</b> – pokazuje pulę BON</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!range</b> – pokazuje zakres numerów</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">!commands</b> – lista komend</div>
      <div style="color:#c9d8e1; font-size:13px;"><b style="color:#ffe08f;">/gift HOST KWOTA numer</b> – wejście w trybie Stawka większa niż pula BON</div>
    </div>

    <div style="margin-top:10px; border:1px solid #5d5a2f; border-radius:8px; padding:8px 10px; background:rgba(56,46,22,0.45);">
      <div style="font-size:12px; font-weight:700; color:#ffd27d; margin-bottom:6px;">Komendy hosta</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!addbon 100</b> – dodaje BON do puli</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!removebon 100</b> – usuwa BON z dodatkowego wkładu hosta</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!winners 3</b> – zmienia liczbę zwycięzców</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!reminder</b> – wysyła przypomnienie</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!pause</b> – wstrzymuje nowe zgłoszenia</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!resume</b> – wznawia nowe zgłoszenia</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!time add 5</b> – dodaje czas</div>
      <div style="color:#e3d8c3; font-size:13px;"><b style="color:#ffe08f;">!time remove 5</b> – odejmuje czas</div>
    </div>
  </div>

  <div id="giveawayRoomsMenu" style="
    display: none;
    position: absolute;
    top: 55px;
    right: 10px;
    z-index: 10000;
    background: #1f262b;
    color: #cbd6dc;
    border: 1px solid #3f4f58;
    border-radius: 6px;
    padding: 12px 14px;
    min-width: 260px;
    box-shadow: 0 4px 10px rgba(0,0,0,0.35);
  ">
    <div style="font-weight:700; color:#9bb2bf; margin-bottom:8px;">Status skryptu</div>
    <div style="font-size:13px;">Zgłoszenia: <b id="roomEntriesValue" style="color:#5DE2E7;">-</b></div>
    <div style="font-size:13px; margin-top:4px;">Sponsorzy/system: <b id="roomSponsorsValue" style="color:#ffc00a;">-</b></div>
    <div style="font-size:12px; color:#9bb2bf; margin:10px 0 6px;">Metadane skryptu</div>
    <div style="font-size:13px;">Name: <b id="scriptNameValue" style="color:#cbd6dc;">-</b></div>
    <div style="font-size:13px; margin-top:4px;">Description: <b id="scriptDescriptionValue" style="color:#cbd6dc;">-</b></div>
    <div style="font-size:13px; margin-top:4px;">Version: <b id="scriptVersionValue" style="color:#5edb7f;">-</b></div>
    <div style="font-size:13px; margin-top:4px;">Author: <b id="scriptAuthorValue" style="color:#9fd2ff;">-</b></div>
    <div style="font-size:12px; color:#9bb2bf; margin:10px 0 6px;">Status API</div>
    <div style="font-size:13px;">Entries API: <b id="apiEntriesStatusValue" style="color:#9bb2bf;">brak danych</b></div>
    <div style="font-size:13px; margin-top:4px;">Sponsors API: <b id="apiSponsorsStatusValue" style="color:#9bb2bf;">brak danych</b></div>
  </div>

  <div id="giveawayOptionsMenu" style="
    display: none;
    position: absolute;
    top: 55px;
    right: 10px;
    z-index: 10000;
    background: #1c2328;
    color: #d5dfe5;
    border: 1px solid #3a4e5a;
    border-radius: 8px;
    padding: 10px 12px;
    width: 360px;
    min-width: 360px;
    max-width: 360px;
    box-sizing: border-box;
    height: 332px;
    min-height: 332px;
    max-height: 332px;
    overflow-y: auto;
    box-shadow: 0 8px 18px rgba(0,0,0,0.4);
  ">
    <button id="optionsCloseButton" type="button" style="float:right; width:20px; height:20px; border:none; border-radius:999px; background:#2a353d; color:#cfe0ea; font-size:12px; line-height:20px; text-align:center; cursor:pointer; padding:0; margin:-2px 0 6px 8px;">X</button>
    <div style="font-weight:700; color:#9fd2ff; margin-bottom:8px;">Opcje</div>

    <div style="font-size:12px; color:#9bb2bf; margin-bottom:6px;">Profile językowe</div>
    <div id="chatProfileV2Hint" style="display:none; font-size:12px; color:#ffd27d; margin:4px 0 8px;">
      W trybie Stawka większa niż pula BON aktywny jest profil: <b>Sarkazm</b>.
    </div>
    <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer;">
      <input type="radio" name="chatProfile" value="sarkazm"> Sarkazm (obecny)
    </label>
    <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer;">
      <input type="radio" name="chatProfile" value="superSarkazm"> Super Extra Sarkazm
    </label>
    <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer;">
      <input type="radio" name="chatProfile" value="memiczny"> Programistyczny RPG vibe
    </label>
    <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer;">
      <input type="radio" name="chatProfile" value="klasyczny"> Klasyczny (orygina&#322;)
    </label>

    <div style="height:1px; background:#34434d; margin:10px 0;"></div>
    <div style="font-size:12px; color:#9bb2bf; margin-bottom:6px;">Wersja zabawy</div>
    <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer;">
      <input type="radio" name="gameMode" value="standard"> Standard giveaway
    </label>
    <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer;">
      <input type="radio" name="gameMode" value="v2_0"> Stawka większa niż pula BON (wpisowe przez gift)
    </label>
    <div id="v2DynamicMinGroup" style="margin-top:8px;" hidden>
      <label style="display:flex; align-items:center; gap:8px; font-size:13px; margin:4px 0; cursor:pointer; color:#ffdba6;">
        <input id="v2DynamicMinToggle" type="checkbox"> Dynamiczne minimum wpisowego (x1, x2, x3...)
      </label>
    </div>
  </div>

  <div class="panel__body">
    <h1 id="coinHeader" class="panel__heading--centered" style="font-size:1.28rem; margin:8px 0 6px;"></h1>

    <div id="giveawayStats" hidden style="max-width: 430px; margin: 10px auto 14px;">
      <div style="display:grid; grid-template-columns:1fr 1fr; gap:8px;">
        <div style="border:1px solid #444; border-radius:6px; padding:8px;">
          <div style="font-size:12px; color:#aaa;">Pula</div>
          <div id="statPot" style="font-weight:700;"></div>
        </div>
        <div style="border:1px solid #444; border-radius:6px; padding:8px;">
          <div style="font-size:12px; color:#aaa;">Host start</div>
          <div id="statHostBase" style="font-weight:700;"></div>
        </div>
        <div style="border:1px solid #444; border-radius:6px; padding:8px;">
          <div style="font-size:12px; color:#aaa;">Host extra</div>
          <div id="statHostExtra" style="font-weight:700;"></div>
        </div>
        <div style="border:1px solid #444; border-radius:6px; padding:8px;">
          <div style="font-size:12px; color:#aaa;">Sponsorzy</div>
          <div id="statSponsors" style="font-weight:700;"></div>
        </div>
        <div style="border:1px solid #444; border-radius:6px; padding:8px;">
          <div style="font-size:12px; color:#aaa;">Zajęte</div>
          <div id="statEntries" style="font-weight:700;"></div>
        </div>
        <div style="border:1px solid #444; border-radius:6px; padding:8px;">
          <div style="font-size:12px; color:#aaa;">Wolne</div>
          <div id="statFree" style="font-weight:700;"></div>
        </div>
      </div>
    </div>

    <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]*" value="" inputmode="numeric" type="text" style="background:#23282e; color:#e7f2f8; border:1px solid #41586a; box-shadow:inset 0 1px 0 rgba(255,255,255,0.05);">
        <label class="form__label form__label--floating" for="giveawayAmount">
          Kwota Giveaway
        </label>
      </p>
      <p id="entryFeeGroup" class="form__group" style="max-width: 35%;" hidden>
        <input class="form__text" id="entryFeeMin" pattern="[0-9]*" value="50" inputmode="numeric" type="text" style="background:#23282e; color:#e7f2f8; border:1px solid #41586a; box-shadow:inset 0 1px 0 rgba(255,255,255,0.05);">
        <label class="form__label form__label--floating" for="entryFeeMin">
          Wpisowe min. (Stawka większa niż pula BON)
        </label>
      </p>
      <div class="panel__body" style="display: flex; justify-content: center; gap: 20px">
        <p class="form__group" style="width: 20%;">
          <input class="form__text" required id="startNum" pattern="-?[0-9]*" value="1" inputmode="numeric" type="text" maxlength="7">
          <label class="form__label form__label--floating" for="startNum">
            Start #
          </label>
        </p>
        <p class="form__group" style="width: 20%;">
          <input class="form__text" required id="endNum" pattern="-?[0-9]*" value="50" inputmode="numeric" type="text" maxlength="7">
          <label class="form__label form__label--floating" for="endNum">
            Koniec #
          </label>
        </p>
      </div>
      <div class="panel__body" style="display: flex; justify-content: center; gap: 20px">
        <p class="form__group" style="width: 28%;">
          <input class="form__text" required id="timerNum" pattern="[0-9]*" value="15" inputmode="numeric" type="text">
          <label class="form__label form__label--floating" for="timerNum">
            Czas (minuty)
          </label>
        </p>
        <p class="form__group" style="width: 28%;">
          <input class="form__text" required id="reminderNum" pattern="[0-9]*" value="2" inputmode="numeric" type="text">
          <label class="form__label form__label--floating" for="reminderNum">
            Przypomnienia
          </label>
        </p>
        <p class="form__group" style="width: 28%;">
          <input class="form__text" required id="winnersNum" pattern="[0-9]*" value="1" inputmode="numeric" type="text">
          <label class="form__label form__label--floating" for="winnersNum">
            Zwycięzcy
          </label>
        </p>
      </div>
      <p class="form__group" style="text-align: center;">
        <button id="startButton" type="button" class="form__button form__button--filled" style="height:44px; font-size:16px; font-weight:800;">
          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>Uzytkownik</th>
            <th>Numer</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </div>
  </div>
</section>
`;

    let giveawayFrame;
    let resetButton;
    let closeButton;
    let coinHeader;
    let coinInput;
    let entryFeeGroup;
    let entryFeeInput;
    let startInput;
    let endInput;
    let timerInput;
    let reminderInput;
    let winnersInput;
    let startButton;
    let countdownHeader;
    let entriesWrapper;
    let giveawayForm;
    let roomEntriesValue;
    let roomSponsorsValue;
    let apiEntriesStatusValue;
    let apiSponsorsStatusValue;
    let scriptNameValue;
    let scriptDescriptionValue;
    let scriptVersionValue;
    let scriptAuthorValue;

    let isFrameDragging = false;
    let frameDragStartX = 0;
    let frameDragStartY = 0;
    let frameDragStartLeft = 0;
    let frameDragStartTop = 0;

    injectMenu();

	function getSponsorMessage(host) {
    const safeHost = String(host || getLoggedInUsername() || "HOST").trim();
    const effectiveProfile = getEffectiveChatProfile(activeChatProfile, activeGameMode);
    const copy = resolveChatCopy(effectiveProfile);

    if (copy && typeof copy.sponsorHint === "function") {
        return copy.sponsorHint(safeHost);
    }

    return `Każdy BON wysłany hostowi podczas giveaway automatycznie zwiększa pulę nagród. ` +
        `Jak dodać BON: [color=red][b]/gift ${copyableNick(safeHost)} KWOTA wiadomość[/b][/color].`;
}


    function enableFrameDragging() {
    if (!giveawayFrame) return;

    const dragHandle = giveawayFrame.querySelector("header");
    if (!dragHandle) return;

    dragHandle.style.cursor = "move";

    const stopFrameDragging = () => {
        if (!isFrameDragging) return;
        isFrameDragging = false;
        document.body.style.userSelect = "";
    };

    const onFrameDragMove = (event) => {
        if (!isFrameDragging) return;

        const panelRect = giveawayFrame.getBoundingClientRect();
        const maxLeft = Math.max(0, window.innerWidth - panelRect.width);
        const maxTop = Math.max(0, window.innerHeight - 40);

        const nextLeft = Math.min(maxLeft, Math.max(0, frameDragStartLeft + (event.clientX - frameDragStartX)));
        const nextTop = Math.min(maxTop, Math.max(0, frameDragStartTop + (event.clientY - frameDragStartY)));

        giveawayFrame.style.inset = "auto";
        giveawayFrame.style.right = "auto";
        giveawayFrame.style.bottom = "auto";
        giveawayFrame.style.left = `${nextLeft}px`;
        giveawayFrame.style.top = `${nextTop}px`;
    };

    dragHandle.addEventListener("mousedown", (event) => {
        if (event.button !== 0) return;

        const target = event.target;
        if (target && target.closest("button, a, input, select, textarea, [role=button]")) {
            return;
        }

        const rect = giveawayFrame.getBoundingClientRect();
        frameDragStartX = event.clientX;
        frameDragStartY = event.clientY;
        frameDragStartLeft = rect.left;
        frameDragStartTop = rect.top;
        isFrameDragging = true;
        document.body.style.userSelect = "none";
        event.preventDefault();
    });

    document.addEventListener("mousemove", onFrameDragMove);
    document.addEventListener("mouseup", stopFrameDragging);
    document.addEventListener("mouseleave", stopFrameDragging);
}
    function injectMenu() {
    if (document.getElementById("giveawayFrame")) {
        return;
    }

    const chatboxHeader = document.querySelector("#chatbox_header div");
    if (!chatboxHeader) {
        setTimeout(injectMenu, 200);
        return;
    }

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

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

    giveawayFrame = document.getElementById("giveawayFrame");
    resetButton = document.getElementById("resetButton");
    closeButton = document.getElementById("closeButton");
    commandsButton = document.getElementById("commandsButton");
    commandsMenu = document.getElementById("giveawayCommandsMenu");
    roomsButton = document.getElementById("roomsButton");
    roomsMenu = document.getElementById("giveawayRoomsMenu");
    optionsButton = document.getElementById("optionsButton");
    optionsMenu = document.getElementById("giveawayOptionsMenu");
    optionsCloseButton = document.getElementById("optionsCloseButton");
    v2DynamicMinToggle = document.getElementById("v2DynamicMinToggle");

    coinHeader = document.getElementById("coinHeader");
    coinInput = document.getElementById("giveawayAmount");
    entryFeeGroup = document.getElementById("entryFeeGroup");
    entryFeeInput = document.getElementById("entryFeeMin");
    startInput = document.getElementById("startNum");
    endInput = document.getElementById("endNum");
    timerInput = document.getElementById("timerNum");
    reminderInput = document.getElementById("reminderNum");
    winnersInput = document.getElementById("winnersNum");
    startButton = document.getElementById("startButton");
    countdownHeader = document.getElementById("countdownHeader");
    entriesWrapper = document.getElementById("entriesWrapper");
    giveawayForm = document.getElementById("giveawayForm");
    roomEntriesValue = document.getElementById("roomEntriesValue");
    roomSponsorsValue = document.getElementById("roomSponsorsValue");
    apiEntriesStatusValue = document.getElementById("apiEntriesStatusValue");
    apiSponsorsStatusValue = document.getElementById("apiSponsorsStatusValue");
    scriptNameValue = document.getElementById("scriptNameValue");
    scriptDescriptionValue = document.getElementById("scriptDescriptionValue");
    scriptVersionValue = document.getElementById("scriptVersionValue");
    scriptAuthorValue = document.getElementById("scriptAuthorValue");

    statsWrapper = document.getElementById("giveawayStats");
    statsPot = document.getElementById("statPot");
    statsHostBase = document.getElementById("statHostBase");
    statsHostExtra = document.getElementById("statHostExtra");
    statsSponsors = document.getElementById("statSponsors");
    statsEntries = document.getElementById("statEntries");
    statsFree = document.getElementById("statFree");
	restoreNotice = document.createElement("div");
	restoreNotice.id = "giveawayRestoreNotice";
	restoreNotice.hidden = true;
	restoreNotice.style.cssText = [
    "display:none",
    "max-width:430px",
    "margin:10px auto 14px",
    "padding:10px 12px",
    "border:1px solid #3c763d",
    "border-radius:6px",
    "background:#1f3a24",
    "color:#d7ffd9",
    "font-weight:600",
    "text-align:center"
].join(";");

const panelBody = giveawayFrame.querySelector(".panel__body");
if (panelBody) {
    panelBody.insertBefore(restoreNotice, panelBody.firstChild);
}

    enableFrameDragging();
    applyHeaderButtonStyles();
    styleStartButton(false);

    resetButton.onclick = function () {
        resetGiveaway(false);
    };
    closeButton.onclick = toggleMenu;
    startButton.onclick = toggleStartStop;

    commandsButton.onclick = function (e) {
        e.stopPropagation();
        toggleCommandsMenu();
    };


    roomsButton.onclick = function (e) {
        e.stopPropagation();
        toggleRoomsMenu();
    };

    optionsButton.onclick = function (e) {
        e.stopPropagation();
        toggleOptionsMenu();
    };

    if (optionsCloseButton) {
        optionsCloseButton.onclick = function (e) {
            e.stopPropagation();
            closeOptionsMenu();
        };
    }

    commandsMenu.onclick = function () {
        closeCommandsMenu();
    };

    roomsMenu.onclick = function () {
        closeRoomsMenu();
    };

    if (optionsMenu) {
        optionsMenu.onclick = function (e) {
            e.stopPropagation();
        };

        optionsMenu.addEventListener("change", function (e) {
            const target = e.target;
            if (!target) return;

            if (target.name === "chatProfile") {
                applyChatProfile(target.value, true);
                return;
            }

            if (target.name === "gameMode") {
                applyGameMode(target.value, true);
                return;
            }

            if (target.id === "v2DynamicMinToggle") {
                activeV2DynamicMinEnabled = Boolean(target.checked);
                persistV2DynamicMinEnabled(activeV2DynamicMinEnabled);
                updateOptionsMenuSelection();
            }
        });
    }

    timerInput.addEventListener("input", reminderAutoScaling);
    if (entryFeeInput) {
        entryFeeInput.addEventListener("input", () => {
            if (parsePositiveInt(entryFeeInput.value) > 0) {
                entryFeeInput.setCustomValidity("");
            }
        });
    }
    reminderInput.addEventListener("input", remindersValidation);
    bindBonInputTheme(coinInput);
    bindBonInputTheme(entryFeeInput);
    startInput.addEventListener("input", entryRangeValidation);
    endInput.addEventListener("input", entryRangeValidation);
    winnersInput.addEventListener("input", winnersValidation);

    updateCoinHeader(readHostBalance());
    updateStatsPanel(null);
    resetApiStatus();
    applyChatProfile(activeChatProfile, false);
    applyGameMode(activeGameMode, false, true);

    document.addEventListener("click", function (e) {
        if (!commandsMenu || !commandsButton || !roomsMenu || !roomsButton || !optionsMenu || !optionsButton) return;

        const clickedInsideCommands = commandsMenu.contains(e.target);
        const clickedCommandsButton = commandsButton.contains(e.target);
        const clickedInsideRooms = roomsMenu.contains(e.target);
        const clickedRoomsButton = roomsButton.contains(e.target);
        const clickedInsideOptions = optionsMenu.contains(e.target);
        const clickedOptionsButton = optionsButton.contains(e.target);

        if (!clickedInsideCommands && !clickedCommandsButton) {
            closeCommandsMenu();
        }

        if (!clickedInsideRooms && !clickedRoomsButton) {
            closeRoomsMenu();
        }

        if (!clickedInsideOptions && !clickedOptionsButton) {
            closeOptionsMenu();
        }
    });

    restoreGiveawayFromStorage();
	}

function toggleMenu() {
    giveawayFrame.hidden = !giveawayFrame.hidden;
    if (giveawayFrame.hidden) {
        closeCommandsMenu();
        closeRoomsMenu();
        closeOptionsMenu();
    } else {
        updateStatsPanel(giveawayData);
        updateRoomInfoPanel();
    }
}
	
function toggleCommandsMenu() {
    if (!commandsMenu) return;
    const isOpen = commandsMenu.style.display === "block";
    if (!isOpen) {
        closeRoomsMenu();
        closeOptionsMenu();
    }
    commandsMenu.style.display = isOpen ? "none" : "block";
}

	function closeCommandsMenu() {
    if (!commandsMenu) return;
    commandsMenu.style.display = "none";
	}

function toggleRoomsMenu() {
    if (!roomsMenu) return;
    const isOpen = roomsMenu.style.display === "block";
    if (!isOpen) {
        closeCommandsMenu();
        closeOptionsMenu();
    }
    roomsMenu.style.display = isOpen ? "none" : "block";
}

function closeRoomsMenu() {
    if (!roomsMenu) return;
    roomsMenu.style.display = "none";
}

function toggleOptionsMenu() {
    if (!optionsMenu) return;
    const isOpen = optionsMenu.style.display === "block";
    if (!isOpen) {
        closeCommandsMenu();
        closeRoomsMenu();
        updateOptionsMenuSelection();
    }
    optionsMenu.style.display = isOpen ? "none" : "block";
}

function closeOptionsMenu() {
    if (!optionsMenu) return;
    optionsMenu.style.display = "none";
}

function updateOptionsMenuSelection() {
    if (!optionsMenu) return;

    const effectiveProfile = getEffectiveChatProfile(activeChatProfile, activeGameMode);
    const isV2 = sanitizeGameMode(activeGameMode) === "v2_0";
    const isRunning = Boolean(giveawayData && !giveawayData.ended);

    const profileOptions = optionsMenu.querySelectorAll('input[name="chatProfile"]');
    profileOptions.forEach(input => {
        input.checked = input.value === effectiveProfile;
        const disabledByMode = isV2 && input.value !== "sarkazm";
        input.disabled = disabledByMode || isRunning;

        const label = input.closest("label");
        if (label) {
            label.style.opacity = input.disabled ? "0.6" : "1";
            label.style.cursor = input.disabled ? "default" : "pointer";
        }
    });

    const gameModeOptions = optionsMenu.querySelectorAll('input[name="gameMode"]');
    gameModeOptions.forEach(input => {
        input.checked = input.value === sanitizeGameMode(activeGameMode);
        input.disabled = isRunning;

        const label = input.closest("label");
        if (label) {
            label.style.opacity = input.disabled ? "0.6" : "1";
            label.style.cursor = input.disabled ? "default" : "pointer";
        }
    });

    const v2Hint = optionsMenu.querySelector("#chatProfileV2Hint");
    if (v2Hint) {
        v2Hint.style.display = "block";
        v2Hint.style.opacity = isV2 ? "1" : "0.45";
    }

    const dynamicGroup = optionsMenu.querySelector("#v2DynamicMinGroup");
    if (dynamicGroup) {
        dynamicGroup.hidden = false;
        dynamicGroup.style.opacity = isV2 ? "1" : "0.45";
        dynamicGroup.style.pointerEvents = isV2 ? "auto" : "none";
    }

    if (v2DynamicMinToggle) {
        v2DynamicMinToggle.checked = Boolean(activeV2DynamicMinEnabled);
        v2DynamicMinToggle.disabled = !isV2 || isRunning;

        const toggleLabel = v2DynamicMinToggle.closest("label");
        if (toggleLabel) {
            toggleLabel.style.opacity = v2DynamicMinToggle.disabled ? "0.6" : "1";
            toggleLabel.style.cursor = v2DynamicMinToggle.disabled ? "default" : "pointer";
        }
    }
}

function updateModeUiState() {
    if (!entryFeeGroup || !entryFeeInput) return;

    const isV2 = sanitizeGameMode(activeGameMode) === "v2_0";
    const isRunning = Boolean(giveawayData && !giveawayData.ended);

    entryFeeGroup.hidden = !isV2;
    entryFeeInput.disabled = !isV2 || isRunning;

    if (!isV2) {
        entryFeeInput.setCustomValidity("");
    }

    refreshBonInputThemes();
    updateOptionsMenuSelection();
}

function applyBonInputTheme(input) {
    if (!input) return;

    const fixedInputBg = "#23282e";
    const fixedInputColor = "#e7f2f8";
    const fixedInputBorder = "#41586a";

    input.style.background = fixedInputBg;
    input.style.backgroundColor = fixedInputBg;
    input.style.color = fixedInputColor;
    input.style.border = `1px solid ${fixedInputBorder}`;
    input.style.opacity = "1";

    // Force dark fill also when browser autocompletion/history styling kicks in.
    input.style.setProperty("background-color", fixedInputBg, "important");
    input.style.setProperty("-webkit-box-shadow", `0 0 0 1000px ${fixedInputBg} inset`, "important");
    input.style.setProperty("box-shadow", `0 0 0 1000px ${fixedInputBg} inset`, "important");
    input.style.setProperty("-webkit-text-fill-color", fixedInputColor, "important");
}

function refreshBonInputThemes() {
    [coinInput, entryFeeInput].forEach((input) => {
        applyBonInputTheme(input);
    });
}

function bindBonInputTheme(input) {
    if (!input) return;

    ["input", "change", "blur", "focus"].forEach((evt) => {
        input.addEventListener(evt, () => {
            applyBonInputTheme(input);
            // Some browsers apply autofill styles asynchronously.
            setTimeout(() => applyBonInputTheme(input), 0);
        });
    });

    applyBonInputTheme(input);
}

function isV2Mode(activeGiveaway = giveawayData) {
    const mode = activeGiveaway ? activeGiveaway.gameMode : activeGameMode;
    return sanitizeGameMode(mode) === "v2_0";
}

function getCurrentV2MinEntryFee(activeGiveaway = giveawayData) {
    if (!activeGiveaway || !isV2Mode(activeGiveaway)) {
        return Math.max(1, Math.floor((activeGiveaway && activeGiveaway.minEntryFee) || 0));
    }

    const staticMin = Math.max(1, Math.floor(activeGiveaway.minEntryFee || 0));
    if (!activeGiveaway.dynamicMinEnabled) {
        return staticMin;
    }

    const baseMin = Math.max(1, Math.floor(activeGiveaway.dynamicMinBase || staticMin));
    const potMin = Math.max(1, Math.floor(activeGiveaway.amount || 0));
    return Math.max(baseMin, potMin);
}

function getV2Copy() {
    return V2_SARKAZM_COPY_EMOJI;
}

function applyChatProfile(profile, persist = true) {
    activeChatProfile = sanitizeChatProfile(profile);
    CHAT_COPY = resolveChatCopy(getEffectiveChatProfile(activeChatProfile, activeGameMode));

    if (persist) {
        persistChatProfile(activeChatProfile);
    }

    updateOptionsMenuSelection();
}

function applyGameMode(mode, persist = true, force = false) {
    const nextMode = sanitizeGameMode(mode);

    if (!force && giveawayData && !giveawayData.ended) {
        updateOptionsMenuSelection();
        return;
    }

    activeGameMode = nextMode;
    CHAT_COPY = resolveChatCopy(getEffectiveChatProfile(activeChatProfile, activeGameMode));

    if (persist) {
        persistGameMode(activeGameMode);
    }

    updateModeUiState();
}

function styleHeaderButton(button, palette = {}) {
    if (!button) return;

    const normalBg = palette.normalBg || "linear-gradient(180deg, #3a3a3a 0%, #222 100%)";
    const hoverBg = palette.hoverBg || "linear-gradient(180deg, #474747 0%, #2a2a2a 100%)";
    const normalBorder = palette.normalBorder || "#555";
    const hoverBorder = palette.hoverBorder || "#7a7a7a";

    button.style.minWidth = palette.minWidth || "78px";
    button.style.height = palette.height || "32px";
    button.style.padding = palette.padding || "0 10px";
    button.style.display = "inline-flex";
    button.style.alignItems = "center";
    button.style.justifyContent = "center";
    button.style.textAlign = "center";
    button.style.lineHeight = "1";
    button.style.whiteSpace = "nowrap";
    button.style.border = `1px solid ${normalBorder}`;
    button.style.borderRadius = "6px";
    button.style.background = normalBg;
    button.style.color = "#f1f1f1";
    button.style.fontWeight = palette.fontWeight || "700";
    button.style.fontSize = palette.fontSize || "13px";
    button.style.letterSpacing = "0.1px";
    button.style.cursor = "pointer";
    button.style.boxShadow = "inset 0 1px 0 rgba(255,255,255,0.08)";
    button.style.transition = "background 120ms ease, border-color 120ms ease, transform 80ms ease, box-shadow 120ms ease";

    button.addEventListener("mouseenter", () => {
        button.style.background = hoverBg;
        button.style.borderColor = hoverBorder;
        button.style.boxShadow = "0 2px 8px rgba(0,0,0,0.35)";
    });

    button.addEventListener("mouseleave", () => {
        button.style.background = normalBg;
        button.style.borderColor = normalBorder;
        button.style.boxShadow = "inset 0 1px 0 rgba(255,255,255,0.08)";
        button.style.transform = "";
    });

    button.addEventListener("mousedown", () => {
        button.style.transform = "translateY(1px)";
    });

    button.addEventListener("mouseup", () => {
        button.style.transform = "";
    });
}

function applyHeaderButtonStyles() {
    const holder = giveawayFrame ? giveawayFrame.querySelector(".button-right") : null;
    if (holder) {
        holder.style.display = "flex";
        holder.style.alignItems = "center";
        holder.style.justifyContent = "center";
        holder.style.flexWrap = "wrap";
        holder.style.gap = "6px";
    }

    styleHeaderButton(resetButton, {
        normalBg: "linear-gradient(180deg, #7a2f2f 0%, #5d2020 100%)",
        hoverBg: "linear-gradient(180deg, #944040 0%, #702929 100%)",
        normalBorder: "#8f3e3e",
        hoverBorder: "#bd6262"
    });

    styleHeaderButton(commandsButton, {
        normalBg: "linear-gradient(180deg, #2b6174 0%, #1f4a58 100%)",
        hoverBg: "linear-gradient(180deg, #34758b 0%, #265969 100%)",
        normalBorder: "#2f6e83",
        hoverBorder: "#57a6c2"
    });

    styleHeaderButton(roomsButton, {
        normalBg: "linear-gradient(180deg, #846210 0%, #5f4508 100%)",
        hoverBg: "linear-gradient(180deg, #9b7415 0%, #73560a 100%)",
        normalBorder: "#9a7721",
        hoverBorder: "#d2aa44"
    });

    styleHeaderButton(optionsButton, {
        normalBg: "linear-gradient(180deg, #3b5f2e 0%, #2b461f 100%)",
        hoverBg: "linear-gradient(180deg, #4a7639 0%, #355628 100%)",
        normalBorder: "#547e44",
        hoverBorder: "#7bb562"
    });

    styleHeaderButton(closeButton, {
        normalBg: "linear-gradient(180deg, #3a3a3a 0%, #222 100%)",
        hoverBg: "linear-gradient(180deg, #474747 0%, #2a2a2a 100%)",
        normalBorder: "#555",
        hoverBorder: "#7a7a7a",
        minWidth: "34px",
        padding: "0",
        fontSize: "14px",
        fontWeight: "800"
    });
}

function styleStartButton(isRunning = false) {
    if (!startButton) return;

    const palette = isRunning
        ? {
            normalBg: "linear-gradient(180deg, #8c1f1f 0%, #631515 100%)",
            hoverBg: "linear-gradient(180deg, #a12626 0%, #751919 100%)",
            normalBorder: "#b64040",
            hoverBorder: "#d96969"
        }
        : {
            normalBg: "linear-gradient(180deg, #2b8a3f 0%, #1f6a31 100%)",
            hoverBg: "linear-gradient(180deg, #33a24a 0%, #257c39 100%)",
            normalBorder: "#3fac59",
            hoverBorder: "#6fd18a"
        };

    startButton.style.minWidth = "0";
    startButton.style.height = "44px";
    startButton.style.padding = "0 16px";
    startButton.style.width = "auto";
    startButton.style.display = "inline-flex";
    startButton.style.alignItems = "center";
    startButton.style.justifyContent = "center";
    startButton.style.textAlign = "center";
    startButton.style.lineHeight = "1";
    startButton.style.whiteSpace = "nowrap";
    startButton.style.border = `1px solid ${palette.normalBorder}`;
    startButton.style.borderRadius = "999px";
    startButton.style.background = palette.normalBg;
    startButton.style.color = "#f6fff9";
    startButton.style.fontSize = "16px";
    startButton.style.fontWeight = "800";
    startButton.style.letterSpacing = "0.2px";
    startButton.style.cursor = "pointer";
    startButton.style.boxShadow = "0 6px 16px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.16)";
    startButton.style.transition = "background 140ms ease, border-color 140ms ease, transform 90ms ease, box-shadow 140ms ease";

    startButton.dataset.normalBg = palette.normalBg;
    startButton.dataset.hoverBg = palette.hoverBg;
    startButton.dataset.normalBorder = palette.normalBorder;
    startButton.dataset.hoverBorder = palette.hoverBorder;

    if (!startButton.dataset.styleInit) {
        startButton.onmouseenter = () => {
            if (startButton.disabled) return;
            startButton.style.background = startButton.dataset.hoverBg || palette.hoverBg;
            startButton.style.borderColor = startButton.dataset.hoverBorder || palette.hoverBorder;
            startButton.style.boxShadow = "0 8px 18px rgba(0,0,0,0.34), inset 0 1px 0 rgba(255,255,255,0.2)";
        };

        startButton.onmouseleave = () => {
            startButton.style.background = startButton.dataset.normalBg || palette.normalBg;
            startButton.style.borderColor = startButton.dataset.normalBorder || palette.normalBorder;
            startButton.style.boxShadow = "0 6px 16px rgba(0,0,0,0.28), inset 0 1px 0 rgba(255,255,255,0.16)";
            startButton.style.transform = "";
        };

        startButton.onmousedown = () => {
            if (startButton.disabled) return;
            startButton.style.transform = "translateY(1px)";
        };

        startButton.onmouseup = () => {
            startButton.style.transform = "";
        };

        startButton.dataset.styleInit = "1";
    }
}

function formatStatusClock(ts) {
    if (!ts) return "--:--:--";
    try {
        return new Date(ts).toLocaleTimeString("pl-PL", { hour12: false });
    } catch (error) {
        return "--:--:--";
    }
}


function getScriptMeta() {
    const fallback = {
        name: "PTT BON Giveaway",
        description: "Giveaway BON dla PolishTorrent",
        version: "2.0.19",
        author: "Aniouek32"
    };

    try {
        if (typeof GM_info !== "undefined" && GM_info && GM_info.script) {
            const script = GM_info.script;
            return {
                name: String(script.name || fallback.name),
                description: String(script.description || fallback.description),
                version: String(script.version || fallback.version),
                author: String(script.author || fallback.author)
            };
        }
    } catch (error) {
    }

    return fallback;
}
function markApiStatus(bucket, ok, errorText = "") {
    const target = apiHealth[bucket];
    if (!target) return;

    target.ok = ok;
    target.lastAt = Date.now();
    target.lastError = String(errorText || "").trim();

    updateRoomInfoPanel();
}

function resetApiStatus() {
    apiHealth.entries.ok = null;
    apiHealth.entries.lastAt = 0;
    apiHealth.entries.lastError = "";

    apiHealth.sponsors.ok = null;
    apiHealth.sponsors.lastAt = 0;
    apiHealth.sponsors.lastError = "";

    updateRoomInfoPanel();
}

function updateRoomInfoPanel() {
    if (!roomEntriesValue || !roomSponsorsValue) return;

    roomEntriesValue.textContent = getEntriesRoomId();
    roomSponsorsValue.textContent = getSponsorRoomId();

    if (scriptNameValue) {
        scriptNameValue.textContent = scriptMeta.name;
    }

    if (scriptDescriptionValue) {
        scriptDescriptionValue.textContent = scriptMeta.description;
    }

    if (scriptVersionValue) {
        scriptVersionValue.textContent = scriptMeta.version;
    }

    if (scriptAuthorValue) {
        scriptAuthorValue.textContent = scriptMeta.author;
    }

    if (!apiEntriesStatusValue || !apiSponsorsStatusValue) return;

    const entries = apiHealth.entries;
    if (entries.ok === true) {
        apiEntriesStatusValue.textContent = `OK ${formatStatusClock(entries.lastAt)}`;
        apiEntriesStatusValue.style.color = "#5edb7f";
    } else if (entries.ok === false) {
        apiEntriesStatusValue.textContent = `BŁĄD ${formatStatusClock(entries.lastAt)}`;
        apiEntriesStatusValue.style.color = "#ff7979";
    } else {
        apiEntriesStatusValue.textContent = "brak danych";
        apiEntriesStatusValue.style.color = "#9bb2bf";
    }

    const sponsors = apiHealth.sponsors;
    if (sponsors.ok === true) {
        apiSponsorsStatusValue.textContent = `OK ${formatStatusClock(sponsors.lastAt)}`;
        apiSponsorsStatusValue.style.color = "#5edb7f";
    } else if (sponsors.ok === false) {
        apiSponsorsStatusValue.textContent = `BŁĄD ${formatStatusClock(sponsors.lastAt)}`;
        apiSponsorsStatusValue.style.color = "#ff7979";
    } else {
        apiSponsorsStatusValue.textContent = "brak danych";
        apiSponsorsStatusValue.style.color = "#9bb2bf";
    }
}

function hideRestoreNotice() {
    if (!restoreNotice) return;

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

    restoreNotice.hidden = true;
    restoreNotice.style.display = "none";
    restoreNotice.textContent = "";
}

function showRestoreNotice(text, timeoutMs = 6000) {
    if (!restoreNotice) return;

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

    restoreNotice.textContent = text;
    restoreNotice.hidden = false;
    restoreNotice.style.display = "block";

    if (timeoutMs > 0) {
        restoreNoticeTimer = setTimeout(() => {
            hideRestoreNotice();
        }, timeoutMs);
    }
}
	
	function updateStatsPanel(activeGiveaway = giveawayData) {
    if (!statsWrapper) return;

    if (!activeGiveaway || activeGiveaway.ended) {
        statsWrapper.hidden = true;
        return;
    }

    const sponsorTotal = Object.values(activeGiveaway.sponsorContribs || {})
        .reduce((sum, value) => sum + (Number(value) || 0), 0);

    const hostBase = Math.floor(activeGiveaway.initialHostContribution || 0);
    const hostCurrent = Math.floor(activeGiveaway.hostContribution || 0);
    const hostExtra = Math.max(0, hostCurrent - hostBase);
    const taken = numberEntries.size;
    const free = Math.max(0, (activeGiveaway.totalEntries || 0) - taken);

    statsPot.textContent = `${cleanPotString(activeGiveaway.amount)} BON`;
    statsHostBase.textContent = `${cleanPotString(hostBase)} BON`;
    statsHostExtra.textContent = `${cleanPotString(hostExtra)} BON`;
    statsSponsors.textContent = `${cleanPotString(sponsorTotal)} BON`;
    statsEntries.textContent = `${taken}/${activeGiveaway.totalEntries}`;
    statsFree.textContent = String(free);

    statsWrapper.hidden = false;
}

function getSerializableGiveawayData() {
    if (!giveawayData) return null;

    return {
        host: giveawayData.host,
        amount: giveawayData.amount,
        hostContribution: giveawayData.hostContribution,
        initialHostContribution: giveawayData.initialHostContribution,
        gameMode: sanitizeGameMode(giveawayData.gameMode || activeGameMode),
        minEntryFee: Math.max(0, Math.floor(giveawayData.minEntryFee || 0)),
        dynamicMinEnabled: Boolean(giveawayData.dynamicMinEnabled),
        dynamicMinBase: Math.max(0, Math.floor(giveawayData.dynamicMinBase || 0)),
        startNum: giveawayData.startNum,
        endNum: giveawayData.endNum,
        totalEntries: giveawayData.totalEntries,
        winningNumber: giveawayData.winningNumber,
        totalSeconds: giveawayData.totalSeconds,
        timeLeft: giveawayData.timeLeft,
        endTs: giveawayData.endTs,
        reminderNum: giveawayData.reminderNum,
        reminderFreqSec: giveawayData.reminderFreqSec,
        winnersNum: giveawayData.winnersNum,
        sponsors: Array.isArray(giveawayData.sponsors) ? [...giveawayData.sponsors] : [],
        sponsorContribs: { ...(giveawayData.sponsorContribs || {}) },
        winnerSent: Boolean(giveawayData.winnerSent),
        entriesPaused: Boolean(giveawayData.entriesPaused),
        ended: Boolean(giveawayData.ended),
        ending: Boolean(giveawayData.ending)
    };
}

function saveState() {
    try {
        if (!giveawayData || giveawayData.ended) {
            clearSavedState();
            return;
        }

        const payload = {
            version: 4,
            giveawayStartTime: giveawayStartTime ? giveawayStartTime.getTime() : null,
            giveawayData: getSerializableGiveawayData(),
            numberEntries: Array.from(numberEntries.entries()),
            fancyNames: Array.from(fancyNames.entries()),
            processedGiftMessages: Array.from(processedGiftMessages),
            processedChatMessages: Array.from(processedChatMessages),
            sponsorDigestBuffer: Array.isArray(sponsorDigestBuffer) ? sponsorDigestBuffer : [],
            sponsorDigestWindowStartAt: sponsorDigestWindowStartAt || 0
        };

        localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
    } catch (error) {
        console.error("Nie udało się zapisać stanu giveaway:", error);
    }
}

function clearSavedState() {
    try {
        localStorage.removeItem(STORAGE_KEY);
    } catch (error) {
        console.error("Nie udało się usunąć zapisanego stanu giveaway:", error);
    }
}

function normalizeRoomId(value) {
    const parsed = parseInt(String(value || "").trim(), 10);
    if (!Number.isFinite(parsed) || parsed <= 0) {
        return null;
    }
    return String(parsed);
}

function getEntriesRoomId() {
    return normalizeRoomId(resolvedEntriesRoomId) ||
        normalizeRoomId(GENERAL_SETTINGS.entriesRoomId) ||
        "1";
}

function getSponsorRoomId() {
    return normalizeRoomId(resolvedSponsorRoomId) ||
        normalizeRoomId(GENERAL_SETTINGS.sponsorRoomId) ||
        getEntriesRoomId();
}

function captureCurrentRoomId() {
    const currentRoomInput = document.getElementById("currentChatroom");
    const currentRoomId = normalizeRoomId(currentRoomInput ? currentRoomInput.value : null);
    if (currentRoomId) {
        resolvedEntriesRoomId = currentRoomId;
    }

    updateRoomInfoPanel();
}

async function resolveChatRoomIds() {
    captureCurrentRoomId();

    if (roomIdResolvePromise) {
        await roomIdResolvePromise;
        updateRoomInfoPanel();
        return;
    }

    roomIdResolvePromise = (async () => {
        try {
            const [configResponse, roomsResponse] = await Promise.all([
                fetch(`${window.location.origin}/api/chat/config`, {
                    method: "GET",
                    credentials: "same-origin"
                }),
                fetch(`${window.location.origin}/api/chat/rooms`, {
                    method: "GET",
                    credentials: "same-origin"
                })
            ]);

            if (!configResponse.ok || !roomsResponse.ok) {
                throw new Error("Failed to resolve chat room IDs");
            }

            const configData = await configResponse.json();
            const roomsData = await roomsResponse.json();
            const rooms = Array.isArray(roomsData.data) ? roomsData.data : [];

            const roomById = new Map();
            const roomByName = new Map();

            for (const room of rooms) {
                const roomId = normalizeRoomId(room?.id);
                if (!roomId) continue;

                roomById.set(roomId, roomId);

                const roomName = String(room?.name || "").trim().toLowerCase();
                if (roomName) {
                    roomByName.set(roomName, roomId);
                }
            }

            captureCurrentRoomId();

            const fallbackEntries = normalizeRoomId(GENERAL_SETTINGS.entriesRoomId);
            if (!normalizeRoomId(resolvedEntriesRoomId) && fallbackEntries && roomById.has(fallbackEntries)) {
                resolvedEntriesRoomId = fallbackEntries;
            }

            const configRoom = configData ? configData.system_chatroom : null;
            const configRoomId = normalizeRoomId(configRoom);
            if (configRoomId && roomById.has(configRoomId)) {
                resolvedSponsorRoomId = configRoomId;
            } else {
                const configRoomName = String(configRoom || "").trim().toLowerCase();
                if (configRoomName && roomByName.has(configRoomName)) {
                    resolvedSponsorRoomId = roomByName.get(configRoomName);
                }
            }

            const fallbackSponsor = normalizeRoomId(GENERAL_SETTINGS.sponsorRoomId);
            if (!normalizeRoomId(resolvedSponsorRoomId) && fallbackSponsor && roomById.has(fallbackSponsor)) {
                resolvedSponsorRoomId = fallbackSponsor;
            }
        } catch (error) {
            if (DEBUG_SETTINGS.logChatMessages) {
                console.warn("Nie udało się automatycznie rozpoznać room IDs czatu:", error);
            }
        } finally {
            roomIdResolvePromise = null;
            updateRoomInfoPanel();
        }
    })();

    await roomIdResolvePromise;
    updateRoomInfoPanel();
}
function restoreGiveawayFromStorage() {
    try {
        const raw = localStorage.getItem(STORAGE_KEY);
        if (!raw) return;

        const payload = JSON.parse(raw);
        if (!payload || !payload.giveawayData) {
            clearSavedState();
            return;
        }

        const savedGiveaway = payload.giveawayData;
        if (savedGiveaway.ended) {
            clearSavedState();
            return;
        }

        const currentUser = normalizeUser(getLoggedInUsername());
        const savedHost = normalizeUser(savedGiveaway.host);

        if (!currentUser || !savedHost || currentUser !== savedHost) {
            clearSavedState();
            return;
        }

        giveawayStartTime = payload.giveawayStartTime
            ? new Date(payload.giveawayStartTime)
            : new Date();

        giveawayData = {
            ...savedGiveaway,
            gameMode: sanitizeGameMode(savedGiveaway.gameMode),
            minEntryFee: Math.max(0, Math.floor(savedGiveaway.minEntryFee || 0)),
            dynamicMinEnabled: Boolean(savedGiveaway.dynamicMinEnabled),
            dynamicMinBase: Math.max(0, Math.floor(savedGiveaway.dynamicMinBase || 0)),
            sponsors: Array.isArray(savedGiveaway.sponsors) ? [...savedGiveaway.sponsors] : [],
            sponsorContribs: { ...(savedGiveaway.sponsorContribs || {}) },
            countdownTimerID: null,
            entriesPaused: Boolean(savedGiveaway.entriesPaused),
            ended: false,
            ending: false
        };

        activeV2DynamicMinEnabled = Boolean(giveawayData.dynamicMinEnabled);

        coinInput.value = String(Math.floor(giveawayData.initialHostContribution || 0));
        startInput.value = String(giveawayData.startNum || 1);
        endInput.value = String(giveawayData.endNum || 50);
        timerInput.value = String(Math.max(1, Math.round((giveawayData.totalSeconds || 0) / 60)));
        reminderInput.value = String(Math.max(0, giveawayData.reminderNum || 0));
        winnersInput.value = String(Math.max(1, giveawayData.winnersNum || 1));
        if (entryFeeInput) {
            const restoredBaseMin = Math.max(1, Math.floor(giveawayData.dynamicMinBase || giveawayData.minEntryFee || 50));
            entryFeeInput.value = String(restoredBaseMin);
        }

        numberEntries = new Map(Array.isArray(payload.numberEntries) ? payload.numberEntries : []);
        fancyNames = new Map(Array.isArray(payload.fancyNames) ? payload.fancyNames : []);
        numberTakenBy = new Map();

        numberEntries.forEach((number, author) => {
            numberTakenBy.set(number, author);
        });

        processedGiftMessages = new Set(Array.isArray(payload.processedGiftMessages) ? payload.processedGiftMessages : []);
        processedChatMessages = new Set(Array.isArray(payload.processedChatMessages) ? payload.processedChatMessages : []);
        sponsorDigestBuffer = Array.isArray(payload.sponsorDigestBuffer) ? payload.sponsorDigestBuffer : [];
        sponsorDigestWindowStartAt = Number(payload.sponsorDigestWindowStartAt) || 0;

        chatbox = document.querySelector("#chatbox__messages-create");

        updateEntries();
        updateCoinHeader(giveawayData.amount);
        updateStatsPanel(giveawayData);
		showRestoreNotice("Przywrócono aktywny giveaway po odswiezeniu strony.");

        entriesWrapper.hidden = false;
        countdownHeader.hidden = false;
        applyGameMode(giveawayData.gameMode || activeGameMode, false, true);
        setRunningState(true);
        void resolveChatRoomIds();

        if (entriesInterval) {
            clearInterval(entriesInterval);
        }
        if (sponsorsInterval) {
            clearInterval(sponsorsInterval);
        }

        giveawayData.countdownTimerID = countdownTimer(countdownHeader, giveawayData);

        entriesInterval = setInterval(() => { void getEntriesFromApi(); }, GENERAL_SETTINGS.entryPollMs);
        sponsorsInterval = setInterval(() => { void getSponsors(); }, GENERAL_SETTINGS.sponsorPollMs);

        void getEntriesFromApi();
        void getSponsors();

        window.onbeforeunload = function () {
            return "Trwa giveaway";
        };
    } catch (error) {
        console.error("Nie udało się przywrócić giveaway:", error);
        clearSavedState();
    }
}

    function toggleStartStop() {
    if (giveawayData && !giveawayData.ended) {
        if (!window.confirm("Na pewno zakończyc giveaway?")) {
            return;
        }
        endGiveaway(giveawayData);
    } else {
        void startGiveaway();
    }
}

    function reminderAutoScaling() {
        const timerValue = parseInt(timerInput.value, 10);
        if (Number.isNaN(timerValue)) {
            reminderInput.value = 0;
            reminderInput.setCustomValidity("");
            return;
        }

        const reminders = Math.floor(timerValue / GENERAL_SETTINGS.defaultMinsPerReminder) - 1;
        reminderInput.value = reminders < 0 ? 0 : reminders;
        reminderInput.setCustomValidity("");
    }

    function entryRangeValidation() {
        const startValue = parseInt(startInput.value, 10);
        const endValue = parseInt(endInput.value, 10);

        if (!Number.isNaN(startValue) && !Number.isNaN(endValue) && startValue > endValue) {
            const msg = "Numer startowy powinien byc mniejszy lub równy numerowi koncowemu";
            startInput.setCustomValidity(msg);
            endInput.setCustomValidity(msg);
        } else {
            startInput.setCustomValidity("");
            endInput.setCustomValidity("");
        }
    }

    function remindersValidation() {
        const timerValue = parseInt(timerInput.value, 10);
        const reminderValue = parseInt(reminderInput.value, 10);

        if (Number.isNaN(timerValue) || Number.isNaN(reminderValue) || reminderValue <= 0) {
            reminderInput.setCustomValidity("");
            return;
        }

        if (timerValue / reminderValue < GENERAL_SETTINGS.minsPerReminderLimit) {
            reminderInput.setCustomValidity(`Nie może byc wiecej niz 1 przypomnienie co ${parseTime(GENERAL_SETTINGS.minsPerReminderLimit * 60000)}.`);
            reminderInput.reportValidity();
        } else {
            reminderInput.setCustomValidity("");
        }
    }

    function winnersValidation() {
        const winnersValue = parseInt(winnersInput.value, 10);
        if (Number.isNaN(winnersValue) || winnersValue < 1) {
            winnersInput.setCustomValidity("Liczba zwycięzców musi byc co najmniej 1.");
        } else {
            winnersInput.setCustomValidity("");
        }
    }

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

    chatbox = document.querySelector("#chatbox__messages-create");
    if (!chatbox) {
        window.alert("Nie udało się znalezc pola czatu.");
        return;
    }

    processedGiftMessages = new Set();
    processedChatMessages = new Set();
    giveawayStartTime = new Date();
    resetV2GiftHistoryState();

    numberEntries = new Map();
    numberTakenBy = new Map();
    fancyNames = new Map();

    sponsorDigestBuffer = [];
    sponsorDigestWindowStartAt = 0;

    userCooldown.clear();
    userCommandLog.clear();
    userLastActionAt.clear();
    userLastCommandAt.clear();
    userSpamStrikes.clear();
    userFeedbackCooldown.clear();
    resetApiStatus();

    updateEntries();

    const totalTimeMs = parseInt(timerInput.value, 10) * 60000;
    const reminderNum = Math.max(0, parseInt(reminderInput.value, 10) || 0);
    const winnersNum = Math.max(1, parseInt(winnersInput.value, 10) || 1);
    const amount = parsePositiveInt(coinInput.value);
    const gameMode = sanitizeGameMode(activeGameMode);
    const minEntryFee = gameMode === "v2_0" ? parsePositiveInt(entryFeeInput ? entryFeeInput.value : 0) : 0;
    const dynamicMinEnabled = gameMode === "v2_0" ? Boolean(activeV2DynamicMinEnabled) : false;

    if (amount <= 0) {
        window.alert("Kwota giveaway musi byc wieksza niz 0 BON.");
        return;
    }

    if (gameMode === "v2_0" && minEntryFee <= 0) {
        window.alert("W trybie Stawka większa niż pula BON minimalne wpisowe musi być większe niż 0 BON.");
        return;
    }

    giveawayData = {
        host: getLoggedInUsername(),
        amount,
        hostContribution: amount,
        initialHostContribution: amount,
        gameMode,
        minEntryFee,
        dynamicMinEnabled,
        dynamicMinBase: dynamicMinEnabled ? minEntryFee : 0,
        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,
        reminderNum,
        reminderFreqSec: reminderNum > 0 ? Math.max(1, Math.round((totalTimeMs / 1000) / (reminderNum + 1))) : 0,
        winnersNum,
        sponsors: [],
        sponsorContribs: {},
        entriesPaused: false,
        countdownTimerID: null,
        winnerSent: false,
        ended: false,
        ending: false,
    };

    const sponsorMessage = getSponsorMessage(giveawayData.host);
    const currentBon = readHostBalance();

    if (currentBon < giveawayData.amount) {
        window.alert(`BŁĄD GIVEAWAY: wpisana kwota (${giveawayData.amount}) jest wyższą niz twój aktualny BON (${currentBon}). Byc może musisz odswiezyc strone.`);
        resetGiveaway(true);
        return;
    }

    await resolveChatRoomIds();

    setRunningState(true);
    updateStatsPanel(giveawayData);
    saveState();

    const winnersText = giveawayData.winnersNum === 1
        ? "1 zwycięzca"
        : `${giveawayData.winnersNum} zwycięzców`;

    if (isV2Mode(giveawayData)) {
        sendMessage(getV2Copy().intro({
            amount: giveawayData.amount,
            hostContributionText: cleanPotString(giveawayData.hostContribution),
            totalTimeMs,
            winnersText,
            startNum: giveawayData.startNum,
            endNum: giveawayData.endNum,
            minEntryFee: getCurrentV2MinEntryFee(giveawayData),
            host: giveawayData.host,
            dynamicMinEnabled: Boolean(giveawayData.dynamicMinEnabled)
        }));
    } else {
        sendMessage(CHAT_COPY.intro({
            amount: giveawayData.amount,
            totalTimeMs,
            winnersText,
            startNum: giveawayData.startNum,
            endNum: giveawayData.endNum,
            sponsorMessage
        }));
    }

    entriesWrapper.hidden = false;
    giveawayData.countdownTimerID = countdownTimer(countdownHeader, giveawayData);

    entriesInterval = setInterval(() => { void getEntriesFromApi(); }, GENERAL_SETTINGS.entryPollMs);
    sponsorsInterval = setInterval(() => { void getSponsors(); }, GENERAL_SETTINGS.sponsorPollMs);

    void getEntriesFromApi();
    void getSponsors();

    window.onbeforeunload = function () {
        return "Trwa giveaway";
    };
}

    function setRunningState(isRunning) {
        startButton.textContent = isRunning ? "Zakończ" : "Start";
        styleStartButton(isRunning);
        coinInput.disabled = isRunning;
        if (entryFeeInput) {
            entryFeeInput.disabled = isRunning || sanitizeGameMode(activeGameMode) !== "v2_0";
        }
        startInput.disabled = isRunning;
        endInput.disabled = isRunning;
        timerInput.disabled = isRunning;
        reminderInput.disabled = isRunning;
        winnersInput.disabled = isRunning;
        startButton.disabled = false;

        refreshBonInputThemes();

        updateModeUiState();
    }

function handleGiveawayCommands(author, messageContent, fancyName, activeGiveaway) {
    if (!activeGiveaway) return;

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

    const validCommands = [
        'time', 'status', 'random', 'number', 'lucky', 'luckye', 'free',
        'addbon', 'removebon', 'commands', 'entries', 'sponsors', 'bon', 'range',
        'winners', 'reminder', 'pause', 'resume'
    ];

    if (!validCommands.includes(command)) {
        return;
    }

    const isHost = normalizeUser(author) === normalizeUser(activeGiveaway.host);

        if (!isHost && applyCooldown(author, { command })) {
        return;
    }

    const userNumber = numberEntries.get(author);
    const isV2 = isV2Mode(activeGiveaway);
    let message;

    if (isV2 && (command === 'random' || command === 'luckye')) {
        sendMessage(getV2Copy().entryViaGiftOnly(author, activeGiveaway.host, getCurrentV2MinEntryFee(activeGiveaway)));
        return;
    }

    switch (command) {
        case 'time': {
            if (args.length === 0) {
                sendMessage(CHAT_COPY.timeLeft(activeGiveaway.timeLeft * 1000));
                return;
            }

            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyTime());
                return;
            }

            const action = (args[0] || "").toLowerCase();
            const amount = parseInt(args[1], 10);

            if (!['add', 'remove'].includes(action) || !Number.isFinite(amount) || amount <= 0) {
                sendMessage(CHAT_COPY.usageTime());
                return;
            }

            const deltaMs = amount * 60000;

            if (action === 'add') {
                activeGiveaway.endTs += deltaMs;
                activeGiveaway.timeLeft = Math.max(0, Math.ceil((activeGiveaway.endTs - Date.now()) / 1000));
                recalculateReminderFrequency(activeGiveaway);
                updateStatsPanel(activeGiveaway);
                saveState();

                sendMessage(CHAT_COPY.hostAddTime(amount, activeGiveaway.timeLeft * 1000));
            } else {
                activeGiveaway.endTs -= deltaMs;
                activeGiveaway.timeLeft = Math.max(0, Math.ceil((activeGiveaway.endTs - Date.now()) / 1000));

                if (activeGiveaway.timeLeft <= 0) {
                    sendMessage(CHAT_COPY.hostTimeEnded());
                    void endGiveaway(activeGiveaway);
                    return;
                }

                recalculateReminderFrequency(activeGiveaway);
                updateStatsPanel(activeGiveaway);
                saveState();

                sendMessage(CHAT_COPY.hostRemoveTime(amount, activeGiveaway.timeLeft * 1000));
            }
            break;
        }

        case 'status': {
            const freeCount = Math.max(0, activeGiveaway.totalEntries - numberEntries.size);
            const sponsorCount = Object.keys(activeGiveaway.sponsorContribs || {}).length;
            const entriesState = activeGiveaway.entriesPaused ? "wstrzymane" : "otwarte";
            if (isV2) {
                sendMessage(getV2Copy().status({
                    amount: activeGiveaway.amount,
                    timeLeftMs: activeGiveaway.timeLeft * 1000,
                    taken: numberEntries.size,
                    total: activeGiveaway.totalEntries,
                    freeCount,
                    sponsorCount,
                    entriesState,
                    minEntryFee: getCurrentV2MinEntryFee(activeGiveaway)
                }));
            } else {
                sendMessage(CHAT_COPY.status({
                    amount: activeGiveaway.amount,
                    timeLeftMs: activeGiveaway.timeLeft * 1000,
                    taken: numberEntries.size,
                    total: activeGiveaway.totalEntries,
                    freeCount,
                    sponsorCount,
                    entriesState
                }));
            }
            break;
        }
        case 'random': {
            if (userNumber !== undefined) {
                sendMessage(CHAT_COPY.alreadyJoined(author, userNumber));
                return;
            }

            if (activeGiveaway.entriesPaused) {
                if (canSendUserFeedback(author, "entry-paused")) {
                    sendMessage(CHAT_COPY.entriesPaused(author));
                }
                return;
            }

            const freeNumbers = getFreeNumbers(activeGiveaway);
            if (freeNumbers.length === 0) {
                sendMessage(CHAT_COPY.noFreeForUser(author));
                return;
            }

            const randomNum = freeNumbers[Math.floor(Math.random() * freeNumbers.length)];
            addNewEntry(author, fancyName || escapeHTML(author), randomNum);
            sendMessage(CHAT_COPY.joinedRandom(author, randomNum, activeGiveaway.timeLeft * 1000));
            break;
        }

        case 'number':
            if (userNumber !== undefined) {
                message = CHAT_COPY.numberInfo(author, userNumber);
            } else {
                message = CHAT_COPY.numberNone(author);
            }
            sendMessage(message);
            break;

        case 'lucky':
            message = CHAT_COPY.luckyNow(getLuckyNumber(activeGiveaway));
            sendMessage(message);
            break;

        case 'luckye': {
            if (userNumber !== undefined) {
                sendMessage(CHAT_COPY.alreadyJoined(author, userNumber));
                return;
            }

            if (activeGiveaway.entriesPaused) {
                if (canSendUserFeedback(author, "entry-paused")) {
                    sendMessage(CHAT_COPY.entriesPaused(author));
                }
                return;
            }

            const luckyNum = getLuckyNumber(activeGiveaway);

            if (luckyNum === null || luckyNum === undefined || numberTakenBy.has(luckyNum)) {
                sendMessage(CHAT_COPY.noLuckyCandidate(author));
                return;
            }

            addNewEntry(author, fancyName || escapeHTML(author), luckyNum);
            sendMessage(CHAT_COPY.joinedLuckye(author, luckyNum, activeGiveaway.timeLeft * 1000));
            break;
        }

        case 'free': {
            const sample = getFreeNumberSample(activeGiveaway, 5);
            const freeCount = activeGiveaway.totalEntries - numberEntries.size;

            if (freeCount <= 0) {
                sendMessage(CHAT_COPY.noFreeLeft());
                return;
            }

            if (sample.length === 0) {
                sendMessage(CHAT_COPY.freeCountOnly(freeCount));
                return;
            }

            sendMessage(CHAT_COPY.freeSample(freeCount, sample));
            break;
        }

        case 'addbon': {
            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyCmd("!addbon"));
                return;
            }

            const amount = parsePositiveInt(args[0]);
            if (!Number.isFinite(amount) || amount <= 0) {
                sendMessage(CHAT_COPY.usage("!addbon 100"));
                return;
            }

            const currentBon = readHostBalance();
            const newHostContribution = Math.floor(activeGiveaway.hostContribution || 0) + amount;

            if (currentBon < newHostContribution) {
                sendMessage(CHAT_COPY.addBonBalanceError(currentBon, newHostContribution));
                return;
            }

            activeGiveaway.amount += amount;
            activeGiveaway.hostContribution += amount;

            updateCoinHeader(activeGiveaway.amount);
            updateStatsPanel(activeGiveaway);
            saveState();

            sendMessage(CHAT_COPY.hostAddedBon(amount, activeGiveaway.amount));
            break;
        }

        case 'removebon': {
            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyCmd("!removebon"));
                return;
            }

            const amount = parsePositiveInt(args[0]);
            if (!Number.isFinite(amount) || amount <= 0) {
                sendMessage(CHAT_COPY.usage("!removebon 100"));
                return;
            }

            const baseHostContribution = Math.floor(activeGiveaway.initialHostContribution || 0);
            const currentHostContribution = Math.floor(activeGiveaway.hostContribution || 0);
            const removableFromHost = Math.max(0, currentHostContribution - baseHostContribution);

            if (removableFromHost <= 0) {
                sendMessage(CHAT_COPY.cannotRemoveNoExtra());
                return;
            }

            if (amount > removableFromHost) {
                sendMessage(CHAT_COPY.cannotRemoveMax(removableFromHost));
                return;
            }

            activeGiveaway.amount -= amount;
            activeGiveaway.hostContribution -= amount;

            updateCoinHeader(activeGiveaway.amount);
            updateStatsPanel(activeGiveaway);
            saveState();

            sendMessage(CHAT_COPY.hostRemovedBon(amount, activeGiveaway.amount));
            break;
        }

        case 'winners': {
            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyWinners());
                return;
            }

            const newWinners = parseInt(args[0], 10);
            if (!Number.isFinite(newWinners) || newWinners < 1) {
                sendMessage(CHAT_COPY.usage("!winners 3"));
                return;
            }

            activeGiveaway.winnersNum = newWinners;
            winnersInput.value = String(newWinners);
            saveState();

            sendMessage(CHAT_COPY.winnersSet(newWinners));
            break;
        }

        case 'reminder':
            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyReminder());
                return;
            }

            sendReminderMessage(activeGiveaway);
            break;

        case 'pause':
            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyPause());
                return;
            }

            if (activeGiveaway.entriesPaused) {
                sendMessage(CHAT_COPY.pausedAlready());
                return;
            }

            activeGiveaway.entriesPaused = true;
            saveState();
            sendMessage(CHAT_COPY.pausedNow());
            break;

        case 'resume':
            if (!isHost) {
                sendMessage(CHAT_COPY.hostOnlyResume());
                return;
            }

            if (!activeGiveaway.entriesPaused) {
                sendMessage(CHAT_COPY.resumedAlready());
                return;
            }

            activeGiveaway.entriesPaused = false;
            saveState();
            sendMessage(CHAT_COPY.resumedNow());
            break;

        case 'commands':
            if (isV2) {
                sendMessage(getV2Copy().commandsList(activeGiveaway.host, getCurrentV2MinEntryFee(activeGiveaway)));
            } else {
                sendMessage(CHAT_COPY.commandsList());
            }
            break;

        case 'entries': {
            if (numberEntries.size === 0) {
                sendMessage(CHAT_COPY.entriesNone());
                return;
            }

            const entriesList = Array.from(numberEntries.entries())
                .sort((a, b) => a[1] - b[1])
                .map(([entryAuthor, entryNumber]) => `[color=#d85e27]${sanitizeNick(entryAuthor)}[/color]: [b]${entryNumber}[/b]`)
                .join(", ");

            const freeCount = activeGiveaway.totalEntries - numberEntries.size;
            sendMessage(CHAT_COPY.entriesList(numberEntries.size, activeGiveaway.totalEntries, freeCount, entriesList));
            break;
        }

        case 'sponsors': {
            const sponsors = getSortedSponsors(activeGiveaway);
            if (sponsors.length === 0) {
                sendMessage(CHAT_COPY.sponsorsNone());
                return;
            }

            const totalSponsored = sponsors.reduce((sum, item) => sum + item.amount, 0);
            const sponsorList = sponsors
                .map(item => `[color=green][b]${sanitizeNick(item.name)}[/b][/color] ([color=#ffc00a]${cleanPotString(item.amount)} BON[/color])`)
                .join(", ");

            sendMessage(CHAT_COPY.sponsorsList(totalSponsored, sponsorList));
            break;
        }

        case 'bon':
            sendMessage(CHAT_COPY.potNow(activeGiveaway.amount));
            break;

        case 'range':
            sendMessage(CHAT_COPY.rangeNow(activeGiveaway.startNum, activeGiveaway.endNum));
            break;

        default:
            break;
    }
}

    function handleEntryMessage(number, author, fancyName, activeGiveaway) {
        if (!activeGiveaway) return;

        if (isV2Mode(activeGiveaway)) {
            if (canSendUserFeedback(author, "entry-v2-gift")) {
                sendMessage(getV2Copy().entryViaGiftOnly(author, activeGiveaway.host, getCurrentV2MinEntryFee(activeGiveaway)));
            }
            return;
        }

        if (activeGiveaway.entriesPaused) {
            if (canSendUserFeedback(author, "entry-paused")) {
                sendMessage(CHAT_COPY.entriesPaused(author));
            }
            return;
        }

        if (number < activeGiveaway.startNum || number > activeGiveaway.endNum) {
            if (canSendUserFeedback(author, "entry-range")) {
                const outOfBoundsMessage = CHAT_COPY.outOfRange(author, number, activeGiveaway.startNum, activeGiveaway.endNum, formatFreeNumberSuggestion(activeGiveaway));
                sendMessage(outOfBoundsMessage);
            }
            return;
        }

        if (numberEntries.has(author)) {
            const currentNumber = numberEntries.get(author);
            if (canSendUserFeedback(author, "entry-repeat")) {
                sendMessage(CHAT_COPY.alreadyJoined(author, currentNumber));
            }
            return;
        }

        addNewEntry(author, fancyName || escapeHTML(author), number);

        const entryMessage = CHAT_COPY.entryJoined(author, number, activeGiveaway.timeLeft * 1000);
        sendMessage(entryMessage);
    }

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

    updateEntries();
    updateStatsPanel(giveawayData);
}

    function updateEntries() {
        let tableStart = "<thead><tr><th>Uzytkownik</th><th>Numer</th></tr></thead><tbody>";
        let tableEntries = "";
        let tableEnd = "</tbody>";

        numberEntries.forEach((entry, author) => {
            const fancyName = fancyNames.get(author) || escapeHTML(author);
            tableEntries += `<tr><td>${fancyName}</td><td>${entry}</td></tr>`;
        });

        document.getElementById("entriesTable").innerHTML = tableStart + tableEntries + tableEnd;
    }

    function collectGiftMetaText(metaMessage) {
        if (!metaMessage || typeof metaMessage !== "object") {
            return [];
        }

        const candidates = [];
        const pushText = (value) => {
            const text = String(value || "").replace(/\s+/g, " ").trim();
            if (!text) return;
            candidates.push(text);
        };

        const directKeys = [
            "gift_message", "giftMessage", "gift_note", "giftNote",
            "note", "message_text", "messageText", "comment"
        ];
        directKeys.forEach((key) => {
            pushText(metaMessage[key]);
        });

        const nestedKeys = ["data", "meta", "payload", "extra"];
        nestedKeys.forEach((bucket) => {
            const obj = metaMessage[bucket];
            if (!obj || typeof obj !== "object") return;
            directKeys.forEach((key) => {
                pushText(obj[key]);
            });
        });

        return Array.from(new Set(candidates));
    }

    function extractIntegerToken(textValue) {
        const text = String(textValue || "").replace(/\s+/g, " ").trim();
        if (!text) return null;

        const matches = text.matchAll(/-?\d+/g);
        for (const match of matches) {
            const token = String(match[0] || "");
            const idx = Number(match.index) || 0;
            const end = idx + token.length;
            const prev = text[idx - 1] || "";
            const prevPrev = text[idx - 2] || "";
            const next = text[end] || "";
            const nextNext = text[end + 1] || "";

            const decimalLeft = prev === "." && /\d/.test(prevPrev);
            const decimalRight = next === "." && /\d/.test(nextNext);
            if (decimalLeft || decimalRight) {
                continue;
            }

            const parsed = parseInt(token, 10);
            if (Number.isFinite(parsed)) {
                return parsed;
            }
        }

        return null;
    }

    function parseGiftHistoryRows(html) {
        const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
        const rows = Array.from(doc.querySelectorAll("table.data-table tbody tr"));

        return rows.map((row) => {
            const cells = Array.from(row.querySelectorAll("td"));
            if (cells.length < 5) {
                return null;
            }

            const sender = normalizeMessageContent(cells[0].textContent || "");
            const recipient = normalizeMessageContent(cells[1].textContent || "");
            const bonText = normalizeMessageContent(cells[2].textContent || "");
            const note = normalizeMessageContent(cells[3].textContent || "");
            const dtValue = String(cells[4].querySelector("time")?.getAttribute("datetime") || "");
            const createdAtMs = Date.parse(dtValue);

            const amount = parseFloat(String(bonText).replace(/,/g, ".").replace(/[^0-9.]/g, ""));
            const safeAmount = Number.isFinite(amount) ? amount : 0;

            return {
                sender,
                recipient,
                amount: safeAmount,
                amountInt: Math.floor(safeAmount),
                note,
                createdAtMs: Number.isFinite(createdAtMs) ? createdAtMs : 0
            };
        }).filter(Boolean);
    }

    async function fetchGiftHistoryRows(hostName) {
        const host = String(hostName || "").trim();
        if (!host) {
            return [];
        }

        const normalizedHost = normalizeUser(host);
        const now = Date.now();
        const cacheFresh = v2GiftHistoryCache.host === normalizedHost && (now - v2GiftHistoryCache.fetchedAt) < 12000;
        if (cacheFresh) {
            return v2GiftHistoryCache.rows;
        }

        if (v2GiftHistoryFetchPromise) {
            return v2GiftHistoryFetchPromise;
        }

        const url = `${window.location.origin}/users/${encodeURIComponent(host)}/gifts`;

        v2GiftHistoryFetchPromise = fetch(url, {
            method: "GET",
            credentials: "same-origin"
        })
            .then((response) => {
                if (!response.ok) {
                    throw new Error(`Gift history fetch failed: ${response.status}`);
                }
                return response.text();
            })
            .then((html) => parseGiftHistoryRows(html))
            .then((rows) => {
                v2GiftHistoryCache = {
                    host: normalizedHost,
                    fetchedAt: Date.now(),
                    rows
                };
                return rows;
            })
            .catch((error) => {
                console.warn("Nie udało się pobrać historii giftów Stawka większa niż pula BON:", error);
                return [];
            })
            .finally(() => {
                v2GiftHistoryFetchPromise = null;
            });

        return v2GiftHistoryFetchPromise;
    }

    function buildGiftHistoryKey(row) {
        return [
            normalizeUser(row?.sender || ""),
            normalizeUser(row?.recipient || ""),
            Math.floor(Number(row?.amount || 0)),
            Number(row?.createdAtMs || 0),
            normalizeMessageContent(row?.note || "")
        ].join("|");
    }

    async function resolveGiftEntryNumberFromHistory(gift, activeGiveaway) {
        const rows = await fetchGiftHistoryRows(activeGiveaway?.host);
        if (!rows.length) {
            return null;
        }

        const expectedSender = normalizeUser(gift?.gifter || "");
        const expectedRecipient = normalizeUser(activeGiveaway?.host || "");
        const expectedAmount = Math.floor(Number(gift?.amount || 0));
        const expectedCreatedAt = Date.parse(String(gift?.createdAt || ""));
        const hasExpectedTime = Number.isFinite(expectedCreatedAt);

        const candidates = rows
            .filter((row) => {
                if (normalizeUser(row.sender) !== expectedSender) return false;
                if (normalizeUser(row.recipient) !== expectedRecipient) return false;
                if (Math.floor(Number(row.amountInt || row.amount || 0)) !== expectedAmount) return false;
                return true;
            })
            .sort((a, b) => {
                if (hasExpectedTime) {
                    const deltaA = Math.abs((Number(a.createdAtMs) || 0) - expectedCreatedAt);
                    const deltaB = Math.abs((Number(b.createdAtMs) || 0) - expectedCreatedAt);
                    if (deltaA !== deltaB) {
                        return deltaA - deltaB;
                    }
                }
                return (Number(b.createdAtMs) || 0) - (Number(a.createdAtMs) || 0);
            });

        for (const row of candidates) {
            const key = buildGiftHistoryKey(row);
            if (v2ConsumedGiftHistoryKeys.has(key)) {
                continue;
            }

            const parsed = extractIntegerToken(row.note);
            if (!Number.isFinite(parsed)) {
                continue;
            }

            v2ConsumedGiftHistoryKeys.add(key);
            return parsed;
        }

        return null;
    }

    function resetV2GiftHistoryState() {
        v2GiftHistoryCache = {
            host: "",
            fetchedAt: 0,
            rows: []
        };
        v2GiftHistoryFetchPromise = null;
        v2ConsumedGiftHistoryKeys = new Set();
    }
    function parseGiftMessage(html, metaMessage = null) {
        try {
            const doc = new DOMParser().parseFromString(String(html || ""), "text/html");
            const links = Array.from(doc.querySelectorAll("a"));
            const rawText = normalizeMessageContent(doc.body.textContent || "");
            const match = rawText.match(/has gifted\s*([0-9.]+)\s*BON\s*to/i);

            if (!match || links.length < 2) {
                return null;
            }

            const gifter = (links[0].textContent || "").trim();
            const recipient = (links[1].textContent || "").trim();

            let messageText = "";
            if (recipient) {
                const idx = rawText.toLowerCase().indexOf(recipient.toLowerCase());
                if (idx >= 0) {
                    messageText = rawText.slice(idx + recipient.length).trim();
                }
            }

            messageText = messageText.replace(/^[\s:;,\-()]+/, "").trim();

            let trailingText = "";
            for (let node = links[1].nextSibling; node; node = node.nextSibling) {
                trailingText += ` ${String(node.textContent || "").trim()}`;
            }
            trailingText = trailingText.replace(/\s+/g, " ").replace(/^[\s:;,\-()]+/, "").trim();

            const attributeTexts = [];
            const attrElements = doc.querySelectorAll("[title],[data-original-title],[data-bs-original-title],[aria-label]");
            attrElements.forEach((el) => {
                const values = [
                    el.getAttribute("title"),
                    el.getAttribute("data-original-title"),
                    el.getAttribute("data-bs-original-title"),
                    el.getAttribute("aria-label")
                ];
                values.forEach((value) => {
                    const text = String(value || "").replace(/\s+/g, " ").trim();
                    if (!text) return;
                    attributeTexts.push(text);
                });
            });

            const metaTexts = collectGiftMetaText(metaMessage);

            return {
                gifter,
                recipient,
                amount: parseFloat(match[1]),
                rawText,
                messageText,
                trailingText,
                attributeTexts,
                metaTexts,
                createdAt: String(metaMessage?.created_at || "")
            };
        } catch (error) {
            return null;
        }
    }

    function extractGiftEntryNumber(gift) {
        const candidates = [
            String(gift?.messageText || ""),
            String(gift?.trailingText || ""),
            ...(Array.isArray(gift?.attributeTexts) ? gift.attributeTexts : []),
            ...(Array.isArray(gift?.metaTexts) ? gift.metaTexts : [])
        ];

        for (const candidate of candidates) {
            const text = String(candidate || "").replace(/\s+/g, " ").trim();
            if (!text) continue;

            const cleaned = text
                .replace(/.*?has gifted\s*[0-9]+(?:\.[0-9]+)?\s*BON\s*to\s*/i, "")
                .replace(/^[\s:;,\-()]+/, "")
                .trim();

            if (!cleaned) continue;

            const parsed = extractIntegerToken(cleaned);
            if (Number.isFinite(parsed)) {
                return parsed;
            }
        }

        return null;
    }

async function handleV2GiftEntry(gift, activeGiveaway) {
    const paidAmount = Math.floor(Number(gift.amount) || 0);
    const gifter = String(gift.gifter || "").trim();

    if (!gifter || paidAmount <= 0) {
        return;
    }

    if (normalizeUser(gift.recipient) !== normalizeUser(activeGiveaway.host)) {
        return;
    }

    const v2 = getV2Copy();

    if (normalizeUser(gifter) === normalizeUser(activeGiveaway.host)) {
        giftBon(gifter, paidAmount, "Stawka większa niż pula BON: host nie bierze udziału");
        sendMessage(v2.refundHost(gifter));
        return;
    }

    if (activeGiveaway.entriesPaused) {
        giftBon(gifter, paidAmount, "Stawka większa niż pula BON: zgłoszenia są wstrzymane");
        sendMessage(v2.refundPaused(gifter));
        return;
    }

    const requiredMinFee = getCurrentV2MinEntryFee(activeGiveaway);
    if (paidAmount < requiredMinFee) {
        giftBon(gifter, paidAmount, `Stawka większa niż pula BON: minimum wpisowego to ${requiredMinFee} BON`);
        sendMessage(v2.refundLow(gifter, paidAmount, requiredMinFee));
        return;
    }

    let selectedNumber = extractGiftEntryNumber(gift);
    if (!Number.isFinite(selectedNumber)) {
        selectedNumber = await resolveGiftEntryNumberFromHistory(gift, activeGiveaway);
    }

    if (!Number.isFinite(selectedNumber)) {
        giftBon(gifter, paidAmount, "Stawka większa niż pula BON: brak numeru w treści gifta");
        sendMessage(v2.refundNoNumber(gifter));
        return;
    }

    if (selectedNumber < activeGiveaway.startNum || selectedNumber > activeGiveaway.endNum) {
        giftBon(gifter, paidAmount, `Stawka większa niż pula BON: numer poza zakresem ${activeGiveaway.startNum}-${activeGiveaway.endNum}`);
        sendMessage(v2.refundOutOfRange(gifter, selectedNumber, activeGiveaway.startNum, activeGiveaway.endNum));
        return;
    }

    if (numberEntries.has(gifter)) {
        const current = numberEntries.get(gifter);
        giftBon(gifter, paidAmount, `Stawka większa niż pula BON: już grasz numerem ${current}`);
        sendMessage(v2.refundAlreadyJoined(gifter, current));
        return;
    }

    if (numberTakenBy.has(selectedNumber)) {
        const existingAuthor = numberTakenBy.get(selectedNumber);
        giftBon(gifter, paidAmount, `Stawka większa niż pula BON: numer ${selectedNumber} jest zajęty`);
        sendMessage(v2.refundTaken(gifter, selectedNumber, existingAuthor));
        return;
    }

    addNewEntry(gifter, escapeHTML(gifter), selectedNumber);

    activeGiveaway.amount += paidAmount;
    activeGiveaway.sponsorContribs[gifter] = (activeGiveaway.sponsorContribs[gifter] || 0) + paidAmount;

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

    updateCoinHeader(activeGiveaway.amount);
    updateStatsPanel(activeGiveaway);

    sendMessage(v2.entryAccepted({
        author: gifter,
        number: selectedNumber,
        paid: paidAmount,
        timeLeftMs: activeGiveaway.timeLeft * 1000,
        totalPot: activeGiveaway.amount,
        newMinEntryFee: getCurrentV2MinEntryFee(activeGiveaway)
    }));
}

async function handleGiftMessage(messageContent, activeGiveaway, metaMessage = null) {
    if (!activeGiveaway) return;

    const gift = parseGiftMessage(messageContent, metaMessage);
    if (!gift) {
        return;
    }

    if (isV2Mode(activeGiveaway)) {
        await handleV2GiftEntry(gift, activeGiveaway);
        return;
    }

    const addAmount = parseFloat(gift.amount);
    const gifter = gift.gifter;
    const recipient = gift.recipient;

    if (!gifter || !recipient || !Number.isFinite(addAmount) || addAmount <= 0) {
        return;
    }

    if (normalizeUser(recipient) !== normalizeUser(activeGiveaway.host)) {
        return;
    }

    activeGiveaway.amount += addAmount;
    activeGiveaway.sponsorContribs[gifter] = (activeGiveaway.sponsorContribs[gifter] || 0) + addAmount;

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

    updateCoinHeader(activeGiveaway.amount);
    updateStatsPanel(activeGiveaway);

    sponsorDigestBuffer.push({ gifter, amount: addAmount });
    maybeFlushSponsorDigest(activeGiveaway);
}

    async function getSponsors() {
    if (!giveawayData || !giveawayStartTime) return;

    const api = `${window.location.origin}/api/chat/messages/${getSponsorRoomId()}`;
    const startTimeTimestamp = giveawayStartTime.getTime();

    try {
        const response = await fetch(api, {
            method: 'GET',
            credentials: 'same-origin'
        });

        if (!response.ok) {
            throw new Error('Network response was not ok');
        }

        const data = await response.json();
        markApiStatus("sponsors", true);
        const systemMessages = Array.isArray(data.data) ? data.data : [];
        let processedAny = false;

        const filteredMessages = systemMessages
            .filter(message => {
                const messageTime = new Date(message.created_at).getTime();
                const giftMessage = String(message.message || "").includes("has gifted");
                return giftMessage && messageTime >= startTimeTimestamp;
            })
            .sort((a, b) => {
                const idA = Number(a?.id) || 0;
                const idB = Number(b?.id) || 0;
                if (idA !== idB) {
                    return idA - idB;
                }

                const timeA = new Date(a?.created_at).getTime();
                const timeB = new Date(b?.created_at).getTime();
                const safeTimeA = Number.isFinite(timeA) ? timeA : 0;
                const safeTimeB = Number.isFinite(timeB) ? timeB : 0;
                return safeTimeA - safeTimeB;
            });

        for (const msg of filteredMessages) {
            if (processedGiftMessages.has(msg.id)) {
                continue;
            }

            processedGiftMessages.add(msg.id);
            processedAny = true;
            await handleGiftMessage(msg.message, giveawayData, msg);
        }

        if (sponsorDigestBuffer.length > 0) {
            maybeFlushSponsorDigest(giveawayData);
        }

        if (processedAny) {
            saveState();
        }
    } catch (error) {
        markApiStatus("sponsors", false, error);
        console.error('There was a problem with the sponsor fetch operation:', error);
    }
}

    async function getEntriesFromApi() {
    if (!giveawayData || !giveawayStartTime) return;

    const api = `${window.location.origin}/api/chat/messages/${getEntriesRoomId()}`;
    const startTimeTimestamp = giveawayStartTime.getTime();

    try {
        const response = await fetch(api, {
            method: 'GET',
            credentials: 'same-origin'
        });

        if (!response.ok) {
            throw new Error('Network response was not ok');
        }

        const data = await response.json();
        markApiStatus("entries", true);
        const messages = Array.isArray(data.data) ? data.data : [];
        let processedAny = false;

        const filteredMessages = messages
            .filter(msg => {
                const messageTime = new Date(msg.created_at).getTime();
                return messageTime >= startTimeTimestamp;
            })
            .sort((a, b) => {
                const idA = Number(a?.id) || 0;
                const idB = Number(b?.id) || 0;
                if (idA !== idB) {
                    return idA - idB;
                }

                const timeA = new Date(a?.created_at).getTime();
                const timeB = new Date(b?.created_at).getTime();
                const safeTimeA = Number.isFinite(timeA) ? timeA : 0;
                const safeTimeB = Number.isFinite(timeB) ? timeB : 0;
                return safeTimeA - safeTimeB;
            });

        for (const msg of filteredMessages) {
            if (processedChatMessages.has(msg.id)) {
                continue;
            }

            processedChatMessages.add(msg.id);
            processedAny = true;

            if (!msg.user || !msg.message) {
                continue;
            }

            const author = String(msg.user.username || "").trim();
            const fancyName = escapeHTML(author);
            const messageContent = normalizeMessageContent(msg.message);

            if (!author || !messageContent) {
                continue;
            }

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

        if (processedAny) {
            saveState();
        }
    } catch (error) {
        markApiStatus("entries", false, error);
        console.error('There was a problem fetching giveaway entries:', error);
    }
}

    async function endGiveaway(activeGiveaway) {
        if (!activeGiveaway || activeGiveaway.ending) {
            return;
        }

        activeGiveaway.ending = true;
        startButton.disabled = true;

        if (activeGiveaway.countdownTimerID) {
            clearInterval(activeGiveaway.countdownTimerID);
            activeGiveaway.countdownTimerID = null;
        }

        if (entriesInterval) {
            clearInterval(entriesInterval);
            entriesInterval = null;
        }
        if (sponsorsInterval) {
            clearInterval(sponsorsInterval);
            sponsorsInterval = null;
        }

        await getSponsors();
        flushSponsorDigest(activeGiveaway, true);		

        if (numberEntries.size === 0) {
            sendMessage(CHAT_COPY.noEntriesEnd());
            finishGiveawayCleanup(activeGiveaway);
            return;
        }

        activeGiveaway.winningNumber = getRandomInt(activeGiveaway.startNum, activeGiveaway.endNum);

        const sponsors = getSortedSponsors(activeGiveaway);
        const isV2 = isV2Mode(activeGiveaway);
        const rankedSource = Array.from(numberEntries.entries())
            .filter(([author]) => !isV2 || normalizeUser(author) !== normalizeUser(activeGiveaway.host));

        if (rankedSource.length === 0) {
            sendMessage(CHAT_COPY.noEntriesEnd());
            finishGiveawayCleanup(activeGiveaway);
            return;
        }

        const rankedEntries = rankedSource
            .map(([author, guess], index) => ({
                author,
                guess,
                gap: Math.abs(activeGiveaway.winningNumber - guess),
                order: index,
            }))
            .sort((a, b) => a.gap - b.gap || a.order - b.order);

        const winnerCount = Math.min(activeGiveaway.winnersNum, rankedEntries.length);
        const winners = rankedEntries.slice(0, winnerCount);
        const payouts = calculatePayouts(activeGiveaway.amount, winnerCount);

        const winnersSummary = winners.map((winner, index) => {
    const payout = payouts[index] || 0;
    return `[color=#5DE2E7][b]#${index + 1}[/b][/color] ` +
      `[color=#d85e27][b]${sanitizeNick(winner.author)}[/b][/color] - ` +
      `[color=green][b]${winner.guess}[/b][/color] ` +
      `([b]różnica ${winner.gap}[/b]) - ` +
      `[color=#ffc00a][b]${formatBon(payout)} BON[/b][/color]`;
}).join(", ");

const hasTie = winners.some((winner, index) => index > 0 && winner.gap === winners[index - 1].gap);

const sponsorTotal = Object.values(activeGiveaway.sponsorContribs || {})
    .reduce((sum, value) => sum + value, 0);

const hostContributionText = cleanPotString(activeGiveaway.hostContribution || 0);
const sponsorTotalText = cleanPotString(sponsorTotal);

const summaryMessage = CHAT_COPY.finishSummary({
    winningNumber: activeGiveaway.winningNumber,
    winnersSummary,
    hostContributionText,
    sponsorTotalText,
    sponsors,
    hasTie
});

sendMessage(summaryMessage);

        const winnersToVerify = [];
        const payoutsToVerify = [];

        for (let i = 0; i < winners.length; i++) {
            const winner = winners[i];
            const payout = payouts[i] || 0;

            if (payout <= 0) {
                continue;
            }

            if (normalizeUser(winner.author) === normalizeUser(activeGiveaway.host)) {
                continue;
            }

            winnersToVerify.push(winner.author);
            payoutsToVerify.push(payout);
            giftBon(winner.author, payout, `Gratulacje! Zajales miejsce ${i + 1} w BON giveaway.`);
        }

        if (winnersToVerify.length > 0) {
            void verifyWinnerGifts(winnersToVerify, payoutsToVerify, activeGiveaway.host);
        }

        finishGiveawayCleanup(activeGiveaway);
    }

function finishGiveawayCleanup(activeGiveaway) {
    if (activeGiveaway && activeGiveaway.countdownTimerID) {
        clearInterval(activeGiveaway.countdownTimerID);
        activeGiveaway.countdownTimerID = null;
    }

    if (entriesInterval) {
        clearInterval(entriesInterval);
        entriesInterval = null;
    }

    if (sponsorsInterval) {
        clearInterval(sponsorsInterval);
        sponsorsInterval = null;
    }

    window.onbeforeunload = null;

    if (activeGiveaway) {
        activeGiveaway.ended = true;
        activeGiveaway.ending = false;
    }

    setRunningState(false);
    startButton.disabled = false;

    sponsorDigestBuffer = [];
    sponsorDigestWindowStartAt = 0;
    resetV2GiftHistoryState();
    updateCoinHeader(readHostBalance());

    entriesWrapper.hidden = true;
    countdownHeader.hidden = true;
    countdownHeader.textContent = "";

    giveawayData = null;

    clearSavedState();
	hideRestoreNotice();

    numberEntries = new Map();
    numberTakenBy = new Map();
    fancyNames = new Map();
    updateEntries();

    updateStatsPanel(null);
    resetApiStatus();
}

    function calculatePayouts(totalAmount, winnerCount) {
        totalAmount = Math.floor(Number(totalAmount) || 0);
        winnerCount = Math.floor(Number(winnerCount) || 0);

        if (winnerCount <= 0) return [];
        if (winnerCount === 1) return [totalAmount];

        const weights = [];
        for (let i = winnerCount; i >= 1; i--) {
            weights.push(i);
        }

        const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
        const payouts = weights.map(weight => Math.floor((totalAmount * weight) / totalWeight));

        let distributed = payouts.reduce((sum, amount) => sum + amount, 0);
        let remainder = totalAmount - distributed;

        if (remainder > 0) {
            payouts[0] += remainder;
        }

        return payouts;
    }

    async function verifyWinnerGifts(winners, payouts, hostName) {
        const expected = new Map();

        for (let i = 0; i < winners.length; i++) {
            const name = winners[i];
            const amount = payouts[i];
            if (!name || !amount) continue;
            expected.set(normalizeUser(name), { name, amount });
        }

        if (expected.size === 0) {
            return;
        }

        for (let attempt = 0; attempt < GENERAL_SETTINGS.verifyAttempts; attempt++) {
            try {
                const response = await fetch(`${window.location.origin}/api/chat/messages/${getSponsorRoomId()}`, {
                    method: 'GET',
                    credentials: 'same-origin',
                });

                if (response.ok) {
                    const data = await response.json();
                    const messages = Array.isArray(data.data) ? data.data : [];

                    for (const msg of messages) {
                        const gift = parseGiftMessage(msg.message);
                        if (!gift) continue;
                        if (normalizeUser(gift.gifter) !== normalizeUser(hostName)) continue;

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

                        if (Math.round(gift.amount) === Math.round(expectedGift.amount)) {
                            expected.delete(normalizeUser(gift.recipient));
                        }
                    }

                    if (expected.size === 0) {
                        return;
                    }
                }
            } catch (error) {
                console.error("Gift verification failed:", error);
            }

            await wait(GENERAL_SETTINGS.verifyDelayMs);
        }

        if (expected.size > 0 && DEBUG_SETTINGS.logChatMessages) {
		const missing = Array.from(expected.values())
        .map(item => `${item.name} (${formatBon(item.amount)} BON)`)
        .join(", ");
		console.warn("Nie udało się potwierdzic giftów po API dla:", missing);
		}
    }

    function giftBon(recipient, amount, messageText) {
        const safeRecipient = String(recipient || "").trim();
        const safeAmount = Math.max(1, Math.floor(Number(amount) || 0));
        const safeMessage = String(messageText || "").trim();

        if (!safeRecipient || !safeAmount) {
            return;
        }

        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
        const giftUrl = `${window.location.origin}/users/${encodeURIComponent(safeRecipient)}/gifts`;

        if (!csrfToken) {
            sendMessage(`/gift ${safeRecipient} ${safeAmount} ${safeMessage}`);
            return;
        }

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

        fetch(giftUrl, {
            method: "POST",
            credentials: "same-origin",
            body: formData
        }).then(response => {
            if (!response.ok) {
                sendMessage(`/gift ${safeRecipient} ${safeAmount} ${safeMessage}`);
            }
        }).catch(() => {
            sendMessage(`/gift ${safeRecipient} ${safeAmount} ${safeMessage}`);
        });
    }
	function sendReminderMessage(activeGiveaway) {
    if (isV2Mode(activeGiveaway)) {
        sendMessage(getV2Copy().reminder({
            amount: activeGiveaway.amount,
            timeLeftMs: activeGiveaway.timeLeft * 1000,
            winnersNum: activeGiveaway.winnersNum,
            startNum: activeGiveaway.startNum,
            endNum: activeGiveaway.endNum,
            minEntryFee: getCurrentV2MinEntryFee(activeGiveaway),
            host: activeGiveaway.host,
            dynamicMinEnabled: Boolean(activeGiveaway.dynamicMinEnabled),
            dynamicMinStep: Math.max(1, Math.floor(activeGiveaway.dynamicMinBase || activeGiveaway.minEntryFee || getCurrentV2MinEntryFee(activeGiveaway)))
        }));
        return;
    }

    sendMessage(CHAT_COPY.reminder({
        amount: activeGiveaway.amount,
        timeLeftMs: activeGiveaway.timeLeft * 1000,
        winnersNum: activeGiveaway.winnersNum,
        startNum: activeGiveaway.startNum,
        endNum: activeGiveaway.endNum,
        sponsorMessage: getSponsorMessage(activeGiveaway.host)
    }));
}

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

    const timerID = setInterval(() => {
        if (activeGiveaway.ending) {
            return;
        }

        const secondsLeft = Math.max(0, Math.ceil((activeGiveaway.endTs - Date.now()) / 1000));
        activeGiveaway.timeLeft = secondsLeft;

        const minutes = Math.floor(secondsLeft / 60);
        const seconds = secondsLeft % 60;

        display.textContent =
            String(minutes).padStart(2, "0") + ":" +
            String(seconds).padStart(2, "0");

        if (secondsLeft <= 0) {
            void endGiveaway(activeGiveaway);
            return;
        }

        if (activeGiveaway.totalEntries === numberEntries.size) {
            sendMessage(CHAT_COPY.earlyFinish(activeGiveaway.totalEntries, activeGiveaway.timeLeft * 1000));
            void endGiveaway(activeGiveaway);
            return;
        }

        if (
            activeGiveaway.reminderFreqSec > 0 &&
            secondsLeft > 0 &&
            secondsLeft % activeGiveaway.reminderFreqSec === 0
        ) {
             sendReminderMessage(activeGiveaway);
        }
    }, 1000);

    return timerID;
}

function normalizeForEmojiMatch(input) {
    return String(input || "")
        .toLowerCase()
        .normalize("NFD")
        .replace(/[\u0300-\u036f]/g, "")
        .replace(/\s+/g, " ")
        .trim();
}

function includesAnyKeyword(haystack, keywords) {
    for (const keyword of keywords) {
        if (haystack.includes(keyword)) {
            return true;
        }
    }
    return false;
}

function includesAllKeywords(haystack, keywords) {
    for (const keyword of keywords) {
        if (!haystack.includes(keyword)) {
            return false;
        }
    }
    return true;
}


const CHAT_EMOJI = Object.freeze({
    party: "\uD83C\uDF89",
    megaphone: "\uD83D\uDCE2",
    hourglass: "\u23F3",
    chart: "\uD83D\uDCCA",
    trophy: "\uD83C\uDFC6",
    money: "\uD83D\uDCB0",
    moneyPlus: "\uD83D\uDCB0\u2795",
    moneyMinus: "\uD83D\uDCB0\u2796",
    handshake: "\uD83E\uDD1D",
    receipt: "\uD83E\uDDFE",
    numbers: "\uD83D\uDD22",
    noEntry: "\uD83D\uDEAB",
    warning: "\u26A0\uFE0F",
    check: "\u2705",
    exclaim: "\u2757",
    dice: "\uD83C\uDFB2",
    sparkles: "\u2728",
    crown: "\uD83D\uDC51",
    partyFace: "\uD83E\uDD73",
    sad: "\uD83D\uDE14",
    pause: "\u23F8\uFE0F",
    play: "\u25B6\uFE0F",
    scroll: "\uD83D\uDCDC",
    package: "\uD83D\uDCE6",
    compass: "\uD83E\uDDED",
    rocket: "\uD83D\uDE80",
    alarm: "\u23F0",
    lock: "\uD83D\uDD12",
    id: "\uD83C\uDD94",
    seedling: "\uD83C\uDF31",
    up: "\u2B06\uFE0F",
    down: "\u2B07\uFE0F",
    gear: "\u2699\uFE0F",
    sleep: "\uD83D\uDCA4",
    moneyWing: "\uD83D\uDCB8",
    construction: "\uD83D\uDEA7",
    skull: "\uD83D\uDC80"
});

function getTaggedEmoji(message) {
    const match = String(message || "").match(/^\[([^\]]+)\]/i);
    if (!match) return "";

    const tag = (match[1] || "").trim().toUpperCase();
    const map = {
        "LOOT": CHAT_EMOJI.package,
        "EXPLORE": CHAT_EMOJI.compass,
        "VICTORY": CHAT_EMOJI.trophy,
        "STATUS": CHAT_EMOJI.chart,
        "BOOT": CHAT_EMOJI.rocket,
        "TIMER": CHAT_EMOJI.hourglass,
        "QUEST": CHAT_EMOJI.compass,
        "CLOCK": CHAT_EMOJI.alarm,
        "LOCK": CHAT_EMOJI.lock,
        "PAUSE": CHAT_EMOJI.pause,
        "CAP": CHAT_EMOJI.noEntry,
        "ID": CHAT_EMOJI.id,
        "SEED": CHAT_EMOJI.seedling,
        "CRIT": CHAT_EMOJI.sparkles,
        "MAP": CHAT_EMOJI.compass,
        "UPGRADE": CHAT_EMOJI.up,
        "ROLLBACK": CHAT_EMOJI.down,
        "CONFIG": CHAT_EMOJI.gear,
        "AFK": CHAT_EMOJI.sleep,
        "ECONOMY": CHAT_EMOJI.moneyWing,
        "GUILD": CHAT_EMOJI.handshake,
        "BANK": CHAT_EMOJI.money,
        "BANK++": CHAT_EMOJI.money,
        "ZONE": CHAT_EMOJI.numbers,
        "BOUNDARY": CHAT_EMOJI.construction,
        "COLLISION": CHAT_EMOJI.warning,
        "JOIN": CHAT_EMOJI.check,
        "GAME OVER": CHAT_EMOJI.skull
    };

    return map[tag] || "";
}

function decorateChatMessage(messageStr) {
    const raw = String(messageStr || "");
    const trimmed = raw.trim();

    if (!trimmed) return raw;
    if (trimmed.startsWith("/")) return raw;

    const knownPrefixes = [
        CHAT_EMOJI.party,
        CHAT_EMOJI.megaphone,
        CHAT_EMOJI.hourglass,
        CHAT_EMOJI.chart,
        CHAT_EMOJI.trophy,
        CHAT_EMOJI.money,
        CHAT_EMOJI.moneyPlus,
        CHAT_EMOJI.moneyMinus,
        CHAT_EMOJI.handshake,
        CHAT_EMOJI.receipt,
        CHAT_EMOJI.numbers,
        CHAT_EMOJI.noEntry,
        CHAT_EMOJI.warning,
        CHAT_EMOJI.check,
        CHAT_EMOJI.exclaim,
        CHAT_EMOJI.dice,
        CHAT_EMOJI.sparkles,
        CHAT_EMOJI.crown,
        CHAT_EMOJI.partyFace,
        CHAT_EMOJI.sad,
        CHAT_EMOJI.pause,
        CHAT_EMOJI.play,
        CHAT_EMOJI.scroll,
        CHAT_EMOJI.package,
        CHAT_EMOJI.compass,
        CHAT_EMOJI.rocket,
        CHAT_EMOJI.alarm,
        CHAT_EMOJI.lock,
        CHAT_EMOJI.id,
        CHAT_EMOJI.seedling,
        CHAT_EMOJI.up,
        CHAT_EMOJI.down,
        CHAT_EMOJI.gear,
        CHAT_EMOJI.sleep,
        CHAT_EMOJI.moneyWing,
        CHAT_EMOJI.construction,
        CHAT_EMOJI.skull
    ];
    const alreadyDecorated = knownPrefixes.some((emoji) => trimmed.startsWith(`${emoji} `));
    if (alreadyDecorated) return raw;

    const taggedEmoji = getTaggedEmoji(trimmed);
    if (taggedEmoji) {
        return `${taggedEmoji} ${raw}`;
    }

    const normalized = normalizeForEmojiMatch(trimmed.replace(/\[[^\]]*\]/g, " "));

    const rules = [
        { emoji: CHAT_EMOJI.party, any: ["organizuje giveaway", "startujemy giveaway", "odpalam giveaway", "wystartowal", "wystartowala"] },
        { emoji: CHAT_EMOJI.megaphone, any: ["trwa giveaway", "giveaway trwa", "przypominajka dla spoznialskich", "przypomnienie", "event aktywny"] },
        { emoji: CHAT_EMOJI.chart, any: ["status giveaway", "status w pigulce", "status trybu", "raport dla wytrwalych", "[status]"] },
        { emoji: CHAT_EMOJI.hourglass, any: ["pozostaly czas giveaway", "pozostaly czas:", "na zegarze zostalo jeszcze", "do konca sesji"] },
        { emoji: CHAT_EMOJI.moneyPlus, any: ["host dodal", "host dodaje", "host dorzuca", "host dorzucil"] },
        { emoji: CHAT_EMOJI.moneyMinus, any: ["host odjal", "host usuwa", "host zdejmuje", "host skrocil o"] },
        { emoji: CHAT_EMOJI.pause, any: ["wstrzymal przyjmowanie nowych zgloszen", "zgloszenia sa juz wstrzymane", "zatrzymal nowe zgloszenia"] },
        { emoji: CHAT_EMOJI.play, any: ["wznowil przyjmowanie nowych zgloszen", "zgloszenia sa juz aktywne", "wznowil zgloszenia"] },
        { emoji: CHAT_EMOJI.scroll, any: ["dostepne komendy", "komendy:"] },
        { emoji: CHAT_EMOJI.money, any: ["aktualna pula giveaway", "laczna pula wynosi teraz", "aktualna pula:", "pula rosnie do", "jak dodac bon", "jak dorzucic bon", "pula rosnie do"] },
        { emoji: CHAT_EMOJI.numbers, any: ["zakres numerow"] },
        { emoji: CHAT_EMOJI.receipt, any: ["zgloszenia ("] },
        { emoji: CHAT_EMOJI.handshake, any: ["sponsorzy tego giveaway", "sponsorzy wlasnie dorzucili", "sponsorzy dorzucili", "sponsorzy (", "padl zrzut"] },
        { emoji: CHAT_EMOJI.dice, any: ["wolne numery", "szczesliwy numer giveaway", "aktualny szczesliwy numer"] },
        { emoji: CHAT_EMOJI.check, all: ["wszystkie", "miejsca zostaly zajete"] },
        { emoji: CHAT_EMOJI.trophy, any: ["zwycieski numer to"] },
        { emoji: CHAT_EMOJI.crown, any: ["host znalazl sie wsrod zwyciezcow"] },
        { emoji: CHAT_EMOJI.sad, any: ["niestety nikt nie wzial udzialu", "zero zgloszen", "brak graczy"] },
        { emoji: CHAT_EMOJI.warning, any: ["spam wykryty"] },
        { emoji: CHAT_EMOJI.noEntry, any: ["przepraszam", "nie ma juz wolnych numerow", "wolnych numerow juz brak"] }
    ];

    for (const rule of rules) {
        if (rule.all && includesAllKeywords(normalized, rule.all)) {
            return `${rule.emoji} ${raw}`;
        }

        if (rule.any && includesAnyKeyword(normalized, rule.any)) {
            return `${rule.emoji} ${raw}`;
        }
    }

    return raw;
}

function applySarkazmFlavor(messageStr) {
    const base = String(messageStr || "").trim();
    if (!base) return String(messageStr || "");
    return base;
}
function sendMessage(messageStr) {
        const effectiveProfile = getEffectiveChatProfile(activeChatProfile, activeGameMode);
        const sourceMessage = (effectiveProfile === "sarkazm") ? applySarkazmFlavor(messageStr) : messageStr;
        const outgoingMessage = decorateChatMessage(sourceMessage);
        if (!DEBUG_SETTINGS.disableChatOutput) {
            if (!chatbox) {
                chatbox = document.querySelector("#chatbox__messages-create");
            }

            if (!chatbox) {
                console.error("Nie znaleziono pola czatu.");
                return;
            }

            const nativeSetter =
                Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")?.set ||
                Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;

            if (nativeSetter) {
                nativeSetter.call(chatbox, outgoingMessage);
            } else {
                chatbox.value = outgoingMessage;
            }

            chatbox.dispatchEvent(new Event("input", { bubbles: true }));
            chatbox.dispatchEvent(new Event("change", { bubbles: true }));

            const enterOptions = {
                key: "Enter",
                code: "Enter",
                keyCode: 13,
                which: 13,
                bubbles: true,
                cancelable: true
            };

            chatbox.dispatchEvent(new KeyboardEvent("keydown", enterOptions));
            chatbox.dispatchEvent(new KeyboardEvent("keypress", enterOptions));
            chatbox.dispatchEvent(new KeyboardEvent("keyup", enterOptions));
        }

        if (DEBUG_SETTINGS.logChatMessages) {
            console.log(outgoingMessage);
        }
    }

    function getLuckyNumber(activeGiveaway) {
        let rangeStart = activeGiveaway.startNum;
        let rangeEnd = activeGiveaway.endNum;

        let numbers = Array.from(numberEntries.values()).sort((a, b) => a - b);
        numbers.push(rangeEnd + 1);

        let bestGap = 0;
        let lucky = rangeStart;
        let pastNum = rangeStart - 1;

        for (let i = 0; i < numbers.length; i++) {
            const currentNum = numbers[i];
            const gap = currentNum - pastNum;
            if (gap > bestGap) {
                lucky = Math.floor((gap - 1) / 2) + pastNum + 1;
                bestGap = gap;
            }
            pastNum = currentNum;
        }

        return lucky;
    }

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

    function parseTime(timeInMs) {
        const totalSeconds = Math.max(0, Math.floor(timeInMs / 1000));
        const hours = Math.floor(totalSeconds / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const seconds = Math.floor(totalSeconds % 60);

        let timeString = ``;

        if (hours > 0) {
            timeString += `${hours} godz.`;
        }
        if (minutes > 0) {
            if (timeString !== ``) {
                timeString += `, `;
            }
            timeString += `${minutes} min.`;
        }
        if (seconds > 0 || timeString === ``) {
            if (timeString !== ``) {
                timeString += `, `;
            }
            timeString += `${seconds} sek.`;
        }

        return timeString;
    }

    function resetGiveaway(skipConfirm = false) {
    const hasAnythingToReset =
        !!giveawayData ||
        numberEntries.size > 0 ||
        sponsorDigestBuffer.length > 0 ||
        (countdownHeader && countdownHeader.textContent) ||
        (coinInput && String(coinInput.value || "").trim() !== "");

    if (!skipConfirm && hasAnythingToReset) {
        if (!window.confirm("Na pewno zresetowac giveaway?")) {
            return;
        }
    }

    if (giveawayData && giveawayData.countdownTimerID) {
        clearInterval(giveawayData.countdownTimerID);
    }
    if (entriesInterval) {
        clearInterval(entriesInterval);
        entriesInterval = null;
    }
    if (sponsorsInterval) {
        clearInterval(sponsorsInterval);
        sponsorsInterval = null;
    }

    window.onbeforeunload = null;
    closeCommandsMenu();
	hideRestoreNotice();
    clearSavedState();

    processedGiftMessages = new Set();
    processedChatMessages = new Set();
    giveawayStartTime = null;
    giveawayData = null;
    sponsorDigestBuffer = [];
    sponsorDigestWindowStartAt = 0;
    resetV2GiftHistoryState();

    numberEntries = new Map();
    numberTakenBy = new Map();
    fancyNames = new Map();

    entriesWrapper.hidden = true;
    countdownHeader.hidden = true;
    countdownHeader.textContent = "";

    setRunningState(false);

    coinInput.disabled = false;
    startInput.disabled = false;
    endInput.disabled = false;
    timerInput.disabled = false;
    reminderInput.disabled = false;
    winnersInput.disabled = false;

    giveawayForm.reset();
    timerInput.value = "15";
    reminderInput.value = "2";
    winnersInput.value = "1";
    if (entryFeeInput) {
        entryFeeInput.value = "50";
    }
    startInput.value = "1";
    endInput.value = "50";

    updateEntries();
    updateCoinHeader(readHostBalance());
    updateStatsPanel(null);
    resetApiStatus();
    applyChatProfile(activeChatProfile, false);
    applyGameMode(activeGameMode, false, true);

    userCooldown.clear();
    userCommandLog.clear();
    userLastActionAt.clear();
    userLastCommandAt.clear();
    userSpamStrikes.clear();
    userFeedbackCooldown.clear();
}

    function getSortedSponsors(activeGiveaway) {
        return Object.entries(activeGiveaway.sponsorContribs || {})
            .map(([name, amount]) => ({ name, amount }))
            .sort((a, b) => b.amount - a.amount || a.name.localeCompare(b.name));
    }

    function getFreeNumbers(activeGiveaway) {
        const freeNumbers = [];
        for (let number = activeGiveaway.startNum; number <= activeGiveaway.endNum; number++) {
            if (!numberTakenBy.has(number)) {
                freeNumbers.push(number);
            }
        }
        return freeNumbers;
    }

    function getFreeNumberSample(activeGiveaway, limit = 5) {
        if (!activeGiveaway) return [];

        const startNum = activeGiveaway.startNum;
        const endNum = activeGiveaway.endNum;
        const totalSlots = endNum - startNum + 1;

        if (totalSlots <= 0) return [];

        const taken = new Set(numberEntries.values());

        if (totalSlots > 100000 && taken.size / totalSlots < 0.01) {
            const sample = new Set();
            let attempts = 0;
            const maxAttempts = 1000;

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

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

        const freeNumbers = [];
        for (let number = startNum; number <= endNum; number++) {
            if (!taken.has(number)) {
                freeNumbers.push(number);
            }
        }

        if (!freeNumbers.length) return [];

        const actualSampleSize = Math.min(limit, 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]];
        }

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

    function formatFreeNumberSuggestion(activeGiveaway) {
        const sample = getFreeNumberSample(activeGiveaway, 5);
        if (sample.length === 0) {
            return "";
        }
        return ` Wolne przyklady: [b][color=green]${sample.join(", ")}[/color][/b].`;
    }

    function updateCoinHeader(amount) {
        if (!coinHeader) return;
        coinHeader.textContent = `${formatBon(amount)} BON`;
        coinHeader.prepend(goldCoins.cloneNode(false));
    }

    function readHostBalance() {
        const points = document.getElementsByClassName("ratio-bar__points")[0];
        if (!points) return 0;

        const rawText = points.textContent || "";
        const digits = rawText.replace(/[^\d]/g, '');
        const parsed = parseInt(digits, 10);

        return Number.isNaN(parsed) ? 0 : parsed;
    }

    function getLoggedInUsername() {
    const navLink =
        document.querySelector('.top-nav__username a[href*="/users/"]') ||
        document.querySelector('.top-nav__username a');

    if (navLink) {
        const linkText = String(navLink.textContent || "")
            .replace(/[\u200B\u200C\u200D\uFEFF]/g, "")
            .trim();
        if (linkText) {
            return linkText;
        }

        const href = navLink.getAttribute("href") || navLink.href || "";
        const match = href.match(/\/users\/([^/?#]+)/i);
        if (match && match[1]) {
            try {
                return decodeURIComponent(match[1]);
            } catch (error) {
                return match[1];
            }
        }
    }

    const topNavText = String(document.querySelector('.top-nav__username')?.textContent || "")
        .replace(/[\u200B\u200C\u200D\uFEFF]/g, "")
        .trim();

    return topNavText || "";
}

    function normalizeMessageContent(value) {
        return String(value || "")
            .replace(/<[^>]*>/g, "")
            .replace(/[\u200B\u200C\u200D\uFEFF]/g, "")
            .trim();
    }

    function normalizeUser(name) {
    return String(name || "")
        .replace(/[\u200B\u200C\u200D\uFEFF]/g, "")
        .trim()
        .toLowerCase();
}

    function copyableNick(nick) {
        return String(nick || "")
            .replace(/[\u200B\u200C\u200D\uFEFF]/g, "")
            .trim();
    }

    function sanitizeNick(nick) {
        if (typeof nick !== "string" || nick.length < 2) return nick;
        return nick[0] + "\u200B" + nick.slice(1);
    }

    function escapeHTML(value) {
        return String(value || "")
            .replace(/&/g, "&amp;")
            .replace(/</g, "&lt;")
            .replace(/>/g, "&gt;")
            .replace(/"/g, "&quot;")
            .replace(/'/g, "&#39;");
    }

    function parsePositiveInt(value) {
        const cleaned = String(value || "").replace(/[^\d]/g, "");
        const parsed = parseInt(cleaned, 10);
        return Number.isNaN(parsed) ? 0 : parsed;
    }

    function formatBon(value) {
        const numeric = Math.floor(Number(value) || 0);
        return numeric.toLocaleString('en-GB');
    }

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

    function wait(ms) {
        return new Promise(resolve => setTimeout(resolve, ms));
    }
	
    function canSendUserFeedback(author, bucket, cooldownMs = ENTRY_FEEDBACK_COOLDOWN_MS) {
        const now = Date.now();
        const authorKey = String(author || "").toLowerCase();
        if (!authorKey) return true;

        const key = `${authorKey}::${bucket}`;
        const last = userFeedbackCooldown.get(key) || 0;

        if (now - last < cooldownMs) {
            return false;
        }

        userFeedbackCooldown.set(key, now);
        return true;
    }

    function applyCooldown(author, opts = {}) {
        const now = Date.now();
        const rawAuthor = String(author || "");
        const authorKey = rawAuthor.toLowerCase();

        if (!authorKey) return false;

        const lockoutExpires = userCooldown.get(authorKey) || 0;
        if (now < lockoutExpires) {
            return true;
        }

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

        const lastAny = userLastActionAt.get(authorKey) || 0;
        const tooFast = (now - lastAny) < MIN_ACTION_GAP_MS;
        userLastActionAt.set(authorKey, now);

        let repeatBlocked = false;
        const cmd = String(opts.command || "").trim().toLowerCase();

        if (cmd) {
            const cooldownMs = Number(REPEAT_COMMAND_COOLDOWNS_MS[cmd]) || 0;
            if (cooldownMs > 0) {
                const key = `${authorKey}::${cmd}`;
                const lastCmd = userLastCommandAt.get(key) || 0;
                repeatBlocked = (now - lastCmd) < cooldownMs;
                userLastCommandAt.set(key, now);
            }
        }

        if (log.length > MAX_COMMANDS_PER_WINDOW) {
            const excess = log.length - MAX_COMMANDS_PER_WINDOW;
            const prev = userSpamStrikes.get(authorKey) || { count: 0, lastAt: 0 };

            if (now - prev.lastAt < STRIKE_WINDOW_MS) {
                prev.count += 1;
            } else {
                prev.count = 1;
            }

            prev.lastAt = now;
            userSpamStrikes.set(authorKey, prev);

            const multiplier = Math.min(
                MAX_STRIKE_MULTIPLIER,
                Math.pow(2, Math.max(0, prev.count - 1))
            );

            const penaltySec = Math.max(1, Math.round(BASE_PENALTY_SECONDS * excess * multiplier));

            userCooldown.set(authorKey, now + penaltySec * 1000);
            userCommandLog.delete(authorKey);

            if (canSendUserFeedback(rawAuthor, "spam-lockout", 60000)) {
                sendMessage(CHAT_COPY.spamDetected(rawAuthor, penaltySec));
            }

            return true;
        }

        return tooFast || repeatBlocked;
    }

    function maybeFlushSponsorDigest(activeGiveaway, force = false) {
        if (!activeGiveaway || !sponsorDigestBuffer.length) return;

        if (SPONSOR_ANNOUNCE.mode === "off") {
            sponsorDigestBuffer = [];
            sponsorDigestWindowStartAt = 0;
            return;
        }

        const now = Date.now();

        if (!sponsorDigestWindowStartAt) {
            sponsorDigestWindowStartAt = now;
        }

        if (SPONSOR_ANNOUNCE.mode === "immediate") {
            flushSponsorDigest(activeGiveaway, true);
            return;
        }

        const deltaTotalNum = sponsorDigestBuffer.reduce((sum, item) => sum + (Number(item.amount) || 0), 0);
        const hasBigSingle = sponsorDigestBuffer.some(item => (Number(item.amount) || 0) >= SPONSOR_ANNOUNCE.immediateSingleMin);
        const tooManyEvents = sponsorDigestBuffer.length >= SPONSOR_ANNOUNCE.maxPendingEvents;
        const hitMinTotal = deltaTotalNum >= SPONSOR_ANNOUNCE.flushMinTotal;
        const hitTime = (now - sponsorDigestWindowStartAt) >= SPONSOR_ANNOUNCE.digestMs;

        if (force || hasBigSingle || tooManyEvents || hitMinTotal || hitTime) {
            flushSponsorDigest(activeGiveaway, true);
        }
    }

    function flushSponsorDigest(activeGiveaway, force = false) {
        if (!activeGiveaway || !sponsorDigestBuffer.length) return;
        if (!force && SPONSOR_ANNOUNCE.mode !== "immediate" && SPONSOR_ANNOUNCE.mode !== "digest") return;

        const grouped = sponsorDigestBuffer.reduce((acc, item) => {
            const name = String(item.gifter || "").trim();
            const amount = Number(item.amount) || 0;

            if (!name || amount <= 0) return acc;

            acc[name] = (acc[name] || 0) + amount;
            return acc;
        }, {});

        const entries = Object.entries(grouped)
            .map(([name, amount]) => ({ name, amount }))
            .filter(item => item.amount > 0)
            .sort((a, b) => b.amount - a.amount);

        const sponsorCount = entries.length;
        const deltaTotalNum = entries.reduce((sum, item) => sum + item.amount, 0);

        if (!sponsorCount || !deltaTotalNum) {
            sponsorDigestBuffer = [];
            sponsorDigestWindowStartAt = 0;
            return;
        }

        const shown = [];
        const topN = Math.max(0, Number(SPONSOR_ANNOUNCE.showTopN) || 0);
        const minPerUser = Math.max(0, Number(SPONSOR_ANNOUNCE.showMinPerUser) || 0);

        for (const item of entries) {
            if (shown.length >= topN) break;
            if (sponsorCount > 1 && item.amount < minPerUser) continue;
            shown.push(item);
        }

        const parts = shown.map(item =>
          `[color=green][b]${sanitizeNick(item.name)}[/b][/color] ` +
          `([color=#ffc00a][b]${cleanPotString(item.amount)}[/b][/color])`
        );

        const othersCount = Math.max(0, sponsorCount - shown.length);

        sendMessage(CHAT_COPY.sponsorDigest({
            deltaTotalNum,
            sponsorCount,
            parts,
            othersCount,
            totalPot: activeGiveaway.amount
        }));

        sponsorDigestBuffer = [];
        sponsorDigestWindowStartAt = 0;
    }
	
	function recalculateReminderFrequency(activeGiveaway) {
    if (!activeGiveaway) return;

    if (!activeGiveaway.reminderNum || activeGiveaway.reminderNum <= 0) {
        activeGiveaway.reminderFreqSec = 0;
        return;
    }

    activeGiveaway.reminderFreqSec = Math.max(
        1,
        Math.round(activeGiveaway.timeLeft / (activeGiveaway.reminderNum + 1))
    );
}
})();