Bounty Hunter

Live Torn bounty board filter — min reward, FFScouter fair-fight range, Okay/Hospital status, med-out watchlist — with clickable attack toasts. Desktop + Torn PDA.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Bounty Hunter
// @namespace    https://github.com/eugene-torn-scripts/bounty-hunter
// @version      1.21.0
// @description  Live Torn bounty board filter — min reward, FFScouter fair-fight range, Okay/Hospital status, med-out watchlist — with clickable attack toasts. Desktop + Torn PDA.
// @author       lannav
// @match        https://www.torn.com/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      api.torn.com
// @connect      ffscouter.com
// @connect      eugene-torn-donors.sytnik-evhen.workers.dev
// @license      GPL-3.0-or-later
// ==/UserScript==

/*
 * Bounty Hunter
 * Copyright (C) 2026 lannav
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details: https://www.gnu.org/licenses/gpl-3.0.html
 *
 * Source: https://github.com/eugene-torn-scripts/bounty-hunter
 */

/* eslint-disable no-undef */

(function () {
    "use strict";

    // ════════════════════════════════════════════════════════════
    //  CONSTANTS & CONFIG
    // ════════════════════════════════════════════════════════════

    // Torn PDA substitutes this literal at load time with the user's public key.
    // On desktop / non-PDA, it stays as the placeholder and we fall back to
    // localStorage (optionally reusing Supply Pack Analyzer's saved key).
    const PDA_API_KEY = "###PDA-APIKEY###";
    const PDA_PLACEHOLDER = "###" + "PDA-APIKEY" + "###"; // split to avoid self-substitution

    const VERSION = "1.21.0";
    const LS = {
        apiKey:    "bh_apiKey",
        ffKey:     "bh_ffscouterKey",
        settings:  "bh_settings",
        shared:    "bh_shared",      // cross-tab: last refresh result (matches + metadata)
        debug:     "bh_debug",       // "1" when the debug log is on; undefined/"" otherwise
        donorCache:"bh_donorCache",  // { userId, donor, lastDonationTs, fetchedAt } — 6h client cache
        donorAck:  "bh_donorAck",    // unix-ts of last donation the user dismissed the banner for
        userId:    "bh_userId",      // resolved Torn user id (cached so DonorClient doesn't race Hunter.start)
        blacklist: "bh_blacklist",   // { "<id>": { name, note, addedAt } } — players excluded from matches
        watchlist: "bh_watchlist",   // { "<id>": { name, reward, lastState, addedAt } } — med-out watch targets
        apiCallPrefix: "bh_apiCalls_", // per-tab ring of recent API-call timestamps; summed cross-tab for the rate counter
    };
    // Per-tab key so the rolling counter sums across tabs without a read-modify-write race.
    const TAB_ID = Math.random().toString(36).slice(2, 10);
    // Donor "thank-you" banner worker. Read-only, takes only the Torn user id, returns { donor, lastDonationTs }.
    const DONOR_API_BASE = "https://eugene-torn-donors.sytnik-evhen.workers.dev";
    // Free-ride on another tab's result while it's within 0.8× the refresh window (clock-drift headroom).
    const SHARED_FRESH_RATIO = 0.8;
    const SPA_LS_APIKEY = "spa_apiKey"; // reuse SPA's key on desktop if present

    // ════════════════════════════════════════════════════════════
    //  DEBUG LOG — opt-in ring buffer surfaced in the Settings tab.
    // ════════════════════════════════════════════════════════════

    const debugLog = [];
    const MAX_DEBUG_LOG = 150;
    // Pub-sub so the Settings tab re-renders the log as entries land.
    const debugBus = document.createElement("div");

    function isDebugOn() {
        try { return localStorage.getItem(LS.debug) === "1"; } catch { return false; }
    }

    // Redact key= so API keys never land in a log the user might screenshot or paste.
    function redactUrl(url) {
        try {
            const u = new URL(url);
            const params = new URLSearchParams(u.search);
            for (const k of ["key", "apikey"]) {
                if (params.has(k)) params.set(k, "***");
            }
            const q = params.toString();
            return `${u.host}${u.pathname}${q ? "?" + q : ""}`;
        } catch {
            return url;
        }
    }

    // `level` is one of "info" | "ok" | "warn" | "err" — drives the row colour.
    function logDebug(label, level = "info", ms = null) {
        if (!isDebugOn()) return;
        debugLog.push({
            ts: new Date().toISOString().slice(11, 19),
            label,
            level,
            ms: (typeof ms === "number") ? Math.round(ms) : null,
        });
        if (debugLog.length > MAX_DEBUG_LOG) debugLog.shift();
        debugBus.dispatchEvent(new CustomEvent("entry"));
    }

    const API_BASE = "https://api.torn.com/v2";
    const FF_BASE  = "https://ffscouter.com/api/v1";
    const API_DELAY_MS = 750;
    // Torn auto-credits the bounty on hospitalisation, so no &bounty param is needed.
    const ATTACK_URL  = "https://www.torn.com/page.php?sid=attack&user2ID=";
    const PROFILE_URL = "https://www.torn.com/profiles.php?XID=";

    const DEFAULT_SETTINGS = {
        minPrice: 500_000,
        minFF:    1.0,
        maxFF:    3.0,
        hospitalMaxMin: 5,
        // When on, hospitalised targets match regardless of remaining hospital
        // time — the "Hospital max" minutes cap is ignored. Default off so the
        // existing behaviour (queue only near-out targets) is unchanged.
        hospNoLimit: false,
        // Country filter. Default on = only surface targets in the same country
        // as you (reachable from an attack page right now). Off includes
        // different-country targets and in-transit ("Traveling"/"Abroad") ones —
        // not attackable until you're co-located, shown for planning/awareness.
        sameCountryOnly: true,
        refreshSec: 60,
        // Poll interval (s) per watched target; 1 request each. Counter warns near ~100/min.
        watchlistIntervalSec: 5,
        toastsEnabled: true,
        includeUnknownFF: false,
        // ⚔ badge for targets in an active ranked war. Adds one cached API call per faction. Off by default.
        warStatus: false,
        warStatusCacheMin: 15,
        // Master kill-switch: off disables auto-refresh + free-ride; manual refresh still works.
        searchEnabled: true,
        // Skip refresh when energy too low to attack. Needs a Minimal+ key (/user/bars).
        pauseOnLowEnergy: false,
        minEnergy: 25, // standard attack = 25 energy; 0 == disabled
        // Pause refresh on our own status. Free (from the profile call). "Abroad"/landed stays attackable, so not gated.
        pauseInHospital: false,
        pauseInJail: false,
        pauseWhileTraveling: false,
        notifications: { // nested so untouched users keep defaults
            position: "bottom-right", // bottom-right | bottom-left | top-right | top-left
            width: 300,               // desktop only — mobile is full-width
            maxVisible: 3,            // stack cap before "+N more"
            timeoutSec: 15,
            fields: {
                level: true,
                reward: true,
                ff: true,
                bs: true,
                status: true,
                location: true,       // 📍 marker when target is in a different country
                blacklist: false,
            },
            sound: {
                enabled: false,
                type: "chime",        // chime | beep | ding | alert
                volume: 0.5,          // 0..1
            },
        },
    };

    // Torn New Player Protection (wiki): non-NPP can't hit NPP; both-NPP ok only after target's first 24h. War NPP-lift ignored.
    const NPP_DAYS = 14;
    function isAttackableByAge(targetAge, myAge) {
        if (targetAge == null) return true; // unknown → don't drop, Torn rejects on attack if any
        const meUnderNPP = (myAge != null) && (myAge < NPP_DAYS);
        if (meUnderNPP) {
            return targetAge >= 1; // past the 24h hard block
        }
        return targetAge >= NPP_DAYS;
    }

    // Physical country from Torn v2 status, to drop unreachable targets.
    // Unknown hospital adjective / Traveling → null, so the country filter falls open (never silently drop).
    const HOSPITAL_ADJ_TO_COUNTRY = {
        "Mexican": "Mexico",
        "Caymanian": "Cayman Islands",
        "Canadian": "Canada",
        "Hawaiian": "Hawaii",
        "British": "United Kingdom",
        "Argentinian": "Argentina",
        "Argentine": "Argentina",
        "Swiss": "Switzerland",
        "Japanese": "Japan",
        "Chinese": "China",
        "Emirati": "United Arab Emirates",
        "South African": "South Africa",
    };
    function getPlayerCountry(status) {
        if (!status || typeof status !== "object") return null;
        const state = status.state;
        const desc = status.description || "";
        if (state === "Okay" || state === "Jail" || state === "Federal") return "Torn";
        if (state === "Abroad") {
            const m = /^In\s+(.+)$/.exec(desc);
            return m ? m[1].trim() : null;
        }
        if (state === "Hospital") {
            if (/^In hospital\b/i.test(desc)) return "Torn";
            const m = /^In an?\s+(.+?)\s+hospital\b/i.exec(desc);
            if (m) {
                const adj = m[1].trim();
                return HOSPITAL_ADJ_TO_COUNTRY[adj] || null;
            }
            return null;
        }
        return null; // Traveling, or unknown state
    }

    const STATUS_CACHE_MS = 20_000;
    const STATUS_CONCURRENCY = 3;

    const IS_PDA = typeof PDA_httpGet === "function";
    const HAS_PDA_KEY = PDA_API_KEY !== PDA_PLACEHOLDER && /^[A-Za-z0-9]{16}$/.test(PDA_API_KEY);
    // Prefer GM_xmlhttpRequest over fetch — FFScouter CORS may block direct torn.com calls.
    // eslint-disable-next-line no-undef
    const GM_XHR = (typeof GM_xmlhttpRequest !== "undefined") ? GM_xmlhttpRequest : null;

    // ════════════════════════════════════════════════════════════
    //  UTILITIES
    // ════════════════════════════════════════════════════════════

    const fmt = {
        money(n) {
            if (n == null) return "$0";
            const a = Math.abs(n);
            const s = a >= 1e9 ? (a / 1e9).toFixed(2) + "B"
                : a >= 1e6 ? (a / 1e6).toFixed(2) + "M"
                : a >= 1e3 ? (a / 1e3).toFixed(1) + "K"
                : a.toLocaleString();
            return (n < 0 ? "-$" : "$") + s;
        },
        moneyFull(n) { return (n < 0 ? "-" : "") + "$" + Math.abs(n).toLocaleString(); },
        num(n) { return n == null ? "" : Number(n).toLocaleString(); },
        secsToMinLabel(s) {
            if (s <= 0) return "0m";
            const m = Math.floor(s / 60);
            const sec = s % 60;
            return m > 0 ? `${m}m ${sec}s`.replace(" 0s", "") : `${sec}s`;
        },
        hospLabel(untilSec) {
            const rem = Math.max(0, untilSec - Math.floor(Date.now() / 1000));
            if (rem <= 0) return "🏥 out";
            const m = Math.floor(rem / 60);
            const s = rem % 60;
            if (m === 0) return `🏥 ${s}s`;
            return `🏥 ${m}m ${s}s`;
        },
        // Single source of truth for a match's status badge (text + CSS class +
        // optional live-countdown attr), shared by the toast, the table row and
        // any other status render site so they never drift.
        statusMeta(b) {
            switch (b.statusState) {
                case "Hospital":
                    return { text: this.hospLabel(b.hospUntil), cls: "bh-badge-hosp", attr: ` data-hosp-until="${b.hospUntil}"` };
                case "Traveling":
                    return { text: "✈ Traveling", cls: "bh-badge-travel", attr: "" };
                case "Abroad":
                    return { text: "✈ Abroad", cls: "bh-badge-travel", attr: "" };
                default:
                    return { text: "Okay", cls: "bh-badge-ok", attr: "" };
            }
        },
    };

    const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

    // Parse FFScouter bs_estimate_human ("3.93k"/"2.99b") to a number; null → sorts last.
    function parseBS(human) {
        if (!human) return null;
        const m = String(human).match(/^\s*([\d.]+)\s*([kKmMbB]?)\s*$/);
        if (!m) return null;
        const n = parseFloat(m[1]);
        if (!Number.isFinite(n)) return null;
        const s = (m[2] || "").toLowerCase();
        const mult = s === "k" ? 1e3 : s === "m" ? 1e6 : s === "b" ? 1e9 : 1;
        return n * mult;
    }

    function escHtml(s) {
        return String(s == null ? "" : s)
            .replace(/&/g, "&amp;").replace(/</g, "&lt;")
            .replace(/>/g, "&gt;").replace(/"/g, "&quot;");
    }

    function loadSettings() {
        try {
            const raw = localStorage.getItem(LS.settings);
            const stored = raw ? JSON.parse(raw) : {};
            return mergeSettings(stored);
        } catch { return mergeSettings({}); }
    }

    // Deep-merge notifications so a partial/older stored value keeps its defaults.
    function mergeSettings(stored) {
        const storedNotif = (stored && typeof stored.notifications === "object" && stored.notifications) || {};
        const storedFields = (storedNotif.fields && typeof storedNotif.fields === "object") ? storedNotif.fields : {};
        const storedSound = (storedNotif.sound && typeof storedNotif.sound === "object") ? storedNotif.sound : {};
        return {
            ...DEFAULT_SETTINGS,
            ...stored,
            notifications: {
                ...DEFAULT_SETTINGS.notifications,
                ...storedNotif,
                fields: { ...DEFAULT_SETTINGS.notifications.fields, ...storedFields },
                sound: { ...DEFAULT_SETTINGS.notifications.sound, ...storedSound },
            },
        };
    }

    function saveSettings(s) {
        localStorage.setItem(LS.settings, JSON.stringify(s));
    }

    // ════════════════════════════════════════════════════════════
    //  BLACKLIST — ids whose bounties never appear. Schema:
    //    { "<numericId>": { name: string|null, note: string, addedAt: number } }
    // ════════════════════════════════════════════════════════════

    function loadBlacklist() {
        try {
            const raw = localStorage.getItem(LS.blacklist);
            if (!raw) return {};
            const parsed = JSON.parse(raw);
            return (parsed && typeof parsed === "object" && !Array.isArray(parsed)) ? parsed : {};
        } catch { return {}; }
    }

    function saveBlacklist(map) {
        try { localStorage.setItem(LS.blacklist, JSON.stringify(map)); } catch { /* quota */ }
    }

    function blAddEntry(map, id, name) {
        const key = String(Number(id));
        if (!/^\d+$/.test(key) || key === "0") return false;
        const existing = map[key];
        map[key] = {
            name: (existing && existing.name) || (name ? String(name) : null),
            note: existing ? existing.note : "",
            addedAt: existing ? existing.addedAt : Math.floor(Date.now() / 1000),
        };
        return true;
    }

    function blSetName(map, id, name) {
        const key = String(Number(id));
        if (!map[key] || !name) return false;
        if (map[key].name === name) return false;
        map[key].name = String(name);
        return true;
    }

    function blSetNote(map, id, note) {
        const key = String(Number(id));
        if (!map[key]) return false;
        map[key].note = String(note || "");
        return true;
    }

    function blRemove(map, id) {
        const key = String(Number(id));
        if (!map[key]) return false;
        delete map[key];
        return true;
    }

    // Accepts our JSON export, a JSON id array, or a plain id list. Returns count added.
    function blImport(map, text) {
        const t = String(text || "").trim();
        if (!t) return 0;
        let added = 0;
        const tryAdd = (id, name) => { if (blAddEntry(map, id, name)) added++; };
        try {
            const parsed = JSON.parse(t);
            if (Array.isArray(parsed)) {
                for (const v of parsed) tryAdd(v);
                return added;
            }
            if (parsed && typeof parsed === "object") {
                for (const [k, v] of Object.entries(parsed)) {
                    tryAdd(k, v && v.name);
                    if (v && map[String(Number(k))]) {
                        if (typeof v.note === "string") map[String(Number(k))].note = v.note;
                        if (typeof v.addedAt === "number") map[String(Number(k))].addedAt = v.addedAt;
                    }
                }
                return added;
            }
        } catch { /* fall through to plain-id parse */ }
        for (const tok of t.split(/[\s,;]+/)) {
            if (/^\d+$/.test(tok)) tryAdd(tok);
        }
        return added;
    }

    // ════════════════════════════════════════════════════════════
    //  WATCHLIST — targets polled fast for a hospital→okay ("med-out")
    //  transition. Schema: { "<numericId>": { name, reward, lastState, addedAt } }
    // ════════════════════════════════════════════════════════════

    function loadWatchlist() {
        try {
            const raw = localStorage.getItem(LS.watchlist);
            if (!raw) return {};
            const parsed = JSON.parse(raw);
            return (parsed && typeof parsed === "object" && !Array.isArray(parsed)) ? parsed : {};
        } catch { return {}; }
    }

    function saveWatchlist(map) {
        try { localStorage.setItem(LS.watchlist, JSON.stringify(map)); } catch { /* quota */ }
    }

    // ════════════════════════════════════════════════════════════
    //  API-CALL RATE COUNTER — rolling 60s, summed across tabs. Each
    //  tab appends to its OWN key (no cross-tab RMW race); the reader
    //  sums all keys and drops ones from closed tabs.
    // ════════════════════════════════════════════════════════════

    const RATE_WINDOW_MS = 60_000;
    const RATE_WARN = 90;              // Torn's real ceiling is ~100/min
    const RATE_STALE_MS = 120_000;     // a tab silent this long is treated as gone

    function recordApiCall() {
        try {
            const key = LS.apiCallPrefix + TAB_ID;
            const now = Date.now();
            let arr = [];
            try { arr = JSON.parse(localStorage.getItem(key) || "[]"); } catch { arr = []; }
            if (!Array.isArray(arr)) arr = [];
            arr.push(now);
            const cutoff = now - RATE_WINDOW_MS;
            arr = arr.filter((t) => t >= cutoff);
            localStorage.setItem(key, JSON.stringify(arr));
        } catch { /* quota / private mode — counter just under-reports */ }
    }

    // Sums all tabs' calls in the last 60s; prunes own array + dead tabs' keys.
    function apiCallsLastMinute() {
        const now = Date.now();
        const cutoff = now - RATE_WINDOW_MS;
        let total = 0;
        let keys = [];
        try {
            for (let i = 0; i < localStorage.length; i++) {
                const k = localStorage.key(i);
                if (k && k.startsWith(LS.apiCallPrefix)) keys.push(k);
            }
        } catch { return 0; }
        for (const k of keys) {
            let arr = [];
            try { arr = JSON.parse(localStorage.getItem(k) || "[]"); } catch { arr = []; }
            if (!Array.isArray(arr) || arr.length === 0) { continue; }
            const recent = arr.filter((t) => t >= cutoff);
            const newest = arr[arr.length - 1];
            if (newest < now - RATE_STALE_MS && k !== LS.apiCallPrefix + TAB_ID) {
                try { localStorage.removeItem(k); } catch { /* noop */ }
                continue;
            }
            total += recent.length;
        }
        return total;
    }

    // max(interval, K*0.75s): K targets can't poll faster than the 750ms gate allows.
    function projectedWatchCallsPerMin(count, intervalSec) {
        if (count <= 0) return 0;
        const gateFloorSec = count * (API_DELAY_MS / 1000);
        const periodSec = Math.max(intervalSec, gateFloorSec);
        return Math.ceil((count * 60) / periodSec);
    }

    // Open a Torn URL in a way that works on desktop and inside the PDA webview.
    // `window.open` is unreliable in PDA — synthesising an anchor click lets the
    // platform handle the navigation natively (new tab on desktop, in-app on PDA).
    function openTornUrl(url) {
        const a = document.createElement("a");
        a.href = url;
        a.target = "_blank";
        a.rel = "noopener noreferrer";
        document.body.appendChild(a);
        a.click();
        a.remove();
    }

    // ════════════════════════════════════════════════════════════
    //  HTTP — uses PDA_httpGet inside PDA, fetch on desktop
    // ════════════════════════════════════════════════════════════

    function _gmGet(url) {
        return new Promise((resolve, reject) => {
            GM_XHR({
                method: "GET",
                url,
                onload: (r) => resolve({ status: r.status, text: r.responseText }),
                onerror: () => reject(new Error("network_error")),
                ontimeout: () => reject(new Error("timeout")),
            });
        });
    }

    async function _httpGetOnceRaw(url) {
        if (IS_PDA) {
            const res = await PDA_httpGet(url);
            if (res.status < 200 || res.status >= 300) {
                const err = new Error(`HTTP ${res.status}`);
                err.status = res.status;
                err.body = res.responseText;
                throw err;
            }
            return JSON.parse(res.responseText);
        }
        if (GM_XHR) {
            const res = await _gmGet(url);
            if (res.status < 200 || res.status >= 300) {
                const err = new Error(`HTTP ${res.status}`);
                err.status = res.status;
                err.body = res.text;
                throw err;
            }
            return JSON.parse(res.text);
        }
        const res = await fetch(url);
        if (!res.ok) {
            const err = new Error(`HTTP ${res.status}`);
            err.status = res.status;
            err.body = await res.text().catch(() => "");
            throw err;
        }
        return res.json();
    }

    // Debug-logging wrapper — records status + ms per request.
    async function _httpGetOnce(url) {
        const started = performance.now();
        const redacted = redactUrl(url);
        try {
            const data = await _httpGetOnceRaw(url);
            const ms = performance.now() - started;
            // Torn returns 200 with { error:{ code:5 } } on rate limit — surface it despite the 200.
            const tornCode = data && data.error && data.error.code;
            if (tornCode === 5) {
                logDebug(`GET ${redacted} → 200 · Torn rate limit (code 5)`, "err", ms);
            } else if (tornCode) {
                logDebug(`GET ${redacted} → 200 · Torn code ${tornCode}`, "warn", ms);
            } else {
                logDebug(`GET ${redacted} → 200`, "ok", ms);
            }
            return data;
        } catch (err) {
            const ms = performance.now() - started;
            const status = err.status || "ERR";
            const level = (status === 429) ? "err" : "warn";
            const tag = (status === 429) ? " · rate limit" : "";
            logDebug(`GET ${redacted} → ${status}${tag}`, level, ms);
            throw err;
        }
    }

    // One retry for transient / PDA-flaky failures. Real HTTP errors fall through.
    async function httpGetJson(url) {
        try { return await _httpGetOnce(url); }
        catch (err) {
            if (err.status >= 400 && err.status < 500) throw err;
            logDebug(`retry after network/5xx error`, "warn");
            await sleep(400);
            return _httpGetOnce(url);
        }
    }

    // Rate-limit signal: Torn envelope code 5, or HTTP 429. Caller aborts the cycle to keep prior matches.
    function isRateLimitError(err) {
        if (!err) return false;
        if (err.status === 429) return true;
        if (err.tornCode === 5) return true;
        return false;
    }

    // ════════════════════════════════════════════════════════════
    //  TORN API CLIENT (rate-limited, 750 ms min gap)
    // ════════════════════════════════════════════════════════════

    class TornAPI {
        constructor(key) {
            this.key = key;
            this._lastReq = 0;
        }

        setKey(key) { this.key = key; }

        async _rateLimit() {
            const wait = API_DELAY_MS - (Date.now() - this._lastReq);
            if (wait > 0) await sleep(wait);
            this._lastReq = Date.now();
        }

        async _get(pathOrUrl, params = null) {
            await this._rateLimit();
            recordApiCall();
            const isFull = /^https?:\/\//.test(pathOrUrl);
            const url = new URL(isFull ? pathOrUrl : API_BASE + pathOrUrl);
            if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
            if (!url.searchParams.has("key")) url.searchParams.set("key", this.key);
            const data = await httpGetJson(url.toString());
            if (data && data.error) {
                const e = new Error(data.error.error || "Torn API error");
                e.tornCode = data.error.code;
                throw e;
            }
            return data;
        }

        async fetchAllBounties(minReward = 0) {
            const all = [];
            // timestamp bypasses Torn's outer board cache (new snapshot ~20s sooner); doesn't beat the
            // 30s bounties_delay floor. One ts for the whole pagination = consistent snapshot.
            const ts = Math.floor(Date.now() / 1000);
            let url = `${API_BASE}/torn/bounties?limit=100&offset=0&timestamp=${ts}`;
            let delay = 0;
            let safety = 10; // cap pagination at 1000 bounties
            while (url && safety-- > 0) {
                const pageUrl = new URL(url);
                pageUrl.searchParams.set("timestamp", ts);
                const data = await this._get(pageUrl.toString());
                if (Array.isArray(data.bounties)) all.push(...data.bounties);
                if (typeof data.bounties_delay === "number") delay = data.bounties_delay;
                // Board is reward-descending: once the cheapest row is below the floor, stop paging (~10 calls → ~1).
                const last = data.bounties && data.bounties[data.bounties.length - 1];
                if (last && last.reward < minReward) break;
                const next = data._metadata && data._metadata.links && data._metadata.links.next;
                if (!next || !data.bounties || data.bounties.length === 0) break;
                url = next;
            }
            return { bounties: all, delaySec: delay };
        }

        async fetchUserProfile(id) {
            // profile+bounties in one call (rate limit is per-request). Reading the bounty list from the
            // same snapshot as status kills the reward/status skew that surfaces a claimed bounty.
            const data = await this._get(`/user/${id}?selections=profile,bounties`);
            return {
                profile: data.profile || null,
                bounties: Array.isArray(data.bounties) ? data.bounties : null,
            };
        }

        // True only if a ranked war is ongoing now (started, not ended, no winner).
        // Upcoming (start in future) and finished (end > 0) don't count. Public-scope.
        async fetchFactionAtWar(id) {
            const data = await this._get(`/faction/${Number(id)}/rankedwars`);
            const ws = Array.isArray(data.rankedwars) ? data.rankedwars : [];
            const now = Math.floor(Date.now() / 1000);
            return ws.some((w) => Number(w.start) <= now && !w.end && w.winner == null);
        }

        async validateKey() {
            // id+level+age+faction_id in one shot; age drives the NPP rule.
            const data = await this._get("/user/profile");
            return (data && data.profile) || null;
        }

        // /user/bars needs a Minimal+ key (Public → code 16). Callers catch and fall back.
        async fetchBars() {
            const data = await this._get("/user/bars");
            return (data && data.bars) || null;
        }
    }

    // ════════════════════════════════════════════════════════════
    //  RANKED-WAR STATUS CACHE (per-faction, TTL-bounded)
    // ════════════════════════════════════════════════════════════
    //
    // War state changes over hours, so one lookup per faction is cached for the
    // configured TTL. On error, fall back to stale, else "not at war" (fail open).
    class WarStatusCache {
        constructor(api, ttlMsGetter) {
            this.api = api;
            this._ttlMs = ttlMsGetter;
            this._cache = new Map(); // factionId -> { atWar, fetchedAt }
        }

        _fresh(e) { return e && (Date.now() - e.fetchedAt) < this._ttlMs(); }

        async resolve(factionId) {
            const id = Number(factionId);
            if (!id) return false;
            const c = this._cache.get(id);
            if (this._fresh(c)) return c.atWar;
            try {
                const atWar = await this.api.fetchFactionAtWar(id);
                this._cache.set(id, { atWar, fetchedAt: Date.now() });
                return atWar;
            } catch {
                return c ? c.atWar : false;
            }
        }

        // Resolve a batch, reusing the cache. Calls serialize through the API
        // client's 750ms gate anyway, so a simple sequential loop is both
        // correct and no slower than firing them in parallel.
        async resolveMany(ids) {
            const out = new Map();
            for (const id of ids) out.set(Number(id), await this.resolve(id));
            return out;
        }
    }

    // ════════════════════════════════════════════════════════════
    //  FFSCOUTER — bulk fair-fight lookup (up to 205 IDs/call)
    // ════════════════════════════════════════════════════════════

    // Validates an FFScouter key via /check-key. Format-valid ≠ usable — the
    // key must also be registered on FFScouter (get-stats otherwise returns
    // code 6 and we silently filter everyone out). /check-key doesn't count
    // against the caller's usage.
    async function validateFFScouterKey(key) {
        if (!/^[A-Za-z0-9]{16}$/.test(key)) {
            return { ok: false, message: "Must be 16 alphanumeric characters." };
        }
        try {
            const data = await httpGetJson(`${FF_BASE}/check-key?key=${encodeURIComponent(key)}`);
            if (data && data.code) return { ok: false, message: data.error || `FFScouter code ${data.code}` };
            if (!data || !data.is_registered) {
                return { ok: false, message: "Key is not registered with FFScouter — sign up at ffscouter.com first." };
            }
            return { ok: true, message: data.is_premium ? "Valid — premium." : "Valid." };
        } catch (e) {
            return { ok: false, message: e && e.message ? e.message : "Could not reach FFScouter." };
        }
    }

    async function fetchFFScouterStats(key, userIds) {
        const result = { map: new Map(), error: null, nullCount: 0, totalReturned: 0 };
        if (!key) { result.error = "no_key"; return result; }
        if (userIds.length === 0) return result;
        for (let i = 0; i < userIds.length; i += 200) {
            const chunk = userIds.slice(i, i + 200);
            const url = `${FF_BASE}/get-stats?key=${encodeURIComponent(key)}&targets=${chunk.join(",")}`;
            try {
                const data = await httpGetJson(url);
                if (!Array.isArray(data)) {
                    result.error = (data && data.error) ? data.error : "unexpected_response";
                    continue;
                }
                result.totalReturned += data.length;
                for (const p of data) {
                    if (p.fair_fight == null) { result.nullCount++; continue; }
                    result.map.set(Number(p.player_id), {
                        ff: Number(p.fair_fight),
                        bs: p.bs_estimate_human || null,
                    });
                }
            } catch (e) {
                // Rate-limit aborts the cycle — partial FF data would filter good targets out.
                if (isRateLimitError(e)) throw e;
                // Surface the first error but keep processing other chunks.
                if (!result.error) result.error = e.message || "network_error";
            }
        }
        return result;
    }

    // ════════════════════════════════════════════════════════════
    //  KEY RESOLVER
    // ════════════════════════════════════════════════════════════

    const KeyResolver = {
        // Returns the Torn API key if resolvable without user input, else null.
        resolveTornKey() {
            if (HAS_PDA_KEY) return PDA_API_KEY;
            return localStorage.getItem(LS.apiKey) || null;
        },
        hasSPAKey() {
            const k = localStorage.getItem(SPA_LS_APIKEY);
            return !!k && /^[A-Za-z0-9]{16}$/.test(k);
        },
        getSPAKey() { return localStorage.getItem(SPA_LS_APIKEY); },
        saveTornKey(k) { localStorage.setItem(LS.apiKey, k); },
        clearTornKey() { localStorage.removeItem(LS.apiKey); },
        getFFKey() { return localStorage.getItem(LS.ffKey) || ""; },
        saveFFKey(k) { localStorage.setItem(LS.ffKey, k); },
        clearFFKey() { localStorage.removeItem(LS.ffKey); },
        // PDA-injected key isn't persisted — it can change per session; user controls it.
        isPDAKey() { return HAS_PDA_KEY; },
    };

    // ════════════════════════════════════════════════════════════
    //  DONOR CLIENT — green "thanks for the Xanax" banner for tippers.
    //  Best-effort, silent on failure; cached 6h in localStorage.
    // ════════════════════════════════════════════════════════════

    const DonorClient = {
        CACHE_TTL_MS: 6 * 60 * 60 * 1000,
        _inflight: null,    // dedupes concurrent fetches across re-renders

        _readCache() {
            try {
                const raw = localStorage.getItem(LS.donorCache);
                return raw ? JSON.parse(raw) : null;
            } catch { return null; }
        },
        _writeCache(obj) {
            try { localStorage.setItem(LS.donorCache, JSON.stringify(obj)); } catch { /* quota */ }
        },
        _readAck() {
            const v = parseInt(localStorage.getItem(LS.donorAck) || "0", 10);
            return Number.isFinite(v) ? v : 0;
        },
        _writeAck(ts) {
            try { localStorage.setItem(LS.donorAck, String(ts)); } catch { /* quota */ }
        },

        // Fresh + same userId only — switching keys must not show the prior user's banner.
        cachedStatus(userId) {
            const c = this._readCache();
            if (!c || c.userId !== userId) return null;
            if ((Date.now() - (c.fetchedAt || 0)) > this.CACHE_TTL_MS) return null;
            return c;
        },

        // Resolve our user id without waiting on the refresh loop; cache it in localStorage.
        async getUserId() {
            const stored = localStorage.getItem(LS.userId);
            if (stored && /^\d+$/.test(stored)) return parseInt(stored, 10);
            const key = KeyResolver.resolveTornKey();
            if (!key) return null;
            try {
                // Native fetch to api.torn.com is unreliable on PDA's webview — reuse the dispatcher.
                const d = await _httpGetOnceRaw(`https://api.torn.com/v2/user/profile?key=${encodeURIComponent(key)}`);
                const id = d && d.profile && d.profile.id;
                if (id) {
                    try { localStorage.setItem(LS.userId, String(id)); } catch { /* quota */ }
                    return id;
                }
            } catch { /* network or HTTP error — silent on this path */ }
            return null;
        },

        // Transport per env: PDA → PDA_httpGet, desktop → GM_xmlhttpRequest (CSP bypass), else native fetch.
        async _request(url) {
            try {
                if (IS_PDA) {
                    const res = await PDA_httpGet(url);
                    if (res.status < 200 || res.status >= 300) return null;
                    return JSON.parse(res.responseText);
                }
                if (typeof GM_XHR === "function") {
                    return await new Promise((resolve) => {
                        GM_XHR({
                            method: "GET",
                            url,
                            timeout: 10_000,
                            onload: (res) => {
                                if (!res || res.status < 200 || res.status >= 300) return resolve(null);
                                try { resolve(JSON.parse(res.responseText)); }
                                catch { resolve(null); }
                            },
                            onerror: () => resolve(null),
                            ontimeout: () => resolve(null),
                        });
                    });
                }
                const r = await fetch(url, { method: "GET", credentials: "omit", cache: "default" });
                return r.ok ? r.json() : null;
            } catch { return null; }
        },

        async fetchStatus(userId) {
            if (!userId) return null;
            if (this._inflight) return this._inflight;
            const url = `${DONOR_API_BASE}/donor?id=${encodeURIComponent(userId)}&script=bounty`;
            this._inflight = (async () => {
                const d = await this._request(url);
                if (!d) return null;
                const status = {
                    userId,
                    donor: !!d.donor,
                    lastDonationTs: Number(d.lastDonationTs) || 0,
                    fetchedAt: Date.now(),
                };
                this._writeCache(status);
                return status;
            })();
            try { return await this._inflight; }
            finally { this._inflight = null; }
        },

        // Show only for a donation newer than the ts the user dismissed. New Xanax → server ts advances → banner returns.
        shouldShow(status) {
            if (!status || !status.donor) return false;
            if (!status.lastDonationTs) return false;
            return status.lastDonationTs > this._readAck();
        },

        dismiss(lastDonationTs) {
            this._writeAck(lastDonationTs || 0);
        },
    };

    // ════════════════════════════════════════════════════════════
    //  HUNTER — fetch → filter → render loop
    // ════════════════════════════════════════════════════════════

    class Hunter {
        constructor(api) {
            this.api = api;
            this.settings = loadSettings();
            this.warStatus = new WarStatusCache(api, () =>
                Math.max(1, Number(this.settings.warStatusCacheMin) || 15) * 60_000);
            this.blacklist = loadBlacklist();
            this.watchlist = loadWatchlist();
            this._watchTimer = null;
            this._watchNextAt = 0;
            this._watchRunning = false;
            this._watchTicking = false; // guards against overlapping _watchTick runs (timer + focus-kick)
            this.onWatchToast = null;   // ui/toaster sets this — med-out alert
            this.onWatchPoll = null;    // ui sets this — (busy:bool) watchlist activity bar
            this.myUserId = null;
            this.myUserLevel = null;
            this.myUserAge = null;      // days since our own signup — drives NPP rule
            this.myCountry = null;      // "Torn" | "<country>" | null — drives country filter
            this.lastMatches = [];      // last render's rows
            this.lastMatchIds = new Set();
            // The first apply from ANOTHER tab's shared data seeds lastMatchIds
            // silently rather than firing a toast for every cached match — that
            // data can be stale (already-claimed bounties). A cold-start's own
            // fresh refresh() is NOT suppressed, so the user still gets toasts
            // for the bounties that are actionable the moment they open Torn.
            this._firstApplyDone = false;
            // Set true by a filter/settings change so the very next apply
            // re-seeds silently — avoids re-toasting matches already on screen
            // under the previous filter. Consumed (reset) by _applyMatches.
            this._reseedSilent = false;
            this.lastCounts = null;     // { total, afterBasic, afterFF, withFF, ffNull, ffError, statusBreakdown }
            this._statusCache = new Map(); // id → { data, fetchedAt }
            this._timer = null;
            this._running = false;
            this._refreshing = null;    // in-flight refresh() promise; shared so concurrent triggers don't double-fetch
            this._nextAt = 0;
            this.lastError = null;
            this.partialFromRateLimit = false; // true when lastMatches comes from a cycle aborted mid-scan by rate limit
            this.myEnergy = null;       // { current, maximum } when pauseOnLowEnergy is on + call succeeded
            this.lastEnergyError = null; // "scope" | "error" | null — drives the settings-panel hint
            this.mySelfState = null;    // last-known own status.state ("Okay"|"Hospital"|"Jail"|"Traveling"|…) from validateKey
            this.pausedReason = null;   // "disabled" | "low-energy" | "hospital" | "jail" | "traveling" | null — drives the Hunt-tab banner
            this.onUpdate = null;       // ui sets this
            this.onToast = null;        // toaster sets this (new matches → showMany)
            this.onMatchesApplied = null; // toaster sets this (current target ids → pruneTo)
        }

        updateSettings(next) {
            this.settings = { ...this.settings, ...next };
            saveSettings(this.settings);
            // Re-seed silently so a filter tweak doesn't re-toast on-screen matches.
            this.lastMatchIds = new Set();
            this._reseedSilent = true;
            // Invalidate the cross-tab cache — computed under the old filters.
            try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
        }

        // --- Blacklist -----------------------------------------------------
        isBlacklisted(id) {
            if (id == null) return false;
            return Object.prototype.hasOwnProperty.call(this.blacklist, String(Number(id)));
        }

        // Add + immediately strip their bounties from this tab's matches (storage event prunes siblings).
        addToBlacklist(id, name) {
            const ok = blAddEntry(this.blacklist, id, name);
            if (!ok) return false;
            saveBlacklist(this.blacklist);
            this._stripBlacklistedFromMatches();
            try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
            if (this.onUpdate) this.onUpdate();
            return true;
        }

        removeFromBlacklist(id) {
            const ok = blRemove(this.blacklist, id);
            if (!ok) return false;
            saveBlacklist(this.blacklist);
            // Don't force a refresh — next tick surfaces them if they're still on the board.
            try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
            if (this.onUpdate) this.onUpdate();
            return true;
        }

        setBlacklistNote(id, note) {
            const ok = blSetNote(this.blacklist, id, note);
            if (!ok) return false;
            saveBlacklist(this.blacklist);
            return true;
        }

        // Merge (not wipe) a JSON/id blob. Returns count added.
        importBlacklist(text) {
            const before = Object.keys(this.blacklist).length;
            const added = blImport(this.blacklist, text);
            if (added > 0 || Object.keys(this.blacklist).length !== before) {
                saveBlacklist(this.blacklist);
                this._stripBlacklistedFromMatches();
                try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
                if (this.onUpdate) this.onUpdate();
            }
            return added;
        }

        clearBlacklist() {
            this.blacklist = {};
            saveBlacklist(this.blacklist);
            try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
            if (this.onUpdate) this.onUpdate();
        }

        // Another tab changed the blacklist — re-read + re-prune.
        applyExternalBlacklist() {
            this.blacklist = loadBlacklist();
            this._stripBlacklistedFromMatches();
            if (this.onUpdate) this.onUpdate();
        }

        // --- Watchlist -----------------------------------------------------
        isWatched(id) {
            if (id == null) return false;
            return Object.prototype.hasOwnProperty.call(this.watchlist, String(Number(id)));
        }

        watchlistCount() { return Object.keys(this.watchlist).length; }

        // Seed lastState from current status so the next flip to Okay fires the alert.
        addToWatchlist(b) {
            const key = String(Number(b.target_id));
            if (!/^\d+$/.test(key) || key === "0") return false;
            this.watchlist[key] = {
                name: b.target_name ? String(b.target_name) : (this.watchlist[key] && this.watchlist[key].name) || null,
                reward: b.reward,
                lastState: b.statusState || "Hospital",
                addedAt: Math.floor(Date.now() / 1000),
            };
            saveWatchlist(this.watchlist);
            this.ensureWatchLoop();
            if (this.onUpdate) this.onUpdate();
            return true;
        }

        removeFromWatchlist(id) {
            const key = String(Number(id));
            if (!this.watchlist[key]) return false;
            delete this.watchlist[key];
            saveWatchlist(this.watchlist);
            if (this.watchlistCount() === 0) this.stopWatch();
            if (this.onUpdate) this.onUpdate();
            return true;
        }

        // Another tab changed the watchlist — re-read + reassess polling.
        applyExternalWatchlist() {
            this.watchlist = loadWatchlist();
            if (this.watchlistCount() === 0) this.stopWatch();
            else this.ensureWatchLoop();
            if (this.onUpdate) this.onUpdate();
        }

        // Idempotent — safe from add / visibility / storage / boot.
        ensureWatchLoop() {
            if (this._watchRunning || this.watchlistCount() === 0) return;
            this._watchRunning = true;
            this._scheduleWatch(0);
        }

        stopWatch() {
            if (this._watchTimer) { clearTimeout(this._watchTimer); this._watchTimer = null; }
            this._watchRunning = false;
        }

        _watchIntervalMs() {
            const sec = Number(this.settings.watchlistIntervalSec);
            return Math.max(1, Number.isFinite(sec) ? sec : 5) * 1000;
        }

        _scheduleWatch(delayMs) {
            if (this._watchTimer) clearTimeout(this._watchTimer);
            this._watchNextAt = Date.now() + delayMs;
            this._watchTimer = setTimeout(() => this._watchTick(), delayMs);
        }

        secondsUntilWatchPoll() {
            if (!this._watchRunning || !this._watchNextAt) return 0;
            return Math.max(0, Math.round((this._watchNextAt - Date.now()) / 1000));
        }

        // Visible tab owns polling — kick a tick on focus for a snappy handoff.
        kickWatchIfVisible() {
            if (document.visibilityState !== "visible") return;
            this.watchlist = loadWatchlist(); // adopt anything a sibling added while we were hidden
            if (this.watchlistCount() === 0) { this.stopWatch(); return; }
            this._watchRunning = true;
            this._scheduleWatch(0);
        }

        async _watchTick() {
            if (!this._watchRunning) return;
            const interval = this._watchIntervalMs();
            // Only the visible, search-on tab polls. Others keep a no-request heartbeat so they resume on focus.
            if (document.visibilityState !== "visible" || !this.settings.searchEnabled
                || !this.api.key || this.watchlistCount() === 0) {
                if (this.watchlistCount() === 0) { this.stopWatch(); return; }
                this._scheduleWatch(interval);
                return;
            }
            // A poll is already in flight (focus-kick vs timer) — skip; the active one reschedules.
            if (this._watchTicking) return;
            this._watchTicking = true;
            let changed = false;
            const ids = Object.keys(this.watchlist);
            if (this.onWatchPoll) this.onWatchPoll(true);
            try {
            for (const id of ids) {
                if (!this._watchRunning) break;
                const entry = this.watchlist[id];
                if (!entry) continue;
                try {
                    const { profile, bounties } = await this.api.fetchUserProfile(Number(id));
                    if (!this.watchlist[id]) continue; // removed mid-loop
                    // Drop if the best remaining bounty is below what we signed up to watch (original claimed/expired). Higher replacement kept.
                    const own = Array.isArray(bounties)
                        ? bounties.filter((x) => Number(x.target_id) === Number(id))
                        : [];
                    const maxReward = own.reduce((m, x) => Math.max(m, Number(x.reward) || 0), 0);
                    if (own.length === 0 || maxReward < entry.reward) { delete this.watchlist[id]; changed = true; continue; }
                    const state = profile && profile.status ? profile.status.state : null;
                    if (state) {
                        const prev = entry.lastState;
                        if (prev && prev !== "Okay" && state === "Okay") {
                            await this._fireMedOut(id, entry, profile);
                        }
                        if (state !== prev) { entry.lastState = state; changed = true; }
                    }
                } catch (e) {
                    // Transient (network / rate limit) — keep the entry and try
                    // again next tick. Don't spam the debug log per-target.
                    if (isRateLimitError(e)) { logDebug("watchlist poll hit rate limit — backing off", "warn"); break; }
                }
            }
            } finally {
                if (this.onWatchPoll) this.onWatchPoll(false);
                this._watchTicking = false;
            }
            if (changed) {
                saveWatchlist(this.watchlist);
                if (this.onUpdate) this.onUpdate();
            }
            if (this.watchlistCount() === 0) { this.stopWatch(); return; }
            this._scheduleWatch(interval);
        }

        async _fireMedOut(id, entry, profile) {
            if (!this.onWatchToast) return;
            // Med-out is exactly when the 60s outside-hitter protection starts
            // if the target's faction is at war — so flag it on this toast.
            let warActive = false;
            if (this.settings.warStatus && profile && profile.faction_id) {
                warActive = await this.warStatus.resolve(profile.faction_id);
            }
            this.onWatchToast([{
                target_id: Number(id),
                target_name: entry.name || (profile && profile.name) || String(id),
                target_level: (profile && profile.level) || "?",
                reward: entry.reward,
                ff: null,
                bs: null,
                statusState: "Okay",
                hospUntil: 0,
                bountyCount: 1,
                awayFromMe: false,
                location: null,
                medOut: true,
                warActive,
            }]);
            logDebug(`watchlist: ${entry.name || id} is OUT of hospital — alert fired`, "ok");
        }

        // Backfill missing names from the board; save only when something changed.
        _backfillBlacklistNames(bounties) {
            let changed = false;
            for (const b of bounties) {
                const key = String(Number(b.target_id));
                const entry = this.blacklist[key];
                if (!entry || entry.name === b.target_name) continue;
                if (blSetName(this.blacklist, b.target_id, b.target_name)) changed = true;
            }
            if (changed) saveBlacklist(this.blacklist);
        }

        // Drop now-blacklisted matches + re-prune toasts. Cheaper than a full refresh.
        _stripBlacklistedFromMatches() {
            const before = this.lastMatches.length;
            const filtered = this.lastMatches.filter((m) => !this.isBlacklisted(m.target_id));
            if (filtered.length === before) return;
            this.lastMatches = filtered;
            this.lastMatchIds = new Set(filtered.map((m) => `${m.target_id}|${m.reward}`));
            if (this.onMatchesApplied) {
                this.onMatchesApplied(new Set(filtered.map((m) => Number(m.target_id))));
            }
        }

        // Another tab's settings change. No saveSettings (they already persisted) — avoids write ping-pong.
        applyExternalSettings(next) {
            this.settings = { ...this.settings, ...next };
            this.lastMatchIds = new Set();
            this._reseedSilent = true;
            // Stop, then let start() self-gate (mirrors persistFilters).
            this.stop();
            if (this.settings.searchEnabled && this.settings.refreshSec > 0) {
                this.pausedReason = null;
                this.start();
            } else if (!this.settings.searchEnabled) {
                this.pausedReason = "disabled";
            } else {
                this.pausedReason = null;
            }
            if (this.onUpdate) this.onUpdate();
        }

        // --- Cross-tab sharing ---------------------------------------------

        _readShared() {
            try {
                const raw = localStorage.getItem(LS.shared);
                if (!raw) return null;
                const s = JSON.parse(raw);
                if (!s || typeof s.writtenAt !== "number") return null;
                return s;
            } catch { return null; }
        }

        _writeShared() {
            try {
                const payload = {
                    writtenAt: Date.now(),
                    refreshSec: this.settings.refreshSec,
                    matches: this.lastMatches,
                    lastCounts: this.lastCounts,
                    myUserId: this.myUserId,
                    myUserLevel: this.myUserLevel,
                    myUserAge: this.myUserAge,
                    myCountry: this.myCountry,
                    lastError: this.lastError ? (this.lastError.message || "error") : null,
                };
                localStorage.setItem(LS.shared, JSON.stringify(payload));
            } catch { /* quota or serialization failure — non-fatal */ }
        }

        // Adopt the "who am I" hints another tab has already resolved; saves one
        // /user/profile call on a cold tab that piggybacks on a warmer one.
        _adoptSharedIdentity(s) {
            if (s.myUserId != null && this.myUserId == null) this.myUserId = s.myUserId;
            if (s.myUserLevel != null && this.myUserLevel == null) this.myUserLevel = s.myUserLevel;
            if (s.myUserAge != null && this.myUserAge == null) this.myUserAge = s.myUserAge;
            // Country is mutable — always adopt the latest writer's value.
            if (s.myCountry != null) this.myCountry = s.myCountry;
        }

        // Diff vs previous to fire toasts. Suppress toasts on: first apply from another tab's
        // (possibly stale) shared data, a silent re-seed after a filter change, or an energy pause.
        _applyMatches(matches, fromShared = false) {
            const matchKey = (m) => `${m.target_id}|${m.reward}`;
            const currentIds = new Set(matches.map(matchKey));
            const firstApply = !this._firstApplyDone;
            this._firstApplyDone = true;
            const suppressed = (firstApply && fromShared)
                || this._reseedSilent
                || this.pausedReason === "low-energy";
            this._reseedSilent = false;
            if (!suppressed && this.settings.toastsEnabled && this.onToast) {
                const newOnes = matches.filter((m) => !this.lastMatchIds.has(matchKey(m)));
                if (newOnes.length > 0) this.onToast(newOnes);
            }
            this.lastMatches = matches;
            this.lastMatchIds = currentIds;
            // Keep the toast stack in sync with the table — else a dropped match's toast lingers its full TTL.
            if (this.onMatchesApplied) {
                this.onMatchesApplied(new Set(matches.map((m) => Number(m.target_id))));
            }
        }

        // Another tab wrote shared data — adopt it + re-render.
        adoptSharedPayload(s) {
            if (!s) return;
            this._adoptSharedIdentity(s);
            if (Array.isArray(s.matches)) {
                this.lastCounts = s.lastCounts || null;
                this.partialFromRateLimit = false; // _writeShared only fires on full cycles
                this._applyMatches(s.matches, true);
            }
            if (this.onUpdate) this.onUpdate();
        }

        stop() {
            if (this._timer) { clearTimeout(this._timer); this._timer = null; }
            this._running = false;
        }

        start() {
            if (this._running) return;
            if (!this.settings.searchEnabled) {
                // Honor the kill-switch on cold boot — render the "disabled" banner, don't start.
                this.pausedReason = "disabled";
                if (this.onUpdate) this.onUpdate();
                return;
            }
            this._running = true;
            this._tick();
        }

        // Pause reason from our own state when the matching toggle is on. Free — state rides the profile call.
        _selfStatusPauseReason(state) {
            const s = state !== undefined ? state : this.mySelfState;
            if (!s) return null;
            if (this.settings.pauseInHospital && s === "Hospital") return "hospital";
            if (this.settings.pauseInJail && s === "Jail") return "jail";
            if (this.settings.pauseWhileTraveling && s === "Traveling") return "traveling";
            return null;
        }

        // True when the energy gate is on and energy < N. Updates myEnergy/lastEnergyError; fails open (never blocks hunting).
        async _isPausedByEnergy() {
            if (!this.settings.pauseOnLowEnergy || this.settings.minEnergy <= 0) {
                this.myEnergy = null;
                this.lastEnergyError = null;
                return false;
            }
            try {
                const bars = await this.api.fetchBars();
                const energy = bars && bars.energy;
                if (energy && typeof energy.current === "number") {
                    this.myEnergy = { current: energy.current, maximum: energy.maximum };
                    this.lastEnergyError = null;
                    return energy.current < this.settings.minEnergy;
                }
            } catch (e) {
                // code 16 = scope too low; anything else transient. Either way, don't block hunting.
                this.lastEnergyError = (e && e.tornCode === 16) ? "scope" : "error";
                logDebug(`bars fetch failed (${e && e.tornCode ? "code " + e.tornCode : "network"}): ${e && e.message || "error"}`, "err");
            }
            return false;
        }

        async _tick() {
            if (!this._running) return;
            if (!this.settings.searchEnabled) {
                // Toggled off while running — stop and let the UI update.
                this.pausedReason = "disabled";
                this._running = false;
                if (this.onUpdate) this.onUpdate();
                return;
            }
            let waitSec = this.settings.refreshSec;
            try {
                // Energy gate — runs BEFORE the free-ride decision so a tab
                // that just cold-started below the energy threshold doesn't
                // pick up another tab's cached matches and spam toasts. Also
                // drops any bounty toasts still on screen from before the
                // user's energy dropped — the Hunt-tab rows survive so the
                // data isn't lost, only the pop-ups.
                if (await this._isPausedByEnergy()) {
                    this.pausedReason = "low-energy";
                    if (this.onMatchesApplied) this.onMatchesApplied(new Set());
                    if (this.onUpdate) this.onUpdate({ loading: false });
                } else if (this.pausedReason !== "disabled") {
                    this.pausedReason = null;
                }

                // Cross-tab free-ride: if another tab refreshed recently under
                // the same settings, reuse its result instead of burning our
                // own API budget. Keeps N-tab users at ~1× call cost. Skipped
                // when paused on low energy so we don't apply fresh toasts.
                const shared = this._readShared();
                const fresh = shared
                    && (Date.now() - shared.writtenAt) < (this.settings.refreshSec * 1000 * SHARED_FRESH_RATIO);
                // If we last knew we were in hospital/jail/traveling (and the
                // matching toggle is on), don't free-ride on a sibling tab's
                // matches — fall through to refresh() so our own status
                // re-confirms this tick. refresh() is the only place mySelfState
                // updates, so gating it here would freeze the pause forever.
                const selfPauseTick = this._selfStatusPauseReason();
                if (this.pausedReason === "low-energy") {
                    // gated above; nothing else to do this tick
                } else if (fresh && !selfPauseTick) {
                    logDebug(`free-ride: reusing fresh result from another tab (${shared.matches ? shared.matches.length : 0} matches)`, "info");
                    this._adoptSharedIdentity(shared);
                    this.lastCounts = shared.lastCounts || null;
                    this.lastError = null;
                    this.partialFromRateLimit = false; // _writeShared only fires on full cycles
                    this._applyMatches(shared.matches || [], true);
                    if (this.onUpdate) this.onUpdate();
                } else {
                    const delaySec = await this.refresh();
                    waitSec = Math.max(this.settings.refreshSec, delaySec || 0);
                }
            } catch (err) {
                this.lastError = err;
                if (isRateLimitError(err)) {
                    // refresh() already logged the partial breakdown; just note table state.
                    logDebug(`refresh aborted — rate limit; ${this.lastMatches.length} match${this.lastMatches.length === 1 ? "" : "es"} on screen${this.partialFromRateLimit ? " (partial)" : ""}`, "err");
                } else {
                    logDebug(`refresh failed: ${err.message || "error"}`, "err");
                }
                if (this.onUpdate) this.onUpdate();
                // Back off briefly on errors; leave auto-refresh alive.
                waitSec = Math.max(this.settings.refreshSec, 30);
            }
            if (this.settings.refreshSec <= 0) return; // "off"
            this._nextAt = Date.now() + waitSec * 1000;
            this._timer = setTimeout(() => this._tick(), waitSec * 1000);
        }

        // Push the next auto-refresh to now + waitSec. No-op if the loop is off.
        _rescheduleTick(waitSec) {
            if (!this._running || this.settings.refreshSec <= 0) return;
            if (this._timer) clearTimeout(this._timer);
            this._nextAt = Date.now() + waitSec * 1000;
            this._timer = setTimeout(() => this._tick(), waitSec * 1000);
        }

        // "Refresh now": refresh, then push the next auto-refresh a full interval out (no double-fetch).
        async manualRefresh() {
            try {
                const delaySec = await this.refresh();
                this._rescheduleTick(Math.max(this.settings.refreshSec, delaySec || 0));
                return delaySec;
            } catch (e) {
                this._rescheduleTick(this.settings.refreshSec);
                throw e;
            }
        }

        // Reentrancy guard — auto-tick, "Refresh now", and cross-tab sync can all fire at once;
        // share one in-flight promise (else double calls + a stale cycle landing last).
        refresh() {
            if (this._refreshing) return this._refreshing;
            this._refreshing = this._refreshImpl().finally(() => { this._refreshing = null; });
            return this._refreshing;
        }

        async _refreshImpl() {
            this.lastError = null;
            const refreshStart = performance.now();
            logDebug(`refresh: start`, "info");
            if (this.onUpdate) this.onUpdate({ loading: true });

            // id/level/age are near-immutable (cached); country flips on travel, so re-read status each cycle.
            try {
                const profile = await this.api.validateKey();
                if (profile) {
                    this.myUserId = profile.id || this.myUserId;
                    this.myUserLevel = (typeof profile.level === "number") ? profile.level : this.myUserLevel;
                    this.myUserAge = (typeof profile.age === "number") ? profile.age : this.myUserAge;
                    this.myCountry = getPlayerCountry(profile.status) || this.myCountry;
                    // drives the pause gates below; free from this response
                    if (profile.status && profile.status.state) this.mySelfState = profile.status.state;
                }
            } catch { /* non-fatal — keep previous identity/country */ }

            // Skip the fetch when we can't act (hospital/jail/flight). Opt-in, free.
            // Don't write shared, so siblings can't free-ride this cycle either.
            const selfPause = this._selfStatusPauseReason();
            if (selfPause) {
                this.pausedReason = selfPause;
                if (this.onMatchesApplied) this.onMatchesApplied(new Set());
                logDebug(`paused — own status is ${this.mySelfState}; skipping bounty fetch`, "info");
                if (this.onUpdate) this.onUpdate({ loading: false });
                return 0;
            }

            // Skip the expensive fetch when energy's too low. Re-checked here because manual refresh bypasses _tick's gate.
            if (await this._isPausedByEnergy()) {
                this.pausedReason = "low-energy";
                if (this.onMatchesApplied) this.onMatchesApplied(new Set());
                logDebug(`paused — energy ${this.myEnergy ? this.myEnergy.current : "?"}/${this.myEnergy ? this.myEnergy.maximum : "?"} < ${this.settings.minEnergy}; skipping bounty fetch`, "info");
                if (this.onUpdate) this.onUpdate({ loading: false });
                return 0;
            }
            this.pausedReason = null;

            const { bounties, delaySec } = await this.api.fetchAllBounties(this.settings.minPrice);
            logDebug(`fetched ${bounties.length} bounties (cache delay ${delaySec || 0}s)`, "ok");

            // Collapse same-target+same-reward rows into one with an aggregated count (dedupes FF/profile calls, cleans the list).
            const grouped = new Map();
            for (const b of bounties) {
                const key = `${b.target_id}|${b.reward}`;
                if (!grouped.has(key)) {
                    grouped.set(key, { ...b, bountyCount: 0 });
                }
                const inc = (typeof b.quantity === "number" && b.quantity > 0) ? b.quantity : 1;
                grouped.get(key).bountyCount += inc;
            }
            const dedupedBounties = [...grouped.values()];

            const counts = {
                total: bounties.length,
                afterBasic: 0,
                withFF: 0,
                ffNull: 0,
                ffError: null,
                afterFF: 0,
                statusBreakdown: {},
                matches: 0,
            };

            // Fill blacklist names from this cycle's board so they stop showing "(unknown)". Cheap.
            this._backfillBlacklistNames(dedupedBounties);

            // 1) Blacklist first — never spend FF/profile budget on an excluded player. Age filter waits for step 3 (needs a profile anyway).
            const afterPriceAndSelf = dedupedBounties.filter((b) =>
                b.reward >= this.settings.minPrice
                && (this.myUserId == null || b.target_id !== this.myUserId)
            );
            const byBasic = afterPriceAndSelf.filter((b) => !this.isBlacklisted(b.target_id));
            counts.afterBasic = byBasic.length;
            counts.blacklisted = afterPriceAndSelf.length - byBasic.length;

            // 2) Bulk FFScouter — keep only rows with a known FF in range.
            const ffKey = KeyResolver.getFFKey();
            const ids = [...new Set(byBasic.map((b) => Number(b.target_id)))];
            logDebug(`FFScouter: requesting ${ids.length} IDs`, "info");
            const ff = await fetchFFScouterStats(ffKey, ids);
            counts.withFF = ff.map.size;
            counts.ffNull = ff.nullCount;
            counts.ffError = ff.error;
            logDebug(`FFScouter: ${ff.map.size} with FF, ${ff.nullCount} null${ff.error ? `, error: ${ff.error}` : ""}`, ff.error ? "warn" : "ok");
            const includeUnknown = !!this.settings.includeUnknownFF;
            const byFF = byBasic
                .map((b) => {
                    const e = ff.map.get(Number(b.target_id));
                    if (e) return { ...b, ff: e.ff, bs: e.bs };
                    // not in FF map (null FF or absent) — include only if opted in
                    return includeUnknown ? { ...b, ff: null, bs: null } : null;
                })
                .filter((b) => {
                    if (b == null) return false;
                    if (b.ff == null) return true; // unknown — pass through
                    return b.ff >= this.settings.minFF && b.ff <= this.settings.maxFF;
                });
            counts.afterFF = byFF.length;

            // 3) Per-target profile — status, age, faction.
            const nowSec = Math.floor(Date.now() / 1000);
            const hospWindowSec = this.settings.hospitalMaxMin * 60;
            logDebug(`profiles: need ${byFF.length} (cache will absorb recent lookups)`, "info");
            const { profiles, rateLimitErr: profileRLErr } = await this._fetchProfiles(byFF.map((b) => Number(b.target_id)));
            // Number of byFF targets we actually have a resolved profile for —
            // drives the "scanned X of Y" line in the rate-limit banner.
            counts.scanned = byFF.reduce((n, b) => n + (profiles.has(Number(b.target_id)) ? 1 : 0), 0);
            const matches = [];
            counts.tooNew = 0;
            counts.differentCountry = 0;
            for (const b of byFF) {
                const p = profiles.get(Number(b.target_id));
                if (!p || !p.status) { counts.statusBreakdown["unknown"] = (counts.statusBreakdown["unknown"] || 0) + 1; continue; }
                // Torn's NPP rule — depends on our own age too. See isAttackableByAge().
                if (!isAttackableByAge(p.age, this.myUserAge)) {
                    counts.tooNew++;
                    continue;
                }
                const state = p.status.state;
                counts.statusBreakdown[state] = (counts.statusBreakdown[state] || 0) + 1;
                const until = p.status.until || 0;
                const remaining = Math.max(0, until - nowSec);
                // Same-country only when both locations are known; unknown/pre-profile falls open (never drop everyone).
                const targetCountry = getPlayerCountry(p.status);
                if (this.settings.sameCountryOnly && this.myCountry && targetCountry && targetCountry !== this.myCountry) {
                    counts.differentCountry++;
                    continue;
                }
                // Reconcile the board reward against the target's own list (same call as status) — the board
                // lags (bounties_delay), so a claimed bounty lingers. Missing list falls open to the board reward.
                let row = b;
                if (Array.isArray(p.bounties)) {
                    const own = p.bounties.filter((x) => Number(x.target_id) === Number(b.target_id));
                    const liveReward = own.some((x) => x.reward === b.reward);
                    if (!liveReward) {
                        counts.staleReward = (counts.staleReward || 0) + 1;
                        continue;
                    }
                    // Refresh the ×N badge count from the authoritative list too.
                    const liveQty = own
                        .filter((x) => x.reward === b.reward)
                        .reduce((n, x) => n + ((typeof x.quantity === "number" && x.quantity > 0) ? x.quantity : 1), 0);
                    if (liveQty > 0) row = { ...b, bountyCount: liveQty };
                }
                // online/idle/offline from the same snapshot — free
                row = { ...row, lastAction: p.lastAction || null, factionId: p.faction_id || null };
                // Where the target is when unreachable: "In transit" mid-flight, else the country.
                const away = state === "Traveling"
                    ? { awayFromMe: true, location: "In transit" }
                    : (targetCountry && this.myCountry && targetCountry !== this.myCountry)
                        ? { awayFromMe: true, location: targetCountry }
                        : { awayFromMe: false, location: null };
                if (state === "Okay") {
                    matches.push({ ...row, statusState: "Okay", hospUntil: 0, ...away });
                } else if (state === "Hospital" && (this.settings.hospNoLimit || remaining <= hospWindowSec)) {
                    matches.push({ ...row, statusState: "Hospital", hospUntil: until, ...away });
                } else if (!this.settings.sameCountryOnly && (state === "Abroad" || state === "Traveling")) {
                    // only in "include all locations" mode — shown for awareness, not attackable yet
                    matches.push({ ...row, statusState: state, hospUntil: 0, ...away });
                }
            }
            matches.sort((a, b) => b.reward - a.reward);
            counts.matches = matches.length;

            // 4a) Rate-limit mid-scan: show partials only if useful — never overwrite a good prior list with worse. No cross-tab share of partials.
            if (profileRLErr) {
                const replace = matches.length > 0 || this.lastMatches.length === 0;
                if (replace) {
                    this.lastCounts = counts;
                    this._applyMatches(matches);
                    this.partialFromRateLimit = true;
                }
                logDebug(`refresh aborted — rate limit at profile step; scanned ${counts.scanned}/${byFF.length}, ${matches.length} partial match${matches.length === 1 ? "" : "es"}${replace ? " (shown)" : " (kept prior)"}`, "err", performance.now() - refreshStart);
                // Clear the activity bar — _tick's catch re-renders but won't pass loading:false.
                if (this.onUpdate) this.onUpdate({ loading: false });
                // _tick will set lastError and trigger the (banner-bearing) re-render.
                throw profileRLErr;
            }

            // 4b) Diff for toasts + render + cross-tab broadcast.
            this.partialFromRateLimit = false;
            this.lastCounts = counts;
            // War badge — opt-in, one cached lookup per faction; clean path only (don't spend budget while throttled).
            if (this.settings.warStatus && matches.length) {
                const facIds = [...new Set(matches.map((m) => m.factionId).filter(Boolean))];
                const warMap = await this.warStatus.resolveMany(facIds);
                for (const m of matches) {
                    m.warActive = !!(m.factionId && warMap.get(Number(m.factionId)));
                }
            }
            this._applyMatches(matches);
            this._writeShared();
            logDebug(`refresh: done — ${matches.length} matches (of ${counts.total} bounties)${counts.staleReward ? `, dropped ${counts.staleReward} stale reward row${counts.staleReward === 1 ? "" : "s"} (claimed/expired per target's own bounty list)` : ""}`, "ok", performance.now() - refreshStart);
            if (this.onUpdate) this.onUpdate({ loading: false });
            return delaySec;
        }

        async _fetchProfiles(ids) {
            const out = new Map();
            const now = Date.now();
            const stale = [];
            // De-dupe so repeated target_ids don't fire parallel fetches (cache alone misses on cold start).
            const unique = [...new Set(ids)];
            for (const id of unique) {
                const c = this._statusCache.get(id);
                if (c) {
                    const d = c.data;
                    // Short freshness dedupe only — never trust a cached Hospital `until` (med-out flips to Okay; we'd miss it).
                    if (now - c.fetchedAt < STATUS_CACHE_MS) {
                        out.set(id, d);
                        continue;
                    }
                }
                stale.push(id);
            }
            // Bounded concurrency — 3 in-flight at a time to stay friendly.
            let i = 0;
            let rateLimitErr = null;
            const workers = Array.from({ length: STATUS_CONCURRENCY }, async () => {
                while (i < stale.length && !rateLimitErr) {
                    const id = stale[i++];
                    try {
                        const { profile, bounties } = await this.api.fetchUserProfile(id);
                        if (profile) {
                            const data = {
                                status: profile.status || null,
                                age: typeof profile.age === "number" ? profile.age : null,
                                faction_id: profile.faction_id || null,
                                // rides the same profile call — no extra request
                                lastAction: (profile.last_action && profile.last_action.status) || null,
                                // same snapshot as status. null = no array (unknown, don't reconcile); [] = genuinely none.
                                bounties: bounties,
                            };
                            out.set(id, data);
                            this._statusCache.set(id, { data, fetchedAt: Date.now() });
                        }
                    } catch (err) {
                        // Rate-limit: stop workers, keep `out` — caller decides partial vs prior.
                        if (isRateLimitError(err)) { rateLimitErr = err; return; }
                        // Other per-target errors: row just won't match this cycle.
                    }
                }
            });
            await Promise.all(workers);
            return { profiles: out, rateLimitErr };
        }

        secondsUntilRefresh() {
            if (!this._nextAt) return 0;
            return Math.max(0, Math.round((this._nextAt - Date.now()) / 1000));
        }
    }

    // ════════════════════════════════════════════════════════════
    //  TOASTER
    // ════════════════════════════════════════════════════════════

    // Synthesized so there are no external files to fetch (CSP/@connect would block them).
    const SoundPlayer = (() => {
        const G = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
        const AC = G.AudioContext || G.webkitAudioContext;
        let ctx = null;
        const PATTERNS = {
            chime: [{ f: 660, t: 0, d: 0.12 }, { f: 988, t: 0.12, d: 0.20 }],
            beep:  [{ f: 880, t: 0, d: 0.16 }],
            ding:  [{ f: 1319, t: 0, d: 0.30 }],
            alert: [{ f: 740, t: 0, d: 0.09 }, { f: 740, t: 0.15, d: 0.09 }, { f: 740, t: 0.30, d: 0.09 }],
        };
        function getCtx() {
            if (!AC) return null;
            if (!ctx) { try { ctx = new AC(); } catch { return null; } }
            if (ctx.state === "suspended") ctx.resume().catch(() => {});
            return ctx;
        }
        // Autoplay policy blocks audio until a user gesture, so prime the context on
        // the first interaction — later toast beeps fire with no gesture of their own.
        function installUnlockHandlers() {
            const unlock = () => { getCtx(); };
            ["pointerdown", "touchstart", "keydown"].forEach((ev) =>
                (G.document || document).addEventListener(ev, unlock, { capture: true, passive: true }));
        }
        function play(type, volume) {
            const c = getCtx();
            if (!c) return;
            const notes = PATTERNS[type] || PATTERNS.chime;
            const vol = Math.max(0, Math.min(1, Number(volume)));
            if (vol <= 0) return;
            const t0 = c.currentTime;
            for (const n of notes) {
                const osc = c.createOscillator();
                const gain = c.createGain();
                osc.type = n.type || "sine";
                osc.frequency.value = n.f;
                const start = t0 + n.t;
                const end = start + n.d;
                gain.gain.setValueAtTime(0.0001, start);
                gain.gain.exponentialRampToValueAtTime(vol, start + 0.012);
                gain.gain.exponentialRampToValueAtTime(0.0001, end);
                osc.connect(gain).connect(c.destination);
                osc.start(start);
                osc.stop(end + 0.03);
            }
        }
        return { play, installUnlockHandlers, TYPES: Object.keys(PATTERNS) };
    })();

    class Toaster {
        // Live settings ref each call, so Settings-tab changes apply on the next toast.
        constructor(getSettings) {
            this.getSettings = getSettings || (() => DEFAULT_SETTINGS);
            this.container = null;
            this._cards = []; // { id, el, timer, remaining, enteredAt }
            this._clearAllEl = null;
        }

        _notif() {
            const s = (this.getSettings && this.getSettings()) || DEFAULT_SETTINGS;
            return s.notifications || DEFAULT_SETTINGS.notifications;
        }

        ensureContainer() {
            if (this.container && document.body.contains(this.container)) {
                this.applySettings();
                return;
            }
            const c = document.createElement("div");
            c.id = "bh-toasts";
            document.body.appendChild(c);
            this.container = c;
            this.applySettings();
        }

        // Apply position/width to the container + live cards. Idempotent.
        applySettings() {
            if (!this.container) return;
            const n = this._notif();
            const pos = n.position || "bottom-right";
            const c = this.container;
            // reset anchors so a top↔bottom flip clears the old edge
            c.style.top = "";
            c.style.bottom = "";
            c.style.left = "";
            c.style.right = "";
            if (pos.startsWith("top")) c.style.top = "16px";
            else c.style.bottom = "16px";
            if (pos.endsWith("right")) c.style.right = "16px";
            else c.style.left = "16px";
            // Children align to the anchor side so the stack hugs the corner.
            const alignSide = pos.endsWith("right") ? "flex-end" : "flex-start";
            c.style.alignItems = alignSide;
            // Card width — applied per card so it overrides the CSS default.
            const w = Math.max(140, Math.min(640, Number(n.width) || 300));
            for (const card of this._cards) {
                if (card.el) card.el.style.width = `${w}px`;
            }
            if (this._clearAllEl) this._clearAllEl.style.alignSelf = alignSide;
        }

        // Lightweight informational card with one optional action button —
        // used by the Hunt-list / toast "Blacklisted X — Undo" flow. Bypasses
        // the maxVisible cap because info cards are user-triggered (rare).
        showAction({ message, actionLabel, onAction, timeoutMs }) {
            this.ensureContainer();
            const el = document.createElement("div");
            el.className = "bh-toast bh-toast-info";
            const w = Math.max(140, Math.min(640, Number(this._notif().width) || 300));
            el.style.width = `${w}px`;
            el.innerHTML = `
                <button class="bh-toast-close" title="Dismiss">&times;</button>
                <div class="bh-toast-info-msg">${escHtml(message)}</div>
                ${actionLabel ? `<button class="bh-toast-info-action">${escHtml(actionLabel)}</button>` : ""}
            `;
            const remaining = Number.isFinite(timeoutMs) ? timeoutMs : 5000;
            const card = { id: null, el, timer: null, remaining, enteredAt: 0, isMore: false, isInfo: true };
            const dismiss = () => this._remove(card);
            el.querySelector(".bh-toast-close").addEventListener("click", (e) => { e.stopPropagation(); dismiss(); });
            const actionBtn = el.querySelector(".bh-toast-info-action");
            if (actionBtn) {
                actionBtn.addEventListener("click", (e) => {
                    e.stopPropagation();
                    try { if (onAction) onAction(); } catch { /* noop */ }
                    dismiss();
                });
            }
            el.addEventListener("pointerenter", () => this._pauseTimer(card));
            el.addEventListener("pointerleave", () => this._resumeTimer(card));
            this._resumeTimer(card);
            this.container.appendChild(el);
            this._cards.push(card);
            this._updateClearAllButton();
        }

        showMany(bounties, { silent = false } = {}) {
            this.ensureContainer();
            // Clear-all / overflow cards don't count toward the bounty slot budget.
            const maxVisible = Math.max(1, Math.min(20, Number(this._notif().maxVisible) || 3));
            const existingBountyCards = this._cards.filter((c) => !c.isMore && !c.isClearAll).length;
            const slots = Math.max(0, maxVisible - existingBountyCards);
            const toShow = bounties.slice(0, slots);
            const overflow = bounties.length - toShow.length;
            for (const b of toShow) this._showOne(b);
            if (overflow > 0) this._showMoreCard(overflow);
            this._updateClearAllButton();
            // One beep per batch, not per card. `silent` lets the preview play it itself.
            if (!silent && bounties.length > 0) {
                const snd = this._notif().sound || DEFAULT_SETTINGS.notifications.sound;
                if (snd.enabled) SoundPlayer.play(snd.type, snd.volume);
            }
        }

        // Plays regardless of the enabled toggle, so Preview doubles as a sound test.
        playTestSound() {
            const snd = this._notif().sound || DEFAULT_SETTINGS.notifications.sound;
            SoundPlayer.play(snd.type, snd.volume);
        }

        _toastTimeoutMs() {
            const sec = Number(this._notif().timeoutSec);
            const clamped = Number.isFinite(sec) ? Math.max(3, Math.min(120, sec)) : 15;
            return clamped * 1000;
        }

        _showOne(b) {
            const el = document.createElement("div");
            el.className = "bh-toast";
            const fields = this._notif().fields || DEFAULT_SETTINGS.notifications.fields;
            const showLevel = fields.level !== false;
            const showReward = fields.reward !== false;
            const showFF = fields.ff !== false;
            const showBS = fields.bs !== false;
            const showStatus = fields.status !== false;
            const showLocation = fields.location !== false;
            const showBlacklist = fields.blacklist === true;
            const sm = fmt.statusMeta(b);
            const statusLabel = sm.text;
            const statusClass = sm.cls;
            const hospAttr = sm.attr;
            const countBadge = b.bountyCount > 1
                ? ` <span class="bh-count">×${b.bountyCount}</span>`
                : "";
            const levelSpan = showLevel
                ? ` <span class="bh-toast-lvl">L${b.target_level}</span>`
                : "";
            const rewardCell = showReward
                ? `<div class="bh-toast-reward">${escHtml(fmt.money(b.reward))}</div>`
                : "";
            const ffChip = showFF
                ? `<span class="bh-chip">FF ${b.ff == null ? "?" : b.ff.toFixed(2)}</span>`
                : "";
            const bsChip = (showBS && b.bs)
                ? `<span class="bh-chip">BS ${escHtml(b.bs)}</span>`
                : "";
            const statusChip = showStatus
                ? `<span class="bh-chip ${statusClass}"${hospAttr}>${statusLabel}</span>`
                : "";
            const locationChip = (showLocation && b.awayFromMe && b.location)
                ? `<span class="bh-chip bh-badge-travel">📍 ${escHtml(b.location)}</span>`
                : "";
            // warActive only set when the setting's on — no separate field toggle needed
            const warChip = b.warActive
                ? `<span class="bh-chip bh-badge-war" title="Faction in an active ranked war — only the war opponent can hit for 60s after hospital/jail.">⚔ War</span>`
                : "";
            const metaRow = (ffChip || bsChip || statusChip || warChip || locationChip)
                ? `<div class="bh-toast-meta">${ffChip}${bsChip}${statusChip}${warChip}${locationChip}</div>`
                : "";
            const actionsRow = showBlacklist
                ? `<div class="bh-toast-actions">
                       <button class="bh-toast-attack">Attack →</button>
                       <button class="bh-toast-blacklist" title="Add to blacklist">🚫</button>
                   </div>`
                : `<button class="bh-toast-attack">Attack →</button>`;
            const medOutBanner = b.medOut
                ? `<div class="bh-toast-medout">🏥→✅ Out of hospital — attack now</div>`
                : "";
            if (b.medOut) el.classList.add("bh-toast-medout-card");
            el.innerHTML = `
                <button class="bh-toast-close" title="Dismiss">&times;</button>
                ${medOutBanner}
                <div class="bh-toast-head">
                    <div class="bh-toast-name">${escHtml(b.target_name)}${levelSpan}${countBadge}</div>
                    ${rewardCell}
                </div>
                ${metaRow}
                ${actionsRow}
            `;
            const w = Math.max(140, Math.min(640, Number(this._notif().width) || 300));
            el.style.width = `${w}px`;
            const card = { id: Number(b.target_id), el, timer: null, remaining: this._toastTimeoutMs(), enteredAt: 0, isMore: false };

            const dismiss = () => this._remove(card);
            el.querySelector(".bh-toast-close").addEventListener("click", (e) => {
                e.stopPropagation();
                dismiss();
            });
            const attack = (e) => {
                if (e) { e.preventDefault(); e.stopPropagation(); }
                openTornUrl(ATTACK_URL + b.target_id);
                dismiss();
            };
            el.querySelector(".bh-toast-attack").addEventListener("click", attack);
            const blBtn = el.querySelector(".bh-toast-blacklist");
            if (blBtn) {
                blBtn.addEventListener("click", (e) => {
                    e.preventDefault();
                    e.stopPropagation();
                    dismiss();
                    if (window.__bhUI) window.__bhUI._blacklistWithUndo(b.target_id, b.target_name);
                });
            }

            // Desktop: whole card clickable. Touch: require the Attack button (mis-tap safety).
            const isTouch = (typeof matchMedia === "function") && matchMedia("(hover: none)").matches;
            if (!isTouch) el.addEventListener("click", attack);

            this._attachSwipeToDismiss(el, card);
            el.addEventListener("pointerenter", () => this._pauseTimer(card));
            el.addEventListener("pointerleave", () => this._resumeTimer(card));

            this._resumeTimer(card);
            this.container.appendChild(el);
            this._cards.push(card);
        }

        _showMoreCard(count) {
            // One unified overflow card (don't stack multiple).
            const existing = this._cards.find((c) => c.isMore);
            if (existing) {
                existing.count += count;
                existing.el.querySelector(".bh-toast-more-label").textContent = `+${existing.count} more`;
                return;
            }
            const el = document.createElement("div");
            el.className = "bh-toast bh-toast-more";
            el.innerHTML = `
                <div class="bh-toast-more-label">+${count} more</div>
                <div class="bh-toast-more-hint">Click to open the Hunt list</div>
            `;
            const w = Math.max(140, Math.min(640, Number(this._notif().width) || 300));
            el.style.width = `${w}px`;
            const card = { id: null, el, timer: null, remaining: this._toastTimeoutMs(), enteredAt: 0, isMore: true, count };
            el.addEventListener("click", () => {
                if (window.__bhUI) window.__bhUI.toggle(true);
                this._remove(card);
            });
            this._attachSwipeToDismiss(el, card);
            el.addEventListener("pointerenter", () => this._pauseTimer(card));
            el.addEventListener("pointerleave", () => this._resumeTimer(card));
            this._resumeTimer(card);
            this.container.appendChild(el);
            this._cards.push(card);
        }

        // Touch-swipe dismiss (desktop drags ignored so clicks aren't hijacked).
        // moved sticky once |dx|>6; capture-phase click swallow blocks a sub-threshold swipe from acting.
        _attachSwipeToDismiss(el, card) {
            let startX = 0, dx = 0, dragging = false, moved = false;
            el.addEventListener("pointerdown", (e) => {
                if (e.pointerType !== "touch") return;
                if (e.target.closest(".bh-toast-close")) return; // don't race the × tap
                dragging = true; moved = false; startX = e.clientX; dx = 0;
                el.style.transition = "none";
                try { el.setPointerCapture(e.pointerId); } catch { /* non-capturable pointer */ }
                this._pauseTimer(card);
            });
            el.addEventListener("pointermove", (e) => {
                if (!dragging) return;
                dx = e.clientX - startX;
                if (Math.abs(dx) > 6) moved = true;
                if (moved) {
                    el.style.transform = `translateX(${dx}px)`;
                    el.style.opacity = String(Math.max(0.3, 1 - Math.abs(dx) / 200));
                }
            });
            const endDrag = () => {
                if (!dragging) return;
                dragging = false;
                el.style.transition = "transform .18s ease-out, opacity .18s ease-out";
                if (Math.abs(dx) > 80) {
                    el.style.transform = `translateX(${dx > 0 ? 400 : -400}px)`;
                    el.style.opacity = "0";
                    setTimeout(() => this._remove(card), 180);
                } else {
                    el.style.transform = ""; el.style.opacity = "";
                    this._resumeTimer(card);
                }
            };
            el.addEventListener("pointerup", endDrag);
            el.addEventListener("pointercancel", endDrag);
            el.addEventListener("click", (e) => {
                if (moved) {
                    e.stopPropagation();
                    e.preventDefault();
                    moved = false;
                }
            }, true);
        }

        // "Clear all" pill above the stack when 2+ toasts. Only dismisses toasts — Hunt matches untouched.
        _updateClearAllButton() {
            const toastCount = this._cards.filter((c) => !c.isClearAll).length;
            const existing = this._clearAllEl;
            if (toastCount < 2) {
                if (existing) { existing.remove(); this._clearAllEl = null; }
                return;
            }
            if (existing) {
                // Keep it pinned to the visual top regardless of insertion order.
                this.container.appendChild(existing);
                return;
            }
            const btn = document.createElement("button");
            btn.type = "button";
            btn.className = "bh-toast-clear-btn";
            btn.textContent = "✕ Clear all";
            btn.addEventListener("click", (e) => { e.stopPropagation(); this.clearAll(); });
            // Pin to the same edge as the stack (right for *-right positions,
            // left for *-left) so the pill always sits over the toasts.
            const pos = this._notif().position || "bottom-right";
            btn.style.alignSelf = pos.endsWith("right") ? "flex-end" : "flex-start";
            this.container.appendChild(btn);
            this._clearAllEl = btn;
        }

        _pauseTimer(card) {
            if (!card.timer) return;
            clearTimeout(card.timer);
            card.timer = null;
            card.remaining = Math.max(0, card.remaining - (Date.now() - card.enteredAt));
        }

        _resumeTimer(card) {
            card.enteredAt = Date.now();
            if (card.timer) clearTimeout(card.timer);
            card.timer = setTimeout(() => this._remove(card), card.remaining);
        }

        _remove(card) {
            if (card.timer) clearTimeout(card.timer);
            if (card.el && card.el.parentNode) card.el.parentNode.removeChild(card.el);
            this._cards = this._cards.filter((c) => c !== card);
            this._updateClearAllButton();
        }

        clearAll() {
            for (const c of [...this._cards]) this._remove(c);
            if (this._clearAllEl) { this._clearAllEl.remove(); this._clearAllEl = null; }
        }

        // Drop toasts whose target left the match set. "+N more" is left — it's a batch, times out on its own.
        pruneTo(currentTargetIds) {
            for (const c of [...this._cards]) {
                if (c.isMore || c.isClearAll || c.id == null) continue;
                if (!currentTargetIds.has(c.id)) this._remove(c);
            }
        }
    }

    // ════════════════════════════════════════════════════════════
    //  CSS
    // ════════════════════════════════════════════════════════════

    function injectCSS() {
        if (document.getElementById("bh-style")) return;
        const style = document.createElement("style");
        style.id = "bh-style";
        style.textContent = `
#bh-overlay{display:none;position:fixed;inset:0;z-index:999998;background:rgba(0,0,0,.7)}
#bh-panel{display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:999999;
  background:#1a1a1a;border:1px solid #444;border-radius:10px;overflow:hidden;resize:both;
  font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#ddd;font-size:14px;
  width:760px;max-width:100vw;max-height:88vh;min-width:340px;min-height:300px;flex-direction:column}
#bh-panel.bh-open{display:flex}
#bh-overlay.bh-open{display:block}
#bh-panel *{box-sizing:border-box;color:inherit}
#bh-panel ::-webkit-scrollbar{width:6px;height:6px}
#bh-panel ::-webkit-scrollbar-track{background:#1a1a1a}
#bh-panel ::-webkit-scrollbar-thumb{background:#444;border-radius:3px}
#bh-panel ::-webkit-scrollbar-thumb:hover{background:#555}
#bh-header{display:flex;align-items:center;justify-content:space-between;padding:10px 16px;
  background:#222;border-bottom:1px solid #444;flex-shrink:0}
#bh-header h2{margin:0;font-size:17px;color:#fff}
#bh-header .bh-ver{color:#666;font-size:12px;margin-left:8px}
#bh-close{background:none;border:none;color:#999;font-size:22px;cursor:pointer;padding:4px 8px}
#bh-close:hover{color:#fff}
#bh-tabs{display:flex;background:#252525;border-bottom:1px solid #444;overflow-x:auto;flex-shrink:0}
.bh-tab{padding:10px 20px;cursor:pointer;color:#999!important;border-bottom:2px solid transparent;
  white-space:nowrap;font-size:14px;transition:all .15s}
.bh-tab:hover{color:#ccc!important;background:#2a2a2a}
.bh-tab.active{color:#ef5350!important;border-bottom-color:#ef5350}
/* Board (red) = bounty refresh, watch (amber) = watchlist poll. Outside #bh-content so they survive re-renders. */
#bh-progress{flex-shrink:0;background:#1c1c1c}
.bh-prog-lane{position:relative;height:3px;overflow:hidden;opacity:0;transition:opacity .25s}
#bh-prog-watch{height:2px}
.bh-prog-lane.active{opacity:1}
.bh-prog-fill{position:absolute;top:0;bottom:0;width:35%;left:-35%;border-radius:2px}
#bh-prog-board .bh-prog-fill{background:linear-gradient(90deg,transparent,#ef5350,transparent)}
#bh-prog-watch .bh-prog-fill{background:linear-gradient(90deg,transparent,#f0ad4e,transparent)}
.bh-prog-lane.busy .bh-prog-fill{animation:bh-prog-sweep .9s linear infinite}
.bh-prog-lane.done .bh-prog-fill{left:0;width:100%;background:#4caf50;animation:none}
@keyframes bh-prog-sweep{0%{left:-35%}100%{left:100%}}
#bh-content{padding:16px;overflow-y:auto;flex:1;min-height:0}
#bh-status-line{display:flex;gap:12px;align-items:center;margin-bottom:12px;color:#888;font-size:12px;flex-wrap:wrap}
#bh-status-line .bh-err{color:#ef5350}
.bh-rl-banner{background:#2a1414;border:1px solid #ef5350;border-left:4px solid #ef5350;border-radius:4px;padding:10px 14px;margin:0 0 12px;color:#ddd;font-size:13px}
.bh-rl-title{color:#ef5350;font-weight:700;font-size:13px;margin-bottom:6px}
.bh-rl-body{color:#ccc;line-height:1.45;font-size:12px}
.bh-rl-tips{margin:6px 0 6px 20px;padding:0;color:#ddd}
.bh-rl-tips li{margin:2px 0}
.bh-rl-hint{color:#888;font-size:11px;font-style:italic}
.bh-rl-pill{display:inline-block;margin-left:6px;padding:1px 6px;border-radius:8px;background:#ef5350;color:#1a0a0a;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;vertical-align:middle}
.bh-paused-banner{background:#1e2833;border:1px solid #3a5a7a;border-left:4px solid #4fa3d4;border-radius:4px;padding:8px 12px;margin:0 0 12px;color:#cfe4f2;font-size:12px}
.bh-donor-banner{background:linear-gradient(180deg,#1c2a1c,#162216);border:1px solid #2e4a2e;border-left:3px solid #4caf50;border-radius:4px;padding:8px 36px 8px 12px;margin:0 0 12px;color:#a5d6a7;font-size:13px;line-height:1.4;position:relative}
.bh-donor-banner b{color:#c5e1a5}
.bh-donor-dismiss{position:absolute;top:4px;right:6px;background:none;border:none;color:#6e836e;font-size:18px;cursor:pointer;padding:0 4px;line-height:1}
.bh-donor-dismiss:hover{color:#fff}
#bh-refresh-btn{padding:4px 10px;background:#333;border:1px solid #444;color:#ddd;border-radius:4px;cursor:pointer;font-size:12px}
#bh-refresh-btn:hover{background:#3a3a3a}
#bh-refresh-btn:disabled{opacity:.5;cursor:not-allowed}

table.bh-table{width:100%;border-collapse:collapse}
.bh-table th,.bh-table td{padding:8px 14px;text-align:left;border-bottom:1px solid #333;font-size:13px;
  color:#ddd;vertical-align:middle;white-space:nowrap}
.bh-table th{color:#999!important;font-weight:600;text-transform:uppercase;font-size:11px;position:sticky;
  top:0;background:#1a1a1a;border-bottom:2px solid #444;user-select:none}
.bh-table th[data-sort]{cursor:pointer}
.bh-table th[data-sort]:hover{color:#fff!important}
.bh-table th[data-sort]::after{content:" ⇅";color:#555;font-size:10px}
.bh-table th[data-sort].sort-asc::after{content:" ▲";color:#ef5350;font-size:10px}
.bh-table th[data-sort].sort-desc::after{content:" ▼";color:#ef5350;font-size:10px}
.bh-table tbody tr:hover td{background:#252525}
.bh-table td.num,.bh-table th.num{text-align:right;font-variant-numeric:tabular-nums}
.bh-table td.bh-col-divider,.bh-table th.bh-col-divider{padding-left:18px;border-left:1px solid #2a2a2a}
.bh-attack{display:inline-block;padding:4px 10px;background:#ef5350;color:#fff!important;border-radius:4px;
  text-decoration:none;font-weight:600;font-size:12px;border:none;cursor:pointer}
.bh-attack:hover{background:#f44336}
.bh-name-link{color:#4fc3f7!important;text-decoration:none}
.bh-name-link:hover{text-decoration:underline}
.bh-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;vertical-align:middle;flex-shrink:0}
.bh-dot-on{background:#4caf50;box-shadow:0 0 4px rgba(76,175,80,.7)}
.bh-dot-idle{background:#ffb74d}
.bh-dot-off{background:#666}
.bh-badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600}
.bh-badge-ok{background:#1e3a2a;color:#4caf50}
.bh-badge-hosp{background:#3a2a1e;color:#ffb74d}
.bh-badge-travel{background:#1e2a3a;color:#64b5f6}
.bh-badge-war{background:#3a1e1e;color:#ff7043}
.bh-count{display:inline-block;padding:1px 6px;margin-left:6px;background:#3a2a4a;color:#c49cff;border-radius:8px;font-size:10px;font-weight:700;vertical-align:middle}
.bh-empty{text-align:center;color:#888;padding:30px 8px;font-size:14px}
.bh-empty button{margin-top:10px}

.bh-section{margin-bottom:20px}
.bh-section h3{margin:0 0 8px;font-size:14px;color:#eee}
.bh-section p,.bh-hint{color:#888;font-size:12px;margin:4px 0}
.bh-grid-2{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.bh-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}
.bh-field label{color:#bbb;font-size:12px;text-transform:uppercase;letter-spacing:.5px}
.bh-input,.bh-select{background:#252525;border:1px solid #444;color:#ddd;padding:6px 10px;border-radius:4px;font-size:14px;width:100%}
/* CSS-masked, not type=password — avoids browser "save login" + password-manager auto-fill. */
.bh-input-masked{-webkit-text-security:disc;text-security:disc;font-family:monospace;letter-spacing:2px}
.bh-btn{padding:7px 14px;border:none;border-radius:4px;cursor:pointer;font-size:13px;font-weight:600;color:#ddd}
.bh-btn-primary{background:#4fc3f7;color:#111!important}.bh-btn-primary:hover{background:#29b6f6}
.bh-btn-danger{background:#ef5350;color:#fff!important}.bh-btn-danger:hover{background:#f44336}
.bh-btn-muted{background:#333;border:1px solid #444;color:#ddd}.bh-btn-muted:hover{background:#3a3a3a}
.bh-btn:disabled{opacity:.5;cursor:not-allowed}
.bh-row-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
.bh-check{display:inline-flex;align-items:center;gap:8px;cursor:pointer;color:#ccc}
.bh-check input{accent-color:#ef5350}
.bh-save-status{margin-top:8px;min-height:1em;font-size:12px}
.bh-save-ok{color:#4caf50}
.bh-save-err{color:#ef5350}
.bh-save-info{color:#bbb}
.bh-debug-log{margin-top:8px;max-height:220px;overflow-y:auto;background:#111;border:1px solid #333;border-radius:4px;padding:6px;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:11px;line-height:1.5}
.bh-debug-hint{color:#666;font-size:11px}
.bh-debug-row{padding:1px 0;color:#aaa;word-break:break-all}
.bh-debug-ts{color:#666;margin-right:4px}
.bh-debug-ms{color:#4caf50;margin-left:4px}
.bh-debug-ok{color:#aaa}
.bh-debug-info{color:#bbb}
.bh-debug-warn{color:#ffb74d}
.bh-debug-err{color:#ef5350}

/* Auth screen */
#bh-auth{padding:24px;color:#ddd;line-height:1.5}
#bh-auth h3{margin:0 0 12px;color:#fff}
#bh-auth .bh-auth-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:14px}

/* Toasts — anchor/align/width set inline by Toaster.applySettings() from notifications config. */
#bh-toasts{position:fixed;display:flex;flex-direction:column-reverse;gap:8px;
  z-index:2147483646;pointer-events:none;max-width:calc(100vw - 32px)}
.bh-toast{pointer-events:auto;width:300px;max-width:100%;background:#1e1e1e;border:1px solid #444;border-left:4px solid #ef5350;
  border-radius:8px;padding:10px 12px;color:#ddd;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;
  font-size:13px;box-shadow:0 4px 16px rgba(0,0,0,.5);position:relative;cursor:pointer;
  touch-action:pan-y;user-select:none;-webkit-user-select:none;
  animation:bh-toast-in .2s ease-out}
@keyframes bh-toast-in{from{opacity:0;transform:translateX(20px)}to{opacity:1;transform:translateX(0)}}
.bh-toast:hover{border-color:#666}
.bh-toast-close{position:absolute;top:4px;right:6px;background:none;border:none;color:#777;font-size:18px;
  cursor:pointer;padding:0 4px;line-height:1}
.bh-toast-close:hover{color:#fff}
.bh-toast-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px;margin:0 18px 4px 0}
.bh-toast-name{color:#fff;font-weight:600;overflow:hidden;text-overflow:ellipsis}
.bh-toast-lvl{color:#888;font-size:11px;font-weight:400;margin-left:4px}
.bh-toast-reward{color:#4caf50;font-weight:700;font-size:15px;white-space:nowrap}
.bh-toast-meta{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px}
.bh-chip{background:#2a2a2a;border:1px solid #3a3a3a;padding:2px 8px;border-radius:10px;font-size:11px;color:#bbb}
.bh-chip.bh-badge-ok{background:#1e3a2a;color:#4caf50;border-color:#1e3a2a}
.bh-chip.bh-badge-hosp{background:#3a2a1e;color:#ffb74d;border-color:#3a2a1e}
.bh-chip.bh-badge-travel{background:#1e2a3a;color:#64b5f6;border-color:#1e2a3a}
.bh-chip.bh-badge-war{background:#3a1e1e;color:#ff7043;border-color:#3a1e1e}
.bh-toast-attack{width:100%;padding:6px 10px;background:#ef5350;color:#fff;border:none;border-radius:4px;
  cursor:pointer;font-weight:600;font-size:12px}
.bh-toast-attack:hover{background:#f44336}
.bh-toast-more{border-left-color:#888;text-align:center}
.bh-toast-more-label{color:#fff;font-weight:700;font-size:16px}
.bh-toast-more-hint{color:#888;font-size:11px;margin-top:2px}
.bh-toast-info{border-left-color:#888;cursor:default}
.bh-toast-info-msg{color:#ddd;margin:0 18px 6px 0;font-size:13px}
.bh-toast-info-action{padding:6px 10px;background:#333;color:#fff;border:1px solid #555;border-radius:4px;cursor:pointer;font-weight:600;font-size:12px}
.bh-toast-info-action:hover{background:#3a3a3a;border-color:#777}
.bh-toast-actions{display:flex;gap:6px;align-items:stretch}
.bh-toast-actions .bh-toast-attack{flex:1;width:auto}
.bh-toast-blacklist{padding:6px 10px;background:#2a2a2a;color:#ddd;border:1px solid #444;border-radius:4px;cursor:pointer;font-size:14px;line-height:1}
.bh-toast-blacklist:hover{background:#3a2a2a;border-color:#a44}

/* Hunt-list row blacklist icon — sits next to Attack in the actions cell. */
.bh-row-actions-cell{white-space:nowrap}
.bh-row-blacklist{margin-left:6px;padding:4px 8px;background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:4px;cursor:pointer;font-size:13px;line-height:1;vertical-align:middle}
.bh-row-blacklist:hover{background:#3a2a2a;border-color:#a44;color:#fff}
.bh-row-watch{margin-left:6px;padding:4px 8px;background:#2a2a2a;color:#ccc;border:1px solid #444;border-radius:4px;cursor:pointer;font-size:13px;line-height:1;vertical-align:middle;filter:grayscale(1);opacity:.75}
.bh-row-watch:hover{background:#1e3038;border-color:#4fc3f7;color:#fff;opacity:1}
.bh-row-watch.watching{filter:none;opacity:1;background:#12303a;border-color:#4fc3f7}

/* API-call rate counter (Hunt status line) */
.bh-api-counter{font-size:11px;color:#7a8a7a;background:#1a231a;border:1px solid #2a3a2a;border-radius:10px;padding:1px 8px;font-variant-numeric:tabular-nums;cursor:default}
.bh-api-counter.warn{color:#ffb0b0;background:#2f1a1a;border-color:#5a2a2a;font-weight:600}

/* Hunt master on/off toggle */
.bh-search-toggle{border:none;border-radius:4px;padding:4px 12px;font-weight:700;font-size:12px;cursor:pointer;font-family:inherit}
.bh-search-toggle.on{background:#1e3a2a;color:#4caf50}
.bh-search-toggle.on:hover{background:#244a33}
.bh-search-toggle.off{background:#3a2a1e;color:#ffb74d}
.bh-search-toggle.off:hover{background:#4a3626}

/* Inline field row (e.g. FF min–max) */
.bh-inline{display:flex;align-items:center;gap:6px}

/* Advanced disclosure */
.bh-adv > summary{cursor:pointer;color:#ccc;font-size:13px;font-weight:600;padding:4px 0;list-style:revert}
.bh-adv > summary:hover{color:#fff}
.bh-adv[open] > summary{margin-bottom:4px;color:#4fc3f7}

/* Watchlist strip (above the Hunt table) */
.bh-watchlist{background:#14232a;border:1px solid #24424f;border-left:3px solid #4fc3f7;border-radius:4px;padding:8px 10px;margin:0 0 10px}
.bh-wl-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;font-size:12px;color:#cbe6f0;font-weight:600;margin-bottom:6px}
.bh-wl-proj{font-weight:600;color:#8fd0e6;background:#0f2731;border:1px solid #24424f;border-radius:10px;padding:1px 8px;font-variant-numeric:tabular-nums}
.bh-wl-proj.warn{color:#ffb0b0;background:#2f1a1a;border-color:#5a2a2a}
.bh-wl-hint{font-weight:400;color:#7a94a0}
.bh-wl-items{display:flex;flex-wrap:wrap;gap:8px}
.bh-wl-item{display:inline-flex;align-items:center;gap:6px;background:#0f2731;border:1px solid #24424f;border-radius:12px;padding:2px 6px 2px 10px;font-size:12px}
.bh-wl-remove{background:none;border:none;color:#8aa;cursor:pointer;font-size:15px;line-height:1;padding:0 2px}
.bh-wl-remove:hover{color:#ef5350}

/* Med-out alert toast */
.bh-toast-medout-card{border-color:#4caf50!important;box-shadow:0 2px 10px rgba(76,175,80,.35)!important}
.bh-toast-medout{background:#173d22;color:#8ff0a4;font-size:11px;font-weight:700;text-align:center;border-radius:4px;padding:3px 6px;margin-bottom:6px;letter-spacing:.3px}

/* Blacklist section (Script tab) — compact table layout */
.bh-bl-banner{background:#2a2418;border:1px solid #5a4a28;border-left:3px solid #c08030;border-radius:4px;padding:6px 10px;margin:0 0 8px;color:#eed8b0;font-size:11px;line-height:1.35}
.bh-bl-table{width:100%;border-collapse:collapse;font-size:12px}
.bh-bl-table th{text-align:left;padding:4px 6px;color:#888;font-weight:600;font-size:10px;text-transform:uppercase;letter-spacing:.4px;border-bottom:1px solid #333;background:#1f1f1f}
.bh-bl-table td{padding:3px 6px;border-bottom:1px solid #262626;vertical-align:middle}
.bh-bl-table tr:hover td{background:#222}
.bh-bl-table input.bh-bl-note{width:100%;padding:3px 6px;font-size:12px;background:#1a1a1a;border:1px solid transparent;border-radius:3px}
.bh-bl-table input.bh-bl-note:hover{border-color:#333}
.bh-bl-table input.bh-bl-note:focus{border-color:#4fc3f7;outline:none;background:#222}
.bh-bl-name-cell{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:200px}
.bh-bl-name-unknown{color:#888!important;font-style:italic}
.bh-bl-x{background:none;border:none;color:#888;cursor:pointer;font-size:14px;padding:2px 6px;line-height:1;border-radius:3px}
.bh-bl-x:hover{color:#ef5350;background:#2a1818}
.bh-bl-empty{text-align:center;color:#666;font-style:italic;padding:14px 8px!important;font-size:12px}
.bh-toast-clear-btn{pointer-events:auto;align-self:flex-end;background:rgba(30,30,30,0.9);color:#aaa;
  border:1px solid #444;border-radius:14px;padding:3px 10px;font-size:11px;font-weight:600;
  letter-spacing:.5px;text-transform:uppercase;cursor:pointer;font-family:inherit;
  box-shadow:0 2px 6px rgba(0,0,0,.4)}
.bh-toast-clear-btn:hover{background:#2a2a2a;color:#fff;border-color:#666}

@media(max-width:768px){
  #bh-panel{width:100vw!important;max-width:100vw;min-width:0;border-radius:0;top:0;left:0;
    transform:none;max-height:100vh;height:100vh}
  .bh-grid-2{grid-template-columns:1fr}
  .bh-table td,.bh-table th{padding:6px 6px;font-size:12px}
  /* comfier touch padding; container max-width clamps to screen */
  .bh-toast{padding:14px 12px 12px}
  /* ≥44x44 tap target so fingers don't miss into the card (which attacks). */
  .bh-toast-close{top:0;right:0;min-width:44px;min-height:44px;padding:0;font-size:26px;
    display:flex;align-items:center;justify-content:center}
  .bh-toast-head{margin-right:44px}
}
`;
        document.head.appendChild(style);
    }

    // ════════════════════════════════════════════════════════════
    //  UI
    // ════════════════════════════════════════════════════════════

    class UI {
        constructor(hunter, toaster) {
            this.hunter = hunter;
            this.toaster = toaster;
            this.activeTab = "hunt";
            this._countdownTimer = null;
            this._panel = null;
            this._overlay = null;
            this._authed = false;
            this._sortCol = "reward";
            this._sortDir = "desc";
            this._laneTimers = {}; // per-lane "done"-flash fade-out timers
        }

        inject() {
            injectCSS();

            const overlay = document.createElement("div");
            overlay.id = "bh-overlay";
            document.body.appendChild(overlay);

            const panel = document.createElement("div");
            panel.id = "bh-panel";
            panel.innerHTML = `
                <div id="bh-header">
                    <h2>Bounty Hunter <span class="bh-ver">v${VERSION}</span>
                        <span style="color:#888;font-size:11px;font-weight:400;margin-left:10px">
                            Like the script? Send a Xanax to
                            <a href="https://www.torn.com/profiles.php?XID=4192025" target="_blank"
                               style="color:#cc3333;text-decoration:none">eugene_s [4192025]</a>
                        </span>
                    </h2>
                    <button id="bh-close">&times;</button>
                </div>
                <div id="bh-tabs">
                    <div class="bh-tab active" data-tab="hunt">Hunt</div>
                    <div class="bh-tab" data-tab="filters">Filters</div>
                    <div class="bh-tab" data-tab="alerts">Alerts</div>
                    <div class="bh-tab" data-tab="blacklist">Blacklist</div>
                    <div class="bh-tab" data-tab="key">Key</div>
                </div>
                <div id="bh-progress">
                    <div class="bh-prog-lane" id="bh-prog-board"><div class="bh-prog-fill"></div></div>
                    <div class="bh-prog-lane" id="bh-prog-watch"><div class="bh-prog-fill"></div></div>
                </div>
                <div id="bh-content"></div>
            `;
            document.body.appendChild(panel);
            this._panel = panel;
            this._overlay = overlay;

            overlay.addEventListener("click", () => this.toggle(false));
            panel.querySelector("#bh-close").addEventListener("click", () => this.toggle(false));
            panel.querySelector("#bh-tabs").addEventListener("click", (e) => {
                const t = e.target.closest(".bh-tab");
                if (!t) return;
                this.activeTab = t.dataset.tab;
                this._syncTabs();
                this._renderActive();
            });

            // hunter updates → UI refresh; {loading} drives the board activity bar even when closed
            this.hunter.onUpdate = (opts) => {
                if (opts && typeof opts.loading === "boolean") this._setLaneBusy("bh-prog-board", opts.loading);
                if (this._isOpen()) this._renderActive();
            };
            this.hunter.onWatchPoll = (busy) => this._setLaneBusy("bh-prog-watch", busy);
            this.hunter.onToast = (bounties) => this.toaster.showMany(bounties);
            this.hunter.onMatchesApplied = (ids) => this.toaster.pruneTo(ids);
            // Med-out alerts bypass the toast master switch — the user asked to watch these.
            this.hunter.onWatchToast = (bounties) => this.toaster.showMany(bounties);

            // 1s ticker: header "next refresh", API counter, and live hospital-countdown badges (toasts too).
            this._countdownTimer = setInterval(() => {
                if (this._isOpen() && this.activeTab === "hunt") {
                    const el = document.getElementById("bh-countdown");
                    if (el) el.textContent = this.hunter.secondsUntilRefresh() + "s";
                    const cEl = document.getElementById("bh-api-counter");
                    if (cEl) {
                        const n = apiCallsLastMinute();
                        cEl.textContent = `API ${n}/min`;
                        cEl.className = n >= RATE_WARN ? "bh-api-counter warn" : "bh-api-counter";
                    }
                    const wEl = document.getElementById("bh-wl-countdown");
                    if (wEl && this.hunter.settings.searchEnabled) wEl.textContent = this.hunter.secondsUntilWatchPoll();
                }
                document.querySelectorAll("[data-hosp-until]").forEach((el) => {
                    const until = parseInt(el.dataset.hospUntil, 10);
                    if (!Number.isFinite(until)) return;
                    el.textContent = fmt.hospLabel(until);
                });
            }, 1000);
        }

        _isOpen() { return this._panel && this._panel.classList.contains("bh-open"); }

        // Drive an activity bar: busy → sweep; else green "done" flash then fade. A new busy cancels a pending fade.
        _setLaneBusy(id, busy) {
            const lane = this._panel && this._panel.querySelector("#" + id);
            if (!lane) return;
            if (this._laneTimers[id]) { clearTimeout(this._laneTimers[id]); this._laneTimers[id] = null; }
            if (busy) {
                lane.classList.remove("done");
                lane.classList.add("active", "busy");
            } else {
                lane.classList.remove("busy");
                lane.classList.add("done");
                this._laneTimers[id] = setTimeout(() => {
                    lane.classList.remove("active", "done");
                    this._laneTimers[id] = null;
                }, 650);
            }
        }

        toggle(open) {
            const isOpen = open != null ? open : !this._isOpen();
            this._panel.classList.toggle("bh-open", isOpen);
            this._overlay.classList.toggle("bh-open", isOpen);
            if (isOpen) this._renderActive();
        }

        _syncTabs() {
            for (const t of this._panel.querySelectorAll(".bh-tab")) {
                t.classList.toggle("active", t.dataset.tab === this.activeTab);
            }
        }

        _renderActive() {
            if (!this._authed) { this._renderAuth(); return; }
            switch (this.activeTab) {
                case "hunt":      this._renderHunt();         break;
                case "filters":   this._renderFilters();      break;
                case "alerts":    this._renderAlerts();       break;
                case "blacklist": this._renderBlacklistTab(); break;
                case "key":       this._renderKey();          break;
                // Migrate legacy tab ids (script/uiux/api/settings) to the new set.
                case "script": case "settings": this.activeTab = "filters"; this._syncTabs(); this._renderFilters(); break;
                case "uiux":   this.activeTab = "alerts"; this._syncTabs(); this._renderAlerts(); break;
                case "api":    this.activeTab = "key"; this._syncTabs(); this._renderKey(); break;
                default:       this._renderHunt();
            }
        }

        setAuthed(b) {
            this._authed = b;
            if (this._isOpen()) this._renderActive();
        }

        _renderAuth() {
            const content = this._panel.querySelector("#bh-content");
            const spaAvailable = !IS_PDA && KeyResolver.hasSPAKey();
            content.innerHTML = `
                <div id="bh-auth">
                    <h3>Set your Torn API key</h3>
                    <p>Bounty Hunter needs a <b>public</b> Torn API key to read the global bounty board, each target's status, and your own user ID. The key is stored in this browser's localStorage and used only against <code>api.torn.com</code>.</p>
                    <p class="bh-hint">Get one at <a class="bh-name-link" href="https://www.torn.com/preferences.php#tab=api" target="_blank" rel="noopener">torn.com → Preferences → API Key</a>. "Public" access is sufficient.</p>
                    <div class="bh-field">
                        <label>Torn API key (16 chars)</label>
                        <input id="bh-auth-key" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other">
                    </div>
                    <div class="bh-auth-actions">
                        <button id="bh-auth-save" class="bh-btn bh-btn-primary">Save &amp; start</button>
                        ${spaAvailable ? `<button id="bh-auth-spa" class="bh-btn bh-btn-muted">Use Supply Pack Analyzer's saved key</button>` : ""}
                    </div>
                    <p class="bh-hint" id="bh-auth-err" style="color:#ef5350;min-height:1em"></p>
                    <hr style="border:none;border-top:1px solid #333;margin:16px 0">
                    <h3>Optional: FFScouter key</h3>
                    <p>FFScouter provides the fair-fight score. Without it Bounty Hunter can't filter targets by FF — which is the whole point of the script.</p>
                    <p class="bh-hint">Get one at <a class="bh-name-link" href="https://ffscouter.com" target="_blank" rel="noopener">ffscouter.com</a>. You can set this later in the Key tab.</p>
                    <div class="bh-field">
                        <label>FFScouter key (16 chars)</label>
                        <input id="bh-auth-ffkey" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" value="${escHtml(KeyResolver.getFFKey())}">
                    </div>
                </div>
            `;

            const keyInput = content.querySelector("#bh-auth-key");
            const err = content.querySelector("#bh-auth-err");
            const save = async () => {
                err.textContent = "";
                const k = keyInput.value.trim();
                if (!/^[A-Za-z0-9]{16}$/.test(k)) {
                    err.textContent = "That doesn't look like a 16-character Torn key.";
                    return;
                }
                await this._adoptKey(k, content.querySelector("#bh-auth-ffkey").value.trim());
            };
            content.querySelector("#bh-auth-save").addEventListener("click", save);
            keyInput.addEventListener("keydown", (e) => { if (e.key === "Enter") save(); });

            const spaBtn = content.querySelector("#bh-auth-spa");
            if (spaBtn) {
                spaBtn.addEventListener("click", async () => {
                    err.textContent = "";
                    const k = KeyResolver.getSPAKey();
                    if (!k) { err.textContent = "Supply Pack Analyzer's key isn't available."; return; }
                    await this._adoptKey(k, content.querySelector("#bh-auth-ffkey").value.trim());
                });
            }
        }

        async _adoptKey(tornKey, ffKey) {
            const err = this._panel.querySelector("#bh-auth-err");
            if (err) err.textContent = "Validating…";
            this.hunter.api.setKey(tornKey);
            try {
                await this.hunter.api.validateKey();
            } catch (e) {
                if (err) err.textContent = "Torn rejected that key: " + (e.message || "invalid");
                return;
            }
            KeyResolver.saveTornKey(tornKey);
            if (ffKey && /^[A-Za-z0-9]{16}$/.test(ffKey)) KeyResolver.saveFFKey(ffKey);
            this.setAuthed(true);
            this.hunter.start();
        }

        _renderHunt() {
            const content = this._panel.querySelector("#bh-content");
            const s = this.hunter.settings;
            const rows = this._sortMatches(this.hunter.lastMatches);
            const c = this.hunter.lastCounts;
            const nextIn = this.hunter.secondsUntilRefresh();
            const rateLimited = isRateLimitError(this.hunter.lastError);
            // generic error line only when not a rate-limit (that gets its own banner)
            const errLine = (this.hunter.lastError && !rateLimited)
                ? `<span class="bh-err">${escHtml(this.hunter.lastError.message || "error")}</span>`
                : "";
            const ffKeyMissing = !KeyResolver.getFFKey() && !this.hunter.settings.includeUnknownFF
                ? `<span class="bh-err">No FFScouter key set — every target will be excluded. Add one in the Key tab, or enable "Include unknown-FF targets" in Filters.</span>`
                : "";
            const ffError = c && c.ffError
                ? `<span class="bh-err">FFScouter: ${escHtml(c.ffError)}</span>`
                : "";
            const thCls = (col) => {
                if (this._sortCol !== col) return "";
                return this._sortDir === "asc" ? "sort-asc" : "sort-desc";
            };
            // Rate-limit banner, three states: partial (aborted mid-scan, incomplete rows), stale prior (kept a prior scan), empty (nothing to fall back on).
            const isPartial = rateLimited && !!this.hunter.partialFromRateLimit;
            const scanned = c && typeof c.scanned === "number" ? c.scanned : null;
            const afterFF = c && typeof c.afterFF === "number" ? c.afterFF : null;
            const scanProgress = (scanned != null && afterFF != null)
                ? `scanned <b>${scanned}</b> of <b>${afterFF}</b> candidate target${afterFF === 1 ? "" : "s"} before the cap`
                : "before the scan could finish";
            const rateLimitTitle = isPartial
                ? "API rate limit hit — partial results shown"
                : (rows.length > 0
                    ? "API rate limit hit — showing previous results"
                    : "API rate limit hit — no results to show");
            const rateLimitLead = isPartial
                ? `Torn's API limited us (~100 requests / minute) and we ${scanProgress}. The table below is <b>incomplete</b> — more matches may exist among the targets we couldn't check this cycle.`
                : (rows.length > 0
                    ? `Torn's API limited us (~100 requests / minute) ${scanProgress}. The table below is from the <b>last successful refresh</b> and may be slightly stale.`
                    : `Torn's API limited us (~100 requests / minute) ${scanProgress}. No matches could be confirmed and no prior cycle has succeeded yet.`);
            const rateLimitBanner = rateLimited
                ? `
                    <div class="bh-rl-banner">
                        <div class="bh-rl-title">${rateLimitTitle}</div>
                        <div class="bh-rl-body">
                            ${rateLimitLead}
                            <br><br>
                            <b>Why this happens:</b> wide filters mean every bounty needs a per-target profile lookup, and Torn caps how fast we can do that. <b>Tighten your filters so fewer targets need scanning:</b>
                            <ul class="bh-rl-tips">
                                <li>Raise <b>Min reward</b> (e.g. $1M+) — skips low-value bounties.</li>
                                <li>Narrow <b>Fair-fight</b> range — excludes targets outside your combat bracket.</li>
                                <li>Lower <b>Hospital max</b> — skips long-hospital targets that aren't actionable.</li>
                                <li>Increase <b>Auto-refresh</b> interval (e.g. 2 min+) — spreads calls over time.</li>
                            </ul>
                            <span class="bh-rl-hint">Auto-retry will continue in the background.</span>
                        </div>
                    </div>
                `
                : "";
            // Why the loop is idle: master off, low energy, or our own hospital/jail/travel.
            const SELF_PAUSE_COPY = {
                hospital: "You're in hospital",
                jail: "You're in jail",
                traveling: "You're travelling",
            };
            const pr = this.hunter.pausedReason;
            const pausedBanner = pr === "low-energy" && this.hunter.myEnergy
                ? `<div class="bh-paused-banner">⏸ Paused — energy <b>${this.hunter.myEnergy.current}/${this.hunter.myEnergy.maximum}</b> (below ${this.hunter.settings.minEnergy}). Auto-refresh resumes when you recover enough to attack.</div>`
                : SELF_PAUSE_COPY[pr]
                    ? `<div class="bh-paused-banner">⏸ Paused — ${SELF_PAUSE_COPY[pr]}. Auto-refresh resumes once you're back and able to attack.</div>`
                    : pr === "disabled"
                        ? `<div class="bh-paused-banner">⏸ Search paused — click the <b>⏸ Paused</b> button above to resume.</div>`
                        : "";
            content.innerHTML = `
                <div id="bh-donor-host"></div>
                <div id="bh-status-line">
                    <button id="bh-search-toggle" class="bh-search-toggle ${s.searchEnabled ? "on" : "off"}" title="${s.searchEnabled ? "Bounty search is running — click to pause all polling" : "Bounty search is paused — click to resume"}">${s.searchEnabled ? "▶ Running" : "⏸ Paused"}</button>
                    <button id="bh-refresh-btn" class="bh-btn-muted">Refresh now</button>
                    <span>Next refresh in <span id="bh-countdown">${nextIn}</span>s</span>
                    <span>${rows.length} match${rows.length === 1 ? "" : "es"}${isPartial ? ' <span class="bh-rl-pill">partial</span>' : ""}</span>
                    ${this._apiCounterHtml()}
                    ${errLine}
                    ${ffKeyMissing}
                    ${ffError}
                </div>
                ${pausedBanner}
                ${this._renderWatchStrip()}
                ${rateLimitBanner}
                ${rows.length === 0 ? (rateLimited ? "" : `
                    <div class="bh-empty">
                        No bounties match your filters right now.<br>
                        <span style="color:#666">Adjust min price, FF range, or hospital window in the Filters tab.</span>
                    </div>
                `) : `
                    <table class="bh-table">
                        <thead>
                            <tr>
                                <th data-sort="target" class="${thCls("target")}">Target</th>
                                <th data-sort="reward" class="num ${thCls("reward")}">Reward</th>
                                <th data-sort="ff" class="num ${thCls("ff")}">FF</th>
                                <th data-sort="bs" class="num bh-col-divider ${thCls("bs")}">BS</th>
                                <th data-sort="status" class="bh-col-divider ${thCls("status")}">Status</th>
                                <th></th>
                            </tr>
                        </thead>
                        <tbody>
                            ${rows.map((row) => this._renderRow(row)).join("")}
                        </tbody>
                    </table>
                `}
            `;
            this._renderDonorBanner(content.querySelector("#bh-donor-host"));
            const toggle = content.querySelector("#bh-search-toggle");
            if (toggle) {
                toggle.addEventListener("click", () => {
                    // Master on/off for board + watchlist; persisted + cross-tab synced.
                    this.hunter.updateSettings({ searchEnabled: !this.hunter.settings.searchEnabled });
                    this._restartLoopFromSettings();
                    this.hunter.ensureWatchLoop();
                    this._renderHunt();
                });
            }
            const btn = content.querySelector("#bh-refresh-btn");
            btn.addEventListener("click", async () => {
                btn.disabled = true;
                try { await this.hunter.manualRefresh(); } catch { /* shown in status line */ }
                btn.disabled = false;
            });
            content.querySelectorAll(".bh-attack").forEach((el) => {
                el.addEventListener("click", (e) => {
                    e.preventDefault();
                    const id = el.dataset.id;
                    openTornUrl(ATTACK_URL + id);
                });
            });
            content.querySelectorAll(".bh-row-blacklist").forEach((el) => {
                el.addEventListener("click", (e) => {
                    e.preventDefault();
                    this._blacklistWithUndo(el.dataset.id, el.dataset.name);
                });
            });
            content.querySelectorAll(".bh-row-watch").forEach((el) => {
                el.addEventListener("click", (e) => {
                    e.preventDefault();
                    const id = el.dataset.id;
                    if (this.hunter.isWatched(id)) {
                        this.hunter.removeFromWatchlist(id);
                    } else {
                        this.hunter.addToWatchlist({
                            target_id: id,
                            target_name: el.dataset.name,
                            reward: Number(el.dataset.reward),
                            statusState: el.dataset.state,
                        });
                    }
                    this._renderHunt();
                });
            });
            content.querySelectorAll(".bh-wl-remove").forEach((el) => {
                el.addEventListener("click", (e) => {
                    e.preventDefault();
                    this.hunter.removeFromWatchlist(el.dataset.id);
                    this._renderHunt();
                });
            });
            content.querySelectorAll("th[data-sort]").forEach((el) => {
                el.addEventListener("click", () => {
                    const col = el.dataset.sort;
                    if (this._sortCol === col) {
                        this._sortDir = this._sortDir === "asc" ? "desc" : "asc";
                    } else {
                        this._sortCol = col;
                        // numeric cols default desc, text cols asc
                        this._sortDir = (col === "target") ? "asc" : "desc";
                    }
                    this._renderHunt();
                });
            });
        }

        _renderDonorBanner(host) {
            // host missing = called outside the Hunt tab
            if (!host) return;

            const paint = (status) => {
                if (!host.isConnected) return; // tab swapped before fetch resolved
                if (!DonorClient.shouldShow(status)) {
                    host.innerHTML = "";
                    return;
                }
                host.innerHTML = `
                    <div class="bh-donor-banner">
                        💚 <b>Thank you for your Xanax donation</b> — I really appreciate your support!
                        <button class="bh-donor-dismiss" title="Dismiss">&times;</button>
                    </div>
                `;
                host.querySelector(".bh-donor-dismiss").addEventListener("click", () => {
                    DonorClient.dismiss(status.lastDonationTs);
                    host.innerHTML = "";
                });
            };

            // Resolve user id independently of the Hunter (it might still be
            // mid-`validateKey` when the panel first renders). Cached after
            // the first successful resolve so subsequent renders are sync.
            DonorClient.getUserId().then((userId) => {
                if (!userId || !host.isConnected) return;
                const cached = DonorClient.cachedStatus(userId);
                if (cached) { paint(cached); return; }
                DonorClient.fetchStatus(userId).then(paint);
            });
        }

        _sortMatches(list) {
            const col = this._sortCol;
            const dir = this._sortDir === "asc" ? 1 : -1;
            const statusRank = (m) => m.statusState === "Okay" ? 0 : 1;
            const keyFor = (m) => {
                switch (col) {
                    case "target": return String(m.target_name || "").toLowerCase();
                    case "reward": return m.reward || 0;
                    case "ff":     return m.ff == null ? Number.POSITIVE_INFINITY : m.ff;
                    case "bs":     return parseBS(m.bs) == null ? Number.POSITIVE_INFINITY : parseBS(m.bs);
                    case "status": {
                        // Okay first (desc) or last (asc); within Hospital, sort by time-remaining.
                        const remain = m.statusState === "Hospital"
                            ? Math.max(0, (m.hospUntil || 0) - Math.floor(Date.now() / 1000))
                            : 0;
                        return statusRank(m) * 1e6 + remain;
                    }
                    default: return m.reward || 0;
                }
            };
            // missing-data keys stay last regardless of dir
            return [...list].sort((a, b) => {
                const ka = keyFor(a); const kb = keyFor(b);
                const aInf = ka === Number.POSITIVE_INFINITY;
                const bInf = kb === Number.POSITIVE_INFINITY;
                if (aInf && !bInf) return 1;
                if (bInf && !aInf) return -1;
                if (ka < kb) return -1 * dir;
                if (ka > kb) return  1 * dir;
                // tie-break: reward desc
                return (b.reward || 0) - (a.reward || 0);
            });
        }

        // API-call counter pill; warns near ~100/min.
        _apiCounterHtml() {
            const n = apiCallsLastMinute();
            const cls = n >= RATE_WARN ? "bh-api-counter warn" : "bh-api-counter";
            const title = n >= RATE_WARN
                ? `${n} API calls in the last minute — near Torn's ~100/min limit. Raise the watchlist interval or watch fewer targets.`
                : `${n} API calls in the last minute (across all open BH tabs). Torn allows ~100/min.`;
            return `<span class="${cls}" id="bh-api-counter" title="${title}">API ${n}/min</span>`;
        }

        // Watchlist strip above the table. Hidden when empty.
        _renderWatchStrip() {
            const wl = this.hunter.watchlist;
            const ids = Object.keys(wl);
            if (ids.length === 0) return "";
            const intervalSec = Math.max(1, Number(this.hunter.settings.watchlistIntervalSec) || 5);
            const proj = projectedWatchCallsPerMin(ids.length, intervalSec);
            const projCls = proj >= RATE_WARN ? "bh-wl-proj warn" : "bh-wl-proj";
            const items = ids.map((id) => {
                const e = wl[id];
                const stateCls = e.lastState === "Okay" ? "bh-badge-ok"
                    : e.lastState === "Hospital" ? "bh-badge-hosp" : "bh-badge-travel";
                const name = escHtml(e.name || id);
                return `<span class="bh-wl-item">
                    <a class="bh-name-link" href="${PROFILE_URL}${id}" target="_blank" rel="noopener">${name}</a>
                    <span class="bh-badge ${stateCls}">${escHtml(e.lastState || "?")}</span>
                    <button class="bh-wl-remove" data-id="${id}" title="Stop watching">&times;</button>
                </span>`;
            }).join("");
            return `
                <div class="bh-watchlist">
                    <div class="bh-wl-head">
                        <span>👁 Watching ${ids.length} for med-out</span>
                        ${this.hunter.settings.searchEnabled
                            ? `<span class="bh-wl-hint">Next poll in <span id="bh-wl-countdown">${this.hunter.secondsUntilWatchPoll()}</span>s</span>`
                            : `<span class="bh-wl-hint" id="bh-wl-countdown">⏸ paused — bounty search off</span>`}
                        <span class="${projCls}" title="Estimated requests/min this watchlist adds at the current interval (${intervalSec}s), accounting for the 750ms call gate.">~${proj}/min</span>
                        <span class="bh-wl-hint">every ${intervalSec}s · visible tab only</span>
                    </div>
                    <div class="bh-wl-items">${items}</div>
                </div>
            `;
        }

        // Presence dot from last_action; none when unknown.
        _onlineDot(lastAction) {
            const cls = lastAction === "Online" ? "on"
                : lastAction === "Idle" ? "idle"
                : lastAction === "Offline" ? "off" : "";
            if (!cls) return "";
            return `<span class="bh-dot bh-dot-${cls}" title="${escHtml(lastAction)}"></span>`;
        }

        _renderRow(b) {
            const sm = fmt.statusMeta(b);
            const statusClass = sm.cls;
            const hospAttr = sm.attr;
            const statusText = sm.text;
            const locationBadge = (b.awayFromMe && b.location)
                ? ` <span class="bh-badge bh-badge-travel">📍 ${escHtml(b.location)}</span>`
                : "";
            const warBadge = b.warActive
                ? ` <span class="bh-badge bh-badge-war" title="This faction is in an active ranked war — only the war opponent can hit them for 60s after they leave hospital/jail.">⚔ War</span>`
                : "";
            const ffCell = b.ff == null ? "—" : b.ff.toFixed(2);
            const countBadge = b.bountyCount > 1
                ? ` <span class="bh-count">×${b.bountyCount}</span>`
                : "";
            return `
                <tr>
                    <td>
                        ${this._onlineDot(b.lastAction)}<a class="bh-name-link" href="${PROFILE_URL}${b.target_id}" target="_blank" rel="noopener">${escHtml(b.target_name)}</a>
                        <span style="color:#666;font-size:11px"> L${b.target_level}</span>${countBadge}
                    </td>
                    <td class="num">${escHtml(fmt.moneyFull(b.reward))}</td>
                    <td class="num">${ffCell}</td>
                    <td class="num bh-col-divider">${escHtml(b.bs || "—")}</td>
                    <td class="bh-col-divider"><span class="bh-badge ${statusClass}"${hospAttr}>${statusText}</span>${warBadge}${locationBadge}</td>
                    <td class="bh-row-actions-cell">
                        <button class="bh-attack" data-id="${b.target_id}">Attack →</button>
                        <button class="bh-row-watch${this.hunter.isWatched(b.target_id) ? " watching" : ""}" data-id="${b.target_id}" data-name="${escHtml(b.target_name)}" data-reward="${b.reward}" data-state="${b.statusState || ""}" title="${this.hunter.isWatched(b.target_id) ? "Stop watching for med-out" : "Watch for med-out (alert when out of hospital)"}">👁</button>
                        <button class="bh-row-blacklist" data-id="${b.target_id}" data-name="${escHtml(b.target_name)}" title="Blacklist ${escHtml(b.target_name)}">🚫</button>
                    </td>
                </tr>
            `;
        }

        // Floor below the all-fields-on min so a stripped card shrinks to ~name + Attack.
        // Reward and the FF/BS/Status chip trio each bump it only when on.
        _notifWidthFloor(fields) {
            const f = fields || DEFAULT_SETTINGS.notifications.fields;
            let min = 140;
            if (f.reward !== false)                                              min += 40;
            if (f.ff !== false && f.bs !== false && f.status !== false)          min += 30;
            return min;
        }

        _renderNotificationsSection(s) {
            const n = s.notifications || DEFAULT_SETTINGS.notifications;
            const f = n.fields || DEFAULT_SETTINGS.notifications.fields;
            const sound = n.sound || DEFAULT_SETTINGS.notifications.sound;
            const soundVol = Math.max(0, Math.min(1, Number(sound.volume)));
            const SOUND_LABELS = { chime: "Chime", beep: "Beep", ding: "Ding", alert: "Alert (triple)" };
            const soundOpts = SoundPlayer.TYPES.map((t) =>
                `<option value="${t}"${sound.type === t ? " selected" : ""}>${SOUND_LABELS[t] || t}</option>`).join("");
            const widthFloor = this._notifWidthFloor(f);
            const POSITIONS = [
                ["bottom-right", "Bottom right"],
                ["bottom-left",  "Bottom left"],
                ["top-right",    "Top right"],
                ["top-left",     "Top left"],
            ];
            const posOpts = POSITIONS.map(([v, label]) =>
                `<option value="${v}"${n.position === v ? " selected" : ""}>${label}</option>`).join("");
            const checked = (v) => v ? " checked" : "";
            return `
                <div class="bh-section">
                    <h3>Notifications</h3>
                    <label class="bh-check"><input type="checkbox" id="bh-set-toasts"${checked(s.toastsEnabled)}> Show toast for new matches</label>
                    <p class="bh-hint">Master switch — when off, the script still fills the Hunt tab but never pops a toast.</p>
                    <div class="bh-grid-2" style="margin-top:8px">
                        <div class="bh-field">
                            <label>Position</label>
                            <select id="bh-set-notif-pos" class="bh-select">${posOpts}</select>
                        </div>
                        <div class="bh-field">
                            <label>Card width (px)</label>
                            <input id="bh-set-notif-width" class="bh-input" type="number" min="${widthFloor}" max="640" step="10" value="${n.width}">
                            <span class="bh-hint" id="bh-notif-width-hint">Min ${widthFloor}, max 640. Lower min available when you hide fields.</span>
                        </div>
                        <div class="bh-field">
                            <label>Max visible at once</label>
                            <input id="bh-set-notif-max" class="bh-input" type="number" min="1" max="20" step="1" value="${n.maxVisible}">
                            <span class="bh-hint">Extra matches roll up into a single "+N more" card.</span>
                        </div>
                        <div class="bh-field">
                            <label>Auto-dismiss (seconds)</label>
                            <input id="bh-set-notif-timeout" class="bh-input" type="number" min="3" max="120" step="1" value="${n.timeoutSec}">
                            <span class="bh-hint">Hovering a card pauses its timer.</span>
                        </div>
                    </div>
                    <div class="bh-field" style="margin-top:4px">
                        <label>Show on each card</label>
                        <div class="bh-row-actions" style="gap:14px">
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-level"${checked(f.level !== false)}> Level</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-reward"${checked(f.reward !== false)}> Reward</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-ff"${checked(f.ff !== false)}> Fair fight</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-bs"${checked(f.bs !== false)}> Battle stats</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-status"${checked(f.status !== false)}> Status</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-location"${checked(f.location !== false)}> Location</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-notif-f-blacklist"${checked(f.blacklist === true)}> Blacklist button</label>
                        </div>
                        <span class="bh-hint">The target name and the Attack button are always shown. Location shows a 📍 marker only when the target is in a different country than you. Blacklist button is off by default — enable it for one-click blacklisting from a toast.</span>
                    </div>
                    <label class="bh-check" style="margin-top:10px"><input type="checkbox" id="bh-set-notif-sound"${checked(sound.enabled)}> Play a sound for new matches</label>
                    <p class="bh-hint">Works on desktop and Torn PDA. Mobile browsers stay muted until you interact with the page once — tap anywhere, then it plays.</p>
                    <div class="bh-grid-2" style="margin-top:6px">
                        <div class="bh-field">
                            <label>Sound</label>
                            <select id="bh-set-notif-sound-type" class="bh-select">${soundOpts}</select>
                        </div>
                        <div class="bh-field">
                            <label>Volume (${Math.round(soundVol * 100)}%)</label>
                            <input id="bh-set-notif-sound-vol" class="bh-input" type="range" min="0" max="100" step="5" value="${Math.round(soundVol * 100)}">
                        </div>
                    </div>
                    <div class="bh-row-actions" style="margin-top:8px">
                        <button id="bh-notif-preview" class="bh-btn bh-btn-muted">Preview toast</button>
                    </div>
                </div>
            `;
        }

        _clampInt(raw, lo, hi, fallback) {
            const n = parseInt(raw, 10);
            if (!Number.isFinite(n)) return fallback;
            return Math.max(lo, Math.min(hi, n));
        }

        // ── Blacklist section (Blacklist tab) — one row per entry; copy/paste hits the clipboard directly. ──
        _renderBlacklistSection() {
            return `
                <div class="bh-section" id="bh-bl-section">
                    <h3>Blacklist</h3>
                    <div class="bh-bl-banner">
                        ⚠ Stored in this browser only — copy/paste to move between devices.
                    </div>
                    <table class="bh-bl-table">
                        <thead>
                            <tr>
                                <th style="width:88px">ID</th>
                                <th style="width:30%">Name</th>
                                <th>Note</th>
                                <th style="width:34px"></th>
                            </tr>
                        </thead>
                        <tbody id="bh-bl-tbody">${this._renderBlacklistRows()}</tbody>
                    </table>
                    <div class="bh-row-actions" style="margin-top:8px;flex-wrap:wrap">
                        <input id="bh-bl-id" class="bh-input" type="number" min="1" step="1" placeholder="Torn user ID" style="max-width:140px">
                        <button id="bh-bl-add" class="bh-btn bh-btn-primary">+ Add</button>
                        <button id="bh-bl-copy" class="bh-btn bh-btn-muted">Copy</button>
                        <button id="bh-bl-paste" class="bh-btn bh-btn-muted">Paste from clipboard</button>
                        <button id="bh-bl-clear" class="bh-btn bh-btn-danger">Clear all</button>
                        <span id="bh-bl-status" class="bh-save-status" style="margin-left:4px"></span>
                    </div>
                </div>
            `;
        }

        _renderBlacklistRows() {
            const entries = Object.entries(this.hunter.blacklist)
                .sort(([, a], [, b]) => (b.addedAt || 0) - (a.addedAt || 0));
            if (entries.length === 0) {
                return `<tr><td colspan="4" class="bh-bl-empty">Blacklist is empty. Add an ID below, or use the 🚫 button on a Hunt-list row.</td></tr>`;
            }
            return entries.map(([id, e]) => {
                const nameCell = e.name
                    ? `<a class="bh-name-link" href="${PROFILE_URL}${id}" target="_blank" rel="noopener">${escHtml(e.name)}</a>`
                    : `<a class="bh-name-link bh-bl-name-unknown" href="${PROFILE_URL}${id}" target="_blank" rel="noopener" title="Name fills in next time this bounty appears">(unknown)</a>`;
                return `
                    <tr data-id="${id}">
                        <td><a class="bh-name-link" href="${PROFILE_URL}${id}" target="_blank" rel="noopener">${id}</a></td>
                        <td class="bh-bl-name-cell">${nameCell}</td>
                        <td><input class="bh-input bh-bl-note" data-id="${id}" type="text" placeholder="Note (private, this device only)" value="${escHtml(e.note || "")}"></td>
                        <td><button class="bh-bl-x" data-id="${id}" title="Remove from blacklist">✕</button></td>
                    </tr>
                `;
            }).join("");
        }

        _wireBlacklistSection(content) {
            const $ = (id) => content.querySelector("#" + id);
            const tbody = $("bh-bl-tbody");
            const refresh = () => { tbody.innerHTML = this._renderBlacklistRows(); };

            const status = (kind, msg, sticky = false) => {
                const el = $("bh-bl-status");
                if (!el) return;
                el.className = "bh-save-status bh-save-" + kind;
                el.textContent = msg;
                if (!sticky && (kind === "ok" || kind === "info")) {
                    setTimeout(() => { if (el.textContent === msg) el.textContent = ""; }, 3000);
                }
            };

            const idInput = $("bh-bl-id");
            const addById = () => {
                const raw = idInput.value.trim();
                if (!/^\d+$/.test(raw) || raw === "0") {
                    status("err", "Enter a numeric Torn user ID.");
                    return;
                }
                if (Number(raw) === this.hunter.myUserId) {
                    status("err", "Can't blacklist yourself.");
                    return;
                }
                if (this.hunter.isBlacklisted(raw)) {
                    status("info", "Already blacklisted.");
                    return;
                }
                if (this.hunter.addToBlacklist(raw, null)) {
                    idInput.value = "";
                    refresh();
                    status("ok", "Added.");
                }
            };
            $("bh-bl-add").addEventListener("click", addById);
            idInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); addById(); } });

            // Delegate to tbody — don't re-bind per row on every refresh.
            tbody.addEventListener("click", (e) => {
                const x = e.target.closest(".bh-bl-x");
                if (!x) return;
                this.hunter.removeFromBlacklist(x.dataset.id);
                refresh();
            });
            // Persist on blur, not keystroke — avoids churning LS + a storage event per character.
            tbody.addEventListener("focusout", (e) => {
                if (!e.target.classList.contains("bh-bl-note")) return;
                this.hunter.setBlacklistNote(e.target.dataset.id, e.target.value);
            });

            $("bh-bl-copy").addEventListener("click", async () => {
                const text = JSON.stringify(this.hunter.blacklist, null, 2);
                try {
                    await navigator.clipboard.writeText(text);
                    status("ok", `Copied ${Object.keys(this.hunter.blacklist).length} entries.`);
                } catch {
                    status("err", "Clipboard write blocked — check browser permissions.");
                }
            });
            $("bh-bl-paste").addEventListener("click", async () => {
                let text;
                try {
                    text = await navigator.clipboard.readText();
                } catch {
                    status("err", "Clipboard read blocked — paste into the ID field above instead.");
                    return;
                }
                if (!text || !text.trim()) {
                    status("err", "Clipboard is empty.");
                    return;
                }
                const added = this.hunter.importBlacklist(text);
                refresh();
                status("ok", added > 0
                    ? `Imported ${added} new entr${added === 1 ? "y" : "ies"}.`
                    : "No new entries (already present or unparseable).");
            });

            $("bh-bl-clear").addEventListener("click", () => {
                const count = Object.keys(this.hunter.blacklist).length;
                if (count === 0) { status("info", "Blacklist is already empty."); return; }
                if (!confirm(`Clear all ${count} blacklist entries? This can't be undone.`)) return;
                this.hunter.clearBlacklist();
                refresh();
                status("ok", "Cleared.");
            });
        }

        // Add + show a ~5s undo toast.
        _blacklistWithUndo(id, name) {
            if (this.hunter.isBlacklisted(id)) return;
            if (!this.hunter.addToBlacklist(id, name)) return;
            this.toaster.showAction({
                message: `Blacklisted ${name || `#${id}`}`,
                actionLabel: "Undo",
                onAction: () => this.hunter.removeFromBlacklist(id),
                timeoutMs: 5000,
            });
        }

        // Restart the loop after a settings change. Shared by Filters + Alerts.
        _restartLoopFromSettings() {
            this.hunter.stop();
            if (this.hunter.settings.searchEnabled && this.hunter.settings.refreshSec > 0) {
                this.hunter.start();
            } else if (!this.hunter.settings.searchEnabled) {
                this.hunter.pausedReason = "disabled";
            } else {
                this.hunter.pausedReason = null;
            }
        }

        // ── Filters tab — which bounties show ──────────────────────────────
        _renderFilters() {
            const content = this._panel.querySelector("#bh-content");
            const s = this.hunter.settings;
            content.innerHTML = `
                <div class="bh-section">
                    <h3>Which bounties</h3>
                    <div class="bh-grid-2">
                        <div class="bh-field">
                            <label>Min reward ($)</label>
                            <input id="bh-set-price" class="bh-input" type="number" min="0" step="10000" value="${s.minPrice}">
                        </div>
                        <div class="bh-field">
                            <label>Fair-fight range</label>
                            <div class="bh-inline">
                                <input id="bh-set-ffmin" class="bh-input" type="number" min="1" max="10" step="0.1" value="${s.minFF}" style="max-width:80px">
                                <span style="color:#888">–</span>
                                <input id="bh-set-ffmax" class="bh-input" type="number" min="1" max="10" step="0.1" value="${s.maxFF}" style="max-width:80px">
                            </div>
                            <span class="bh-hint">Lower = easier. 1.0–3.0 is the respect sweet spot.</span>
                        </div>
                        <div class="bh-field">
                            <label>Hospital window (min left)</label>
                            <input id="bh-set-hosp" class="bh-input" type="number" min="0" max="10080" step="1" value="${s.hospitalMaxMin}"${s.hospNoLimit ? " disabled" : ""}>
                            <label class="bh-check" style="margin-top:4px"><input type="checkbox" id="bh-set-hosp-nolimit"${s.hospNoLimit ? " checked" : ""}> No limit (all hospitalised)</label>
                            <span class="bh-hint">0 = Okay only. ~5 queues targets about to get out.</span>
                        </div>
                        <div class="bh-field">
                            <label>Reachability</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-samecountry"${s.sameCountryOnly ? " checked" : ""}> Only targets in my country</label>
                            <label class="bh-check" style="margin-top:4px"><input type="checkbox" id="bh-set-unkff"${s.includeUnknownFF ? " checked" : ""}> Include unknown-FF targets</label>
                            <span class="bh-hint">"My country" hides abroad / in-transit targets. "Unknown-FF" shows targets with no FF data (use without an FF key).</span>
                        </div>
                    </div>
                </div>

                <div class="bh-section">
                    <h3>War status</h3>
                    <label class="bh-check"><input type="checkbox" id="bh-set-war"${s.warStatus ? " checked" : ""}> Show ranked-war indicator (⚔ War)</label>
                    <div class="bh-field" style="max-width:200px;margin-top:6px">
                        <label>Cache (minutes)</label>
                        <input id="bh-set-war-cache" class="bh-input" type="number" min="1" max="1440" step="1" value="${Math.max(1, Number(s.warStatusCacheMin) || 15)}"${s.warStatus ? "" : " disabled"}>
                    </div>
                    <span class="bh-hint">Flags targets whose faction is in an active ranked war — only the war opponent can hit them for 60s right after they leave hospital/jail. <b>Adds one API call per unique faction</b> (a separate request), cached for the minutes above. Off by default.</span>
                </div>

                <div class="bh-section">
                    <button id="bh-reset" class="bh-btn bh-btn-muted">Reset filters to defaults</button>
                </div>
            `;

            const $ = (id) => content.querySelector("#" + id);
            const persistFilters = () => {
                const minFF = parseFloat($("bh-set-ffmin").value);
                const maxFF = parseFloat($("bh-set-ffmax").value);
                const cleanMin = Number.isFinite(minFF) ? Math.max(1, minFF) : 1.0;
                const cleanMax = Number.isFinite(maxFF) ? Math.max(cleanMin, maxFF) : cleanMin;
                this.hunter.updateSettings({
                    minPrice: Math.max(0, parseInt($("bh-set-price").value, 10) || 0),
                    hospitalMaxMin: Math.max(0, Math.min(10080, parseInt($("bh-set-hosp").value, 10) || 0)),
                    hospNoLimit: $("bh-set-hosp-nolimit").checked,
                    sameCountryOnly: $("bh-set-samecountry").checked,
                    minFF: cleanMin,
                    maxFF: cleanMax,
                    includeUnknownFF: $("bh-set-unkff").checked,
                    warStatus: $("bh-set-war").checked,
                    warStatusCacheMin: Math.max(1, Math.min(1440, parseInt($("bh-set-war-cache").value, 10) || 15)),
                });
                this._restartLoopFromSettings();
            };
            ["bh-set-price", "bh-set-hosp", "bh-set-hosp-nolimit", "bh-set-samecountry",
             "bh-set-ffmin", "bh-set-ffmax", "bh-set-unkff", "bh-set-war", "bh-set-war-cache"]
                .forEach((id) => $(id).addEventListener("change", persistFilters));

            // grey out the cap when "no limit" is on
            $("bh-set-hosp-nolimit").addEventListener("change", (e) => {
                $("bh-set-hosp").disabled = e.target.checked;
            });

            // Grey out the cache field while the war indicator is off.
            $("bh-set-war").addEventListener("change", (e) => {
                $("bh-set-war-cache").disabled = !e.target.checked;
            });

            $("bh-reset").addEventListener("click", () => {
                if (!confirm("Reset filters to defaults?")) return;
                // Preserve user-choice toggles + blacklist — "reset filters" shouldn't flip search/toasts or wipe the blacklist.
                const cur = this.hunter.settings;
                this.hunter.updateSettings({
                    ...DEFAULT_SETTINGS,
                    toastsEnabled: cur.toastsEnabled,
                    searchEnabled: cur.searchEnabled,
                    refreshSec: cur.refreshSec,
                    watchlistIntervalSec: cur.watchlistIntervalSec,
                    pauseOnLowEnergy: cur.pauseOnLowEnergy,
                    minEnergy: cur.minEnergy,
                    pauseInHospital: cur.pauseInHospital,
                    pauseInJail: cur.pauseInJail,
                    pauseWhileTraveling: cur.pauseWhileTraveling,
                    notifications: cur.notifications,
                });
                this._renderActive();
            });
        }

        // ── Blacklist tab ──────────────────────────────────────────────────
        _renderBlacklistTab() {
            const content = this._panel.querySelector("#bh-content");
            content.innerHTML = this._renderBlacklistSection();
            this._wireBlacklistSection(content);
        }

        // ── Alerts tab — timing, toasts, watchlist, and pauses ─────────────
        _renderAlerts() {
            const content = this._panel.querySelector("#bh-content");
            const s = this.hunter.settings;
            const energyScopeHint = (s.pauseOnLowEnergy && this.hunter.lastEnergyError === "scope")
                ? `<span class="bh-err">Your Torn API key can't read <code>/user/bars</code>. Regenerate it in <a class="bh-name-link" href="https://www.torn.com/preferences.php#tab=api" target="_blank" rel="noopener">Preferences → API Key</a> with <b>Minimal</b> access or higher.</span>`
                : "";
            const energyNowLine = (s.pauseOnLowEnergy && this.hunter.myEnergy)
                ? `<span class="bh-hint">Current energy: <b>${this.hunter.myEnergy.current}/${this.hunter.myEnergy.maximum}</b></span>`
                : "";
            content.innerHTML = `
                <div class="bh-section">
                    <h3>Timing</h3>
                    <div class="bh-grid-2">
                        <div class="bh-field">
                            <label>Board auto-refresh</label>
                            <select id="bh-set-refresh" class="bh-select">
                                <option value="30"${s.refreshSec === 30 ? " selected" : ""}>Every 30 s</option>
                                <option value="60"${s.refreshSec === 60 ? " selected" : ""}>Every 60 s</option>
                                <option value="120"${s.refreshSec === 120 ? " selected" : ""}>Every 2 min</option>
                                <option value="300"${s.refreshSec === 300 ? " selected" : ""}>Every 5 min</option>
                                <option value="0"${s.refreshSec === 0 ? " selected" : ""}>Off (manual only)</option>
                            </select>
                            <span class="bh-hint">60 s is comfortably under the rate limit.</span>
                        </div>
                        <div class="bh-field">
                            <label>Watchlist poll (seconds)</label>
                            <input id="bh-set-watch-int" class="bh-input" type="number" min="1" max="600" step="1" value="${Math.max(1, Number(s.watchlistIntervalSec) || 5)}">
                            <span class="bh-hint" id="bh-watch-proj-hint"></span>
                        </div>
                    </div>
                </div>

                ${this._renderNotificationsSection(s)}

                <div class="bh-section">
                    <details class="bh-adv">
                        <summary>Advanced — pause auto-refresh when you can't attack</summary>
                        <div style="margin-top:10px">
                            <label class="bh-check"><input type="checkbox" id="bh-set-pause-energy"${s.pauseOnLowEnergy ? " checked" : ""}> Pause when energy is below</label>
                            <div class="bh-field" style="max-width:160px;margin-top:4px">
                                <input id="bh-set-min-energy" class="bh-input" type="number" min="0" max="1000" step="1" value="${s.minEnergy}">
                            </div>
                            <span class="bh-hint">Skips the fetch when you can't attack (25 = standard attack cost). Needs a Torn key with <b>Minimal</b> access or higher.</span>
                            ${energyNowLine}
                            ${energyScopeHint}
                            <label class="bh-check" style="margin-top:8px"><input type="checkbox" id="bh-set-pause-hosp"${s.pauseInHospital ? " checked" : ""}> Pause while I'm in hospital</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-pause-jail"${s.pauseInJail ? " checked" : ""}> Pause while I'm in jail</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-pause-travel"${s.pauseWhileTraveling ? " checked" : ""}> Pause while I'm travelling</label>
                            <span class="bh-hint">Free — read from your profile, no extra API calls or key scope needed.</span>
                        </div>
                    </details>
                </div>
            `;

            const $ = (id) => content.querySelector("#" + id);
            const persistAlerts = () => {
                const allowedPositions = new Set(["bottom-right", "bottom-left", "top-right", "top-left"]);
                const posRaw = $("bh-set-notif-pos").value;
                const position = allowedPositions.has(posRaw) ? posRaw : "bottom-right";
                const fieldsObj = {
                    level:     $("bh-set-notif-f-level").checked,
                    reward:    $("bh-set-notif-f-reward").checked,
                    ff:        $("bh-set-notif-f-ff").checked,
                    bs:        $("bh-set-notif-f-bs").checked,
                    status:    $("bh-set-notif-f-status").checked,
                    location:  $("bh-set-notif-f-location").checked,
                    blacklist: $("bh-set-notif-f-blacklist").checked,
                };
                const widthFloor = this._notifWidthFloor(fieldsObj);
                const widthInput = $("bh-set-notif-width");
                widthInput.min = String(widthFloor);
                const widthHint = $("bh-notif-width-hint");
                if (widthHint) widthHint.textContent = `Min ${widthFloor}, max 640. Lower min available when you hide fields.`;
                this.hunter.updateSettings({
                    toastsEnabled: $("bh-set-toasts").checked,
                    refreshSec: parseInt($("bh-set-refresh").value, 10),
                    watchlistIntervalSec: Math.max(1, Math.min(600, parseInt($("bh-set-watch-int").value, 10) || 5)),
                    pauseOnLowEnergy: $("bh-set-pause-energy").checked,
                    minEnergy: Math.max(0, Math.min(1000, parseInt($("bh-set-min-energy").value, 10) || 0)),
                    pauseInHospital: $("bh-set-pause-hosp").checked,
                    pauseInJail: $("bh-set-pause-jail").checked,
                    pauseWhileTraveling: $("bh-set-pause-travel").checked,
                    notifications: {
                        position,
                        width:      this._clampInt(widthInput.value,                widthFloor, 640, 300),
                        maxVisible: this._clampInt($("bh-set-notif-max").value,              1,  20,   3),
                        timeoutSec: this._clampInt($("bh-set-notif-timeout").value,          3, 120,  15),
                        fields: fieldsObj,
                        sound: {
                            enabled: $("bh-set-notif-sound").checked,
                            type:    $("bh-set-notif-sound-type").value,
                            volume:  this._clampInt($("bh-set-notif-sound-vol").value, 0, 100, 50) / 100,
                        },
                    },
                });
                this.toaster.applySettings();
                this._restartLoopFromSettings();
            };
            ["bh-set-refresh", "bh-set-watch-int", "bh-set-toasts",
             "bh-set-notif-pos", "bh-set-notif-width", "bh-set-notif-max", "bh-set-notif-timeout",
             "bh-set-notif-f-level", "bh-set-notif-f-reward", "bh-set-notif-f-ff",
             "bh-set-notif-f-bs", "bh-set-notif-f-status", "bh-set-notif-f-location", "bh-set-notif-f-blacklist",
             "bh-set-notif-sound", "bh-set-notif-sound-type", "bh-set-notif-sound-vol",
             "bh-set-pause-energy", "bh-set-min-energy",
             "bh-set-pause-hosp", "bh-set-pause-jail", "bh-set-pause-travel"]
                .forEach((id) => { const el = $(id); if (el) el.addEventListener("change", persistAlerts); });

            const soundVolEl = $("bh-set-notif-sound-vol");
            if (soundVolEl) {
                soundVolEl.addEventListener("input", () => {
                    const lbl = soundVolEl.closest(".bh-field")?.querySelector("label");
                    if (lbl) lbl.textContent = `Volume (${this._clampInt(soundVolEl.value, 0, 100, 50)}%)`;
                });
            }

            // Live projection hint under the watchlist interval input.
            const watchIntEl = $("bh-set-watch-int");
            const projHint = $("bh-watch-proj-hint");
            const updateProjHint = () => {
                const iv = Math.max(1, Math.min(600, parseInt(watchIntEl.value, 10) || 5));
                const k = this.hunter.watchlistCount();
                if (k === 0) { projHint.textContent = "No targets watched. Click 👁 on a Hunt row."; return; }
                const proj = projectedWatchCallsPerMin(k, iv);
                projHint.textContent = `${k} watched → ~${proj} calls/min at ${iv}s${proj >= RATE_WARN ? " ⚠ near the ~100/min limit" : ""}.`;
            };
            updateProjHint();
            watchIntEl.addEventListener("input", updateProjHint);

            $("bh-notif-preview").addEventListener("click", () => {
                persistAlerts();
                this.toaster.showMany([{
                    target_id: -1,
                    target_name: "Preview Target",
                    target_level: 50,
                    reward: 2_500_000,
                    ff: 2.75,
                    bs: "12.3k",
                    statusState: "Okay",
                    hospUntil: 0,
                    bountyCount: 1,
                    awayFromMe: true,
                    location: "Mexico",
                    warActive: !!this.hunter.settings.warStatus,
                }], { silent: true });
                this.toaster.playTestSound();
            });
        }

        // ── Key tab — Torn + FFScouter keys, debug log, environment ────────
        _renderKey() {
            const content = this._panel.querySelector("#bh-content");
            const tornKey = KeyResolver.resolveTornKey() || "";
            const pdaNote = KeyResolver.isPDAKey()
                ? `<p class="bh-hint">Your Torn key is provided by Torn PDA. It is not editable here.</p>`
                : "";
            content.innerHTML = `
                <div class="bh-section">
                    <h3>Torn API key</h3>
                    ${pdaNote}
                    <div class="bh-field">
                        <input id="bh-set-tornkey" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" value="${escHtml(tornKey)}" ${KeyResolver.isPDAKey() ? "disabled" : ""}>
                    </div>
                    ${!KeyResolver.isPDAKey() ? `
                        <div class="bh-row-actions">
                            <button id="bh-key-save" class="bh-btn bh-btn-primary">Save Torn key</button>
                            <button id="bh-key-clear" class="bh-btn bh-btn-danger">Clear Torn key &amp; log out</button>
                        </div>
                        <div id="bh-key-status" class="bh-save-status"></div>
                    ` : ""}
                </div>

                <div class="bh-section">
                    <h3>FFScouter key</h3>
                    <p class="bh-hint">Used to fetch fair-fight scores in bulk (one call per refresh).</p>
                    <div class="bh-field">
                        <input id="bh-set-ffkey" class="bh-input bh-input-masked" type="text" maxlength="16" spellcheck="false" autocomplete="off" autocorrect="off" autocapitalize="off" data-lpignore="true" data-1p-ignore="true" data-form-type="other" value="${escHtml(KeyResolver.getFFKey())}">
                    </div>
                    <div class="bh-row-actions">
                        <button id="bh-ffkey-save" class="bh-btn bh-btn-primary">Save FFScouter key</button>
                        <button id="bh-ffkey-clear" class="bh-btn bh-btn-muted">Clear</button>
                    </div>
                    <div id="bh-ffkey-status" class="bh-save-status"></div>
                </div>

                <div class="bh-section">
                    <h3>Debug log</h3>
                    <label class="bh-check">
                        <input type="checkbox" id="bh-set-debug"${isDebugOn() ? " checked" : ""}> Record API requests &amp; rate-limit hits
                    </label>
                    <p class="bh-hint">When on, every Torn / FFScouter request is logged below with its status and latency. Rate-limit hits (Torn code 5, HTTP 429) are highlighted. Use this to confirm whether a slow refresh is rate-limited or just the network.</p>
                    <div id="bh-debug-log" class="bh-debug-log"></div>
                    <div class="bh-row-actions" style="margin-top:6px">
                        <button id="bh-debug-clear" class="bh-btn bh-btn-muted">Clear log</button>
                    </div>
                </div>

                <div class="bh-section">
                    <h3>Environment</h3>
                    <p class="bh-hint">Version ${VERSION} · Platform: ${IS_PDA ? "Torn PDA" : "Desktop"}</p>
                </div>
            `;

            const $ = (id) => content.querySelector("#" + id);
            const setStatus = (elId, kind, msg) => {
                const el = $(elId);
                if (!el) return;
                el.className = "bh-save-status bh-save-" + kind;
                el.textContent = msg;
            };

            if (!KeyResolver.isPDAKey()) {
                $("bh-key-save").addEventListener("click", async () => {
                    const k = $("bh-set-tornkey").value.trim();
                    if (!/^[A-Za-z0-9]{16}$/.test(k)) {
                        setStatus("bh-key-status", "err", "Torn key must be 16 alphanumeric characters.");
                        return;
                    }
                    setStatus("bh-key-status", "info", "Validating…");
                    this.hunter.api.setKey(k);
                    try {
                        await this.hunter.api.validateKey();
                    } catch (e) {
                        setStatus("bh-key-status", "err", "Key rejected: " + (e.message || "invalid"));
                        return;
                    }
                    KeyResolver.saveTornKey(k);
                    this.hunter.myUserId = null;
                    this.hunter.myUserLevel = null;
                    this.hunter.myUserAge = null;
                    setStatus("bh-key-status", "ok", "Saved. Refreshing matches…");
                    await this.hunter.refresh().catch(() => {});
                    setStatus("bh-key-status", "ok", "Saved ✓");
                });
                $("bh-key-clear").addEventListener("click", () => {
                    if (!confirm("Clear the Torn API key and return to the auth screen?")) return;
                    KeyResolver.clearTornKey();
                    this.hunter.stop();
                    this.toaster.clearAll();
                    this.setAuthed(false);
                });
            }
            $("bh-ffkey-save").addEventListener("click", async () => {
                const k = $("bh-set-ffkey").value.trim();
                if (!k) {
                    KeyResolver.clearFFKey();
                    setStatus("bh-ffkey-status", "info", "Cleared.");
                    if (this.hunter.lastCounts) this.hunter.lastCounts.ffError = null;
                    this.hunter.refresh().catch(() => {});
                    return;
                }
                setStatus("bh-ffkey-status", "info", "Validating with FFScouter…");
                const result = await validateFFScouterKey(k);
                if (!result.ok) {
                    setStatus("bh-ffkey-status", "err", result.message);
                    return;
                }
                KeyResolver.saveFFKey(k);
                if (this.hunter.lastCounts) this.hunter.lastCounts.ffError = null;
                setStatus("bh-ffkey-status", "ok", result.message + " Refreshing matches…");
                await this.hunter.refresh().catch(() => {});
                setStatus("bh-ffkey-status", "ok", result.message + " Saved ✓");
            });
            $("bh-ffkey-clear").addEventListener("click", () => {
                KeyResolver.clearFFKey();
                $("bh-set-ffkey").value = "";
                if (this.hunter.lastCounts) this.hunter.lastCounts.ffError = null;
                setStatus("bh-ffkey-status", "info", "Cleared.");
                this.hunter.refresh().catch(() => {});
            });

            // Debug log section — checkbox persists, log area updates live.
            const debugToggle = $("bh-set-debug");
            const debugEl = $("bh-debug-log");
            const renderLog = () => {
                if (!debugEl.isConnected) return;
                if (!isDebugOn()) {
                    debugEl.innerHTML = `<span class="bh-debug-hint">Debug is off. Enable it to start recording.</span>`;
                    return;
                }
                if (debugLog.length === 0) {
                    debugEl.innerHTML = `<span class="bh-debug-hint">No events yet — trigger a refresh from the Hunt tab.</span>`;
                    return;
                }
                const rows = [...debugLog].reverse().map((r) => {
                    const msPart = (r.ms != null) ? ` <span class="bh-debug-ms">${r.ms}ms</span>` : "";
                    return `<div class="bh-debug-row bh-debug-${r.level}"><span class="bh-debug-ts">${r.ts}</span> ${escHtml(r.label)}${msPart}</div>`;
                }).join("");
                debugEl.innerHTML = rows;
            };
            debugToggle.addEventListener("change", () => {
                try { localStorage.setItem(LS.debug, debugToggle.checked ? "1" : ""); } catch {}
                if (!debugToggle.checked) debugLog.length = 0;
                renderLog();
            });
            $("bh-debug-clear").addEventListener("click", () => {
                debugLog.length = 0;
                renderLog();
            });
            const onEntry = () => renderLog();
            debugBus.addEventListener("entry", onEntry);
            const detach = new MutationObserver(() => {
                if (!debugEl.isConnected) {
                    debugBus.removeEventListener("entry", onEntry);
                    detach.disconnect();
                }
            });
            detach.observe(this._panel, { childList: true, subtree: true });
            renderLog();
        }
    }

    // ════════════════════════════════════════════════════════════
    //  Shared footer menu (eugene-torn-scripts). 1 script → icon in the
    //  footer; 2+ → a 3-dots menu expanding a row above it. Idempotent,
    //  duplicated verbatim across scripts; __eugFooterMenuLoaded guards
    //  one setup per page.
    // ════════════════════════════════════════════════════════════

    (function setupEugFooterMenu() {
        // Page's real window so different @grant sandboxes share one registry (SPA @grant none vs GM_* isolated).
        const W = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
        if (W.__eugFooterMenuLoaded) return;
        W.__eugFooterMenuLoaded = true;
        W.__eugeneScripts = W.__eugeneScripts || [];

        const ROW_ID = "eug-footer-row";

        function injectCSS() {
            if (document.getElementById("eug-footer-style")) return;
            const style = document.createElement("style");
            style.id = "eug-footer-style";
            style.textContent = `
[data-eug="menu"]{background:linear-gradient(to bottom,#444,#2a2a2a)!important}
[data-eug="menu"]:hover{background:linear-gradient(to bottom,#555,#333)!important}
#${ROW_ID}{display:none;position:fixed;padding:4px;
  background:rgba(20,20,20,0.96);border:1px solid #444;border-radius:6px;
  gap:4px;z-index:2147483647;white-space:nowrap;pointer-events:auto}
#${ROW_ID}.eug-open{display:flex;flex-direction:row}
`;
            document.head.appendChild(style);
        }

        function injectEntryCSS(entry) {
            if (!entry.color) return;
            const id = `eug-color-${entry.id}`;
            const existing = document.getElementById(id);
            const dark = entry.colorDark || "#222";
            const hover = entry.hoverColor || entry.color;
            const css = `
[data-eug-id="${entry.id}"]{background:linear-gradient(to bottom, ${entry.color}, ${dark})!important}
[data-eug-id="${entry.id}"]:hover{background:linear-gradient(to bottom, ${hover}, ${entry.color})!important}
`;
            if (existing) { existing.textContent = css; return; }
            const el = document.createElement("style");
            el.id = id;
            el.textContent = css;
            document.head.appendChild(el);
        }

        function findRefBtn() {
            return document.getElementById("notes_panel_button")
                || document.getElementById("people_panel_button");
        }

        function getRow() { return document.getElementById(ROW_ID); }
        function closeRow() { const r = getRow(); if (r) r.classList.remove("eug-open"); }

        function openRow(menuBtn) {
            const row = getRow();
            if (!row) return;
            const rect = menuBtn.getBoundingClientRect();
            row.classList.add("eug-open");
            const rowRect = row.getBoundingClientRect();
            const gap = 6;
            const centerX = rect.left + rect.width / 2;
            let left = centerX - rowRect.width / 2;
            const maxLeft = window.innerWidth - rowRect.width - 4;
            left = Math.max(4, Math.min(left, maxLeft));
            row.style.left = left + "px";
            row.style.bottom = (window.innerHeight - rect.top + gap) + "px";
        }

        function makeScriptBtn(entry, refBtn, role) {
            const iconClasses = refBtn.querySelector("svg")?.className?.baseVal || "";
            const btn = document.createElement("button");
            btn.type = "button";
            btn.className = refBtn.className;
            btn.title = entry.name;
            btn.setAttribute("data-eug", role);
            btn.setAttribute("data-eug-id", entry.id);
            const svg = (entry.iconSVG || "").replace(/<svg\b([^>]*)>/, (match, attrs) =>
                /\sclass\s*=/.test(attrs) ? match : `<svg${attrs} class="${iconClasses}">`);
            btn.innerHTML = svg;
            btn.addEventListener("click", (e) => {
                e.preventDefault();
                e.stopPropagation();
                closeRow();
                try { entry.onClick(); } catch { /* noop */ }
            });
            injectEntryCSS(entry);
            return btn;
        }

        function makeMenuBtn(refBtn) {
            const iconClasses = refBtn.querySelector("svg")?.className?.baseVal || "";
            const btn = document.createElement("button");
            btn.type = "button";
            btn.className = refBtn.className;
            btn.title = "My userscripts";
            btn.setAttribute("data-eug", "menu");
            btn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" class="${iconClasses}">
                <defs><linearGradient id="eug_menu_grad" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
                    <stop offset="0" stop-color="#ddd"/><stop offset="1" stop-color="#999"/>
                </linearGradient></defs>
                <g fill="url(#eug_menu_grad)">
                    <circle cx="5" cy="12" r="2"/>
                    <circle cx="12" cy="12" r="2"/>
                    <circle cx="19" cy="12" r="2"/>
                </g>
            </svg>`;
            btn.addEventListener("click", (e) => {
                e.preventDefault();
                e.stopPropagation();
                const row = getRow();
                if (row && row.classList.contains("eug-open")) closeRow();
                else openRow(btn);
            });
            return btn;
        }

        // Nuke pre-shared-menu standalone buttons from mixed (old+new) installs so the shared menu stays authoritative.
        const LEGACY_BUTTON_IDS = ["tat-footer-btn", "spa-footer-btn"];

        function render() {
            const refBtn = findRefBtn();
            if (!refBtn) return false;
            injectCSS();

            const parent = refBtn.parentNode;
            parent.querySelectorAll('[data-eug]').forEach((el) => el.remove());
            LEGACY_BUTTON_IDS.forEach((id) => {
                const el = document.getElementById(id);
                if (el) el.remove();
            });
            const oldRow = getRow();
            if (oldRow) oldRow.remove();

            const scripts = W.__eugeneScripts || [];
            if (scripts.length === 0) return true;

            if (scripts.length === 1) {
                parent.insertBefore(makeScriptBtn(scripts[0], refBtn, "solo"), refBtn);
            } else {
                const menuBtn = makeMenuBtn(refBtn);
                parent.insertBefore(menuBtn, refBtn);
                const row = document.createElement("div");
                row.id = ROW_ID;
                row.setAttribute("data-eug-row", "");
                for (const s of scripts) row.appendChild(makeScriptBtn(s, refBtn, "item"));
                document.body.appendChild(row);
            }
            return true;
        }

        function mount() {
            render();
            // Torn's SPA swaps the footer on nav — re-render when the ref button returns but ours are gone. rAF-throttled.
            let pending = false;
            const obs = new MutationObserver(() => {
                if (pending) return;
                pending = true;
                requestAnimationFrame(() => {
                    pending = false;
                    const refBtn = findRefBtn();
                    if (refBtn && !refBtn.parentNode.querySelector('[data-eug]')) render();
                });
            });
            obs.observe(document.body, { childList: true, subtree: true });
        }

        W.addEventListener("eugene-scripts-updated", render);
        document.addEventListener("click", (e) => {
            const row = getRow();
            if (!row || !row.classList.contains("eug-open")) return;
            const menuBtn = document.querySelector('[data-eug="menu"]');
            if (menuBtn && menuBtn.contains(e.target)) return;
            if (row.contains(e.target)) return;
            closeRow();
        });
        document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeRow(); });
        W.addEventListener("scroll", closeRow, { passive: true });
        W.addEventListener("resize", closeRow);

        W.registerEugeneScript = function (entry) {
            const list = W.__eugeneScripts;
            const i = list.findIndex((s) => s.id === entry.id);
            if (i >= 0) list[i] = entry;
            else list.push(entry);
            W.dispatchEvent(new CustomEvent("eugene-scripts-updated"));
        };
        W.mountEugeneFooterMenu = mount;
    })();

    // ════════════════════════════════════════════════════════════
    //  MAIN
    // ════════════════════════════════════════════════════════════

    const BH_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
        <defs><linearGradient id="bh_icon_grad" x1="0.5" x2="0.5" y2="1" gradientUnits="objectBoundingBox">
            <stop offset="0" stop-color="#ddd"/><stop offset="1" stop-color="#999"/>
        </linearGradient></defs>
        <g fill="url(#bh_icon_grad)">
            <path d="M12 2a10 10 0 1 0 10 10A10 10 0 0 0 12 2Zm0 18a8 8 0 1 1 8-8 8 8 0 0 1-8 8Z"/>
            <path d="M12 6a6 6 0 1 0 6 6 6 6 0 0 0-6-6Zm0 10a4 4 0 1 1 4-4 4 4 0 0 1-4 4Z"/>
            <circle cx="12" cy="12" r="1.5"/>
            <path d="M11 0h2v5h-2zM11 19h2v5h-2zM0 11h5v2H0zM19 11h5v2h-5z"/>
        </g>
    </svg>`;

    function main() {
        const initialKey = KeyResolver.resolveTornKey();
        const api = new TornAPI(initialKey || "");
        const hunter = new Hunter(api);
        const toaster = new Toaster(() => hunter.settings);
        const ui = new UI(hunter, toaster);

        SoundPlayer.installUnlockHandlers();

        // Toaster needs a way to reach the UI for its "+N more" card.
        window.__bhUI = ui;

        ui.inject();
        ui.setAuthed(!!initialKey);

        // Cross-tab sync: adopt another tab's match writes; settings/blacklist/watchlist ride the same storage event under their own keys.
        window.addEventListener("storage", (e) => {
            if (!e.newValue) return;
            if (e.key === LS.shared) {
                try {
                    const payload = JSON.parse(e.newValue);
                    hunter.adoptSharedPayload(payload);
                } catch { /* ignore malformed writes */ }
            } else if (e.key === LS.settings) {
                try {
                    const next = JSON.parse(e.newValue);
                    if (next && typeof next === "object") {
                        hunter.applyExternalSettings(next);
                        // only if a toast has been shown (container exists); else the next showMany picks up the new values
                        toaster.applySettings();
                    }
                } catch { /* ignore malformed writes */ }
            } else if (e.key === LS.blacklist) {
                hunter.applyExternalBlacklist();
            } else if (e.key === LS.watchlist) {
                hunter.applyExternalWatchlist();
            }
        });

        // Visible tab owns watchlist polling — take over on focus (kick a tick for an instant handoff).
        document.addEventListener("visibilitychange", () => {
            if (document.visibilityState === "visible") hunter.kickWatchIfVisible();
        });

        const W = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
        W.registerEugeneScript({
            id: "bh",
            name: "Bounty Hunter",
            color: "#b33a3a",
            colorDark: "#6b1f1f",
            hoverColor: "#d64a4a",
            iconSVG: BH_ICON_SVG,
            onClick: () => ui.toggle(true),
        });
        W.mountEugeneFooterMenu();

        if (initialKey) {
            hunter.start();
            // Resume watching any targets carried over from a previous session.
            hunter.ensureWatchLoop();
        }
    }

    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", main);
    } else {
        main();
    }
})();