Bounty Hunter

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

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Bounty Hunter
// @namespace    https://github.com/eugene-torn-scripts/bounty-hunter
// @version      1.3.0
// @description  Live Torn bounty board filter — min reward, FFScouter fair-fight range, Okay/Hospital status — 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
// @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.3.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
    };
    // When shared data is younger than (refreshSec * SHARED_FRESH_RATIO) ms,
    // a tab skips its own refresh and free-rides on the writer's result.
    // 0.8 leaves headroom for clock drift between tabs that started close
    // together — we'd rather free-ride than double-fetch.
    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.
    //  Goal: let the user SEE requests firing and rate-limit hits
    //  in real time without opening devtools.
    // ════════════════════════════════════════════════════════════

    const debugLog = [];
    const MAX_DEBUG_LOG = 150;
    // Cheap pub-sub so the Settings tab can re-render the log as new
    // entries land. document is always present before the IIFE runs.
    const debugBus = document.createElement("div");

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

    // Strip host noise and redact the `key=` query param so Torn/FFScouter
    // API keys never end up 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;
    // Direct attack URL — lands on the fight page for the target. Torn
    // auto-credits the bounty when the target is hospitalised, so we don't
    // need `&bounty=<contract_id>` (which isn't in the public API anyway).
    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,
        refreshSec: 60,
        toastsEnabled: true,
        includeUnknownFF: false,
    };

    // Torn New Player Protection (https://wiki.torn.com/wiki/New_Player_Protection):
    //   - NPP lasts 14 days (age 0..13). At age >= 14 the player loses NPP.
    //   - A non-NPP player cannot attack an NPP player.
    //   - An NPP player CAN attack another NPP player, but not in the target's first 24 h.
    //   - (Edge case we ignore: faction-war participation lifts NPP temporarily.)
    const NPP_DAYS = 14;
    function isAttackableByAge(targetAge, myAge) {
        if (targetAge == null) return true; // unknown → don't drop, Torn will reject on attack if any
        const meUnderNPP = (myAge != null) && (myAge < NPP_DAYS);
        if (meUnderNPP) {
            // Both under NPP → target must be past the 24-hour hard block.
            return targetAge >= 1;
        }
        // We're past NPP → target must also be past NPP to be attackable.
        return targetAge >= NPP_DAYS;
    }

    // Physical-country derivation from Torn v2 status. Used to exclude targets
    // who are in a different country than us — they aren't reachable from an
    // attack page. Return values:
    //   "Torn"      — in Torn (Okay/Jail/Federal, or hospitalised in Torn)
    //   "<Country>" — abroad (state "Abroad"), or hospitalised in a specific
    //                 foreign country (description "In a <Adjective> hospital")
    //   null        — unknown or "Traveling" (in transit, unattackable anyway)
    //
    // Adjective→country map covers every Torn travel destination. If a future
    // destination ships without an entry here the target is returned as null
    // and the country filter falls open — safer than silently dropping them.
    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 TOAST_TIMEOUT_MS = 15_000;
    const TOAST_MAX_VISIBLE_DESKTOP = 3;
    const TOAST_MAX_VISIBLE_MOBILE = 1;
    const MOBILE_MEDIA = "(max-width: 768px)";
    function toastMaxVisible() {
        try { return window.matchMedia(MOBILE_MEDIA).matches ? TOAST_MAX_VISIBLE_MOBILE : TOAST_MAX_VISIBLE_DESKTOP; }
        catch { return TOAST_MAX_VISIBLE_DESKTOP; }
    }
    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);
    // Desktop userscript managers expose GM_xmlhttpRequest when the script
    // requests `@grant GM_xmlhttpRequest`. We prefer it over fetch because
    // FFScouter's CORS headers may not allow direct calls from torn.com.
    // 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`;
        },
    };

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

    // Parse FFScouter's bs_estimate_human ("3.93k" / "2.99b") to a comparable
    // number. Returns null for missing/unparseable values so callers can sort
    // them to the end.
    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);
            return { ...DEFAULT_SETTINGS, ...(raw ? JSON.parse(raw) : {}) };
        } catch { return { ...DEFAULT_SETTINGS }; }
    }

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

    // 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 around the raw HTTP call. Records status + ms
    // for every request so the user can watch traffic in real time.
    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 OK with { error: { code: 5, error: "Too many requests" } }
            // on rate limit. Surface that in the log even though the HTTP layer saw 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 for both Torn (JSON envelope error.code=5) and
    // FFScouter/HTTP layer (status 429). When true, the caller should abort
    // the whole refresh cycle so prior matches stay on screen instead of
    // being overwritten with a partial/empty set.
    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();
            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() {
            const all = [];
            let url = `${API_BASE}/torn/bounties?limit=100&offset=0`;
            let delay = 0;
            let safety = 10; // cap pagination at 1000 bounties
            while (url && safety-- > 0) {
                const data = await this._get(url);
                if (Array.isArray(data.bounties)) all.push(...data.bounties);
                if (typeof data.bounties_delay === "number") delay = data.bounties_delay;
                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 includes status + age + level + faction_id in one call,
            // which we need to filter out Torn's new-account protection window.
            const data = await this._get(`/user/${id}/profile`);
            return data.profile || null;
        }

        async validateKey() {
            // /user/profile gives us id + level + age + faction_id in one shot;
            // age is needed to evaluate Torn's NPP rule against bounty targets.
            const data = await this._get("/user/profile");
            return (data && data.profile) || null;
        }
    }

    // ════════════════════════════════════════════════════════════
    //  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 so prior matches stay visible —
                // partial FFScouter data would otherwise 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); },
        // When the PDA-injected key is in use we don't persist it — it may
        // change per session and the user controls it through PDA settings.
        isPDAKey() { return HAS_PDA_KEY; },
    };

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

    class Hunter {
        constructor(api) {
            this.api = api;
            this.settings = loadSettings();
            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();
            this.lastCounts = null;     // { total, afterBasic, afterFF, withFF, ffNull, ffError, statusBreakdown }
            this._statusCache = new Map(); // id → { data, fetchedAt }
            this._timer = null;
            this._running = false;
            this._nextAt = 0;
            this.lastError = null;
            this.onUpdate = null;       // ui sets this
            this.onToast = null;        // toaster sets this
        }

        updateSettings(next) {
            this.settings = { ...this.settings, ...next };
            saveSettings(this.settings);
            // Reset diff memory so tightening/loosening filters doesn't spam toasts
            // with everything, nor suppress legitimate new matches.
            this.lastMatchIds = new Set();
            // Invalidate the cross-tab cache — its matches were computed with the
            // previous filters, so neither this tab nor its siblings should reuse it.
            try { localStorage.removeItem(LS.shared); } catch { /* noop */ }
        }

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

        // Apply a new match list: diff against previous to fire toasts, update
        // lastMatches/lastMatchIds. Shared by the real-refresh path and the
        // cross-tab free-ride path.
        _applyMatches(matches) {
            const matchKey = (m) => `${m.target_id}|${m.reward}`;
            const currentIds = new Set(matches.map(matchKey));
            const newOnes = matches.filter((m) => !this.lastMatchIds.has(matchKey(m)));
            if (this.settings.toastsEnabled && this.onToast && newOnes.length > 0) {
                this.onToast(newOnes);
            }
            this.lastMatches = matches;
            this.lastMatchIds = currentIds;
        }

        // Called by the `storage` event listener when another tab writes new
        // shared data. Updates our in-memory state + re-renders via onUpdate.
        adoptSharedPayload(s) {
            if (!s) return;
            this._adoptSharedIdentity(s);
            if (Array.isArray(s.matches)) {
                this.lastCounts = s.lastCounts || null;
                this._applyMatches(s.matches);
            }
            if (this.onUpdate) this.onUpdate();
        }

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

        start() {
            if (this._running) return;
            this._running = true;
            this._tick();
        }

        async _tick() {
            if (!this._running) return;
            let waitSec = this.settings.refreshSec;
            try {
                // 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.
                const shared = this._readShared();
                const fresh = shared
                    && (Date.now() - shared.writtenAt) < (this.settings.refreshSec * 1000 * SHARED_FRESH_RATIO);
                if (fresh) {
                    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._applyMatches(shared.matches || []);
                    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)) {
                    logDebug(`refresh aborted — rate limit; keeping ${this.lastMatches.length} prior matches on screen`, "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);
        }

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

            // Resolve our ID/level/age/country. ID/level/age are near-immutable
            // so we cache them after the first success, but `country` flips
            // whenever we travel, so we re-read status on every 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;
                }
            } catch { /* non-fatal — keep previous identity/country */ }

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

            // Collapse multiple bounty rows on the same target + same reward
            // into one entry with an aggregated count. Rows with different
            // reward amounts stay separate. This avoids duplicate FFScouter
            // and profile calls AND cleans up the Hunt 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,
            };

            // 1) Price + self filter. Age-based "new-account" filter happens
            // later (in step 3) since target age requires a per-user profile
            // fetch anyway.
            const byBasic = dedupedBounties.filter((b) =>
                b.reward >= this.settings.minPrice
                && (this.myUserId == null || b.target_id !== this.myUserId)
            );
            counts.afterBasic = 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 };
                    // Target not in FF map (FFScouter returned null FF, or wasn't
                    // in the response at all). Include if the user 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 = await this._fetchProfiles(byFF.map((b) => Number(b.target_id)));
            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);
                // Country filter — must be in the same country as us to be
                // attackable. Only applied when both sides are known; if we
                // can't determine our own location (pre-first-profile, or
                // "Traveling") or the target's (unknown hospital adjective),
                // the filter falls open so we don't silently drop everyone.
                const targetCountry = getPlayerCountry(p.status);
                if (this.myCountry && targetCountry && targetCountry !== this.myCountry) {
                    counts.differentCountry++;
                    continue;
                }
                if (state === "Okay") {
                    matches.push({ ...b, statusState: "Okay", hospUntil: 0 });
                } else if (state === "Hospital" && remaining <= hospWindowSec) {
                    matches.push({ ...b, statusState: "Hospital", hospUntil: until });
                }
            }
            matches.sort((a, b) => b.reward - a.reward);
            counts.matches = matches.length;

            // 4) Diff for toasts + render + cross-tab broadcast.
            this.lastCounts = counts;
            this._applyMatches(matches);
            this._writeShared();
            logDebug(`refresh: done — ${matches.length} matches (of ${counts.total} bounties)`, "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 IDs so repeated target_ids don't trigger parallel
            // fetches for the same player. The cache lookup alone isn't
            // enough — on a cold start every duplicate would still miss.
            const unique = [...new Set(ids)];
            for (const id of unique) {
                const c = this._statusCache.get(id);
                if (c) {
                    const d = c.data;
                    // Hospital with a future `until` is effectively locked in —
                    // the target can't leave hospital unless revived (rare).
                    // Trust the cache until the timestamp passes.
                    const hospLocked = d && d.status && d.status.state === "Hospital"
                        && d.status.until && (d.status.until * 1000) > now;
                    if (hospLocked || (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 = 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,
                            };
                            out.set(id, data);
                            this._statusCache.set(id, { data, fetchedAt: Date.now() });
                        }
                    } catch (err) {
                        // Rate-limit: stop all workers and bubble up so the
                        // cycle aborts and prior matches stay on screen.
                        if (isRateLimitError(err)) { rateLimitErr = err; return; }
                        // Other per-target errors: row just won't match this cycle.
                    }
                }
            });
            await Promise.all(workers);
            if (rateLimitErr) throw rateLimitErr;
            return out;
        }

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

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

    class Toaster {
        constructor() {
            this.container = null;
            this._cards = []; // { id, el, timer, remaining, enteredAt }
            this._clearAllEl = null;
        }

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

        showMany(bounties) {
            this.ensureContainer();
            // Responsive cap — fewer alerts on phones where screen real estate
            // is scarce; more on desktop. Clear-all / overflow cards don't
            // count toward the bounty slot budget.
            const maxVisible = toastMaxVisible();
            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();
        }

        _showOne(b) {
            const el = document.createElement("div");
            el.className = "bh-toast";
            const isHosp = b.statusState === "Hospital";
            const statusLabel = isHosp ? fmt.hospLabel(b.hospUntil) : "Okay";
            const statusClass = isHosp ? "bh-badge-hosp" : "bh-badge-ok";
            const hospAttr = isHosp ? ` data-hosp-until="${b.hospUntil}"` : "";
            const countBadge = b.bountyCount > 1
                ? ` <span class="bh-count">×${b.bountyCount}</span>`
                : "";
            el.innerHTML = `
                <button class="bh-toast-close" title="Dismiss">&times;</button>
                <div class="bh-toast-head">
                    <div class="bh-toast-name">${escHtml(b.target_name)} <span class="bh-toast-lvl">L${b.target_level}</span>${countBadge}</div>
                    <div class="bh-toast-reward">${escHtml(fmt.money(b.reward))}</div>
                </div>
                <div class="bh-toast-meta">
                    <span class="bh-chip">FF ${b.ff == null ? "?" : b.ff.toFixed(2)}</span>
                    ${b.bs ? `<span class="bh-chip">BS ${escHtml(b.bs)}</span>` : ""}
                    <span class="bh-chip ${statusClass}"${hospAttr}>${statusLabel}</span>
                </div>
                <button class="bh-toast-attack">Attack →</button>
            `;
            const card = { id: Number(b.target_id), el, timer: null, remaining: TOAST_TIMEOUT_MS, 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);
            el.addEventListener("click", attack);
            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 card = { id: null, el, timer: null, remaining: TOAST_TIMEOUT_MS, enteredAt: 0, isMore: true, count };
            el.addEventListener("click", () => {
                if (window.__bhUI) window.__bhUI.toggle(true);
                this._remove(card);
            });
            el.addEventListener("pointerenter", () => this._pauseTimer(card));
            el.addEventListener("pointerleave", () => this._resumeTimer(card));
            this._resumeTimer(card);
            this.container.appendChild(el);
            this._cards.push(card);
        }

        // Small "Clear all" pill button, appears above the stack when 2+
        // toasts are on screen. Lives inside the container as the last DOM
        // child so the reversed column flex puts it visually on top.
        // Clicking only dismisses the toast cards — Hunt-tab matches are
        // driven by Hunter.lastMatches and are 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(); });
            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);
            // Count dropped — maybe the Clear-all button should vanish too.
            this._updateClearAllButton();
        }

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

    // ════════════════════════════════════════════════════════════
    //  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}
