Brazen Framework - Download Manager

Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts

Este script não deve ser instalado diretamente. É uma biblioteca destinada a ser incluída por outros scripts através da diretiva de metadados // @require https://update.greasyfork.org/scripts/587126/1901981/Brazen%20Framework%20-%20Download%20Manager.js

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

// ==UserScript==
// @name         Brazen Framework - Download Manager
// @namespace    brazenvoid
// @version      3.0.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Cross-tab download queue and resolution pipeline for Brazen user scripts
// @grant        GM_download
// ==/UserScript==

/** Build id for Tampermonkey Resource-override checks (must match local `base-scripts` file). */
const BRAZEN_DOWNLOAD_MANAGER_BUILD = 'local-3.0.0'

/** @typedef {'resolution'|'download'} BrazenDownloadManagerLaneId */
/** @typedef {{itemId: string|null, promptTabId: string|null, openUrl: string|null, at: number}} BrazenHumanInteractionLane */
/** @typedef {{resolution: BrazenHumanInteractionLane|null, download: BrazenHumanInteractionLane|null}} BrazenHumanInteractionMap */

const OPTION_ENABLE_DOWNLOAD_MANAGER = 'enable-download-manager'
/** Behaviours: search pages open with selection mode active (toggleable from the dock per visit). */
const OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT = 'download-selection-mode-default'
/** Behaviours: reopen tag discovery when ignored tags empty filename pins. */
const OPTION_REVIEW_IGNORED_FILENAME_PINS = 'review-ignored-filename-pins'
/** Behaviours: skip batch downloads with no tags for any filename/subfolder tag-type pin. */
const OPTION_SKIP_EMPTY_FILENAME_PINS = 'skip-empty-filename-pins'
/** Behaviours: defer tag-discovery review until the resolution queue drains (unattended batches). */
const OPTION_DEFER_TAG_DISCOVERY = 'defer-tag-discovery-unattended'

/** Canonical config field keys for download-path settings (SSOT for DM registration). */
const BRAZEN_DOWNLOAD_PATH_FIELD_KEYS = Object.freeze({
  folder: 'download-folder',
  filenamePattern: 'filename-pattern',
  subfolderPattern: 'subfolder-pattern',
  substitutions: 'filename-tag-substitutions',
  tagIgnore: 'filename-tag-ignore-list',
  stripCharacterSeries: 'strip-series-from-character-tags',
})

const DOCK_TAG_DISCOVERY_MODE = 'dock-tag-discovery-mode'
const DOCK_SELECTION_MODE = 'dock-selection-mode'
const DOCK_ADD_TO_QUEUE = 'dock-add-to-queue'
const DOCK_DOWNLOAD_START_PAUSE = 'dock-download-start-pause'
const DOCK_DOWNLOAD_LEADER = 'dock-download-leader'
const DOCK_CLEAR_DOWNLOAD_QUEUE = 'dock-clear-download-queue'

// Shared with BrazenIndexedDBStorage (loaded before this module).
const RESOLUTION_TERMINAL = RESOLUTION_QUEUE_TERMINAL_SET
const DOWNLOAD_TERMINAL = DOWNLOAD_QUEUE_TERMINAL_SET

/** Max similar tags shown in the discovery panel. */
const SIMILAR_DISCOVERY_LIMIT = 10
/** Page size when scanning registry tags by type for similarity. */
const SIMILAR_DISCOVERY_PAGE_SIZE = 200

/** Leader heartbeat interval while this tab owns {@link processingTabId}. */
const PROCESSING_HEARTBEAT_INTERVAL_MS = 3000
/** Steal the processor lock if the owner has not heartbeated within this window (covers refresh / crash). */
const PROCESSING_LOCK_STALE_MS = 12000
/** Periodic HI panel restore / stale `promptTabId` reclaim (visible tabs only). */
const HUMAN_INTERACTION_WATCHDOG_MS = 6000
/**
 * Default wait before the processor leader clears an HI lane (CF soft block often expires).
 * Overridable per lane via `rateLimitHandlers[context].humanInteraction.softExpireMs`.
 */
const HUMAN_INTERACTION_SOFT_EXPIRE_MS = 120000
/** Coalesce bulk-enqueue wake pings so other tabs see one storage event. */
const PROCESSORS_WAKE_SIGNAL_DEBOUNCE_MS = 150
/** Coalesce stacked wake resumes on the receiving tab. */
const PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS = 150
/** Coalesce dock progress UI pings so non-leader tabs can repaint counters. */
const PROGRESS_UI_WAKE_SIGNAL_DEBOUNCE_MS = 150
/** Coalesce stacked progress UI refreshes on the receiving tab. */
const PROGRESS_UI_WAKE_RECEIVE_DEBOUNCE_MS = 150
/**
 * Max active queue item ids mirrored in sessionStorage for instant selection-mark paint.
 * Above the cap the truncated set is stored; async reconcile fills the rest.
 */
const SELECTION_MARK_MIRROR_MAX = 4000

/**
 * Default detailed help HTML for Download Manager settings and dock actions.
 * Consumer scripts may override per field via {@link ConfigurationField#setHelp}.
 *
 * @type {Readonly<Record<string, string>>}
 */
const DOWNLOAD_MANAGER_FIELD_DETAILED_HELP = Object.freeze({
  ENABLE_DOWNLOAD_MANAGER:
      '<p>Shows download-manager dock controls (selection mode, queue start/pause, tag discovery, and related actions). ' +
      'When off, batch enqueue and queue processing UI are hidden.</p>',

  SELECTION_MODE_DEFAULT:
      '<p>When enabled, search pages open with selection mode active so you can click posts to enqueue downloads.</p>' +
      '<p>Turn selection on/off from the dock for each visit without changing this default. Workflow:</p>' +
      '<ol><li>Click each media thumb you want — tiles show queue progress.</li>' +
      '<li>When items are Ready, use <strong>Download Queue Start/Pause</strong>.</li></ol>',

  REVIEW_IGNORED_FILENAME_PINS:
      '<p>When a post would download with an empty filename or folder segment because all matching tags are on the Filename Tag Ignore List, ' +
      'Tag Discovery opens with those tags and similar suggestions so you can substitute aliases or remove ignores before the save continues.</p>' +
      '<p>Runs after unknown-tag review, or alone when there are no unknown tags.</p>',

  SKIP_EMPTY_FILENAME_PINS:
      '<p>Skips enqueue/download when your filename or subfolder pattern expects tag-type tokens but the post provides none for those slots. ' +
      'The Filename Tag Ignore List does not affect this check. Patterns without tag slots are unaffected.</p>',

  DEFER_TAG_DISCOVERY:
      '<p>For unattended batches: posts needing tag review wait in a discovery lane until ordinary resolution finishes. ' +
      'The resolution progress bar counts both lanes. New enqueue items wait while discovery review is active. Requires Tag Discovery on the dock.</p>',

  DOCK_TAG_DISCOVERY_MODE:
      '<p>Pauses resolution when a post has tags not yet in your database. Review mappings in the discovery panel, then confirm to continue. ' +
      'Downloads already marked Ready keep going unless blocked by other review steps.</p>',

  DOCK_SELECTION_MODE:
      '<p>Toggle on search pages to click posts for batch enqueue. Selected items enter the resolution queue; click again to remove. ' +
      'Works with Start in Selection Mode under Behaviours.</p>',

  DOCK_ADD_TO_QUEUE:
      '<p>On media pages, enqueue or dequeue the open post for resolution/download processing. ' +
      'The button state shows whether this post is already queued.</p>',

  DOCK_DOWNLOAD_LEADER:
      '<p>Only one browser tab runs download-manager processing at a time. The crown shows whether this tab is leader. ' +
      'Click to take leadership when the manager is idle on another tab.</p>',

  DOCK_CLEAR_DOWNLOAD_QUEUE:
      '<p>Clears pending download saves only. Items still resolving, tag-discovery review, and selection-mode state are unchanged.</p>',

  DOCK_DOWNLOAD_START_PAUSE:
      '<p>Controls saving of Ready downloads only. Resolution and tag discovery continue unless rate-limited or blocked by review.</p>',

  DOWNLOAD_FOLDER:
      '<p>Root folder all downloads are saved under. It can be renamed but not removed — clearing it restores the default.</p>' +
      '<p>Subfolder and filename patterns build paths inside this folder under your browser download location.</p>',

  FILENAME_PATTERN:
      '<p>Build the saved file name from the pattern field below. The file extension is added automatically.</p>' +
      '<p>Tag substitutions and the Filename Tag Ignore List apply before the path is resolved. ' +
      'When a tag-type segment has nothing to use, that part becomes <code>unknown</code>.</p>',

  SUBFOLDER_PATTERN:
      '<p>Optional path segments inside the download folder. Use the pattern field and <code>/</code> between folder levels.</p>' +
      '<p>Leave empty to save directly under the download folder.</p>',

  TAG_SUBSTITUTIONS:
      '<p>Rename tags for filename and subfolder tokens (Subject → Alias). Use underscores, not spaces.</p>' +
      '<p>Add rules in the panel below. Scripts with sidebar tag actions may also add rules from there.</p>' +
      '<p>The same mappings can appear in Tag Discovery while resolution is paused for review.</p>',

  TAG_IGNORE:
      '<p>Tags excluded from tag-type tokens in file names. One tag per row (underscores, not spaces).</p>' +
      '<p>Ignored tags stay out of Artists, Characters, and other filename tokens.</p>',

  STRIP_CHARACTER_SERIES:
      '<p>Shortens disambiguated character tags in filename and subfolder tokens only — for example ' +
      '<code>sciel_(clair_obscur_expedition_33)</code> → <code>sciel</code>.</p>' +
      '<p>Does not change search tags or compliance rules.</p>',
})

class BrazenDownloadManager
{
  // -------------------------------------------------------------------------
  // Static public methods
  // -------------------------------------------------------------------------

  /**
   * Prefix-scoped localStorage / sessionStorage key for Download Manager cross-tab signals.
   * @param {string|null|undefined} scriptPrefix
   * @param {string} suffix
   * @return {string}
   */
  static storageKey(scriptPrefix, suffix)
  {
    return (scriptPrefix ?? 'brazen-') + suffix
  }

  /**
   * True when {@link downloadManagerState} still carries pre-3.0 legacy fields.
   * @param {object|null|undefined} state
   * @return {boolean}
   * @private
   */
  static _stateNeedsLegacyMigration(state)
  {
    if (!state) {
      return false
    }
    if ('pendingImmediateDownload' in state) {
      return true
    }
    if (state.humanInteractionBlocked) {
      return true
    }
    for (let key of [
      'humanInteractionContext',
      'humanInteractionItemId',
      'humanInteractionPromptTabId',
      'humanInteractionOpenUrl',
    ]) {
      if (key in state) {
        return true
      }
    }
    let map = state.humanInteraction
    if (map == null) {
      return false
    }
    if (typeof map !== 'object' || !('resolution' in map) || !('download' in map)) {
      return true
    }
    return false
  }

  /**
   * True when this script's shared human-interaction block is mirrored in localStorage.
   * Usable in Phase-1 page ops before Download Manager initialize().
   * @param {string|null|undefined} scriptPrefix
   * @return {boolean}
   */
  static peekHumanInteractionBlockedMirror(scriptPrefix)
  {
    try {
      return localStorage.getItem(BrazenDownloadManager.storageKey(scriptPrefix, 'dm-human-interaction-blocked')) === '1'
    } catch (e) {
      return false
    }
  }

  /**
   * True when any per-lane human-interaction entry is set (or legacy single-slot block).
   * Usable in Phase-1 before Download Manager initialize() — survives CF stripping
   * `brazen_hi` and Chrome's default `noopener` on `window.open`.
   * @param {string|null|undefined} scriptPrefix
   * @return {Promise<boolean>}
   */
  static async peekHumanInteractionBlockedIdb(scriptPrefix)
  {
    let prefix = BrazenDownloadManager.storageKey(scriptPrefix, '')
    try {
      if (typeof BrazenStorageRepositories !== 'function') {
        return false
      }
      let repos = new BrazenStorageRepositories(prefix)
      if (!repos.storage.available) {
        return false
      }
      await repos.storage.open()
      let state = await repos.downloadManagerState.get()
      return BrazenDownloadManager._anyHumanInteractionState(state)
    } catch (e) {
      return false
    }
  }

  /**
   * Lane entry from DM state (`humanInteraction[ctx]`), or legacy single-slot fold.
   * @param {object|null|undefined} state
   * @param {BrazenDownloadManagerLaneId} ctx
   * @return {BrazenHumanInteractionLane|null}
   * @private
   */
  static _hiLaneState(state, ctx)
  {
    if (!state) {
      return null
    }
    let map = state.humanInteraction
    if (map && typeof map === 'object' && map[ctx]) {
      return map[ctx]
    }
    // Legacy single-slot schema (pre per-lane humanInteraction map).
    if (state.humanInteractionBlocked) {
      let legacyCtx = state.humanInteractionContext === 'download' ? 'download' : 'resolution'
      if (legacyCtx === ctx) {
        return {
          itemId: state.humanInteractionItemId ?? null,
          promptTabId: state.humanInteractionPromptTabId ?? null,
          openUrl: state.humanInteractionOpenUrl ?? null,
          at: 0,
        }
      }
    }
    return null
  }

  /**
   * True when any lane (or legacy slot) has an active human-interaction block.
   * @param {object|null|undefined} state
   * @return {boolean}
   * @private
   */
  static _anyHumanInteractionState(state)
  {
    if (!state) {
      return false
    }
    if (state.humanInteractionBlocked) {
      return true
    }
    let map = state.humanInteraction
    return !!(map && (map.resolution || map.download))
  }

  /**
   * Lane ids that currently have a human-interaction block.
   * @param {object|null|undefined} state
   * @return {BrazenDownloadManagerLaneId[]}
   * @private
   */
  static _hiContextsState(state)
  {
    /** @type {BrazenDownloadManagerLaneId[]} */
    let out = []
    if (BrazenDownloadManager._hiLaneState(state, 'resolution')) {
      out.push('resolution')
    }
    if (BrazenDownloadManager._hiLaneState(state, 'download')) {
      out.push('download')
    }
    return out
  }

