Torn Revive Payment Tracker

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

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Torn Revive Payment Tracker
// @namespace    torn-revive-payment-tracker
// @version      0.5.5
// @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.5.5";

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

  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(afterElement, reputation) {
    const ratio =
      reputation.total_count > 0
        ? Math.round((100 * reputation.paid_count) / reputation.total_count)
        : null;

    const badge = document.createElement("span");
    badge.style.cssText =
      "margin-left:6px;padding:1px 6px;border-radius:3px;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;");
    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)}`;
    }
    // Appended *inside* the name span (not as a sibling after it) - Torn's
    // hospital row is a flex row with positional/implicit columns, so an
    // extra top-level sibling shifted every column after it (Time/Level/
    // Reason misaligned, revive button broken for that row - confirmed live
    // via screenshot). A child of the name span doesn't affect row layout.
    afterElement.appendChild(badge);
  }

  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;
        for (const nameSpan of nameSpans) {
          renderInlineBadge(nameSpan, reputation);
          rendered++;
        }
      }
      console.info("[Revive Tracker] hospital list badges rendered", { rendered });
    } catch (err) {
      console.error("[Revive Tracker] hospital list batch lookup failed", err);
      handleReputationFetchError(err);
    }
  }

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

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

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

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