#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-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-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-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%}
/* Visual masking for API-key inputs. We intentionally avoid type="password"
   because Chrome/Safari then prompt to save the value as a login credential
   and password managers inject auto-fill UI on top. CSS masking gives the
   bullet-dot look without the browser treating the field as a login. */
.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 */
#bh-toasts{position:fixed;bottom:16px;right:16px;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;
  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-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-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}
  #bh-toasts{left:8px;right:8px;bottom:8px;align-items:stretch}
  .bh-toast{width:auto}
}
`;
        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";
        }

        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="settings">Settings</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();
            });

            // Hook hunter updates → UI refresh.
            this.hunter.onUpdate = () => { if (this._isOpen()) this._renderActive(); };
            this.hunter.onToast = (bounties) => this.toaster.showMany(bounties);

            // Countdown ticker — updates both the "next refresh in Xs" header
            // and any live hospital-countdown badges (rows + toasts). Ticks
            // every second; toasts live outside the panel so they tick too.
            this._countdownTimer = setInterval(() => {
                if (this._isOpen() && this.activeTab === "hunt") {
                    const el = document.getElementById("bh-countdown");
                    if (el) el.textContent = this.hunter.secondsUntilRefresh() + "s";
                }
                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"); }

        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; }
            if (this.activeTab === "hunt") this._renderHunt();
            else this._renderSettings();
        }

        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 under Settings.</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 rows = this._sortMatches(this.hunter.lastMatches);
            const c = this.hunter.lastCounts;
            const nextIn = this.hunter.secondsUntilRefresh();
            const rateLimited = isRateLimitError(this.hunter.lastError);
            // Only show the generic error line when it isn't a rate-limit —
            // the rate-limit case gets its own banner below with actionable copy.
            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 Settings, or enable "Include unknown-FF targets".</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 — prominent, actionable. When prior matches
            // exist we keep them on screen; when they don't, the banner fully
            // replaces the misleading "no matches" empty state.
            const rateLimitTitle = rows.length > 0
                ? "Rate limit hit — showing previous results"
                : "Rate limit hit — no results to show";
            const rateLimitLead = rows.length > 0
                ? `Torn / FFScouter returned a rate-limit response while processing ${c && c.total != null ? c.total + " " : ""}bounties. The table below is from the last successful refresh and may be slightly stale.`
                : `Torn / FFScouter returned a rate-limit response before any matches could be confirmed this cycle. No table is shown because no successful refresh has completed 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>To reduce API load, tighten your filters so fewer targets need a per-profile lookup:</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>
                `
                : "";
            content.innerHTML = `
                <div id="bh-status-line">
                    <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"}</span>
                    ${errLine}
                    ${ffKeyMissing}
                    ${ffError}
                </div>
                ${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 under Settings.</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>
                `}
            `;
            const btn = content.querySelector("#bh-refresh-btn");
            btn.addEventListener("click", async () => {
                btn.disabled = true;
                try { await this.hunter.refresh(); } 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("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 columns default to descending (biggest first),
                        // text columns default to ascending (A–Z).
                        this._sortDir = (col === "target") ? "asc" : "desc";
                    }
                    this._renderHunt();
                });
            });
        }

        _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;
                }
            };
            // "Infinity" keys (missing data) stay at the end regardless of dir,
            // so the user isn't bombarded with blank cells on asc.
            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;
                // Secondary: reward desc, keeps the big ones on top within ties.
                return (b.reward || 0) - (a.reward || 0);
            });
        }

        _renderRow(b) {
            const statusClass = b.statusState === "Hospital" ? "bh-badge-hosp" : "bh-badge-ok";
            const hospAttr = b.statusState === "Hospital" ? ` data-hosp-until="${b.hospUntil}"` : "";
            const statusText = b.statusState === "Hospital" ? fmt.hospLabel(b.hospUntil) : "Okay";
            const ffCell = b.ff == null ? "—" : b.ff.toFixed(2);
            const countBadge = b.bountyCount > 1
                ? ` <span class="bh-count">×${b.bountyCount}</span>`
                : "";
            return `
                <tr>
                    <td>
                        <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></td>
                    <td><button class="bh-attack" data-id="${b.target_id}">Attack →</button></td>
                </tr>
            `;
        }

        _renderSettings() {
            const content = this._panel.querySelector("#bh-content");
            const s = this.hunter.settings;
            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>Filters</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>Hospital max (minutes remaining)</label>
                            <input id="bh-set-hosp" class="bh-input" type="number" min="0" max="60" step="1" value="${s.hospitalMaxMin}">
                            <span class="bh-hint">0 = Okay only. ~5 lets you queue targets about to leave hospital.</span>
                        </div>
                        <div class="bh-field">
                            <label>Fair-fight min</label>
                            <input id="bh-set-ffmin" class="bh-input" type="number" min="1" max="10" step="0.1" value="${s.minFF}">
                        </div>
                        <div class="bh-field">
                            <label>Fair-fight max</label>
                            <input id="bh-set-ffmax" class="bh-input" type="number" min="1" max="10" step="0.1" value="${s.maxFF}">
                        </div>
                    </div>
                </div>

                <div class="bh-section">
                    <h3>Refresh</h3>
                    <div class="bh-grid-2">
                        <div class="bh-field">
                            <label>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">Honors Torn's global bounty-cache delay. 60 s is comfortably under the rate limit.</span>
                        </div>
                        <div class="bh-field">
                            <label>Notifications</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-toasts"${s.toastsEnabled ? " checked" : ""}> Show toast for new matches</label>
                            <label class="bh-check"><input type="checkbox" id="bh-set-unkff"${s.includeUnknownFF ? " checked" : ""}> Include targets with unknown FF score</label>
                        </div>
                    </div>
                </div>

                <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>
                    <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(60, parseInt($("bh-set-hosp").value, 10) || 0)),
                    minFF: cleanMin,
                    maxFF: cleanMax,
                    refreshSec: parseInt($("bh-set-refresh").value, 10),
                    toastsEnabled: $("bh-set-toasts").checked,
                    includeUnknownFF: $("bh-set-unkff").checked,
                });
                if (this.hunter.settings.refreshSec > 0) {
                    this.hunter.stop();
                    this.hunter.start();
                } else {
                    this.hunter.stop();
                }
            };
            ["bh-set-price", "bh-set-hosp", "bh-set-ffmin", "bh-set-ffmax", "bh-set-refresh", "bh-set-toasts", "bh-set-unkff"]
                .forEach((id) => $(id).addEventListener("change", persistFilters));

            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.");
                    // Drop stale error so Hunt tab doesn't display a ghost message.
                    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);
                // Clear any "no_key" / "Invalid API key" error left over from the
                // previous refresh, so tab-switching to Hunt before the new
                // refresh lands doesn't show a ghost FFScouter error.
                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(() => {});
            });
            $("bh-reset").addEventListener("click", () => {
                if (!confirm("Reset filters to defaults?")) return;
                this.hunter.updateSettings({ ...DEFAULT_SETTINGS, toastsEnabled: this.hunter.settings.toastsEnabled });
                this._renderActive();
            });

            // 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();
            });
            // Live updates: subscribe once per render, and clean up when the
            // node goes away (next settings re-render replaces the container).
            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 userscripts)
    //  — 1 script installed: its icon goes in the footer directly.
    //  — 2+ installed: a single 3-dots menu holds them all and
    //    expands a row above the footer on click.
    //  Idempotent and duplicated verbatim across scripts. The
    //  __eugFooterMenuLoaded guard ensures setup runs once per page.
    // ════════════════════════════════════════════════════════════

    (function setupEugFooterMenu() {
        // Use the page's real window so scripts in different @grant sandboxes
        // share the same registry. SPA (@grant none) and TAT (@grant GM_*)
        // otherwise see isolated `window` objects and can't find each other.
        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;
        }

        // Legacy standalone-button IDs from pre-shared-menu versions.
        // If a user has a mixed install (one script new, one old), the old
        // script creates its own button under one of these IDs. Nuke them
        // so the shared menu stays authoritative. Safe to add new IDs here.
        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 DOM on navigation, taking our buttons
            // with it. Keep observing indefinitely and re-render whenever the
            // ref button is back but our buttons are gone. Throttled via rAF.
            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();
        const ui = new UI(hunter, toaster);

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

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

        // Cross-tab sync: when another tab writes fresh match data, adopt it
        // here and re-render. The Hunter's own _tick path separately reads
        // the same store on its next cycle to decide whether to skip its
        // own fetch.
        window.addEventListener("storage", (e) => {
            if (e.key !== LS.shared || !e.newValue) return;
            try {
                const payload = JSON.parse(e.newValue);
                hunter.adoptSharedPayload(payload);
            } catch { /* ignore malformed writes */ }
        });

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

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