Torn Revive Payment Tracker

Track whether revived players pay voluntarily, and show other players' payment history before you revive them.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey, Greasemonkey или Violentmonkey.

Для установки этого скрипта вам необходимо установить расширение, такое как Tampermonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Violentmonkey.

Чтобы установить этот скрипт, вы сначала должны установить расширение браузера, например Tampermonkey или Userscripts.

Чтобы установить этот скрипт, сначала вы должны установить расширение браузера, например Tampermonkey.

Чтобы установить этот скрипт, вы должны установить расширение — менеджер скриптов.

(у меня уже есть менеджер скриптов, дайте мне установить скрипт!)

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение браузера, например Stylus.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

Чтобы установить этот стиль, сначала вы должны установить расширение — менеджер стилей.

(у меня уже есть менеджер стилей, дайте мне установить скрипт!)

// ==UserScript==
// @name         Torn Revive Payment Tracker
// @namespace    torn-revive-payment-tracker
// @version      0.7.8
// @description  Track whether revived players pay voluntarily, and show other players' payment history before you revive them.
// @author       LordVillage
// @license      No redistribution without permission from the author
// @match        https://www.torn.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_xmlhttpRequest
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @connect      qiverof64h.execute-api.eu-west-1.amazonaws.com
// @connect      update.greasyfork.org
// @run-at       document-start
// ==/UserScript==

/*
 * TornPDA compatibility, confirmed against Manuito83/torn-pda's own
 * userscripts/README.md, GMforPDA.user.js and TornPDA_API.js:
 *   - GM_setValue/GM_getValue and GM_xmlhttpRequest ARE shimmed (backed by
 *     localStorage and PDA_httpGet/PDA_httpPost respectively) with the same
 *     call shape Tampermonkey uses, so those needed no changes.
 *   - GM_registerMenuCommand/GM_unregisterMenuCommand do NOT exist in that
 *     shim. Calling them unconditionally (as this script used to) throws a
 *     ReferenceError inside TornPDA and aborts the rest of the script - the
 *     menu-command calls below are guarded behind a feature check for this
 *     reason. There's no on-page fallback for setting the key manually when
 *     the menu is unavailable (see PDA_APIKEY_PLACEHOLDER below for why
 *     that's not needed there); Contract mode still gets a floating on-page
 *     toggle though, shown only on pages where a revive button (a.revive)
 *     is actually present.
 *   - PDA_APIKEY_PLACEHOLDER below is a documented magic string
 *     ("###PDA-APIKEY###") that TornPDA text-substitutes for the user's
 *     real Torn API key before running the script; left untouched by any
 *     normal browser/Tampermonkey. A key set via GM_setValue (only reachable
 *     through the menu, so desktop-only) always takes priority over it.
 */