  /**
   * Read `brazen_hi_ctx` from the live URL (query or hash).
   * @return {BrazenDownloadManagerLaneId|null}
   * @private
   */
  static _peekHumanInteractionContextFromLocation()
  {
    try {
      let liveParams = new URLSearchParams(location.search)
      let fromQuery = liveParams.get('brazen_hi_ctx')
      if (fromQuery === 'download' || fromQuery === 'resolution') {
        return fromQuery
      }
      let hash = location.hash || ''
      let match = /(?:^|[&#?])brazen_hi_ctx=(resolution|download)\b/.exec(hash)
      if (match) {
        return /** @type {BrazenDownloadManagerLaneId} */ (match[1])
      }
    } catch (e) {
      // ignore
    }
    return null
  }

  /**
   * When `brazen_hi_ctx` was stripped, pick the blocked lane that matches this challenge page.
   * @param {object|null|undefined} state
   * @return {BrazenDownloadManagerLaneId|null}
   * @private
   */
  static _resolveChallengeHumanInteractionContext(state)
  {
    let contexts = BrazenDownloadManager._hiContextsState(state)
    if (!contexts.length) {
      return null
    }
    if (contexts.length === 1) {
      return contexts[0]
    }
    // Prefer the lane whose openUrl points at this document (sans brazen_hi markers).
    let current = null
    try {
      current = new URL(location.href)
      current.searchParams.delete('brazen_hi')
      current.searchParams.delete('brazen_hi_ctx')
      current.hash = ''
    } catch (e) {
      current = null
    }
    if (current) {
      for (let ctx of contexts) {
        let openUrl = BrazenDownloadManager._hiLaneState(state, ctx)?.openUrl
        if (!openUrl) {
          continue
        }
        try {
          let marked = new URL(openUrl, location.href)
          marked.searchParams.delete('brazen_hi')
          marked.searchParams.delete('brazen_hi_ctx')
          marked.hash = ''
          if (marked.origin === current.origin && marked.pathname === current.pathname) {
            return ctx
          }
        } catch (e) {
          // ignore bad openUrl
        }
      }
    }
    return contexts[0]
  }

  /**
   * True when the URL still carries a queue-verification marker (`brazen_hi` query or hash).
   * @return {boolean}
   */
  static hasBrazenHumanInteractionMarker()
  {
    try {
      let liveParams = new URLSearchParams(location.search)
      if (liveParams.get('brazen_hi') === '1') {
        return true
      }
      let hash = location.hash || ''
      if (/(^|[&#?])brazen_hi=1\b/.test(hash) || hash === '#brazen_hi') {
        return true
      }
    } catch (e) {
      // ignore
    }
    return false
  }

  /**
   * True when this tab was opened by same-origin `window.open` / `<a rel="opener">`
   * (queue verification). Chrome's default `noopener` on `window.open(_blank)` breaks this
   * unless the opener used {@link BrazenDownloadManager#_openHumanInteractionTab}.
   * @return {boolean}
   */
  static isQueueOpenedVerificationTab()
  {
    try {
      let opener = window.opener
      if (!opener || opener.closed) {
        return false
      }
      void opener.location.href
      return true
    } catch (e) {
      return false
    }
  }

  /**
   * Whether a mediaCloudflare / challenge page should stay silent (queue owns the leader pane).
   * Fail closed toward silence. Usable in Phase-1 before `initialize()`.
   * @param {string|null|undefined} scriptPrefix
   * @param {BrazenDownloadManager|null|undefined} [instance] optional live instance for sync state
   * @return {Promise<boolean>}
   */
  static async shouldSilenceMediaCloudflarePrompt(scriptPrefix, instance = null)
  {
    if (BrazenDownloadManager.hasBrazenHumanInteractionMarker()) {
      return true
    }
    if (BrazenDownloadManager.peekHumanInteractionBlockedMirror(scriptPrefix)) {
      return true
    }
    if (instance?.isHumanInteractionBlockedSync?.()) {
      return true
    }
    if (BrazenDownloadManager.isQueueOpenedVerificationTab()) {
      return true
    }
    if (await BrazenDownloadManager.peekHumanInteractionBlockedIdb(scriptPrefix)) {
      return true
    }
    return false
  }

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {BrazenFramework} framework
   * @param {BrazenConfigurationManager} configurationManager
   * @param {object} config
   */
  constructor(framework, configurationManager, config)
  {
    this._framework = framework
    this._cm = configurationManager
    this._config = config
    this._tabId = Utilities.generateId('dm-tab-')
    /**
     * When true, resolution and download share one interleaved dispatcher (shared pacing).
     * Default false — IndependentPolicy: concurrent lanes with own-lane gates.
     * @type {boolean}
     */
    this._linkQueues = config.linkQueues === true
    /** @type {boolean} Interleaved-policy serial dispatcher lock. */
    this._processing = false
    /** @type {boolean} Set when interleaved dispatcher is kicked while already running. */
    this._dispatcherWakeRequested = false
    /** @type {boolean} */
    this._resolutionProcessing = false
    /** @type {boolean} */
    this._resolutionWakeRequested = false
    /** @type {boolean} */
    this._downloadProcessing = false
    /** @type {boolean} */
    this._downloadWakeRequested = false
    /** @type {string|null} In-tab resolution item currently owned by this tab's processor. */
    this._activeResolutionItemId = null
    /** @type {string|null} In-tab download item currently owned by this tab's processor. */
    this._activeDownloadItemId = null
    /**
     * Bumps on unload / bfcache interrupt so in-flight processor awaits cannot recommit
     * after the document has been suspended or restored.
     * @type {number}
     */
    this._processorEpoch = 0
    /**
     * True after `pagehide`/`beforeunload` until a live resume clears it. Blocks lock-mirror
     * rewrites from late heartbeats while the document is dying or frozen in bfcache.
     * @type {boolean}
     */
    this._documentSuspended = false
    this._selectionClickHandler = null
    /** @type {((event: DragEvent) => void)|null} Capture-phase drag guard while selection mode is on. */
    this._selectionDragStartHandler = null
    this._crossTabVisibilityHandler = null
    this._crossTabPageshowHandler = null
    this._crossTabPagehideHandler = null
    this._crossTabStorageHandler = null
    this._selectionModeActive = false
    /** @type {boolean} Sync cache for media-page Add/Remove queue dock chrome. */
    this._currentMediaQueued = false
    this._progressElement = null
    this._tagDiscoveryPanel = null
    /** @type {boolean} True while Confirm/Skip is finishing work after an immediate hide. */
    this._tagDiscoveryActionInFlight = false
    /** @type {number} Bumps to cancel stale similar-tag prefetch loads. */
    this._similarDiscoveryLoadId = 0
    /** @type {number|null} Pending `requestAnimationFrame` id for similar prefetch. */
    this._similarDiscoveryRaf = null
    /** @type {string|null} Fingerprint of new/known names for the last similar load. */
    this._similarDiscoveryFingerprint = null
    /** @type {Array<{name: string, type: string|null, count: null}>|null} Last filled similar rows (reuse on attribute refresh). */
    this._similarDiscoveryCachedTags = null
    /** @type {number|null} Debounced tag-discovery panel refresh (avoids <details> toggle mid-click). */
    this._tagDiscoveryRefreshTimer = null
    /**
     * When set, the next discovery refresh runs only if the open panel lists one of these names.
     * `null` means unrestricted; `undefined` means no pending filter from the last schedule.
     * @type {string[]|null|undefined}
     */
    this._tagDiscoveryRefreshOnlyTags = undefined
    /**
     * Per-lane human-interaction dock panels.
     * @type {{resolution: HTMLElement|null, download: HTMLElement|null}}
     */
    this._humanInteractionPanels = {resolution: null, download: null}
    /** @type {number|null} Interval id for HI panel restore / stale prompt reclaim. */
    this._humanInteractionWatchdogTimer = null
    /** @type {number|null} Debounced selection-mark mirror write. */
    this._selectionMarkMirrorWriteTimer = null
    /**
     * True while a navigation-interrupted in-progress download awaits a timed-panel decision.
     * Gates the download lane so nothing new starts in parallel.
     * @type {boolean}
     */
    this._downloadInterruptionPending = false
    /**
     * Orphan `downloading` rows with `inProgress` awaiting the interruption panel decision.
     * @type {object[]|null}
     */
    this._downloadInterruptionRows = null
    /** @type {HTMLElement|null} */
    this._downloadInterruptionPanel = null
    /** @type {object|null} Lazy lane descriptors for the dispatcher. */
    this._lanes = null
    this._trackedItemElements = new Map()
    /** @type {Map<string, number>} Bumps per itemId so a later click can cancel an in-flight toggle. */
    this._selectionOpEpoch = new Map()
    /**
     * @type {Set<string>}
     * Item ids whose overlay was cleared by a local deselect/cancel. Blocks stale
     * `_syncFromStorage` / processor paints until the row is actually gone from the queues.
     */
    this._selectionUiHidden = new Set()
    /**
     * @type {Set<string>}
     * Optimistic select in flight (painted, IDB put not settled). Keeps refresh/reconcile
     * from clearing the overlay when `isQueued` is still false.
     */
    this._selectionUiPending = new Set()
    this._cachedResolutionCount = 0
    this._cachedDownloadCount = 0
    this._processingHeartbeatTimer = null
    this._resolutionGapMs = config.resolutionInitiationGapMs ?? 2000
    this._downloadGapMs = config.downloadInitiationGapMs ?? 2000
    /** @type {number} In-tab clock — avoids IDB lost-update races wiping the download gap. */
    this._lastDownloadInitiationAt = 0
    /** @type {number} */
    this._lastResolutionInitiationAt = 0
    /** @type {Promise<void>} Serializes DM state mutations that must re-read before write. */
    this._stateWriteTail = Promise.resolve()
    /** @type {Promise<void>} Serializes download initiation pacing (batch + immediate). */
    this._downloadInitiateTail = Promise.resolve()
    /** @type {Promise<void>} Serializes resolution / same-origin site fetch pacing. */
    this._resolutionInitiateTail = Promise.resolve()
    /** @type {number|null} Debounce timer for `_signalProcessorsWake` localStorage writes. */
    this._processorsWakeSignalTimer = null
    /** @type {number|null} Debounce timer for incoming wake / focus resume. */
    this._processorsWakeReceiveTimer = null
    /** @type {Promise<void>} Serializes `_resumeProcessorsFromExternalWake` work. */
    this._processorsWakeResumeTail = Promise.resolve()
    /** @type {number|null} Debounce timer for `_signalProgressUiWake` localStorage writes. */
    this._progressUiWakeSignalTimer = null
    /** @type {number|null} Debounce timer for incoming progress UI wake. */
    this._progressUiWakeReceiveTimer = null
    /** @type {boolean} Dock progress refresh requested while a paint is in flight. */
    this._dockProgressRefreshWanted = false
    /** @type {boolean} */
    this._dockProgressRefreshRunning = false
    /** @type {boolean} OR of refreshAllItems across coalesced refresh requests. */
    this._dockProgressRefreshAllItems = false
    /** @type {boolean} One-shot latch: orphan downloading requeue while peek has no queued row. */
    this._orphanDownloadRequeueAttempted = false
    this._registerConfigFields()
    this._registerDownloadPathFields()
    this._setupDock()
  }

  /**
   * @private
   */
  _registerConfigFields()
  {
    let seeds = {}
    if (this._config.enabled !== true) {
      let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
      if (!this._cm.getField(enableKey)) {
        seeds[enableKey] = !!this._config.enableDefault
        this._cm.addFlagField(enableKey).
            setTitle('Enable Download Manager').
            setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.ENABLE_DOWNLOAD_MANAGER)
      }
    }

    let selectionDefaultKey = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
    if (!this._cm.getField(selectionDefaultKey)) {
      seeds[selectionDefaultKey] = false
      this._cm.addFlagField(selectionDefaultKey).
          setTitle('Start in Selection Mode').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SELECTION_MODE_DEFAULT)
    }

    if (!this._cm.getField(OPTION_REVIEW_IGNORED_FILENAME_PINS)) {
      seeds[OPTION_REVIEW_IGNORED_FILENAME_PINS] = false
      this._cm.addFlagField(OPTION_REVIEW_IGNORED_FILENAME_PINS).
          setTitle('Review Ignored Download Path Tags').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.REVIEW_IGNORED_FILENAME_PINS)
    }

    if (!this._cm.getField(OPTION_SKIP_EMPTY_FILENAME_PINS)) {
      seeds[OPTION_SKIP_EMPTY_FILENAME_PINS] = false
      this._cm.addFlagField(OPTION_SKIP_EMPTY_FILENAME_PINS).
          setTitle('Skip Media Without Download Path Tags').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SKIP_EMPTY_FILENAME_PINS)
    }

    if (!this._cm.getField(OPTION_DEFER_TAG_DISCOVERY)) {
      seeds[OPTION_DEFER_TAG_DISCOVERY] = false
      this._cm.addFlagField(OPTION_DEFER_TAG_DISCOVERY).
          setTitle('Defer Tag Discovery Until Resolution Completes').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DEFER_TAG_DISCOVERY)
    }
    if (Object.keys(seeds).length) {
      this._cm.registerFieldSeeds(seeds)
    }
  }

  /**
   * Registers download-path settings fields when {@link BrazenDownloadManager} config includes `downloadPaths`.
   * Consumer scripts may override titles, defaults, or detailed help per field after `configureDownloadManager()`.
   *
   * @private
   */
  _registerDownloadPathFields()
  {
    let paths = this._config.downloadPaths
    if (!paths) {
      return
    }

    let seeds = {}
    let folderKey = paths.folderConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.folder
    if (!this._cm.getField(folderKey)) {
      if (paths.defaultFolder) {
        seeds[folderKey] = paths.defaultFolder
      }
      this._cm.addTextField(folderKey).
          setTitle('Download Folder').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOWNLOAD_FOLDER)
    }

    let filenameKey = paths.filenamePatternConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.filenamePattern
    if (!this._cm.getField(filenameKey)) {
      seeds[filenameKey] = paths.defaultFilenamePattern ?? 'ID'
      this._cm.addTextField(filenameKey).
          setTitle('Filename Pattern').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.FILENAME_PATTERN)
    }

    let subfolderKey = paths.subfolderPatternConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.subfolderPattern
    if (!this._cm.getField(subfolderKey)) {
      this._cm.addTextField(subfolderKey).
          setTitle('Subfolder Pattern').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SUBFOLDER_PATTERN)
    }

    let substitutionsKey = paths.substitutionsFieldKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.substitutions
    if (!this._cm.getField(substitutionsKey)) {
      seeds[substitutionsKey] = {
        templateId: 'substitution',
        templateConfig: {attributeKind: 'requiresTag'},
        config: {...DEFAULT_RULESET_USER_CONFIG, autoSort: true},
      }
      this._cm.addRulesetField(substitutionsKey).
          setTitle('Filename Tag Substitutions').
          setTemplate('substitution').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.TAG_SUBSTITUTIONS)
    }

    let ignoreKey = paths.tagIgnoreFieldKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.tagIgnore
    if (!this._cm.getField(ignoreKey)) {
      seeds[ignoreKey] = {
        templateId: 'tag-sole-ignore',
        templateConfig: {attributeKind: 'toggle'},
        config: {...DEFAULT_RULESET_USER_CONFIG, autoSort: true},
      }
      this._cm.addRulesetField(ignoreKey).
          setTitle('Filename Tag Ignore List').
          setTemplate('tag-sole-ignore').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.TAG_IGNORE)
    }

    let stripSeriesKey = paths.stripCharacterSeriesConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.stripCharacterSeries
    if (!this._cm.getField(stripSeriesKey)) {
      this._cm.addFlagField(stripSeriesKey).
          setTitle('Strip Series From Character Tags').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.STRIP_CHARACTER_SERIES)
    }
    if (Object.keys(seeds).length) {
      this._cm.registerFieldSeeds(seeds)
    }
  }

  /**
   * @return {Promise<void>}
   */
  async initialize()
  {
    await this._ensureState()
    this._syncDiscoveryTagTypesFromPatterns()
    this._selectionModeActive = this._shouldStartSelectionMode()
    try {
      sessionStorage.removeItem(BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-selection'))
    } catch (error) {
      // ignore unavailable sessionStorage
    }
    this._setupCrossTabSync()
    if (this._cm.canPersist()) {
      this._hydrateInitiationClocksFromState(await this._getState())
    }
    this._framework.onConfigurationChange((event) => {
      let source = event?.source
      // Queue / DM state / ledger puts fire on every processor step — UI updates are owned
      // by task-complete hooks, not by this broadcast.
      if (source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
          source === 'downloadManagerState' || source === 'ledger') {
        return
      }
      if (source === 'tags') {
        // Tag attribute chrome — consumer refreshTagActionSurfaces owns discovery repaint.
        return
      }
      this._scheduleTagDiscoveryPanelRefresh()
      this._refreshDockProgress()
      this._refreshSelectionMarks()
      this._syncDiscoveryTagTypesFromPatterns()
      if (this.isDownloadPageRole('selection')) {
        this._bindSelectionHandlers()
        this.registerSelectionDragGuard()
      }
    })

    if (this.isDownloadPageRole('selection')) {
      this._bindSelectionHandlers()
      this.registerSelectionDragGuard()
      // Sync paint from sessionStorage before the async IDB reconcile (avoids blank flash).
      this._paintSelectionMarksFromMirror()
    }

    await this._syncFromStorage()
    await this._pruneAllTerminalQueueRows()
    await this._tryClaimProcessingLeadership()
    // HI restore + dock progress remount run after the dock exists (Framework post-UI).
    // Painting progress here targets a detached slot; HI panels also need the dock stack.
    this._startHumanInteractionWatchdog()
    if (this._downloadInterruptionPending) {
      this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
    }
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * After Framework mounts the dock: remount progress onto the live slot, restore HI /
   * discovery panels. Safe to call once UI exists (or again on focus).
   * @return {Promise<void>}
   */
  async afterDockReady()
  {
    // Live `.bv-dock-progress-panel` now exists — repaint counters that early init missed.
    this._refreshDockProgress({refreshAllItems: true})
    await this._restoreHumanInteractionPanelsIfNeeded()
    if (this._downloadInterruptionPending) {
      this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
    }
  }

  /**
   * Drop terminal queue rows (and their fat payloads) left after crashes / missed finally.
   * Safe across tabs — only deletes terminal statuses.
   * @return {Promise<void>}
   * @private
   */
  async _pruneAllTerminalQueueRows()
  {
    if (!this._cm.canPersist()) {
      return
    }
    try {
      await Promise.all([
        this._repos().downloadResolutionQueue.pruneTerminal(),
        this._repos().downloadQueue.pruneTerminal(),
      ])
    } catch (error) {
      console.log('Terminal queue prune failed:', error)
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  _shouldStartSelectionMode()
  {
    if (!this.isDownloadManagerEnabled() || !this.isDownloadPageRole('selection')) {
      return false
    }
    let key = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
    return !!this._framework._getConfig(key)
  }

  // -------------------------------------------------------------------------
  // Public API
  // -------------------------------------------------------------------------

  /**
   * Adds a post to the resolution queue when it is not already in the pipeline.
   * Dedupe checks both queues: resolution (pending/in-progress resolve) and download
   * (already resolved, waiting or downloading). Items already in the download queue
   * are never re-enqueued for resolution.
   *
   * @param {object} context
   * @return {Promise<boolean>} whether the item was added to the queue
   */
  async enqueueDownload(context)
  {
    let itemId = this._config.getQueueItemId(context)
    if (!itemId) {
      return false
    }
    if (await this.isQueued(itemId)) {
      return false
    }
    // Reset before enqueue so a prior session's completed counts do not inflate dock totals
    // (e.g. 4 prior + 1 new looking like a 5-item queue).
    await this._resetProgressCountersIfIdle()
    await this._clearTerminalQueueRows(itemId)

    let row = {
      itemId,
      downloadType: context.downloadType ?? this._getActivePageConfig()?.defaultDownloadType ?? 'post',
      sourceUrl: context.sourceUrl ?? '',
      status: 'queued',
      originTabId: this._tabId,
      resolveContext: context.resolveContext ?? {},
      pendingTagGroups: null,
      addedAt: Date.now(),
      error: null,
    }
    await this._repos().downloadResolutionQueue.put(row)
    this._addSelectionMarkMirrorId(itemId)
    // Wake an idle leader in another tab (same-tab notify does not cross documents).
    this._signalProcessorsWake()
    await this._tryClaimProcessingLeadership()
    await this._syncFromStorage()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
    return true
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _clearTerminalQueueRows(itemId)
  {
    let resolution = await this._repos().downloadResolutionQueue.get(itemId)
    if (resolution && RESOLUTION_TERMINAL.has(resolution.status)) {
      await this._repos().downloadResolutionQueue.remove(itemId)
    }
    let download = await this._repos().downloadQueue.get(itemId)
    if (download && DOWNLOAD_TERMINAL.has(download.status)) {
      await this._repos().downloadQueue.remove(itemId)
    }
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   */
  async dequeueDownload(itemId)
  {
    // Read before `_syncFromStorage` refreshes the cache from IDB.
    let state = this._getStateSync()
    let blockedId = state?.resolutionBlockedItemId
    let cancelDiscovery = blockedId != null
        && String(itemId) === String(blockedId)
        && !this._tagDiscoveryActionInFlight
    let itemKey = String(itemId)
    let clearHiContexts = /** @type {BrazenDownloadManagerLaneId[]} */ ([])
    for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
      let lane = this._hiLane(state, ctx)
      if (lane?.itemId != null && String(lane.itemId) === itemKey) {
        clearHiContexts.push(ctx)
      }
    }

    await this._repos().downloadResolutionQueue.remove(itemId)
    await this._repos().downloadQueue.remove(itemId)
    this._removeSelectionMarkMirrorId(itemId)

    if (cancelDiscovery) {
      // Deselect of the review subject — same unlock as Skip (no type confirm).
      this._hideTagDiscoveryPanel()
      await this._withState((next) => {
        this._clearDiscoveryReviewState(next)
      })
      if (String(this._activeResolutionItemId) === String(itemId)
          || String(this._activeDownloadItemId) === String(itemId)) {
        this._processorEpoch++
        this._activeResolutionItemId = null
        this._activeDownloadItemId = null
      }
      await this._maybeEndDiscoveryLanePhase()
      this._resumeProcessorsAfterDiscoveryReview()
    }

    // Deselect of an HI subject unlocks that lane immediately (do not wait for orphan restore).
    for (let ctx of clearHiContexts) {
      await this._clearHumanInteractionBlock(ctx)
    }
    if (clearHiContexts.length) {
      this._signalProcessorsWake()
      if (this._shouldRunProcessors()) {
        void this._runProcessors()
      }
    }

    await this._syncFromStorage()
    this._refreshSelectionMarks()
  }

  /**
   * Whether a post is anywhere in the active download pipeline (either queue).
   * Resolution queue: queued, resolving, tagReview, etc.
   * Download queue: queued or downloading (already resolved — no re-resolution).
   *
   * @param {string} itemId
   * @return {Promise<boolean>}
   */
  async isQueued(itemId)
  {
    let resolution = await this._repos().downloadResolutionQueue.get(itemId)
    if (resolution && !RESOLUTION_TERMINAL.has(resolution.status)) {
      return true
    }
    let download = await this._repos().downloadQueue.get(itemId)
    return !!(download && !DOWNLOAD_TERMINAL.has(download.status))
  }

  /**
   * @return {Promise<void>}
   */
  async confirmTagDiscoveryMappings()
  {
    // Hide immediately so Confirm feels instant; reopen later if ignored-pin review is needed.
    this._hideTagDiscoveryPanel()
    if (this._tagDiscoveryActionInFlight) {
      return
    }
    this._tagDiscoveryActionInFlight = true
    try {
      let state = await this._getState()
      if (!state.resolutionBlocked) {
        return
      }

      let reviewMode = state.discoveryReviewMode ?? 'unknown'
      // Type promotion only for unknown-tag review (ignored-pin tags already have typeEntryId).
      if (reviewMode !== 'ignoredPins') {
        await this._confirmDiscoveryPanelTagTypes(state.discoveryPanelTags)
      }

      let itemId = state.resolutionBlockedItemId
      if (!itemId) {
        await this._withState((next) => {
          this._clearDiscoveryReviewState(next)
        })
        await this._maybeEndDiscoveryLanePhase()
        this._resumeProcessorsAfterDiscoveryReview()
        return
      }

      let item = await this._repos().downloadResolutionQueue.get(itemId)
      if (!item?.pendingTagGroups) {
        await this._withState((next) => {
          this._clearDiscoveryReviewState(next)
        })
        await this._maybeEndDiscoveryLanePhase()
        this._resumeProcessorsAfterDiscoveryReview()
        return
      }

      // After unknown Confirm: reopen for ignored filename pins if needed.
      // After ignoredPins Confirm: re-collect; proceed only when no pin type is still blocking.
      let resolved = item.pendingTagGroups
      let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
      let tagIncidences = resolved.tagIncidences ?? {}
      let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
      if (ignoredPin.pinJoinEmpty && ignoredPin.tags.length) {
        let ignoredTags = ignoredPin.tags
        await this._withState((next) => {
          next.discoveryReviewMode = 'ignoredPins'
          next.discoveryPanelTags = ignoredTags
          next.discoveryPanelKnownTags = []
        })
        await this._presentTagDiscoveryReview(ignoredTags, [])
        return
      }

      await this._finishResolutionAfterTagDiscovery(item)
    } finally {
      this._tagDiscoveryActionInFlight = false
    }
  }

  /**
   * Promote or skip-empty after discovery/ignored-pin review when nothing remains to review.
   * @param {object} item Resolution queue row with `pendingTagGroups`
   * @return {Promise<void>}
   * @private
   */
  async _finishResolutionAfterTagDiscovery(item)
  {
    let itemId = item?.itemId ?? null
    try {
      if (!item?.pendingTagGroups) {
        return
      }
      let resolvedPayload = item.pendingTagGroups
      let tagGroups = this._tagGroupsFromResolvedPayload(resolvedPayload)
      if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
        await this._dropResolvedWithoutDownload(item, {clearDiscoveryGate: false})
        return
      }
      await this._promoteToDownloadQueue(item, resolvedPayload)
      await this._repos().downloadResolutionQueue.remove(itemId)
      if (itemId) {
        this._refreshItemProgress(itemId)
      }
      await this._incrementResolutionProgress()
    } catch (error) {
      console.error('[BrazenDownloadManager] finish after tag discovery failed', error)
    } finally {
      await this._withState((next) => {
        this._clearDiscoveryReviewState(next)
      })
      try {
        await this._syncFromStorage()
      } catch (syncError) {
        /* ignore */
      }
      await this._maybeEndDiscoveryLanePhase()
      this._resumeProcessorsAfterDiscoveryReview()
    }
  }

  /**
   * Clear discovery panel gate fields on a state snapshot.
   * @param {object} state
   * @private
   */
  _clearDiscoveryReviewState(state)
  {
    state.resolutionBlocked = false
    state.resolutionBlockedItemId = null
    state.discoveryPanelTags = null
    state.discoveryPanelKnownTags = null
    state.discoveryReviewMode = null
    state.tagDiscoveryPanelTabId = null
  }

  /**
   * Skip the current resolution item waiting on tag review (do not promote to download;
   * do not set isDiscovered so discovery can fire again later).
   * @return {Promise<void>}
   */
  async skipTagDiscoveryInclusion()
  {
    // Hide immediately; finish queue/state cleanup in the background.
    this._hideTagDiscoveryPanel()
    if (this._tagDiscoveryActionInFlight) {
      return
    }
    this._tagDiscoveryActionInFlight = true
    try {
      let state = await this._getState()
      if (!state.resolutionBlocked) {
        return
      }

      let itemId = state.resolutionBlockedItemId
      if (itemId) {
        await this._repos().downloadResolutionQueue.remove(itemId)
        // Clear progress on the element before untracking — `_renderItemProgress` no-ops
        // when the tile is already missing from `_trackedItemElements`.
        let element = this._trackedItemElements.get(String(itemId))
        this._untrackItemElement(itemId)
        if (element) {
          this._setItemProgress(element, null)
        }
      }

      // Serialize gate clear through `_withState` so a concurrent heartbeat put cannot
      // resurrect `resolutionBlocked` from a stale snapshot (raw `_putState` race).
      await this._withState((next) => {
        this._clearDiscoveryReviewState(next)
      })
      await this._syncFromStorage()
      await this._maybeEndDiscoveryLanePhase()
      // Skip does not confirm types — discovery can fire again later for these names.
      this._resumeProcessorsAfterDiscoveryReview()
    } finally {
      this._tagDiscoveryActionInFlight = false
    }
  }

  /**
   * After Confirm / Skip on any tab: wake an idle leader elsewhere, and run locally if we can.
   * @private
   */
  _resumeProcessorsAfterDiscoveryReview()
  {
    this._signalProcessorsWake()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Open the media page for the item currently under tag review.
   * @return {Promise<void>}
   */
  async openTagDiscoveryMedia()
  {
    let state = await this._getState()
    if (!state.resolutionBlocked || !state.resolutionBlockedItemId) {
      return
    }
    let item = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
    let openUrl = item?.sourceUrl
        || item?.pendingTagGroups?.metadata?.sourceUrl
        || null
    if (openUrl) {
      window.open(openUrl, '_blank')
    }
  }

  /**
   * @return {Promise<void>}
   */
  async toggleDownloadManagerPaused()
  {
    let state = await this._getState()
    // Linked / interleaved: any HI owns Start. Independent: only download-lane HI.
    if (this._isStartPauseShowingHumanInteraction(state)) {
      void this._promptHumanInteractionResume('download')
      if (this._linkQueues) {
        for (let ctx of this._hiContexts(state)) {
          if (ctx !== 'download') {
            void this._promptHumanInteractionResume(ctx)
          }
        }
      }
      return
    }
    // Start/Pause only affects the download queue — never resolution / tag discovery.
    // Allow toggle while resolution is still in-batch (download may be empty mid-batch).
    let downloadPending = await this._getPendingDownloadCount()
    let resolutionPending = await this._getPendingResolutionCount()
    if (this._isDownloadBatchIdle(downloadPending, resolutionPending)) {
      return
    }
    // Patch via `_withState` — a raw put of this snapshot can wipe concurrent progress counts.
    let nextPaused = !state.paused
    await this._withState((next) => {
      next.paused = nextPaused
    })
    if (!nextPaused) {
      this._signalProcessorsWake()
      await this._tryClaimProcessingLeadership()
    }
    await this._syncFromStorage()
    if (!nextPaused) {
      void this._runProcessors()
    }
  }

  /**
   * Clears pending/in-progress download-queue rows only. Does not touch the resolution
   * queue, tag discovery, selection mode, or processor leadership.
   * @return {Promise<void>}
   */
  async clearDownloadQueue()
  {
    await this._withState((state) => {
      state.paused = true
      state.completedDownloadCount = 0
      this._ensureHumanInteractionMap(state)
      if (state.humanInteraction.download) {
        this._hideHumanInteractionPanel('download')
        state.humanInteraction.download = null
        this._writeHumanInteractionBlockMirror(this._anyHumanInteraction(state))
      }
    })
    await this._repos().downloadQueue.clearAll()
    await this._syncFromStorage()
    this._refreshSelectionMarks()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
   */
  async getDownloadManagerProgress()
  {
    let downloadActive = await this._getPendingDownloadCount()
    let resolutionActive = await this._getPendingResolutionCount()
    let state = await this._getState()
    let completedResolution = Number(state.completedResolutionCount) || 0
    let completedDownload = Number(state.completedDownloadCount) || 0
    return {
      resolution: {
        current: completedResolution,
        total: completedResolution + resolutionActive,
      },
      download: {
        current: completedDownload,
        total: completedDownload + downloadActive,
      },
    }
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _getPendingDownloadCount()
  {
    return this._repos().downloadQueue.countActive()
  }

  /**
   * @return {number}
   */
  getPendingResolutionCountSync()
  {
    return this._cachedResolutionCount ?? 0
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _getPendingResolutionCount()
  {
    return this._repos().downloadResolutionQueue.countActive()
  }

  /**
   * Non-terminal items in both queues (item progress / internal pipeline checks).
   * @return {Promise<number>}
   */
  async getPendingPipelineCount()
  {
    return this._getPendingPipelineCount()
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _getPendingPipelineCount()
  {
    let downloadCount = await this._getPendingDownloadCount()
    let resolutionCount = await this._getPendingResolutionCount()
    return resolutionCount + downloadCount
  }

  /**
   * @return {number}
   */
  getPendingDownloadCountSync()
  {
    return this._cachedDownloadCount ?? 0
  }

  /**
   * @return {Promise<number>}
   */
  async getDownloadQueueCount()
  {
    let downloadCount = await this._getPendingDownloadCount()
    if (this._isTagDiscoveryActive()) {
      return downloadCount
    }
    let resolutionCount = await this._getPendingResolutionCount()
    return resolutionCount + downloadCount
  }

  /**
   * @return {HTMLElement}
   */
  getOrCreateProgressSlot()
  {
    // Prefer the node currently mounted in the dock — slide-out remounts can leave
    // `_progressElement` pointing at a detached panel while the rail shows a stale copy.
    let live = document.querySelector('.bv-dock .bv-dock-progress-panel')
    if (live) {
      this._progressElement = live
      return this._progressElement
    }
    if (this._progressElement != null && this._isProgressSlotConnected(this._progressElement)) {
      return this._progressElement
    }
    this._progressElement = BrazenViewLayer.createDownloadManagerProgressSlot()
    return this._progressElement
  }

  /**
   * @param {HTMLElement|null|undefined} slot
   * @return {boolean}
   * @private
   */
  _isProgressSlotConnected(slot)
  {
    return !!(slot && slot.isConnected)
  }

  /**
   * Rebuild Start/Pause slide-out so a detached progress panel can remount.
   * @private
   */
  _remountDownloadStartPauseSlot()
  {
    let field = this._cm.getField(DOCK_DOWNLOAD_START_PAUSE)
    if (!field?.dockElement) {
      return
    }
    this._cm._refreshDockRootSlot(field)
  }

  /**
   * @return {Promise<void>}
   */
  async toggleTagDiscoveryMode()
  {
    if (!this._config.tagDiscovery) {
      return
    }
    await this._withState((state) => {
      state.tagDiscoveryEnabled = !state.tagDiscoveryEnabled
    })
    this._framework.refreshDockInterface(undefined, {layout: false})
  }

  /**
   * @return {void}
   */
  toggleSelectionMode()
  {
    if (!this.isDownloadPageRole('selection')) {
      return
    }
    this._selectionModeActive = !this._selectionModeActive
    this._framework.refreshDockInterface(undefined, {layout: false})
    this._bindSelectionHandlers()
    this.registerSelectionDragGuard()
    if (!this._selectionModeActive) {
      this._clearAllItemProgress()
      return
    }
    this._paintSelectionMarksFromMirror()
    this._refreshSelectionMarks()
  }

  /**
   * @return {Promise<void>}
   */
  async toggleCurrentMediaQueued()
  {
    if (!this.isDownloadPageRole('enqueueMedia')) {
      return
    }
    let itemId = this._config.getQueueItemId({sourceUrl: location.href})
    if (!itemId) {
      return
    }
    let wasQueued = await this.isQueued(itemId)
    // Optimistic chrome so the click always flips icon/active before IDB settles.
    this._currentMediaQueued = !wasQueued
    this._paintCurrentMediaQueueDockButton()
    if (wasQueued) {
      // Stop an in-flight claim from rewriting the row after remove.
      if (String(this._activeResolutionItemId) === String(itemId)
          || String(this._activeDownloadItemId) === String(itemId)) {
        this._processorEpoch++
        this._activeResolutionItemId = null
        this._activeDownloadItemId = null
      }
      await this.dequeueDownload(itemId)
    } else {
      await this.enqueueDownload({
        itemId,
        sourceUrl: location.href,
        downloadType: this._getActivePageConfig()?.defaultDownloadType ?? 'post',
        resolveContext: {fromMediaPage: true},
      })
    }
    // Authoritative re-read — dequeue/enqueue sync must not leave a stale active icon.
    this._currentMediaQueued = await this.isQueued(itemId)
    this._paintCurrentMediaQueueDockButton()
  }

  /**
   * Paint Add/Remove queue dock chrome from {@link _currentMediaQueued}.
   * @private
   */
  _paintCurrentMediaQueueDockButton()
  {
    let field = this._cm.getField(DOCK_ADD_TO_QUEUE)
    if (!field?.dockElement) {
      this._framework.refreshDockInterface(undefined, {layout: false})
      return
    }
    this._cm._updateDockFieldButton(field)
  }

  /**
   * Refresh sync cache for the media-page Add/Remove queue dock button.
   * @param {{refreshDock?: boolean}} [options]
   * @return {Promise<void>}
   * @private
   */
  async _refreshCurrentMediaQueueState(options = {})
  {
    if (!this.isDownloadPageRole('enqueueMedia')) {
      this._currentMediaQueued = false
      if (options.refreshDock !== false) {
        this._paintCurrentMediaQueueDockButton()
      }
      return
    }
    let itemId = this._config.getQueueItemId?.({sourceUrl: location.href})
    if (!itemId) {
      this._currentMediaQueued = false
    } else {
      this._currentMediaQueued = await this.isQueued(itemId)
    }
    if (options.refreshDock !== false) {
      this._paintCurrentMediaQueueDockButton()
    }
  }

  /**
   * Whether the active DM page config lists `role` in `roles`.
   * Known roles: `selection`, `enqueueMedia`, `tagDiscoveryToggle`,
   * `dashboard` (dedicated control / future-analytics host — does not grant page-oriented
   * queue actions and does not auto-claim processor leadership).
   * @param {string} role
   * @return {boolean}
   */
  isDownloadPageRole(role)
  {
    let pageName = this._getActivePageName()
    if (!pageName) {
      return false
    }
    let pageConfig = this._config.pages?.[pageName]
    return !!(pageConfig?.roles?.includes(role))
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerEnabled()
  {
    if (!this._cm.isDockActive()) {
      return false
    }
    if (this._config.enabled === true) {
      return true
    }
    let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
    return !!this._framework._getConfig(enableKey)
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerRunning()
  {
    if (!this.isDownloadManagerEnabled()) {
      return false
    }
    let state = this._getStateSync()
    if (!state) {
      return false
    }
    if (this._processing || this._resolutionProcessing || this._downloadProcessing) {
      return true
    }
    if (this._anyHumanInteraction(state) || state.resolutionBlocked) {
      return true
    }
    if (!state.resolutionBlocked && this.getPendingResolutionCountSync() > 0) {
      return true
    }
    if (!state.paused && this.getPendingDownloadCountSync() > 0) {
      return true
    }
    return false
  }

  /**
   * True when this tab owns processor leadership ({@link processingTabId}).
   * @return {boolean}
   */
  isDownloadManagerLeaderTab()
  {
    return this._getStateSync()?.processingTabId === this._tabId
  }

  /**
   * Make this tab the processor leader when the download manager is idle.
   * No-op when this tab already leads. Blocks with an alert while active.
   * @return {Promise<boolean>} True when this tab owns leadership after the call.
   */
  async requestDownloadManagerLeadership()
  {
    if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
      return false
    }
    if (this.isDownloadManagerLeaderTab()) {
      return true
    }
    if (this._isQueueVerificationTab()) {
      return false
    }
    this._cachedDownloadCount = await this._getPendingDownloadCount()
    this._cachedResolutionCount = await this._getPendingResolutionCount()
    this._cachedState = await this._getState()
    if (this.isDownloadManagerRunning()) {
      alert("Can't switch leader while the download manager is active.")
      return false
    }
    if (!await this._forceClaimProcessingLeadership()) {
      return false
    }
    this._signalProcessorsWake()
    this._cm.refreshDockButtonStates()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
    return this.isDownloadManagerLeaderTab()
  }

  /**
   * @param {string[]} lines
   * @param {function(string): string} normalizeToken
   * @return {{subject: string, replacement: string}[]}
   */
  parseDownloadTagSubstitutionLines(lines, normalizeToken)
  {
    return this._parseDownloadTagSubstitutionLines(lines, normalizeToken)
  }

  /**
   * @param {{subject: string, replacement: string}[]} rules
   * @return {Map<string, string>}
   */
  buildDownloadTagSubstitutionMap(rules)
  {
    return this._buildDownloadTagSubstitutionMap(rules)
  }

  // -------------------------------------------------------------------------
  // Path creation (moved from BrazenFramework)
  // -------------------------------------------------------------------------

  /**
   * @param {string} text
   * @return {string}
   */
  decodeHtmlEntities(text)
  {
    return Utilities.decodeHtmlEntities(text)
  }

  /**
   * @param {string} segment
   * @return {string}
   */
  sanitizePathSegment(segment)
  {
    return this.decodeHtmlEntities(String(segment)).
        replace(/[<>:"/\\|?*]/g, '-').
        replace(/[.\s]+$/, '').
        trim()
  }

  /**
   * @param {string} folder
   * @param {string} name
   * @return {string}
   */
  buildDownloadPath(folder, name)
  {
    let folderPath = String(folder).split('/').
        map((segment) => this.sanitizePathSegment(segment)).
        filter((segment) => segment.length).
        join('/').
        substring(0, 120).
        replace(/[.\s/]+$/, '')

    let fileName = this.sanitizePathSegment(name) || 'media'
    return folderPath ? folderPath + '/' + fileName : fileName
  }

  /**
   * Prefetch tag registry rows needed for filename ignore / substitutions.
   * @param {{}} tagGroups
   * @return {Promise<void>}
   * @private
   */
  async _ensureDownloadPathTagsLoaded(tagGroups)
  {
    let tagRuntime = this._cm.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    let normalize = this._getDownloadTagNormalize()
    let stripCharacterSeries = false
    try {
      stripCharacterSeries = !!this._config.downloadPaths?.getPatternResolver?.()?.stripCharacterSeries
    } catch (error) {
      stripCharacterSeries = false
    }
    let names = []
    for (let group of Object.values(tagGroups ?? {})) {
      if (Array.isArray(group)) {
        for (let name of group) {
          if (!name) {
            continue
          }
          names.push(name)
          let normalized = normalize(name)
          if (normalized && normalized !== name) {
            names.push(normalized)
          }
          if (stripCharacterSeries && normalized) {
            let stripped = normalized.replace(/_\([^)]*\)$/, '')
            if (stripped && stripped !== normalized) {
              names.push(stripped)
            }
          }
        }
      }
    }
    if (names.length) {
      await tagRuntime.ensureNames(names)
    } else {
      await tagRuntime.warmCache()
    }
    try {
      await tagRuntime.refreshDownloadRulesetMaps?.()
    } catch (error) {
      // Join / ignore review fall back to ruleset reads when maps are unavailable.
    }
  }

  /**
   * Consumer tag normalizer for filename ignore / substitution lookups (same as attribute actions).
   * @return {function(string): string}
   * @private
   */
  _getDownloadTagNormalize()
  {
    let fromResolver = null
    try {
      fromResolver = this._config.downloadPaths?.getPatternResolver?.()?.normalizeTag
    } catch (error) {
      fromResolver = null
    }
    if (typeof fromResolver === 'function') {
      return fromResolver
    }
    let discovery = this._config.tagDiscovery ?? {}
    let fromActions = discovery.actions?.normalize ?? discovery.normalizeTag
    if (typeof fromActions === 'function') {
      return fromActions
    }
    return (value) => String(value ?? '').trim()
  }

  /**
   * @param {{}} data
   * @param {{}} tagGroups
   * @return {string}
   */
  buildDownloadPathFromPatterns(data, tagGroups)
  {
    let paths = this._config.downloadPaths
    let resolver = paths.getPatternResolver()
    let filenamePattern = this._framework._getConfig(paths.filenamePatternConfigKey)
    let name = this._resolveDownloadPattern(filenamePattern, data, tagGroups, resolver)
    if (!name) {
      name = paths.nameFallback?.(data) ?? (data.md5 || data.id || 'media')
    }
    if (paths.appendExtension && data.ext) {
      name += '.' + data.ext
    }

    let root = this._framework._getConfig(paths.folderConfigKey) || paths.defaultFolder || ''
    let subfolderPattern = paths.subfolderPatternConfigKey ?
        this._framework._getConfig(paths.subfolderPatternConfigKey) :
        ''
    let subfolder = subfolderPattern ? this._resolveDownloadPattern(subfolderPattern, data, tagGroups, resolver) : ''
    let folder = subfolder ? root + '/' + subfolder : root
    return this.buildDownloadPath(folder, name)
  }

  /**
   * @param {string} pattern
   * @param {{}} data
   * @param {{}} tagGroups
   * @param {object} resolver
   * @return {string}
   * @private
   */
  _resolveDownloadPattern(pattern, data, tagGroups, resolver)
  {
    let resolved = pattern
    let chips = [...resolver.chips].sort((a, b) => b.label.length - a.label.length)
    for (let chip of chips) {
      let value
      if (resolver.tagTypes.includes(chip.token)) {
        value = this._resolveDownloadTagTypeTokenForPath(pattern, tagGroups[chip.token] ?? [], chip.token, resolver.ignore, resolver)
      } else {
        value = data[chip.token] ?? ''
      }
      resolved = resolved.replaceAll(chip.label, value)
    }
    return resolved.replace(/\s+/g, ' ').trim()
  }

  /**
   * @param {string} pattern
   * @param {string[]} tags
   * @param {string} type
   * @param {Set<string>} ignore
   * @param {object} resolver
   * @return {string}
   * @private
   */
  _resolveDownloadTagTypeTokenForPath(pattern, tags, type, ignore, resolver)
  {
    let value = this._joinTagsForDownloadPath(tags, type, ignore, resolver)
    if (!value && this._patternEmploysDownloadTagTypeToken(pattern, type, resolver.chips, resolver.unknownTypes)) {
      return resolver.unknownDefault
    }
    return value
  }

  /**
   * @param {string} pattern
   * @param {string} type
   * @param {{token: string, label: string}[]} chips
   * @param {Set<string>} unknownTypes
   * @return {boolean}
   * @private
   */
  _patternEmploysDownloadTagTypeToken(pattern, type, chips, unknownTypes)
  {
    if (!unknownTypes.has(type)) {
      return false
    }
    for (let chip of chips) {
      if (chip.token === type && this.patternIncludesChip(pattern, chip)) {
        return true
      }
    }
    return false
  }

  /**
   * True when {@link pattern} references a chip via label, {label}, or {token}.
   * @param {string} pattern
   * @param {{token?: string, label?: string}} chip
   * @return {boolean}
   */
  patternIncludesChip(pattern, chip)
  {
    if (!pattern || !chip) {
      return false
    }
    // Label / {label} / {token} only — bare token substrings (e.g. meta) over-match.
    let needles = []
    if (chip.label) {
      needles.push(chip.label, `{${chip.label}}`)
    }
    if (chip.token) {
      needles.push(`{${chip.token}}`)
    }
    for (let needle of needles) {
      if (needle && pattern.includes(needle)) {
        return true
      }
    }
    return false
  }

  /**
   * @param {string[]} tags
   * @param {string|null} type
   * @param {Set<string>|null} ignore
   * @param {object} options
   * @return {string}
   * @private
   */
  _joinTagsForDownloadPath(tags, type, ignore, options)
  {
    let normalize = typeof options.normalizeTag === 'function' ?
        options.normalizeTag :
        this._getDownloadTagNormalize()
    // Registry ignore / substitution keys are normalized (same as attribute toggles).
    tags = (Array.isArray(tags) ? tags : []).
        map((tag) => normalize(tag)).
        filter(Boolean)
    let applyFilenameIgnore = options.applyFilenameIgnore !== false
    if (options.tagRuntime?._warmed) {
      tags = options.tagRuntime.applyDownloadAttributesSync(
          tags, options.stripCharacterSeries, applyFilenameIgnore)
    } else {
      tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
      if (applyFilenameIgnore && ignore?.size) {
        tags = tags.filter((tag) => {
          if (ignore.has(tag) || ignore.has(normalize(tag))) {
            return false
          }
          if (options.stripCharacterSeries && type === 'character') {
            let stripped = tag.replace(/_\([^)]*\)$/, '')
            if (stripped !== tag && (ignore.has(stripped) || ignore.has(normalize(stripped)))) {
              return false
            }
          }
          return true
        })
      }
    }

    let seen = new Set()
    let unique = []
    for (let tag of tags) {
      let formatted = this._formatTagForDownloadPath(tag, type, options.stripCharacterSeries)
      if (!formatted || seen.has(formatted)) {
        continue
      }
      seen.add(formatted)
      unique.push(formatted)
    }
    return unique.
        sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})).
        join(options.multiTagSeparator)
  }

  /**
   * @param {string[]} tags
   * @param {string|null} type
   * @param {Map<string, string>} substitutions
   * @param {boolean} stripCharacterSeries
   * @return {string[]}
   * @private
   */
  _applyDownloadTagSubstitutions(tags, type, substitutions, stripCharacterSeries)
  {
    if (!substitutions.size) {
      return tags
    }
    return tags.map((tag) => this._resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries))
  }

  /**
   * @param {string} tag
   * @param {string|null} type
   * @param {Map<string, string>} substitutions
   * @param {boolean} stripCharacterSeries
   * @return {string}
   * @private
   */
  _resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries)
  {
    if (substitutions.has(tag)) {
      return substitutions.get(tag)
    }
    if (type === 'character' && stripCharacterSeries) {
      let stripped = tag.replace(/_\([^)]*\)$/, '')
      if (stripped !== tag && substitutions.has(stripped)) {
        return substitutions.get(stripped)
      }
    }
    return tag
  }

  /**
   * @param {string} tag
   * @param {string|null} type
   * @param {boolean} stripCharacterSeries
   * @return {string}
   * @private
   */
  _formatTagForDownloadPath(tag, type, stripCharacterSeries)
  {
    let formatted = this.decodeHtmlEntities(tag)
    if (type === 'character' && stripCharacterSeries) {
      formatted = formatted.replace(/_\([^)]*\)$/, '')
    }
    return formatted.replaceAll('_', ' ')
  }

  /**
   * @param {string[]|{subject: string, replacement: string}[]} lines
   * @param {function(string): string} normalizeToken
   * @return {{subject: string, replacement: string}[]}
   * @private
   */
  _parseDownloadTagSubstitutionLines(lines, normalizeToken)
  {
    let rules = []
    for (let line of lines) {
      if (typeof line === 'object' && line?.subject && line?.replacement) {
        let subject = normalizeToken(line.subject)
        let replacement = normalizeToken(line.replacement)
        if (subject.length && replacement.length) {
          rules.push({subject, replacement})
        }
        continue
      }
      if (typeof line !== 'string') {
        continue
      }
      let match = line.match(/^(.+?)\s*(?:→|->|-)\s+(.+)$/)
      if (!match) {
        continue
      }
      let subject = normalizeToken(match[1])
      let replacement = normalizeToken(match[2])
      if (subject.length && replacement.length) {
        rules.push({subject, replacement})
      }
    }
    return rules
  }

  /**
   * @param {{subject: string, replacement: string}[]} rules
   * @return {Map<string, string>}
   * @private
   */
  _buildDownloadTagSubstitutionMap(rules)
  {
    let map = new Map()
    for (let rule of rules) {
      map.set(rule.subject, rule.replacement)
    }
    return map
  }

  // -------------------------------------------------------------------------
  // Download execution
  // -------------------------------------------------------------------------

  /**
   * @param {{name: string|null, element: HTMLElement|null, url: string, downloadId?: string|null, restorePictureOnFailure?: boolean}} download
   * @return {Promise<void>}
   * @private
   */
  _wrapDownloadTask(download)
  {
    let path = download.name
    if (!path) {
      alert('Download failed: missing file path.')
      return Promise.resolve()
    }

    return new Promise((resolve) => {
      download.element?.remove()
      GM_download({
        url: download.url,
        name: path,
        // Filenames come from user patterns — never uniquify (no file(1).ext).
        conflictAction: 'overwrite',
        onload: () => {
          this._framework._handleDownloadSucceeded(download)
          resolve()
        },
        onerror: (error) => {
          this._framework._handleDownloadFailed(download, error)
          resolve()
        },
      })
    })
  }

  /**
   * @param {object} item
   * @return {Promise<void>}
   * @private
   */
  async _processDownloadItem(item)
  {
    try {
      // Status is already `downloading` from `_claimDownloadQueueItem`.

      let payload = item.resolvedPayload ?? {}
      await this._ensureDownloadPathTagsLoaded(payload.tagGroups ?? {})
      let path = this.buildDownloadPathFromPatterns(payload.data ?? {}, payload.tagGroups ?? {})
      let downloadUrl = payload.mediaUrl ?? ''
      let downloadId = payload.downloadId ?? item.downloadId ?? item.itemId

      // Claim once per queue item. Retries after lock steal / requeue must not re-claim —
      // the ledger is reserved before GM_download (Tampermonkey often never fires onload).
      if (!item.ledgerClaimed) {
        // IndexedDB membership (bounded positive cache + postId index) — never getAll the ledger.
        if (await this._framework._isDownloadDuplicateAsync(downloadId)) {
          item.status = 'duplicate'
          if (await this._commitDownloadRow(item)) {
            await this._incrementDownloadProgress()
          }
          return
        }
        if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
            !(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
          item.status = 'duplicate'
          if (await this._commitDownloadRow(item)) {
            await this._incrementDownloadProgress()
          }
          return
        }
        if (this._framework._shouldClaimDownloadDuplicateLedger() && downloadId) {
          item.ledgerClaimed = true
          if (!(await this._commitDownloadRow(item))) {
            return
          }
        }
      }

      if (!downloadUrl) {
        item.status = 'failed'
        item.error = 'Missing media URL'
        if (await this._commitDownloadRow(item)) {
          await this._incrementDownloadProgress()
        }
        return
      }

      try {
        await this._paceDownloadInitiation()
        if (!(await this._isActiveDownloadClaim(item.itemId))) {
          return
        }
        // Persist before GM_download so a mid-transfer navigation can detect an orphan.
        item.inProgress = true
        item.startedAt = Date.now()
        if (!(await this._commitDownloadRow(item))) {
          return
        }
        await this._wrapDownloadTask({name: path, element: null, url: downloadUrl, downloadId})
        item.status = 'done'
        item.inProgress = false
      } catch (error) {
        item.inProgress = false
        let rateLimit = await this._handleRateLimit('download', error, {sourceUrl: downloadUrl, resolvingUrl: downloadUrl}, item)
        if (rateLimit === 'blocked') {
          item.status = 'queued'
          item.error = null
          await this._commitDownloadRow(item)
          return
        }
        item.status = 'failed'
        item.error = String(error)
      }
      if (await this._commitDownloadRow(item)) {
        await this._incrementDownloadProgress()
      }
    } finally {
      await this._onDownloadTaskComplete(item.itemId)
    }
  }

  // -------------------------------------------------------------------------
  // Resolution pipeline
  // -------------------------------------------------------------------------

  /**
   * @param {string} url
   * @return {Promise<Document>}
   * @private
   */
  async _resolvePage(url)
  {
    let epoch = this._processorEpoch
    let controller = new AbortController()
    let timeoutId = setTimeout(() => controller.abort(), 30000)
    let onPageHide = () => controller.abort()
    window.addEventListener('pagehide', onPageHide, {once: true})
    try {
      if (this._documentSuspended || this._processorEpoch !== epoch) {
        throw Object.assign(new Error('Page resolution aborted'), {status: 0, doc: null, url})
      }
      let response = await fetch(url, {
        credentials: 'include',
        signal: controller.signal,
        headers: {Accept: 'text/html'},
      })
      if (this._documentSuspended || this._processorEpoch !== epoch) {
        throw Object.assign(new Error('Page resolution aborted'), {status: 0, doc: null, url})
      }
      let text = await response.text()
      let doc = new DOMParser().parseFromString(text, 'text/html')
      // Keep status on reject so timedReload / humanInteraction detectors can use it;
      // still attach parsed HTML (site 429 / Cloudflare bodies often arrive as 200 or 4xx).
      if (response.status === 429) {
        throw Object.assign(new Error('HTTP 429'), {status: 429, doc, url})
      }
      if (!response.ok) {
        throw Object.assign(new Error('HTTP ' + response.status), {status: response.status, doc, url})
      }
      return doc
    } catch (error) {
      if (error?.doc !== undefined || error?.status !== undefined) {
        throw error
      }
      let message = error?.name === 'AbortError'
          ? 'Page resolution aborted'
          : ('Page resolution failed: ' + url + ' — ' + (error?.message || error))
      throw Object.assign(new Error(message), {status: 0, doc: null, url})
    } finally {
      clearTimeout(timeoutId)
      window.removeEventListener('pagehide', onPageHide)
    }
  }

  /**
   * @param {object} item
   * @return {Promise<void>}
   * @private
   */
  async _processResolutionItem(item)
  {
    try {
      // Status is already `resolving` from `_claimResolutionQueueItem`.
      let typeHandler = this._config.downloadTypes?.[item.downloadType]
      if (!typeHandler) {
        item.status = 'failed'
        item.error = 'Unknown download type: ' + item.downloadType
        if (await this._commitResolutionRow(item)) {
          await this._incrementResolutionProgress()
        }
        return
      }

      try {
        let ctx = {
          ...item.resolveContext,
          sourceUrl: item.sourceUrl,
          itemId: item.itemId,
          downloadType: item.downloadType,
          resolvingUrl: item.sourceUrl,
        }

        if (ctx.fromMediaPage && typeHandler.resolveFromMedia) {
          let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [document, ctx], null)
          await this._handleResolvedPayload(item, resolved)
          return
        }

        let searchStep = typeHandler.resolveFromSearch ?
            await Utilities.callEventHandler(typeHandler.resolveFromSearch, [ctx], null) :
            {nextUrl: item.sourceUrl, downloadType: item.downloadType}
        let nextUrl = searchStep?.nextUrl ?? item.sourceUrl
        ctx.resolvingUrl = nextUrl

        let doc
        while (true) {
          // Pace every attempt (including timedReload retries and post–human-interaction
          // resume) so a long Cloudflare pause cannot leave the clock stale relative to
          // the next burst of resolves.
          await this._paceResolutionInitiation()
          if (!(await this._isActiveResolutionClaim(item.itemId))) {
            return
          }
          try {
            doc = await this._resolvePage(nextUrl)
          } catch (error) {
            let rateLimit = await this._handleRateLimit('resolution', error, ctx, item)
            if (rateLimit === 'retry') {
              continue
            }
            if (rateLimit === 'blocked') {
              item.status = 'queued'
              await this._commitResolutionRow(item)
              return
            }
            throw error
          }

          let rateLimit = await this._handleRateLimit('resolution', doc, ctx, item)
          if (rateLimit === 'retry') {
            continue
          }
          if (rateLimit === 'blocked') {
            item.status = 'queued'
            await this._commitResolutionRow(item)
            return
          }
          break
        }

        if (typeHandler.resolveFromMedia) {
          let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [doc, ctx], null)
          if (resolved?.childUrls?.length) {
            await this._fanOutChildUrls(item, resolved.childUrls, resolved.downloadType ?? 'post')
            item.status = 'done'
            if (await this._removeResolutionRowIfActive(item.itemId)) {
              await this._incrementResolutionProgress()
            }
            return
          }
          await this._handleResolvedPayload(item, resolved)
          return
        }

        item.status = 'failed'
        item.error = 'No resolveFromMedia handler'
        if (await this._commitResolutionRow(item)) {
          await this._incrementResolutionProgress()
        }
      } catch (error) {
        item.status = 'failed'
        item.error = String(error?.message ?? error)
        if (await this._commitResolutionRow(item)) {
          await this._incrementResolutionProgress()
        }
      }
    } finally {
      await this._onResolutionTaskComplete(item.itemId)
    }
  }

  /**
   * @param {object} item
   * @param {object|null} resolved
   * @return {Promise<void>}
   * @private
   */
  async _handleResolvedPayload(item, resolved)
  {
    if (!resolved?.mediaUrl) {
      item.status = 'failed'
      item.error = 'Resolution produced no media URL'
      if (await this._commitResolutionRow(item)) {
        await this._incrementResolutionProgress()
      }
      return
    }

    let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
    let tagIncidences = resolved.tagIncidences ?? {}
    await this._registerResolvedTagGroups(tagGroups)
    if (this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')) {
      let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
      if (lists.unknown.length) {
        if (this._shouldDeferTagDiscoveryReview()) {
          item.status = 'discoveryQueued'
          item.pendingTagGroups = resolved
          if (!(await this._commitResolutionRow(item))) {
            return
          }
          return
        }
        item.status = 'tagReview'
        item.pendingTagGroups = resolved
        if (!(await this._commitResolutionRow(item))) {
          return
        }
        await this._withState((state) => {
          state.resolutionBlocked = true
          state.resolutionBlockedItemId = item.itemId
          state.discoveryReviewMode = 'unknown'
          // Fresh list for this item — do not merge leftover tags from a prior review.
          state.discoveryPanelTags = lists.unknown
          state.discoveryPanelKnownTags = lists.known
        })
        let state = this._getStateSync()
        await this._presentTagDiscoveryReview(state.discoveryPanelTags, state.discoveryPanelKnownTags)
        return
      }
    }

    if (await this._tryBeginIgnoredPinReview(item, resolved, tagGroups, tagIncidences)) {
      return
    }

    if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
      await this._dropResolvedWithoutDownload(item)
      return
    }

    if (!(await this._promoteToDownloadQueue(item, resolved))) {
      return
    }
    if (!(await this._removeResolutionRowIfActive(item.itemId))) {
      return
    }
    await this._incrementResolutionProgress()
  }

  /**
   * @param {object} item
   * @param {object} resolved
   * @return {Promise<boolean>}
   * @private
   */
  async _promoteToDownloadQueue(item, resolved)
  {
    let row = {
      itemId: item.itemId,
      downloadType: item.downloadType,
      resolvedPayload: resolved,
      status: 'queued',
      downloadId: resolved.downloadId ?? null,
      ledgerClaimed: false,
      inProgress: false,
      startedAt: null,
      addedAt: Date.now(),
      error: null,
    }
    await this._repos().downloadQueue.put(row)
    this._kickDownloadProcessor()
    return true
  }

  /**
   * Wake the download pipeline after new Ready rows appear (promote / confirm).
   * @private
   */
  _kickDownloadProcessor()
  {
    // Promote may run on a follower after Confirm — wake the leader if we cannot run here.
    this._signalProcessorsWake()
    if (!this._shouldRunProcessors()) {
      return
    }
    void this._runProcessors()
  }

  /**
   * @param {object} item
   * @param {string[]} childUrls
   * @param {string} childType
   * @return {Promise<void>}
   * @private
   */
  async _fanOutChildUrls(item, childUrls, childType)
  {
    for (let sourceUrl of childUrls) {
      let childId = this._config.getQueueItemId({sourceUrl})
      if (!childId) {
        continue
      }
      await this.enqueueDownload({
        itemId: childId,
        sourceUrl,
        downloadType: childType,
        resolveContext: {parentItemId: item.itemId},
      })
    }
  }

  // -------------------------------------------------------------------------
  // Rate limits
  // -------------------------------------------------------------------------

  /**
   * @param {'resolution'|'download'} context
   * @param {*} signal
   * @param {object} ctx
   * @param {object} item
   * @return {Promise<'retry'|'blocked'|false>}
   * @private
   */
  async _handleRateLimit(context, signal, ctx, item)
  {
    let handlers = this._config.rateLimitHandlers?.[context]
    if (!handlers) {
      return false
    }

    // Prefer humanInteraction over timedReload. Cloudflare challenges often arrive as
    // HTTP 429 with a CAPTCHA body — a bare status===429 must not auto-retry those.
    let payload = signal?.doc ?? signal

    if (handlers.humanInteraction) {
      let detect = handlers.humanInteraction.detect
      let matched = detect ?
          Utilities.callEventHandler(detect, [payload, ctx], false) :
          signal?.status === 429
      if (matched) {
        let openUrl = handlers.humanInteraction.openUrl?.(ctx) ?? ctx.resolvingUrl ?? ctx.sourceUrl
        await this._beginHumanInteractionRateLimit(context, item, openUrl)
        return 'blocked'
      }
    }

    if (handlers.timedReload) {
      let detect = handlers.timedReload.detect
      if (Utilities.callEventHandler(detect, [payload, ctx], false)) {
        let delayMs = typeof handlers.timedReload.delayMs === 'function' ?
            handlers.timedReload.delayMs(ctx) :
            handlers.timedReload.delayMs
        await Utilities.sleep(delayMs ?? 5000)
        return 'retry'
      }
    }

    return false
  }

  /**
   * @param {BrazenDownloadManagerLaneId} context
   * @param {object|null|undefined} item
   * @param {string|null|undefined} openUrl
   * @return {Promise<void>}
   * @private
   */
  async _beginHumanInteractionRateLimit(context, item, openUrl)
  {
    let openUrlMarked = openUrl ? this._markHumanInteractionOpenUrl(openUrl, context) : null
    // Sync mirror before the user opens the challenge so Phase-1 mediaCloudflare can detect HI.
    this._writeHumanInteractionBlockMirror(true)
    // Only a visible tab may own the Open/Done chrome. Background processor leaders leave
    // `promptTabId` null so a focused follower can steal immediately on wake.
    let canOwnPrompt = document.visibilityState === 'visible' && !this._documentSuspended
    // Serialize through `_withState` so a concurrent heartbeat/progress put cannot wipe
    // initiation clocks (or clear this block) via a stale raw `_putState` snapshot.
    // Per-lane: writing one lane never clobbers the other.
    let began = false
    await this._withState((state) => {
      this._ensureHumanInteractionMap(state)
      if (state.humanInteraction[context]) {
        // Refresh URL / item; do not re-pin ownership from a hidden initiator.
        let existing = state.humanInteraction[context]
        let promptTabId = canOwnPrompt
            ? this._tabId
            : (existing.promptTabId === this._tabId ? null : (existing.promptTabId ?? null))
        state.humanInteraction[context] = {
          itemId: item?.itemId ?? existing.itemId ?? null,
          promptTabId,
          openUrl: openUrlMarked ?? existing.openUrl ?? null,
          at: existing.at ?? Date.now(),
        }
        began = true
        return
      }
      state.humanInteraction[context] = {
        itemId: item?.itemId ?? null,
        promptTabId: canOwnPrompt ? this._tabId : null,
        openUrl: openUrlMarked,
        at: Date.now(),
      }
      began = true
    })
    if (!began) {
      return
    }
    this._refreshAllItemProgress()
    // Show the dock panel only when this tab is focused — do not auto-open the challenge.
    if (canOwnPrompt) {
      await this._promptHumanInteractionResume(context)
    }
    // Wake other visible tabs so a focused follower can steal the panel if this tab
    // stays backgrounded (same pattern as tag discovery).
    this._signalProcessorsWake()
    await this._syncFromStorage()
  }

  /**
   * Marks verification URLs so the opened mediaCloudflare tab stays silent
   * (queue confirmation stays on the processor dock panel).
   * Query `brazen_hi=1` (+ `brazen_hi_ctx`) plus hash — CF often replaceState-strips
   * search params but leaves the hash.
   * @param {string} url
   * @param {BrazenDownloadManagerLaneId} [context]
   * @return {string}
   * @private
   */
  _markHumanInteractionOpenUrl(url, context = null)
  {
    try {
      let parsed = new URL(url, location.href)
      parsed.searchParams.set('brazen_hi', '1')
      if (context === 'resolution' || context === 'download') {
        parsed.searchParams.set('brazen_hi_ctx', context)
      }
      let hashParts = ['brazen_hi=1']
      if (context === 'resolution' || context === 'download') {
        hashParts.push('brazen_hi_ctx=' + context)
      }
      if (!/(^|[&#])brazen_hi=1\b/.test(parsed.hash)) {
        parsed.hash = hashParts.join('&')
      } else if (context && !/(^|[&#])brazen_hi_ctx=/.test(parsed.hash)) {
        parsed.hash = parsed.hash.replace(/^#/, '') + '&brazen_hi_ctx=' + context
      }
      return parsed.href
    } catch {
      return url
    }
  }

  /**
   * Open the verification tab while keeping `window.opener` set.
   * Chrome 88+ treats `window.open(url, '_blank')` as `noopener` by default, which
   * made queue CF tabs look "standalone" and revived the old grey confirm dialog.
   * @param {string} url
   * @private
   */
  _openHumanInteractionTab(url)
  {
    let anchor = document.createElement('a')
    anchor.href = url
    anchor.target = '_blank'
    anchor.rel = 'opener'
    anchor.style.display = 'none'
    document.documentElement.appendChild(anchor)
    anchor.click()
    anchor.remove()
  }

  /**
   * @return {string}
   * @private
   */
  _humanInteractionBlockMirrorKey()
  {
    return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-human-interaction-blocked')
  }

  /**
   * @param {boolean} blocked
   * @private
   */
  _writeHumanInteractionBlockMirror(blocked)
  {
    try {
      let key = this._humanInteractionBlockMirrorKey()
      if (blocked) {
        localStorage.setItem(key, '1')
      } else {
        localStorage.removeItem(key)
      }
    } catch (e) {
      // ignore quota / private-mode localStorage failures
    }
  }

  /**
   * @return {boolean}
   */
  isHumanInteractionBlockedSync()
  {
    return this._anyHumanInteraction(this._getStateSync())
  }

  /**
   * Phase-1 mediaCloudflare (or equivalent) page operation.
   * Queue verification / HI-blocked tabs show **Done — resume** / **Done — resume — close tab**
   * on this media page (where the CAPTCHA is). Standalone browsing shows **Done — reload**
   * (never a grey modal).
   * @param {{
   *   title?: string,
   *   message?: string,
   *   confirmLabel?: string,
   *   confirmAndCloseLabel?: string,
   * }} [options]
   * @return {Promise<void>}
   */
  async handleMediaCloudflarePage(options = {})
  {
    let scriptPrefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
    if (await BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)) {
      // Silence formerly meant "no UI" — now the challenge tab owns Done — resume.
      this._showQueueChallengeResumePanel(options)
      return
    }
    this._showStandaloneCloudflareReloadPanel(options)
  }

  /**
   * @return {Promise<boolean>}
   */
  async shouldSilenceMediaCloudflarePrompt()
  {
    let scriptPrefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
    return BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)
  }

  /**
   * Lane entry helper (instance).
   * @param {object|null|undefined} state
   * @param {BrazenDownloadManagerLaneId} ctx
   * @return {BrazenHumanInteractionLane|null}
   * @private
   */
  _hiLane(state, ctx)
  {
    return BrazenDownloadManager._hiLaneState(state, ctx)
  }

  /**
   * @param {object|null|undefined} state
   * @return {boolean}
   * @private
   */
  _anyHumanInteraction(state)
  {
    return BrazenDownloadManager._anyHumanInteractionState(state)
  }

  /**
   * @param {object|null|undefined} state
   * @return {BrazenDownloadManagerLaneId[]}
   * @private
   */
  _hiContexts(state)
  {
    return BrazenDownloadManager._hiContextsState(state)
  }

  /**
   * Ensure `state.humanInteraction` is a per-lane map (mutates in place).
   * @param {object} state
   * @private
   */
  _ensureHumanInteractionMap(state)
  {
    if (!state.humanInteraction || typeof state.humanInteraction !== 'object') {
      state.humanInteraction = {resolution: null, download: null}
    }
    if (!('resolution' in state.humanInteraction)) {
      state.humanInteraction.resolution = null
    }
    if (!('download' in state.humanInteraction)) {
      state.humanInteraction.download = null
    }
  }

  /**
   * Show the human-interaction dock panel for one lane on the focused tab (steals ownership).
   * Includes the queue verification / media challenge tab so Done — resume appears
   * where the CAPTCHA was completed.
   * @param {BrazenDownloadManagerLaneId} context
   * @param {{force?: boolean}} [options] `force` skips the visibility gate (rare; preferred
   *   path only shows on visible tabs so background leaders do not pin the chrome).
   * @return {Promise<void>}
   * @private
   */
  async _promptHumanInteractionResume(context, options = {})
  {
    if (context !== 'resolution' && context !== 'download') {
      return
    }
    let state = await this._getState()
    if (!this._hiLane(state, context)) {
      // Remote clear — drop a stale local panel instead of leaving a dead zombie.
      this._hideHumanInteractionPanel(context)
      return
    }
    if (!options.force && document.visibilityState !== 'visible') {
      return
    }
    if (!this._canOwnHumanInteractionPrompt()) {
      return
    }
    // Focused tab always claims / steals — same model as tag discovery (not processor-leader-only).
    await this._withState((next) => {
      this._ensureHumanInteractionMap(next)
      let lane = next.humanInteraction[context]
      if (!lane) {
        return
      }
      lane.promptTabId = this._tabId
    })
    state = this._getStateSync()
    let lane = this._hiLane(state, context)
    if (!lane || lane.promptTabId !== this._tabId) {
      return
    }
    let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
    let onChallengeTab = this._isQueueVerificationTab()
    let openLabel = this._resolveHumanInteractionOpenLabel(handlers)
    let defaultTitle = this._humanInteractionDefaultTitle(context)
    // Challenge media tab: always use the Phase-1 challenge pane (Done — resume +
    // Done — resume — close tab). Do not reuse the leader Open/Done chrome.
    if (onChallengeTab) {
      this._showQueueChallengeResumePanel({
        title: handlers.confirmTitle ?? defaultTitle,
        message: handlers.challengeConfirmMessage
            ?? 'Complete the verification on this page, then resume the download queue.',
        confirmLabel: handlers.confirmLabel,
        confirmAndCloseLabel: handlers.confirmAndCloseLabel,
      })
      return
    }
    let panel = this._ensureHumanInteractionPanel(context, {reopenLabel: openLabel})
    BrazenViewLayer.updateHumanInteractionPanelContent(panel, {
      title: handlers.confirmTitle ?? defaultTitle,
      message: handlers.confirmMessage,
      confirmLabel: handlers.confirmLabel,
      reopenLabel: openLabel,
    })
    let reopenButton = panel.querySelector('.bv-human-interaction-reopen')
    if (reopenButton) {
      reopenButton.hidden = false
    }
    this._showHumanInteractionPanelInteractive(context, panel)
  }

  /**
   * Label for the Open-challenge control. Ignores legacy consumer copy
   * ("Reopen verification tab") so GreasyFork apps without a local override still rename.
   * @param {object} [handlers]
   * @return {string}
   * @private
   */
  _resolveHumanInteractionOpenLabel(handlers = {})
  {
    let label = handlers.openChallengeLabel ?? handlers.reopenLabel
    if (!label || label === 'Reopen verification tab') {
      return 'Open Cloudflare Challenge'
    }
    return label
  }

  /**
   * @param {BrazenDownloadManagerLaneId} context
   * @return {string}
   * @private
   */
  _humanInteractionDefaultTitle(context)
  {
    return context === 'download' ? 'Download verification required' : 'Resolution verification required'
  }

  /**
   * @param {BrazenDownloadManagerLaneId} context
   * @param {{reopenLabel?: string}} [options]
   * @return {HTMLElement}
   * @private
   */
  _ensureHumanInteractionPanel(context, options = {})
  {
    let existing = this._humanInteractionPanels[context]
    if (existing) {
      // Recreate when left in an inconsistent offscreen/visible state (dead clicks).
      let stuckOffscreen = existing.classList.contains('bv-dock-panel-offscreen')
          && !existing.classList.contains('bv-dock-panel-hidden')
          && existing.style.display === 'flex'
      if (stuckOffscreen) {
        this._framework._hideDockSlidePanel(existing, false)
        existing.remove()
        this._humanInteractionPanels[context] = null
      } else {
        if (options.reopenLabel) {
          let reopenButton = existing.querySelector('.bv-human-interaction-reopen')
          if (reopenButton) {
            reopenButton.textContent = options.reopenLabel
          }
        }
        return existing
      }
    }
    let panelId = context === 'download'
        ? 'bv-human-interaction-panel-download'
        : 'bv-human-interaction-panel-resolution'
    let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
    let panel = BrazenViewLayer.createHumanInteractionPanel({
      id: panelId,
      onConfirm: () => { void this._confirmHumanInteractionResume(context) },
      onReopen: () => { void this._reopenHumanInteractionTab(context) },
      reopenLabel: options.reopenLabel,
      title: handlers.confirmTitle ?? this._humanInteractionDefaultTitle(context),
    })
    this._humanInteractionPanels[context] = panel
    BrazenViewLayer.appendToDockPanelStack(panel)
    return panel
  }

  /**
   * Show an HI panel and clear a stuck `bv-dock-panel-offscreen` (pointer-events: none).
   * @param {BrazenDownloadManagerLaneId} context
   * @param {HTMLElement} panel
   * @private
   */
  _showHumanInteractionPanelInteractive(context, panel)
  {
    if (!panel) {
      return
    }
    BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
    if (BrazenViewLayer.isDockSlidePanelVisible(panel) &&
        !panel.classList.contains('bv-dock-panel-offscreen')) {
      return
    }
    this._framework._showDockSlidePanel(panel)
    panel.classList.remove('bv-dock-panel-offscreen')
  }

  /**
   * Themed dock pane on a browsed challenge page (not queue-driven). Same chrome as the
   * leader verification panel — never a grey modal overlay.
   * @param {{title?: string, message?: string, confirmLabel?: string}} [options]
   * @private
   */
  _showStandaloneCloudflareReloadPanel(options = {})
  {
    if (document.getElementById('bv-human-interaction-panel')) {
      return
    }
    let panel = BrazenViewLayer.createHumanInteractionPanel({
      id: 'bv-human-interaction-panel',
      showReopen: false,
      title: options.title ?? 'Cloudflare challenge',
      message: options.message ??
          'Complete the CAPTCHA on this page, then confirm to reload and continue.',
      confirmLabel: options.confirmLabel ?? 'Done — reload',
      onConfirm: () => location.reload(),
    })
    BrazenViewLayer.appendToDockPanelStack(panel)
    this._framework._showDockSlidePanel(panel)
  }

  /**
   * Phase-1 queue Cloudflare tab: show Done — resume on the challenge media page itself
   * (full DM init is halted on mediaCloudflare).
   * @param {{
   *   title?: string,
   *   message?: string,
   *   confirmLabel?: string,
   *   confirmAndCloseLabel?: string,
   * }} [options]
   * @private
   */
  _showQueueChallengeResumePanel(options = {})
  {
    let context = BrazenDownloadManager._peekHumanInteractionContextFromLocation() ?? 'download'
    let panelId = context === 'resolution'
        ? 'bv-human-interaction-panel-resolution'
        : 'bv-human-interaction-panel-download'
    let handlers = this._config?.rateLimitHandlers?.[context]?.humanInteraction ?? {}
    let title = options.title ?? handlers.confirmTitle ??
        (context === 'resolution' ? 'Resolution verification required' : 'Cloudflare challenge')
    let message = options.message ?? handlers.challengeConfirmMessage ??
        'Complete the Cloudflare check on this page, then resume the download queue.'
    // Label defaults live on View Layer createHumanInteractionPanel — omit when unset.
    let confirmLabel = options.confirmLabel ?? handlers.confirmLabel
    let confirmAndCloseLabel = options.confirmAndCloseLabel ?? handlers.confirmAndCloseLabel

    let existing = this._humanInteractionPanels[context]
        ?? document.getElementById(panelId)
        ?? document.getElementById('bv-human-interaction-panel')
    if (existing?.querySelector?.('.bv-human-interaction-confirm-close')) {
      BrazenViewLayer.updateHumanInteractionPanelContent(existing, {
        title,
        message,
        confirmLabel,
        confirmAndCloseLabel,
      })
      existing.querySelector('.bv-human-interaction-reopen')?.remove()
      this._humanInteractionPanels[context] = existing
      this._showHumanInteractionPanelInteractive(context, existing)
      return
    }
    // Leader Open/Done chrome (or a stale pane) — replace with challenge controls.
    if (existing) {
      this._framework._hideDockSlidePanel(existing)
      existing.remove()
      if (this._humanInteractionPanels[context] === existing) {
        this._humanInteractionPanels[context] = null
      }
    }

    let panel = BrazenViewLayer.createHumanInteractionPanel({
      id: panelId,
      showReopen: false,
      title,
      message,
      confirmLabel,
      confirmAndCloseLabel,
      onConfirm: () => { void this._confirmHumanInteractionFromChallengePage(context, panel) },
      onConfirmAndClose: () => {
        void this._confirmHumanInteractionFromChallengePage(context, panel, {closeTab: true})
      },
    })
    this._humanInteractionPanels[context] = panel
    BrazenViewLayer.appendToDockPanelStack(panel)
    this._showHumanInteractionPanelInteractive(context, panel)
  }

  /**
   * Show the navigation-interrupted download decision panel (visible leader only).
   * Click marks pending rows done and pauses the queue; timeout requeues and relaunches.
   * @param {object[]|null|undefined} rows
   * @private
   */
  _showDownloadInterruptionPanel(rows)
  {
    if (!rows?.length || document.visibilityState !== 'visible' || this._documentSuspended) {
      return
    }
    this._downloadInterruptionPending = true
    this._downloadInterruptionRows = rows
    this._hideDownloadInterruptionPanel({keepPending: true})
    let count = rows.length
    let panel = BrazenViewLayer.createDownloadInterruptionPanel({
      message: count === 1
          ? 'A download was still in progress when this page loaded. Click below to mark it complete and pause the queue, or wait for the timer to relaunch it.'
          : `${count} downloads were still in progress when this page loaded. Click below to mark them complete and pause the queue, or wait for the timer to relaunch them.`,
      onComplete: () => { void this._completeDownloadInterruption() },
      onTimeout: () => { void this._timeoutDownloadInterruption() },
    })
    this._downloadInterruptionPanel = panel
    BrazenViewLayer.appendToDockPanelStack(panel)
    this._framework._showDockSlidePanel(panel)
  }

  /**
   * @param {{keepPending?: boolean}} [options]
   * @private
   */
  _hideDownloadInterruptionPanel(options = {})
  {
    let panel = this._downloadInterruptionPanel
        ?? document.getElementById('bv-download-interruption-panel')
    if (panel) {
      panel._bvTimedButtonApi?.cancel?.()
      this._framework._hideDockSlidePanel(panel)
      panel.remove()
    }
    this._downloadInterruptionPanel = null
    if (!options.keepPending) {
      this._downloadInterruptionPending = false
      this._downloadInterruptionRows = null
    }
  }

  /**
   * User clicked the timed button: treat interrupted downloads as complete and pause.
   * @return {Promise<void>}
   * @private
   */
  async _completeDownloadInterruption()
  {
    let rows = this._downloadInterruptionRows ?? []
    this._hideDownloadInterruptionPanel()
    if (!this._cm.canPersist()) {
      return
    }
    for (let row of rows) {
      let current = await this._repos().downloadQueue.get(row.itemId)
      if (!current || current.status !== 'downloading') {
        continue
      }
      current.status = 'done'
      current.inProgress = false
      current.error = null
      await this._repos().downloadQueue.put(current)
      await this._incrementDownloadProgress()
      await this._clearTerminalQueueRows(current.itemId)
    }
    await this._withState((state) => {
      state.paused = true
    })
    this._refreshDockProgress({refreshAllItems: true})
    await this._syncFromStorage()
  }

  /**
   * Timed button expired: requeue interrupted downloads and relaunch (today's default).
   * @return {Promise<void>}
   * @private
   */
  async _timeoutDownloadInterruption()
  {
    let rows = this._downloadInterruptionRows ?? []
    this._hideDownloadInterruptionPanel()
    if (!this._cm.canPersist()) {
      return
    }
    for (let row of rows) {
      let current = await this._repos().downloadQueue.get(row.itemId)
      if (!current || current.status !== 'downloading') {
        continue
      }
      current.status = 'queued'
      current.inProgress = false
      current.error = null
      await this._repos().downloadQueue.put(current)
    }
    this._signalProcessorsWake()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Clear one HI lane from a Phase-1 challenge tab (CM/DM may not be fully initialized).
   * Optional `closeTab` also calls `window.close()` after resume (may be blocked by the browser).
   * @param {BrazenDownloadManagerLaneId} [context]
   * @param {HTMLElement} [panel]
   * @param {{closeTab?: boolean}} [options]
   * @return {Promise<void>}
   * @private
   */
  async _confirmHumanInteractionFromChallengePage(context = null, panel = null, options = {})
  {
    let prefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
    let state = null
    let repos = null
    try {
      if (typeof BrazenStorageRepositories === 'function') {
        repos = new BrazenStorageRepositories(prefix)
        if (repos.storage.available) {
          await repos.storage.open()
          state = await repos.downloadManagerState.get()
        }
      }
    } catch (e) {
      // ignore — fall through to URL / default context
    }
    let ctx = context
        ?? BrazenDownloadManager._peekHumanInteractionContextFromLocation()
        ?? BrazenDownloadManager._resolveChallengeHumanInteractionContext(state)
        ?? 'download'
    let el = panel
        ?? document.getElementById(
            ctx === 'resolution'
                ? 'bv-human-interaction-panel-resolution'
                : 'bv-human-interaction-panel-download',
        )
        ?? document.getElementById('bv-human-interaction-panel')
    if (el) {
      this._framework._hideDockSlidePanel(el)
    }
    try {
      if (repos?.storage?.available && state) {
        // Migrate + clear the specified lane only.
        if (!state.humanInteraction || typeof state.humanInteraction !== 'object') {
          state.humanInteraction = {resolution: null, download: null}
        }
        if (state.humanInteractionBlocked) {
          let legacyCtx = state.humanInteractionContext === 'download' ? 'download' : 'resolution'
          if (!state.humanInteraction[legacyCtx]) {
            state.humanInteraction[legacyCtx] = {
              itemId: state.humanInteractionItemId ?? null,
              promptTabId: state.humanInteractionPromptTabId ?? null,
              openUrl: state.humanInteractionOpenUrl ?? null,
              at: Date.now(),
            }
          }
        }
        state.humanInteraction[ctx] = null
        delete state.humanInteractionBlocked
        delete state.humanInteractionContext
        delete state.humanInteractionItemId
        delete state.humanInteractionPromptTabId
        delete state.humanInteractionOpenUrl
        state.id = 'state'
        await repos.downloadManagerState.put(state)
        this._cachedState = state
        this._writeHumanInteractionBlockMirror(
            BrazenDownloadManager._anyHumanInteractionState(state),
        )
      }
    } catch (e) {
      // ignore IDB failures on challenge tabs
      this._writeHumanInteractionBlockMirror(false)
    }
    this._signalProcessorsWake()
    if (typeof this._shouldRunProcessors === 'function' && this._shouldRunProcessors()) {
      void this._runProcessors()
    }
    if (!options.closeTab) {
      return
    }
    try {
      window.close()
    } catch (e) {
      // ignore — browsers often block close for non-script-opened tabs
    }
    // Resume already succeeded; if close was ignored, surface a short note.
    await new Promise((resolve) => setTimeout(resolve, 50))
    if (window.closed || !el) {
      return
    }
    BrazenViewLayer.updateHumanInteractionPanelContent(el, {
      message: 'Queue resumed. Close this tab manually if it stays open.',
    })
    el.querySelector('.bv-dock-list-footer')?.replaceChildren()
    this._framework._showDockSlidePanel(el)
  }

  /**
   * @param {BrazenDownloadManagerLaneId} context
   * @return {Promise<void>}
   * @private
   */
  async _confirmHumanInteractionResume(context)
  {
    this._hideHumanInteractionPanel(context)
    await this._clearHumanInteractionBlock(context)
    // Confirm may run on a follower — wake an idle leader elsewhere.
    this._signalProcessorsWake()
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Re-open the verification URL if the user closed that tab by mistake.
   * Opens synchronously from the sync cache so the click keeps user activation.
   * @param {BrazenDownloadManagerLaneId} context
   * @return {Promise<void>}
   * @private
   */
  async _reopenHumanInteractionTab(context)
  {
    let openUrl = this._hiLane(this._getStateSync(), context)?.openUrl
    if (openUrl) {
      this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl, context))
      return
    }
    // Cache miss — await may lose transient user activation; best-effort only.
    let state = await this._getState()
    openUrl = this._hiLane(state, context)?.openUrl
    if (openUrl) {
      this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl, context))
      return
    }
    let panel = this._humanInteractionPanels[context]
    if (panel) {
      BrazenViewLayer.updateHumanInteractionPanelContent(panel, {
        message: 'Could not open the challenge tab (missing URL). Try Done — resume, or reload this page.',
      })
    }
  }

  /**
   * @param {BrazenDownloadManagerLaneId} [context] omit to hide all lane panels
   * @private
   */
  _hideHumanInteractionPanel(context = null)
  {
    if (context === 'resolution' || context === 'download') {
      let panel = this._humanInteractionPanels[context]
      if (panel) {
        this._framework._hideDockSlidePanel(panel)
      }
      return
    }
    for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
      let panel = this._humanInteractionPanels[ctx]
      if (panel) {
        this._framework._hideDockSlidePanel(panel)
      }
    }
  }

  /**
   * True when this tab may show the human-interaction resume panel.
   * Any focused tab may claim it — including the queue verification media page
   * where the CAPTCHA was cleared (processors still refuse leadership there).
   * @return {boolean}
   * @private
   */
  _canOwnHumanInteractionPrompt()
  {
    return true
  }

  /**
   * True when this document is a queue-opened Cloudflare / verification tab
   * (`brazen_hi` marker). Same-origin opener alone is not enough — normal search/media
   * tabs opened from another tab must still show Open Cloudflare Challenge.
   * @return {boolean}
   * @private
   */
  _isQueueVerificationTab()
  {
    return BrazenDownloadManager.hasBrazenHumanInteractionMarker()
  }

  /**
   * Hide local HI panels and drop ownership so another focused tab can claim them.
   * Does not clear lane blocks — only Done — resume unlocks the queue.
   * @param {{hideLocal?: boolean}} [options] When false, only clears IDB ownership (minimize / tab blur).
   * @return {Promise<void>}
   * @private
   */
  async _releaseHumanInteractionPanelOwnership(options = {})
  {
    let hideLocal = options.hideLocal !== false
    if (hideLocal) {
      this._hideHumanInteractionPanel()
    }
    if (this._documentSuspended) {
      return
    }
    let stillBlocked = false
    await this._withState((state) => {
      this._ensureHumanInteractionMap(state)
      for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
        let lane = state.humanInteraction[ctx]
        if (lane?.promptTabId === this._tabId) {
          lane.promptTabId = null
        }
      }
      stillBlocked = this._anyHumanInteraction(state)
    })
    // Ownership drop alone does not notify peers — wake so a focused follower can steal now
    // instead of waiting for the HI watchdog.
    if (stillBlocked) {
      this._signalProcessorsWake()
    }
  }

  /**
   * @param {BrazenDownloadManagerLaneId} context
   * @return {Promise<void>}
   * @private
   */
  async _clearHumanInteractionBlock(context)
  {
    if (context !== 'resolution' && context !== 'download') {
      return
    }
    this._hideHumanInteractionPanel(context)
    // Stamp only the cleared pipeline's clock so resolution/download gaps stay independent.
    this._stampInitiationClock(context, Date.now())
    await this._withState((next) => {
      this._ensureHumanInteractionMap(next)
      next.humanInteraction[context] = null
      delete next.humanInteractionBlocked
      delete next.humanInteractionContext
      delete next.humanInteractionItemId
      delete next.humanInteractionPromptTabId
      delete next.humanInteractionOpenUrl
      let now = Date.now()
      if (context === 'download') {
        next.lastDownloadInitiationAt = Math.max(next.lastDownloadInitiationAt ?? 0, now)
      } else {
        next.lastResolutionInitiationAt = Math.max(next.lastResolutionInitiationAt ?? 0, now)
      }
      this._writeHumanInteractionBlockMirror(this._anyHumanInteraction(next))
    })
    this._refreshAllItemProgress()
    await this._syncFromStorage()
    // Sync reloads `_cachedState` only — re-apply local stamp in case IDB lagged.
    this._stampInitiationClock(context, Date.now())
  }

  /**
   * Advance one pipeline's in-tab initiation clock to at least `at`.
   * @param {'resolution'|'download'} kind
   * @param {number} at
   * @private
   */
  _stampInitiationClock(kind, at)
  {
    let ts = at ?? Date.now()
    let localKey = kind === 'download' ? '_lastDownloadInitiationAt' : '_lastResolutionInitiationAt'
    let stateKey = kind === 'download' ? 'lastDownloadInitiationAt' : 'lastResolutionInitiationAt'
    this[localKey] = Math.max(this[localKey] ?? 0, ts)
    if (this._cachedState) {
      this._cachedState[stateKey] = Math.max(this._cachedState[stateKey] ?? 0, ts)
    }
  }

  // -------------------------------------------------------------------------
  // Tag discovery
  // -------------------------------------------------------------------------

  /**
   * Write extracted typed tag groups into the tag registry.
   * Discovery on → register-on-seen (`media`, lastSeenTypeEntryId only; isDiscovered stays null).
   * Discovery off → confirm as-is (`resolution`, sets typeEntryId and isDiscovered).
   *
   * @param {Record<string, string[]>|null|undefined} tagGroups
   * @return {Promise<void>}
   * @private
   */
  async _registerResolvedTagGroups(tagGroups)
  {
    if (!this._cm.canPersist() || !tagGroups || typeof tagGroups !== 'object') {
      return
    }
    let normalize = this._getDownloadTagNormalize()
    let normalizedGroups = {}
    for (let [typeName, names] of Object.entries(tagGroups)) {
      if (!Array.isArray(names)) {
        continue
      }
      let list = []
      let seen = new Set()
      for (let name of names) {
        if (!name) {
          continue
        }
        let normalized = normalize(name) || name
        if (seen.has(normalized)) {
          continue
        }
        seen.add(normalized)
        list.push(normalized)
      }
      if (list.length) {
        normalizedGroups[typeName] = list
      }
    }
    let hasNames = Object.values(normalizedGroups).some((names) => names.some(Boolean))
    if (!hasNames) {
      return
    }
    let source = this._isTagDiscoveryActive() ? 'media' : 'resolution'
    await this._cm.registerTypedTagGroups(normalizedGroups, source)
  }

  /**
   * @return {boolean}
   */
  isTagDiscoveryEnabled()
  {
    return !!this._getStateSync()?.tagDiscoveryEnabled
  }

  /**
   * @return {boolean}
   * @private
   */
  _isTagDiscoveryActive()
  {
    return !!this._config.tagDiscovery && this.isTagDiscoveryEnabled()
  }

  /**
   * @param {string} step
   * @return {boolean}
   * @private
   */
  _shouldRunTagDiscovery(step)
  {
    return this._config.tagDiscovery?.discoverAt?.includes(step) ?? false
  }

  /**
   * After discovery review: mark tags {@link TagEntry.isDiscovered} and promote seen types
   * to {@link TagEntry.typeEntryId} when still unset (attributes may have typed earlier).
   * @param {Array<{name: string, type: string|null}>|null} tags
   * @return {Promise<void>}
   * @private
   */
  async _confirmDiscoveryPanelTagTypes(tags)
  {
    if (!tags?.length) {
      return
    }
    let tagRuntime = this._cm.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    for (let tag of tags) {
      if (!tag?.name) {
        continue
      }
      let entry = await tagRuntime.getTag(tag.name)
      // Already discovered with a type — nothing left for Confirm.
      if (entry?.isDiscovered === true && entry?.typeEntryId != null) {
        continue
      }
      // Prefer existing type / lastSeen from resolution; fall back to the panel row's type.
      let typeEntryId = null
      let typeName = null
      if (entry?.typeEntryId == null) {
        typeEntryId = entry?.meta?.lastSeenTypeEntryId ?? null
        if (typeEntryId == null && tag.type) {
          typeName = tag.type
          typeEntryId = await this._repos().tags.resolveCanonicalTypeEntryId(tag.type)
        }
        // Nothing to promote and nothing to discover-mark usefully — skip.
        if (typeEntryId == null && !typeName) {
          continue
        }
      }
      // discovery-confirm sets isDiscovered even when typeEntryId was set via attributes.
      await tagRuntime.ensureTag(tag.name, {
        typeEntryId,
        typeName,
        source: 'discovery-confirm',
      })
    }
  }

  /**
   * @param {{}} tagGroups
   * @param {Record<string, number|string>|null|undefined} [tagIncidences]
   * @return {Promise<{unknown: Array<{name: string, type: string|null, count: number|null}>, known: Array<{name: string, type: string|null, count: number|null}>}>}
   * @private
   */
  async _partitionDiscoveryTags(tagGroups, tagIncidences = {})
  {
    let empty = {unknown: [], known: []}
    if (!this._config.tagDiscovery) {
      return empty
    }
    let resolver = this._config.downloadPaths.getPatternResolver()
    let patterns = this._getActiveDownloadPatterns()
    let relevantTypes = new Set()
    for (let chip of resolver.chips) {
      if (!resolver.tagTypes.includes(chip.token)) {
        continue
      }
      for (let pattern of patterns) {
        if (this.patternIncludesChip(pattern, chip)) {
          relevantTypes.add(chip.token)
        }
      }
    }

    // Pattern pins always own scan scope. Optional tagDiscovery.tagTypes array is an
    // allowlist intersect only — never a replacement that expands beyond pins.
    let tagTypes = [...relevantTypes]
    let configured = this._config.tagDiscovery.tagTypes
    if (Array.isArray(configured) && configured.length) {
      let allow = new Set(configured)
      tagTypes = tagTypes.filter((type) => allow.has(type))
    }
    if (!tagTypes.length) {
      return empty
    }
    let candidates = []
    let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
    for (let type of tagTypes) {
      for (let tag of tagGroups[type] ?? []) {
        if (!tag) {
          continue
        }
        candidates.push({
          name: tag,
          type,
          count: this._resolveTagIncidence(tag, incidences),
        })
      }
    }
    // One TagRuntime ensure for the whole set — avoid per-tag IDB round-trips.
    let tagRuntime = this._cm.getTagRuntime?.()
    if (tagRuntime && candidates.length) {
      try {
        await tagRuntime.ensureNames(candidates.map((entry) => entry.name))
      } catch (error) {
        // Fall through to per-tag lookups.
      }
    }
    let unknown = []
    let known = []
    for (let entry of candidates) {
      if (await this._isTagKnownForDiscovery(entry.name)) {
        known.push(entry)
      } else {
        unknown.push(entry)
      }
    }
    return {unknown, known}
  }

  /**
   * @param {string} tagName
   * @param {Record<string, number|string>} incidences
   * @return {number|null}
   * @private
   */
  _resolveTagIncidence(tagName, incidences)
  {
    if (!incidences || typeof incidences !== 'object') {
      return null
    }
    let raw = incidences[tagName]
    if (raw == null) {
      let lower = String(tagName).toLowerCase()
      for (let [key, value] of Object.entries(incidences)) {
        if (String(key).toLowerCase() === lower) {
          raw = value
          break
        }
      }
    }
    if (raw == null || raw === '') {
      return null
    }
    let count = typeof raw === 'number' ? raw : parseInt(String(raw).replace(/[^\d]/g, ''), 10)
    return Number.isFinite(count) ? count : null
  }

  /**
   * @param {{}} tagGroups
   * @return {Promise<Array<{name: string, type: string|null, count: number|null}>|null>}
   * @private
   */
  async _findNewDiscoveryTags(tagGroups)
  {
    let lists = await this._partitionDiscoveryTags(tagGroups, {})
    return lists.unknown.length ? lists.unknown : null
  }

  /**
   * Whether a tag has completed discovery review ({@link TagEntry.isDiscovered}).
   * Type may be set earlier via attribute actions; discovery is gated separately.
   * Script {@link tagDiscovery.isTagKnown} overrides; default uses {@link TagRepository.getByName}.
   *
   * @param {string} tagName
   * @return {Promise<boolean>}
   * @private
   */
  async _isTagKnownForDiscovery(tagName)
  {
    if (this._config.tagDiscovery?.isTagKnown) {
      let result = Utilities.callEventHandler(this._config.tagDiscovery.isTagKnown, [tagName], false)
      return !!await result
    }
    let tagRuntime = this._cm.getTagRuntime?.()
    let cached = tagRuntime?.resolveCachedByName?.(tagName)
    if (cached) {
      return cached.isDiscovered === true
    }
    if (!this._cm.canPersist()) {
      return false
    }
    let tag = await this._repos().tags.getByName(tagName)
    return !!(tag && tag.isDiscovered === true)
  }

  /**
   * @param {Array|null} existing
   * @param {Array} discovered
   * @return {Array}
   * @private
   */
  _mergeDiscoveryTags(existing, discovered)
  {
    let merged = [...(existing ?? [])]
    let indexByKey = new Map(merged.map((tag, index) => [tag.name + '\0' + (tag.type ?? ''), index]))
    for (let tag of discovered) {
      let key = tag.name + '\0' + (tag.type ?? '')
      if (indexByKey.has(key)) {
        let prior = merged[indexByKey.get(key)]
        if ((prior.count == null || prior.count === '') && tag.count != null && tag.count !== '') {
          prior.count = tag.count
        }
        continue
      }
      indexByKey.set(key, merged.length)
      merged.push(tag)
    }
    return merged
  }

  /**
   * @return {string[]}
   * @private
   */
  _getActiveDownloadPatterns()
  {
    let paths = this._config.downloadPaths
    let patterns = []
    if (paths.filenamePatternConfigKey) {
      patterns.push(this._framework._getConfig(paths.filenamePatternConfigKey) ?? '')
    }
    if (paths.subfolderPatternConfigKey) {
      patterns.push(this._framework._getConfig(paths.subfolderPatternConfigKey) ?? '')
    }
    return patterns
  }

  /**
   * @return {boolean}
   * @private
   */
  _isIgnoredPinReviewEnabled()
  {
    let review = this._config.tagDiscovery?.ignoredPinReview
    if (review?.isEnabled) {
      return !!Utilities.callEventHandler(review.isEnabled, [], false)
    }
    if (!this._cm.getField(OPTION_REVIEW_IGNORED_FILENAME_PINS)) {
      return false
    }
    return !!this._framework._getConfig(OPTION_REVIEW_IGNORED_FILENAME_PINS)
  }

  /**
   * @return {boolean}
   * @private
   */
  _isSkipEmptyFilenamePinsEnabled()
  {
    let skip = this._config.tagDiscovery?.skipEmptyFilenamePins
    if (skip?.isEnabled) {
      return !!Utilities.callEventHandler(skip.isEnabled, [], false)
    }
    if (!this._cm.getField(OPTION_SKIP_EMPTY_FILENAME_PINS)) {
      return false
    }
    return !!this._framework._getConfig(OPTION_SKIP_EMPTY_FILENAME_PINS)
  }

  /**
   * @return {boolean}
   * @private
   */
  _isDeferredTagDiscoveryEnabled()
  {
    if (!this._cm.getField(OPTION_DEFER_TAG_DISCOVERY)) {
      return false
    }
    return !!this._framework._getConfig(OPTION_DEFER_TAG_DISCOVERY)
  }

  /**
   * Defer unknown-tag and ignored-pin review to the discovery lane (batch only).
   * @return {boolean}
   * @private
   */
  _shouldDeferTagDiscoveryReview()
  {
    return this._isDeferredTagDiscoveryEnabled() && this._isTagDiscoveryActive()
  }

  /**
   * @return {Promise<number>}
   * @private
   */
  async _countDiscoveryQueuedItems()
  {
    return this._repos().downloadResolutionQueue.listByStatus('discoveryQueued').
        then((rows) => rows.length)
  }

  /**
   * Clear discovery-lane phase when no deferred rows remain.
   * @return {Promise<void>}
   * @private
   */
  async _maybeEndDiscoveryLanePhase()
  {
    if (!this._isDeferredTagDiscoveryEnabled()) {
      return
    }
    let pending = await this._countDiscoveryQueuedItems()
    if (pending > 0) {
      return
    }
    await this._withState((state) => {
      state.discoveryLanePhaseActive = false
    })
  }

  /**
   * @param {object|null|undefined} state
   * @return {Promise<object|null>}
   * @private
   */
  async _peekResolutionWork(state)
  {
    if (state?.resolutionBlocked) {
      return null
    }
    let repo = this._repos().downloadResolutionQueue
    if (!this._isDeferredTagDiscoveryEnabled()) {
      return repo.peekNextQueued()
    }
    if (state?.discoveryLanePhaseActive) {
      return repo.peekNextDiscoveryQueued()
    }
    let next = await repo.peekNextQueued()
    if (next) {
      return next
    }
    let deferred = await repo.peekNextDiscoveryQueued()
    if (!deferred) {
      return null
    }
    await this._withState((nextState) => {
      nextState.discoveryLanePhaseActive = true
    })
    return deferred
  }

  /**
   * @param {string} itemId
   * @return {Promise<object|null>}
   * @private
   */
  async _claimResolutionWorkItem(itemId)
  {
    let row = await this._repos().downloadResolutionQueue.get(itemId)
    if (!row) {
      return null
    }
    if (row.status === 'queued' || row.status === 'discoveryQueued') {
      row.status = 'resolving'
      row.error = null
      await this._repos().downloadResolutionQueue.put(row)
      return row
    }
    return null
  }

  /**
   * @param {object} item
   * @return {Promise<void>}
   * @private
   */
  async _processResolutionWorkItem(item)
  {
    if (item.pendingTagGroups && this._isDeferredTagDiscoveryEnabled()) {
      await this._activateDeferredDiscoveryItem(item)
      return
    }
    await this._processResolutionItem(item)
  }

  /**
   * Open tag review for a deferred discovery-lane item (no re-fetch).
   * @param {object} item
   * @return {Promise<void>}
   * @private
   */
  async _activateDeferredDiscoveryItem(item)
  {
    try {
      let pending = item.pendingTagGroups
      if (!pending?.mediaUrl) {
        item.status = 'failed'
        item.error = 'Deferred discovery item has no resolved payload'
        if (await this._commitResolutionRow(item)) {
          await this._incrementResolutionProgress()
        }
        return
      }
      let tagGroups = this._tagGroupsFromResolvedPayload(pending)
      let tagIncidences = pending.tagIncidences ?? {}
      if (this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')) {
        let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
        if (lists.unknown.length) {
          item.status = 'tagReview'
          item.pendingTagGroups = pending
          if (!(await this._commitResolutionRow(item))) {
            return
          }
          await this._withState((state) => {
            state.resolutionBlocked = true
            state.resolutionBlockedItemId = item.itemId
            state.discoveryReviewMode = 'unknown'
            state.discoveryPanelTags = lists.unknown
            state.discoveryPanelKnownTags = lists.known
          })
          let state = this._getStateSync()
          await this._presentTagDiscoveryReview(state.discoveryPanelTags, state.discoveryPanelKnownTags)
          return
        }
      }
      if (await this._tryBeginIgnoredPinReview(item, pending, tagGroups, tagIncidences, {forceImmediate: true})) {
        return
      }
      if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
        await this._dropResolvedWithoutDownload(item)
        await this._maybeEndDiscoveryLanePhase()
        return
      }
      if (!(await this._promoteToDownloadQueue(item, pending))) {
        return
      }
      if (!(await this._removeResolutionRowIfActive(item.itemId))) {
        return
      }
      await this._incrementResolutionProgress()
      await this._maybeEndDiscoveryLanePhase()
    } finally {
      await this._onResolutionTaskComplete(item.itemId)
    }
  }

  /**
   * Tag-type tokens present as chips in the active filename/subfolder patterns.
   * @return {string[]}
   * @private
   */
  _getPinnedFilenameTagTypes()
  {
    if (!this._config.downloadPaths?.getPatternResolver) {
      return []
    }
    let resolver = this._config.downloadPaths.getPatternResolver()
    let patterns = this._getActiveDownloadPatterns()
    let pinned = []
    let seen = new Set()
    for (let chip of resolver.chips ?? []) {
      if (!resolver.tagTypes.includes(chip.token) || seen.has(chip.token)) {
        continue
      }
      if (!this._activePatternsIncludeTagType(patterns, chip.token, resolver.chips)) {
        continue
      }
      seen.add(chip.token)
      pinned.push(chip.token)
    }
    return pinned
  }

  /**
   * True when any active filename/subfolder tag-type pin has at least one raw tag on the post.
   * Filename Tag Ignore List is not applied.
   * @param {{}} tagGroups
   * @return {boolean}
   * @private
   */
  _hasRawTagsForAnyFilenamePin(tagGroups)
  {
    let groups = tagGroups && typeof tagGroups === 'object' ? tagGroups : {}
    for (let type of this._getPinnedFilenameTagTypes()) {
      if ((groups[type] ?? []).some(Boolean)) {
        return true
      }
    }
    return false
  }

  /**
   * Queue-only: drop before promote when enabled and no pinned type has raw tags.
   * No-op when patterns contain no tag-type pins.
   * @param {{}} tagGroups
   * @return {boolean}
   * @private
   */
  _shouldSkipEmptyFilenamePins(tagGroups)
  {
    if (!this._isSkipEmptyFilenamePinsEnabled()) {
      return false
    }
    let pinned = this._getPinnedFilenameTagTypes()
    if (!pinned.length) {
      return false
    }
    return !this._hasRawTagsForAnyFilenamePin(tagGroups)
  }

  /**
   * Drop a resolved resolution item without promoting to the download queue.
   * @param {object} item
   * @param {{clearDiscoveryGate?: boolean}} [options]
   * @return {Promise<void>}
   * @private
   */
  async _dropResolvedWithoutDownload(item, options = {})
  {
    let itemId = item?.itemId
    if (!itemId) {
      return
    }
    let removed = await this._removeResolutionRowIfActive(itemId)
    if (!removed) {
      let existing = await this._repos().downloadResolutionQueue.get(itemId)
      if (existing) {
        await this._repos().downloadResolutionQueue.remove(itemId)
        removed = true
      }
    }
    let element = this._trackedItemElements.get(String(itemId))
    this._untrackItemElement(itemId)
    if (element) {
      this._setItemProgress(element, null)
    }
    if (options.clearDiscoveryGate) {
      await this._withState((next) => {
        this._clearDiscoveryReviewState(next)
      })
      await this._syncFromStorage()
    }
    if (removed) {
      await this._incrementResolutionProgress()
    }
  }

  /**
   * True when any active filename/subfolder pattern includes a chip for {@link type}.
   * @param {string[]} patterns
   * @param {string} type
   * @param {{token: string, label: string}[]} chips
   * @return {boolean}
   * @private
   */
  _activePatternsIncludeTagType(patterns, type, chips)
  {
    for (let chip of chips ?? []) {
      if (chip.token !== type) {
        continue
      }
      for (let pattern of patterns) {
        if (this.patternIncludesChip(pattern, chip)) {
          return true
        }
      }
    }
    return false
  }

  /**
   * Resolve `tagGroups` from a resolution payload (nested or flat).
   * @param {object|null|undefined} resolved
   * @return {{}}
   * @private
   */
  _tagGroupsFromResolvedPayload(resolved)
  {
    if (!resolved || typeof resolved !== 'object') {
      return {}
    }
    if (resolved.tagGroups && typeof resolved.tagGroups === 'object' && !Array.isArray(resolved.tagGroups)) {
      return resolved.tagGroups
    }
    // Defensive: older/flat payloads stored groups at the top level.
    if (Array.isArray(resolved.author) || Array.isArray(resolved.character) ||
        Array.isArray(resolved.copyright) || Array.isArray(resolved.general)) {
      return resolved
    }
    return {}
  }

  /**
   * Whether a post tag is filename-ignored — same source as the Aa ignore button
   * ({@link BrazenConfigurationManager.hasTagSoleAttribute} / shared cache row).
   * When strip-character-series is on, an ignore on the series-stripped base also counts.
   * @param {string} name
   * @param {object} resolver
   * @param {string|null} [type]
   * @return {boolean}
   * @private
   */
  _isFilenameIgnoredTag(name, resolver, type = null)
  {
    if (!name) {
      return false
    }
    let normalize = typeof resolver?.normalizeTag === 'function' ?
        resolver.normalizeTag :
        this._getDownloadTagNormalize()
    let normalized = normalize(name)
    if (!normalized) {
      return false
    }

    let isIgnoredName = (candidate) => {
      if (!candidate) {
        return false
      }
      if (this._cm?.canPersist?.()) {
        return !!this._cm.hasTagSoleAttribute('filename-tag-ignore-list', candidate)
      }
      let ignore = resolver?.ignore
      return !!(ignore?.size && (ignore.has(candidate) || ignore.has(normalize(candidate))))
    }

    if (isIgnoredName(normalized) || (normalized !== name && isIgnoredName(name))) {
      return true
    }
    if (resolver?.stripCharacterSeries && (type === 'character' || type == null)) {
      let stripped = normalized.replace(/_\([^)]*\)$/, '')
      if (stripped && stripped !== normalized && isIgnoredName(stripped)) {
        return true
      }
    }
    return false
  }

  /**
   * Filename-ignored tags for active filename/subfolder tag-type pins that are blocking.
   * A pin type is blocking when it has raw tags, the download-path join is empty with ignore
   * applied, and a no-ignore join is non-empty (ignore emptied the slot; substitution-only
   * empties do not block). Lists filename-ignored tags only for those blocking types.
   * `pinJoinEmpty` is true only when `tags.length > 0`.
   *
   * @param {{}} tagGroups
   * @param {Record<string, number|string>|null|undefined} [tagIncidences]
   * @return {Promise<{tags: Array<{name: string, type: string|null, count: number|null}>, pinJoinEmpty: boolean}>}
   * @private
   */
  async _collectIgnoredFilenamePinTags(tagGroups, tagIncidences = {})
  {
    let empty = {tags: [], pinJoinEmpty: false}
    if (!this._isIgnoredPinReviewEnabled() || !this._config.downloadPaths?.getPatternResolver) {
      return empty
    }
    let types = this._getPinnedFilenameTagTypes()
    if (!types.length) {
      return empty
    }
    await this._ensureDownloadPathTagsLoaded(tagGroups)
    let resolver = this._config.downloadPaths.getPatternResolver()
    let normalize = typeof resolver?.normalizeTag === 'function' ?
        resolver.normalizeTag :
        this._getDownloadTagNormalize()
    let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
    /** @type {string[]} */
    let blockingTypes = []
    for (let type of types) {
      let raw = (tagGroups[type] ?? []).filter(Boolean)
      if (!raw.length) {
        continue
      }
      // Product truth: slot empty after ignore/substitution — same join as the download path.
      let joined = this._joinTagsForDownloadPath(raw, type, resolver.ignore, resolver)
      if (joined) {
        continue
      }
      // Substitution-only empties do not block; ignore-caused empties do.
      let joinedNoIgnore = this._joinTagsForDownloadPath(raw, type, resolver.ignore, {
        ...resolver,
        applyFilenameIgnore: false,
      })
      if (!joinedNoIgnore) {
        continue
      }
      blockingTypes.push(type)
    }
    if (!blockingTypes.length) {
      return empty
    }
    let collected = []
    let seen = new Set()
    for (let type of blockingTypes) {
      let raw = (tagGroups[type] ?? []).filter(Boolean)
      for (let name of raw) {
        if (!this._isFilenameIgnoredTag(name, resolver, type)) {
          continue
        }
        let normalized = normalize(name) || name
        if (seen.has(normalized)) {
          continue
        }
        seen.add(normalized)
        collected.push({
          name: normalized,
          type,
          count: this._resolveTagIncidence(normalized, incidences) ??
              this._resolveTagIncidence(name, incidences),
        })
      }
    }
    if (!collected.length) {
      return empty
    }
    return {tags: collected, pinJoinEmpty: true}
  }

  /**
   * Block on ignored filename pins when configured.
   *
   * @param {object} item
   * @param {object} resolved
   * @param {{}} tagGroups
   * @param {Record<string, number|string>} tagIncidences
   * @param {{forceImmediate?: boolean}} [options]
   * @return {Promise<boolean>} true if review panel was opened or item was deferred
   * @private
   */
  async _tryBeginIgnoredPinReview(item, resolved, tagGroups, tagIncidences, options = {})
  {
    let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
    let ignoredTags = ignoredPin.tags
    // Gate only when ignore-caused empty join with listable ignored tags.
    if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
      return false
    }
    if (this._shouldDeferTagDiscoveryReview() && !options.forceImmediate) {
      item.status = 'discoveryQueued'
      item.pendingTagGroups = resolved
      if (!(await this._commitResolutionRow(item))) {
        return false
      }
      return true
    }
    item.status = 'tagReview'
    item.pendingTagGroups = resolved
    if (!(await this._commitResolutionRow(item))) {
      // Claim lost — do not report handled (caller may still promote / retry).
      return false
    }
    await this._withState((state) => {
      state.resolutionBlocked = true
      state.resolutionBlockedItemId = item.itemId
      state.discoveryReviewMode = 'ignoredPins'
      state.discoveryPanelTags = ignoredTags
      state.discoveryPanelKnownTags = []
    })
    await this._presentTagDiscoveryReview(ignoredTags, [])
    return true
  }

  /**
   * Open tag discovery on this tab if visible, and wake other tabs so a focused
   * non-leader can claim the panel when the processor leader is in the background.
   * @param {Array} tags
   * @param {Array|null|undefined} [knownTags]
   * @return {Promise<void>}
   * @private
   */
  async _presentTagDiscoveryReview(tags, knownTags = null)
  {
    await this._showTagDiscoveryPanel(tags, knownTags)
    this._signalProcessorsWake()
  }

  /**
   * Show the tag-discovery panel on the focused tab. Visible tabs may steal
   * `tagDiscoveryPanelTabId` so opening another search tab cannot leave review
   * locked on a background owner with no UI.
   * @param {Array} tags
   * @param {Array|null|undefined} [knownTags]
   * @return {Promise<void>}
   * @private
   */
  async _showTagDiscoveryPanel(tags, knownTags = null)
  {
    if (document.visibilityState !== 'visible') {
      return
    }
    await this._withState((state) => {
      // Caller already gated on visibility — focused tab always claims / steals.
      state.tagDiscoveryPanelTabId = this._tabId
      if (knownTags != null) {
        state.discoveryPanelKnownTags = knownTags
      }
    })
    let state = this._getStateSync()
    let known = state?.discoveryPanelKnownTags ?? knownTags ?? []
    let tagRuntime = this._cm.getTagRuntime()
    if (tagRuntime) {
      let names = []
      for (let tag of [...(tags ?? []), ...known]) {
        if (tag?.name) {
          names.push(tag.name)
        }
      }
      await tagRuntime.ensureNames(names)
    }
    this._renderTagDiscoveryPanel(tags, known)
  }

  /**
   * Hide the local panel and drop ownership so another focused tab can claim it.
   * Does not clear `resolutionBlocked` — only Confirm / Skip unlock the queue.
   * @param {{hideLocal?: boolean}} [options] When false, only clears IDB ownership (minimize / tab blur).
   * @return {Promise<void>}
   * @private
   */
  async _releaseTagDiscoveryPanelOwnership(options = {})
  {
    let hideLocal = options.hideLocal !== false
    if (hideLocal) {
      this._hideTagDiscoveryPanel()
    }
    if (this._documentSuspended) {
      return
    }
    await this._withState((state) => {
      if (state.tagDiscoveryPanelTabId === this._tabId) {
        state.tagDiscoveryPanelTabId = null
      }
    })
  }

  /**
   * @param {Array} tags
   * @param {Array|null|undefined} [knownTags]
   * @private
   */
  _renderTagDiscoveryPanel(tags, knownTags = null)
  {
    if (!this._config.tagDiscovery) {
      return
    }
    if (!this._tagDiscoveryPanel) {
      this._tagDiscoveryPanel = BrazenViewLayer.createTagDiscoveryPanel({
        onConfirm: () => { void this.confirmTagDiscoveryMappings() },
        onSkip: () => { void this.skipTagDiscoveryInclusion() },
        onOpenMedia: () => { void this.openTagDiscoveryMedia() },
      })
      BrazenViewLayer.appendToDockPanelStack(this._tagDiscoveryPanel)
    }
    let known = knownTags ?? this._getStateSync()?.discoveryPanelKnownTags ?? []
    let reviewMode = this._getStateSync()?.discoveryReviewMode ?? 'unknown'
    let panelOptions = {
      ...(this._config.tagDiscovery?.panel ?? {}),
      knownTags: known,
      onTagNameClick: (tag) => this._openTagDiscoverySearch(tag),
      resetSimilar: true,
      intro: reviewMode === 'ignoredPins'
          ? 'Filename tag slots are empty after ignore. Review ignored tags or similar matches, then confirm to continue.'
          : 'New tags were found. Review actions, then confirm to continue.',
      unknownSectionLabel: reviewMode === 'ignoredPins' ? 'Ignored tags' : undefined,
    }
    if (reviewMode === 'ignoredPins') {
      panelOptions.similarOpen = true
      panelOptions.similarShowCount = false
    }
    let fingerprint = this._discoverySimilarFingerprint(tags, known)
    let sameFingerprint = fingerprint === this._similarDiscoveryFingerprint
    let reuseSimilar = sameFingerprint && Array.isArray(this._similarDiscoveryCachedTags)
    // New tag set → soft-reset Similar (keep open + count) instead of collapsing/flashing.
    panelOptions.resetSimilar = !sameFingerprint
    panelOptions.preserveSimilarOpen = true
    panelOptions.clearSimilarCount = false
    BrazenViewLayer.renderTagDiscoveryPanelContent(
        this._tagDiscoveryPanel,
        tags,
        (row, tag, actionsElement) => {
          this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
        },
        panelOptions,
    )
    if (BrazenViewLayer.isDockSlidePanelVisible(this._tagDiscoveryPanel)) {
      // Content-only refresh — keep the open panel still; only re-stack positions.
      this._framework._syncDockPanelPosition()
    } else {
      this._framework._showDockSlidePanel(this._tagDiscoveryPanel)
    }
    if (reuseSimilar) {
      // Attribute-only refresh: repaint cached similar rows (action chrome) without IDB scan.
      let excluded = this._buildSimilarDiscoveryExcludedNames(tags, known)
      let cached = (this._similarDiscoveryCachedTags ?? []).filter((tag) => !excluded.has(tag.name))
      this._similarDiscoveryCachedTags = cached
      void this._paintSimilarDiscoveryTags(cached, panelOptions)
      return
    }
    if (sameFingerprint && this._similarDiscoveryRaf != null) {
      // Prefetch already scheduled for this tag set — do not restart.
      return
    }
    // Same fingerprint with no cache (cancelled mid-load) or a new fingerprint → (re)load.
    this._scheduleSimilarDiscoveryLoad(tags, known, fingerprint)
  }

  /**
   * Open the consumer search-list URL for a discovery-panel tag name.
   * @param {{name: string, type?: string|null}} tag
   * @private
   */
  _openTagDiscoverySearch(tag)
  {
    let discovery = this._config.tagDiscovery ?? {}
    let actions = discovery.actions ?? {}
    let buildUrl = actions.bookmark?.buildUrl
    if (!buildUrl || !tag?.name) {
      return
    }
    let normalize = actions.normalize ?? discovery.normalizeTag ?? ((name) => name)
    let url = buildUrl(normalize(tag.name))
    if (url) {
      window.open(url, '_blank')
    }
  }

  /**
   * @param {Array} tags
   * @param {Array} knownTags
   * @return {string}
   * @private
   */
  _discoverySimilarFingerprint(tags, knownTags)
  {
    let newKey = (Array.isArray(tags) ? tags : [])
        .map((tag) => `${tag?.type ?? ''}\0${tag?.name ?? ''}`)
        .sort()
        .join('\n')
    let knownKey = (Array.isArray(knownTags) ? knownTags : [])
        .map((tag) => tag?.name ?? '')
        .sort()
        .join('\n')
    return newKey + '\n---\n' + knownKey
  }

  /**
   * Cancel in-flight similar prefetch. Keeps a completed cache so hide/restore
   * (e.g. brief visibility loss) can reuse rows without a Loading flash.
   * @private
   */
  _cancelSimilarDiscoveryLoad()
  {
    this._similarDiscoveryLoadId++
    if (this._similarDiscoveryRaf != null) {
      cancelAnimationFrame(this._similarDiscoveryRaf)
      this._similarDiscoveryRaf = null
    }
    if (!Array.isArray(this._similarDiscoveryCachedTags)) {
      // Incomplete load — allow the next render to schedule a fresh prefetch.
      this._similarDiscoveryFingerprint = null
    }
  }

  /**
   * Names already on the discovery panel (New + Known) — never suggest these as Similar.
   * Includes raw and consumer-normalized forms when a normalize hook exists.
   *
   * @param {Array} tags
   * @param {Array} knownTags
   * @return {Set<string>}
   * @private
   */
  _buildSimilarDiscoveryExcludedNames(tags, knownTags)
  {
    let discovery = this._config.tagDiscovery ?? {}
    let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
    let excluded = new Set()
    let add = (name) => {
      if (!name) {
        return
      }
      excluded.add(name)
      if (typeof normalize === 'function') {
        let normalized = normalize(name)
        if (normalized) {
          excluded.add(normalized)
        }
      }
    }
    for (let tag of Array.isArray(tags) ? tags : []) {
      add(tag?.name)
    }
    for (let tag of Array.isArray(knownTags) ? knownTags : []) {
      add(tag?.name)
    }
    return excluded
  }

  /**
   * Ensure TagRuntime rows for similar names, then paint Similar section actions.
   * @param {Array<{name: string, type: string|null, count?: number|null}>} similarTags
   * @param {object} panelOptions
   * @return {Promise<void>}
   * @private
   */
  async _paintSimilarDiscoveryTags(similarTags, panelOptions)
  {
    if (!this._tagDiscoveryPanel) {
      return
    }
    let tags = Array.isArray(similarTags) ? similarTags.filter((tag) => tag?.name) : []
    let tagRuntime = this._cm.getTagRuntime()
    if (tagRuntime && tags.length) {
      await tagRuntime.ensureNames(tags.map((tag) => tag.name))
    }
    if (!this._tagDiscoveryPanel) {
      return
    }
    BrazenViewLayer.renderTagDiscoverySimilarContent(
        this._tagDiscoveryPanel,
        tags,
        (row, tag, actionsElement) => {
          this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
        },
        panelOptions,
    )
  }

  /**
   * After paint: prefetch similar registry tags for the current new-tag set.
   * @param {Array} tags
   * @param {Array} knownTags
   * @param {string} fingerprint
   * @private
   */
  _scheduleSimilarDiscoveryLoad(tags, knownTags, fingerprint)
  {
    if (this._similarDiscoveryRaf != null) {
      cancelAnimationFrame(this._similarDiscoveryRaf)
      this._similarDiscoveryRaf = null
    }
    let loadId = ++this._similarDiscoveryLoadId
    this._similarDiscoveryFingerprint = fingerprint
    this._similarDiscoveryCachedTags = null
    this._similarDiscoveryRaf = requestAnimationFrame(() => {
      this._similarDiscoveryRaf = null
      void this._loadSimilarDiscoveryTags(loadId, tags, knownTags, fingerprint)
    })
  }

  /**
   * Strip bracket segments and split snake_case tokens for similarity scoring.
   * @param {string} name
   * @return {string[]}
   * @private
   */
  _normalizeTagTokensForSimilarity(name)
  {
    let cleaned = String(name ?? '').replace(/\([^)]*\)|\[[^\]]*\]/g, '')
    cleaned = cleaned.replace(/_+/g, '_').replace(/^_|_$/g, '')
    if (!cleaned) {
      return []
    }
    return cleaned.split('_').filter(Boolean)
  }

  /**
   * Coverage of the longest contiguous shared token run over the shorter joined length.
   * @param {string[]} tokensA
   * @param {string[]} tokensB
   * @return {number}
   * @private
   */
  _scoreTagTokensSimilarity(tokensA, tokensB)
  {
    if (!tokensA?.length || !tokensB?.length) {
      return 0
    }
    let bestLen = 0
    let bestJoined = 0
    for (let i = 0; i < tokensA.length; i++) {
      for (let j = 0; j < tokensB.length; j++) {
        let k = 0
        while (i + k < tokensA.length && j + k < tokensB.length && tokensA[i + k] === tokensB[j + k]) {
          k++
        }
        if (k > bestLen) {
          bestLen = k
          bestJoined = tokensA.slice(i, i + k).join('_').length
        }
      }
    }
    if (!bestLen) {
      return 0
    }
    let shorter = Math.min(tokensA.join('_').length, tokensB.join('_').length)
    return shorter > 0 ? bestJoined / shorter : 0
  }

  /**
   * Keep at most {@link SIMILAR_DISCOVERY_LIMIT} unique candidates (by name), ranked by score.
   * @param {Array<{name: string, type: string|null, count: null, score: number}>} top
   * @param {{name: string, type: string|null, count: null, score: number}} candidate
   * @private
   */
  _considerSimilarDiscoveryCandidate(top, candidate)
  {
    let existingIdx = top.findIndex((row) => row.name === candidate.name)
    if (existingIdx >= 0) {
      if (candidate.score > top[existingIdx].score) {
        top[existingIdx] = candidate
        top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
      }
      return
    }
    if (top.length < SIMILAR_DISCOVERY_LIMIT) {
      top.push(candidate)
      top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
      return
    }
    let worst = top[top.length - 1]
    if (candidate.score < worst.score ||
        (candidate.score === worst.score && candidate.name >= worst.name)) {
      return
    }
    top[top.length - 1] = candidate
    top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
  }

  /**
   * @param {number} loadId
   * @param {Array} tags
   * @param {Array} knownTags
   * @param {string} fingerprint
   * @return {Promise<void>}
   * @private
   */
  async _loadSimilarDiscoveryTags(loadId, tags, knownTags, fingerprint)
  {
    if (!this._tagDiscoveryPanel || loadId !== this._similarDiscoveryLoadId) {
      return
    }
    let newTags = Array.isArray(tags) ? tags.filter((tag) => tag?.name) : []
    let reviewMode = this._getStateSync()?.discoveryReviewMode ?? 'unknown'
    let panelOptions = {
      ...(this._config.tagDiscovery?.panel ?? {}),
      onTagNameClick: (tag) => this._openTagDiscoverySearch(tag),
    }
    if (reviewMode === 'ignoredPins') {
      panelOptions.similarOpen = true
      panelOptions.similarShowCount = false
    }
    let finish = async (similar) => {
      if (loadId !== this._similarDiscoveryLoadId) {
        return
      }
      let excluded = this._buildSimilarDiscoveryExcludedNames(tags, knownTags)
      let filtered = (Array.isArray(similar) ? similar : []).filter((tag) => tag?.name && !excluded.has(tag.name))
      this._similarDiscoveryCachedTags = filtered
      this._similarDiscoveryFingerprint = fingerprint
      await this._paintSimilarDiscoveryTags(filtered, panelOptions)
      if (loadId !== this._similarDiscoveryLoadId) {
        return
      }
    }
    if (!newTags.length || !this._cm.canPersist()) {
      await finish([])
      return
    }

    // Exclude every name already listed under New tags or Known tags.
    let excluded = this._buildSimilarDiscoveryExcludedNames(newTags, knownTags)

    /** @type {Array<{name: string, type: string|null, count: null, score: number}>} */
    let top = []
    /** @type {Map<string, Array<{name: string, tokens: string[]}>>} */
    let tagsByType = new Map()
    for (let tag of newTags) {
      let type = tag.type ?? 'unknown'
      if (!tagsByType.has(type)) {
        tagsByType.set(type, [])
      }
      tagsByType.get(type).push({
        name: tag.name,
        tokens: this._normalizeTagTokensForSimilarity(tag.name),
      })
    }

    let tagRepo = this._repos()?.tags
    if (!tagRepo) {
      await finish([])
      return
    }

    for (let [type, typeNewTags] of tagsByType) {
      if (loadId !== this._similarDiscoveryLoadId) {
        return
      }
      let typeEntryId = type && type !== 'unknown'
          ? await tagRepo.resolveCanonicalTypeEntryId(type)
          : null
      if (typeEntryId == null) {
        continue
      }
      let discovery = this._config.tagDiscovery ?? {}
      let normalize = discovery.actions?.normalize ?? discovery.normalizeTag
      let cursor = null
      while (true) {
        if (loadId !== this._similarDiscoveryLoadId) {
          return
        }
        let page = await tagRepo.listTags({
          typeEntryId,
          limit: SIMILAR_DISCOVERY_PAGE_SIZE,
          cursor,
        })
        let entries = page.entries ?? []
        let nextCursor = page.nextCursor
        for (let entry of entries) {
          let candidateName = entry?.name
          if (!candidateName || excluded.has(candidateName)) {
            continue
          }
          if (typeof normalize === 'function') {
            let normalizedCandidate = normalize(candidateName)
            if (normalizedCandidate && excluded.has(normalizedCandidate)) {
              continue
            }
          }
          let candidateTokens = this._normalizeTagTokensForSimilarity(candidateName)
          let bestScore = 0
          for (let newTag of typeNewTags) {
            let score = this._scoreTagTokensSimilarity(newTag.tokens, candidateTokens)
            if (score > bestScore) {
              bestScore = score
            }
          }
          if (bestScore <= 0.5) {
            continue
          }
          this._considerSimilarDiscoveryCandidate(top, {
            name: candidateName,
            type,
            count: null,
            score: bestScore,
          })
        }
        if (!nextCursor) {
          break
        }
        cursor = nextCursor
        await Utilities.sleep(0)
      }
    }

    await finish(top.map(({name, type, count}) => ({name, type, count})))
  }

  /**
   * Framework owns tag-attribute actions; discovery panel maps them internally.
   * @param {HTMLElement} row
   * @param {{name: string, type: string|null}} tag
   * @param {HTMLElement} actionsElement
   * @private
   */
  _appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
  {
    actionsElement.replaceChildren()
    let discovery = this._config.tagDiscovery ?? {}
    let actions = discovery.actions ?? {}
    this._framework.appendTagAttributeActions(actionsElement, tag, {
      className: actions.className ?? discovery.actionButtonClass,
      iconClass: actions.iconClass ?? discovery.actionIconClass,
      ignoreClassName: actions.ignoreClassName,
      normalize: actions.normalize ?? discovery.normalizeTag,
      bookmark: actions.bookmark,
      ignore: actions.ignore,
      substitute: actions.substitute ?? {fieldKey: discovery.substitutionFieldKey ?? 'filename-tag-substitutions'},
      blacklist: actions.blacklist,
      explore: actions.explore,
    })
    let state = actions.getRowState?.(tag) ?? discovery.getRowState?.(tag)
    if (!state) {
      return
    }
    row.classList.toggle('bv-tag-muted', !!state.muted)
    row.classList.toggle('bv-tag-hidden', !!state.hidden)
    for (let className of actions.extraMutedClasses ?? discovery.extraMutedClasses ?? []) {
      row.classList.toggle(className, !!state.muted)
    }
    for (let className of actions.extraHiddenClasses ?? discovery.extraHiddenClasses ?? []) {
      row.classList.toggle(className, !!state.hidden)
    }
  }

  /**
   * Debounce discovery panel re-renders so attribute clicks finish before DOM swap
   * (synchronous replace mid-click toggles the Similar <details>).
   * @param {string[]|null} [onlyTagNames] When a non-empty array, refresh only if the open
   *   panel lists one of those names. `null`/omitted = unrestricted.
   * @private
   */
  _scheduleTagDiscoveryPanelRefresh(onlyTagNames = null)
  {
    if (this._tagDiscoveryActionInFlight) {
      return
    }
    if (onlyTagNames == null) {
      this._tagDiscoveryRefreshOnlyTags = null
    } else if (Array.isArray(onlyTagNames) && onlyTagNames.length) {
      if (this._tagDiscoveryRefreshOnlyTags == null && this._tagDiscoveryRefreshTimer != null) {
        // A prior unrestricted schedule already queued — keep unrestricted.
      } else if (this._tagDiscoveryRefreshOnlyTags == null && this._tagDiscoveryRefreshTimer == null) {
        this._tagDiscoveryRefreshOnlyTags = onlyTagNames.slice()
      } else if (Array.isArray(this._tagDiscoveryRefreshOnlyTags)) {
        let seen = new Set(this._tagDiscoveryRefreshOnlyTags)
        for (let name of onlyTagNames) {
          if (name && !seen.has(name)) {
            seen.add(name)
            this._tagDiscoveryRefreshOnlyTags.push(name)
          }
        }
      }
    }
    if (this._tagDiscoveryRefreshTimer != null) {
      clearTimeout(this._tagDiscoveryRefreshTimer)
    }
    this._tagDiscoveryRefreshTimer = setTimeout(() => {
      this._tagDiscoveryRefreshTimer = null
      this._refreshTagDiscoveryPanel()
    }, 0)
  }

  /**
   * @private
   */
  _refreshTagDiscoveryPanel()
  {
    if (this._tagDiscoveryActionInFlight) {
      return
    }
    let onlyTags = this._tagDiscoveryRefreshOnlyTags
    this._tagDiscoveryRefreshOnlyTags = undefined
    let state = this._getStateSync()
    if (!state?.resolutionBlocked || state.tagDiscoveryPanelTabId !== this._tabId) {
      return
    }
    if (!state.discoveryPanelTags?.length) {
      return
    }
    if (Array.isArray(onlyTags) && onlyTags.length &&
        !this._discoveryOpenTagNamesContainsAny(onlyTags, state)) {
      return
    }
    // Keep the open review list on attribute toggles (including ignored-pin un-ignore).
    // Re-collect only on Confirm / restore — do not drop rows the moment Aa is cleared.
    this._renderTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
  }

  /**
   * Re-collect ignored-pin rows for restore / Confirm (not attribute toggles).
   * Attribute toggles keep the open list and only refresh action chrome.
   * @return {Promise<void>}
   * @private
   */
  async _refreshIgnoredPinDiscoveryPanel()
  {
    let state = this._getStateSync()
    if (!state?.resolutionBlocked || state.discoveryReviewMode !== 'ignoredPins' ||
        state.tagDiscoveryPanelTabId !== this._tabId) {
      return
    }
    let refreshGen = (this._ignoredPinRefreshGen = (this._ignoredPinRefreshGen ?? 0) + 1)

    let itemId = state.resolutionBlockedItemId
    if (!itemId) {
      // Gate with no subject — unlock so resolution cannot sit in Waiting forever.
      this._hideTagDiscoveryPanel()
      await this._withState((next) => {
        this._clearDiscoveryReviewState(next)
      })
      this._resumeProcessorsAfterDiscoveryReview()
      return
    }
    let queueItem = await this._repos().downloadResolutionQueue.get(itemId)
    if (refreshGen !== this._ignoredPinRefreshGen) {
      return
    }
    let resolved = queueItem?.pendingTagGroups
    if (!resolved) {
      // Blocked item missing or no longer in tagReview — clear the orphan gate.
      this._hideTagDiscoveryPanel()
      await this._withState((next) => {
        this._clearDiscoveryReviewState(next)
      })
      this._resumeProcessorsAfterDiscoveryReview()
      return
    }
    let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
    let tagIncidences = resolved.tagIncidences ?? {}

    let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
    if (refreshGen !== this._ignoredPinRefreshGen) {
      return
    }
    let ignoredTags = ignoredPin.tags
    await this._withState((next) => {
      next.discoveryPanelTags = ignoredTags
      next.discoveryPanelKnownTags = []
    })
    // No actionable ignored rows (or substitution-only empty) — continue.
    if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
      if (queueItem) {
        await this._finishResolutionAfterTagDiscovery(queueItem)
      } else {
        this._hideTagDiscoveryPanel()
        await this._withState((next) => {
          this._clearDiscoveryReviewState(next)
        })
        this._resumeProcessorsAfterDiscoveryReview()
      }
      return
    }
    this._renderTagDiscoveryPanel(ignoredTags, [])
  }

  /**
   * Re-render tag discovery panel rows (e.g. after bookmark/rule toggles).
   * @param {string[]|null} [onlyTagNames] When set, no-op unless the open panel lists one.
   */
  refreshTagDiscoveryPanel(onlyTagNames = null)
  {
    this._scheduleTagDiscoveryPanelRefresh(onlyTagNames)
  }

  /**
   * @param {string|string[]} tags
   * @return {boolean}
   */
  openTagDiscoveryContainsAnyTag(tags)
  {
    let names = Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])
    if (!names.length) {
      return false
    }
    let state = this._getStateSync()
    if (!state?.resolutionBlocked || state.tagDiscoveryPanelTabId !== this._tabId ||
        !state.discoveryPanelTags?.length) {
      return false
    }
    return this._discoveryOpenTagNamesContainsAny(names, state)
  }

  /**
   * Raw + consumer-normalized tag names currently shown on the open discovery panel.
   * @param {object|null|undefined} [state]
   * @return {Set<string>}
   * @private
   */
  _buildDiscoveryOpenTagNames(state = null)
  {
    state = state ?? this._getStateSync()
    let discovery = this._config.tagDiscovery ?? {}
    let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
    let openNames = new Set()
    let add = (name) => {
      if (!name) {
        return
      }
      openNames.add(name)
      if (typeof normalize === 'function') {
        let normalized = normalize(name)
        if (normalized) {
          openNames.add(normalized)
        }
      }
    }
    for (let tag of state?.discoveryPanelTags ?? []) {
      add(tag?.name)
    }
    for (let tag of state?.discoveryPanelKnownTags ?? []) {
      add(tag?.name)
    }
    for (let tag of this._similarDiscoveryCachedTags ?? []) {
      add(tag?.name)
    }
    return openNames
  }

  /**
   * @param {string|string[]} names Notify/detail tag names (often normalized).
   * @param {object|null|undefined} [state]
   * @return {boolean}
   * @private
   */
  _discoveryOpenTagNamesContainsAny(names, state = null)
  {
    let openNames = this._buildDiscoveryOpenTagNames(state)
    let list = Array.isArray(names) ? names : (names != null && names !== '' ? [names] : [])
    let discovery = this._config.tagDiscovery ?? {}
    let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
    return list.some((name) => {
      if (!name) {
        return false
      }
      if (openNames.has(name)) {
        return true
      }
      if (typeof normalize === 'function') {
        let normalized = normalize(name)
        if (normalized && openNames.has(normalized)) {
          return true
        }
      }
      return false
    })
  }

  /**
   * Re-open the tag discovery panel after reload / tab focus when review is still pending.
   * Safe to call once the dock exists (or on later focus). Visible tabs steal ownership.
   * @return {Promise<void>}
   */
  async restoreTagDiscoveryPanelIfNeeded()
  {
    return this._restoreTagDiscoveryPanelIfNeeded()
  }

  /**
   * Re-open the tag discovery panel after reload / tab focus when review is still pending.
   * @return {Promise<void>}
   * @private
   */
  async _restoreTagDiscoveryPanelIfNeeded()
  {
    if (!this._config.tagDiscovery) {
      return
    }
    if (this._tagDiscoveryActionInFlight) {
      return
    }
    let state = await this._getState()
    // Already gated — show even if discovery was toggled off so the user can Confirm/Skip.
    // Re-collect ignored-pin rows so un-ignore / strip-base ignores match current registry.
    if (state.resolutionBlocked && state.discoveryReviewMode === 'ignoredPins') {
      // Steal ownership from a closed/stale tab so refresh/clear can run here.
      await this._withState((next) => {
        next.tagDiscoveryPanelTabId = this._tabId
      })
      await this._refreshIgnoredPinDiscoveryPanel()
      let after = this._getStateSync()
      if (!after?.resolutionBlocked) {
        return
      }
      if (after.discoveryPanelTags?.length) {
        await this._showTagDiscoveryPanel(after.discoveryPanelTags, [])
        return
      }
      // Still gated with an empty list — unlock (refresh should have cleared; belt-and-suspenders).
      this._hideTagDiscoveryPanel()
      let stuckId = after.resolutionBlockedItemId
      let stuckItem = stuckId ? await this._repos().downloadResolutionQueue.get(stuckId) : null
      if (stuckItem?.pendingTagGroups) {
        await this._finishResolutionAfterTagDiscovery(stuckItem)
      } else {
        await this._withState((next) => {
          this._clearDiscoveryReviewState(next)
        })
        this._resumeProcessorsAfterDiscoveryReview()
      }
      return
    }
    // Unknown review: always re-partition from pending tag groups so pin scope
    // applies after reload (do not reopen a stale all-types list from IDB).
    if (state.resolutionBlocked) {
      let tagGroups = null
      let tagIncidences = {}
      let queueItem = null
      if (state.resolutionBlockedItemId) {
        queueItem = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
        let resolved = queueItem?.pendingTagGroups
        if (resolved) {
          tagGroups = this._tagGroupsFromResolvedPayload(resolved)
          tagIncidences = resolved.tagIncidences ?? {}
        }
      }
      if (tagGroups && this._isTagDiscoveryActive() && state.discoveryReviewMode !== 'ignoredPins') {
        let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
        if (lists.unknown.length) {
          await this._withState((next) => {
            next.discoveryReviewMode = 'unknown'
            next.discoveryPanelTags = lists.unknown
            next.discoveryPanelKnownTags = lists.known
            next.tagDiscoveryPanelTabId = this._tabId
          })
          await this._showTagDiscoveryPanel(lists.unknown, lists.known)
          return
        }
        let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
        if (ignoredPin.pinJoinEmpty && ignoredPin.tags.length) {
          await this._withState((next) => {
            next.discoveryReviewMode = 'ignoredPins'
            next.discoveryPanelTags = ignoredPin.tags
            next.discoveryPanelKnownTags = []
            next.tagDiscoveryPanelTabId = this._tabId
          })
          await this._showTagDiscoveryPanel(ignoredPin.tags, [])
          return
        }
        // Pin scope left nothing to review — continue instead of reopening a stale all-types list.
        if (queueItem?.pendingTagGroups) {
          await this._finishResolutionAfterTagDiscovery(queueItem)
          return
        }
        await this._withState((next) => {
          this._clearDiscoveryReviewState(next)
        })
        return
      }
      // Gate set but subject missing / no pending groups — clear instead of zombie panel.
      if (!(queueItem?.pendingTagGroups)) {
        this._hideTagDiscoveryPanel()
        await this._withState((next) => {
          this._clearDiscoveryReviewState(next)
        })
        this._resumeProcessorsAfterDiscoveryReview()
        return
      }
      // No tag groups available to re-scope — last resort show cached rows.
      if (state.discoveryPanelTags?.length) {
        await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
      }
      return
    }
    // Recover orphan `tagReview` rows (including when discovery was toggled off mid-review).
    let resolutionRows = await this._repos().downloadResolutionQueue.listByStatus('tagReview')
    let stuck = resolutionRows.find((row) => row.pendingTagGroups)
    if (!stuck) {
      return
    }
    let pending = stuck.pendingTagGroups
    let tagGroups = this._tagGroupsFromResolvedPayload(pending)
    let tagIncidences = pending.tagIncidences ?? {}
    if (this._isTagDiscoveryActive()) {
      let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
      if (lists.unknown.length) {
        await this._withState((next) => {
          next.resolutionBlocked = true
          next.resolutionBlockedItemId = stuck.itemId
          next.discoveryReviewMode = 'unknown'
          next.discoveryPanelTags = lists.unknown
          next.discoveryPanelKnownTags = lists.known
        })
        let stateAfter = this._getStateSync()
        if (stateAfter?.discoveryPanelTags?.length) {
          await this._showTagDiscoveryPanel(stateAfter.discoveryPanelTags, stateAfter.discoveryPanelKnownTags)
        }
        return
      }
    }
    let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
    let ignoredTags = ignoredPin.tags
    if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
      // Nothing left to review — promote/drop the orphan tagReview row instead of
      // leaving it stranded (peek only claims `queued`, so it would sit forever).
      await this._finishResolutionAfterTagDiscovery(stuck)
      return
    }
    await this._withState((next) => {
      next.resolutionBlocked = true
      next.resolutionBlockedItemId = stuck.itemId
      next.discoveryReviewMode = 'ignoredPins'
      next.discoveryPanelTags = ignoredTags
      next.discoveryPanelKnownTags = []
    })
    await this._showTagDiscoveryPanel(ignoredTags, [])
    // Resume deferred discovery lane after reload when resolution queue is empty.
    let latest = this._getStateSync() ?? await this._getState()
    if (this._isDeferredTagDiscoveryEnabled() && !latest?.resolutionBlocked) {
      let [deferredRows, queuedRows] = await Promise.all([
        this._repos().downloadResolutionQueue.listByStatus('discoveryQueued'),
        this._repos().downloadResolutionQueue.listByStatus('queued'),
      ])
      if (deferredRows.length && !queuedRows.length) {
        await this._withState((next) => {
          next.discoveryLanePhaseActive = true
        })
        this._signalProcessorsWake()
        if (this._shouldRunProcessors()) {
          void this._runProcessors()
        }
      }
    }
  }

  /**
   * @private
   */
  _hideTagDiscoveryPanel()
  {
    if (this._tagDiscoveryRefreshTimer != null) {
      clearTimeout(this._tagDiscoveryRefreshTimer)
      this._tagDiscoveryRefreshTimer = null
    }
    this._cancelSimilarDiscoveryLoad()
    this._similarDiscoveryCachedTags = null
    this._similarDiscoveryFingerprint = null
    if (!this._tagDiscoveryPanel) {
      return
    }
    this._framework._hideDockSlidePanel(this._tagDiscoveryPanel)
  }

  // -------------------------------------------------------------------------
  // Processors & sync
  // -------------------------------------------------------------------------

  /**
   * True when the processor lock looks dead. Uses the fresher of IDB
   * `processingHeartbeatAt` and the sync localStorage mirror `at` when the mirror
   * still names the same owner — background timer throttling often stalls IDB
   * heartbeats while the leader is still pacing / resolving.
   * @param {object|null} state
   * @return {boolean}
   * @private
   */
  _isProcessingLockStale(state)
  {
    if (!state?.processingTabId) {
      return true
    }
    let lastBeat = state.processingHeartbeatAt ?? 0
    let mirror = this._readProcessingLockMirror()
    if (mirror?.tabId === state.processingTabId) {
      let mirrorAt = Number(mirror.at) || 0
      lastBeat = Math.max(lastBeat, mirrorAt)
    }
    return (Date.now() - lastBeat) > PROCESSING_LOCK_STALE_MS
  }

  /**
   * Sync companion to IndexedDB `processingTabId`. Cleared on `pagehide` so a refresh can
   * steal immediately even when the async IDB release never finishes.
   * @return {string}
   * @private
   */
  _processingLockStorageKey()
  {
    return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-processing-lock')
  }

  /**
   * Cross-tab wake ping so an idle leader starts work when another tab enqueues.
   * (`storage` events fire only in other documents — not the writer.)
   * @return {string}
   * @private
   */
  _processorsWakeStorageKey()
  {
    return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-processors-wake')
  }

  /**
   * Cross-tab ping so non-leader tabs can repaint dock progress counters.
   * (`storage` events fire only in other documents — not the writer.)
   * @return {string}
   * @private
   */
  _progressUiWakeStorageKey()
  {
    return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-progress-ui-wake')
  }

  /**
   * Notify other tabs that queue work may be available.
   * Debounced so bulk enqueue coalesces to one storage event.
   * @private
   */
  _signalProcessorsWake()
  {
    if (this._processorsWakeSignalTimer) {
      clearTimeout(this._processorsWakeSignalTimer)
    }
    this._processorsWakeSignalTimer = setTimeout(() => {
      this._processorsWakeSignalTimer = null
      try {
        localStorage.setItem(this._processorsWakeStorageKey(), String(Date.now()))
      } catch (e) {
        // ignore quota / private-mode localStorage failures
      }
    }, PROCESSORS_WAKE_SIGNAL_DEBOUNCE_MS)
  }

  /**
   * Notify other tabs that dock progress counters changed.
   * Debounced so rapid increments coalesce to one storage event.
   * @private
   */
  _signalProgressUiWake()
  {
    if (this._progressUiWakeSignalTimer) {
      clearTimeout(this._progressUiWakeSignalTimer)
    }
    this._progressUiWakeSignalTimer = setTimeout(() => {
      this._progressUiWakeSignalTimer = null
      try {
        localStorage.setItem(this._progressUiWakeStorageKey(), String(Date.now()))
      } catch (e) {
        // ignore quota / private-mode localStorage failures
      }
    }, PROGRESS_UI_WAKE_SIGNAL_DEBOUNCE_MS)
  }

  /**
   * Debounced reload of cached DM state + dock progress paint (visible tabs only).
   * @private
   */
  _scheduleProgressUiWakeReceive()
  {
    if (this._progressUiWakeReceiveTimer) {
      clearTimeout(this._progressUiWakeReceiveTimer)
    }
    this._progressUiWakeReceiveTimer = setTimeout(() => {
      this._progressUiWakeReceiveTimer = null
      void this._refreshProgressUiFromExternalWake()
    }, PROGRESS_UI_WAKE_RECEIVE_DEBOUNCE_MS)
  }

  /**
   * Visible non-leader tabs: hydrate completed counts, dock progress, and selection tiles.
   * @return {Promise<void>}
   * @private
   */
  async _refreshProgressUiFromExternalWake()
  {
    if (this._documentSuspended || document.visibilityState !== 'visible') {
      return
    }
    if (this._cm.canPersist()) {
      this._cachedState = await this._repos().downloadManagerState.get()
    }
    this._refreshDockProgress({refreshAllItems: false})
    this._refreshSelectionMarks()
    this._framework._scheduleLedgerComplianceRefresh?.()
  }

  /**
   * Schedule reclaim / resume after focus, bfcache restore, or cross-tab wake.
   * Debounced so stacked wake events do not run concurrent full syncs.
   * @param {{fullUi?: boolean, requeueInterrupted?: boolean}} [options]
   * @private
   */
  _scheduleResumeFromExternalWake(options = {})
  {
    let fullUi = options.fullUi !== false
    let requeueInterrupted = options.requeueInterrupted === true
    if (this._processorsWakeReceiveTimer) {
      clearTimeout(this._processorsWakeReceiveTimer)
    }
    this._processorsWakeReceiveTimer = setTimeout(() => {
      this._processorsWakeReceiveTimer = null
      this._processorsWakeResumeTail = this._processorsWakeResumeTail
          .catch(() => {})
          .then(() => this._resumeProcessorsFromExternalWake({fullUi, requeueInterrupted}))
    }, PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS)
  }

  /**
   * Reclaim leadership and resume processors after focus / bfcache restore / cross-tab wake.
   * Hidden tabs skip full UI hydrate — only cheap leadership / processor resume.
   * @param {{fullUi?: boolean, requeueInterrupted?: boolean}} [options]
   * @return {Promise<void>}
   * @private
   */
  async _resumeProcessorsFromExternalWake(options = {})
  {
    if (this._documentSuspended) {
      return
    }
    let fullUi = options.fullUi !== false && document.visibilityState === 'visible'
    let requeueInterrupted = options.requeueInterrupted === true
    if (fullUi) {
      await this._syncFromStorage()
    } else {
      // Background wake: refresh cached state only — no dock/selection listAll storm.
      // Permanent hidden leaders rely on this path (storage wake), not visibilitychange.
      if (this._cm.canPersist()) {
        this._cachedState = await this._repos().downloadManagerState.get()
      }
    }
    if (requeueInterrupted && this._cm.canPersist()) {
      // After bfcache / unload interrupt, local active ids are cleared — reclaim orphan
      // `resolving` / `downloading` rows before renewing an owned lock (renew skips requeue).
      let state = this._getStateSync() ?? await this._getState()
      if (state?.processingTabId === this._tabId || !this._isProcessingOwnerAlive(state)) {
        await this._requeueInterruptedPipelineWork({deferInProgress: fullUi})
      }
    }
    // Renew when we own; steal only when visible and the owner is truly dead.
    // Browse maximize with a live hidden leader hits `_isProcessingOwnerAlive` and
    // does not take the crown.
    await this._tryClaimProcessingLeadership()
    if (fullUi) {
      // Tag discovery / HI UI belong on the visible browse tab; processor crown
      // still reflects IDB `processingTabId` (leader) after dock chrome refresh.
      await this._restoreTagDiscoveryPanelIfNeeded()
      await this._restoreHumanInteractionPanelsIfNeeded()
      if (this._downloadInterruptionPending) {
        this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
      }
    }
    // Hidden leader renew: restart processors after a loop that exited on a failed
    // claim (or idle settle) without needing a visibility flip on the leader tab.
    if (this._shouldRunProcessors()) {
      void this._runProcessors()
    }
  }

  /**
   * Stop local processor ownership so unload / bfcache cannot leave orphan `resolving`
   * rows shielded by stale `_active*ItemId`, and so late awaits cannot recommit.
   * @private
   */
  _interruptDocumentProcessors()
  {
    this._processorEpoch++
    this._stopProcessingHeartbeat()
    this._activeResolutionItemId = null
    this._activeDownloadItemId = null
    this._processing = false
    this._resolutionProcessing = false
    this._downloadProcessing = false
    this._dispatcherWakeRequested = false
    this._resolutionWakeRequested = false
    this._downloadWakeRequested = false
  }

  /**
   * Sync unload path: block mirror rewrites, drop processor RAM ownership, clear the
   * leadership mirror, close IDB so bfcache cannot pin the database, then best-effort
   * async release of IDB leadership / panel ownership.
   * @private
   */
  _suspendDocumentForUnload()
  {
    if (this._documentSuspended) {
      return
    }
    this._documentSuspended = true
    this._interruptDocumentProcessors()
    this._stopHumanInteractionWatchdog()
    this._clearProcessingLockMirrorIfOwned()
    try {
      this._cm.repos?.storage?.close?.()
    } catch (e) {
      // ignore
    }
    void this._releaseProcessingLeadership().catch(() => {})
    void this._releaseTagDiscoveryPanelOwnership().catch(() => {})
    void this._releaseHumanInteractionPanelOwnership().catch(() => {})
  }

  /**
   * bfcache restore: reopen storage, invalidate any thawed processor continuations, then
   * resume with forced orphan requeue.
   * @private
   */
  _resumeDocumentAfterBfcache()
  {
    this._documentSuspended = false
    this._interruptDocumentProcessors()
    this._startHumanInteractionWatchdog()
    void (async () => {
      try {
        await this._cm.repos?.storage?.open?.()
      } catch (e) {
        // ignore — first IDB helper will retry open()
      }
      this._scheduleResumeFromExternalWake({fullUi: true, requeueInterrupted: true})
    })()
  }

  /**
   * @return {{tabId: string, at: number}|null}
   * @private
   */
  _readProcessingLockMirror()
  {
    try {
      let raw = localStorage.getItem(this._processingLockStorageKey())
      if (!raw) {
        return null
      }
      let parsed = JSON.parse(raw)
      if (!parsed?.tabId) {
        return null
      }
      return parsed
    } catch (e) {
      return null
    }
  }

  /**
   * @private
   */
  _writeProcessingLockMirror()
  {
    if (this._documentSuspended) {
      return
    }
    try {
      localStorage.setItem(this._processingLockStorageKey(), JSON.stringify({
        tabId: this._tabId,
        at: Date.now(),
      }))
    } catch (e) {
      // ignore quota / private-mode localStorage failures
    }
  }

  /**
   * Sync-touch the lock mirror while this tab owns `processingTabId` (e.g. during
   * initiation gaps) so background tabs do not treat a throttled IDB heartbeat as death.
   * @private
   */
  _touchProcessingLockMirrorIfOwned()
  {
    if (this._documentSuspended) {
      return
    }
    let state = this._getStateSync()
    if (state?.processingTabId !== this._tabId) {
      return
    }
    this._writeProcessingLockMirror()
  }

  /**
   * @private
   */
  _clearProcessingLockMirrorIfOwned()
  {
    try {
      let key = this._processingLockStorageKey()
      let mirror = this._readProcessingLockMirror()
      if (mirror?.tabId === this._tabId) {
        localStorage.removeItem(key)
      }
    } catch (e) {
      // ignore
    }
  }

  /**
   * True when another tab still looks like a live processor owner (fresh IDB-or-mirror
   * heartbeat via `_isProcessingLockStale`, and matching sync localStorage mirror).
   * Missing/mismatched mirror means the owner unloaded.
   * @param {object|null} state
   * @return {boolean}
   * @private
   */
  _isProcessingOwnerAlive(state)
  {
    if (!state?.processingTabId || state.processingTabId === this._tabId) {
      return false
    }
    if (this._isProcessingLockStale(state)) {
      return false
    }
    let mirror = this._readProcessingLockMirror()
    return !!(mirror && mirror.tabId === state.processingTabId)
  }

  /**
   * Claim or renew the cross-tab processor lock. Renews when this tab already owns.
   * Steals only when visible and the owner is dead (dual-stale heartbeat, or sync mirror
   * cleared on unload). Hidden tabs never steal — they renew owned locks only.
   * @return {Promise<boolean>}
   * @private
   */
  async _tryClaimProcessingLeadership()
  {
    if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
      return false
    }
    // Queue verification tabs must never run processors or hold the lock — if the
    // leader refreshes while a CF tab is open, that tab used to steal leadership and
    // leave the resume panel stranded until the lock went stale.
    if (this._isQueueVerificationTab()) {
      let owned = (await this._getState())?.processingTabId === this._tabId
      if (owned) {
        await this._releaseProcessingLeadership()
      } else {
        this._stopProcessingHeartbeat()
      }
      return false
    }
    let state = await this._getState()
    if (state.processingTabId === this._tabId) {
      this._hydrateInitiationClocksFromState(state)
      // Heartbeat renew via `_withState` — a raw put of a stale snapshot can wipe
      // `lastResolutionInitiationAt` / human-interaction flags written by the processor.
      await this._withState((next) => {
        if (this._documentSuspended || next.processingTabId !== this._tabId) {
          return
        }
        next.processingHeartbeatAt = Date.now()
      })
      this._writeProcessingLockMirror()
      this._startProcessingHeartbeat()
      return true
    }
    if (this._isProcessingOwnerAlive(state)) {
      this._stopProcessingHeartbeat()
      return false
    }
    // Hidden tabs renew owned locks (above) but never steal. Stops background
    // ping-pong; a visible tab (browse on maximize) may steal a truly dead owner
    // (mirror cleared / dual-stale). Browse auto-steal of a live hidden leader is
    // prevented by mirror-fused staleness in `_isProcessingLockStale`.
    if (document.visibilityState !== 'visible') {
      this._stopProcessingHeartbeat()
      return false
    }

    await this._requeueInterruptedPipelineWork({
      deferInProgress: document.visibilityState === 'visible' && !this._documentSuspended,
    })
    // Claim via `_withState` so a concurrent progress increment cannot be wiped by a
    // stale full-state snapshot put after the requeue awaits above.
    let claimed = false
    await this._withState((next) => {
      if (this._documentSuspended) {
        return
      }
      if (document.visibilityState !== 'visible') {
        return
      }
      if (this._isProcessingOwnerAlive(next) && next.processingTabId !== this._tabId) {
        return
      }
      next.processingTabId = this._tabId
      next.processingHeartbeatAt = Date.now()
      if (next.tagDiscoveryPanelTabId && next.tagDiscoveryPanelTabId !== this._tabId) {
        next.tagDiscoveryPanelTabId = null
      }
      this._hydrateInitiationClocksFromState(next)
      claimed = true
    })
    if (!claimed) {
      return false
    }
    this._writeProcessingLockMirror()
    this._startProcessingHeartbeat()
    // Fresh ledger after taking leadership so duplicate checks match other tabs' claims.
    await this._framework._reloadDownloadDuplicateLedgerFromStorage()
    // Crash leftovers with fat payloads — prune before processors resume.
    await this._pruneAllTerminalQueueRows()
    return true
  }

  /**
   * User-driven leadership transfer: take the processor lock even when another tab still
   * looks alive. Caller must gate on idle ({@link isDownloadManagerRunning}) first.
   * @return {Promise<boolean>}
   * @private
   */
  async _forceClaimProcessingLeadership()
  {
    if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
      return false
    }
    if (this._isQueueVerificationTab()) {
      let owned = (await this._getState())?.processingTabId === this._tabId
      if (owned) {
        await this._releaseProcessingLeadership()
      } else {
        this._stopProcessingHeartbeat()
      }
      return false
    }
    let state = await this._getState()
    if (state.processingTabId === this._tabId) {
      this._hydrateInitiationClocksFromState(state)
      await this._withState((next) => {
        if (this._documentSuspended || next.processingTabId !== this._tabId) {
          return
        }
        next.processingHeartbeatAt = Date.now()
      })
      this._writeProcessingLockMirror()
      this._startProcessingHeartbeat()
      return true
    }

    let claimed = false
    await this._withState((next) => {
      if (this._documentSuspended) {
        return
      }
      next.processingTabId = this._tabId
      next.processingHeartbeatAt = Date.now()
      if (next.tagDiscoveryPanelTabId && next.tagDiscoveryPanelTabId !== this._tabId) {
        next.tagDiscoveryPanelTabId = null
      }
      this._hydrateInitiationClocksFromState(next)
      claimed = true
    })
    if (!claimed) {
      return false
    }
    this._writeProcessingLockMirror()
    this._startProcessingHeartbeat()
    await this._framework._reloadDownloadDuplicateLedgerFromStorage()
    await this._pruneAllTerminalQueueRows()
    return true
  }

  /**
   * Requeue orphan `resolving` / `downloading` rows after unload / leadership steal.
   * When `deferInProgress` is true on a visible page, `downloading` rows with `inProgress`
   * are held for the interruption timed panel instead of being flipped to `queued`.
   * @param {{deferInProgress?: boolean}} [options]
   * @return {Promise<void>}
   * @private
   */
  async _requeueInterruptedPipelineWork(options = {})
  {
    let deferInProgress = options.deferInProgress === true &&
        document.visibilityState === 'visible' &&
        !this._documentSuspended

    let resolutionRows = await this._repos().downloadResolutionQueue.listByStatus('resolving')
    for (let row of resolutionRows) {
      // Never requeue an item this tab is actively processing.
      if (String(this._activeResolutionItemId) === String(row.itemId)) {
        continue
      }
      row.status = 'queued'
      row.error = null
      await this._repos().downloadResolutionQueue.put(row)
    }
    let downloadRows = await this._repos().downloadQueue.listByStatus('downloading')
    /** @type {object[]} */
    let pending = []
    for (let row of downloadRows) {
      if (String(this._activeDownloadItemId) === String(row.itemId)) {
        continue
      }
      if (deferInProgress && row.inProgress) {
        pending.push(row)
        continue
      }
      row.status = 'queued'
      row.error = null
      row.inProgress = false
      // Keep ledgerClaimed — the slot was already reserved before GM_download.
      await this._repos().downloadQueue.put(row)
    }
    if (pending.length) {
      this._downloadInterruptionPending = true
      this._downloadInterruptionRows = pending
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _releaseProcessingLeadership()
  {
    this._stopProcessingHeartbeat()
    this._clearProcessingLockMirrorIfOwned()
    // While unloading / bfcache-frozen, do not reopen IDB just to clear the lock —
    // the sync mirror clear is enough for the next document to steal.
    if (this._documentSuspended || !this._cm.canPersist()) {
      return
    }
    await this._withState((state) => {
      if (state.processingTabId !== this._tabId) {
        return
      }
      state.processingTabId = null
      state.processingHeartbeatAt = 0
    })
  }

  /**
   * @private
   */
  _startProcessingHeartbeat()
  {
    if (this._processingHeartbeatTimer) {
      return
    }
    this._processingHeartbeatTimer = setInterval(() => {
      if (this._documentSuspended) {
        this._stopProcessingHeartbeat()
        return
      }
      void this._maybeExpireHumanInteractionBlocks()
      void this._withState((state) => {
        if (this._documentSuspended || state.processingTabId !== this._tabId) {
          this._stopProcessingHeartbeat()
          this._clearProcessingLockMirrorIfOwned()
          return
        }
        state.processingHeartbeatAt = Date.now()
        this._writeProcessingLockMirror()
      })
    }, PROCESSING_HEARTBEAT_INTERVAL_MS)
  }

  /**
   * @private
   */
  _stopProcessingHeartbeat()
  {
    if (!this._processingHeartbeatTimer) {
      return
    }
    clearInterval(this._processingHeartbeatTimer)
    this._processingHeartbeatTimer = null
  }

  /**
   * Clear human-interaction lanes whose soft-expiry TTL has elapsed (works on hidden leaders).
   * @param {object|null|undefined} [state]
   * @return {Promise<boolean>} true when any lane was cleared
   * @private
   */
  async _maybeExpireHumanInteractionBlocks(state = null)
  {
    let snapshot = state ?? this._getStateSync() ?? await this._getState()
    let clearedAny = false
    for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
      let lane = this._hiLane(snapshot, ctx)
      if (!lane) {
        continue
      }
      let expireMs = Number(
          this._config.rateLimitHandlers?.[ctx]?.humanInteraction?.softExpireMs,
      )
      if (!(Number.isFinite(expireMs) && expireMs > 0)) {
        expireMs = HUMAN_INTERACTION_SOFT_EXPIRE_MS
      }
      if (Date.now() - (Number(lane.at) || 0) < expireMs) {
        continue
      }
      await this._clearHumanInteractionBlock(ctx)
      clearedAny = true
      snapshot = this._getStateSync() ?? snapshot
    }
    if (clearedAny) {
      this._signalProcessorsWake()
      if (this._shouldRunProcessors()) {
        void this._runProcessors()
      }
    }
    return clearedAny
  }

  /**
   * Re-show / reclaim HI panels on a visible tab; hide local panels when the lane was cleared remotely.
   * @param {object|null|undefined} [state]
   * @return {Promise<void>}
   * @private
   */
  async _restoreHumanInteractionPanelsIfNeeded(state = null)
  {
    if (this._documentSuspended) {
      return
    }
    await this._maybeExpireHumanInteractionBlocks(state)
    let snapshot = this._getStateSync() ?? state ?? await this._getState()
    for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
      let lane = this._hiLane(snapshot, ctx)
      if (!lane) {
        this._hideHumanInteractionPanel(ctx)
        continue
      }
      // Orphan lane (item gone from both queues) — clear so Waiting cannot stick forever.
      if (lane.itemId != null && this._cm.canPersist()) {
        let itemKey = String(lane.itemId)
        let [resolution, download] = await Promise.all([
          this._repos().downloadResolutionQueue.get(itemKey),
          this._repos().downloadQueue.get(itemKey),
        ])
        let resolutionAlive = !!(resolution && !RESOLUTION_TERMINAL.has(resolution.status))
        let downloadAlive = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
        if (!resolutionAlive && !downloadAlive) {
          await this._clearHumanInteractionBlock(ctx)
          this._signalProcessorsWake()
          if (this._shouldRunProcessors()) {
            void this._runProcessors()
          }
          snapshot = this._getStateSync() ?? snapshot
          continue
        }
      }
      if (document.visibilityState !== 'visible') {
        continue
      }
      void this._promptHumanInteractionResume(ctx)
    }
  }

  /**
   * Lightweight periodic reclaim so a stale/closed `promptTabId` cannot leave HI blocked forever.
   * @private
   */
  _startHumanInteractionWatchdog()
  {
    if (this._humanInteractionWatchdogTimer) {
      return
    }
    this._humanInteractionWatchdogTimer = setInterval(() => {
      if (this._documentSuspended) {
        return
      }
      void this._maybeExpireHumanInteractionBlocks()
      if (document.visibilityState !== 'visible') {
        return
      }
      let state = this._getStateSync()
      if (!this._anyHumanInteraction(state)) {
        // Still hide any leftover local zombie if lanes are clear.
        this._hideHumanInteractionPanel()
        return
      }
      void this._restoreHumanInteractionPanelsIfNeeded(state)
    }, HUMAN_INTERACTION_WATCHDOG_MS)
  }

  /**
   * @private
   */
  _stopHumanInteractionWatchdog()
  {
    if (!this._humanInteractionWatchdogTimer) {
      return
    }
    clearInterval(this._humanInteractionWatchdogTimer)
    this._humanInteractionWatchdogTimer = null
  }

  /**
   * Lane descriptors wrapping peek/claim/process/block for the dispatcher.
   * @return {{resolution: object, download: object}}
   * @private
   */
  _ensureLanes()
  {
    if (this._lanes) {
      return this._lanes
    }
    this._lanes = {
      resolution: {
        id: /** @type {BrazenDownloadManagerLaneId} */ ('resolution'),
        peek: async () => {
          let state = await this._getState()
          return this._peekResolutionWork(state)
        },
        claim: (itemId) => this._claimResolutionWorkItem(itemId),
        process: (item) => this._processResolutionWorkItem(item),
        ownBlocked: (state) => this._isResolutionPipelineBlocked(state),
        getActiveId: () => this._activeResolutionItemId,
        setActiveId: (itemId) => { this._activeResolutionItemId = itemId },
        processingKey: '_resolutionProcessing',
        wakeKey: '_resolutionWakeRequested',
      },
      download: {
        id: /** @type {BrazenDownloadManagerLaneId} */ ('download'),
        peek: () => this._repos().downloadQueue.peekNextQueued(),
        claim: (itemId) => this._claimDownloadQueueItem(itemId),
        process: (item) => this._processDownloadItem(item),
        ownBlocked: (state) => this._isDownloadPipelineBlocked(state),
        getActiveId: () => this._activeDownloadItemId,
        setActiveId: (itemId) => { this._activeDownloadItemId = itemId },
        processingKey: '_downloadProcessing',
        wakeKey: '_downloadWakeRequested',
      },
    }
    return this._lanes
  }

  /**
   * Scheduling policy from `linkQueues`.
   * IndependentPolicy (default / r34xxx): each unblocked lane advances in parallel.
   * InterleavedPolicy (linked): single serial selection, shared HI pause, opportunistic
   * download during discovery, retractable in-flight jobs.
   * @return {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}}
   * @private
   */
  _getSchedulingPolicy()
  {
    if (this._linkQueues) {
      return {
        name: 'interleaved',
        isLaneBlocked: (lane, state) => {
          // Any lane HI pauses the shared regime.
          if (this._anyHumanInteraction(state)) {
            return true
          }
          if (lane.id === 'download' && state.paused) {
            return true
          }
          // Discovery gates resolution only — download stays opportunistic.
          if (lane.id === 'resolution' && state.resolutionBlocked) {
            return true
          }
          return false
        },
        run: () => { void this._runInterleavedDispatcher() },
      }
    }
    /** @type {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}} */
    let independent = {
      name: 'independent',
      isLaneBlocked: (lane, state) => lane.ownBlocked(state),
      run: () => {
        let lanes = this._ensureLanes()
        void this._runLaneProcessor(lanes.resolution, independent)
        void this._runLaneProcessor(lanes.download, independent)
      },
    }
    return independent
  }

  /**
   * Kick entry for init, enqueue, Start, tag confirm/skip, HI resume, and visibility.
   * Delegates to IndependentPolicy or InterleavedPolicy from `linkQueues`.
   * @return {Promise<void>}
   * @private
   */
  async _runProcessors()
  {
    if (!this._shouldRunProcessors()) {
      return
    }
    if (!await this._tryClaimProcessingLeadership()) {
      // Do not busy-reenter on wake after a failed claim.
      this._dispatcherWakeRequested = false
      this._resolutionWakeRequested = false
      this._downloadWakeRequested = false
      return
    }
    this._runDispatcher(this._getSchedulingPolicy())
  }

  /**
   * @param {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}} policy
   * @private
   */
  _runDispatcher(policy)
  {
    policy.run()
  }

  /**
   * IndependentPolicy lane loop (one concurrent loop per unblocked lane).
   * @param {object} lane
   * @param {{isLaneBlocked: function(object, object): boolean}} policy
   * @return {Promise<void>}
   * @private
   */
  async _runLaneProcessor(lane, policy)
  {
    if (this[lane.processingKey]) {
      this[lane.wakeKey] = true
      return
    }
    let runEpoch = this._processorEpoch
    this[lane.processingKey] = true
    try {
      do {
        this[lane.wakeKey] = false
        if (this._processorEpoch !== runEpoch || this._documentSuspended) {
          this[lane.wakeKey] = false
          break
        }
        if (!await this._tryClaimProcessingLeadership()) {
          this[lane.wakeKey] = false
          break
        }
        if (!this._shouldRunProcessors()) {
          break
        }
        while (this._shouldRunProcessors() && this._processorEpoch === runEpoch) {
          await this._bumpProcessingHeartbeat()
          if (this._processorEpoch !== runEpoch) {
            break
          }
          let state = await this._getState()
          await this._maybeExpireHumanInteractionBlocks(state)
          state = this._getStateSync() ?? state
          if (policy.isLaneBlocked(lane, state)) {
            break
          }

          let next = await lane.peek()
          if (!next) {
            break
          }
          let claimed = await lane.claim(next.itemId)
          if (!claimed) {
            // Another worker claimed it — exit; a wake will restart if needed.
            break
          }
          if (this._processorEpoch !== runEpoch) {
            break
          }
          lane.setActiveId(claimed.itemId)
          try {
            this._refreshItemProgress(claimed.itemId)
            await lane.process(claimed)
            if (this._processorEpoch === runEpoch) {
              await this._reloadCachedStateQuiet()
            }
          } finally {
            if (this._processorEpoch === runEpoch) {
              lane.setActiveId(null)
            }
          }
        }
      } while (this[lane.wakeKey] && this._processorEpoch === runEpoch)
    } finally {
      if (this._processorEpoch === runEpoch) {
        this[lane.processingKey] = false
      }
      if (this._processorEpoch === runEpoch && !this._documentSuspended) {
        // Resolution often finishes last — settle download idle here too.
        if (lane.id === 'resolution' && await this._tryRecoverStrandedDownloads()) {
          this._downloadWakeRequested = true
          let lanes = this._ensureLanes()
          void this._runLaneProcessor(lanes.download, policy)
        } else if (lane.id === 'download' && await this._tryRecoverStrandedDownloads()) {
          this[lane.wakeKey] = true
        }
        await this._pauseDownloadQueueIfIdle()
        await this._resetProgressCountersIfIdle()
        this._refreshDockProgress({refreshAllItems: false})
        if (this[lane.wakeKey]) {
          this[lane.wakeKey] = false
          void this._runLaneProcessor(lane, policy)
        }
      }
    }
  }

  /**
   * InterleavedPolicy: single serial selection across lanes under one shared regime.
   * Prefers resolution; when resolution is discovery-gated, opportunistically downloads.
   * Retracts in-flight jobs when a shared gate (any HI / pause) lands mid-process.
   * @return {Promise<void>}
   * @private
   */
  async _runInterleavedDispatcher()
  {
    if (this._processing) {
      this._dispatcherWakeRequested = true
      return
    }
    let runEpoch = this._processorEpoch
    this._processing = true
    let policy = this._getSchedulingPolicy()
    let lanes = this._ensureLanes()
    try {
      do {
        this._dispatcherWakeRequested = false
        if (this._processorEpoch !== runEpoch || this._documentSuspended) {
          this._dispatcherWakeRequested = false
          break
        }
        if (!await this._tryClaimProcessingLeadership()) {
          this._dispatcherWakeRequested = false
          break
        }
        if (!this._shouldRunProcessors()) {
          break
        }
        while (this._shouldRunProcessors() && this._processorEpoch === runEpoch) {
          await this._bumpProcessingHeartbeat()
          if (this._processorEpoch !== runEpoch) {
            break
          }
          let state = await this._getState()
          await this._maybeExpireHumanInteractionBlocks(state)
          state = this._getStateSync() ?? state
          // Any HI pauses the entire shared regime.
          if (this._anyHumanInteraction(state)) {
            break
          }

          let selected = null
          // Prefer resolution when it can take work.
          if (!policy.isLaneBlocked(lanes.resolution, state)) {
            let nextResolution = await lanes.resolution.peek()
            if (nextResolution) {
              selected = {lane: lanes.resolution, next: nextResolution}
            }
          }
          // Opportunistic download while resolution is discovery-gated or empty.
          if (!selected && !policy.isLaneBlocked(lanes.download, state)) {
            let nextDownload = await lanes.download.peek()
            if (nextDownload) {
              selected = {lane: lanes.download, next: nextDownload}
            }
          }
          if (!selected) {
            break
          }

          let claimed = await selected.lane.claim(selected.next.itemId)
          if (!claimed) {
            break
          }
          if (this._processorEpoch !== runEpoch) {
            break
          }
          selected.lane.setActiveId(claimed.itemId)
          try {
            this._refreshItemProgress(claimed.itemId)
            await selected.lane.process(claimed)
            if (this._processorEpoch === runEpoch) {
              await this._reloadCachedStateQuiet()
            }
          } finally {
            if (this._processorEpoch === runEpoch) {
              // Retract if a shared gate landed mid-flight and the claim is still open.
              await this._retractLaneJobIfGated(selected.lane)
              selected.lane.setActiveId(null)
            }
          }
        }
      } while (this._dispatcherWakeRequested && this._processorEpoch === runEpoch)
    } finally {
      if (this._processorEpoch === runEpoch) {
        this._processing = false
      }
      if (this._processorEpoch === runEpoch && !this._documentSuspended) {
        if (await this._tryRecoverStrandedDownloads()) {
          this._dispatcherWakeRequested = true
        }
        await this._pauseDownloadQueueIfIdle()
        await this._resetProgressCountersIfIdle()
        this._refreshDockProgress({refreshAllItems: false})
        if (this._dispatcherWakeRequested) {
          this._dispatcherWakeRequested = false
          void this._runInterleavedDispatcher()
        }
      }
    }
  }

  /**
   * Cooperative retract of an in-flight interleaved job when pause / any-HI gates the regime.
   * Clears active-id ownership so commit guards fail, then returns the row to `queued`.
   * @param {object} lane
   * @return {Promise<void>}
   * @private
   */
  async _retractLaneJobIfGated(lane)
  {
    let itemId = lane.getActiveId()
    if (!itemId) {
      return
    }
    let state = this._getStateSync() ?? await this._getState()
    let gated = this._anyHumanInteraction(state)
        || (lane.id === 'download' && !!state?.paused)
    if (!gated) {
      return
    }
    let repo = lane.id === 'resolution'
        ? this._repos().downloadResolutionQueue
        : this._repos().downloadQueue
    let expected = lane.id === 'resolution' ? 'resolving' : 'downloading'
    let row = await repo.get(itemId)
    if (!row || row.status !== expected) {
      return
    }
    // Drop in-tab ownership first so late commits cannot win.
    lane.setActiveId(null)
    row.status = 'queued'
    row.error = null
    await repo.put(row)
  }

  /**
   * Flip a resolution row from `queued` → `resolving`. Returns null if another worker already claimed it.
   * @param {string} itemId
   * @return {Promise<object|null>}
   * @private
   */
  async _claimResolutionQueueItem(itemId)
  {
    let row = await this._repos().downloadResolutionQueue.get(itemId)
    if (!row || row.status !== 'queued') {
      return null
    }
    row.status = 'resolving'
    row.error = null
    await this._repos().downloadResolutionQueue.put(row)
    return row
  }

  /**
   * Flip a download row from `queued` → `downloading`. Returns null if another worker already claimed it.
   * @param {string} itemId
   * @return {Promise<object|null>}
   * @private
   */
  async _claimDownloadQueueItem(itemId)
  {
    let row = await this._repos().downloadQueue.get(itemId)
    if (!row || row.status !== 'queued') {
      return null
    }
    row.status = 'downloading'
    row.error = null
    await this._repos().downloadQueue.put(row)
    return row
  }

  /**
   * True while this tab still owns the in-flight resolution claim for `itemId`.
   * @param {string} itemId
   * @return {Promise<boolean>}
   * @private
   */
  async _isActiveResolutionClaim(itemId)
  {
    if (String(this._activeResolutionItemId) !== String(itemId)) {
      return false
    }
    let row = await this._repos().downloadResolutionQueue.get(itemId)
    return !!(row && row.status === 'resolving')
  }

  /**
   * True while this tab still owns the in-flight download claim for `itemId`.
   * @param {string} itemId
   * @return {Promise<boolean>}
   * @private
   */
  async _isActiveDownloadClaim(itemId)
  {
    if (String(this._activeDownloadItemId) !== String(itemId)) {
      return false
    }
    let row = await this._repos().downloadQueue.get(itemId)
    return !!(row && row.status === 'downloading')
  }

  /**
   * Commit a resolution row only if this tab still owns the `resolving` claim (or is
   * intentionally writing `queued` / `tagReview` / terminal from that claim).
   * @param {object} item
   * @return {Promise<boolean>}
   * @private
   */
  async _commitResolutionRow(item)
  {
    if (String(this._activeResolutionItemId) !== String(item.itemId)) {
      return false
    }
    let existing = await this._repos().downloadResolutionQueue.get(item.itemId)
    if (!existing) {
      return false
    }
    // Steal requeued to `queued` while we still thought we owned resolving.
    if (existing.status !== 'resolving') {
      return false
    }
    await this._repos().downloadResolutionQueue.put(item)
    return true
  }

  /**
   * Commit a download row only if this tab still owns the `downloading` claim.
   * @param {object} item
   * @return {Promise<boolean>}
   * @private
   */
  async _commitDownloadRow(item)
  {
    if (String(this._activeDownloadItemId) !== String(item.itemId)) {
      return false
    }
    let existing = await this._repos().downloadQueue.get(item.itemId)
    if (!existing) {
      // Cleared mid-flight — do not resurrect.
      return false
    }
    if (existing.status !== 'downloading') {
      return false
    }
    await this._repos().downloadQueue.put(item)
    return true
  }

  /**
   * Remove a resolution row after successful promote when this tab still owns the claim.
   * @param {string} itemId
   * @return {Promise<boolean>}
   * @private
   */
  async _removeResolutionRowIfActive(itemId)
  {
    if (String(this._activeResolutionItemId) !== String(itemId)) {
      return false
    }
    let existing = await this._repos().downloadResolutionQueue.get(itemId)
    if (!existing) {
      return false
    }
    if (existing.status !== 'resolving' && existing.status !== 'tagReview') {
      return false
    }
    await this._repos().downloadResolutionQueue.remove(itemId)
    return true
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _bumpProcessingHeartbeat()
  {
    if (this._documentSuspended) {
      return
    }
    await this._maybeExpireHumanInteractionBlocks()
    await this._withState((state) => {
      if (this._documentSuspended || state.processingTabId !== this._tabId) {
        return
      }
      state.processingHeartbeatAt = Date.now()
      this._writeProcessingLockMirror()
    })
  }

  /**
   * Own-lane resolution gate (IndependentPolicy / lane.ownBlocked).
   * Linked coupling (any-HI / discovery opportunism) lives in InterleavedPolicy.
   * @param {object|null|undefined} state
   * @return {boolean}
   * @private
   */
  _isResolutionPipelineBlocked(state)
  {
    if (!state) {
      return true
    }
    if (this._hiLane(state, 'resolution')) {
      return true
    }
    return !!state.resolutionBlocked
  }

  /**
   * Own-lane download gate (IndependentPolicy / lane.ownBlocked).
   * @param {object|null|undefined} state
   * @return {boolean}
   * @private
   */
  _isDownloadPipelineBlocked(state)
  {
    if (!state) {
      return true
    }
    if (state.paused) {
      return true
    }
    if (this._downloadInterruptionPending) {
      return true
    }
    return !!this._hiLane(state, 'download')
  }

  /**
   * Whether Start/Pause should surface human-interaction resume instead of toggle.
   * Independent: download-lane HI. Interleaved: any lane HI.
   * @param {object|null|undefined} state
   * @return {boolean}
   * @private
   */
  _isStartPauseShowingHumanInteraction(state)
  {
    if (this._linkQueues) {
      return this._anyHumanInteraction(state)
    }
    return !!this._hiLane(state, 'download')
  }

  /**
   * Whether this tab may attempt to own / run processors (leadership + enabled).
   * Per-lane / policy gates are checked inside the dispatcher.
   * @return {boolean}
   * @private
   */
  _shouldRunProcessors()
  {
    if (this._documentSuspended || !this._cm.canPersist()) {
      return false
    }
    let state = this._getStateSync()
    if (!state) {
      return false
    }
    if (this._isProcessingOwnerAlive(state)) {
      return false
    }
    if (!this.isDownloadManagerEnabled()) {
      return false
    }
    return true
  }

  /**
   * @param {number} lastAt
   * @param {number} gapMs
   * @return {Promise<number>}
   * @private
   */
  async _awaitInitiationGap(lastAt, gapMs)
  {
    let remaining = gapMs - (Date.now() - (lastAt ?? 0))
    if (remaining > 0) {
      await Utilities.sleep(remaining)
    }
    return Date.now()
  }

  /**
   * Seed in-tab initiation clocks from shared state (leadership claim / renew).
   * @param {object|null|undefined} state
   * @private
   */
  _hydrateInitiationClocksFromState(state)
  {
    this._lastDownloadInitiationAt = Math.max(
        this._lastDownloadInitiationAt ?? 0,
        state?.lastDownloadInitiationAt ?? 0,
    )
    this._lastResolutionInitiationAt = Math.max(
        this._lastResolutionInitiationAt ?? 0,
        state?.lastResolutionInitiationAt ?? 0,
    )
  }

  /**
   * Serialize IDB state mutations that re-read before write (avoids lost updates).
   * @param {function(object): (void|Promise<void>)} mutator
   * @return {Promise<object>}
   * @private
   */
  _withState(mutator)
  {
    if (this._documentSuspended || !this._cm.canPersist()) {
      return Promise.resolve(this._cachedState)
    }
    let run = this._stateWriteTail.then(async () => {
      if (this._documentSuspended) {
        return this._cachedState
      }
      let state = await this._repos().downloadManagerState.get()
      await mutator(state)
      this._sanitizeStateForPersist(state)
      state.id = 'state'
      await this._repos().downloadManagerState.put(state)
      this._cachedState = state
      return state
    })
    this._stateWriteTail = run.then(() => undefined, () => undefined)
    return run
  }

  /**
   * Normalize state before IDB put.
   * @param {object} state
   * @private
   */
  _sanitizeStateForPersist(state)
  {
    if (!state) {
      return
    }
  }

  /**
   * Pace the next GM_download / resolution fetch initiation.
   * Uses an in-tab clock so concurrent heartbeat/progress puts cannot wipe lastAt in IDB.
   * @param {'download'|'resolution'} kind
   * @return {Promise<void>}
   * @private
   */
  async _paceInitiation(kind)
  {
    let gapMs = kind === 'resolution' ? this._resolutionGapMs : this._downloadGapMs
    let localKey = kind === 'resolution' ? '_lastResolutionInitiationAt' : '_lastDownloadInitiationAt'
    let stateKey = kind === 'resolution' ? 'lastResolutionInitiationAt' : 'lastDownloadInitiationAt'
    let lastAt = Math.max(this[localKey] ?? 0, this._cachedState?.[stateKey] ?? 0)
    // Mirror before/after the gap so a throttled sleep cannot exceed the stale
    // window without updating sync liveness (resolution hits this every item).
    this._touchProcessingLockMirrorIfOwned()
    let nextAt = await this._awaitInitiationGap(lastAt, gapMs)
    this._touchProcessingLockMirrorIfOwned()
    this[localKey] = nextAt
    // Keep the sync cache in lockstep with the in-tab clock so a later
    // `_reloadCachedStateQuiet` of a briefly stale IDB row cannot undercut pacing.
    if (this._cachedState) {
      this._cachedState[stateKey] = Math.max(this._cachedState[stateKey] ?? 0, nextAt)
    }
    if (!this._cm.canPersist()) {
      return
    }
    await this._withState((state) => {
      state[stateKey] = Math.max(state[stateKey] ?? 0, nextAt)
    })
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _paceDownloadInitiation()
  {
    // Serialize download initiations so concurrent unlinked work still honors the gap.
    let run = this._downloadInitiateTail.then(() => this._paceInitiation('download'))
    this._downloadInitiateTail = run.then(() => undefined, () => undefined)
    await run
  }

  /**
   * Pace the next resolution / same-origin HTML fetch.
   * Serialized so concurrent callers (e.g. tag-type lookups during resolve) cannot stampede.
   * @return {Promise<void>}
   */
  async paceResolutionInitiation()
  {
    return this._paceResolutionInitiation()
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _paceResolutionInitiation()
  {
    // Serialize resolve-page + site tag-list fetches so parallel workers honor the gap.
    let run = this._resolutionInitiateTail.then(() => this._paceInitiation('resolution'))
    this._resolutionInitiateTail = run.then(() => undefined, () => undefined)
    await run
  }

  /**
   * When pending downloads exist but none are `queued` (orphan `downloading` rows),
   * requeue via the steal/bfcache helper and allow one wake. Latch prevents tight-loop.
   * @return {Promise<boolean>} true when a wake should re-enter the download processor
   * @private
   */
  async _tryRecoverStrandedDownloads()
  {
    let pending = await this._getPendingDownloadCount()
    if (pending <= 0) {
      this._orphanDownloadRequeueAttempted = false
      return false
    }
    if (await this._repos().downloadQueue.peekNextQueued()) {
      this._orphanDownloadRequeueAttempted = false
      return false
    }
    // Interruption panel owns these orphans — do not requeue under it.
    if (this._downloadInterruptionPending) {
      return false
    }
    if (this._orphanDownloadRequeueAttempted) {
      return false
    }
    this._orphanDownloadRequeueAttempted = true
    let defer = document.visibilityState === 'visible' && !this._documentSuspended
    await this._requeueInterruptedPipelineWork({deferInProgress: defer})
    if (this._downloadInterruptionPending) {
      this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
      return false
    }
    return !!(await this._repos().downloadQueue.peekNextQueued())
  }

  /**
   * Download batch is idle only when both queues are empty (and resolution is not mid-tick).
   * Once started, the download side stays in-batch until resolution empties too.
   * @param {number|null} [downloadPending] Omit to use cached sync counts.
   * @param {number|null} [resolutionPending]
   * @return {boolean}
   * @private
   */
  _isDownloadBatchIdle(downloadPending = null, resolutionPending = null)
  {
    if (this._resolutionProcessing || this._resolutionWakeRequested ||
        this._processing || this._dispatcherWakeRequested) {
      return false
    }
    let download = downloadPending == null ? (this._cachedDownloadCount ?? 0) : downloadPending
    let resolution = resolutionPending == null ? (this._cachedResolutionCount ?? 0) : resolutionPending
    return download <= 0 && resolution <= 0
  }

  /**
   * Zero each dock progress counter when that pipeline's batch is fully idle.
   * Download completed count clears only when both queues are empty.
   * @return {Promise<void>}
   * @private
   */
  async _resetProgressCountersIfIdle()
  {
    let downloadPending = await this._getPendingDownloadCount()
    let resolutionPending = await this._getPendingResolutionCount()
    let cleared = false
    await this._withState((state) => {
      if (resolutionPending === 0 && (Number(state.completedResolutionCount) || 0) !== 0) {
        state.completedResolutionCount = 0
        cleared = true
      }
      if (this._isDownloadBatchIdle(downloadPending, resolutionPending) &&
          (Number(state.completedDownloadCount) || 0) !== 0) {
        state.completedDownloadCount = 0
        cleared = true
      }
    })
    if (cleared) {
      this._signalProgressUiWake()
    }
  }

  /**
   * Return to idle (Start required) only when both download and resolution queues are empty
   * so a later batch does not auto-run. Does not clear a user Pause.
   * @return {Promise<void>}
   * @private
   */
  async _pauseDownloadQueueIfIdle()
  {
    let downloadPending = await this._getPendingDownloadCount()
    let resolutionPending = await this._getPendingResolutionCount()
    if (!this._isDownloadBatchIdle(downloadPending, resolutionPending)) {
      return
    }
    await this._withState((state) => {
      if (state.paused) {
        return
      }
      state.paused = true
    })
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _incrementResolutionProgress()
  {
    // Serialize with heartbeats — a raw put of a stale snapshot can wipe the count.
    await this._withState((state) => {
      state.completedResolutionCount = (Number(state.completedResolutionCount) || 0) + 1
    })
    this._signalProgressUiWake()
    this._refreshDockProgress({refreshAllItems: false})
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _incrementDownloadProgress()
  {
    await this._withState((state) => {
      state.completedDownloadCount = (Number(state.completedDownloadCount) || 0) + 1
    })
    await this._pauseDownloadQueueIfIdle()
    this._signalProgressUiWake()
    this._refreshDockProgress({refreshAllItems: false})
  }

  /**
   * Reload cached DM state without UI rebuilds (used between processor tasks).
   * @return {Promise<void>}
   * @private
   */
  async _reloadCachedStateQuiet()
  {
    if (!this._cm.canPersist()) {
      return
    }
    this._cachedState = await this._repos().downloadManagerState.get()
  }

  /**
   * One UI reset after a resolution task finishes (success, fail, block, or requeue).
   * Prunes terminal rows so overnight `listAll` stays O(pending), not O(completed).
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _onResolutionTaskComplete(itemId)
  {
    await this._reloadCachedStateQuiet()
    await this._renderItemProgress(itemId)
    await this._clearTerminalQueueRows(itemId)
    if (!(await this.isQueued(itemId))) {
      this._removeSelectionMarkMirrorId(itemId)
      this._forgetTrackedItem(itemId)
    }
    this._refreshDockProgress({refreshAllItems: false})
    this._refreshSelectionMarks()
  }

  /**
   * One UI reset after a download task finishes (success, fail, duplicate, or requeue).
   * Prunes terminal rows (including fat `resolvedPayload`) after a final progress paint.
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _onDownloadTaskComplete(itemId)
  {
    await this._reloadCachedStateQuiet()
    await this._renderItemProgress(itemId)
    await this._clearTerminalQueueRows(itemId)
    if (!(await this.isQueued(itemId))) {
      this._removeSelectionMarkMirrorId(itemId)
      this._forgetTrackedItem(itemId)
    }
    this._refreshDockProgress({refreshAllItems: false})
    this._refreshSelectionMarks()
    // Hide Downloaded: re-run compliance even if ledger config events coalesced with queue puts.
    this._framework._scheduleLedgerComplianceRefresh?.()
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _ensureState()
  {
    if (!this._cm.canPersist()) {
      return
    }
    let state = await this._repos().downloadManagerState.get()
    if (!state?.id) {
      await this._repos().downloadManagerState.reset()
      return
    }
    let hiChanged = false
    let immediateCleared = false
    if (BrazenDownloadManager._stateNeedsLegacyMigration(state)) {
      hiChanged = this._migrateHumanInteractionState(state)
      immediateCleared = this._clearLegacyImmediateDownloadState(state)
    }
    let phaseDefaulted = false
    if (!('discoveryLanePhaseActive' in state)) {
      state.discoveryLanePhaseActive = false
      phaseDefaulted = true
    }
    if (hiChanged || immediateCleared || phaseDefaulted) {
      state.id = 'state'
      await this._repos().downloadManagerState.put(state)
      this._cachedState = state
      this._writeHumanInteractionBlockMirror(this._anyHumanInteraction(state))
    }
  }

  /**
   * Drop removed `pendingImmediateDownload` and clear an orphan discovery gate that had
   * no queue subject (legacy mid-review immediate downloads after upgrade).
   * @param {object} state
   * @return {boolean} true when the row was changed
   * @private
   */
  _clearLegacyImmediateDownloadState(state)
  {
    if (!state || !('pendingImmediateDownload' in state)) {
      return false
    }
    let hadPending = !!state.pendingImmediateDownload
    delete state.pendingImmediateDownload
    if (hadPending && state.resolutionBlocked && !state.resolutionBlockedItemId) {
      this._clearDiscoveryReviewState(state)
    }
    return true
  }

  /**
   * Fold legacy single-slot HI fields into `humanInteraction` map. Mutates `state`.
   * @param {object} state
   * @return {boolean} true when the row was changed
   * @private
   */
  _migrateHumanInteractionState(state)
  {
    let changed = false
    let priorMap = state.humanInteraction
    this._ensureHumanInteractionMap(state)
    if (priorMap !== state.humanInteraction || !priorMap ||
        !('resolution' in priorMap) || !('download' in priorMap)) {
      changed = true
    }
    if (state.humanInteractionBlocked) {
      let ctx = state.humanInteractionContext === 'download' ? 'download' : 'resolution'
      if (!state.humanInteraction[ctx]) {
        state.humanInteraction[ctx] = {
          itemId: state.humanInteractionItemId ?? null,
          promptTabId: state.humanInteractionPromptTabId ?? null,
          openUrl: state.humanInteractionOpenUrl ?? null,
          at: Date.now(),
        }
        changed = true
      }
    }
    for (let key of [
      'humanInteractionBlocked',
      'humanInteractionContext',
      'humanInteractionItemId',
      'humanInteractionPromptTabId',
      'humanInteractionOpenUrl',
    ]) {
      if (key in state) {
        delete state[key]
        changed = true
      }
    }
    return changed
  }

  /**
   * @return {object|null}
   * @private
   */
  _getStateSync()
  {
    return this._cachedState ?? null
  }

  /**
   * @return {Promise<object>}
   * @private
   */
  async _getState()
  {
    let state = await this._repos().downloadManagerState.get()
    this._cachedState = state
    return state
  }

  /**
   * @param {object} state
   * @return {Promise<object>}
   * @private
   */
  async _putState(state)
  {
    this._sanitizeStateForPersist(state)
    let row = await this._repos().downloadManagerState.put(state)
    this._cachedState = row
    return row
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncFromStorage()
  {
    if (!this._cm.canPersist()) {
      return
    }
    this._cachedState = await this._repos().downloadManagerState.get()
    await this._resetProgressCountersIfIdle()
    await this._refreshCurrentMediaQueueState({refreshDock: false})
    this._framework.refreshDockInterface(undefined, {layout: false})
    // Selection marks paint queued tiles; skip `_refreshAllItemProgress` on sync.
    this._refreshDockProgress({refreshAllItems: false})
    this._refreshSelectionMarks()
  }

  /**
   * @private
   */
  _setupCrossTabSync()
  {
    if (this._crossTabVisibilityHandler) {
      document.removeEventListener('visibilitychange', this._crossTabVisibilityHandler)
    }
    this._crossTabVisibilityHandler = () => {
      if (document.visibilityState === 'visible') {
        this._scheduleResumeFromExternalWake({fullUi: true})
        return
      }
      // Drop panel ownership while hidden so the focused search tab can claim it.
      // Keep local DOM visible on minimize — only unload paths hide panels.
      void this._releaseTagDiscoveryPanelOwnership({hideLocal: false})
      void this._releaseHumanInteractionPanelOwnership({hideLocal: false})
    }
    document.addEventListener('visibilitychange', this._crossTabVisibilityHandler)
    // bfcache back/forward restores the page without a reliable visibility flip — reclaim here.
    if (this._crossTabPageshowHandler) {
      window.removeEventListener('pageshow', this._crossTabPageshowHandler)
    }
    this._crossTabPageshowHandler = (event) => {
      let persisted = !!event.persisted
      if (persisted) {
        // Hard interrupt + reopen IDB + requeue orphans (soft resume is not enough).
        this._resumeDocumentAfterBfcache()
        return
      }
      if (document.visibilityState === 'visible') {
        this._scheduleResumeFromExternalWake({fullUi: true})
      }
    }
    window.addEventListener('pageshow', this._crossTabPageshowHandler)
    if (this._crossTabPagehideHandler) {
      window.removeEventListener('pagehide', this._crossTabPagehideHandler)
      window.removeEventListener('beforeunload', this._crossTabPagehideHandler)
    }
    this._crossTabPagehideHandler = (event) => {
      // Sync interrupt: stop mirror rewrites, clear active ids, close IDB for bfcache.
      this._suspendDocumentForUnload()
      // Keep overlays on bfcache restore so marks do not blank-flash on back/forward.
      if (!event?.persisted) {
        this._clearAllItemProgress()
      }
    }
    window.addEventListener('pagehide', this._crossTabPagehideHandler)
    window.addEventListener('beforeunload', this._crossTabPagehideHandler)
    // Other tabs' enqueue / progress writes wake keys; only other documents receive these.
    // Hidden tabs get a cheap leadership resume; visible tabs hydrate UI after debounce.
    if (this._crossTabStorageHandler) {
      window.removeEventListener('storage', this._crossTabStorageHandler)
    }
    this._crossTabStorageHandler = (event) => {
      let key = event.key
      let newValue = event.newValue
      if (!newValue) {
        return
      }
      if (key === this._progressUiWakeStorageKey()) {
        this._scheduleProgressUiWakeReceive()
        return
      }
      if (key !== this._processorsWakeStorageKey()) {
        return
      }
      this._scheduleResumeFromExternalWake({
        fullUi: document.visibilityState === 'visible',
      })
    }
    window.addEventListener('storage', this._crossTabStorageHandler)
  }

  // -------------------------------------------------------------------------
  // Search item progress
  // -------------------------------------------------------------------------

  /**
   * @return {{stepCount: number, indices: {queued: number, resolving: number, tagReview: number, downloadQueued: number, downloading: number}}}
   * @private
   */
  _getPipelineStepLayout()
  {
    if (this._isTagDiscoveryActive()) {
      return {
        stepCount: 5,
        indices: {queued: 0, resolving: 1, tagReview: 2, downloadQueued: 3, downloading: 4},
      }
    }
    return {
      stepCount: 4,
      indices: {queued: 0, resolving: 1, tagReview: -1, downloadQueued: 2, downloading: 3},
    }
  }

  /**
   * @param {string|number} itemId
   * @return {HTMLElement|null}
   * @private
   */
  _findSelectionItemElement(itemId)
  {
    let pageConfig = this._getActivePageConfig()
    if (!pageConfig?.itemSelector || !pageConfig.resolveItem) {
      return null
    }
    let itemKey = String(itemId)
    for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
      let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
      if (resolved?.itemId != null && String(resolved.itemId) === itemKey) {
        return element
      }
    }
    return null
  }

  /**
   * @param {string|number} itemId
   * @param {HTMLElement|null|undefined} element
   * @private
   */
  _trackItemElement(itemId, element)
  {
    if (itemId == null || !element) {
      return
    }
    this._trackedItemElements.set(String(itemId), element)
  }

  /**
   * @param {string|number} itemId
   * @private
   */
  _untrackItemElement(itemId)
  {
    if (itemId == null) {
      return
    }
    this._trackedItemElements.delete(String(itemId))
  }

  /**
   * Drops DOM + epoch retention for a tile that is no longer in the active pipeline.
   * @param {string|number} itemId
   * @private
   */
  _forgetTrackedItem(itemId)
  {
    if (itemId == null) {
      return
    }
    let itemKey = String(itemId)
    let element = this._trackedItemElements.get(itemKey)
    this._untrackItemElement(itemId)
    this._selectionOpEpoch.delete(itemKey)
    if (element) {
      this._setItemProgress(element, null)
    }
  }

  /**
   * @param {string|number} itemId
   * @private
   */
  _refreshItemProgress(itemId)
  {
    void this._renderItemProgress(itemId)
  }

  /**
   * @private
   */
  _refreshAllItemProgress()
  {
    for (let itemId of this._trackedItemElements.keys()) {
      void this._renderItemProgress(itemId)
    }
  }

  /**
   * @private
   */
  _clearAllItemProgress()
  {
    for (let element of this._trackedItemElements.values()) {
      this._setItemProgress(element, null)
    }
    this._trackedItemElements.clear()
    this._selectionUiHidden.clear()
    this._selectionUiPending.clear()
    this._selectionOpEpoch.clear()
  }

  /**
   * @param {string|number} itemId
   * @return {Promise<void>}
   * @private
   */
  async _renderItemProgress(itemId)
  {
    let itemKey = String(itemId)
    if (this._selectionUiHidden.has(itemKey)) {
      // Local cancel won the UI; ignore late reconcile/processor paints until dequeue lands.
      if (!(await this.isQueued(itemId))) {
        this._selectionUiHidden.delete(itemKey)
      }
      return
    }
    let element = this._trackedItemElements.get(itemKey)
    if (!element) {
      element = this._findSelectionItemElement(itemId)
      if (element) {
        this._trackItemElement(itemId, element)
      }
    }
    if (!element) {
      return
    }
    let progress = await this._resolveItemPipelineView(itemId)
    if (this._selectionUiHidden.has(itemKey)) {
      return
    }
    // Optimistic select painted before IDB put — don't wipe it with a null reconcile.
    if (!progress && this._selectionUiPending.has(itemKey)) {
      return
    }
    this._setItemProgress(element, progress)
  }

  /**
   * @param {string|number} itemId
   * @return {Promise<{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null>}
   * @private
   */
  async _resolveItemPipelineView(itemId)
  {
    let layout = this._getPipelineStepLayout()
    let state = this._getStateSync() ?? await this._getState()
    let itemKey = String(itemId)
    let [resolution, download] = await Promise.all([
      this._repos().downloadResolutionQueue.get(itemKey),
      this._repos().downloadQueue.get(itemKey),
    ])
    let blocked = !!state?.resolutionBlocked
    let blockedItemId = state?.resolutionBlockedItemId != null ? String(state.resolutionBlockedItemId) : null
    let hiResolution = this._hiLane(state, 'resolution')
    let hiDownload = this._hiLane(state, 'download')

    for (let hiEntry of [
      {ctx: /** @type {BrazenDownloadManagerLaneId} */ ('download'), lane: hiDownload},
      {ctx: /** @type {BrazenDownloadManagerLaneId} */ ('resolution'), lane: hiResolution},
    ]) {
      if (!hiEntry.lane) {
        continue
      }
      let humanItemId = hiEntry.lane.itemId != null ? String(hiEntry.lane.itemId) : null
      if (humanItemId === itemKey) {
        return {
          label: 'Verify',
          stepIndex: hiEntry.ctx === 'download' ?
              layout.indices.downloading :
              layout.indices.resolving,
          stepCount: layout.stepCount,
        }
      }
      if (humanItemId) {
        let showWaiting = this._linkQueues
        if (!showWaiting && hiEntry.ctx === 'download') {
          showWaiting = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
        }
        if (!showWaiting && hiEntry.ctx === 'resolution') {
          showWaiting = !!(resolution && !RESOLUTION_TERMINAL.has(resolution.status) && !download)
        }
        if (showWaiting) {
          return {
            label: 'Waiting',
            stepIndex: layout.indices.queued,
            stepCount: layout.stepCount,
          }
        }
      }
    }

    if (download) {
      if (download.status === 'downloading') {
        return {
          label: 'Downloading',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
        }
      }
      if (download.status === 'queued') {
        return {
          // paused gates starting downloads; queued items stay Ready until Start (not "Paused")
          label: 'Ready',
          stepIndex: layout.indices.downloadQueued,
          stepCount: layout.stepCount,
        }
      }
      if (download.status === 'done') {
        return {
          label: 'Done',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          complete: true,
        }
      }
      if (download.status === 'duplicate') {
        return {
          label: 'Duplicate',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          complete: true,
        }
      }
      if (download.status === 'skipped') {
        return {
          label: 'Skipped',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          complete: true,
        }
      }
      if (download.status === 'failed') {
        return {
          label: 'Failed',
          stepIndex: layout.indices.downloading,
          stepCount: layout.stepCount,
          failed: true,
        }
      }
    }

    if (resolution) {
      if (resolution.status === 'failed') {
        return {
          label: 'Failed',
          stepIndex: layout.indices.resolving,
          stepCount: layout.stepCount,
          failed: true,
        }
      }
      if (resolution.status === 'discoveryQueued') {
        return {
          label: 'Discovery',
          stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.resolving,
          stepCount: layout.stepCount,
        }
      }
      if (resolution.status === 'tagReview') {
        return {
          label: blocked && blockedItemId === itemKey ? 'Tags' : 'Waiting',
          stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.downloadQueued,
          stepCount: layout.stepCount,
        }
      }
      if (resolution.status === 'resolving') {
        return {
          label: 'Resolving',
          stepIndex: layout.indices.resolving,
          stepCount: layout.stepCount,
        }
      }
      if (resolution.status === 'queued') {
        if (state?.discoveryLanePhaseActive && (!blocked || (blockedItemId && blockedItemId !== itemKey))) {
          return {
            label: 'Waiting',
            stepIndex: layout.indices.queued,
            stepCount: layout.stepCount,
          }
        }
        if (blocked && blockedItemId && blockedItemId !== itemKey) {
          return {
            label: 'Waiting',
            stepIndex: layout.indices.queued,
            stepCount: layout.stepCount,
          }
        }
        return {
          label: 'Queued',
          stepIndex: layout.indices.queued,
          stepCount: layout.stepCount,
        }
      }
    }

    return null
  }

  /**
   * @param {HTMLElement|null|undefined} element
   * @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
   * @private
   */
  _setItemProgress(element, progress)
  {
    let pageConfig = this._getActivePageConfig()
    if (pageConfig?.setItemProgress) {
      Utilities.callEventHandler(pageConfig.setItemProgress, [element, progress], null)
      return
    }
    if (progress) {
      BrazenViewLayer.updateDownloadManagerItemProgress(element, progress)
      return
    }
    BrazenViewLayer.clearDownloadManagerItemProgress(element)
  }

  // -------------------------------------------------------------------------
  // Selection mode
  // -------------------------------------------------------------------------

  /**
   * Push filename/subfolder pattern pins into live `tagDiscovery.tagTypes` so discovery
   * scan scope tracks the user's active download-path patterns without app handlers.
   * @private
   */
  _syncDiscoveryTagTypesFromPatterns()
  {
    if (!this._config.tagDiscovery) {
      return
    }
    this._config.tagDiscovery.tagTypes = this._getPinnedFilenameTagTypes()
  }

  /**
   * While download selection mode is on, block native drag on search thumbs
   * so click-to-queue is not stolen by image/link dragging. Applies body class
   * `bv-dm-selection` for consumer CSS (pointer-events on thumb tiles).
   * @return {void}
   */
  registerSelectionDragGuard()
  {
    let pageConfig = this._getActivePageConfig()
    let active = !!(this.isDownloadManagerEnabled() && this._isSelectionModeActive() &&
        this.isDownloadPageRole('selection'))
    document.body.classList.toggle('bv-dm-selection', active)
    if (this._selectionDragStartHandler) {
      document.removeEventListener('dragstart', this._selectionDragStartHandler, true)
      this._selectionDragStartHandler = null
    }
    if (!active || !pageConfig?.itemSelector) {
      return
    }
    let itemSelector = pageConfig.itemSelector
    this._selectionDragStartHandler = (event) => {
      if (event.target instanceof Element && event.target.closest(itemSelector)) {
        event.preventDefault()
      }
    }
    document.addEventListener('dragstart', this._selectionDragStartHandler, true)
  }

  /**
   * @return {boolean}
   * @private
   */
  _isSelectionModeActive()
  {
    return !!this._selectionModeActive
  }

  /**
   * @private
   */
  _bindSelectionHandlers()
  {
    if (this._selectionClickHandler) {
      document.removeEventListener('click', this._selectionClickHandler)
      this._selectionClickHandler = null
    }

    let pageConfig = this._getActivePageConfig()
    if (!pageConfig?.resolveItem || !pageConfig.itemSelector) {
      return
    }

    if (!this._isSelectionModeActive() || !this.isDownloadManagerEnabled()) {
      return
    }

    let itemSelector = pageConfig.itemSelector
    this._selectionClickHandler = (event) => {
      let target = event.target.closest(itemSelector)
      if (!target) {
        return
      }
      event.preventDefault()
      event.stopPropagation()
      let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [target], null)
      if (!resolved?.itemId) {
        return
      }
      void this._toggleSelectionItem(resolved, target)
    }
    document.addEventListener('click', this._selectionClickHandler)
  }

  /**
   * @param {object} resolved
   * @param {HTMLElement} element
   * @return {Promise<void>}
   * @private
   */
  async _toggleSelectionItem(resolved, element)
  {
    let itemKey = String(resolved.itemId)
    let epoch = (this._selectionOpEpoch.get(itemKey) || 0) + 1
    this._selectionOpEpoch.set(itemKey, epoch)

    // Sync local heuristic only — never await IDB before painting click feedback.
    let locallySelected = this._trackedItemElements.has(itemKey) || this._selectionUiPending.has(itemKey)

    if (locallySelected) {
      // Clear overlay immediately; hide until IDB dequeue finishes so stale sync cannot re-paint.
      this._selectionUiPending.delete(itemKey)
      this._selectionUiHidden.add(itemKey)
      this._untrackItemElement(resolved.itemId)
      this._setItemProgress(element, null)
      await this.dequeueDownload(resolved.itemId)
      if (!(await this.isQueued(resolved.itemId))) {
        this._selectionUiHidden.delete(itemKey)
      }
      return
    }

    // Optimistic paint before any IDB (isQueued / enqueue) so click→blur is not gated on storage.
    this._selectionUiHidden.delete(itemKey)
    this._selectionUiPending.add(itemKey)
    let layout = this._getPipelineStepLayout()
    this._trackItemElement(resolved.itemId, element)
    this._setItemProgress(element, {
      label: 'Queued',
      stepIndex: layout.indices.queued,
      stepCount: layout.stepCount,
    })

    try {
      // Rare: queued in IDB but not tracked yet (other tab / refresh race) — treat as deselect.
      if (await this.isQueued(resolved.itemId)) {
        if (this._selectionOpEpoch.get(itemKey) !== epoch) {
          return
        }
        this._selectionUiPending.delete(itemKey)
        this._selectionUiHidden.add(itemKey)
        this._untrackItemElement(resolved.itemId)
        this._setItemProgress(element, null)
        await this.dequeueDownload(resolved.itemId)
        if (!(await this.isQueued(resolved.itemId))) {
          this._selectionUiHidden.delete(itemKey)
        }
        return
      }
      if (this._selectionOpEpoch.get(itemKey) !== epoch) {
        return
      }

      let added = await this.enqueueDownload(resolved)
      if (this._selectionOpEpoch.get(itemKey) !== epoch) {
        // A later click cancelled this select — keep UI hidden and drop the row if written.
        this._selectionUiPending.delete(itemKey)
        this._selectionUiHidden.add(itemKey)
        this._untrackItemElement(resolved.itemId)
        this._setItemProgress(element, null)
        if (added) {
          await this.dequeueDownload(resolved.itemId)
        }
        if (!(await this.isQueued(resolved.itemId))) {
          this._selectionUiHidden.delete(itemKey)
        }
        return
      }
      this._selectionUiPending.delete(itemKey)
      if (!added) {
        // Another tab may have enqueued first — keep overlay if still in the pipeline.
        if (await this.isQueued(resolved.itemId)) {
          await this._renderItemProgress(resolved.itemId)
          return
        }
        this._untrackItemElement(resolved.itemId)
        this._setItemProgress(element, null)
        return
      }
      await this._renderItemProgress(resolved.itemId)
    } catch (e) {
      // IDB wedged / closed mid-click — drop optimistic paint so UI matches persistence.
      if (this._selectionOpEpoch.get(itemKey) !== epoch) {
        return
      }
      this._selectionUiPending.delete(itemKey)
      this._untrackItemElement(resolved.itemId)
      this._setItemProgress(element, null)
      console.warn('[BrazenDM] selection enqueue failed:', e)
    }
  }

  /**
   * @private
   */
  _refreshSelectionMarks()
  {
    let pageConfig = this._getActivePageConfig()
    if (!pageConfig?.itemSelector || !this.isDownloadPageRole('selection')) {
      return
    }
    // Synchronous paint from the sessionStorage mirror before async IDB reconcile.
    this._paintSelectionMarksFromMirror()
    void (async () => {
      let selectionActive = this._isSelectionModeActive()
      let mirrorIds = this._readSelectionMarkMirror()
      let [resolutionIds, downloadIds] = await Promise.all([
        this._repos().downloadResolutionQueue.listActiveItemIds(),
        this._repos().downloadQueue.listActiveItemIds(),
      ])
      let activeIds = new Set([...resolutionIds, ...downloadIds].map(String))
      this._scheduleSelectionMarkMirrorWrite(activeIds)
      /** @type {Promise<void>[]} */
      let progressPaints = []
      for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
        let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
        if (!resolved?.itemId) {
          continue
        }
        let itemKey = String(resolved.itemId)
        let queued = activeIds.has(itemKey)
        if (this._selectionUiHidden.has(itemKey)) {
          if (!queued) {
            this._selectionUiHidden.delete(itemKey)
          }
          continue
        }
        let selected = selectionActive && queued
        let inMirror = mirrorIds.has(itemKey)
        let hasOverlay = element.dataset.bvDmProgress != null && element.dataset.bvDmProgress !== ''
        // Keep DOM↔item mapping warm so processor claim/complete refreshes find the tile.
        if (selected) {
          this._trackItemElement(resolved.itemId, element)
          progressPaints.push(this._renderItemProgress(resolved.itemId))
          continue
        }
        if (this._selectionUiPending.has(itemKey)) {
          // Optimistic select — IDB row not visible yet; leave the painted overlay alone.
          continue
        }
        if (this._trackedItemElements.has(itemKey) || inMirror || hasOverlay) {
          progressPaints.push((async () => {
            await this._renderItemProgress(resolved.itemId)
            if (!queued) {
              let progress = await this._resolveItemPipelineView(resolved.itemId)
              if (!progress) {
                this._untrackItemElement(resolved.itemId)
                this._setItemProgress(element, null)
                if (inMirror) {
                  this._removeSelectionMarkMirrorId(resolved.itemId)
                }
              }
            }
          })())
        }
      }
      if (progressPaints.length) {
        await Promise.all(progressPaints)
      }
    })()
  }

  /**
   * sessionStorage key for the bounded active-id mirror (selection-mark paint).
   * @return {string}
   * @private
   */
  _selectionMarkMirrorKey()
  {
    return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-active-item-ids')
  }

  /**
   * @return {Set<string>}
   * @private
   */
  _readSelectionMarkMirror()
  {
    try {
      let raw = sessionStorage.getItem(this._selectionMarkMirrorKey())
      if (!raw) {
        return new Set()
      }
      let parsed = JSON.parse(raw)
      if (!Array.isArray(parsed)) {
        return new Set()
      }
      return new Set(parsed.map(String).slice(0, SELECTION_MARK_MIRROR_MAX))
    } catch (e) {
      return new Set()
    }
  }

  /**
   * @param {Iterable<string>} ids
   * @private
   */
  _writeSelectionMarkMirror(ids)
  {
    try {
      let list = [...ids].map(String)
      if (list.length > SELECTION_MARK_MIRROR_MAX) {
        list = list.slice(0, SELECTION_MARK_MIRROR_MAX)
      }
      sessionStorage.setItem(this._selectionMarkMirrorKey(), JSON.stringify(list))
    } catch (e) {
      // ignore unavailable sessionStorage
    }
  }

  /**
   * Debounced authoritative mirror write after async active-id reads.
   * @param {Set<string>} ids
   * @private
   */
  _scheduleSelectionMarkMirrorWrite(ids)
  {
    if (this._selectionMarkMirrorWriteTimer != null) {
      clearTimeout(this._selectionMarkMirrorWriteTimer)
    }
    this._selectionMarkMirrorWriteTimer = setTimeout(() => {
      this._selectionMarkMirrorWriteTimer = null
      this._writeSelectionMarkMirror(ids)
    }, 0)
  }

  /**
   * @param {string|number} itemId
   * @private
   */
  _addSelectionMarkMirrorId(itemId)
  {
    let ids = this._readSelectionMarkMirror()
    ids.add(String(itemId))
    this._writeSelectionMarkMirror(ids)
  }

  /**
   * @param {string|number} itemId
   * @private
   */
  _removeSelectionMarkMirrorId(itemId)
  {
    let ids = this._readSelectionMarkMirror()
    if (!ids.delete(String(itemId))) {
      return
    }
    this._writeSelectionMarkMirror(ids)
  }

  /**
   * Paint Queued overlays from the sync sessionStorage mirror (before IDB reconcile).
   * @private
   */
  _paintSelectionMarksFromMirror()
  {
    if (!this._isSelectionModeActive() || !this.isDownloadPageRole('selection')) {
      return
    }
    let pageConfig = this._getActivePageConfig()
    if (!pageConfig?.itemSelector || !pageConfig.resolveItem) {
      return
    }
    let ids = this._readSelectionMarkMirror()
    if (!ids.size) {
      return
    }
    let layout = this._getPipelineStepLayout()
    for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
      let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
      if (!resolved?.itemId) {
        continue
      }
      let itemKey = String(resolved.itemId)
      if (!ids.has(itemKey) || this._selectionUiHidden.has(itemKey)) {
        continue
      }
      // Do not clobber an accurate overlay (Downloading / Ready / Done) with mirror Queued.
      if (element.dataset.bvDmProgress) {
        this._trackItemElement(resolved.itemId, element)
        continue
      }
      this._trackItemElement(resolved.itemId, element)
      this._setItemProgress(element, {
        label: 'Queued',
        stepIndex: layout.indices.queued,
        stepCount: layout.stepCount,
      })
    }
  }

  // -------------------------------------------------------------------------
  // Dock UI
  // -------------------------------------------------------------------------

  /**
   * @private
   */
  _setupDock()
  {
    if (this._config.tagDiscovery) {
      this._cm.addActionField(DOCK_TAG_DISCOVERY_MODE).
          setTitle('Tag Discovery Mode').
          setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_TAG_DISCOVERY_MODE).
          setAction(() => { void this.toggleTagDiscoveryMode() }).
          setDockButton({
            icon: 'discovery',
            getState: () => this._getStateSync()?.tagDiscoveryEnabled ? 'bv-dock-btn-active' : '',
            tooltip: () => this._getStateSync()?.tagDiscoveryEnabled ?
                'Tag discovery: on — unknown tags pause downloads until you confirm them' :
                'Tag discovery: off — click to pause on unknown tags and review mappings',
            include: function() {
              return this.isDownloadManagerEnabled() && this.isDownloadPageRole('tagDiscoveryToggle')
            },
          })
    }

    this._cm.addActionField(DOCK_SELECTION_MODE).
        setTitle('Selection Mode').
        setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_SELECTION_MODE).
        setAction(() => this.toggleSelectionMode()).
        setDockButton({
          icon: 'select',
          getState: () => this._isSelectionModeActive() ? 'bv-dock-btn-active' : '',
          tooltip: () => this._isSelectionModeActive() ?
              'Selection mode: on — click items to queue' :
              'Selection mode: off — click to select items for download queue',
          include: function() {
            return this.isDownloadManagerEnabled() && this.isDownloadPageRole('selection')
          },
        })

    this._cm.addActionField(DOCK_ADD_TO_QUEUE).
        setTitle('Add to Download Queue').
        setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_ADD_TO_QUEUE).
        setAction(() => { void this.toggleCurrentMediaQueued() }).
        setDockButton({
          icon: () => this._currentMediaQueued ? 'queue-remove' : 'queue-add',
          getState: () => this._currentMediaQueued ? 'bv-dock-btn-active' : '',
          tooltip: () => this._currentMediaQueued
              ? 'In download queue — click to remove this post'
              : 'Add this post to the download queue',
          include: function() {
            return this.isDownloadManagerEnabled() && this.isDownloadPageRole('enqueueMedia')
          },
        })

    this._cm.addActionField(DOCK_DOWNLOAD_LEADER).
        setTitle('Download Manager Leader Tab').
        setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_DOWNLOAD_LEADER).
        setAction(() => { void this.requestDownloadManagerLeadership() }).
        setDockButton({
          icon: 'crown',
          getState: () => this.isDownloadManagerLeaderTab() ? 'bv-dock-btn-active' : '',
          tooltip: () => this.isDownloadManagerLeaderTab()
              ? 'This tab is the download manager leader'
              : 'Make this tab the download manager leader',
          include: () => this.isDownloadManagerEnabled(),
        })

    this._cm.addActionField(DOCK_CLEAR_DOWNLOAD_QUEUE).
        setTitle('Clear Download Queue').
        setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_CLEAR_DOWNLOAD_QUEUE).
        setAction(() => {
          if (confirm('Clear pending downloads? Resolution work and selection mode are not cleared.')) {
            void this.clearDownloadQueue()
          }
        }).
        setDockButton({
          icon: 'clear',
          tooltip: 'Clear download queue',
          include: () => this.isDownloadManagerEnabled() && this.getPendingDownloadCountSync() > 0,
        })

    this._cm.addActionField(DOCK_DOWNLOAD_START_PAUSE).
        setTitle('Download Queue Start/Pause').
        setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_DOWNLOAD_START_PAUSE).
        setAction(() => { void this.toggleDownloadManagerPaused() }).
        setDockButton({
          icon: () => {
            let state = this._getStateSync()
            if (this._isStartPauseShowingHumanInteraction(state)) {
              return 'play'
            }
            // Idle Play only when both queues empty — not when download drained mid-batch.
            if (this._isDownloadBatchIdle()) {
              return 'play'
            }
            return state?.paused ? 'play' : 'pause'
          },
          getState: () => {
            let state = this._getStateSync()
            if (this._isStartPauseShowingHumanInteraction(state)) {
              return ''
            }
            if (this._isDownloadBatchIdle()) {
              return ''
            }
            return state?.paused ? '' : 'bv-dock-btn-active'
          },
          isDisabled: () => {
            let state = this._getStateSync()
            if (this._isStartPauseShowingHumanInteraction(state)) {
              return false
            }
            return this._isDownloadBatchIdle()
          },
          tooltip: () => {
            let state = this._getStateSync()
            if (this._isStartPauseShowingHumanInteraction(state)) {
              return 'Verification required — click to confirm and resume'
            }
            if (this._isDownloadBatchIdle()) {
              return 'Queues idle — click Start when the next batch has Ready downloads'
            }
            if (this.getPendingDownloadCountSync() <= 0 && this.getPendingResolutionCountSync() > 0) {
              if (state?.paused) {
                return 'Batch still resolving — downloads paused; click to start when Ready'
              }
              return 'Batch still resolving — downloads run as items become Ready; click to pause'
            }
            if (state?.paused) {
              return 'Download queue paused — click to start downloads'
            }
            return 'Download queue running — click to pause downloads'
          },
          slideOutWhen: () => this._isQueueDockSlideOutOpen(),
          slideOutPinnedWhen: () => this._isQueueDockSlideOutPinned(),
          include: function() {
            return this.isDownloadManagerEnabled()
          },
        }).
        setDockSlideOut(this._getQueueDockSlideOutChildKeys()).
        setDockSlideOutNodes(() => this._getQueueDockSlideOutNodes())

    // Skip Duplicate ships its dock template from Framework; DM only attaches Hide as slide-out.
    let skipDuplicateKey = this._framework._downloadDuplicateLedgerConfig?.enableConfigKey ??
        (typeof OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER !== 'undefined' ? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER : null)
    let skipDuplicateField = skipDuplicateKey ? this._cm.getField(skipDuplicateKey) : null
    if (skipDuplicateField) {
      let hideDownloadedKey = typeof OPTION_HIDE_DOWNLOADED_MEDIA !== 'undefined' ?
          OPTION_HIDE_DOWNLOADED_MEDIA :
          'hide-downloaded-media'
      let hideField = this._cm.getField(hideDownloadedKey)
      if (hideField && !hideField.dock) {
        hideField.applyDockTemplate('hideDownloaded')
      }
      skipDuplicateField.setDockSlideOut([hideDownloadedKey])
    }
  }

  /**
   * @return {boolean}
   * @private
   */
  _isQueueDockSlideOutOpen()
  {
    let slideOut = this._config.queueDockSlideOut
    if (slideOut?.slideOutWhen) {
      return !!Utilities.callEventHandler(slideOut.slideOutWhen, [this], null)
    }
    return this.getPendingResolutionCountSync() > 0 || this.getPendingDownloadCountSync() > 0
  }

  /**
   * @return {boolean}
   * @private
   */
  _isQueueDockSlideOutPinned()
  {
    let slideOut = this._config.queueDockSlideOut
    if (slideOut?.slideOutPinnedWhen) {
      return !!Utilities.callEventHandler(slideOut.slideOutPinnedWhen, [this], null)
    }
    return this._isQueueDockSlideOutOpen()
  }

  /**
   * @return {HTMLElement[]}
   * @private
   */
  _getQueueDockSlideOutNodes()
  {
    let slideOut = this._config.queueDockSlideOut
    if (slideOut?.getSlideOutNodes) {
      let nodes = Utilities.callEventHandler(slideOut.getSlideOutNodes, [this, this._cm], null)
      return Array.isArray(nodes) ? nodes.filter((node) => node != null) : []
    }
    return [this.getOrCreateProgressSlot()]
  }

  /**
   * @return {string[]}
   * @private
   */
  _getQueueDockSlideOutChildKeys()
  {
    let slideOut = this._config.queueDockSlideOut
    if (Array.isArray(slideOut?.childFields)) {
      return slideOut.childFields
    }
    return [DOCK_DOWNLOAD_LEADER, DOCK_CLEAR_DOWNLOAD_QUEUE]
  }

  /**
   * Coalesced dock progress paint. Concurrent callers set a dirty flag; the runner
   * loops until quiet so a resolution refresh cannot cancel a download increment paint.
   * @param {{refreshAllItems?: boolean}} [options]
   * @private
   */
  _refreshDockProgress(options = {})
  {
    if (options.refreshAllItems === true) {
      this._dockProgressRefreshAllItems = true
    }
    this._dockProgressRefreshWanted = true
    if (this._dockProgressRefreshRunning) {
      return
    }
    this._dockProgressRefreshRunning = true
    void (async () => {
      try {
        while (this._dockProgressRefreshWanted) {
          this._dockProgressRefreshWanted = false
          let refreshAllItems = this._dockProgressRefreshAllItems
          this._dockProgressRefreshAllItems = false
          await this._paintDockProgress({refreshAllItems})
        }
      } finally {
        this._dockProgressRefreshRunning = false
        // A request may have arrived after the loop check but before Running cleared.
        if (this._dockProgressRefreshWanted) {
          this._refreshDockProgress({refreshAllItems: false})
        }
      }
    })()
  }

  /**
   * Update dock progress counters/bars. Remount slide-out only when pending work
   * appears or clears; refresh Start/Pause chrome before painting the live node.
   * @param {{refreshAllItems?: boolean}} [options]
   * @return {Promise<void>}
   * @private
   */
  async _paintDockProgress(options = {})
  {
    // Early DM init runs before `_buildDock` — painting then fills a detached slot and
    // leaves the live rail empty until a later wake. Skip until the dock exists.
    if (!document.querySelector('.bv-dock .bv-dock-rail-body')) {
      return
    }
    let refreshAllItems = options.refreshAllItems === true
    let downloadCount = await this._getPendingDownloadCount()
    let resolutionCount = await this._getPendingResolutionCount()
    let prevDownload = this._cachedDownloadCount
    let prevResolution = this._cachedResolutionCount
    this._cachedDownloadCount = downloadCount
    this._cachedResolutionCount = resolutionCount

    let slideOutBefore = prevDownload > 0 || prevResolution > 0
    let slideOutAfter = downloadCount > 0 || resolutionCount > 0
    // Chrome first so `_refreshDockRootSlot` cannot detach the node after we paint it.
    if (slideOutBefore !== slideOutAfter) {
      this._framework.refreshDockInterface(undefined, {layout: false})
    } else {
      this._cm.refreshDockButtonStates()
    }

    let showProgress = (resolutionCount > 0 || downloadCount > 0) && this.isDownloadManagerEnabled()
    let progressElement = this.getOrCreateProgressSlot()
    if (showProgress && !this._isProgressSlotConnected(progressElement)) {
      this._remountDownloadStartPauseSlot()
      progressElement = this.getOrCreateProgressSlot()
    }
    BrazenViewLayer.setDownloadManagerProgressSlotVisible(progressElement, showProgress)
    if (showProgress) {
      let state = await this._getState()
      let completedResolution = Number(state.completedResolutionCount) || 0
      let completedDownload = Number(state.completedDownloadCount) || 0
      BrazenViewLayer.updateDownloadManagerProgressSlot(progressElement, {
        resolution: {
          current: completedResolution,
          total: completedResolution + resolutionCount,
        },
        download: {
          current: completedDownload,
          total: completedDownload + downloadCount,
        },
      })
    }

    if (refreshAllItems) {
      this._refreshAllItemProgress()
    }
  }

  // -------------------------------------------------------------------------
  // Page helpers
  // -------------------------------------------------------------------------

  /**
   * @return {string|null}
   * @private
   */
  _getActivePageName()
  {
    for (let pageName of Object.keys(this._config.pages ?? {})) {
      if (this._framework.isPage(pageName)) {
        return pageName
      }
    }
    return null
  }

  /**
   * @return {object|null}
   * @private
   */
  _getActivePageConfig()
  {
    let pageName = this._getActivePageName()
    return pageName ? this._config.pages[pageName] : null
  }

  /**
   * @return {BrazenStorageRepositories}
   * @private
   */
  _repos()
  {
    return this._cm.getRepos()
  }
}

/** @type {Readonly<Record<string, string>>} */
BrazenDownloadManager.FIELD_DETAILED_HELP = DOWNLOAD_MANAGER_FIELD_DETAILED_HELP
/** @type {Readonly<typeof BRAZEN_DOWNLOAD_PATH_FIELD_KEYS>} */
BrazenDownloadManager.BRAZEN_DOWNLOAD_PATH_FIELD_KEYS = BRAZEN_DOWNLOAD_PATH_FIELD_KEYS