User:Swatjester/Sentinel.js

// Sentinel — focused counter-disruption patrolling assistant for English Wikipedia.
// Documentation: [[WP:SENTINEL]] / [[User:Swatjester/Sentinel]]. Maintainer: [[User:Swatjester]].
/* global mw */
(function () {
  'use strict';

  const mw = window.mw;
  if (!mw || !mw.config) {
    return;
  }

  const APP = 'Sentinel';
  const VERSION = '1.3.1';
  let api; // assigned after mediawiki.api loads (see bootstrap at the bottom)

  // Page globals like OO and jQuery load lazily, so they can't be captured at
  // startup the way `mw` is; resolve them at call time.
  function pageGlobal(name) {
    return window[name] || null;
  }
  const currentPage = mw.config.get('wgRelevantPageName') || mw.config.get('wgPageName') || '';
  const namespace = mw.config.get('wgNamespaceNumber');
  const monthHeading = new Date().toLocaleString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' });
  const TEMPLATE_OPEN = '{{';
  const SUBST = 'su' + 'bst:';
  const SIGNATURE = '~~' + '~~';
  // The project-page link used in every edit summary and in custom-warning
  // attribution. Single source of truth: if the shortcut's capitalization
  // ever changes, change it here only. Note that page titles are
  // case-sensitive after the first character, so [[WP:SENTINEL]] and
  // [[WP:Sentinel]] are different pages — verify the one used here resolves
  // (ideally create both, one as a redirect) before wide distribution.
  const PROJECT_LINK = '[[WP:SENTINEL|Sentinel]]';
  // Rendered-preview parse results, keyed on target title + wikitext.
  const parsePreviewCache = new Map();
  // hasPriorCtAlert promises, keyed on normalized username. Caching the
  // promise (not just the value) also dedupes concurrent hover lookups.
  const ctAlertCache = new Map();

  const DEFAULT_SETTINGS = {
    aiEnabled: false,
    // api.anthropic.com is on Wikipedia's CSP connect allowlist AND supports
    // browser requests (via the dangerous-direct-browser-access header), so
    // it works from an on-wiki script with no extension or relay. OpenRouter
    // does not: the CSP blocks the request before it leaves the page.
    aiEndpoint: 'https://api.anthropic.com/v1/messages',
    aiModel: 'claude-haiku-4-5-20251001',
    aiFormat: 'anthropic',
    // 'off' is cheapest and right for routine triage; higher tiers buy the
    // model thinking room at a real cost/latency multiple.
    aiReasoning: 'off',
    // Web-search verification of recent events and sourcing claims before the
    // model is allowed to allege hoax/fabrication. 'standard' is the default
    // because unverified authenticity findings proved to be mostly knowledge-
    // cutoff false positives. 'off' | 'standard' | 'thorough'.
    aiVerification: 'standard',
    // 'none' = held for the current page view only; 'session' = sessionStorage
    // (survives navigation, dies with the tab); 'local' = localStorage
    // (persists until removed). See the Risks section on [[WP:SENTINEL]].
    apiKeyStorage: 'none',
    aiEditLimit: 8,
    // Safe default for fresh installs: simulate edits until the user has seen
    // a preview cycle and turned this off. Saved settings are not affected.
    dryRun: true,
    requireConfirm: true,
    watchlist: 'nochange',
    customPageCriteria: {}
  };

  // Curated model options per API format, cheapest first; the id is sent to
  // the endpoint verbatim. The settings panel also offers a Custom entry for
  // model strings released after this list was written.
  const AI_MODELS = {
    anthropic: [
      { id: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5 — cheapest, fast (default)' },
      { id: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6 — balanced' },
      { id: 'claude-opus-4-8', label: 'Claude Opus 4.8 — strong, expensive' },
      { id: 'claude-fable-5', label: 'Claude Fable 5 — most capable, priciest' }
    ],
    openai: [
      { id: 'gpt-5-nano', label: 'GPT-5 nano — cheapest' },
      { id: 'gpt-5-mini', label: 'GPT-5 mini — balanced (default)' },
      { id: 'gpt-5.1', label: 'GPT-5.1 — most capable, priciest' }
    ]
  };
  const DEFAULT_AI_MODEL = { anthropic: 'claude-haiku-4-5-20251001', openai: 'gpt-5-mini' };

  // Reasoning tiers: Anthropic gets an extended-thinking token budget;
  // OpenAI-format endpoints get the tier passed through as reasoning_effort.
  const REASONING_BUDGETS = { low: 2048, medium: 8192, high: 16384 };

  const TOPICS = [
    {
      id: 'ct-ap',
      label: 'US politics',
      shortLabel: 'CT: US Pol92',
      kind: 'ct',
      topic: 'ap',
      scope: 'Post-1992 politics of the United States and closely related people',
      restrictions: []
    },
    {
      id: 'ct-imh',
      label: 'Indian Military History',
      shortLabel: 'CT: India MilHist',
      kind: 'ct',
      topic: 'imh',
      scope: 'Indian military history',
      extraText: 'This alert is specifically for the Indian military history contentious topic area.',
      restrictions: ['This is a South Asia subtopic code and is subject to the Indian military history contentious-topic/extended-confirmed framework.']
    },
    {
      id: 'ct-ai',
      label: 'Arab-Israeli',
      shortLabel: 'CT: Arab-Israel',
      kind: 'ct',
      topic: 'a-i',
      scope: 'Arab-Israeli conflict, broadly construed',
      restrictions: ['Extended confirmed restriction', '1RR']
    },
    {
      id: 'ct-irp',
      label: 'Iran politics',
      shortLabel: 'CT: IranPol.',
      kind: 'ct',
      topic: 'irp',
      scope: 'Post-1978 Iranian politics',
      restrictions: []
    },
    {
      id: 'gs-rusukr',
      label: 'Ukraine war',
      shortLabel: 'CT: Ukraine War',
      kind: 'gs',
      topic: 'rusukr',
      scope: 'Russia-Ukraine War',
      restrictions: ['Community-authorized extended confirmed restriction']
    },
  ];

  // Standard Twinkle-style multi-level warnings. `template` is substituted with
  // the chosen level appended (e.g. uw-vandalism2); the page name, if provided,
  // is passed as the first positional parameter.
  const WARNINGS = [
    { id: 'unsourced', label: 'Unsourced', template: 'uw-unsourced', levels: ['1', '2', '3', '4'] },
    { id: 'fv', label: 'Failed verification', template: 'uw-fv', levels: ['1', '2', '3'] },
    { id: 'error', label: 'Factual error', template: 'uw-error', levels: ['1', '2', '3', '4', '4im'] },
    { id: 'tdel', label: 'Maintenance tags', template: 'uw-tdel', levels: ['1', '2', '3', '4'] },
    { id: 'mislead', label: 'Misleading summaries', template: 'uw-mislead', levels: ['1', '2', '3'] },
    { id: 'blanking', label: 'Blanking', template: 'uw-delete', levels: ['1', '2', '3', '4', '4im'] },
    { id: 'vandalism', label: 'Vandalism', template: 'uw-vandalism', levels: ['1', '2', '3', '4', '4im'] },
    { id: 'subtle', label: 'Subtle vandalism', template: 'uw-subtle', levels: ['1', '2', '3', '4'] },
    { id: 'disruptive', label: 'Disruptive editing', template: 'uw-disruptive', levels: ['1', '2', '3'] },
    { id: 'npa', label: 'No personal attacks', template: 'uw-npa', levels: ['1', '2', '3', '4', '4im'] },
    { id: 'agf', label: 'Assume good faith', template: 'uw-agf', levels: ['1', '2', '3'] },
    { id: 'fringe', label: 'Fringe', template: 'uw-fringe', levels: ['1', '2', '3'] },
    { id: 'npov', label: 'NPOV', template: 'uw-npov', levels: ['1', '2', '3', '4'] },
    { id: 'nor', label: 'NOR', template: 'uw-nor', levels: ['1', '2', '3', '4'] },
    { id: 'ai', label: 'LLM misuse', template: 'uw-ai', levels: ['1', '2', '3', '4'] },
    { id: 'advert', label: 'Advertising', template: 'uw-advert', levels: ['1', '2', '3', '4', '4im'] },
    { id: 'chat', label: 'Talk-page forum', template: 'uw-chat', levels: ['1', '2', '3', '4'] },
    { id: 'mos', label: 'MOS noncompliance', template: 'uw-mos', levels: ['1', '2', '3', '4'] }
  ];

  // One set of groups for all warnings and notices. Each group's dropdown
  // mixes leveled (standard uw-) warnings and single-issue custom notices,
  // separated by optgroups; the option value encodes which kind it is.
  // Custom warnings reference these group ids via their `group` field.
  const WARNING_GROUPS = [
    { id: 'sourcing', label: 'Sourcing', standardIds: ['unsourced', 'fv', 'error', 'fringe', 'npov', 'nor'] },
    { id: 'conduct', label: 'Conduct', standardIds: ['blanking', 'vandalism', 'subtle', 'disruptive', 'npa', 'agf'] },
    { id: 'consensus', label: 'Consensus', standardIds: [] },
    { id: 'style', label: 'Style', standardIds: ['mos'] },
    { id: 'content', label: 'Content', standardIds: ['ai', 'advert', 'tdel'] },
    { id: 'communication', label: 'Communication', standardIds: ['mislead', 'chat'] },
    { id: 'other', label: 'Other notices', standardIds: [] }
  ];

  const BARNSTARS = [
    { id: 'milhist', label: 'Military History', heading: 'The Military Barnstar', template: 'The Military Barnstar' },
    { id: 'original', label: 'Original', heading: 'The Original Barnstar', template: 'The Original Barnstar' },
    { id: 'constitutional', label: 'Constitutional', heading: 'The Constitutional Barnstar', template: 'The Constitutional Barnstar' }
  ];

  const UNRELIABLE_SOURCE_BODY = "Your recent edit${page} added or relied on a source that does not appear to meet Wikipedia's [[WP:RS|reliable sources]] guideline. Please cite published, independent sources with a reputation for fact-checking and accuracy, and avoid self-published, user-generated, or otherwise unreliable sources. You may find [[WP:RSP]] helpful for frequently discussed sources.";
  const CONSENSUS_BODY = "Your recent edit${page} was reverted because it did not have consensus. Please discuss the proposed change on the article talk page and work toward consensus before restoring it. Wikipedia's consensus process depends on explaining disputed changes, responding to other editors' concerns, and seeking agreement rather than repeatedly reinstating contested material.";

  // Sentinel-specific single-issue warnings written as plain signed wikitext.
  // `${page}` is replaced with " to [[Page]]" when a page is supplied.
  const CUSTOM_WARNINGS = [
    { id: 'badcat', group: 'style', label: 'Bad categories', heading: 'Incorrect categories', template: 'uw-badcat' },
    { id: 'badlistentry', group: 'sourcing', label: 'Bad list entry', heading: 'Adding non-notable list entries', template: 'uw-badlistentry' },
    { id: 'brd', group: 'consensus', label: 'BRD', heading: 'Bold, revert, discuss', template: 'uw-BRD' },
    { id: 'coi-brief', group: 'conduct', label: 'COI question', heading: 'Possible conflict of interest', template: 'uw-coi-brief' },
    { id: 'coi', group: 'conduct', label: 'COI', heading: 'Conflict of interest', template: 'uw-coi' },
    { id: 'crystal', group: 'sourcing', label: 'Crystal', heading: 'Speculative future content', template: 'uw-crystal' },
    { id: 'date', group: 'style', label: 'Date format', heading: 'Date formatting', template: 'uw-date' },
    { id: 'engvar', group: 'style', label: 'ENGVAR', heading: 'National varieties of English', template: 'uw-engvar' },
    { id: 'flag', group: 'style', label: 'Flag misuse', heading: 'Misuse of flags', template: 'uw-flag' },
    { id: 'grammar', group: 'style', label: 'Grammar', heading: 'Grammar errors', template: 'uw-grammar' },
    { id: 'guideline', group: 'other', label: 'Guideline', heading: 'Editing against a guideline', template: 'uw-guideline' },
    { id: 'infobox', group: 'style', label: 'Infobox', heading: 'Unexplained infobox edits', template: 'uw-infobox' },
    { id: 'linking', group: 'style', label: 'Linking', heading: 'Excessive or repeated links', template: 'uw-linking' },
    { id: 'login', group: 'conduct', label: 'Login', heading: 'Controversial logged-out editing', template: 'uw-login' },
    { id: 'memorial', group: 'other', label: 'Memorial', heading: 'Wikipedia is not a memorial', template: 'uw-memorial' },
    { id: 'minor', group: 'style', label: 'Minor edits', heading: 'Incorrect minor edit marking', template: 'uw-minor' },
    { id: 'mt', group: 'style', label: 'Machine translation', heading: 'Machine-translated content', template: 'uw-mt' },
    { id: 'refimprove', group: 'sourcing', label: 'Refimprove', heading: 'Poorly referenced article', template: 'uw-refimprove' },
    { id: 'stats', group: 'sourcing', label: 'Unsourced stats', heading: 'Changing statistics without a source', template: 'uw-stats' },
    { id: 'unreliable-template', group: 'sourcing', label: 'Unreliable source tmpl', heading: 'Adding unreliable sources', template: 'uw-unreliable' },
    { id: 'whitewashing', group: 'sourcing', label: 'Whitewashing', heading: 'Whitewashing sourced content', template: 'uw-whitewashing' },
    { id: 'ecr', group: 'conduct', label: 'ECR', heading: 'Extended confirmed restriction violation', template: 'uw-ecr' },
    { id: 'multipleTAs', group: 'conduct', label: 'Multiple TAs', heading: 'Inappropriate use of multiple temporary accounts', template: 'uw-multipleTAs' },
    { id: 'paraphrase', group: 'sourcing', label: 'Close paraphrase', heading: 'Close paraphrasing', template: 'uw-paraphrase' },
    {
      id: 'srcmisrep',
      group: 'sourcing',
      label: 'Source misrepresentation',
      heading: 'Source misrepresentation',
      body: "Your recent edit${page} misrepresented what the cited source(s) actually say. Material on Wikipedia must be verifiable and must accurately reflect the sources it cites (see [[WP:V]] and [[WP:INTEGRITY]]). Please make sure any text you add is directly and fully supported by the source you give for it. Misrepresenting sources is a serious problem, and in [[WP:CTOP|contentious topic areas]] it can lead to sanctions if it continues."
    },
    {
      id: 'unreliable',
      group: 'sourcing',
      label: 'Unreliable source',
      heading: 'Use of unreliable sources',
      body: UNRELIABLE_SOURCE_BODY,
      options: [
        {
          id: 'unreliable-armyrecognition',
          label: 'WP:ARMYRECOGNITION',
          heading: 'Use of unreliable sources',
          body: `${UNRELIABLE_SOURCE_BODY} In particular, editors have reached a consensus at [[WP:ARMYRECOGNITION]] that Army Recognition is generally unreliable. Please replace it with higher-quality independent sourcing.`
        }
      ]
    },
    {
      id: 'burden',
      group: 'sourcing',
      label: 'WP:BURDEN',
      heading: 'Verifiability and burden',
      body: "Your recent edit${page} added or restored material whose verifiability has been challenged. Under [[WP:BURDEN]], the burden to demonstrate verifiability lies with the editor who adds or restores material, and it is satisfied by providing an inline citation to a reliable source that directly supports the contribution. Please do not restore challenged material unless and until you provide reliable sourcing that clearly supports it."
    },
    {
      id: 'consensus',
      group: 'consensus',
      label: 'No consensus',
      heading: 'Please seek consensus',
      body: CONSENSUS_BODY,
      options: [
        {
          id: 'consensus-onus',
          label: 'WP:ONUS',
          heading: 'Please seek consensus',
          body: `${CONSENSUS_BODY} Under [[WP:ONUS]], the responsibility for achieving consensus for inclusion is on editors seeking to add or restore disputed content. Please leave the material out unless and until consensus supports including it.`
        },
        {
          id: 'consensus-statusquo',
          label: 'Status quo',
          heading: 'Please seek consensus before changing the status quo',
          body: "Your recent edit${page} changed or restored material against the existing status quo after that change had been challenged. The longstanding or stable version of an article generally reflects at least tacit consensus until a new consensus is formed. Please do not revert away from that status quo without first explaining your proposed change on the talk page and gaining consensus."
        },
      ]
    },
    {
      id: 'battleground',
      group: 'conduct',
      label: 'Battleground editing',
      heading: 'Battleground conduct',
      body: "Wikipedia is built on collaboration and consensus, not [[WP:BATTLEGROUND|battleground]] conduct. Your recent editing${page} comes across as adversarial. Please focus on content rather than contributors, avoid personalizing disputes, and work toward consensus on the talk page. Continued battleground behaviour, particularly in [[WP:CTOP|contentious topic areas]], may lead to sanctions."
    }
  ];

  function storageGet(key, fallback) {
    try {
      const raw = localStorage.getItem(`${APP}:${key}`);
      return raw ? JSON.parse(raw) : fallback;
    } catch (error) {
      console.warn(`[${APP}] Could not read setting`, key, error);
      return fallback;
    }
  }

  function storageSet(key, value) {
    try {
      localStorage.setItem(`${APP}:${key}`, JSON.stringify(value));
    } catch (error) {
      console.warn(`[${APP}] Could not save setting`, key, error);
    }
  }

  let settings = { ...DEFAULT_SETTINGS, ...storageGet('settings', {}) };

  function saveSettings() {
    storageSet('settings', settings);
  }

  // ---- API key handling ---------------------------------------------------
  // The key is never part of the settings blob. It lives in this closure for
  // the current page view, and is additionally mirrored to sessionStorage or
  // localStorage only when the user has opted into those tiers. All on-wiki
  // scripts share one origin and one execution context, so anything in web
  // storage is readable by every other script the user runs; 'none' is the
  // default for that reason.
  let sessionApiKey = '';
  let legacyAiMigrated = false;
  const API_KEY_STORE = `${APP}:apiKey`;

  function readStoredApiKey() {
    try {
      if (settings.apiKeyStorage === 'local') {
        return localStorage.getItem(API_KEY_STORE) || '';
      }
      if (settings.apiKeyStorage === 'session') {
        return sessionStorage.getItem(API_KEY_STORE) || '';
      }
    } catch (error) {
      console.warn(`[${APP}] Could not read stored API key`, error);
    }
    return '';
  }

  // Clears both tiers, then writes to whichever one the current setting
  // allows. Passing a falsy key just clears everything.
  function writeStoredApiKey(key) {
    try {
      sessionStorage.removeItem(API_KEY_STORE);
      localStorage.removeItem(API_KEY_STORE);
      if (!key) {
        return;
      }
      if (settings.apiKeyStorage === 'local') {
        localStorage.setItem(API_KEY_STORE, key);
      } else if (settings.apiKeyStorage === 'session') {
        sessionStorage.setItem(API_KEY_STORE, key);
      }
    } catch (error) {
      console.warn(`[${APP}] Could not store API key`, error);
    }
  }

  async function promptForApiKey() {
    try {
      await mw.loader.using(['oojs-ui-core', 'oojs-ui-windows']);
      const OO = pageGlobal('OO');
      if (OO && OO.ui && OO.ui.prompt) {
        const value = await OO.ui.prompt(
          'Enter the API key for AI analysis. With "Remember API key" set to Off (the default) it is kept for this page view only.',
          { textInput: { type: 'password' } }
        );
        return (value || '').trim();
      }
    } catch (error) {
      console.warn(`[${APP}] OOUI prompt unavailable`, error);
    }
    return (window.prompt('Enter the API key for AI analysis:') || '').trim();
  }

  async function getApiKey() {
    if (!sessionApiKey) {
      sessionApiKey = readStoredApiKey();
    }
    if (sessionApiKey) {
      return sessionApiKey;
    }
    const entered = await promptForApiKey();
    if (entered) {
      sessionApiKey = entered;
      writeStoredApiKey(sessionApiKey);
    }
    return sessionApiKey;
  }

  function clearApiKey() {
    sessionApiKey = '';
    writeStoredApiKey('');
  }

  // Pre-1.0 versions kept the key inside the persisted settings blob and
  // defaulted the endpoint to OpenRouter, which Wikipedia's CSP now blocks.
  // Move the key out (it follows the new storage setting from here on, which
  // defaults to page-view-only) and reset unreachable endpoints.
  (function migrateLegacyAiSettings() {
    let changed = false;
    if (settings.aiApiKey) {
      sessionApiKey = String(settings.aiApiKey);
      delete settings.aiApiKey;
      changed = true;
    }
    if (/openrouter\.ai/i.test(settings.aiEndpoint || '')) {
      settings.aiEndpoint = DEFAULT_SETTINGS.aiEndpoint;
      settings.aiModel = DEFAULT_SETTINGS.aiModel;
      settings.aiFormat = DEFAULT_SETTINGS.aiFormat;
      changed = true;
    }
    // 1.0.0's migration wrote its sonnet default into saved settings; treat
    // that as "never chose a model" and apply the cheaper current default.
    if (settings.aiModel === 'claude-sonnet-4-6' && !settings.aiModelChosen) {
      settings.aiModel = DEFAULT_SETTINGS.aiModel;
      changed = true;
    }
    if (changed) {
      legacyAiMigrated = true;
    }
    // Housekeeping that shouldn't surface the AI-migration notice.
    if ('markEdits' in settings) {
      delete settings.markEdits; // the tag is always applied now
      changed = true;
    }
    if (changed) {
      saveSettings();
    }
  })();
  // -------------------------------------------------------------------------

  function escapeHtml(value) {
    return String(value || '').replace(/[&<>"']/g, (char) => ({
      '&': '&amp;',
      '<': '&lt;',
      '>': '&gt;',
      '"': '&quot;',
      "'": '&#039;'
    }[char]));
  }

  function stripHtml(value) {
    const tmp = document.createElement('div');
    tmp.innerHTML = value || '';
    return tmp.textContent.replace(/\s+/g, ' ').trim();
  }

  function debounce(fn, delayMs) {
    let timer = null;
    return (...args) => {
      if (timer) {
        window.clearTimeout(timer);
      }
      timer = window.setTimeout(() => {
        timer = null;
        fn(...args);
      }, delayMs);
    };
  }

  function normalizeTitle(title) {
    return String(title || '').replace(/ /g, '_');
  }

  function userTalkTitle(user) {
    return `User talk:${user}`;
  }

  function getUserFromUserNamespaceTitle() {
    if (namespace !== 2 && namespace !== 3) {
      return '';
    }
    const title = mw.config.get('wgTitle') || currentPage.replace(/^User(?:[ _]talk)?:/i, '');
    return title
      .split('/')[0]
      .replace(/_/g, ' ')
      .trim();
  }

  function getCurrentUserTalkRootTitle() {
    const titleUser = getUserFromUserNamespaceTitle();
    return titleUser ? userTalkTitle(titleUser) : '';
  }

  // Detects the most likely subject user and reports *where* the name came
  // from, so the panel can show provenance and flag weak heuristics. Sources
  // are ordered by reliability; `weak: true` marks page-body scrapes that the
  // patroller should verify before posting.
  function detectUser() {
    const params = new URLSearchParams(location.search);
    const explicit = params.get('target') || params.get('user');
    if (explicit) {
      return { user: explicit.replace(/_/g, ' '), source: 'URL parameter' };
    }

    // MediaWiki sets this whenever the page is associated with a user (user
    // page, user talk, Special:Contributions, Special:Block, etc.). It is the
    // canonical signal — it correctly returns the *subject* user rather than
    // the logged-in user, and it already handles subpages — so it outranks
    // our own title parsing.
    const relevantUser = mw.config.get('wgRelevantUserName');
    if (relevantUser) {
      return { user: relevantUser, source: 'page context' };
    }

    const titleUser = getUserFromUserNamespaceTitle();
    if (titleUser) {
      return { user: titleUser, source: 'page title' };
    }

    // On a diff, prefer the author of the newer revision.
    const diffNewUser = document.querySelector('#mw-diff-ntitle2 .mw-userlink bdi, #mw-diff-ntitle2 .mw-userlink, .mw-diff-ntitle2 .mw-userlink bdi, .mw-diff-ntitle2 .mw-userlink');
    if (diffNewUser) {
      return { user: diffNewUser.textContent.trim(), source: 'diff (newer revision)' };
    }

    // Scope the remaining heuristics to page content so we never accidentally
    // pick up the logged-in user's own "Contributions" link in the personal bar.
    const content = document.querySelector('#mw-content-text') || document;
    const contribLink = content.querySelector('a[href*="/wiki/Special:Contributions/"]');
    if (contribLink) {
      const match = contribLink.href.match(/Special:Contributions\/([^?#]+)/);
      if (match) {
        return { user: decodeURIComponent(match[1]).replace(/_/g, ' '), source: 'contributions link in page body', weak: true };
      }
    }

    const diffUser = content.querySelector('.mw-userlink bdi, .mw-userlink');
    if (diffUser) {
      return { user: diffUser.textContent.trim(), source: 'first user link in page body', weak: true };
    }

    return { user: '', source: '' };
  }

  function getLikelyUser() {
    return detectUser().user;
  }

  // Syntax-level validation before anything is posted; catches typo'd or
  // malformed names that would otherwise template a redlink talk page.
  function usernameError(user) {
    if (!user) {
      return 'No user selected.';
    }
    if (/[#<>[\]|{}\/\n]/.test(user)) {
      return `"${user}" contains characters that are not valid in usernames.`;
    }
    // Usernames cannot contain ':' (namespace separator) and rarely start
    // with digits-and-dots, so these patterns must be valid IPs to pass.
    if (user.includes(':') && !(mw.util && mw.util.isIPAddress(user, true))) {
      return /^user(?: talk)?:/i.test(user)
        ? `Remove the namespace prefix — enter just the username, not "${user}".`
        : `"${user}" contains a colon, which is only valid in IPv6 addresses.`;
    }
    if (/^\d{1,3}\./.test(user) && !(mw.util && mw.util.isIPAddress(user, true))) {
      return `"${user}" looks like an IP address but is not a valid one.`;
    }
    return '';
  }

  function buildSummary(action) {
    return `${action} (${PROJECT_LINK})`;
  }

  // Reads a page with redirects resolved and returns everything the edit path
  // needs in one round trip: the resolved title (so we never read the redirect
  // target but write to the redirect page), the current text, and the
  // timestamps the edit API uses for conflict detection.
  async function getPageInfo(title) {
    const response = await api.get({
      action: 'query',
      prop: 'revisions',
      titles: title,
      redirects: true,
      rvprop: 'content|timestamp',
      rvslots: 'main',
      curtimestamp: true,
      formatversion: 2
    });
    const page = response.query.pages[0];
    const revision = page.missing ? null : (page.revisions?.[0] || null);
    return {
      title: page.title || title,
      missing: Boolean(page.missing),
      text: revision?.slots?.main?.content || '',
      baseTimestamp: revision?.timestamp || null,
      startTimestamp: response.curtimestamp || null
    };
  }

  async function getPageText(title) {
    return (await getPageInfo(title)).text;
  }

  async function pageHasRevisionTag(title, tagName) {
    let rvcontinue;

    do {
      const response = await api.get({
        action: 'query',
        prop: 'revisions',
        titles: title,
        rvprop: 'ids|timestamp|comment|tags',
        rvlimit: 'max',
        formatversion: 2,
        ...(rvcontinue ? { rvcontinue } : {})
      });
      const page = response.query.pages[0];
      if (page.missing) {
        return false;
      }

      const revisions = page.revisions || [];
      if (revisions.some((revision) => (revision.tags || []).includes(tagName))) {
        return true;
      }
      rvcontinue = response.continue?.rvcontinue;
    } while (rvcontinue);

    return false;
  }

  // mw.Api rejects jQuery-style with (code, result); `await` only captures the
  // first argument (the code string), so wrap to keep both in one object.
  function postEdit(params) {
    return new Promise((resolve, reject) => {
      api.postWithToken('csrf', params).then(resolve, (code, result) => {
        reject({ code: code, result: result });
      });
    });
  }

  function isAbuseFilterWarning(error) {
    const code = String(error?.code || '');
    const details = JSON.stringify(error?.result || error || {});
    return /abusefilter[- ]?warning/i.test(code) || /abusefilter|edit filter|Did you check/i.test(details);
  }

  function isEditConflict(error) {
    const code = String(error?.code || '');
    const details = JSON.stringify(error?.result || {});
    return /editconflict/i.test(code) || /editconflict/i.test(details);
  }

  function isBadTagsError(error) {
    const code = String(error?.code || '');
    const details = JSON.stringify(error?.result || {});
    return /badtags|tags-apply/i.test(code) || /badtags|tags-apply-not-allowed/i.test(details);
  }

  function apiErrorMessage(error) {
    return error?.result?.errors?.[0]?.html
      || error?.result?.error?.info
      || error?.code
      || 'Unknown API error.';
  }

  // ---- OOUI dialogs -------------------------------------------------------
  // All confirmations go through these helpers. They return `null` when OOUI
  // is unavailable, signalling the caller to fall back to window.confirm /
  // window.prompt, so the script still works if module loading fails.

  // One window manager and one reusable MessageDialog, created lazily on
  // first use. MessageDialog is designed for reuse via openWindow; creating
  // and removing one per call was a bug (removeWindows takes window *names*,
  // and re-adding collides on the symbolic name), and the resulting exception
  // after a successful dialog made callers think OOUI was unavailable — so a
  // redundant native confirm appeared after the styled one.
  let dialogEnvironment = null;

  async function getDialogEnvironment() {
    if (dialogEnvironment) {
      return dialogEnvironment;
    }
    await mw.loader.using(['oojs-ui-core', 'oojs-ui-windows']);
    const OO = pageGlobal('OO');
    const $ = pageGlobal('jQuery');
    if (!OO || !OO.ui || !$) {
      return null;
    }
    const manager = new OO.ui.WindowManager();
    $(document.body).append(manager.$element);
    const dialog = new OO.ui.MessageDialog();
    manager.addWindows([dialog]);
    dialogEnvironment = { manager, dialog, $ };
    return dialogEnvironment;
  }

  // Opens a MessageDialog with arbitrary labelled actions. Resolves to the
  // chosen action string, 'dismissed' if closed via Esc/overlay, or null ONLY
  // when OOUI genuinely could not be used (callers then fall back to native
  // prompts). Once the dialog has opened, the user's answer is final — no
  // code path re-asks.
  async function ooChoice({ title, message, $extra = null, actions, size = 'medium' }) {
    let env;
    try {
      env = await getDialogEnvironment();
    } catch (error) {
      console.warn(`[${APP}] OOUI failed to load; falling back to native prompts`, error);
      return null;
    }
    if (!env) {
      return null;
    }
    try {
      const data = await env.manager.openWindow(env.dialog, {
        title,
        message: buildDialogMessage(env.$, message, $extra),
        actions,
        size
      }).closed;
      return (data && data.action) ? data.action : 'dismissed';
    } catch (error) {
      console.warn(`[${APP}] OOUI dialog failed to open; falling back to native prompts`, error);
      return null;
    }
  }

  function buildDialogMessage($, text, $extra) {
    const $body = $('<div>');
    String(text || '').split('\n').forEach((line) => {
      if (line.trim()) {
        $body.append($('<p>').text(line));
      }
    });
    if ($extra) {
      $body.append($extra);
    }
    return $body;
  }

  // Two-way confirm. `fallbackText` feeds window.confirm when OOUI is out.
  async function sentinelConfirm({ title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', $extra = null, fallbackText = null, size = 'medium' }) {
    const action = await ooChoice({
      title,
      message,
      $extra,
      size,
      actions: [
        { action: 'cancel', label: cancelLabel, flags: 'safe' },
        { action: 'confirm', label: confirmLabel, flags: ['primary', 'progressive'] }
      ]
    });
    if (action === null) {
      return window.confirm(fallbackText || `${title}\n\n${message}`);
    }
    return action === 'confirm';
  }
  // -------------------------------------------------------------------------

  // The page Sentinel will actually edit. Normally User talk:<user>, but if the
  // "post to current page" box is ticked (shown on talk subpages, transcluded
  // talk subpages, etc.) we post to the page currently being viewed instead.
  function getTargetTitle(user) {
    const override = panel && panel.querySelector('[data-sentinel-target-current]');
    if (override && override.checked) {
      return mw.config.get('wgPageName').replace(/_/g, ' ');
    }
    if (namespace === 3) {
      // Only fold subpages back to the root when the chosen user IS this
      // page's user. If the patroller explicitly picked someone else (typed
      // or Alt-clicked from a thread), post to *that* user's talk page —
      // previously this branch silently hijacked the target.
      const titleUser = getUserFromUserNamespaceTitle();
      if (titleUser && titleUser === user) {
        return getCurrentUserTalkRootTitle();
      }
    }
    return userTalkTitle(user);
  }

  function getSelectedWarningUser() {
    const typedUser = panel.querySelector('[data-sentinel-user]').value.trim();
    if (typedUser) {
      return typedUser;
    }
    // Empty field on a user-talk page: fall back to that page's user.
    return namespace === 3 ? getUserFromUserNamespaceTitle() : '';
  }

  function pageTitleFromUrl(url) {
    try {
      const parsed = new URL(url, location.href);
      if (parsed.origin !== location.origin) {
        return '';
      }

      const queryTitle = parsed.searchParams.get('title');
      if (queryTitle) {
        return queryTitle.replace(/_/g, ' ');
      }

      const articlePath = mw.config.get('wgArticlePath') || '/wiki/$1';
      const pathPrefix = articlePath.split('$1')[0] || '/wiki/';
      if (parsed.pathname.startsWith(pathPrefix)) {
        return decodeURIComponent(parsed.pathname.slice(pathPrefix.length)).replace(/_/g, ' ');
      }
    } catch (error) {
      return '';
    }
    return '';
  }

  function isLikelyContentPage(title) {
    return Boolean(title) && !/^(?:User(?: talk)?|Special|Talk|Wikipedia(?: talk)?|Template(?: talk)?|File(?: talk)?|Category(?: talk)?|Help(?: talk)?|Portal(?: talk)?|Draft(?: talk)?|Module(?: talk)?|TimedText(?: talk)?|MediaWiki(?: talk)?):/i.test(title);
  }

  // Best guess at the article a warning is about (for the optional page param).
  function getLikelyPage() {
    const diffTitle = document.querySelector('#mw-diff-ntitle1 a, #mw-diff-otitle1 a');
    if (diffTitle?.textContent.trim()) {
      return diffTitle.textContent.trim();
    }

    if (namespace === 0) {
      return (mw.config.get('wgPageName') || '').replace(/_/g, ' ');
    }

    const referrerTitle = pageTitleFromUrl(document.referrer);
    return isLikelyContentPage(referrerTitle) ? referrerTitle : '';
  }

  function escapeRegExp(value) {
    return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  }

  function monthlyHeaderPattern() {
    return new RegExp(`^==\\s*${escapeRegExp(monthHeading)}\\s*==\\s*$`, 'm');
  }

  function stripMonthlyHeader(wikitext) {
    const pattern = new RegExp(`^\\n*==\\s*${escapeRegExp(monthHeading)}\\s*==\\s*\\n?`);
    return wikitext.replace(pattern, '').trim();
  }

  function stripWikiMarkup(value) {
    return String(value)
      .replace(/\[\[(?:[^|\]]*\|)?([^\]]+)\]\]/g, '$1')
      .replace(/'{2,5}/g, '')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function levelNumber(level) {
    if (level === '4im') {
      return 4;
    }
    return Number(level) || 0;
  }

  function parseUtcTimestamp(value) {
    const match = /(\d{1,2}):(\d{2}),\s+(\d{1,2})\s+([A-Z][a-z]+)\s+(\d{4})\s+\(UTC\)/.exec(value);
    if (!match) {
      return null;
    }
    const months = {
      January: 0,
      February: 1,
      March: 2,
      April: 3,
      May: 4,
      June: 5,
      July: 6,
      August: 7,
      September: 8,
      October: 9,
      November: 10,
      December: 11
    };
    const month = months[match[4]];
    if (month === undefined) {
      return null;
    }
    return new Date(Date.UTC(Number(match[5]), month, Number(match[3]), Number(match[1]), Number(match[2])));
  }

  function warningDescriptor(entry) {
    const age = entry.date ? entry.date.toISOString().replace('T', ' ').replace(/:00\.000Z$/, ' UTC') : 'unknown time';
    return `${entry.label}${entry.level ? ` level ${entry.level}` : ''} at ${age}`;
  }

  function customWarningNeedle(warning) {
    if (warning.template) {
      return '';
    }
    return stripWikiMarkup(warning.body.replace(/\$\{page\}/g, '')).slice(0, 70).toLowerCase();
  }

  function extractTalkWarningHistory(text) {
    const entries = [];
    const timestampPattern = /\d{1,2}:\d{2},\s+\d{1,2}\s+[A-Z][a-z]+\s+\d{4}\s+\(UTC\)/g;
    let match;
    let previousTimestampEnd = 0;
    while ((match = timestampPattern.exec(text)) !== null) {
      const date = parseUtcTimestamp(match[0]);
      const context = text.slice(Math.max(previousTimestampEnd, match.index - 1600), match.index + match[0].length);
      const plainContext = stripWikiMarkup(context).toLowerCase();

      WARNINGS.forEach((warning) => {
        const pattern = new RegExp(`${escapeRegExp(warning.template)}\\s*([1-4](?:im)?)`, 'ig');
        let warningMatch;
        while ((warningMatch = pattern.exec(context)) !== null) {
          entries.push({
            kind: 'standard',
            template: warning.template,
            label: warning.label,
            level: warningMatch[1].toLowerCase(),
            date
          });
        }
      });

      getCustomWarningOptions().forEach((warning) => {
        if (warning.template) {
          const pattern = new RegExp(`${escapeRegExp(warning.template)}(?:\\s*[|}])`, 'i');
          if (pattern.test(context)) {
            entries.push({
              kind: 'custom',
              id: warning.id,
              label: warning.label,
              date
            });
          }
          return;
        }
        const needle = customWarningNeedle(warning);
        if (needle && plainContext.includes(needle)) {
          entries.push({
            kind: 'custom',
            id: warning.id,
            label: warning.label,
            date
          });
        }
      });
      previousTimestampEnd = match.index + match[0].length;
    }
    return entries;
  }

  function latestWarning(entries, predicate) {
    return entries
      .filter(predicate)
      .sort((a, b) => (b.date?.getTime() || 0) - (a.date?.getTime() || 0))[0] || null;
  }

  function recentWarning(entries) {
    const cutoff = Date.now() - 24 * 60 * 60 * 1000;
    return latestWarning(entries, (entry) => entry.date && entry.date.getTime() >= cutoff);
  }

  // Warning-history thresholds. These are intentionally different:
  //  - Standard warnings HAVE levels, so any prior warning at level 3+ is
  //    worth surfacing — the patroller may want to escalate to 4/4im rather
  //    than post another mid-level warning.
  //  - Custom warnings have no level to escalate to, so only a prior FINAL
  //    warning (4 or 4im) triggers the extra "no level to change" dialog;
  //    lower-level history is already covered by the recent-warning check.
  const ESCALATION_REVIEW_LEVEL = 3;
  const FINAL_WARNING_LEVEL = 4;

  function highLevelWarning(entries, minimumLevel = ESCALATION_REVIEW_LEVEL) {
    return latestWarning(entries, (entry) => entry.kind === 'standard' && levelNumber(entry.level) >= minimumLevel);
  }

  // Twinkle's standard warning summaries encode the level in a prefix word.
  const SUMMARY_LEVEL_WORDS = {
    'general note': '1',
    'caution': '2',
    'warning': '3',
    'final warning': '4',
    'only warning': '4im'
  };

  // Warnings the user has blanked or archived vanish from the live page text
  // (blanking is permitted and counts as acknowledgment), so also mine recent
  // talk-page edit summaries for Sentinel- and Twinkle-style warning markers.
  function extractSummaryWarningHistory(revisions) {
    const entries = [];
    revisions.forEach((revision) => {
      const comment = String(revision.comment || '');
      const date = revision.timestamp ? new Date(revision.timestamp) : null;

      // Sentinel's own leveled summaries: "Warning X (vandalism, level 3) ([[WP:Sentinel|Sentinel]])"
      const sentinelLeveled = /\(([^()]+), level (4im|[1-4])\) \(\[\[WP:Sentinel/i.exec(comment);
      if (sentinelLeveled) {
        entries.push({
          kind: 'standard',
          label: `${sentinelLeveled[1]} (from edit summary)`,
          level: sentinelLeveled[2].toLowerCase(),
          date,
          viaSummary: true
        });
        return;
      }

      // Sentinel's custom (unleveled) summaries: "Warning X (no consensus) ([[WP:Sentinel|Sentinel]])"
      const sentinelCustom = /^Warning .+ \(([^()]+)\) \(\[\[WP:Sentinel/i.exec(comment);
      if (sentinelCustom) {
        entries.push({
          kind: 'custom',
          label: `${sentinelCustom[1]} (from edit summary)`,
          date,
          viaSummary: true
        });
        return;
      }

      // Twinkle-style: "General note: Vandalism on [[Page]]. (TW)" etc.
      const twinkle = /^(General note|Caution|Warning|Final warning|Only warning):\s*(.+)$/i.exec(comment);
      if (twinkle) {
        const level = SUMMARY_LEVEL_WORDS[twinkle[1].toLowerCase()];
        entries.push({
          kind: 'standard',
          label: `${stripWikiMarkup(twinkle[2]).replace(/\s*\(TW\)\s*$/i, '')} (from edit summary)`,
          level,
          date,
          viaSummary: true
        });
      }
    });
    return entries;
  }

  async function getRecentTalkRevisions(title) {
    try {
      const response = await api.get({
        action: 'query',
        prop: 'revisions',
        titles: title,
        redirects: true,
        rvprop: 'timestamp|comment',
        rvlimit: 50,
        // Only the recent window matters for the 24h/escalation checks; this
        // bounds the scan instead of walking deep history.
        rvend: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000).toISOString(),
        formatversion: 2
      });
      const page = response.query.pages[0];
      return page.missing ? [] : (page.revisions || []);
    } catch (error) {
      console.warn(`[${APP}] Could not scan talk-page edit summaries`, error);
      return [];
    }
  }

  // Combined view: what's on the page now, plus what edit summaries say was
  // posted recently. A warning still on the page may appear in both lists;
  // that's harmless for the latest/highest-level checks these feed.
  async function getWarningHistory(user) {
    const title = getTargetTitle(user);
    const [text, revisions] = await Promise.all([
      getPageText(title),
      getRecentTalkRevisions(title)
    ]);
    return extractTalkWarningHistory(text).concat(extractSummaryWarningHistory(revisions));
  }

  // Replaces the contents of <nowiki>/<pre>/<syntaxhighlight>/<source> spans
  // and HTML comments with same-length filler (newlines preserved), so the
  // section regexes below can't match header lookalikes inside them while
  // keeping every index aligned with the original text.
  function maskIgnoredWikitext(text) {
    return String(text).replace(
      /<!--[\s\S]*?-->|<(nowiki|pre|syntaxhighlight|source)\b[\s\S]*?<\/\1\s*>/gi,
      (match) => match.replace(/[^\n]/g, 'x')
    );
  }

  function insertIntoExistingMonthSection(existingText, wikitext) {
    // Search a masked copy; slice the original. Indices line up because the
    // mask is length-preserving.
    const masked = maskIgnoredWikitext(existingText);
    const match = monthlyHeaderPattern().exec(masked);
    if (!match) {
      return existingText ? `${existingText.replace(/\s*$/, '')}\n\n${wikitext.trimStart()}` : wikitext.trimStart();
    }

    const body = stripMonthlyHeader(wikitext);
    const afterHeader = match.index + match[0].length;
    const maskedRest = masked.slice(afterHeader);
    const nextSectionMatch = /^==[^=\n].*==\s*$/m.exec(maskedRest);
    const insertAt = nextSectionMatch ? afterHeader + nextSectionMatch.index : existingText.length;
    const before = existingText.slice(0, insertAt).replace(/\s*$/, '');
    const after = existingText.slice(insertAt).replace(/^\s*/, '');

    return `${before}\n\n${body}${after ? `\n\n${after}` : ''}`;
  }

  async function appendToTalkPage(user, wikitext, summary, options = {}) {
    // Resolve redirects (renamed/usurped users) up front so the page we read
    // for the month-header merge is the same page we write to.
    const info = await getPageInfo(getTargetTitle(user));
    const title = info.title;
    if (settings.dryRun) {
      return { dryRun: true, title, wikitext };
    }

    const editParams = {
      action: 'edit',
      title,
      summary,
      watchlist: settings.watchlist || 'nochange',
      formatversion: 2
    };
    if (options.combineMonthHeader) {
      editParams.text = insertIntoExistingMonthSection(info.text, wikitext);
      // Full-page rewrite: let the API detect a concurrent edit instead of
      // silently overwriting it. basetimestamp is omitted for new pages.
      if (info.startTimestamp) {
        editParams.starttimestamp = info.startTimestamp;
      }
      if (info.baseTimestamp) {
        editParams.basetimestamp = info.baseTimestamp;
      }
    } else {
      editParams.appendtext = wikitext;
    }
    // Sentinel edits always carry the script's change tag; if the tag turns
    // out not to be registered on this wiki, the badtags retry below drops it.
    editParams.tags = APP;
    try {
      return await postEdit(editParams);
    } catch (error) {
      if (isEditConflict(error)) {
        if (options.conflictRetried) {
          throw new Error('Edit conflict: the talk page changed while posting. Nothing was saved; please try again.');
        }
        // Re-read the page and re-merge once; a second conflict aborts.
        return appendToTalkPage(user, wikitext, summary, { ...options, conflictRetried: true });
      }
      if (isBadTagsError(error) && editParams.tags) {
        // The "Sentinel" change tag isn't registered or can't be applied
        // here; the notice matters more than the tag, so retry without it.
        console.warn(`[${APP}] Edit tag not accepted; retrying untagged.`, error);
        delete editParams.tags;
        return postEdit(editParams);
      }
      if (!isAbuseFilterWarning(error)) {
        throw new Error(apiErrorMessage(error));
      }

      const warningText = apiErrorMessage(error);
      const $ = pageGlobal('jQuery');
      // Filter warnings arrive as server-sanitized HTML; show it properly
      // when we can rather than as raw markup in a native confirm.
      const confirmed = await sentinelConfirm({
        title: 'Edit filter warning',
        message: 'The edit filter raised a warning about this post:',
        $extra: $ ? $('<div>').addClass('sentinel-dialog-body').html(warningText) : null,
        confirmLabel: 'Submit anyway',
        fallbackText: `${stripHtml(warningText)}\n\nSubmit the notice anyway?`
      });
      if (!confirmed) {
        throw new Error('Edit filter warning was not confirmed.');
      }

      // Warn-mode abuse filters pass an *identical* resubmission; the edit
      // API's ignorewarnings flag has no effect on them.
      return postEdit(editParams);
    }
  }

  // Cached per user: the underlying lookup walks the target's entire talk-page
  // revision history (correct for alert/first semantics, but expensive), and
  // the preview pane calls this on every CTOPS hover. Storing the promise also
  // dedupes overlapping lookups; failures are evicted so they can be retried.
  function hasPriorCtAlert(user) {
    const key = normalizeTitle(user);
    if (!ctAlertCache.has(key)) {
      const lookup = (async () => {
        const title = userTalkTitle(user);
        if (await pageHasRevisionTag(title, 'contentious topics alert')) {
          return true;
        }

        const text = await getPageText(title);
        return /\{\{\s*(?:subst:)?(?:contentious topics\/aware|contentious topics\/alert|alert\/first|alert\b|ds\/alert)/i.test(text);
      })();
      lookup.catch(() => ctAlertCache.delete(key));
      ctAlertCache.set(key, lookup);
    }
    return ctAlertCache.get(key);
  }

  function markCtAlertIssued(user) {
    ctAlertCache.set(normalizeTitle(user), Promise.resolve(true));
  }

  function buildNoticeWikitext(topic, firstCtAlert) {
    if (topic.kind === 'ct') {
      const template = firstCtAlert ? 'Contentious topics/alert/first' : 'Contentious topics/alert';
      const extra = topic.extraText ? `|2=${topic.extraText}|sig=yes` : '';
      return `\n\n${TEMPLATE_OPEN}${SUBST}${template}|topic=${topic.topic}${extra}}}${topic.extraText ? '' : ` ${SIGNATURE}`}`;
    }

    if (topic.kind === 'gs') {
      // Gs/alert takes the topic code as the first positional parameter; it does
      // NOT accept |topic= the way Contentious topics/alert does.
      return `\n\n== ${monthHeading} ==\n${TEMPLATE_OPEN}${SUBST}Gs/alert|${topic.topic}}} ${SIGNATURE}`;
    }

    return '';
  }

  async function expandPreviewWikitext(wikitext) {
    if (!wikitext) {
      return '';
    }
    try {
      const response = await api.post({
        action: 'parse',
        title: getTargetTitle(getSelectedWarningUser() || 'Example'),
        text: wikitext,
        contentmodel: 'wikitext',
        prop: 'wikitext',
        pst: true,
        formatversion: 2
      });
      const parsed = response.parse?.wikitext;
      return (typeof parsed === 'string' ? parsed : parsed?.['*']) || wikitext;
    } catch (error) {
      console.warn(`[${APP}] Could not expand preview`, error);
      return wikitext;
    }
  }

  // Recipient's-eye view: parse the exact wikitext Sentinel will post into
  // rendered HTML. pst:true expands subst: templates and ~~~~ first, so the
  // preview shows the warning box (and signature) as it will appear on the
  // target talk page. Results are cached per target title + wikitext.
  async function renderPreviewHtml(wikitext) {
    if (!wikitext) {
      return '';
    }
    const title = getTargetTitle(getSelectedWarningUser() || 'Example');
    const cacheKey = `${title}\u0000${wikitext}`;
    if (parsePreviewCache.has(cacheKey)) {
      return parsePreviewCache.get(cacheKey);
    }
    try {
      const response = await api.post({
        action: 'parse',
        title,
        text: wikitext,
        contentmodel: 'wikitext',
        prop: 'text',
        pst: true,
        disablelimitreport: true,
        disableeditsection: true,
        formatversion: 2
      });
      const html = response.parse?.text || '';
      parsePreviewCache.set(cacheKey, html);
      return html;
    } catch (error) {
      console.warn(`[${APP}] Could not render preview`, error);
      return '';
    }
  }

  function noticeLabel(topic, firstCtAlert) {
    return `${topic.label}: ${firstCtAlert ? 'first CT alert' : topic.kind === 'ct' ? 'subsequent CT alert' : 'GS alert'}`;
  }

  // Confirmation step: show the message as the recipient will see it. Falls
  // back to the old expanded-wikitext window.confirm if rendering or OOUI
  // is unavailable. Dismissing the dialog never falls through to posting.
  async function confirmPost(target, wikitext, fallbackPreview) {
    const html = await renderPreviewHtml(wikitext);
    const $ = pageGlobal('jQuery');
    if (html && $) {
      const $extra = $('<div>')
        .addClass('sentinel-dialog-preview mw-parser-output')
        .html(html);
      const action = await ooChoice({
        title: `Post to ${target}?`,
        message: 'This is how the message will appear:',
        $extra,
        size: 'large',
        actions: [
          { action: 'cancel', label: 'Cancel', flags: 'safe' },
          { action: 'post', label: 'Post', flags: ['primary', 'progressive'] }
        ]
      });
      if (action !== null) {
        return action === 'post';
      }
    }
    const expandedPreview = await expandPreviewWikitext(fallbackPreview);
    return window.confirm(`Post to [[${target}]]?\n\n${expandedPreview}`);
  }

  // Shared confirm -> post -> status flow used by notices and warnings.
  // Returns the edit result, or null when validation failed or the user
  // cancelled.
  async function postToTalk(user, wikitext, summary, preview, options = {}) {
    const problem = usernameError(user);
    if (problem) {
      setStatus(problem, 'error');
      return null;
    }

    const target = getTargetTitle(user);
    if (settings.requireConfirm) {
      if (!(await confirmPost(target, wikitext, preview))) {
        setStatus('Cancelled.', 'neutral');
        return null;
      }
    }

    setStatus(`Posting to ${target}...`, 'neutral');
    const result = await appendToTalkPage(user, wikitext, summary, options);
    if (result && result.dryRun) {
      setStatus(`Dry run — nothing was posted (would target ${result.title}).`, 'neutral');
      return result;
    }

    const newRevId = result?.edit?.newrevid;
    if (newRevId) {
      const diffHref = `/wiki/Special:Diff/${newRevId}`;
      setStatus(`Posted to ${target} — `, 'success', { href: diffHref, label: 'view diff' });
      notifyPosted(target, diffHref);
    } else {
      setStatus(`Posted to ${target}.`, 'success');
    }
    return result;
  }

  function notifyPosted(target, diffHref) {
    if (typeof mw.notify !== 'function') {
      return;
    }
    const message = document.createElement('span');
    message.appendChild(document.createTextNode(`Posted to ${target} — `));
    const link = document.createElement('a');
    link.href = diffHref;
    link.textContent = 'view diff';
    link.target = '_blank';
    link.rel = 'noopener';
    message.appendChild(link);
    mw.notify(message, { title: APP, tag: 'sentinel-posted' });
  }

  async function issueNotice(topicId) {
    const user = getSelectedWarningUser();
    const topic = TOPICS.find((item) => item.id === topicId);
    if (!user || !topic) {
      setStatus('Choose a valid user and topic first.', 'error');
      return;
    }

    const firstCtAlert = topic.kind === 'ct' ? !(await hasPriorCtAlert(user)) : false;
    const wikitext = buildNoticeWikitext(topic, firstCtAlert);
    const summary = buildSummary(`Notifying ${user} about ${topic.label} ${topic.kind === 'ct' ? 'contentious topic' : 'general sanctions'}`);
    const preview = `${noticeLabel(topic, firstCtAlert)}\n\n${wikitext}`;

    const result = await postToTalk(user, wikitext, summary, preview);
    if (result && !result.dryRun && topic.kind === 'ct') {
      // The user now has a CT alert on record; keep the cache truthful so a
      // follow-up notice in this session correctly uses the non-first template.
      markCtAlertIssued(user);
    }
  }

  // Pick a level that actually exists for this warning (clamps requests like a
  // level 4 for disruptive, which only goes to 3, or 4im where unsupported).
  function clampLevel(warning, selected) {
    if (warning.levels.includes(selected)) {
      return selected;
    }
    const nums = warning.levels.filter((value) => value !== '4im').map(Number);
    const maxNum = Math.max(...nums);
    const want = selected === '4im' ? maxNum : Number(selected) || 1;
    return String(Math.min(want, maxNum));
  }

  function maxNumericLevel(warning) {
    const nums = warning.levels.filter((value) => value !== '4im').map(Number);
    return Math.max(...nums);
  }

  function requestedLevelExceedsWarning(warning, selected) {
    return selected !== clampLevel(warning, selected);
  }

  function selectedWarningPage() {
    return panel.querySelector('[data-sentinel-warn-page]').value.trim();
  }

  function additionalWarningText() {
    return panel.querySelector('[data-sentinel-extra-text]').value.trim();
  }

  function additionalWarningWikitext(extraText) {
    return extraText ? `\n\n:''${extraText}''` : '';
  }

  function withMonthHeader(body) {
    return `\n\n== ${monthHeading} ==\n${body}`;
  }

  function buildWarningWikitext(warning, level, page, extraText = '') {
    const pageParam = page ? `|${page}` : '';
    return withMonthHeader(`${TEMPLATE_OPEN}${SUBST}${warning.template}${level}${pageParam}}}${additionalWarningWikitext(extraText)} ${SIGNATURE}`);
  }

  function buildCustomWarningWikitext(warning, page, extraText = '') {
    if (warning.template) {
      const pageParam = page ? `|${page}` : '';
      return withMonthHeader(`${TEMPLATE_OPEN}${SUBST}${warning.template}${pageParam}}}${additionalWarningWikitext(extraText)} ${SIGNATURE}`);
    }
    const pagePhrase = page ? ` to [[${page}]]` : '';
    const body = warning.body.replace(/\$\{page\}/g, pagePhrase);
    return withMonthHeader(`${body}${additionalWarningWikitext(extraText)} (-- via ${PROJECT_LINK}) ${SIGNATURE}`);
  }

  function selectedBarnstar() {
    const id = panel.querySelector('[data-sentinel-barnstar-select]').value;
    return BARNSTARS.find((barnstar) => barnstar.id === id);
  }

  function barnstarMessage(extraText = '') {
    return extraText || 'Thank you for your contributions.';
  }

  function buildBarnstarWikitext(barnstar, extraText = '') {
    return `\n\n== ${barnstar.heading} ==\n${TEMPLATE_OPEN}${SUBST}${barnstar.template}|1=${barnstarMessage(extraText)} ${SIGNATURE}}}`;
  }

  function getCustomWarningOptions() {
    return CUSTOM_WARNINGS.flatMap((warning) => [
      warning,
      ...((warning.options || []).map((option) => ({ ...option, parentLabel: warning.label })))
    ]);
  }

  async function confirmRecentWarning(user, recent) {
    if (!recent) {
      return true;
    }
    const message = `${user} appears to have been warned in the past 24 hours:\n${warningDescriptor(recent)}`;
    return sentinelConfirm({
      title: 'Recently warned',
      message,
      confirmLabel: 'Post another warning',
      fallbackText: `${message}\n\nContinue with another warning?`
    });
  }

  async function resolveEscalatedWarningLevel(user, warning, selectedLevel, history) {
    const high = highLevelWarning(history);
    if (!high) {
      return selectedLevel;
    }

    const escalated = clampLevel(warning, '4');
    const canEscalate = levelNumber(escalated) > levelNumber(selectedLevel);
    const message = `${user} already appears to have received a high-level warning:\n${warningDescriptor(high)}`;
    if (!canEscalate) {
      const confirmed = await sentinelConfirm({
        title: 'Prior high-level warning',
        message,
        confirmLabel: `Post level ${selectedLevel} warning`,
        fallbackText: `${message}\n\nContinue with the selected level ${selectedLevel} warning?`
      });
      return confirmed ? selectedLevel : null;
    }

    const action = await ooChoice({
      title: 'Prior high-level warning',
      message: `${message}\nEscalate this warning?`,
      actions: [
        { action: 'cancel', label: 'Cancel', flags: 'safe' },
        { action: 'keep', label: `Keep level ${selectedLevel}` },
        { action: 'escalate', label: `Escalate to level ${escalated}`, flags: ['primary', 'progressive'] }
      ]
    });
    if (action === null) {
      // Native-prompt fallback for the three-way choice.
      const choice = window.prompt(
        `${message}\n\nType 4 to escalate this warning to level ${escalated}, type C to continue with level ${selectedLevel}, or cancel/blank to stop.`,
        '4'
      );
      if (choice === null || !choice.trim()) {
        return null;
      }
      if (choice.trim().toLowerCase() === '4') {
        return escalated;
      }
      if (choice.trim().toLowerCase() === 'c') {
        return selectedLevel;
      }
      setStatus('Cancelled. Sentinel did not recognize the warning-history choice.', 'neutral');
      return null;
    }
    if (action === 'escalate') {
      return escalated;
    }
    if (action === 'keep') {
      return selectedLevel;
    }
    return null;
  }

  async function issueWarning(warningId) {
    const user = getSelectedWarningUser();
    const warning = WARNINGS.find((item) => item.id === warningId);
    if (!user || !warning) {
      setStatus('Choose a valid user and warning first.', 'error');
      return;
    }

    const selected = panel.querySelector('[data-sentinel-warn-level]').value;
    let level = clampLevel(warning, selected);
    const history = await getWarningHistory(user);
    const resolvedLevel = await resolveEscalatedWarningLevel(user, warning, level, history);
    if (!resolvedLevel) {
      setStatus('Cancelled.', 'neutral');
      return;
    }
    level = resolvedLevel;
    if (!highLevelWarning(history) && !(await confirmRecentWarning(user, recentWarning(history)))) {
      setStatus('Cancelled.', 'neutral');
      return;
    }

    const page = selectedWarningPage();
    const extraText = additionalWarningText();
    const wikitext = buildWarningWikitext(warning, level, page, extraText);
    const summary = buildSummary(`Warning ${user} (${warning.label.toLowerCase()}, level ${level})`);
    const preview = `${warning.label} — level ${level}${page ? ` re ${page}` : ''}\n\n${wikitext}`;

    await postToTalk(user, wikitext, summary, preview, { combineMonthHeader: true });
  }

  async function issueCustomWarning(warningId) {
    const user = getSelectedWarningUser();
    const warning = getCustomWarningOptions().find((item) => item.id === warningId);
    if (!user || !warning) {
      setStatus('Choose a valid user and warning first.', 'error');
      return;
    }

    const page = selectedWarningPage();
    const extraText = additionalWarningText();
    const history = await getWarningHistory(user);
    const high = highLevelWarning(history, FINAL_WARNING_LEVEL);
    const recent = recentWarning(history);
    if (high) {
      const message = `${user} already appears to have received a high-level warning:\n${warningDescriptor(high)}\nThis warning type has no level to change.`;
      const confirmed = await sentinelConfirm({
        title: 'Prior high-level warning',
        message,
        confirmLabel: 'Post this warning',
        fallbackText: `${message}\n\nContinue with this warning?`
      });
      if (!confirmed) {
        setStatus('Cancelled.', 'neutral');
        return;
      }
    }
    if (!high && !(await confirmRecentWarning(user, recent))) {
      setStatus('Cancelled.', 'neutral');
      return;
    }

    const wikitext = buildCustomWarningWikitext(warning, page, extraText);
    const summary = buildSummary(`Warning ${user} (${warning.label.toLowerCase()})`);
    const preview = `${warning.label}${page ? ` re ${page}` : ''}\n\n${wikitext}`;

    await postToTalk(user, wikitext, summary, preview, { combineMonthHeader: true });
  }

  async function issueBarnstar() {
    const user = getSelectedWarningUser();
    const barnstar = selectedBarnstar();
    if (!user || !barnstar) {
      setStatus('Choose a valid user and barnstar first.', 'error');
      return;
    }

    const extraText = additionalWarningText();
    const wikitext = buildBarnstarWikitext(barnstar, extraText);
    const summary = buildSummary(`Awarding ${user} ${barnstar.heading}`);
    const preview = `${barnstar.heading} for ${user}\n\n${wikitext}`;

    await postToTalk(user, wikitext, summary, preview);
  }

  async function getUserContribs(user, limit) {
    const response = await api.get({
      action: 'query',
      list: 'usercontribs',
      ucuser: user,
      uclimit: limit,
      ucprop: 'ids|title|timestamp|comment|size|sizediff|flags|tags',
      formatversion: 2
    });
    return response.query.usercontribs || [];
  }

  // Populates the Page field's suggestion list with the target's last five
  // distinct edited titles, and prefills the most recent one when the field
  // is untouched. Suggestions beat silent prefill: on contributions pages
  // especially, the latest edit is often not the one being warned about.
  async function refreshPageSuggestions() {
    const user = getSelectedWarningUser();
    const field = panel.querySelector('[data-sentinel-warn-page]');
    const datalist = panel.querySelector('#sentinel-page-options');
    if (!field || !datalist) {
      return;
    }
    if (!user) {
      datalist.innerHTML = '';
      return;
    }

    try {
      const contribs = await getUserContribs(user, 25);
      const titles = [...new Set(contribs.map((contrib) => contrib.title).filter(Boolean))].slice(0, 5);
      datalist.innerHTML = titles.map((title) => `<option value="${escapeHtml(title)}"></option>`).join('');
      if (field.dataset.touched !== 'true' && !field.value.trim() && titles[0]) {
        field.value = titles[0];
        updateWarningPreview();
      }
    } catch (error) {
      console.warn(`[${APP}] Could not load page suggestions`, error);
    }
  }

  async function getDiffForContribution(contrib) {
    if (!contrib.parentid || !contrib.revid) {
      return '';
    }

    const response = await api.get({
      action: 'compare',
      fromrev: contrib.parentid,
      torev: contrib.revid,
      prop: 'diff|ids|title',
      formatversion: 2
    });
    return stripHtml(response.compare?.body || '');
  }

  // Canonical storage key for a page title: mw.Title handles capitalization,
  // namespace aliases, and space/underscore variants; fall back to the simple
  // underscore form if the title is unparseable or mw.Title isn't loaded.
  function canonicalTitleKey(title) {
    const parsed = mw.Title ? mw.Title.newFromText(String(title || '').replace(/_/g, ' ')) : null;
    return parsed ? parsed.getPrefixedDb() : normalizeTitle(title);
  }

  function getCriteriaForTitle(title) {
    const key = canonicalTitleKey(title);
    return (settings.customPageCriteria && settings.customPageCriteria[key]) || [];
  }

  async function buildAnalysisPayload(user) {
    const contribs = await getUserContribs(user, Number(settings.aiEditLimit) || 8);
    const withDiffs = [];
    for (const contrib of contribs) {
      withDiffs.push({
        title: contrib.title,
        revid: contrib.revid,
        parentid: contrib.parentid,
        timestamp: contrib.timestamp,
        comment: contrib.comment || '',
        sizeDiff: contrib.sizediff,
        tags: contrib.tags || [],
        pageCriteria: getCriteriaForTitle(contrib.title),
        diffText: await getDiffForContribution(contrib)
      });
    }
    return withDiffs;
  }

  function aiPromptParts(user, edits) {
    const today = new Date().toISOString().slice(0, 10);
    // No relay exists yet, so tools are Anthropic-only; treat openai format
    // as verification-off regardless of the setting.
    const verification = settings.aiFormat === 'openai' ? 'off' : (settings.aiVerification || 'off');
    const lines = [
      'You are assisting an experienced English Wikipedia counter-disruption patroller.',
      `The current date is ${today}. Your training data ends well before this date, so recent events, products, releases, and reference access dates that you do not recognize are EXPECTED, not evidence of fabrication. Never allege that something is a hoax, fabricated, speculative, or future-dated based only on your own non-recognition or your beliefs about what year it is.`,
      'Analyze only the supplied diffs and metadata. Do not invent facts or imply certainty without evidence.',
      'Flag issues for human review, especially source misrepresentation, unsourced additions, synthesis, inaccurate sourcing, dubious or unreliable sources, contentious-topic or extended-confirmed restriction concerns, and page-specific inclusion criteria violations.'
    ];
    if (verification !== 'off') {
      lines.push('Verification tools are available: web_search (the live web) and search_wikipedia (English Wikipedia, free). Before flagging any recent event, product, or claim as a possible hoax, fabrication, or unverifiable, search for it first. Multiple independent search results weigh heavily against a hoax. If searches find nothing, that supports but does not prove the concern: cap such a finding at medium severity and say what you searched. Also use searches to spot-check whether cited sources plausibly cover the claims they are cited for.');
    }
    if (verification === 'thorough') {
      lines.push('Thorough mode: you MUST run at least one relevant search before issuing ANY finding about sourcing, authenticity, or verifiability, and verify each distinct questionable claim separately rather than batching them.');
    }
    lines.push('Return concise JSON with keys: assessment (string), confidence (low, medium, or high), issues (array of objects, each with keys: type (short snake_case category), severity (low, medium, or high), description (one or two sentences), verification (short note on what was searched and what was found, or "not searched"), affectedRevisions (array of revision ID numbers)), suggestedActions (array of strings), caveats (array of strings).');
    lines.push('After any tool use, your final reply must be ONLY the JSON object. Do not narrate your analysis or search findings in prose: no commentary before the JSON, no markdown fences, nothing after it. Everything you want to convey belongs inside the JSON fields.');
    return {
      system: lines.join(' '),
      user: JSON.stringify({ user, edits }, null, 2)
    };
  }

  // The free corroboration tool: executed by the script itself through the
  // same-origin MediaWiki API, so it costs nothing and the CSP is irrelevant.
  const WIKIPEDIA_SEARCH_TOOL = {
    name: 'search_wikipedia',
    description: 'Search English Wikipedia for articles matching a query. Free. Use it to corroborate whether an entity, product, or event is covered on Wikipedia itself. Returns up to 5 results with title and snippet.',
    input_schema: {
      type: 'object',
      properties: {
        query: { type: 'string', description: 'Search terms; a few words.' }
      },
      required: ['query']
    }
  };

  function buildAnthropicTools() {
    if (settings.aiFormat === 'openai' || (settings.aiVerification || 'off') === 'off') {
      return null;
    }
    const maxUses = settings.aiVerification === 'thorough' ? 10 : 5;
    return [
      { type: 'web_search_20250305', name: 'web_search', max_uses: maxUses },
      WIKIPEDIA_SEARCH_TOOL
    ];
  }

  async function executeWikipediaSearch(input) {
    try {
      const response = await api.get({
        action: 'query',
        list: 'search',
        srsearch: String(input?.query || '').slice(0, 300),
        srlimit: 5,
        formatversion: 2
      });
      const results = (response.query?.search || []).map((result) => ({
        title: result.title,
        snippet: stripHtml(result.snippet || '')
      }));
      return JSON.stringify(results.length ? results : { results: 'No matching Wikipedia articles found.' });
    } catch (error) {
      return JSON.stringify({ error: String(error?.message || error) });
    }
  }

  // Models — especially with search results in context — sometimes narrate
  // prose around the JSON despite instructions. Find every balanced top-level
  // {...} span (string-aware, so braces inside JSON strings don't break the
  // depth count) and try parsing candidates from the last span backward,
  // since the final object in the response is almost always the answer.
  function balancedJsonSpans(text) {
    const spans = [];
    let depth = 0;
    let start = -1;
    let inString = false;
    let escaped = false;
    for (let i = 0; i < text.length; i++) {
      const ch = text[i];
      if (inString) {
        if (escaped) {
          escaped = false;
        } else if (ch === '\\') {
          escaped = true;
        } else if (ch === '"') {
          inString = false;
        }
        continue;
      }
      if (ch === '"' && depth > 0) {
        inString = true;
      } else if (ch === '{') {
        if (depth === 0) {
          start = i;
        }
        depth++;
      } else if (ch === '}' && depth > 0) {
        depth--;
        if (depth === 0 && start !== -1) {
          spans.push(text.slice(start, i + 1));
          start = -1;
        }
      }
    }
    return spans;
  }

  function parseAnalysisResponse(content) {
    const raw = String(content || '').trim();
    const candidates = [];
    // Clean case: the whole response is the JSON, possibly fenced.
    candidates.push(raw.replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, '').trim());
    // Messy case: JSON embedded in narrative, fenced or bare.
    candidates.push(...balancedJsonSpans(raw).reverse());
    for (const candidate of candidates) {
      if (!candidate) {
        continue;
      }
      try {
        const parsed = JSON.parse(candidate);
        if (parsed && typeof parsed === 'object') {
          return parsed;
        }
      } catch (error) {
        // try the next candidate
      }
    }
    return null;
  }

  // Sends one analysis request and returns the model's text output.
  // Two wire formats:
  //  - 'anthropic' (default): api.anthropic.com/v1/messages. On Wikipedia's
  //    CSP connect allowlist and explicitly supports in-browser calls when
  //    the dangerous-direct-browser-access header is set, so it works from
  //    an on-wiki script with no extension or relay.
  //  - 'openai': chat-completions shape, for a self-hosted relay. The relay
  //    must live on a CSP-allowlisted host (e.g. *.toolforge.org) or the
  //    browser will refuse the connection before it leaves the page.
  async function postAiRequest(systemText, userText, key) {
    const isOpenAi = settings.aiFormat === 'openai';
    const headers = isOpenAi
      ? {
        'Content-Type': 'application/json',
        Authorization: `Bearer ${key}`
      }
      : {
        'Content-Type': 'application/json',
        'x-api-key': key,
        'anthropic-version': '2023-06-01',
        'anthropic-dangerous-direct-browser-access': 'true'
      };
    const reasoning = settings.aiReasoning || 'off';
    const budget = REASONING_BUDGETS[reasoning] || 0;
    let body;
    if (isOpenAi) {
      body = {
        model: settings.aiModel,
        response_format: { type: 'json_object' },
        messages: [
          { role: 'system', content: systemText },
          { role: 'user', content: userText }
        ]
      };
      if (reasoning !== 'off') {
        body.reasoning_effort = reasoning;
      }
      // No temperature: current OpenAI reasoning models reject non-default
      // values, and relays generally pass parameters through verbatim.
    } else {
      body = {
        model: settings.aiModel,
        // With thinking enabled the budget must fit under max_tokens with
        // room left for the actual answer.
        max_tokens: budget ? budget + 4000 : 2000,
        system: systemText,
        messages: [{ role: 'user', content: userText }]
      };
      if (budget) {
        // Extended thinking requires the default temperature (1), so omit it.
        body.thinking = { type: 'enabled', budget_tokens: budget };
      } else {
        body.temperature = 0.1;
      }
    }

    if (!isOpenAi) {
      const tools = buildAnthropicTools();
      if (tools) {
        body.tools = tools;
        // Search results land in context and the model narrates around them;
        // give the final answer room.
        body.max_tokens += 1500;
      }
    }

    const send = async () => {
      let response;
      try {
        response = await fetch(settings.aiEndpoint, {
          method: 'POST',
          headers,
          body: JSON.stringify(body)
        });
      } catch (error) {
        // fetch throws (rather than returning a status) when the browser
        // refuses the connection — on enwiki that is almost always the CSP.
        throw new Error("The browser refused the connection. Two layers can do this: Wikipedia's Content Security Policy blocks hosts not on its allowlist (openrouter.ai and most endpoints; *.toolforge.org is allowed), and some allowlisted hosts refuse browser requests themselves — api.openai.com is CSP-allowed but blocks browser CORS, so OpenAI models need a relay. api.anthropic.com works directly. See [[WP:SENTINEL]] § Risks.");
      }
      const data = await response.json().catch(() => ({}));
      if (!response.ok) {
        if (response.status === 401 || response.status === 403) {
          clearApiKey();
          throw new Error('The endpoint rejected the API key. It has been cleared; you will be asked for it again on the next run.');
        }
        if (response.status === 429) {
          throw new Error('Rate limited by the AI endpoint. Wait a moment and try again.');
        }
        throw new Error(data.error?.message || data.error?.code || `AI endpoint returned HTTP ${response.status}.`);
      }
      return data;
    };

    if (isOpenAi) {
      const data = await send();
      return data.choices?.[0]?.message?.content || '';
    }

    // Agentic loop. Server-side web_search resolves inside Anthropic's turn;
    // the only thing the script must execute is the (free) search_wikipedia
    // tool. pause_turn means a long search-heavy turn was checkpointed and
    // should be continued as-is. Hard-capped to keep a confused model from
    // spending the key.
    let data = await send();
    let verifyRound = 0;
    for (let turn = 0; turn < 8; turn++) {
      if (data.stop_reason === 'pause_turn') {
        body.messages.push({ role: 'assistant', content: data.content });
        data = await send();
        continue;
      }
      if (data.stop_reason !== 'tool_use') {
        break;
      }
      const toolUses = (data.content || []).filter((block) => block.type === 'tool_use');
      if (!toolUses.length) {
        break;
      }
      verifyRound += 1;
      setStatus(`Verifying claims (round ${verifyRound})...`, 'neutral');
      // Assistant content must go back verbatim (including any thinking
      // blocks and their signatures) for the tool results to be accepted.
      body.messages.push({ role: 'assistant', content: data.content });
      const toolResults = [];
      for (const use of toolUses) {
        const output = use.name === 'search_wikipedia'
          ? await executeWikipediaSearch(use.input)
          : JSON.stringify({ error: `Unknown tool: ${use.name}` });
        toolResults.push({ type: 'tool_result', tool_use_id: use.id, content: output });
      }
      body.messages.push({ role: 'user', content: toolResults });
      data = await send();
    }
    return (data.content || []).filter((block) => block.type === 'text').map((block) => block.text).join('\n');
  }

  async function runAiAnalysis() {
    const user = panel.querySelector('[data-sentinel-user]').value.trim();
    if (!user) {
      setStatus('Enter a user before running analysis.', 'error');
      return;
    }

    if (!settings.aiEnabled) {
      openSettings();
      setStatus('AI analysis is disabled — enable it in Settings first.', 'error');
      return;
    }
    if (!settings.aiEndpoint) {
      openSettings();
      setStatus('Set an AI endpoint in Settings first.', 'error');
      return;
    }

    const key = await getApiKey();
    if (!key) {
      setStatus('Cancelled — AI analysis needs an API key.', 'neutral');
      return;
    }

    setStatus(`Collecting recent diffs for ${user}...`, 'neutral');
    const edits = await buildAnalysisPayload(user);
    if (!edits.length) {
      setStatus(`No recent edits found for ${user}.`, 'error');
      return;
    }
    setStatus(`Analyzing ${edits.length} edits...`, 'neutral');
    const parts = aiPromptParts(user, edits);
    const content = await postAiRequest(parts.system, parts.user, key);

    let analysis = parseAnalysisResponse(content);
    if (!analysis) {
      analysis = {
        assessment: 'Response was not in the expected format',
        confidence: 'low',
        issues: [String(content || '(empty response)').trim()],
        suggestedActions: [],
        caveats: ['No parseable JSON was found anywhere in the AI response; the raw text is shown above.']
      };
    }
    renderAnalysis(analysis, edits);
    setStatus('AI analysis complete. Treat this as triage, not a finding.', 'success');
  }

  function humanizeLabel(value) {
    const spaced = String(value || '').replace(/[_-]+/g, ' ').trim();
    return spaced ? spaced.charAt(0).toUpperCase() + spaced.slice(1) : '';
  }

  // Models occasionally return strings or unexpected object shapes despite
  // the schema in the prompt; everything here degrades to readable text
  // rather than raw JSON.
  function readableValue(value) {
    if (value == null) {
      return '';
    }
    if (typeof value === 'string') {
      return value;
    }
    if (typeof value === 'object') {
      return Object.entries(value)
        .map(([key, entry]) => `${humanizeLabel(key)}: ${typeof entry === 'string' ? entry : JSON.stringify(entry)}`)
        .join(' — ');
    }
    return String(value);
  }

  const SEVERITY_RANK = { high: 0, medium: 1, low: 2 };

  function renderIssue(issue) {
    if (typeof issue === 'string') {
      return `<li class="sentinel-issue">${escapeHtml(issue)}</li>`;
    }
    if (!issue || typeof issue !== 'object') {
      return `<li class="sentinel-issue">${escapeHtml(String(issue))}</li>`;
    }
    const severity = String(issue.severity || '').toLowerCase();
    const severityHtml = severity in SEVERITY_RANK
      ? `<span class="sentinel-severity" data-severity="${severity}">${severity}</span>`
      : '';
    const typeHtml = issue.type ? `<strong>${escapeHtml(humanizeLabel(issue.type))}</strong>` : '';
    const description = typeof issue.description === 'string' ? issue.description : '';
    const revisions = (Array.isArray(issue.affectedRevisions) ? issue.affectedRevisions : [])
      .map((revid) => String(revid))
      .filter((revid) => /^\d+$/.test(revid));
    const revisionLinks = revisions
      .map((revid) => `<a target="_blank" rel="noopener" href="https://artikeldigital.com/en/Special:Diff/${revid}">${revid}</a>`)
      .join(', ');

    const verification = typeof issue.verification === 'string' ? issue.verification.trim() : '';
    if (!severityHtml && !typeHtml && !description) {
      // Unknown shape: flatten to labeled text instead of dumping JSON.
      return `<li class="sentinel-issue">${escapeHtml(readableValue(issue))}</li>`;
    }
    return `
      <li class="sentinel-issue">
        <div class="sentinel-issue-head">${severityHtml}${typeHtml}</div>
        ${description ? `<div>${escapeHtml(description)}</div>` : ''}
        ${verification && verification.toLowerCase() !== 'not searched' ? `<small class="sentinel-issue-verification">Verification: ${escapeHtml(verification)}</small>` : ''}
        ${revisionLinks ? `<small class="sentinel-issue-revs">Diffs: ${revisionLinks}</small>` : ''}
      </li>`;
  }

  function renderAnalysis(analysis, edits) {
    const output = panel.querySelector('[data-sentinel-analysis]');
    const issues = (Array.isArray(analysis.issues) ? analysis.issues.slice() : [])
      .sort((a, b) => {
        const rank = (issue) => SEVERITY_RANK[String(issue?.severity || '').toLowerCase()] ?? 3;
        return rank(a) - rank(b);
      });
    const actions = Array.isArray(analysis.suggestedActions) ? analysis.suggestedActions : [];
    const caveats = Array.isArray(analysis.caveats) ? analysis.caveats : [];

    output.innerHTML = `
      <div class="sentinel-result-head">
        <strong>${escapeHtml(analysis.assessment || 'Analysis')}</strong>
        <span>${escapeHtml(analysis.confidence || 'low')} confidence</span>
      </div>
      <h4>Issues</h4>
      ${issues.length ? `<ul class="sentinel-issue-list">${issues.map(renderIssue).join('')}</ul>` : '<p>No specific issues returned.</p>'}
      <h4>Suggested actions</h4>
      ${actions.length ? `<ul>${actions.map((action) => `<li>${escapeHtml(readableValue(action))}</li>`).join('')}</ul>` : '<p>No suggested actions returned.</p>'}
      <h4>Reviewed edits</h4>
      <ul>${edits.map((edit) => `<li><a target="_blank" rel="noopener" href="https://artikeldigital.com/en/Special:Diff/${edit.revid}">${escapeHtml(edit.title)} #${edit.revid}</a></li>`).join('')}</ul>
      ${caveats.length ? `<h4>Caveats</h4><ul>${caveats.map((caveat) => `<li>${escapeHtml(readableValue(caveat))}</li>`).join('')}</ul>` : ''}
    `;
  }

  function setStatus(message, tone, link) {
    const status = panel.querySelector('[data-sentinel-status]');
    status.textContent = message;
    if (link && link.href) {
      const anchor = document.createElement('a');
      anchor.href = link.href;
      anchor.textContent = link.label || link.href;
      anchor.target = '_blank';
      anchor.rel = 'noopener';
      status.appendChild(anchor);
    }
    status.dataset.tone = tone || 'neutral';
  }

  function setUserSource(detection) {
    const hint = panel.querySelector('[data-sentinel-user-source]');
    if (!hint) {
      return;
    }
    if (!detection || (!detection.user && !detection.source)) {
      hint.textContent = 'No user detected on this page — type a name, click ↻, or Alt-click a user link.';
      hint.dataset.weak = 'false';
      return;
    }
    if (detection.source === 'manual') {
      hint.textContent = 'Entered manually.';
      hint.dataset.weak = 'false';
      return;
    }
    hint.textContent = `Detected from ${detection.source}${detection.weak ? ' — verify before posting' : ''}.`;
    hint.dataset.weak = detection.weak ? 'true' : 'false';
  }

  // Sets the user field from a detection (redetect button, Alt-click) and
  // re-runs everything downstream that depends on the user.
  function applyDetectedUser(detection) {
    lastDetection = detection;
    const field = panel.querySelector('[data-sentinel-user]');
    field.value = detection.user || '';
    setUserSource(detection);
    const pageField = panel.querySelector('[data-sentinel-warn-page]');
    if (pageField) {
      pageField.dataset.touched = 'false';
    }
    refreshPageSuggestions();
    updateWarningPreview();
  }

  function openSettings() {
    const dialog = panel.querySelector('[data-sentinel-settings]');
    dialog.hidden = false;
  }

  function closeSettings() {
    const dialog = panel.querySelector('[data-sentinel-settings]');
    dialog.hidden = true;
  }

  function updateWarningLevelHints() {
    const selected = panel.querySelector('[data-sentinel-warn-level]').value;
    panel.querySelectorAll('[data-sentinel-warn-select]').forEach((select) => {
      const hint = select.closest('.sentinel-action-control')?.querySelector('.sentinel-level-warning');
      if (!hint) {
        return;
      }
      const value = String(select.value || '');
      const warning = value.startsWith('std:')
        ? WARNINGS.find((item) => item.id === value.slice(4))
        : null;

      if (warning && requestedLevelExceedsWarning(warning, selected)) {
        hint.textContent = `Maximum warning level is ${maxNumericLevel(warning)}`;
        hint.hidden = false;
      } else {
        hint.textContent = '';
        hint.hidden = true;
      }
    });
  }

  function renderOptions(options) {
    return options.map((option) => `
      <option value="${escapeHtml(option.value || option.id)}" title="${escapeHtml(option.title || option.label)}">${escapeHtml(option.label)}</option>
    `).join('');
  }

  function renderActionControl({ title, selectAttr, buttonAttr, optionsHtml, emoji, ariaLabel, extraClass = '', hint = '' }) {
    return `
      <div class="sentinel-action-control ${extraClass}">
        <div class="sentinel-action-title">
          <strong>${escapeHtml(title)}</strong>
        </div>
        <div class="sentinel-action-row">
          <select ${selectAttr}>
            ${optionsHtml}
          </select>
          <button type="button" ${buttonAttr} title="${escapeHtml(ariaLabel)}" aria-label="${escapeHtml(ariaLabel)}">${emoji}</button>
        </div>
        ${hint || '<small class="sentinel-action-spacer" aria-hidden="true"></small>'}
      </div>
    `;
  }

  function renderTopicControl() {
    const options = TOPICS.map((topic) => ({
      id: topic.id,
      label: topic.shortLabel || topic.label,
      title: [topic.scope, ...topic.restrictions].filter(Boolean).join(' | ')
    }));
    return renderActionControl({
      title: 'CTOPS',
      selectAttr: 'data-sentinel-topic-select',
      buttonAttr: 'data-sentinel-issue-topic',
      optionsHtml: renderOptions(options),
      emoji: '🚨',
      ariaLabel: 'Issue selected CTOPS notice'
    });
  }

  // Leveled and single-issue warnings share each group's dropdown; the option
  // value encodes which posting path handles it ("std:<id>" or "cust:<id>").
  function mergedGroupOptions(group) {
    const standard = group.standardIds
      .map((id) => WARNINGS.find((warning) => warning.id === id))
      .filter(Boolean)
      .map((warning) => ({
        value: `std:${warning.id}`,
        label: warning.label,
        title: `{{${warning.template}}} — levels ${warning.levels.join(', ')}`
      }));
    const custom = CUSTOM_WARNINGS.filter((warning) => warning.group === group.id).flatMap((warning) => [
      { value: `cust:${warning.id}`, label: warning.label, title: warning.heading },
      ...((warning.options || []).map((option) => ({
        value: `cust:${option.id}`,
        label: `${warning.label}: ${option.label}`,
        title: option.heading
      })))
    ]);
    return { standard, custom };
  }

  function renderMergedSelectOptions({ standard, custom }) {
    if (standard.length && custom.length) {
      return `<optgroup label="Leveled warnings">${renderOptions(standard)}</optgroup>`
        + `<optgroup label="Single-issue notices">${renderOptions(custom)}</optgroup>`;
    }
    return renderOptions(standard.concat(custom));
  }

  function renderWarningControls() {
    return WARNING_GROUPS.map((group) => {
      const options = mergedGroupOptions(group);
      if (!options.standard.length && !options.custom.length) {
        return '';
      }
      return renderActionControl({
        title: group.label,
        selectAttr: 'data-sentinel-warn-select',
        buttonAttr: 'data-sentinel-issue-warning',
        optionsHtml: renderMergedSelectOptions(options),
        emoji: '📣',
        ariaLabel: `Issue selected ${group.label.toLowerCase()} warning or notice`,
        hint: '<small class="sentinel-level-warning" hidden></small>'
      });
    }).join('');
  }

  function renderBarnstarControl() {
    return renderActionControl({
      title: 'Barnstars',
      selectAttr: 'data-sentinel-barnstar-select',
      buttonAttr: 'class="sentinel-medal-button" data-sentinel-award-barnstar',
      optionsHtml: renderOptions(BARNSTARS),
      emoji: '🏅',
      ariaLabel: 'Award selected barnstar',
      extraClass: 'sentinel-barnstar-control'
    });
  }

  function renderActionControls() {
    return `
      <div class="sentinel-tool-grid sentinel-special-tools">
        ${renderTopicControl()}
        ${renderBarnstarControl()}
      </div>
      <div class="sentinel-tool-grid sentinel-warning-tools">
        ${renderWarningControls()}
      </div>
    `;
  }

  function renderTargetRow() {
    const here = (mw.config.get('wgPageName') || '').replace(/_/g, ' ');
    const detected = getLikelyUser();
    const isTalk = namespace >= 0 && namespace % 2 === 1;
    if (!here || !isTalk || here === userTalkTitle(detected)) {
      return '';
    }
    return `
      <label class="sentinel-inline">
        <input type="checkbox" data-sentinel-target-current>
        <span>Post to the page I'm on instead: <code>${escapeHtml(here)}</code></span>
      </label>
    `;
  }

  let activePreview = null;
  let lastDetection = null;
  let previewMode = 'rendered'; // 'rendered' | 'wikitext'
  let previewRequestId = 0;
  const PREVIEW_EMPTY_MESSAGE = 'Select a warning to preview it.';

  // Builds the exact wikitext a given action would post, plus a short label
  // and any "this field will be ignored" notes. Both preview tabs render from
  // this single source of truth, so what you preview is what gets posted.
  async function buildPreviewData(type, id) {
    const page = selectedWarningPage();
    const extraText = additionalWarningText();

    if (type === 'notice') {
      const topic = TOPICS.find((item) => item.id === id);
      if (!topic) {
        return null;
      }
      const user = getSelectedWarningUser();
      const firstCtAlert = topic.kind === 'ct' && user ? !(await hasPriorCtAlert(user)) : false;
      const noun = topic.kind === 'ct' ? 'CTOPS' : 'GS';
      const notes = [
        page ? `Note: the Page field is not used for ${noun} notices.` : '',
        extraText ? `Note: additional text is not appended to ${noun} notices.` : ''
      ].filter(Boolean).join('\n');
      return {
        label: noticeLabel(topic, firstCtAlert),
        wikitext: buildNoticeWikitext(topic, firstCtAlert),
        notes
      };
    }

    if (type === 'standard') {
      const warning = WARNINGS.find((item) => item.id === id);
      if (!warning) {
        return null;
      }
      const selected = panel.querySelector('[data-sentinel-warn-level]').value;
      const level = clampLevel(warning, selected);
      return {
        label: `${warning.label} — level ${level}${page ? ` re ${page}` : ''}`,
        wikitext: buildWarningWikitext(warning, level, page, extraText),
        notes: ''
      };
    }

    if (type === 'custom') {
      const warning = getCustomWarningOptions().find((item) => item.id === id);
      if (!warning) {
        return null;
      }
      return {
        label: `${warning.parentLabel ? `${warning.parentLabel}: ` : ''}${warning.label}${page ? ` re ${page}` : ''}`,
        wikitext: buildCustomWarningWikitext(warning, page, extraText),
        notes: ''
      };
    }

    if (type === 'barnstar') {
      const barnstar = BARNSTARS.find((item) => item.id === id);
      if (!barnstar) {
        return null;
      }
      return {
        label: barnstar.heading,
        wikitext: buildBarnstarWikitext(barnstar, extraText),
        notes: ''
      };
    }

    return null;
  }

  async function updateWarningPreview() {
    const renderedOut = panel.querySelector('[data-sentinel-preview-rendered]');
    const wikitextOut = panel.querySelector('[data-sentinel-preview]');
    if (!renderedOut || !wikitextOut) {
      return;
    }
    const requestId = ++previewRequestId;

    if (!activePreview) {
      renderedOut.textContent = PREVIEW_EMPTY_MESSAGE;
      renderedOut.dataset.state = 'empty';
      wikitextOut.textContent = PREVIEW_EMPTY_MESSAGE;
      wikitextOut.dataset.state = 'empty';
      return;
    }

    const visibleOut = previewMode === 'rendered' ? renderedOut : wikitextOut;
    visibleOut.textContent = 'Loading preview...';
    visibleOut.dataset.state = 'loading';

    const data = await buildPreviewData(activePreview.type, activePreview.id);
    if (requestId !== previewRequestId) {
      return;
    }
    if (!data || !data.wikitext) {
      visibleOut.textContent = 'No preview available.';
      visibleOut.dataset.state = 'empty';
      return;
    }

    const header = [data.label, data.notes].filter(Boolean).join('\n');
    wikitextOut.textContent = `${header}\n\n${data.wikitext.trim()}`;
    wikitextOut.dataset.state = 'ready';

    // Only spend a parse API call when the rendered tab is actually showing;
    // switching tabs re-runs this function, and parses are cached anyway.
    if (previewMode !== 'rendered') {
      return;
    }

    const html = await renderPreviewHtml(data.wikitext);
    if (requestId !== previewRequestId) {
      return;
    }
    if (!html) {
      renderedOut.textContent = 'Could not render preview — see the Wikitext tab.';
      renderedOut.dataset.state = 'empty';
      return;
    }
    renderedOut.innerHTML = `
      <div class="sentinel-preview-caption">${escapeHtml(data.label)}${data.notes ? `<br><span>${escapeHtml(data.notes)}</span>` : ''}</div>
      <div class="mw-parser-output">${html}</div>
    `;
    renderedOut.dataset.state = 'ready';
  }

  function setPreviewMode(mode) {
    previewMode = mode;
    panel.querySelectorAll('[data-sentinel-preview-tab]').forEach((tab) => {
      tab.classList.toggle('sentinel-tab-active', tab.dataset.sentinelPreviewTab === mode);
    });
    panel.querySelector('[data-sentinel-preview-rendered]').hidden = mode !== 'rendered';
    panel.querySelector('[data-sentinel-preview]').hidden = mode !== 'wikitext';
    updateWarningPreview();
  }

  function setActivePreview(type, id) {
    activePreview = { type, id };
    updateWarningPreview();
  }

  // ---- Panel state (position, open/closed, compact mode) -----------------

  let panelState = storageGet('panelState', {});

  function savePanelState(patch) {
    panelState = { ...panelState, ...patch };
    storageSet('panelState', panelState);
  }

  // Clamps so at least a grabbable corner of the header always stays on
  // screen, then switches the panel from right/bottom to left/top anchoring.
  function positionPanel(left, top) {
    const width = panel.offsetWidth || 720;
    const clampedLeft = Math.min(Math.max(left, -(width - 120)), Math.max(8, window.innerWidth - 120));
    const clampedTop = Math.min(Math.max(top, 0), Math.max(0, window.innerHeight - 48));
    panel.style.left = `${clampedLeft}px`;
    panel.style.top = `${clampedTop}px`;
    panel.style.right = 'auto';
    panel.style.bottom = 'auto';
  }

  // Persisted position is stored as RIGHT/BOTTOM offsets, never top/left.
  // The panel is bottom-anchored by design (it grows upward and hugs the
  // bottom edge regardless of its own height or the viewport); a stored top
  // coordinate converts it to top-anchored, which is the bug that made it
  // load mid-page. positionPanel (top/left) is used only for live tracking
  // during a drag; on release the position snaps back to bottom anchoring.
  function applyPanelOffsets(right, bottom) {
    const width = panel.offsetWidth || 720;
    const clampedRight = Math.min(Math.max(right, -(width - 120)), Math.max(8, window.innerWidth - 120));
    const clampedBottom = Math.min(Math.max(bottom, 0), Math.max(0, window.innerHeight - 48));
    panel.style.right = `${clampedRight}px`;
    panel.style.bottom = `${clampedBottom}px`;
    panel.style.left = 'auto';
    panel.style.top = 'auto';
  }

  function resetPanelPosition() {
    panel.style.left = '';
    panel.style.top = '';
    panel.style.right = '';
    panel.style.bottom = '';
    savePanelState({ left: null, top: null, right: null, bottom: null });
  }

  function makePanelDraggable() {
    const header = panel.querySelector('.sentinel-header');
    header.addEventListener('mousedown', (event) => {
      // Buttons in the header keep their click behavior.
      if (event.target.closest('button') || event.button !== 0) {
        return;
      }
      event.preventDefault();
      const rect = panel.getBoundingClientRect();
      const offsetX = event.clientX - rect.left;
      const offsetY = event.clientY - rect.top;
      const startX = event.clientX;
      const startY = event.clientY;
      let dragging = false;
      const onMove = (move) => {
        // A plain click must never count as a drag: require real movement
        // before tracking starts or anything is saved.
        if (!dragging && Math.abs(move.clientX - startX) + Math.abs(move.clientY - startY) < 5) {
          return;
        }
        dragging = true;
        positionPanel(move.clientX - offsetX, move.clientY - offsetY);
      };
      const onUp = () => {
        document.removeEventListener('mousemove', onMove);
        document.removeEventListener('mouseup', onUp);
        if (!dragging) {
          return;
        }
        const finalRect = panel.getBoundingClientRect();
        const right = window.innerWidth - finalRect.right;
        const bottom = window.innerHeight - finalRect.bottom;
        applyPanelOffsets(right, bottom);
        savePanelState({ right, bottom, left: null, top: null });
      };
      document.addEventListener('mousemove', onMove);
      document.addEventListener('mouseup', onUp);
    });
    header.addEventListener('dblclick', (event) => {
      if (event.target.closest('button')) {
        return;
      }
      resetPanelPosition();
      setStatus('Panel position reset to the default corner.', 'neutral');
    });
  }

  function setMiniMode(mini) {
    panel.classList.toggle('sentinel-mini', mini);
    const button = panel.querySelector('[data-sentinel-mini]');
    if (button) {
      button.textContent = mini ? '+' : '–';
      button.title = mini ? 'Expand Sentinel' : 'Toggle compact mode';
    }
    savePanelState({ mini });
  }

  function setPanelVisible(visible) {
    panel.hidden = !visible;
    savePanelState({ open: visible });
  }

  function restorePanelState() {
    if (panelState.mini) {
      setMiniMode(true);
    }
    if (typeof panelState.right === 'number' && typeof panelState.bottom === 'number') {
      applyPanelOffsets(panelState.right, panelState.bottom);
    }
    // Positions saved by 1.2.0 and earlier were top/left-anchored (the
    // mid-page-load bug) and could be written by a mere header click;
    // discard them so the panel returns to its bottom-right default.
    if (panelState.left != null || panelState.top != null) {
      savePanelState({ left: null, top: null });
    }
  }
  // -------------------------------------------------------------------------

  function buildPanel() {
    const initialDetection = detectUser();
    lastDetection = initialDetection;
    const el = document.createElement('aside');
    el.id = 'sentinel-panel';
    el.hidden = true;
    el.innerHTML = `
      <div class="sentinel-header" title="Drag to move Sentinel; double-click to reset position">
        <strong>Sentinel</strong>
        <button type="button" class="sentinel-header-link" data-sentinel-open-settings>Settings</button>
        <span>v${VERSION}</span>
        <button type="button" data-sentinel-mini title="Toggle compact mode" aria-label="Toggle compact mode">–</button>
        <button type="button" data-sentinel-close aria-label="Close Sentinel">x</button>
      </div>
      <label>
        <span>User</span>
        <span class="sentinel-user-row">
          <input data-sentinel-user type="text" value="${escapeHtml(initialDetection.user)}" placeholder="Username or IP">
          <button type="button" data-sentinel-redetect title="Re-detect the user from the current page" aria-label="Re-detect user">↻</button>
        </span>
        <small class="sentinel-user-source" data-sentinel-user-source></small>
      </label>
      ${renderTargetRow()}
      <section>
        <h3>Warnings and Notices</h3>
        <div class="sentinel-warn-controls">
          <label>
            <span>Level</span>
            <select data-sentinel-warn-level>
              <option value="1">1 – notice</option>
              <option value="2">2 – caution</option>
              <option value="3">3 – warning</option>
              <option value="4">4 – final</option>
              <option value="4im">4im – only</option>
            </select>
          </label>
          <label>
            <span>Page (optional)</span>
            <input data-sentinel-warn-page type="text" list="sentinel-page-options" value="${escapeHtml(getLikelyPage())}" placeholder="Article name">
            <datalist id="sentinel-page-options"></datalist>
          </label>
        </div>
        <label>
          <span>Additional text (optional)</span>
          <textarea data-sentinel-extra-text rows="3" placeholder="Optional note"></textarea>
        </label>
        <h4 class="sentinel-preview-heading">
          <span>Preview</span>
          <span class="sentinel-preview-tabs" role="tablist" aria-label="Preview mode">
            <button type="button" data-sentinel-preview-tab="rendered" class="sentinel-tab-active">Rendered</button>
            <button type="button" data-sentinel-preview-tab="wikitext">Wikitext</button>
          </span>
        </h4>
        <div class="sentinel-preview sentinel-preview-rendered" data-sentinel-preview-rendered data-state="empty">${PREVIEW_EMPTY_MESSAGE}</div>
        <pre class="sentinel-preview" data-sentinel-preview data-state="empty" hidden>${PREVIEW_EMPTY_MESSAGE}</pre>
        <h4 class="sentinel-tools-heading">
          <span>Notice and warning tools</span>
          <span class="sentinel-help" tabindex="0" aria-label="Notice and warning tools help">❔<span role="tooltip">CTOPS: choose the notice and click 🚨; the User box is the target. Warnings and notices: each category dropdown holds both leveled warnings and single-issue notices — choose one and click 📣. Level applies to leveled warnings only; Page/Additional text are optional. Barnstars: choose the award and click 🏅; Additional text is the award message. Selecting an item updates the preview; the Rendered tab shows the message as the recipient will see it.</span></span>
        </h4>
        ${renderActionControls()}
      </section>
      <button type="button" data-sentinel-ai>Analyze recent edits with AI</button>
      <div data-sentinel-analysis class="sentinel-analysis"></div>
      <p data-sentinel-status class="sentinel-status" data-tone="neutral">Ready.</p>
      <div data-sentinel-settings class="sentinel-settings" hidden>
        <h3>Settings</h3>
        <label><input type="checkbox" data-setting="aiEnabled"> Enable AI analysis</label>
        <label><input type="checkbox" data-setting="dryRun"> Dry run edits</label>
        <label><input type="checkbox" data-setting="requireConfirm"> Confirm before posting</label>
        <label>
          <span>Watchlist behavior for posted pages</span>
          <select data-setting="watchlist">
            <option value="nochange">No change</option>
            <option value="watch">Watch the talk page</option>
            <option value="preferences">Use my preferences</option>
          </select>
        </label>
        <label><span>Endpoint</span><input type="text" data-setting="aiEndpoint"></label>
        <label>
          <span>API format</span>
          <select data-setting="aiFormat">
            <option value="anthropic">Anthropic (api.anthropic.com)</option>
            <option value="openai">OpenAI-compatible (requires self-hosted relay; none exists yet)</option>
          </select>
        </label>
        <label>
          <span>Model</span>
          <select data-sentinel-model-select></select>
        </label>
        <label data-sentinel-custom-model-row hidden>
          <span>Custom model ID</span>
          <input type="text" data-sentinel-custom-model placeholder="Exact model string, sent verbatim">
        </label>
        <label>
          <span>Reasoning</span>
          <select data-setting="aiReasoning">
            <option value="off">Off — cheapest, fastest (default)</option>
            <option value="low">Low</option>
            <option value="medium">Medium</option>
            <option value="high">High — most thorough, priciest</option>
          </select>
        </label>
        <label>
          <span>Verification (web search)</span>
          <select data-setting="aiVerification">
            <option value="off">Off — pattern analysis only, cheapest</option>
            <option value="standard">Standard — searches as needed (default)</option>
            <option value="thorough">Thorough — search required before sourcing flags, priciest</option>
          </select>
        </label>
        <label><span>API key</span><input type="password" data-sentinel-api-key autocomplete="off"></label>
        <label>
          <span>Remember API key</span>
          <select data-setting="apiKeyStorage">
            <option value="none">Off — this page view only (default)</option>
            <option value="session">While tab open</option>
            <option value="local">On — until removed (persistent)</option>
          </select>
        </label>
        <label><span>Edit limit</span><input type="number" min="1" max="25" data-setting="aiEditLimit"></label>
        <div class="sentinel-criteria">
          <h4>Page rules for AI analysis</h4>
          <p class="sentinel-note">Rules added here ride along with any analyzed edit on that page, so the AI can check edits against local consensus it has no other way of knowing — list inclusion criteria, sourcing RfC outcomes, and the like. Write them in plain English, the way you'd explain them on a talk page. Changes here save immediately.</p>
          <div data-sentinel-criteria-list class="sentinel-criteria-list"></div>
          <label>
            <span>Page</span>
            <input type="text" data-sentinel-criteria-page placeholder="Exact page title">
          </label>
          <label>
            <span>Rule</span>
            <input type="text" data-sentinel-criteria-rule placeholder="e.g. List entries must have a standalone article">
          </label>
          <button type="button" data-sentinel-criteria-addbtn>Add rule</button>
        </div>
        <div class="sentinel-actions">
          <button type="button" data-sentinel-save-settings>Save</button>
          <button type="button" data-sentinel-close-settings>Close</button>
        </div>
        <p class="sentinel-note">AI requests send recent diff text and metadata to the configured endpoint; use only when that is acceptable for your workflow. The endpoint must be on Wikipedia's CSP connect allowlist (api.anthropic.com is; openrouter.ai is not). The API key is never saved with these settings: by default it is held for the current page view only and you'll be asked again after navigating. The "While tab open" and persistent options trade security for convenience — anything in browser storage is readable by every other user script and gadget you run. Whatever tier you pick, use a dedicated key with a hard spend cap. The OpenAI-compatible format only works through a relay on an allowlisted host (api.openai.com itself blocks browser requests); until such a relay exists, use the Anthropic format. Reasoning above Off multiplies cost and latency — Off with the cheapest model is right for routine triage. Verification lets the model run web searches before alleging hoaxes or unverifiable claims; without it, anything more recent than the model's training data tends to be falsely flagged. Searches are billed by the provider (roughly US$10 per thousand on Anthropic) plus the retrieved content as tokens, and apply to the Anthropic format only until a relay exists.</p>
      </div>
    `;
    document.body.appendChild(el);
    return el;
  }

  // ---- Page-rule editor ---------------------------------------------------
  // Friendly editing surface over settings.customPageCriteria. The storage
  // shape is unchanged ({ canonical_title: [rule, ...] }), so existing data
  // and the analysis pipeline are untouched; only the UI is new. Edits here
  // save immediately rather than waiting for the Save button, so a rule list
  // can't be lost by closing the settings panel.
  function displayTitleFromKey(key) {
    return String(key).replace(/_/g, ' ');
  }

  function renderCriteriaList() {
    const container = panel.querySelector('[data-sentinel-criteria-list]');
    if (!container) {
      return;
    }
    const criteria = settings.customPageCriteria || {};
    const keys = Object.keys(criteria).sort();
    if (!keys.length) {
      container.innerHTML = '<p class="sentinel-note">No page rules yet.</p>';
      return;
    }
    container.innerHTML = keys.map((key) => {
      const rules = Array.isArray(criteria[key]) ? criteria[key] : [];
      const items = rules.map((rule, index) => `
        <li>
          <span>${escapeHtml(rule)}</span>
          <button type="button" data-sentinel-criteria-delete data-page="${escapeHtml(key)}" data-index="${index}" title="Delete this rule" aria-label="Delete this rule">×</button>
        </li>`).join('');
      return `
        <div class="sentinel-criteria-page">
          <div class="sentinel-criteria-page-head">
            <strong>${escapeHtml(displayTitleFromKey(key))}</strong>
            <button type="button" data-sentinel-criteria-deletepage data-page="${escapeHtml(key)}" title="Delete all rules for this page" aria-label="Delete all rules for this page">Remove page</button>
          </div>
          <ul>${items}</ul>
        </div>`;
    }).join('');
  }

  function addCriterion() {
    const pageField = panel.querySelector('[data-sentinel-criteria-page]');
    const ruleField = panel.querySelector('[data-sentinel-criteria-rule]');
    const pageTitle = pageField.value.trim();
    const rule = ruleField.value.trim();
    if (!pageTitle || !rule) {
      setStatus('A page rule needs both a page title and the rule itself.', 'error');
      return;
    }
    if (mw.Title && !mw.Title.newFromText(pageTitle)) {
      setStatus(`"${pageTitle}" is not a valid page title.`, 'error');
      return;
    }
    const key = canonicalTitleKey(pageTitle);
    if (!settings.customPageCriteria || typeof settings.customPageCriteria !== 'object') {
      settings.customPageCriteria = {};
    }
    const rules = Array.isArray(settings.customPageCriteria[key]) ? settings.customPageCriteria[key] : [];
    if (rules.includes(rule)) {
      setStatus('That exact rule is already set for this page.', 'error');
      return;
    }
    rules.push(rule);
    settings.customPageCriteria[key] = rules;
    saveSettings();
    ruleField.value = '';
    renderCriteriaList();
    setStatus(`Rule added for ${displayTitleFromKey(key)} — saved.`, 'success');
  }

  function deleteCriterion(key, index) {
    const rules = settings.customPageCriteria && settings.customPageCriteria[key];
    if (!Array.isArray(rules) || index < 0 || index >= rules.length) {
      return;
    }
    rules.splice(index, 1);
    if (!rules.length) {
      delete settings.customPageCriteria[key];
    }
    saveSettings();
    renderCriteriaList();
    setStatus('Rule deleted — saved.', 'success');
  }

  async function deleteCriteriaPage(key) {
    const rules = (settings.customPageCriteria && settings.customPageCriteria[key]) || [];
    if (!rules.length) {
      return;
    }
    if (rules.length > 1) {
      const accepted = await sentinelConfirm({
        title: 'Remove all rules for this page?',
        message: `Delete all ${rules.length} rules for ${displayTitleFromKey(key)}?`,
        confirmLabel: 'Delete all',
        fallbackText: `Delete all ${rules.length} rules for ${displayTitleFromKey(key)}?`
      });
      if (!accepted) {
        return;
      }
    }
    delete settings.customPageCriteria[key];
    saveSettings();
    renderCriteriaList();
    setStatus(`All rules removed for ${displayTitleFromKey(key)} — saved.`, 'success');
  }
  // -------------------------------------------------------------------------

  // Fills the model dropdown with the catalog for the chosen API format,
  // selecting `preferred` when it's a known model and falling back to the
  // Custom entry (with the free-text row revealed) when it isn't — so any
  // model string survives round-trips even if it's not in the curated list.
  function populateModelSelect(preferred) {
    const select = panel.querySelector('[data-sentinel-model-select]');
    const customRow = panel.querySelector('[data-sentinel-custom-model-row]');
    const customInput = panel.querySelector('[data-sentinel-custom-model]');
    if (!select || !customRow || !customInput) {
      return;
    }
    const formatField = panel.querySelector('[data-setting="aiFormat"]');
    const format = (formatField && formatField.value) || settings.aiFormat || 'anthropic';
    const catalog = AI_MODELS[format] || [];
    const wanted = preferred || settings.aiModel || DEFAULT_AI_MODEL[format] || '';
    select.innerHTML = catalog.map((model) => `<option value="${escapeHtml(model.id)}" title="${escapeHtml(model.id)}">${escapeHtml(model.label)}</option>`).join('')
      + '<option value="custom">Custom model ID…</option>';
    const known = catalog.some((model) => model.id === wanted);
    select.value = known ? wanted : 'custom';
    customRow.hidden = known;
    customInput.value = known ? '' : wanted;
  }

  function hydrateSettingsForm() {
    panel.querySelectorAll('[data-setting]').forEach((field) => {
      const key = field.dataset.setting;
      if (field.type === 'checkbox') {
        field.checked = Boolean(settings[key]);
      } else if (field.tagName === 'TEXTAREA') {
        field.value = JSON.stringify(settings[key] || {}, null, 2);
      } else {
        field.value = settings[key] || '';
      }
    });
    // The key is deliberately not in the settings blob; show whatever copy
    // the current storage tier provides.
    const keyField = panel.querySelector('[data-sentinel-api-key]');
    if (keyField) {
      if (!sessionApiKey) {
        sessionApiKey = readStoredApiKey();
      }
      keyField.value = sessionApiKey;
    }
    populateModelSelect(settings.aiModel);
    renderCriteriaList();
  }

  function readSettingsForm() {
    panel.querySelectorAll('[data-setting]').forEach((field) => {
      const key = field.dataset.setting;
      if (field.type === 'checkbox') {
        settings[key] = field.checked;
      } else if (field.tagName === 'TEXTAREA') {
        try {
          settings[key] = JSON.parse(field.value || '{}');
        } catch (error) {
          setStatus(`Invalid JSON for ${key}: ${error.message}`, 'error');
          throw error;
        }
      } else if (field.type === 'number') {
        settings[key] = Number(field.value);
      } else {
        settings[key] = field.value.trim();
      }
    });
    // Normalize criteria keys so "Magazine (firearms)" and
    // "Magazine_(firearms)" address the same entry.
    if (settings.customPageCriteria && typeof settings.customPageCriteria === 'object') {
      const normalized = {};
      Object.keys(settings.customPageCriteria).forEach((key) => {
        normalized[canonicalTitleKey(key)] = settings.customPageCriteria[key];
      });
      settings.customPageCriteria = normalized;
    }
    // Model comes from the dropdown, or the custom field when Custom is
    // chosen; an empty custom field falls back to the format's default.
    const modelSelect = panel.querySelector('[data-sentinel-model-select]');
    if (modelSelect) {
      const customValue = (panel.querySelector('[data-sentinel-custom-model]')?.value || '').trim();
      settings.aiModel = modelSelect.value === 'custom'
        ? (customValue || DEFAULT_AI_MODEL[settings.aiFormat] || DEFAULT_SETTINGS.aiModel)
        : modelSelect.value;
      settings.aiModelChosen = true;
    }
    // The key field is handled outside the settings blob, AFTER the loop so
    // a tier change made in the same save is already in settings and the key
    // lands in (only) the newly chosen tier.
    const keyField = panel.querySelector('[data-sentinel-api-key]');
    if (keyField) {
      sessionApiKey = keyField.value.trim();
      writeStoredApiKey(sessionApiKey);
    }
    saveSettings();
    setStatus('Settings saved.', 'success');
  }

  function bindPanel() {
    const refreshPreview = () => updateWarningPreview();
    // Text inputs trigger a parse round-trip per preview rebuild, so debounce
    // them; the hover path already has its own delay.
    const refreshPreviewDebounced = debounce(refreshPreview, 300);
    panel.querySelector('[data-sentinel-close]').addEventListener('click', () => {
      setPanelVisible(false);
    });
    panel.querySelector('[data-sentinel-mini]').addEventListener('click', () => {
      setMiniMode(!panel.classList.contains('sentinel-mini'));
    });
    makePanelDraggable();
    // AI analysis is disabled for now; guard the lookup so re-enabling the
    // button later (see buildPanel) just works without errors meanwhile.
    const aiButton = panel.querySelector('[data-sentinel-ai]');
    if (aiButton) {
      aiButton.addEventListener('click', () => {
        runAiAnalysis().catch((error) => {
          console.error(`[${APP}] AI analysis failed`, error);
          setStatus(`AI analysis failed: ${error.message || error}`, 'error');
        });
      });
    }
    panel.querySelector('[data-sentinel-open-settings]').addEventListener('click', () => {
      hydrateSettingsForm();
      openSettings();
    });
    panel.querySelector('[data-sentinel-save-settings]').addEventListener('click', readSettingsForm);
    panel.querySelector('[data-sentinel-close-settings]').addEventListener('click', closeSettings);
    panel.querySelectorAll('[data-sentinel-preview-tab]').forEach((tab) => {
      tab.addEventListener('click', () => setPreviewMode(tab.dataset.sentinelPreviewTab));
    });
    panel.querySelector('[data-sentinel-redetect]').addEventListener('click', () => {
      applyDetectedUser(detectUser());
    });
    panel.querySelector('[data-sentinel-user]').addEventListener('input', () => {
      lastDetection = { user: panel.querySelector('[data-sentinel-user]').value.trim(), source: 'manual' };
      setUserSource(lastDetection);
    });
    panel.querySelector('[data-sentinel-user]').addEventListener('change', () => {
      const field = panel.querySelector('[data-sentinel-warn-page]');
      if (field) {
        field.dataset.touched = 'false';
      }
      refreshPageSuggestions();
      refreshPreview();
    });
    panel.querySelector('[data-sentinel-warn-level]').addEventListener('change', () => {
      updateWarningLevelHints();
      refreshPreview();
    });
    panel.querySelector('[data-sentinel-warn-page]').addEventListener('input', (event) => {
      event.target.dataset.touched = 'true';
      refreshPreviewDebounced();
    });
    panel.querySelector('[data-sentinel-extra-text]').addEventListener('input', refreshPreviewDebounced);

    // Preview updates on explicit selection (change) or on clicking the issue
    // button — not on hover or focus, so stray mouseovers can't swap the pane.
    function bindDropdownAction(selectSelector, buttonSelector, resolveAction, errorLabel) {
      panel.querySelectorAll(buttonSelector).forEach((button) => {
        const control = button.closest('.sentinel-action-control');
        const select = control && control.querySelector(selectSelector);
        if (!select) {
          return;
        }
        const previewSelected = () => {
          const action = resolveAction(select.value);
          if (action) {
            setActivePreview(action.previewType, action.id);
          }
        };
        select.addEventListener('change', () => {
          previewSelected();
          updateWarningLevelHints();
        });
        button.addEventListener('click', () => {
          previewSelected();
          const action = resolveAction(select.value);
          if (!action) {
            return;
          }
          action.execute(action.id).catch((error) => {
            console.error(`[${APP}] ${errorLabel} failed`, error);
            setStatus(`${errorLabel} failed: ${error.message || error}`, 'error');
          });
        });
      });
    }

    // Merged warning dropdowns: "std:<id>" routes to the leveled-warning
    // path, "cust:<id>" to the single-issue path.
    const resolveWarnAction = (value) => {
      const raw = String(value || '');
      const sep = raw.indexOf(':');
      if (sep === -1) {
        return null;
      }
      const kind = raw.slice(0, sep);
      const id = raw.slice(sep + 1);
      if (kind === 'std') {
        return { previewType: 'standard', id, execute: issueWarning };
      }
      if (kind === 'cust') {
        return { previewType: 'custom', id, execute: issueCustomWarning };
      }
      return null;
    };

    bindDropdownAction('[data-sentinel-topic-select]', '[data-sentinel-issue-topic]', (value) => ({ previewType: 'notice', id: value, execute: issueNotice }), 'Notice');
    bindDropdownAction('[data-sentinel-warn-select]', '[data-sentinel-issue-warning]', resolveWarnAction, 'Warning');
    bindDropdownAction('[data-sentinel-barnstar-select]', '[data-sentinel-award-barnstar]', (value) => ({ previewType: 'barnstar', id: value, execute: async () => issueBarnstar() }), 'Barnstar');
    const criteriaAdd = panel.querySelector('[data-sentinel-criteria-addbtn]');
    if (criteriaAdd) {
      criteriaAdd.addEventListener('click', addCriterion);
      ['[data-sentinel-criteria-page]', '[data-sentinel-criteria-rule]'].forEach((selector) => {
        panel.querySelector(selector).addEventListener('keydown', (event) => {
          if (event.key === 'Enter') {
            event.preventDefault();
            addCriterion();
          }
        });
      });
    }
    const criteriaList = panel.querySelector('[data-sentinel-criteria-list]');
    if (criteriaList) {
      criteriaList.addEventListener('click', (event) => {
        const ruleDelete = event.target.closest('[data-sentinel-criteria-delete]');
        if (ruleDelete) {
          deleteCriterion(ruleDelete.dataset.page, Number(ruleDelete.dataset.index));
          return;
        }
        const pageDelete = event.target.closest('[data-sentinel-criteria-deletepage]');
        if (pageDelete) {
          deleteCriteriaPage(pageDelete.dataset.page).catch((error) => {
            console.error(`[${APP}] Rule deletion failed`, error);
          });
        }
      });
    }
    const formatSelect = panel.querySelector('[data-setting="aiFormat"]');
    if (formatSelect) {
      formatSelect.addEventListener('change', () => {
        populateModelSelect(DEFAULT_AI_MODEL[formatSelect.value]);
      });
    }
    const modelSelect = panel.querySelector('[data-sentinel-model-select]');
    if (modelSelect) {
      modelSelect.addEventListener('change', () => {
        const customRow = panel.querySelector('[data-sentinel-custom-model-row]');
        if (customRow) {
          customRow.hidden = modelSelect.value !== 'custom';
        }
      });
    }
    // Moving the key to a longer-lived storage tier requires acknowledging
    // the shared-origin exposure; moving to a shorter-lived tier never does.
    const tierSelect = panel.querySelector('[data-setting="apiKeyStorage"]');
    if (tierSelect) {
      const tierRank = { none: 0, session: 1, local: 2 };
      tierSelect.addEventListener('change', async () => {
        const next = tierSelect.value;
        const current = settings.apiKeyStorage || 'none';
        if ((tierRank[next] || 0) <= (tierRank[current] || 0)) {
          return;
        }
        const messages = {
          session: 'While the tab stays open, the key will sit in sessionStorage, which every other user script and gadget you run can read. The exposure window is the life of the tab rather than forever, but the shared-origin exposure is the same while it lasts.',
          local: 'The key will be saved unencrypted in localStorage for en.wikipedia.org until you remove it, and every other user script and gadget you run can read it at any time. Only do this with a dedicated key that has a hard spend cap.'
        };
        const accepted = await sentinelConfirm({
          title: 'Lower API key protection?',
          message: messages[next] || '',
          confirmLabel: 'I understand the risk',
          fallbackText: `${messages[next] || ''}\n\nContinue?`
        });
        if (!accepted) {
          tierSelect.value = current;
        }
      });
    }
    updateWarningLevelHints();
    setUserSource(lastDetection);
    if (legacyAiMigrated) {
      setStatus('AI settings updated for 1.0: the API key is no longer saved with settings (see "Remember API key"), and the endpoint was reset to api.anthropic.com — Wikipedia\'s CSP blocks OpenRouter.', 'neutral');
    } else {
      setStatus(settings.dryRun
        ? 'Ready (dry-run mode: edits are simulated — change in Settings).'
        : 'Ready.', 'neutral');
    }
    updateWarningPreview();
    refreshPageSuggestions();
  }

  function injectStyles() {
    mw.util.addCSS(`
      #sentinel-panel {
        --sentinel-bg: var(--background-color-base, #fff);
        --sentinel-fg: var(--color-base, #202122);
        --sentinel-border: var(--border-color-base, #a2a9b1);
        --sentinel-border-strong: var(--border-color-interactive, #72777d);
        --sentinel-border-subtle: var(--border-color-subtle, #c8ccd1);
        --sentinel-surface: var(--background-color-neutral-subtle, #f8f9fa);
        --sentinel-divider: var(--background-color-neutral, #eaecf0);
        --sentinel-muted: var(--color-subtle, #54595d);
        --sentinel-link: var(--color-progressive, #36c);
        --sentinel-link-hover: var(--color-progressive--hover, #447ff5);
        --sentinel-error: var(--color-destructive, #d33);
        --sentinel-success: var(--color-success, #14866d);
        --sentinel-warn: var(--color-warning, #ac6600);
        --sentinel-inverted: var(--color-inverted, #fff);
        position: fixed;
        right: 16px;
        bottom: 16px;
        z-index: 10000;
        width: min(720px, calc(100vw - 32px));
        max-height: calc(100vh - 32px);
        overflow-y: auto;
        overflow-x: hidden;
        scrollbar-gutter: stable;
        padding: 14px;
        background: var(--sentinel-bg);
        color: var(--sentinel-fg);
        border: 1px solid var(--sentinel-border);
        box-shadow: 0 8px 24px rgba(0, 0, 0, .22);
        font-size: 14px;
      }
      #sentinel-panel[hidden] { display: none; }
      #sentinel-panel button {
        border: 1px solid var(--sentinel-border);
        background: var(--sentinel-surface);
        color: var(--sentinel-fg);
        padding: 3px 6px;
        cursor: pointer;
        border-radius: 2px;
        font-size: 12px;
        line-height: 1.15;
      }
      #sentinel-panel button:hover { background: var(--sentinel-bg); border-color: var(--sentinel-border-strong); }
      .sentinel-header {
        display: grid;
        grid-template-columns: 1fr auto auto auto auto;
        gap: 8px;
        align-items: center;
        margin-bottom: 12px;
        cursor: move;
        user-select: none;
      }
      .sentinel-header strong { font-size: 18px; }
      #sentinel-panel.sentinel-mini { width: 340px; }
      #sentinel-panel.sentinel-mini section,
      #sentinel-panel.sentinel-mini .sentinel-inline,
      #sentinel-panel.sentinel-mini .sentinel-settings,
      #sentinel-panel.sentinel-mini .sentinel-analysis { display: none; }
      .sentinel-dialog-preview {
        max-height: 55vh;
        overflow: auto;
        margin-top: 8px;
        padding: 8px;
        border: 1px solid var(--border-color-subtle, #c8ccd1);
        background: var(--background-color-base, #fff);
        text-align: left;
      }
      .sentinel-dialog-body { text-align: left; }
      #sentinel-panel .sentinel-header-link {
        border: 0;
        background: transparent;
        color: var(--sentinel-link);
        padding: 0;
        font-size: 12px;
        text-decoration: underline;
      }
      #sentinel-panel .sentinel-header-link:hover {
        background: transparent;
        border: 0;
        color: var(--sentinel-link-hover);
      }
      #sentinel-panel label { display: grid; gap: 4px; margin: 8px 0; }
      #sentinel-panel label[hidden] { display: none; }
      #sentinel-panel input[type="text"],
      #sentinel-panel input[type="password"],
      #sentinel-panel input[type="number"],
      #sentinel-panel textarea {
        width: 100%;
        box-sizing: border-box;
        border: 1px solid var(--sentinel-border);
        padding: 6px;
      }
      #sentinel-panel textarea {
        resize: vertical;
        font-family: monospace;
        font-size: 12px;
      }
      #sentinel-panel h3 {
        margin: 14px 0 8px;
        padding-bottom: 4px;
        border-bottom: 1px solid var(--sentinel-divider);
        font-size: 15px;
      }
      #sentinel-panel h4 { margin: 10px 0 4px; font-size: 13px; }
      .sentinel-user-row {
        display: flex;
        align-items: stretch;
        gap: 4px;
      }
      .sentinel-user-row input { flex: 1; min-width: 0; }
      #sentinel-panel .sentinel-user-row button {
        flex: 0 0 30px;
        font-size: 14px;
        text-align: center;
      }
      .sentinel-user-source {
        min-height: 14px;
        color: var(--sentinel-muted);
        font-size: 11px;
      }
      .sentinel-user-source[data-weak="true"] {
        color: var(--sentinel-warn);
        font-weight: 700;
      }
      .sentinel-tools-heading {
        display: flex;
        align-items: center;
        gap: 4px;
      }
      .sentinel-preview-heading {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 8px;
      }
      .sentinel-preview-tabs {
        display: inline-flex;
      }
      #sentinel-panel .sentinel-preview-tabs button {
        border-radius: 0;
        font-size: 11px;
        padding: 2px 8px;
      }
      #sentinel-panel .sentinel-preview-tabs button:first-child {
        border-radius: 2px 0 0 2px;
      }
      #sentinel-panel .sentinel-preview-tabs button:last-child {
        border-radius: 0 2px 2px 0;
        border-left: 0;
      }
      #sentinel-panel .sentinel-preview-tabs button.sentinel-tab-active {
        background: var(--sentinel-link);
        border-color: var(--sentinel-link);
        color: var(--sentinel-inverted);
      }
      .sentinel-tool-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        column-gap: 8px;
        row-gap: 8px;
      }
      .sentinel-special-tools {
        margin-bottom: 12px;
      }
      .sentinel-warning-tools {
        row-gap: 8px;
      }
      .sentinel-action-control {
        min-width: 0;
      }
      .sentinel-action-title {
        display: flex;
        align-items: center;
        gap: 4px;
        margin: 0 0 4px;
        font-size: 13px;
      }
      .sentinel-action-row {
        display: flex;
        align-items: stretch;
        gap: 4px;
      }
      .sentinel-action-row select {
        min-width: 0;
        min-height: 26px;
        font-size: 12px;
      }
      #sentinel-panel .sentinel-action-row button {
        flex: 0 0 30px;
        min-height: 26px;
        padding: 2px 4px;
        font-size: 15px;
        line-height: 1;
        text-align: center;
      }
      .sentinel-action-hint,
      .sentinel-action-spacer,
      .sentinel-action-control .sentinel-level-warning {
        display: block;
        min-height: 14px;
        margin-top: 2px;
        color: var(--sentinel-error);
        font-size: 11px;
        font-weight: 700;
      }
      .sentinel-action-control .sentinel-level-warning[hidden] {
        display: block;
        visibility: hidden;
      }
      .sentinel-action-spacer {
        visibility: hidden;
      }
      .sentinel-topic-grid {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 4px;
      }
      .sentinel-topic-grid.sentinel-compact-grid {
        grid-template-columns: repeat(4, minmax(0, 1fr));
      }
      .sentinel-topic-grid.sentinel-half-grid {
        grid-template-columns: repeat(2, minmax(0, 1fr));
      }
      .sentinel-topic-grid button {
        display: grid;
        gap: 1px;
        min-height: 26px;
        align-items: center;
        text-align: left;
      }
      .sentinel-topic-grid button.sentinel-inline-code-button {
        display: flex;
        flex-wrap: wrap;
        min-height: 26px;
        align-content: center;
        align-items: baseline;
        column-gap: 5px;
        row-gap: 1px;
      }
      .sentinel-topic-grid button.sentinel-suboption {
        border-left: 3px solid var(--sentinel-link);
        padding-left: 8px;
      }
      .sentinel-topic-grid small { color: var(--sentinel-muted); }
      .sentinel-topic-grid button span {
        overflow-wrap: anywhere;
        line-height: 1.2;
      }
      .sentinel-topic-grid .sentinel-level-warning {
        flex-basis: 100%;
        color: var(--sentinel-error);
        font-weight: 700;
      }
      .sentinel-warn-controls { display: flex; gap: 8px; margin-bottom: 8px; }
      .sentinel-warn-controls label { margin: 0; flex: 1; }
      .sentinel-bottom-tools {
        display: grid;
        grid-template-columns: repeat(2, minmax(0, 1fr));
        gap: 8px;
        align-items: start;
      }
      .sentinel-bottom-tool h4 {
        margin-top: 10px;
      }
      .sentinel-barnstar-control { margin: 0; }
      #sentinel-panel .sentinel-medal-button {
        font-size: 15px;
      }
      .sentinel-help {
        position: relative;
        display: inline-flex;
        align-items: center;
        cursor: help;
      }
      .sentinel-help [role="tooltip"] {
        position: absolute;
        left: 18px;
        top: 0;
        z-index: 1;
        display: none;
        width: 320px;
        padding: 6px;
        border: 1px solid var(--sentinel-border);
        background: var(--sentinel-bg);
        box-shadow: 0 2px 8px rgba(0, 0, 0, .16);
        color: var(--sentinel-fg);
        font-size: 12px;
        font-weight: 400;
        line-height: 1.25;
      }
      .sentinel-help:hover [role="tooltip"],
      .sentinel-help:focus [role="tooltip"] {
        display: block;
      }
      #sentinel-panel select {
        width: 100%;
        box-sizing: border-box;
        border: 1px solid var(--sentinel-border);
        padding: 6px;
      }
      .sentinel-inline { display: flex !important; align-items: flex-start; gap: 6px; }
      .sentinel-inline code { word-break: break-all; }
      .sentinel-actions { display: flex; flex-wrap: wrap; gap: 6px; }
      .sentinel-preview {
        height: 150px;
        resize: vertical;
        overflow: auto;
        white-space: pre-wrap;
        overflow-wrap: anywhere;
        word-break: break-word;
        border: 1px solid var(--sentinel-border-subtle);
        background: var(--sentinel-surface);
        padding: 8px;
        font-family: monospace;
        font-size: 12px;
        margin: 0 0 8px;
      }
      .sentinel-preview[data-state="empty"] {
        color: var(--sentinel-muted);
      }
      .sentinel-preview[data-state="loading"] {
        color: var(--sentinel-muted);
      }
      .sentinel-preview[hidden] { display: none; }
      .sentinel-preview-rendered {
        white-space: normal;
        font-family: sans-serif;
        font-size: 13px;
        background: var(--sentinel-bg);
      }
      .sentinel-preview-rendered .mw-parser-output {
        overflow-wrap: anywhere;
      }
      .sentinel-preview-caption {
        margin-bottom: 6px;
        padding-bottom: 4px;
        border-bottom: 1px solid var(--sentinel-divider);
        color: var(--sentinel-muted);
        font-size: 12px;
        white-space: pre-line;
      }
      .sentinel-analysis {
        margin-top: 8px;
        max-height: 280px;
        overflow: auto;
        background: var(--sentinel-surface);
        border: 1px solid var(--sentinel-divider);
        padding: 8px;
      }
      .sentinel-analysis:empty { display: none; }
      .sentinel-result-head {
        display: flex;
        justify-content: space-between;
        gap: 8px;
      }
      .sentinel-status {
        margin: 12px 0 0;
        padding: 8px;
        border-left: 4px solid var(--sentinel-link);
        background: var(--sentinel-surface);
      }
      .sentinel-status[data-tone="success"] { border-color: var(--sentinel-success); }
      .sentinel-status[data-tone="error"] { border-color: var(--sentinel-error); }
      .sentinel-settings {
        margin-top: 12px;
        padding: 10px;
        border: 1px solid var(--sentinel-border-subtle);
        background: var(--sentinel-surface);
      }
      .sentinel-note { color: var(--sentinel-muted); font-size: 12px; }
      .sentinel-issue-list { list-style: none; margin: 0; padding: 0; }
      .sentinel-issue { margin: 0 0 10px; }
      .sentinel-issue-head { margin-bottom: 2px; }
      .sentinel-issue-revs,
      .sentinel-issue-verification { display: block; color: var(--sentinel-muted); }
      .sentinel-severity {
        display: inline-block;
        margin-right: 6px;
        padding: 0 6px;
        border-radius: 8px;
        font-size: 11px;
        font-weight: 700;
        text-transform: uppercase;
        color: var(--sentinel-inverted);
      }
      .sentinel-severity[data-severity="high"] { background: var(--sentinel-error); }
      .sentinel-severity[data-severity="medium"] { background: var(--sentinel-warn); }
      .sentinel-severity[data-severity="low"] { background: var(--sentinel-muted); }
      .sentinel-criteria { margin-top: 12px; padding-top: 8px; border-top: 1px solid var(--sentinel-divider); }
      .sentinel-criteria h4 { margin-top: 0; }
      .sentinel-criteria-list { display: grid; gap: 8px; margin: 8px 0; }
      .sentinel-criteria-page {
        padding: 6px 8px;
        border: 1px solid var(--sentinel-divider);
        background: var(--sentinel-bg);
      }
      .sentinel-criteria-page-head {
        display: flex;
        align-items: center;
        justify-content: space-between;
        gap: 8px;
      }
      .sentinel-criteria-page-head strong { overflow-wrap: anywhere; }
      .sentinel-criteria-page ul { list-style: none; margin: 6px 0 0; padding: 0; }
      .sentinel-criteria-page li {
        display: flex;
        align-items: baseline;
        justify-content: space-between;
        gap: 8px;
        margin: 2px 0;
      }
      .sentinel-criteria-page li span { overflow-wrap: anywhere; }
      #sentinel-panel .sentinel-criteria button { flex: 0 0 auto; }
      @media (max-width: 520px) {
        #sentinel-panel { right: 8px; bottom: 8px; width: calc(100vw - 16px); }
        .sentinel-tool-grid { grid-template-columns: 1fr; }
        .sentinel-bottom-tools { grid-template-columns: 1fr; }
        .sentinel-topic-grid,
        .sentinel-topic-grid.sentinel-compact-grid,
        .sentinel-topic-grid.sentinel-half-grid { grid-template-columns: 1fr; }
      }
    `);
  }

  function injectLaunchStyles() {
    mw.util.addCSS('#pt-sentinel a { text-transform: none; }');
  }

  function ensurePanelReady() {
    if (panelReady) {
      return panelReady;
    }

    panelReady = mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.Title']).then(function () {
      api = new mw.Api();
      injectStyles();
      panel = buildPanel();
      bindPanel();
      restorePanelState();
    });
    return panelReady;
  }

  // Alt-clicking any user link on the page targets that user directly,
  // sidestepping detection heuristics entirely — useful on history pages,
  // RC, and noticeboard threads with many usernames.
  function bindAltClickCapture() {
    document.addEventListener('click', (event) => {
      if (!event.altKey) {
        return;
      }
      const link = event.target.closest('.mw-userlink');
      if (!link) {
        return;
      }
      const name = link.textContent.trim();
      if (!name) {
        return;
      }
      event.preventDefault();
      event.stopPropagation();
      ensurePanelReady().then(() => {
        panel.hidden = false;
        applyDetectedUser({ user: name, source: 'Alt-clicked user link' });
      }).catch((error) => {
        console.error(`[${APP}] Alt-click targeting failed`, error);
      });
    }, true);
  }

  function addLaunchLink() {
    // On Vector 2022, p-personal is the collapsed user-menu dropdown (two
    // clicks to reach); prefer the always-visible overflow area there.
    const portlets = mw.config.get('skin') === 'vector-2022'
      ? ['p-vector-user-menu-overflow', 'p-personal']
      : ['p-personal'];
    let node = null;
    for (const portlet of portlets) {
      node = mw.util.addPortletLink(portlet, '#', APP, 'pt-sentinel', 'Open Sentinel');
      if (node) {
        break;
      }
    }
    if (!node) {
      return;
    }
    node.addEventListener('click', (event) => {
      event.preventDefault();
      ensurePanelReady().then(() => {
        hydrateSettingsForm();
        setPanelVisible(panel.hidden);
      }).catch((error) => {
        console.error(`[${APP}] Startup failed`, error);
      });
    });
  }

  // If the panel was open when the last page unloaded, bring it back.
  function restoreOpenPanel() {
    if (!storageGet('panelState', {}).open) {
      return;
    }
    ensurePanelReady().then(() => {
      hydrateSettingsForm();
      panel.hidden = false;
    }).catch((error) => {
      console.error(`[${APP}] Could not restore panel`, error);
    });
  }

  let panel;
  let panelReady;

  if (mw.config.get('wgDBname') !== 'enwiki') {
    return;
  }

  // Add the visible toolbar link as early as possible. The heavier API-backed
  // panel setup is lazy-loaded on first open so it cannot shift page chrome
  // after Watchlist has already settled.
  mw.loader.using('mediawiki.util').then(() => {
    injectLaunchStyles();
    addLaunchLink();
    bindAltClickCapture();
    restoreOpenPanel();
  });
})();

Content Disclaimer

Informasi ini disarikan dari Wikipedia dan disajikan kembali untuk tujuan edukasi. Konten tersedia di bawah lisensi CC BY-SA 3.0. Kami tidak bertanggung jawab atas ketidakakuratan data yang bersumber dari kontribusi publik tersebut.

  1. The information displayed on this website is sourced in part or in whole from Wikipedia and has been adapted for the purpose of restating it. We strive to provide accurate and relevant information, however:
  2. There is no guarantee of absolute accuracy. Wikipedia is an open, collaborative project that can be edited by anyone, so information is subject to change.
  3. It is not intended to constitute professional advice. The content displayed is for informational and educational purposes only. For important decisions (e.g., medical, legal, or financial), please consult a professional.
  4. Content copyright. Wikipedia is licensed under the Creative Commons Attribution-ShareAlike License (CC BY-SA). This means that content may be reused with appropriate attribution and shared under a similar license.
  5. Responsible use. Any risk arising from the use of information from this website is entirely the responsibility of the user.