(function () {
  "use strict";

  // Unconditional, low-volume startup log - the whole point is to answer
  // "is the script running at all on this page" without guesswork if
  // something appears silently broken (readyState included since @run-at
  // document-start means this may fire before the page has finished loading).
  console.info("[Revive Tracker] script starting", {
    href: window.location.href,
    readyState: document.readyState,
  });

  const API_BASE_URL = "https://qiverof64h.execute-api.eu-west-1.amazonaws.com/Prod";

  // Kept in sync with the @version header above, not read from GM_info -
  // confirmed live that TornPDA's GM_info shim doesn't populate
  // .script.version, which was reporting the literal string "undefined" to
  // the backend (encodeURIComponent(undefined) === "undefined").
  const SCRIPT_VERSION = "0.7.8";

  const GREASYFORK_SCRIPT_ID = "586027";
  const GREASYFORK_META_URL = `https://update.greasyfork.org/scripts/${GREASYFORK_SCRIPT_ID}/Torn%20Revive%20Payment%20Tracker.meta.js`;
  const GREASYFORK_SCRIPT_URL = `https://greasyfork.org/scripts/${GREASYFORK_SCRIPT_ID}`;
  const UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
  const TILL_PROFILE_ID = "3254720";

  const REVIVE_TEXT_PATTERN = /You successfully revived/i;
  const PROFILE_ID_PATTERN = /XID=(\d+)/;
  const RECORDED_NODES = new WeakSet();

  const HAS_MENU_COMMANDS =
    typeof GM_registerMenuCommand === "function" && typeof GM_unregisterMenuCommand === "function";

  const PDA_APIKEY_PLACEHOLDER = "###PDA-APIKEY###";

  function getPdaApiKey() {
    // Untouched (still contains the literal placeholder) outside TornPDA.
    return PDA_APIKEY_PLACEHOLDER.indexOf("###") === -1 ? PDA_APIKEY_PLACEHOLDER : null;
  }

  function getApiKey() {
    return GM_getValue("torn_api_key", null) || getPdaApiKey();
  }

  function promptForApiKey() {
    const key = window.prompt(
      "Torn Revive Tracker: paste a Torn API key with at least 'Limited' " +
        "access (create one at torn.com/preferences.php#tab=api). It's " +
        "stored locally in your browser, and also sent to this script's " +
        "backend (over HTTPS, encrypted at rest) so it can check your " +
        "Torn activity on your behalf for revive/payment tracking."
    );
    if (key) {
      GM_setValue("torn_api_key", key.trim());
    }
    return key ? key.trim() : null;
  }

  function ensureApiKey() {
    return getApiKey() || promptForApiKey();
  }

  if (HAS_MENU_COMMANDS) {
    GM_registerMenuCommand("Set Torn API key", promptForApiKey);
  }
  // No on-page fallback when menu commands are unavailable (e.g. TornPDA) -
  // that platform auto-detects its own injected key (see getPdaApiKey), so
  // there's no floating settings button cluttering the screen there anymore.
  // A manual override is only reachable via GM_setValue on platforms that
  // do have a menu.

  // Contract mode: while ON, revives are recorded as exempt up front (e.g.
  // a faction contract with no payment expected) - the backend skips
  // verification/payment tracking for them entirely.
  const CONTRACT_MODE_KEY = "contract_mode";
  let contractMenuCommandId = null;

  function isContractMode() {
    return !!GM_getValue(CONTRACT_MODE_KEY, false);
  }

  function refreshContractMenuCommand() {
    if (!HAS_MENU_COMMANDS) return;
    if (contractMenuCommandId !== null) {
      GM_unregisterMenuCommand(contractMenuCommandId);
    }
    const label = `Contract mode: ${isContractMode() ? "ON" : "OFF"}`;
    contractMenuCommandId = GM_registerMenuCommand(label, toggleContractMode);
  }

  // Toast confirming the new state - toggling is otherwise silent (a menu
  // command or a small fixed-position button), easy to fire by accident and
  // not notice. Positioned just above the contract-mode button so it reads
  // as tied to that control even on pages where the button isn't shown
  // (e.g. toggled via the Tampermonkey menu instead).
  function showContractModeToast(on) {
    if (!document.body) return;
    const toast = document.createElement("div");
    toast.textContent = on
      ? "Contract mode enabled - not expecting payment for these revives"
      : "Contract mode disabled - payment tracking resumed";
    toast.style.cssText =
      "position:fixed;bottom:52px;left:8px;z-index:2147483647;" +
      "background:#023b4e;color:#fff;padding:8px 12px;border-radius:6px;" +
      "font-size:13px;font-family:Arial, Helvetica, sans-serif;max-width:240px;" +
      "box-shadow:0 1px 4px rgba(0,0,0,0.4);opacity:0;transition:opacity 0.4s ease;";
    document.body.appendChild(toast);
    // Set opacity in a follow-up frame so the browser paints the opacity:0
    // state first - otherwise the transition has nothing to animate from.
    requestAnimationFrame(() => {
      toast.style.opacity = "1";
    });
    setTimeout(() => {
      toast.style.opacity = "0";
      setTimeout(() => toast.remove(), 400);
    }, 3000);
  }

  function toggleContractMode() {
    const newState = !isContractMode();
    GM_setValue(CONTRACT_MODE_KEY, newState);
    refreshContractMenuCommand();
    if (contractModeButtonUpdateLabel) contractModeButtonUpdateLabel();
    showContractModeToast(newState);
  }

  refreshContractMenuCommand();

  // Hospital-list sorting: reorder the hospital list so the best-paying
  // targets float to the top, using the same reputation the row badges are
  // already fetching (see flushRowBadges / sortHospitalList). Subscriber-only
  // in effect - without an active subscription the batch lookup 402s, there's
  // no reputation, and nothing gets reordered. On by default; toggled from the
  // Tampermonkey menu OR the on-page button below (the button is what makes it
  // controllable on TornPDA, which has no menu-command shim).
  const SORT_HOSPITAL_KEY = "sort_hospital_by_reputation";
  let sortMenuCommandId = null;
  let sortButtonUpdateLabel = null;

  function isHospitalSortEnabled() {
    return GM_getValue(SORT_HOSPITAL_KEY, true) !== false;
  }

  function refreshSortMenuCommand() {
    if (!HAS_MENU_COMMANDS) return;
    if (sortMenuCommandId !== null) {
      GM_unregisterMenuCommand(sortMenuCommandId);
    }
    const label = `Filter hospital list: ${isHospitalSortEnabled() ? "ON" : "OFF"}`;
    sortMenuCommandId = GM_registerMenuCommand(label, toggleHospitalSort);
  }

  function toggleHospitalSort() {
    GM_setValue(SORT_HOSPITAL_KEY, !isHospitalSortEnabled());
    refreshSortMenuCommand();
    if (sortButtonUpdateLabel) sortButtonUpdateLabel();
    // The filter master switch: turning it on sorts the list AND (if the hide
    // sub-setting is on) hides un-revivable rows; turning it off shows every
    // row again. sortHospitalList no-ops when off, so rows already moved stay
    // put until the next page load restores Torn's natural order.
    sortHospitalList();
    applyRevivableFilter();
  }

  refreshSortMenuCommand();

  // The single "Filter hospital list" floating button - styled like Torn's own
  // bottom-toolbar icons and tucked tight against the contract-mode button
  // like a native toolbar group (contract at left:8px is 40px wide, ending at
  // 48px; this at left:50px = a 2px gap). Only injected on the hospital list,
  // once its rows exist. A descending-bars icon; struck through when the filter
  // is OFF. Clicking it sorts by pay rate and applies the hide sub-setting.
  let sortButtonShown = false;

  function injectSortButtonIfPresent() {
    if (sortButtonShown) return;
    if (!document.querySelector(".user-wrap.user-name")) return;
    sortButtonShown = true;

    const button = document.createElement("div");
    button.style.cssText =
      "position:fixed;bottom:0;left:50px;z-index:2147483647;" +
      "width:40px;height:40px;display:flex;align-items:center;justify-content:center;" +
      "border-radius:8px 8px 0 0;cursor:pointer;" +
      "box-shadow:0 1px 4px rgba(0,0,0,0.4),inset 0 1px 1px rgba(255,255,255,0.15),inset 0 -6px 10px rgba(0,0,0,0.25);";

    const sortIcon = (struckThrough) =>
      '<svg viewBox="0 0 24 24" width="22" height="22" style="filter:drop-shadow(0 1px 1px rgba(0,0,0,0.5));">' +
      '<rect x="5" y="6" width="14" height="2.6" rx="1.3" fill="#fff"/>' +
      '<rect x="5" y="11" width="9" height="2.6" rx="1.3" fill="#fff"/>' +
      '<rect x="5" y="16" width="5" height="2.6" rx="1.3" fill="#fff"/>' +
      (struckThrough ? '<line x1="4" y1="20" x2="20" y2="4" stroke="#fff" stroke-width="2" stroke-linecap="round"/>' : "") +
      "</svg>";

    sortButtonUpdateLabel = () => {
      const on = isHospitalSortEnabled();
      button.innerHTML = sortIcon(!on);
      button.title = `Filter hospital list: ${on ? "ON" : "OFF"} (click to toggle)`;
      button.style.backgroundImage = on
        ? "linear-gradient(#026181, #023b4e)"
        : "linear-gradient(#5b5b5b, #525252)";
    };
    sortButtonUpdateLabel();

    button.addEventListener("click", toggleHospitalSort);
    document.body.appendChild(button);
  }

  // Hide-un-revivable sub-setting of the hospital filter: when the filter is
  // on (the floating button), also hide every row whose player you can't
  // revive right now (no a.revive, or reviveNotAvailable - see hasRevivesOn).
  // On by default; lives ONLY in the Revive Tracker preferences tab (see
  // injectPreferencesTab) plus a Tampermonkey menu command - no floating
  // button of its own. It only takes effect while the filter button is on.
  const HIDE_UNREVIVABLE_KEY = "hide_unrevivable";
  let hideMenuCommandId = null;

  function isHideUnrevivableEnabled() {
    return GM_getValue(HIDE_UNREVIVABLE_KEY, true) !== false;
  }

  // Hide un-revivable rows only when BOTH the hospital filter (the floating
  // button, isHospitalSortEnabled) is on AND this sub-setting is on; otherwise
  // every row is shown. So the filter button is the master switch and the
  // hide setting just decides whether hiding is part of filtering.
  function applyRevivableFilter() {
    const hide = isHospitalSortEnabled() && isHideUnrevivableEnabled();
    document.querySelectorAll(".user-wrap.user-name").forEach((nameSpan) => {
      const row = nameSpan.closest("li");
      if (!row) return;
      row.style.display = hide && !hasRevivesOn(nameSpan) ? "none" : "";
    });
  }

  function refreshHideMenuCommand() {
    if (!HAS_MENU_COMMANDS) return;
    if (hideMenuCommandId !== null) {
      GM_unregisterMenuCommand(hideMenuCommandId);
    }
    const label = `Hide un-revivable: ${isHideUnrevivableEnabled() ? "ON" : "OFF"}`;
    hideMenuCommandId = GM_registerMenuCommand(label, toggleHideUnrevivable);
  }

  function toggleHideUnrevivable() {
    GM_setValue(HIDE_UNREVIVABLE_KEY, !isHideUnrevivableEnabled());
    refreshHideMenuCommand();
    applyRevivableFilter(); // re-apply immediately (hide or restore)
  }

  refreshHideMenuCommand();

  let contractModeButtonShown = false;
  let contractModeButtonUpdateLabel = null;

  function injectContractModeButtonIfPresent() {
    if (contractModeButtonShown) return;
    if (!document.querySelector("a.revive")) return;
    contractModeButtonShown = true;

    // Styled like Torn's own bottom-toolbar icons - colors and the subtle
    // top-to-bottom gradient sampled directly from a live screenshot of that
    // bar (gray #5b5b5b->#525252 inactive, teal #026181->#023b4e active).
    // Rounded only at the top and flush with the bottom edge (no bottom
    // offset, square bottom corners) so it reads as a tab built into the
    // edge of the page rather than a floating overlay.
    const button = document.createElement("div");
    button.style.cssText =
      "position:fixed;bottom:0;left:8px;z-index:2147483647;" +
      "width:40px;height:40px;display:flex;align-items:center;justify-content:center;" +
      "border-radius:8px 8px 0 0;cursor:pointer;" +
      "box-shadow:0 1px 4px rgba(0,0,0,0.4),inset 0 1px 1px rgba(255,255,255,0.15),inset 0 -6px 10px rgba(0,0,0,0.25);";

    const dollarIcon = (crossedOut) =>
      '<svg viewBox="0 0 24 24" width="22" height="22" style="filter:drop-shadow(0 1px 1px rgba(0,0,0,0.5));">' +
      '<text x="12" y="17" text-anchor="middle" font-size="17" font-weight="bold" ' +
      'font-family="Arial, Helvetica, sans-serif" fill="#fff">$</text>' +
      (crossedOut ? '<line x1="4" y1="20" x2="20" y2="4" stroke="#fff" stroke-width="2" stroke-linecap="round"/>' : "") +
      "</svg>";

    contractModeButtonUpdateLabel = () => {
      const on = isContractMode();
      // ON = contract/exempt = not expecting payment, so the $ gets struck
      // through; OFF = normal revive = expecting payment = plain $.
      button.innerHTML = dollarIcon(on);
      button.title = `Contract mode: ${on ? "ON" : "OFF"} (click to toggle - no payment tracking for revives while ON)`;
      button.style.backgroundImage = on
        ? "linear-gradient(#026181, #023b4e)"
        : "linear-gradient(#5b5b5b, #525252)";
    };
    contractModeButtonUpdateLabel();

    button.addEventListener("click", toggleContractMode);
    document.body.appendChild(button);
  }

  function whenBodyReady(callback) {
    if (document.body) {
      callback();
      return;
    }
    // @run-at document-start means document.body may not exist yet -
    // <html> does exist that early, though, so watch it for body's
    // insertion rather than waiting all the way for DOMContentLoaded. Lets
    // DOM-dependent setup (revive detection, hospital rows, the
    // contract-mode button) attach as soon as the page structurally can
    // support it, not just once the whole document has finished parsing.
    const observer = new MutationObserver(() => {
      if (document.body) {
        observer.disconnect();
        callback();
      }
    });
    observer.observe(document.documentElement, { childList: true });
  }

  function waitForCondition(check, callback) {
    const existing = check();
    if (existing) {
      callback(existing);
      return;
    }
    // Unlike whenBodyReady, this has to watch for something specific deep in
    // the page (e.g. the name heading), which can appear well after <body>
    // itself does - a one-shot check gated only on document.body existing
    // fires too early under document-start and silently finds nothing (the
    // bug this replaced). subtree:true on <html> catches it appearing
    // anywhere below, whenever that happens.
    const observer = new MutationObserver(() => {
      const result = check();
      if (result) {
        observer.disconnect();
        callback(result);
      }
    });
    observer.observe(document.documentElement, { childList: true, subtree: true });
  }

  function waitForElement(selector, callback) {
    waitForCondition(() => document.querySelector(selector), callback);
  }

  function watchForReviveButton() {
    injectContractModeButtonIfPresent();
    if (contractModeButtonShown) return;

    const observer = new MutationObserver(() => {
      injectContractModeButtonIfPresent();
      if (contractModeButtonShown) observer.disconnect();
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function apiRequest(method, path, body) {
    const apiKey = getApiKey();
    return new Promise((resolve, reject) => {
      // script_version travels as a query param rather than a header -
      // confirmed live that TornPDA's native GET handler doesn't forward
      // custom headers the way Tampermonkey/POST do, silently dropping it,
      // while the URL itself is unaffected on every platform.
      const separator = path.includes("?") ? "&" : "?";
      const url = `${API_BASE_URL}${path}${separator}script_version=${encodeURIComponent(SCRIPT_VERSION)}`;
      const headers = Object.assign(
        { "Content-Type": "application/json" },
        apiKey ? { Authorization: `Bearer ${apiKey}` } : {}
      );
      GM_xmlhttpRequest({
        method,
        url,
        headers,
        data: body ? JSON.stringify(body) : undefined,
        onload: (response) => {
          if (response.status >= 200 && response.status < 300) {
            try {
              resolve(JSON.parse(response.responseText));
            } catch (err) {
              reject(err);
            }
          } else {
            const err = new Error(`API ${method} ${path} failed: ${response.status} ${response.responseText}`);
            err.status = response.status;
            try {
              err.body = JSON.parse(response.responseText);
            } catch (parseErr) {
              err.body = null;
            }
            reject(err);
          }
        },
        onerror: reject,
      });
    });
  }

  function compareVersions(a, b) {
    const partsA = a.split(".").map(Number);
    const partsB = b.split(".").map(Number);
    for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
      const diff = (partsA[i] || 0) - (partsB[i] || 0);
      if (diff !== 0) return diff > 0 ? 1 : -1;
    }
    return 0;
  }

  // Greasy Fork publishes a lightweight ".meta.js" alongside every script -
  // just the UserScript header block, not the full code - specifically so
  // consumers can check for updates without downloading the whole thing.
  // Checked at most once every 6h (cached via GM_setValue) since this fires
  // on every page load; mainly useful on TornPDA, which has no equivalent of
  // Tampermonkey's own update-available badge on the extension icon.
  function checkForScriptUpdate() {
    const currentVersion = SCRIPT_VERSION;

    const lastCheckedAt = Number(GM_getValue("update_check_at", 0));
    if (Date.now() - lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) {
      const cachedLatest = GM_getValue("update_latest_version", null);
      return Promise.resolve(
        cachedLatest && compareVersions(cachedLatest, currentVersion) > 0 ? cachedLatest : null
      );
    }

    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: "GET",
        url: GREASYFORK_META_URL,
        onload: (response) => {
          GM_setValue("update_check_at", Date.now());
          const match = /@version\s+([\d.]+)/.exec(response.responseText || "");
          const latestVersion = match ? match[1] : null;
          GM_setValue("update_latest_version", latestVersion);
          if (latestVersion && compareVersions(latestVersion, currentVersion) > 0) {
            console.info(
              `[Revive Tracker] Update available: v${latestVersion} (currently running v${currentVersion}) - ${GREASYFORK_SCRIPT_URL}`
            );
            resolve(latestVersion);
          } else {
            resolve(null);
          }
        },
        onerror: (err) => {
          console.warn("[Revive Tracker] Update check failed:", err);
          resolve(null);
        },
      });
    });
  }

  // Fired immediately (not gated on DOM/page type) so it runs in parallel
  // with page parsing and its console notice shows up on every page, not
  // just the home page.
  const updateCheckPromise = checkForScriptUpdate();

  let noticeShown = false;

  function showNotice(message, action) {
    if (noticeShown) return;
    noticeShown = true;

    const banner = document.createElement("div");
    // Max 32-bit z-index so the notice sits above everything - Torn's own
    // overlays and, importantly, this script's own fixed buttons/toasts (which
    // also use 2147483647). At the old 99999 the "set your API key" prompt
    // could end up hidden behind those, which is the worst thing to hide.
    banner.style.cssText =
      "position:fixed;top:0;left:0;right:0;z-index:2147483647;" +
      "background:#c62828;color:#fff;padding:10px 16px;font-size:13px;" +
      "font-weight:bold;text-align:center;box-shadow:0 2px 6px rgba(0,0,0,0.3);";

    const text = document.createElement("span");
    text.textContent = message + " ";
    banner.appendChild(text);

    if (action) {
      if (action.href) {
        const link = document.createElement("a");
        link.href = action.href;
        link.target = "_blank";
        link.rel = "noopener";
        link.style.cssText = "color:#fff;text-decoration:underline;";
        link.textContent = action.label;
        banner.appendChild(link);
      } else if (action.onClick) {
        const button = document.createElement("span");
        button.textContent = action.label;
        button.style.cssText = "color:#fff;text-decoration:underline;cursor:pointer;";
        button.addEventListener("click", action.onClick);
        banner.appendChild(button);
      }
    }

    const dismiss = document.createElement("span");
    dismiss.textContent = " ×";
    dismiss.style.cssText = "margin-left:12px;cursor:pointer;font-weight:bold;";
    dismiss.addEventListener("click", () => banner.remove());
    banner.appendChild(dismiss);

    document.body.prepend(banner);
  }

  function handleReputationFetchError(err) {
    if (err.status === 402) {
      showNotice(err.body && err.body.error ? err.body.error : "Revive Tracker: no active subscription.", {
        href: `https://www.torn.com/profiles.php?XID=${TILL_PROFILE_ID}`,
        label: "Send Xanax here",
      });
    } else if (err.status === 401) {
      showNotice("Revive Tracker: set your Torn API key to see reputation badges.", {
        onClick: promptForApiKey,
        label: "Set it now",
      });
    } else if (err.status === 403) {
      // Key verified but lacks the Limited access the tool needs to track
      // payments - tell them what's wrong and let them set a better key.
      showNotice(
        err.body && err.body.error
          ? `Revive Tracker: ${err.body.error}`
          : "Revive Tracker: your API key needs at least 'Limited' access to track payments.",
        { onClick: promptForApiKey, label: "Set a new key" }
      );
    } else {
      console.error("[Revive Tracker] Failed to fetch reputation:", err);
    }
  }

  function extractTargetId(node) {
    const match = node.innerHTML && node.innerHTML.match(PROFILE_ID_PATTERN);
    if (match) return parseInt(match[1], 10);

    const currentUrlMatch = window.location.href.match(/(?:XID|user2ID)=(\d+)/);
    return currentUrlMatch ? parseInt(currentUrlMatch[1], 10) : null;
  }

  // Best-effort only, unconfirmed live - the confirm-revive container's own
  // profile link text is assumed to be the target's display name (matches
  // how extractTargetId reads the same link's href), but this hasn't been
  // checked against a real hospital-list DOM snapshot the way the rest of
  // this container's structure was. Purely cosmetic if wrong: every
  // downstream use of target_username tolerates it being absent (see
  // record_revive.py), falling back to "Player #<id>".
  function extractTargetUsername(node) {
    if (!node.querySelector) return null;
    const link = node.querySelector('a[href*="XID="]');
    const text = link && link.textContent && link.textContent.trim();
    return text || null;
  }

  // Returns the recorded revive's {revive_id, target_id, target_username}
  // for showThankYouPromptToast to build a payment-request link from, or
  // null if nothing should be nudged - either the call failed, or contract
  // mode means no payment was ever expected for this one.
  async function recordRevive(targetId, targetUsername) {
    const apiKey = ensureApiKey();
    if (!apiKey) return null;

    const contract = isContractMode();
    try {
      const response = await apiRequest("POST", "/revives", {
        target_id: targetId,
        revived_at: Math.floor(Date.now() / 1000),
        contract,
        target_username: targetUsername || undefined,
      });
      console.info(
        `[Revive Tracker] Recorded revive of player ${targetId}` +
          (contract ? " (contract mode: no payment tracked)" : "")
      );
      if (contract) return null;
      return { revive_id: response.revive_id, target_id: targetId, target_username: targetUsername };
    } catch (err) {
      console.error("[Revive Tracker] Failed to record revive:", err);
      return null;
    }
  }

  // Prompt shown right after a successful (non-contract) revive - unlike
  // the 12h-later "Pending Payment Reminders" nudge, this fires
  // immediately, so it's framed as a thank-you-in-advance rather than a
  // follow-up (see THANK_YOU_MESSAGE). Persistent (no auto-fade, unlike
  // showContractModeToast) since the whole point is for the reviewer to
  // actually click through - it just sits until dismissed or used.
  // Positioned bottom-right, mirroring the contract-mode toast's
  // bottom-left slot, so the two never overlap.
  function showThankYouPromptToast(revive) {
    if (!document.body) return;
    const toast = document.createElement("div");
    toast.style.cssText =
      "position:fixed;bottom:8px;right:8px;z-index:2147483647;" +
      "background:#023b4e;color:#fff;padding:10px 14px;border-radius:8px;" +
      "font-size:13px;font-family:Arial, Helvetica, sans-serif;max-width:260px;" +
      "box-shadow:0 1px 4px rgba(0,0,0,0.4);";

    const text = document.createElement("div");
    text.textContent = "Revive recorded. Let them know a donation's expected?";
    toast.appendChild(text);

    const link = document.createElement("a");
    link.href = buildPaymentRequestLink(revive);
    link.textContent = "Send thank-you & payment note";
    link.style.cssText = "color:#8ecdf0;display:inline-block;margin-top:6px;font-weight:bold;text-decoration:none;";
    // Same same-tick-before-navigation pattern as the reminder link (see
    // renderUnpaidRevivesRows) - just tagged "thank_you" so
    // injectComposePagePrefill picks the warmer message/subject.
    link.addEventListener("click", () => {
      storePendingPaymentRequest(revive, "thank_you");
      toast.remove();
    });
    toast.appendChild(link);

    const dismiss = document.createElement("span");
    dismiss.textContent = " ×";
    dismiss.style.cssText = "margin-left:10px;cursor:pointer;font-weight:bold;color:#9fb3bd;";
    dismiss.addEventListener("click", () => toast.remove());
    toast.appendChild(dismiss);

    document.body.appendChild(toast);
  }

  function watchForRevive() {
    // The hospital list ships one empty <div class="confirm-revive"> per row
    // up front. Torn fills the row's own div in place (e.g. via .html(text
    // + link)), which adds the confirmation text and the profile <a> as
    // sibling nodes rather than one wrapped node - so the check has to look
    // at the container's combined textContent, not at each added node.
    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        const container = mutation.target;
        if (!(container instanceof Element)) continue;
        if (!container.classList.contains("confirm-revive")) continue;
        if (RECORDED_NODES.has(container)) continue;

        const text = container.textContent || "";
        if (REVIVE_TEXT_PATTERN.test(text)) {
          RECORDED_NODES.add(container);
          const targetId = extractTargetId(container);
          if (targetId) {
            recordRevive(targetId, extractTargetUsername(container)).then((revive) => {
              if (revive) showThankYouPromptToast(revive);
            });
          } else {
            console.warn("[Revive Tracker] Detected a revive but couldn't determine the target's player id.");
          }
        }
      }
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function getProfileIdFromUrl() {
    const match = window.location.href.match(/profiles\.php\?XID=(\d+)/);
    return match ? match[1] : null;
  }

  function findGeneralInformationList() {
    // The box's own id looks dynamic/unstable (e.g. "item18534331" -
    // confirmed live via devtools screenshot), so it's found by its
    // h5.box-title text instead.
    const headers = document.querySelectorAll(".box-title");
    for (const header of headers) {
      if (header.textContent.trim() !== "General Information") continue;
      const box = header.closest(".sortable-box");
      const list = box && box.querySelector("ul.info-cont-wrap");
      if (list) return list;
    }
    return null;
  }

  // Mirrors Torn's own box shape (title bar + ul.info-cont-wrap), so it
  // inherits Torn's own styling instead of needing any of our own -
  // structure confirmed live via devtools against the General
  // Information / Equipped Weapons & Armor boxes' own markup, including
  // the arrow-wrap/move-wrap icon pair. Inserted directly after
  // anchorElement (afterend), so callers control placement by choosing
  // what to anchor to - e.g. General Information for the first box in the
  // column, or a previously-created box's own element to stack a second
  // one directly below it. Returns the box's own element and its
  // ul.info-cont-wrap list (rows go into the latter).
  function createInfoBox(anchorElement, title) {
    const box = document.createElement("div");
    box.className = "sortable-box t-blue-cont h";

    const titleBar = document.createElement("div");
    titleBar.className = "title main-title title-black active top-round";
    titleBar.setAttribute("role", "table");

    const arrowWrap = document.createElement("div");
    arrowWrap.className = "arrow-wrap";
    const arrowLink = document.createElement("a");
    arrowLink.href = "#/";
    arrowLink.setAttribute("role", "button");
    arrowLink.className = "accordion-header-arrow right";
    arrowLink.tabIndex = 0;
    arrowWrap.appendChild(arrowLink);

    const moveWrap = document.createElement("div");
    moveWrap.className = "move-wrap";
    moveWrap.setAttribute("data-prevent-flyout-swipe", "true");
    const moveIcon = document.createElement("i");
    moveIcon.className = "accordion-header-move right";
    moveWrap.appendChild(moveIcon);

    const heading = document.createElement("h5");
    heading.className = "box-title";
    heading.textContent = title;
    heading.tabIndex = 0;

    titleBar.appendChild(arrowWrap);
    titleBar.appendChild(moveWrap);
    titleBar.appendChild(heading);

    const bottomRound = document.createElement("div");
    bottomRound.className = "bottom-round";
    const contGray = document.createElement("div");
    contGray.className = "cont-gray bottom-round";
    const list = document.createElement("ul");
    list.className = "info-cont-wrap";
    contGray.appendChild(list);
    bottomRound.appendChild(contGray);

    // Torn wires its own collapse handler at page load, so a box injected
    // afterward wouldn't pick that up - replicate it ourselves instead,
    // toggling the same "active" class + bottom-round visibility Torn uses
    // (confirmed via devtools: title-black.active pairs with
    // bottom-round { display: block }).
    arrowLink.setAttribute("aria-label", `Close ${title} panel`);
    arrowLink.addEventListener("click", (event) => {
      event.preventDefault();
      event.stopPropagation();
      const collapsed = titleBar.classList.toggle("active") === false;
      bottomRound.style.display = collapsed ? "none" : "block";
      arrowLink.setAttribute("aria-label", `${collapsed ? "Open" : "Close"} ${title} panel`);
    });

    box.appendChild(titleBar);
    box.appendChild(bottomRound);
    anchorElement.insertAdjacentElement("afterend", box);

    return { box, list };
  }

  let reviveTrackerBoxShown = false;

  function createReviveTrackerBox(generalInfoList) {
    if (reviveTrackerBoxShown) return null;
    const generalInfoBox = generalInfoList.closest(".sortable-box");
    if (!generalInfoBox) return null;
    reviveTrackerBoxShown = true;
    // Placed directly after General Information, in the same column, so it
    // reads as a natural extension of that area rather than appearing
    // somewhere unrelated.
    return createInfoBox(generalInfoBox, "Revive Tracker");
  }

  let unpaidRevivesBoxShown = false;

  // Its own box (title bar reads "Pending Payment Reminders"), stacked
  // directly below the Revive Tracker box rather than folded into it as
  // more rows - a distinct, self-contained action list reads more clearly
  // as its own thing than as an extension of the stats box above it.
  function createUnpaidRevivesBox(reviveTrackerBoxElement) {
    if (unpaidRevivesBoxShown) return null;
    unpaidRevivesBoxShown = true;
    return createInfoBox(reviveTrackerBoxElement, "Pending Payment Reminders");
  }

  function appendInfoRow(list, label, value) {
    const row = document.createElement("li");
    row.tabIndex = 0;
    // Same two-column shape as Torn's own rows (confirmed live via
    // devtools - e.g. "Money" in .divider, "$202,148" in .desc): .divider
    // holds the label, .desc holds the value, so this inherits Torn's own
    // column styling instead of needing any of our own - except bold,
    // which Torn's own label column turned out not to apply on its own
    // (confirmed live), so it's set explicitly here to match the rest of
    // the table's look.
    const divider = document.createElement("span");
    divider.className = "divider";
    divider.style.fontWeight = "bold";
    divider.textContent = label;
    const desc = document.createElement("span");
    desc.className = "desc";
    if (value instanceof Node) {
      desc.appendChild(value);
    } else {
      desc.textContent = value;
    }
    row.appendChild(divider);
    row.appendChild(desc);
    // Inserted after the last real <li>, not just appended to the list -
    // #liveNetworth (a non-li child) trails the last row in this box,
    // confirmed live via devtools screenshot.
    const rows = list.querySelectorAll(":scope > li");
    const lastRow = rows[rows.length - 1];
    if (lastRow) {
      lastRow.insertAdjacentElement("afterend", row);
    } else {
      list.appendChild(row);
    }
  }

  function renderSubscriptionRow(list, subscriptionExpiresAt, subscriptionSource) {
    // null for the till account - it's exempt from the gate (see
    // get_reviewer_status.py). Still shown as its own row (rather than
    // omitted, which is what silently dropped every subscription row for
    // the till account's own home page - confirmed live via screenshot),
    // just with no expiry date row to go with it since there isn't one.
    if (!subscriptionExpiresAt) {
      appendInfoRow(list, "Subscription", "Exempt (till account)");
      return;
    }

    // Reflects whichever subscription is actually granting access - a
    // reviewer covered only by their faction's subscription would
    // otherwise see their own (possibly long-expired) personal trial here
    // instead, which is misleading since they have full access. See
    // subscriptions.active_subscription.
    const viaFaction = subscriptionSource === "faction" ? " (faction)" : "";
    const remaining = subscriptionExpiresAt - Math.floor(Date.now() / 1000);
    const expired = remaining <= 0;
    const days = Math.max(1, Math.ceil(remaining / (24 * 60 * 60)));
    appendInfoRow(
      list,
      "Subscription",
      (expired ? "Expired" : `${days} day${days === 1 ? "" : "s"} left`) + viaFaction
    );

    const expiryDate = new Date(subscriptionExpiresAt * 1000).toLocaleDateString(undefined, {
      day: "numeric",
      month: "short",
      year: "numeric",
    });
    appendInfoRow(list, "Subscription Expiry", expiryDate);
  }

  function renderReviewerStatsRows(list, status) {
    appendInfoRow(list, "Total Revives", String(status.total_revives));
    appendInfoRow(list, "Pending", String(status.pending_revives));
    appendInfoRow(
      list,
      "Paid",
      status.pay_rate === null ? String(status.paid_revives) : `${status.paid_revives} (${status.pay_rate}%)`
    );
    appendInfoRow(list, "Contract", String(status.contract_revives));
  }

  // Byte-for-byte the same link Torn's own "Send Message" button on a
  // profile page uses - confirmed live via devtools (see
  // diagnostic_images/): <a href="/messages.php#/p=compose&XID=2749499">,
  // no query string at all. Our own revive_id/target_username payload
  // travels out-of-band via GM storage instead (see
  // storePendingPaymentRequest/consumePendingPaymentRequest) specifically
  // so this link is indistinguishable from a native one - if Torn's
  // compose page does anything special when it sees XID that a query
  // string might interfere with, this avoids that risk entirely.
  function buildPaymentRequestLink(revive) {
    return `https://www.torn.com/messages.php#/p=compose&XID=${revive.target_id}`;
  }

  // Single global slot rather than a list - only ever one pending
  // navigation at a time (the reviewer just clicked a link), consumed
  // (read once, then cleared) by the compose page that follows it. Stored
  // as a JSON string rather than a raw object for portability across
  // GM_setValue shims that may only round-trip strings faithfully (see
  // TornPDA compatibility notes above).
  const PENDING_PAYMENT_REQUEST_KEY = "pending_payment_request";

  // kind picks which subject/message injectComposePagePrefill fills in -
  // "reminder" (default, the existing 12h-unpaid nudge) or "thank_you" (the
  // immediate post-revive prompt, see showThankYouPromptToast). Both land
  // on the exact same compose page, but only "reminder" marks
  // payment_request_sent_at once sent (see injectComposePagePrefill) -
  // sending an immediate thank-you note deliberately doesn't suppress the
  // later reminder, since the target may still not have paid by then.
  function storePendingPaymentRequest(revive, kind) {
    GM_setValue(
      PENDING_PAYMENT_REQUEST_KEY,
      JSON.stringify({ reviveId: revive.revive_id, targetUsername: revive.target_username || null, kind: kind || "reminder" })
    );
  }

  function consumePendingPaymentRequest() {
    const raw = GM_getValue(PENDING_PAYMENT_REQUEST_KEY, null);
    GM_setValue(PENDING_PAYMENT_REQUEST_KEY, null);
    if (!raw) return null;
    try {
      return JSON.parse(raw);
    } catch (err) {
      return null;
    }
  }

  // Capped rather than showing every unpaid revive ever - a heavy reviewer
  // can accumulate dozens of these, and the homepage box isn't the place
  // for an unbounded list. The backend already returns oldest-unpaid-first
  // (see get_unpaid_revives.py), so this keeps the ones most worth chasing.
  const MAX_UNPAID_REVIVES_SHOWN = 10;

  // Caller is expected to only create the box (and call this) when
  // unpaidRevives is non-empty - the "Pending Payment Reminders" title
  // now lives on that box's own title bar, not a row inside it, so an
  // empty list has nothing to title in the first place.
  function renderUnpaidRevivesRows(list, unpaidRevives) {
    const shown = unpaidRevives.slice(0, MAX_UNPAID_REVIVES_SHOWN);
    for (const revive of shown) {
      const label = revive.target_username || `Player #${revive.target_id}`;
      if (revive.payment_request_sent) {
        appendInfoRow(list, label, `Requested (${revive.hours_unpaid}h unpaid)`);
        continue;
      }
      const link = document.createElement("a");
      link.href = buildPaymentRequestLink(revive);
      link.textContent = `Send payment request (${revive.hours_unpaid}h unpaid)`;
      // Runs synchronously before the browser follows the link, same tick
      // as the click - the compose page that loads next reads this back
      // via consumePendingPaymentRequest.
      link.addEventListener("click", () => storePendingPaymentRequest(revive, "reminder"));
      appendInfoRow(list, label, link);
    }
    if (unpaidRevives.length > MAX_UNPAID_REVIVES_SHOWN) {
      appendInfoRow(list, "", `+${unpaidRevives.length - MAX_UNPAID_REVIVES_SHOWN} more unpaid`);
    }
  }

  function renderUpdateRow(list, latestVersion) {
    const link = document.createElement("a");
    link.href = GREASYFORK_SCRIPT_URL;
    link.target = "_blank";
    link.rel = "noopener noreferrer";
    link.textContent = `v${latestVersion} on Greasy Fork`;
    appendInfoRow(list, "Update Available", link);
  }

  // Shown in the free stats box when a lapsed/free-tier reviewer's
  // payment-reminders lookup comes back 402 (nudges are a paid feature).
  // A single, targeted upsell row right where the reminders would otherwise
  // appear - deliberately not the full-width red banner (showNotice), which
  // is reserved for reaching a hard paywall like another player's reputation.
  // Personal stats above stay free and un-nagged.
  function renderNudgeUpsellRow(list) {
    const link = document.createElement("a");
    link.href = `https://www.torn.com/profiles.php?XID=${TILL_PROFILE_ID}`;
    link.target = "_blank";
    link.rel = "noopener";
    link.textContent = "Subscribe to chase unpaid revives";
    appendInfoRow(list, "Payment Reminders", link);
  }

  // Formats a payment response time (avg_response_seconds from the backend
  // - a mean over every paid revive for a target, see models.Reputation)
  // as a short human string - "45m", "2h", "3d" - rather than raw seconds.
  function formatDuration(seconds) {
    if (seconds < 60) return `${Math.round(seconds)}s`;
    const minutes = seconds / 60;
    if (minutes < 60) return `${Math.round(minutes)}m`;
    const hours = minutes / 60;
    if (hours < 24) return `${Math.round(hours * 10) / 10}h`;
    const days = hours / 24;
    return `${Math.round(days * 10) / 10}d`;
  }

  function renderNameBadge(nameElement, reputation) {
    const ratio =
      reputation.total_count > 0
        ? Math.round((100 * reputation.paid_count) / reputation.total_count)
        : null;

    const badge = document.createElement("div");
    badge.style.cssText =
      "margin-left:8px;padding:6px 10px;border-radius:4px;font-size:10px;" +
      "font-weight:bold;display:inline-block;vertical-align:middle;" +
      (ratio === null
        ? "background:#666;color:#fff;"
        : ratio >= 70
        ? "background:#2e7d32;color:#fff;"
        : ratio >= 30
        ? "background:#f9a825;color:#000;"
        : "background:#c62828;color:#fff;");
    // avg_response_seconds is subscriber-only data (the endpoints it comes
    // from are gated behind an active subscription already - see
    // apiRequest/authorize_reviewer), so no separate check is needed here;
    // it's simply absent/null for a target with no paid revives yet.
    const speedSuffix =
      ratio !== null && reputation.avg_response_seconds != null
        ? `, avg ${formatDuration(reputation.avg_response_seconds)}`
        : "";
    badge.textContent =
      ratio === null
        ? "Revive Tracker: no data yet"
        : `Revive Tracker: pays ${ratio}% of the time (${reputation.paid_count}/${reputation.total_count})${speedSuffix}`;
    // Appended as a child of the name heading, not a sibling in
    // .content-title - confirmed live (screenshot) that container is a
    // float layout (icon list/name/links-top-wrap all float:left), and the
    // hospital-row badge incident showed inserting a new sibling into an
    // existing row can shift everything after it. A child only grows the
    // h4's own box; display:inline-block keeps this div on the same line
    // as the name text instead of dropping to a new line.
    nameElement.appendChild(badge);
  }

  function injectReputationBadge() {
    const targetId = getProfileIdFromUrl();
    if (!targetId) return;

    // Fired immediately at document-start - only needs the URL, not the DOM
    // - so the network round-trip runs in parallel with page parsing rather
    // than waiting for the name heading to exist first. The .catch here is
    // just to prevent an unhandled-rejection warning if this settles before
    // waitForElement's callback below awaits it; the real error handling
    // happens there.
    const reputationPromise = apiRequest("GET", `/reputation/${targetId}`);
    reputationPromise.catch(() => {});

    // #skip-to-content is the profile name heading - it's the accessibility
    // skip-navigation landmark target (confirmed live via devtools), which
    // is a more stable anchor than a page-specific class. Falls back to the
    // page-title heading inside .content-title in case that ID ever moves.
    waitForElement("h4#skip-to-content, .content-title h4", async (nameElement) => {
      console.info("[Revive Tracker] profile name heading found, awaiting reputation lookup");
      try {
        const reputation = await reputationPromise;
        renderNameBadge(nameElement, reputation);
        console.info("[Revive Tracker] profile reputation badge rendered", reputation);
      } catch (err) {
        console.error("[Revive Tracker] profile reputation lookup failed", err);
        handleReputationFetchError(err);
      }
    });
  }

  function injectHomePageInfo() {
    // Fired immediately for the same reason as injectReputationBadge above -
    // needs no DOM, so it runs in parallel with page parsing.
    const statusPromise = apiRequest("GET", "/reviewer/status");
    statusPromise.catch(() => {});
    const unpaidRevivesPromise = apiRequest("GET", "/reviewer/unpaid-revives");
    unpaidRevivesPromise.catch(() => {});

    // Shown as rows in their own "Revive Tracker" box (placed right after
    // General Information, mirroring Torn's own box shape) rather than
    // badges - badges are reserved for the hospital list and other
    // players' profile pages.
    waitForCondition(findGeneralInformationList, async (generalInfoList) => {
      const reviveTrackerBox = createReviveTrackerBox(generalInfoList);
      if (!reviveTrackerBox) return;
      const { box: reviveTrackerBoxElement, list } = reviveTrackerBox;
      console.info("[Revive Tracker] Revive Tracker box created, awaiting /reviewer/status");
      try {
        const status = await statusPromise;
        console.info("[Revive Tracker] /reviewer/status resolved", status);
        renderSubscriptionRow(list, status.subscription_expires_at, status.subscription_source);
        renderReviewerStatsRows(list, status);
      } catch (err) {
        console.error("[Revive Tracker] /reviewer/status failed", err);
        handleReputationFetchError(err);
      }

      // Payment nudges stay a paid feature (unlike the personal stats above,
      // which are now free - see get_reviewer_status). Its own box, stacked
      // below Revive Tracker, only created once there's something to nudge.
      // A 402 here means a lapsed/free-tier reviewer - rather than nag on the
      // free stats, surface one targeted upsell row where the reminders would
      // appear (see renderNudgeUpsellRow).
      try {
        const { unpaid_revives: unpaidRevives } = await unpaidRevivesPromise;
        if (unpaidRevives.length > 0) {
          const unpaidBox = createUnpaidRevivesBox(reviveTrackerBoxElement);
          if (unpaidBox) renderUnpaidRevivesRows(unpaidBox.list, unpaidRevives);
        }
      } catch (err) {
        if (err.status === 402) {
          renderNudgeUpsellRow(list);
        } else {
          console.error("[Revive Tracker] /reviewer/unpaid-revives failed", err);
        }
      }

      // Independent of reviewer status above - shown either way.
      const latestVersion = await updateCheckPromise;
      if (latestVersion) {
        renderUpdateRow(list, latestVersion);
      }
    });
  }

  const BADGED_NAME_SPANS = new WeakSet();

  function renderInlineBadge(nameSpan, reputation) {
    const ratio =
      reputation.total_count > 0
        ? Math.round((100 * reputation.paid_count) / reputation.total_count)
        : null;

    // The hospital row's columns are CSS-positioned, not laid out by DOM
    // order. Anchoring to the .reason cell overshot because that cell is a
    // fixed 302px box (wider than its visible text), so left:100% pushed the
    // pill over the revive button. Instead, anchor to the revive button
    // itself: measure its position and place the pill a fixed gap to its LEFT,
    // so it lands in the gap between the reason text and the button without
    // ever overlapping it, regardless of how wide the reason box is.
    const row = nameSpan.closest("li");
    if (!row) return;
    if (row.querySelector(".rt-pay-pill")) return; // don't double-insert
    const reviveBtn = row.querySelector("a.revive");
    if (!reviveBtn) return; // only revivable rows are badged; should exist

    const badge = document.createElement("span");
    badge.className = "rt-pay-pill";
    badge.style.cssText =
      "position:absolute;" +
      "padding:1px 6px;border-radius:3px;font-size:10px;font-weight:bold;" +
      "white-space:nowrap;text-align:center;z-index:2;" +
      (ratio === null
        ? "background:#666;color:#fff;"
        : ratio >= 70
        ? "background:#2e7d32;color:#fff;"
        : ratio >= 30
        ? "background:#f9a825;color:#000;"
        : "background:#c62828;color:#fff;");
    badge.textContent = ratio === null ? "no data" : `${ratio}% pay`;
    // Row space is too tight for a visible speed suffix here (unlike the
    // profile badge) - surfaced as a hover tooltip instead.
    if (ratio !== null && reputation.avg_response_seconds != null) {
      badge.title = `Avg response time: ${formatDuration(reputation.avg_response_seconds)}`;
    }

    // Make the row the positioning context only if it isn't already one, so we
    // never override a position Torn set itself, then place the pill by
    // measured geometry (works regardless of how Torn lays the columns out).
    // Two layouts:
    //   Desktop  - short single row: pill sits a fixed gap to the LEFT of the
    //              revive button, vertically centred on it.
    //   Mobile   - tall, stacked row (TornPDA) with the revive icon at the top
    //   /TornPDA   and a blank cell beneath it: pill is centred in that blank
    //              cell - both horizontally on the button's column centre and
    //              vertically in the space below the button.
    // Detected by whether there's meaningful blank space below the button.
    if (getComputedStyle(row).position === "static") {
      row.style.position = "relative";
    }
    row.appendChild(badge);
    const rowRect = row.getBoundingClientRect();
    const btnRect = reviveBtn.getBoundingClientRect();
    const stacked = rowRect.bottom - btnRect.bottom > btnRect.height * 0.6;
    if (stacked) {
      badge.style.left = (btnRect.left + btnRect.right) / 2 - rowRect.left + "px";
      badge.style.top = (btnRect.bottom + rowRect.bottom) / 2 - rowRect.top + "px";
      // translateX(-100%) rather than -50% nudges the pill left by half its own
      // width from the button's column centre (self-adjusting to pill width).
      badge.style.transform = "translate(-100%, -50%)";
    } else {
      const GAP = 10; // px between the pill's right edge and the button's left
      badge.style.right = rowRect.right - btnRect.left + GAP + "px";
      badge.style.top = (btnRect.top + btnRect.bottom) / 2 - rowRect.top + "px";
      badge.style.transform = "translateY(-50%)";
    }
  }

  function hasRevivesOn(nameSpan) {
    const row = nameSpan.closest("li");
    const reviveLink = row && row.querySelector("a.revive");
    // "reviveNotAvailable" on the row's revive button is the only DOM signal
    // for "this player can't be revived by you right now" - assumed to
    // reflect their revive-privacy setting being off (rather than e.g. a
    // level restriction or cooldown, which weren't distinguished live).
    return !!reviveLink && !reviveLink.classList.contains("reviveNotAvailable");
  }

  // Rows queued for the next batch fetch, keyed by target id (a hospital
  // list page can have >1 row for the same player across pagination
  // reloads, so this dedupes the outgoing request too).
  let pendingRowBadges = new Map();
  let pendingBatchTimer = null;
  // Debounce window before firing a batch request - long enough to catch
  // every row from a single page load/pagination reload (which all arrive
  // as one burst of DOM mutations), short enough that it's imperceptible.
  const BATCH_DEBOUNCE_MS = 50;

  function queueRowBadge(nameSpan) {
    if (BADGED_NAME_SPANS.has(nameSpan)) return;
    if (!hasRevivesOn(nameSpan)) return;
    const link = nameSpan.querySelector('a[href*="profiles.php?XID="]');
    if (!link) return;
    const match = link.getAttribute("href").match(PROFILE_ID_PATTERN);
    if (!match) return;
    BADGED_NAME_SPANS.add(nameSpan);

    const targetId = match[1];
    if (!pendingRowBadges.has(targetId)) pendingRowBadges.set(targetId, []);
    pendingRowBadges.get(targetId).push(nameSpan);

    if (pendingBatchTimer) clearTimeout(pendingBatchTimer);
    pendingBatchTimer = setTimeout(flushRowBadges, BATCH_DEBOUNCE_MS);
  }

  async function flushRowBadges() {
    pendingBatchTimer = null;
    if (pendingRowBadges.size === 0) return;
    const batch = pendingRowBadges;
    pendingRowBadges = new Map();

    // One request covers every row queued during the debounce window,
    // instead of one request (and one key/subscription re-check) per row.
    console.info("[Revive Tracker] hospital list batch request", { count: batch.size });
    try {
      const response = await apiRequest("POST", "/reputation/batch", {
        target_ids: Array.from(batch.keys()).map((id) => parseInt(id, 10)),
      });
      let rendered = 0;
      for (const [targetId, nameSpans] of batch) {
        const reputation = response.items[targetId];
        if (!reputation) continue;
        // Kept around (keyed by target id) so sortHospitalList can rank every
        // row, including ones fetched in earlier batches after pagination.
        hospitalReputation.set(targetId, reputation);
        for (const nameSpan of nameSpans) {
          renderInlineBadge(nameSpan, reputation);
          rendered++;
        }
      }
      console.info("[Revive Tracker] hospital list badges rendered", { rendered });
      sortHospitalList();
    } catch (err) {
      console.error("[Revive Tracker] hospital list batch lookup failed", err);
      handleReputationFetchError(err);
    }
  }

  // Reputation for hospital-list targets, accumulated across batch fetches
  // (keyed by target-id string, matching the /reputation/batch response keys)
  // so a re-sort after pagination can rank against everything seen so far.
  const hospitalReputation = new Map();

  function hospitalRowTargetId(row) {
    const link = row.querySelector('a[href*="profiles.php?XID="]');
    const match = link && link.getAttribute("href").match(PROFILE_ID_PATTERN);
    return match ? match[1] : null;
  }

  // Ranking score for a hospital row, higher = revive sooner:
  //   - pill shows above 0%   -> that rate (0..1), highest first  (above no-data)
  //   - revivable, no pay data -> 0                               (middle)
  //   - pill shows 0%          -> -0.5                            (below no-data)
  //   - can't be revived       -> -1                             (very bottom)
  // The 0% test uses the same rounding as the pill (renderInlineBadge), so the
  // sort order matches the pills exactly: a known non-payer ranks below an
  // unknown, but anyone who has ever paid ranks above it. A stable sort
  // preserves Torn's original order within each tier.
  function hospitalRowScore(row) {
    const nameSpan = row.querySelector(".user-wrap.user-name");
    if (!nameSpan || !hasRevivesOn(nameSpan)) return -1;
    const id = hospitalRowTargetId(row);
    const rep = id && hospitalReputation.get(id);
    if (!rep || !rep.total_count) return 0;
    if (Math.round((100 * rep.paid_count) / rep.total_count) === 0) return -0.5;
    return rep.paid_count / rep.total_count;
  }

  let hospitalSorting = false;

  // Reorder each hospital list (its row <li>s) in place by hospitalRowScore,
  // descending. Rows are moved among themselves only - anything before or
  // after the block of rows stays put (see the anchor below), so a header row
  // or trailing element isn't disturbed. Guarded against the re-entrancy the
  // DOM moves would otherwise cause via injectHospitalBadges' observer (those
  // moved rows are already in BADGED_NAME_SPANS, so they don't re-queue, but
  // the flag makes it explicit and cheap).
  function sortHospitalList() {
    if (!isHospitalSortEnabled() || hospitalSorting) return;

    const rowsByParent = new Map();
    document.querySelectorAll(".user-wrap.user-name").forEach((span) => {
      const row = span.closest("li");
      if (!row || !row.parentNode) return;
      if (!rowsByParent.has(row.parentNode)) rowsByParent.set(row.parentNode, []);
      rowsByParent.get(row.parentNode).push(row);
    });

    hospitalSorting = true;
    try {
      for (const [parent, rows] of rowsByParent) {
        if (rows.length < 2) continue;
        const sorted = rows.slice().sort((a, b) => hospitalRowScore(b) - hospitalRowScore(a));
        // No-op if already ordered - avoids pointless DOM churn every flush.
        if (sorted.every((row, i) => row === rows[i])) continue;
        const anchor = rows[rows.length - 1].nextSibling;
        sorted.forEach((row) => parent.insertBefore(row, anchor));
      }
    } finally {
      hospitalSorting = false;
    }
  }

  function injectHospitalBadges() {
    injectSortButtonIfPresent();
    document.querySelectorAll(".user-wrap.user-name").forEach(queueRowBadge);
    applyRevivableFilter();

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;
          if (node.matches(".user-wrap.user-name")) {
            queueRowBadge(node);
          }
          node.querySelectorAll(".user-wrap.user-name").forEach(queueRowBadge);
        }
      }
      // Rows can arrive after the initial pass (async load / pagination) -
      // inject the filter button once they're present, and re-apply the
      // filter to the new rows.
      injectSortButtonIfPresent();
      applyRevivableFilter();
    });
    observer.observe(document.body, { childList: true, subtree: true });
  }

  function isMessageComposePage() {
    // A pending payload in GM storage (set by storePendingPaymentRequest
    // just before navigating here) is what distinguishes "compose opened
    // from our unpaid-revives list" from any other visit to messages.php -
    // without it, this page-flag stays off and none of the
    // prefill/send-tracking logic below runs. Just a peek (not consumed)
    // here - the real read-and-clear happens once in
    // injectComposePagePrefill via consumePendingPaymentRequest.
    return /messages\.php/.test(window.location.href) && !!GM_getValue(PENDING_PAYMENT_REQUEST_KEY, null);
  }

  // Firm but not demanding: states the norm as a plain fact (this is what
  // reviving costs, this is what tracking it does) rather than a request
  // ("please") or a demand ("must") - and doubles as low-key advertising
  // by linking the tool itself, since the target may not have it yet.
  const MESSAGE_SUBJECT = "Revive payment";
  // \n\n between each point so setComposeMessageBody renders them as
  // separate paragraphs (blank line between) rather than one dense block.
  const PAYMENT_REQUEST_MESSAGE =
    `You were revived earlier. This revive was tracked by Revive Payment Tracker (${GREASYFORK_SCRIPT_URL}) - a tool that shows revivers whether the people they revive actually pay them afterward.\n\n` +
    `The standard thank-you for a revive is 1 Xanax and I would very much appreciate a donation towards the cost of energy spent reviving you.\n\n` +
    `Paying also keeps your own record looking good for the next reviver considering reviving you.`;

  // Sent right after a revive, not 12h later - warmer/thankful rather than
  // firm, since nobody's overdue yet at this point. Same payment norm
  // stated as the same plain fact, just framed as a thank-you-in-advance
  // instead of a follow-up. See showThankYouPromptToast.
  const THANK_YOU_SUBJECT = "Thanks for the revive!";
  const THANK_YOU_MESSAGE =
    `Hey - I just revived you, hope that helped.\n\n` +
    `No pressure at all, but the standard courtesy for a revive is 1 Xanax, sent whenever you're able - genuinely appreciated if you do. Tracked with Revive Payment Tracker (${GREASYFORK_SCRIPT_URL}), which is also how I knew to send this.\n\n` +
    `Thanks in advance, and take care out there!`;

  function setFieldValue(field, value) {
    field.value = value;
    // The recipient field is a jQuery UI autocomplete
    // (ui-autocomplete-input, confirmed live via devtools) that does a
    // live AJAX search as you type - dispatching both input and keyup
    // covers widgets that listen to either. Confirmed live that typing
    // alone (even with these events) does NOT register as a real
    // selection - the reviewer still had to pick the suggestion from the
    // dropdown by hand - so this is only step one; see
    // selectAutocompleteSuggestion for the part that actually picks it.
    field.dispatchEvent(new Event("input", { bubbles: true }));
    field.dispatchEvent(new Event("keyup", { bubbles: true }));
    field.dispatchEvent(new Event("change", { bubbles: true }));
  }

  // Looks for the rendered suggestion list after typing into the recipient
  // field - #ac-search-0-cont is the widget's own results container
  // (confirmed live via devtools), with a couple of generic jQuery UI
  // fallbacks in case Torn ever changes that id. Returns the list of
  // candidate items (li/a - exact tag not confirmed, since the dropdown's
  // contents weren't expanded in the devtools snapshot this was checked
  // against) or null if nothing's rendered yet.
  // Confirmed live (screenshot): the results dropdown is scoped by a row
  // of tabs - "Friends", "Faction", "Company", "All" - with "Friends"
  // active by default. That default is exactly why selection was failing:
  // the till account and a typical revive target aren't friends,
  // faction-mates, or company-mates, so the default tab shows "No person
  // could be found" every time. "All" is the one that actually searches
  // every player.
  const RECIPIENT_TAB_LABELS = ["friends", "faction", "company", "all"];
  const NO_RESULTS_TEXT = "no person could be found";

  function findRecipientAllTab() {
    const candidates = document.querySelectorAll('a, li, button, [role="tab"]');
    return Array.from(candidates).find((el) => (el.textContent || "").trim().toLowerCase() === "all") || null;
  }

  function clickRecipientAllTab(allTab) {
    ["mousedown", "click"].forEach((type) => {
      allTab.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
    });
  }

  // Excludes the tab labels themselves and the "no results" placeholder
  // text - both matched the old, broader selector and could otherwise get
  // clicked as if they were a real person (most likely explanation for
  // "selection isn't working": with a target that doesn't match any tab
  // label, the old code's items[0] fallback would have landed on the
  // first tab rather than an actual result).
  function findAutocompleteSuggestions() {
    const container = document.querySelector("#ac-search-0-cont, .autocomplete-wrap, .ui-autocomplete");
    if (!container) return null;
    const items = Array.from(container.querySelectorAll("li, a")).filter((el) => {
      const text = (el.textContent || "").trim().toLowerCase();
      return text && !RECIPIENT_TAB_LABELS.includes(text) && text !== NO_RESULTS_TEXT;
    });
    return items.length > 0 ? items : null;
  }

  // Picks the suggestion whose text matches the target username (falling
  // back to the first result if none match exactly - searching by their
  // exact username should make the first hit correct regardless) and
  // dispatches mousedown before click: stock jQuery UI Autocomplete binds
  // selection to mousedown specifically, so the input's blur doesn't
  // cancel the selection before a click would register. Firing both
  // covers Torn's implementation whether or not it's customized from the
  // jQuery UI default.
  function selectAutocompleteSuggestion(items, targetUsername) {
    const lowerTarget = (targetUsername || "").toLowerCase();
    const match =
      Array.from(items).find((el) => (el.textContent || "").toLowerCase().includes(lowerTarget)) || items[0];
    ["mousedown", "click"].forEach((type) => {
      match.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
    });
  }

  // Message body is a rich-text editor (TinyMCE, confirmed live via
  // devtools - mce-content-body/mce_0 are TinyMCE's own conventions), not
  // a plain <textarea>: the visible, editable surface is a contenteditable
  // div. There's also a genuine <textarea> in the DOM (class sourceArea),
  // but it's hidden (TinyMCE's own "source view" mirror) and isn't what
  // gets read on submit - writing to it would silently do nothing.
  function setComposeMessageBody(value) {
    // #mce_0 is TinyMCE's actual id on the one live compose form checked
    // (see diagnostic_images/) - the class-based fallback covers it
    // shifting if Torn ever mounts more than one editor on this page.
    const editor = document.querySelector("#mce_0, .editor-content[contenteditable='true'], [contenteditable='true']");
    if (!editor) return false;
    // Plain textContent collapses \n under normal CSS whitespace rules -
    // a contenteditable surface needs real <br> nodes to show line breaks.
    // Built via DOM calls (not innerHTML) so nothing in value is ever
    // parsed as markup.
    editor.textContent = "";
    value.split("\n").forEach((line, i) => {
      if (i > 0) editor.appendChild(document.createElement("br"));
      editor.appendChild(document.createTextNode(line));
    });
    // contenteditable elements are driven by input events, not
    // value/change - TinyMCE listens for this to sync its hidden mirror
    // field before the form submits.
    editor.dispatchEvent(new Event("input", { bubbles: true }));
    return true;
  }

  // Confirmed live via devtools (see diagnostic_images/): the recipient
  // field is <input name="sendto" id="ac-search-0" class="...
  // ui-autocomplete-input" placeholder="Name">, and the message body is a
  // TinyMCE contenteditable div (see setComposeMessageBody). Also
  // confirmed live (real test run): typing into #sendto alone isn't
  // enough - the reviewer still had to pick the suggestion from the
  // dropdown by hand for it to register as a real recipient, so this now
  // also simulates that pick (see findAutocompleteSuggestions/
  // selectAutocompleteSuggestion) once the AJAX search has rendered
  // results. What's still genuinely uncertain:
  //   - The exact suggestion markup (li vs a, structure) wasn't visible in
  //     the devtools snapshot this was built against (the dropdown wasn't
  //     expanded), so the selector there is broader/more speculative than
  //     the rest of this function.
  //   - Detecting an actual send: rather than guessing one exact button
  //     selector, this listens broadly for a form submit or a click on
  //     anything whose visible text looks like a send action (the real
  //     button reads "SEND"), so it's more likely to fire even if the
  //     exact markup differs - at the cost of firing on the intent to
  //     send, not a confirmed delivery (see mark_payment_request_sent.py's
  //     docstring for why that's an accepted tradeoff here).
  function injectComposePagePrefill() {
    const pending = consumePendingPaymentRequest();
    if (!pending) return;
    const { reviveId, targetUsername, kind } = pending;
    const isThankYou = kind === "thank_you";
    const subject = isThankYou ? THANK_YOU_SUBJECT : MESSAGE_SUBJECT;
    const message = isThankYou ? THANK_YOU_MESSAGE : PAYMENT_REQUEST_MESSAGE;

    waitForElement("#mce_0, .editor-content[contenteditable='true'], [contenteditable='true']", () => {
      setComposeMessageBody(message);
      console.info("[Revive Tracker] Prefilled payment-request message body");
    });

    // UNVERIFIED LIVE - unlike the recipient/message-body selectors, the
    // subject field's own devtools panel was never expanded in the one
    // snapshot this was built against (see diagnostic_images/), so this is
    // a guess: a plain text input, sibling to the recipient's own
    // form-title-input-text wrapper, most likely named/placeholder'd
    // "subject" by analogy with sendto/placeholder="Name" on that field.
    waitForElement('input[name="subject" i], input[placeholder="Subject" i], input[name="title" i]', (subjectField) => {
      setFieldValue(subjectField, subject);
      console.info("[Revive Tracker] Prefilled payment-request subject");
    });

    if (targetUsername) {
      waitForElement('input[name="sendto"], #ac-search-0', (recipientInput) => {
        // Now that the link matches Torn's own native "Send Message" href
        // exactly (see buildPaymentRequestLink), Torn's own compose page
        // may already resolve the recipient from XID by itself before this
        // even runs - if so, defer to it entirely rather than clobbering a
        // correct native selection with a redundant manual search.
        if ((recipientInput.value || "").trim().toLowerCase() === targetUsername.toLowerCase()) {
          console.info("[Revive Tracker] Recipient already preselected natively - skipping manual search");
          return;
        }
        setFieldValue(recipientInput, targetUsername);
        // Wait for the tab row to render, then switch off the default
        // "Friends" scope to "All" before looking for a real result - see
        // findRecipientAllTab's comment for why the default tab almost
        // never finds a typical revive target.
        waitForCondition(findRecipientAllTab, (allTab) => {
          clickRecipientAllTab(allTab);
          console.info("[Revive Tracker] Switched recipient search to the All tab");
          waitForCondition(findAutocompleteSuggestions, (items) => {
            selectAutocompleteSuggestion(items, targetUsername);
            console.info("[Revive Tracker] Auto-selected recipient from autocomplete dropdown");
          });
        });
      });
    }

    // Deliberately NOT wired up for the thank-you flow: sending an
    // immediate thank-you note shouldn't set payment_request_sent_at, so a
    // revive that gets one can still surface in Pending Payment Reminders
    // later if it's still unpaid after 12h - the two are independent nudges
    // now, not mutually exclusive.
    if (isThankYou) return;

    let marked = false;
    const markSentOnce = () => {
      if (marked) return;
      marked = true;
      apiRequest("POST", `/revives/${encodeURIComponent(reviveId)}/payment-request`)
        .then(() => console.info("[Revive Tracker] Marked payment request as sent", reviveId))
        .catch((err) => console.error("[Revive Tracker] Failed to mark payment request as sent", err));
    };
    document.addEventListener("submit", markSentOnce, true);
    document.addEventListener(
      "click",
      (event) => {
        const el = event.target.closest && event.target.closest('button, [type="submit"], a');
        if (!el) return;
        const text = (el.textContent || "").trim().toLowerCase();
        if (text === "send" || text.includes("send message")) markSentOnce();
      },
      true
    );
  }

  // Adds a "Revive Tracker" tab to Torn's preferences page (a jQuery UI Tabs
  // widget: #prefs-tab-menu holds ul.ui-tabs-nav of <li> tabs, each
  // aria-controls a sibling panel <div>). We add our own <li> + panel and
  // drive show/hide ourselves rather than depending on the page's jQuery, so
  // it works regardless of userscript-manager sandboxing. Home for the
  // hospital-list toggles (sort / hide) that also live behind the on-page
  // buttons - both read/write the same GM values, so they stay in sync.
  let preferencesTabShown = false;

  function buildSettingToggleRow(labelText, descText, isOn, onToggle) {
    const row = document.createElement("div");
    row.style.cssText = "display:flex;align-items:center;gap:12px;padding:10px 0;border-bottom:1px solid rgba(128,128,128,0.2);";

    const cb = document.createElement("input");
    cb.type = "checkbox";
    cb.checked = isOn();
    cb.style.cssText = "width:16px;height:16px;cursor:pointer;flex:0 0 auto;";
    cb.addEventListener("change", () => {
      // onToggle flips the stored value; sync the checkbox to the real state
      // afterwards in case the toggle was a no-op or clamped.
      onToggle();
      cb.checked = isOn();
    });

    const text = document.createElement("div");
    const label = document.createElement("div");
    label.textContent = labelText;
    label.style.cssText = "font-weight:bold;";
    const desc = document.createElement("div");
    desc.textContent = descText;
    desc.style.cssText = "font-size:11px;opacity:0.75;";
    text.appendChild(label);
    text.appendChild(desc);

    row.appendChild(cb);
    row.appendChild(text);
    return row;
  }

  function injectPreferencesTab() {
    if (preferencesTabShown) return;
    const container = document.querySelector("#prefs-tab-menu");
    const nav = container && container.querySelector("ul.ui-tabs-nav");
    if (!container || !nav) return;
    preferencesTabShown = true;

    // Our panel, styled to sit where the native panels do.
    const panel = document.createElement("div");
    panel.id = "rt-settings";
    panel.setAttribute("role", "tabpanel");
    panel.style.cssText = "display:none;padding:15px;";
    const heading = document.createElement("h4");
    heading.textContent = "Revive Tracker - Hospital list";
    heading.style.cssText = "margin:0 0 4px;";
    const sub = document.createElement("div");
    sub.textContent = "Applies when the hospital-list filter button is on.";
    sub.style.cssText = "font-size:11px;opacity:0.75;margin-bottom:10px;";
    panel.appendChild(heading);
    panel.appendChild(sub);
    panel.appendChild(
      buildSettingToggleRow(
        "Hide non-revivable people",
        "When filtering, also hide rows for players you can't currently revive.",
        isHideUnrevivableEnabled,
        toggleHideUnrevivable
      )
    );
    container.appendChild(panel);

    // Our tab, mirroring a native tab li's classes/markup.
    const li = document.createElement("li");
    li.id = "rt-settings-tab";
    li.className = "c-pointer ui-state-default ui-corner-top";
    li.setAttribute("role", "tab");
    li.setAttribute("aria-controls", "rt-settings");
    const anchor = document.createElement("a");
    anchor.className = "t-gray-6 bold h ui-tabs-anchor";
    anchor.href = "#rt-settings";
    anchor.textContent = "Revive Tracker";
    li.appendChild(anchor);
    nav.appendChild(li);

    // Take FULL control of tab switching rather than coexisting with jQuery
    // UI. The previous attempt let jQuery UI manage native tabs while we
    // toggled ours, but activating our tab bypassed jQuery UI's internal
    // "active index", so afterwards it no-op'd on native tabs and their panels
    // stopped rendering. Instead, a single capture-phase handler intercepts
    // every tab click, stops it reaching jQuery UI (no dual management, no
    // desync), and drives the whole switch itself.
    const panelForLi = (targetLi) =>
      targetLi === li ? panel : document.getElementById(targetLi.getAttribute("aria-controls") || "");

    const allPanels = () => {
      const ps = Array.from(nav.querySelectorAll("li[aria-controls]"))
        .map((l) => document.getElementById(l.getAttribute("aria-controls")))
        .filter(Boolean);
      ps.push(panel);
      return ps;
    };

    function selectTab(targetLi) {
      const targetPanel = panelForLi(targetLi);
      nav.querySelectorAll("li").forEach((l) => {
        const active = l === targetLi;
        l.classList.toggle("ui-tabs-active", active);
        l.classList.toggle("ui-state-active", active);
        l.setAttribute("aria-selected", active ? "true" : "false");
      });
      allPanels().forEach((p) => {
        if (p === targetPanel) {
          // Beat jQuery UI's class-based hide (older builds use ui-tabs-hide)
          // as well as any inline display it set.
          p.classList.remove("ui-tabs-hide");
          p.style.display = "block";
          p.removeAttribute("aria-hidden");
        } else {
          p.style.display = "none";
          p.setAttribute("aria-hidden", "true");
        }
      });
    }

    // Capture-phase handler so our switch logic runs before jQuery UI's/Torn's
    // (bubble-phase) handlers. We drive the display+active state ourselves so
    // the right panel shows even when jQuery UI no-ops on a tab it still thinks
    // is active (its model was bypassed while our tab was showing).
    //
    // Crucially we only BLOCK the native handlers for OUR OWN tab (whose panel
    // is static and self-managed). For native tabs we let their handlers run:
    // some Torn panels (e.g. Navigation settings) lazy-load their content via a
    // Torn click handler, and blocking it left them blank.
    nav.addEventListener(
      "click",
      (event) => {
        const clickedLi = event.target.closest("li");
        if (!clickedLi || (clickedLi !== li && !clickedLi.getAttribute("aria-controls"))) return;
        event.preventDefault();
        if (clickedLi === li) {
          event.stopImmediatePropagation();
        }
        selectTab(clickedLi);
      },
      true
    );

    // Mobile (TornPDA) hides the sidebar nav and switches tabs via a native
    // <select id="categories"> whose <option value> is the panel id. Add our
    // option so the tab is reachable there too. On change we apply the same
    // split as the desktop nav: for our option block Torn's handler (it can't
    // switch to a panel jQuery UI doesn't know) and show ours; for a native
    // option hide ours and let Torn's handler run (incl. any lazy load).
    const select = container.querySelector("select#categories") || container.querySelector("select");
    if (select && !select.querySelector('option[value="rt-settings"]')) {
      const opt = document.createElement("option");
      opt.value = "rt-settings";
      opt.textContent = "Revive Tracker";
      select.appendChild(opt);

      select.addEventListener(
        "change",
        (event) => {
          if (select.value === "rt-settings") {
            event.stopImmediatePropagation();
            selectTab(li);
          } else {
            panel.style.display = "none";
            panel.setAttribute("aria-hidden", "true");
            li.classList.remove("ui-tabs-active", "ui-state-active");
            li.setAttribute("aria-selected", "false");
          }
        },
        true
      );
    }
  }

  function isPreferencesPage() {
    return /preferences\.php/.test(window.location.href);
  }

  function isProfilePage() {
    return /profiles\.php/.test(window.location.href);
  }

  function isHospitalListPage() {
    return /hospitalview/.test(window.location.href);
  }

  function isHospitalOrAttackPage() {
    return /(hospitalview|loader\.php\?sid=attack)/.test(window.location.href);
  }

  function isHomePage() {
    const path = window.location.pathname;
    return path === "/" || path === "/index.php";
  }

  console.info("[Revive Tracker] page flags", {
    hospitalOrAttack: isHospitalOrAttackPage(),
    profile: isProfilePage(),
    hospitalList: isHospitalListPage(),
    home: isHomePage(),
    messageCompose: isMessageComposePage(),
    preferences: isPreferencesPage(),
  });

  if (isPreferencesPage()) {
    // The tabs widget mounts after page load - wait for its nav to exist.
    waitForElement("#prefs-tab-menu ul.ui-tabs-nav", injectPreferencesTab);
  }

  if (isHospitalOrAttackPage()) {
    whenBodyReady(() => {
      watchForRevive();
      watchForReviveButton();
    });
  }
  if (isProfilePage()) {
    injectReputationBadge();
  }
  if (isHospitalListPage()) {
    whenBodyReady(injectHospitalBadges);
  }
  if (isHomePage()) {
    injectHomePageInfo();
  }
  if (isMessageComposePage()) {
    whenBodyReady(injectComposePagePrefill);
  }
})();