Brazen Framework - IndexedDB Storage

IndexedDB storage layer and repositories for Brazen user scripts

Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/scripts/587030/1909462/Brazen%20Framework%20-%20IndexedDB%20Storage.js

Du musst eine Erweiterung wie Tampermonkey, Greasemonkey oder Violentmonkey installieren, um dieses Skript zu installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Violentmonkey installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey oder Userscripts installieren.

Um dieses Skript zu installieren, müssen Sie eine Erweiterung wie Tampermonkey installieren.

Sie müssten eine Skript Manager Erweiterung installieren damit sie dieses Skript installieren können

(Ich habe schon ein Skript Manager, Lass mich es installieren!)

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um dieses Design zu installieren, müssen Sie eine Erweiterung wie Stylus installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

Um diesen Stil zu installieren, müssen Sie eine Erweiterung für den Benutzerstil Verwaltung installieren.

(Ich habe bereits einen Benutzerstil Verwaltung, ich möchte ihn installieren!)

// ==UserScript==
// @name         Brazen Framework - IndexedDB Storage
// @namespace    brazenvoid
// @version      2.0.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  IndexedDB storage layer and repositories for Brazen user scripts
// ==/UserScript==

// @ts-nocheck

// Keep at 3+: a brief mistaken bump in development may already have upgraded live DBs.
// IndexedDB cannot open a lower version (VersionError → entire script persistence dies).
// v4: compound `status_addedAt` on download queues for slim peekNextQueued.
// v5: flag async TagEntry.isDiscovered backfill for typed legacy rows (discovery gate).
// v6: rulesetFields + rulesetEntries stores for unified ruleset membership.
// v7: drop legacy tagRules + tagRuleSets stores (rules live in rulesetEntries).
// v8: bookmarks rows migrate into rulesetEntries under the bookmarks template.
// v9: one-time ruleset user-config defaults backfill (autoSort, hideTagTypes opt-in).
// v10: one-time correction resetting ruleset hideTagTypes to false (tag-type colors are native/on by default).
// v11: fieldKey_rawLine index on rulesetEntries for native add-time rawLine dedup.
const IDB_SCHEMA_VERSION = 12

const IDB_STORE_META = 'meta'
const IDB_STORE_SETTINGS = 'settings'
const IDB_STORE_APIS = 'apis'
const IDB_STORE_TAG_TYPES = 'tagTypes'
const IDB_STORE_TAGS = 'tags'
const IDB_STORE_RULESET_FIELDS = 'rulesetFields'
const IDB_STORE_RULESET_ENTRIES = 'rulesetEntries'
const IDB_STORE_BOOKMARKS = 'bookmarks'
const IDB_STORE_LEDGER = 'ledgerEntries'
const IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE = 'downloadResolutionQueue'
const IDB_STORE_DOWNLOAD_QUEUE = 'downloadQueue'
const IDB_STORE_DOWNLOAD_MANAGER_STATE = 'downloadManagerState'

const IDB_ALL_STORES = [
  IDB_STORE_META,
  IDB_STORE_SETTINGS,
  IDB_STORE_APIS,
  IDB_STORE_TAG_TYPES,
  IDB_STORE_TAGS,
  IDB_STORE_RULESET_FIELDS,
  IDB_STORE_RULESET_ENTRIES,
  IDB_STORE_BOOKMARKS,
  IDB_STORE_LEDGER,
  IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
  IDB_STORE_DOWNLOAD_QUEUE,
  IDB_STORE_DOWNLOAD_MANAGER_STATE,
]

const IDB_ROW_STORES = [
  IDB_STORE_TAGS,
  IDB_STORE_RULESET_ENTRIES,
  IDB_STORE_BOOKMARKS,
  IDB_STORE_LEDGER,
  IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
  IDB_STORE_DOWNLOAD_QUEUE,
]

const IDB_SINGLETON_STORES = [
  IDB_STORE_SETTINGS,
  IDB_STORE_APIS,
  IDB_STORE_TAG_TYPES,
  IDB_STORE_RULESET_FIELDS,
  IDB_STORE_DOWNLOAD_MANAGER_STATE,
]

const IDB_PUT_MANY_CHUNK = 500

/** Bound for TagRuntime name/id maps — full-table warm is not used. */
const TAG_RUNTIME_CACHE_MAX = 4096

/** Bound for positive ledger membership cache (recent claims / primed hits). */
const LEDGER_POSITIVE_CACHE_MAX = 16384

/** Max post ids written per IndexedDB flush during folder import (avoids giant RAM batches). */
const LEDGER_FOLDER_IMPORT_BATCH_SIZE = 4096

const LEGACY_SOURCE_LOCAL = 'local'
const LEGACY_SOURCE_GM = 'gm'

/** @type {{source: string, read: function(string, *): *, remove: function(string): void}[]} */
const LEGACY_STORAGE_BACKENDS = [
  {
    source: LEGACY_SOURCE_LOCAL,
    read: readLegacyLocal,
    remove: removeLegacyLocal,
  },
  {
    source: LEGACY_SOURCE_GM,
    read: readLegacyGm,
    remove: removeLegacyGm,
  },
]

/**
 * @param {string} storeKey
 * @param {*} [fallback]
 * @return {*}
 */
function readLegacyLocal(storeKey, fallback = null)
{
  let storedStore = localStorage.getItem(storeKey)
  if (!storedStore || storedStore === '') {
    return fallback
  }
  let parsed = JSON.parse(storedStore)
  if (parsed && typeof parsed === 'object' && 'arrays' in parsed && 'objects' in parsed && 'properties' in parsed) {
    return Utilities.objectFromJSON(storedStore)
  }
  return parsed
}

/**
 * @param {string} storeKey
 */
function removeLegacyLocal(storeKey)
{
  localStorage.removeItem(storeKey)
}

/**
 * @param {string} storeKey
 * @param {*} [fallback]
 * @return {*}
 */
function readLegacyGm(storeKey, fallback = null)
{
  try {
    let value = GM_getValue(storeKey, fallback)
    if (value === undefined) {
      return fallback
    }
    return Utilities.reviveGmStoredValue(value)
  } catch (error) {
    console.log('Failed to read legacy GM storage key:', storeKey, error)
    return fallback
  }
}

/**
 * @param {string} storeKey
 */
function removeLegacyGm(storeKey)
{
  if (typeof GM_deleteValue === 'function') {
    GM_deleteValue(storeKey)
  }
}

/**
 * Whether localStorage or GM still holds pre-IDB Brazen settings/bookmarks keys.
 * @param {string} prefix
 * @param {string|null|undefined} legacyPrefix
 * @return {boolean}
 */
function legacyStorageHasData(prefix, legacyPrefix)
{
  for (let backend of LEGACY_STORAGE_BACKENDS) {
    if (backend.read(prefix + 'settings', null)) {
      return true
    }
    if (legacyPrefix && legacyPrefix !== prefix && backend.read(legacyPrefix + 'settings', null)) {
      return true
    }
  }
  if (readLegacyGm(prefix + 'bookmarks', null)) {
    return true
  }
  if (legacyPrefix && legacyPrefix !== prefix && readLegacyGm(legacyPrefix + 'bookmarks', null)) {
    return true
  }
  return false
}

/**
 * @param {string} prefix
 * @return {Object|null}
 */
function readLegacySettingsAggregate(prefix)
{
  return readLegacyLocal(prefix + 'settings', null) ?? readLegacyGm(prefix + 'settings', null)
}

/** Framework-owned tag-attribute constraint kinds (fields reference these; consumers do not add kinds). */
const TAG_ATTRIBUTE_SPEC = {
  toggle: {
    kind: 'toggle',
    description: 'Membership on/off; a sole tag or combo row confers the attribute.',
  },
  requiresTag: {
    kind: 'requiresTag',
    description: 'Payload must include a required companion tag (e.g. substitution replacement).',
    slots: ['companion'],
  },
  requiresCombo: {
    kind: 'requiresCombo',
    description: 'Attribute valid only as a >=2 tag combination, never as a sole tag.',
    minTags: 2,
  },
}

/** Per-field attribute conflicts enforced on ruleset row write (migration logs/skips). */
const RULESET_FIELD_CONFLICTS = [
  {
    fieldKey: 'filename-tag-ignore-list',
    otherFieldKey: 'filename-tag-substitutions',
    tagFromPayload: (payload) => payload?.tagName ?? null,
    tagFromOtherPayload: (payload) => payload?.subjectName ?? null,
  },
  {
    fieldKey: 'filename-tag-substitutions',
    otherFieldKey: 'filename-tag-ignore-list',
    tagFromPayload: (payload) => payload?.subjectName ?? null,
    tagFromOtherPayload: (payload) => payload?.tagName ?? null,
  },
]

/**
 * When rawLine dedup finds an existing sole-tag row, hydrate missing tagEntryId from the writer.
 * @param {*} duplicate
 * @param {*} incoming
 * @return {*|null} merged row for update, or null when duplicate is already authoritative
 */
function mergeSoleTagRulesetDuplicate(duplicate, incoming)
{
  let stored = duplicate?.payload ?? {}
  let incomingPayload = incoming?.payload ?? {}
  if (incomingPayload.tagEntryId == null) {
    return null
  }
  let tagName = incomingPayload.tagName ?? stored.tagName ?? incoming.rawLine ?? duplicate.rawLine ?? ''
  tagName = String(tagName).trim()
  if (!tagName) {
    return null
  }
  if (stored.tagEntryId != null) {
    if (stored.tagEntryId === incomingPayload.tagEntryId &&
        String(stored.tagName ?? duplicate.rawLine ?? '').trim() === tagName) {
      return null
    }
    if (stored.tagEntryId !== incomingPayload.tagEntryId) {
      return {
        ...duplicate,
        payload: {...stored, tagName, tagEntryId: incomingPayload.tagEntryId},
        rawLine: tagName,
      }
    }
  }
  return {
    ...duplicate,
    payload: {...stored, tagName, tagEntryId: incomingPayload.tagEntryId},
    rawLine: tagName,
  }
}

/**
 * Resolve canonical tag identity for ruleset conflict matching.
 * @param {*} repos
 * @param {string|null} tagName
 * @param {number|null} tagEntryId
 * @return {{tagName: string, tagEntryId: number|null}}
 */
function resolveRulesetConflictTagIdentity(repos, tagName, tagEntryId)
{
  let resolvedName = String(tagName ?? '').trim()
  let resolvedEntryId = tagEntryId ?? null
  let runtime = repos?.tagRuntime
  if (!runtime) {
    return {tagName: resolvedName, tagEntryId: resolvedEntryId}
  }
  let cached = null
  if (resolvedEntryId != null && typeof runtime.getCachedByEntryId === 'function') {
    cached = runtime.getCachedByEntryId(resolvedEntryId)
  }
  if (!cached && resolvedName && typeof runtime.resolveCachedByName === 'function') {
    cached = runtime.resolveCachedByName(resolvedName)
  }
  if (cached?.entryId != null) {
    resolvedEntryId = cached.entryId
  }
  if (cached?.name) {
    resolvedName = String(cached.name).trim()
  }
  return {tagName: resolvedName, tagEntryId: resolvedEntryId}
}

/**
 * @param {*} leftTag
 * @param {*} rightTag
 * @return {boolean}
 */
function rulesetConflictTagsMatch(leftTag, rightTag)
{
  if (leftTag.tagEntryId != null && rightTag.tagEntryId != null &&
      leftTag.tagEntryId === rightTag.tagEntryId) {
    return true
  }
  if (leftTag.tagName && rightTag.tagName &&
      leftTag.tagName.toLowerCase() === rightTag.tagName.toLowerCase()) {
    return true
  }
  return false
}

/**
 * @param {RulesetEntryRepository} rulesetEntriesRepo
 * @param {string} fieldKey
 * @param {*} payload
 * @param {{logSkip?: boolean, repos?: *}} [options]
 * @return {Promise<{otherFieldKey: string|null, rows: *[], rule: object|null}>}
 */
async function listRulesetRowConflicts(rulesetEntriesRepo, fieldKey, payload, options = {})
{
  let rule = RULESET_FIELD_CONFLICTS.find((entry) => entry.fieldKey === fieldKey)
  if (!rule) {
    return {otherFieldKey: null, rows: [], rule: null}
  }
  let incomingTag = resolveRulesetConflictTagIdentity(
      options.repos,
      rule.tagFromPayload(payload),
      payload?.tagEntryId ?? payload?.subjectTagEntryId ?? null,
  )
  if (!incomingTag.tagName && incomingTag.tagEntryId == null) {
    return {otherFieldKey: rule.otherFieldKey, rows: [], rule}
  }
  let otherRows = await rulesetEntriesRepo.listAllForField(rule.otherFieldKey)
  let matches = []
  for (let row of otherRows) {
    let otherPayload = row.payload ?? {}
    let otherTag = resolveRulesetConflictTagIdentity(
        options.repos,
        rule.tagFromOtherPayload(otherPayload) ?? row.rawLine ?? null,
        otherPayload.tagEntryId ?? otherPayload.subjectTagEntryId ?? null,
    )
    if (!rulesetConflictTagsMatch(incomingTag, otherTag)) {
      continue
    }
    if (options.logSkip) {
      console.log('[RulesetEntry] attribute conflict skipped:', fieldKey,
          incomingTag.tagName || incomingTag.tagEntryId, '↔', rule.otherFieldKey)
    }
    matches.push(row)
  }
  return {otherFieldKey: rule.otherFieldKey, rows: matches, rule}
}

/**
 * @param {RulesetEntryRepository} rulesetEntriesRepo
 * @param {string} fieldKey
 * @param {*} payload
 * @param {{logSkip?: boolean, repos?: *}} [options]
 * @return {Promise<boolean>} true when a declared conflict exists
 */
async function rulesetRowConflictsWithOtherField(rulesetEntriesRepo, fieldKey, payload, options = {})
{
  let result = await listRulesetRowConflicts(rulesetEntriesRepo, fieldKey, payload, options)
  return result.rows.length > 0
}

/**
 * Remove conflicting ruleset rows in the paired field before an interactive write (last write wins).
 * @param {BrazenStorageRepositories} repos
 * @param {string} fieldKey
 * @param {*} payload
 * @return {Promise<{removedFieldKeys: string[], removedRows: Array<{fieldKey: string, row: *}>}>}
 */
async function resolveRulesetFieldConflicts(repos, fieldKey, payload)
{
  let {otherFieldKey, rows} = await listRulesetRowConflicts(repos.rulesetEntries, fieldKey, payload, {repos})
  if (!otherFieldKey || !rows.length) {
    return {removedFieldKeys: [], removedRows: []}
  }
  let removedRows = []
  for (let row of rows) {
    await repos.rulesetEntries.remove(row.entryId)
    removedRows.push({fieldKey: otherFieldKey, row})
  }
  if (typeof compileRulesetField === 'function') {
    await compileRulesetField(repos, otherFieldKey)
  }
  return {removedFieldKeys: [otherFieldKey], removedRows}
}

/** rule34xxx ruleset field main-entry defaults for legacy migration. */
const RULESET_MIGRATION_FIELD_SPECS = [
  {fieldKey: 'default-tags', templateId: 'default-tag', templateConfig: {}, rulesetSubject: 'default tag'},
  {fieldKey: 'tag-blacklist', templateId: 'tag-blacklist', templateConfig: {compileGroup: 'blacklist', attributeKind: 'toggle'}},
  {fieldKey: 'explored-tags-tracker', templateId: 'explored-tags', templateConfig: {compileGroup: 'explored', attributeKind: 'toggle'}},
  {fieldKey: 'filename-tag-ignore-list', templateId: 'tag-sole-ignore', templateConfig: {attributeKind: 'toggle'}},
  {fieldKey: 'filename-tag-substitutions', templateId: 'substitution', templateConfig: {attributeKind: 'requiresTag'}},
]

const RULESET_FIELD_KEYS = new Set(RULESET_MIGRATION_FIELD_SPECS.map((spec) => spec.fieldKey))

/**
 * @param {string} fieldKey
 * @return {object|null}
 */
function getRulesetFieldSpec(fieldKey)
{
  return RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === fieldKey) ?? null
}

/**
 * CM-owned ruleset user-config defaults (Configuration Manager loads after IDB; read at migration time).
 * @return {typeof DEFAULT_RULESET_USER_CONFIG}
 */
function rulesetUserConfigDefaults()
{
  if (typeof DEFAULT_RULESET_USER_CONFIG !== 'undefined') {
    return DEFAULT_RULESET_USER_CONFIG
  }
  if (typeof BrazenConfigurationManager !== 'undefined' &&
      BrazenConfigurationManager.DEFAULT_RULESET_USER_CONFIG) {
    return BrazenConfigurationManager.DEFAULT_RULESET_USER_CONFIG
  }
  throw new Error('DEFAULT_RULESET_USER_CONFIG unavailable — Configuration Manager must load before ruleset migrations')
}

/**
 * CM-owned persisted ruleset config keys (read at migration time).
 * @return {string[]}
 */
function rulesetUserConfigKeys()
{
  if (typeof RULESET_USER_CONFIG_KEYS !== 'undefined') {
    return RULESET_USER_CONFIG_KEYS
  }
  if (typeof BrazenConfigurationManager !== 'undefined' &&
      BrazenConfigurationManager.RULESET_USER_CONFIG_KEYS) {
    return BrazenConfigurationManager.RULESET_USER_CONFIG_KEYS
  }
  throw new Error('RULESET_USER_CONFIG_KEYS unavailable — Configuration Manager must load before ruleset migrations')
}

/**
 * @param {string|number} left
 * @param {string|number} right
 * @return {number}
 */
function naturalSortCompare(left, right)
{
  return String(left ?? '').localeCompare(String(right ?? ''), undefined, {numeric: true, sensitivity: 'base'})
}

/**
 * @param {*} entry
 * @return {string}
 */
function rulesetEntrySortKey(entry)
{
  return String(entry?.rawLine ?? '').trim()
}

/**
 * @param {*} left
 * @param {*} right
 * @param {string} [sortMode]
 * @return {number}
 */
function compareRulesetEntriesByMode(left, right, sortMode = 'natural-asc')
{
  let cmp = naturalSortCompare(rulesetEntrySortKey(left), rulesetEntrySortKey(right))
  return sortMode === 'natural-desc' ? -cmp : cmp
}

/**
 * Reassign `sortOrder` for all rows in a field using natural sort of `rawLine`.
 * @param {BrazenStorageRepositories} repos
 * @param {string} fieldKey
 * @param {string} [sortMode]
 * @return {Promise<*[]>}
 */
async function applyRulesetFieldSort(repos, fieldKey, sortMode = 'natural-asc')
{
  await repos.rulesetEntries.healSortOrderOrphans(fieldKey)
  let rows = await repos.rulesetEntries.listAllForField(fieldKey)
  rows.sort((left, right) => compareRulesetEntriesByMode(left, right, sortMode))
  await repos.rulesetEntries.reorder(fieldKey, rows.map((row) => row.entryId))
  return rows
}

/**
 * @param {string} line
 * @return {{rulePart: string, comment: string}}
 */
function splitRulesetImportLine(line)
{
  let text = String(line ?? '')
  let commentIndex = text.indexOf('//')
  if (commentIndex < 0) {
    return {rulePart: text.trim(), comment: ''}
  }
  return {
    rulePart: text.slice(0, commentIndex).trim(),
    comment: text.slice(commentIndex + 2).trim(),
  }
}

/**
 * @param {*} mainEntry
 * @param {BrazenStorageRepositories} repos
 * @return {{fieldKey: string, templateId: string, templateConfig: object, config: object, repos: BrazenStorageRepositories}}
 */
function buildRulesetContext(mainEntry, repos)
{
  return {
    fieldKey: mainEntry.fieldKey,
    templateId: mainEntry.templateId,
    templateConfig: mainEntry.templateConfig ?? {},
    config: mainEntry.config ?? {},
    repos,
  }
}

/**
 * @param {string} [subject]
 * @return {string}
 */
function rulesetSubjectFormLabel(subject)
{
  let word = String(subject ?? '').trim()
  if (!word) {
    return 'Rule'
  }
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}

/**
 * Resolve tag type metadata for ruleset row tokens.
 * @param {string} name
 * @param {{config?: {hideTagTypes?: boolean}, repos?: *}|null} ctx
 * @param {number|null} [tagEntryId]
 * @return {{typeEntryId?: number, showType: boolean}}
 */
function resolveRulesetTagMeta(name, ctx, tagEntryId = null)
{
  // Tag-type colors are native ruleset behavior and always resolve. `hideTagTypes` only controls
  // whether the type name is rendered as a prefix (e.g. `artist:name`) — never the color.
  let showType = !ctx?.config?.hideTagTypes
  let runtime = ctx?.repos?.tagRuntime
  let cached = null
  if (tagEntryId != null && typeof runtime?.getCachedByEntryId === 'function') {
    cached = runtime.getCachedByEntryId(tagEntryId)
  }
  if (!cached && typeof runtime?.resolveCachedByName === 'function') {
    cached = runtime.resolveCachedByName(name)
  }
  // Fall back to the last-seen type when no confirmed type exists (mirrors on-page compliance
  // colors). General tags are typically only seen — their type lands in `lastSeenTypeEntryId`,
  // not the confirmed `typeEntryId` — so without this they would never colorize.
  let typeEntryId = cached?.typeEntryId ?? cached?.meta?.lastSeenTypeEntryId ?? null
  if (typeEntryId != null) {
    return {typeEntryId, showType}
  }
  return {showType}
}

/**
 * Default opt-in tag-query operator scheme (Gelbooru family). Prefix operators control how a tag is
 * considered (`-tag` exclude, `*tag` leading wildcard); the `:` separator introduces metatag operators
 * (`rating:safe`, `sort:id:asc`, `md5:foo*`) and trailing suffix operators manipulate meaning
 * (`night~` fuzzy, `tag*` trailing wildcard). See `_splitTagOperators` for the full grammar.
 * @type {{prefixOperators: string[], suffixOperators: string[], suffixSeparators: string[]}}
 */
const DEFAULT_RULESET_TAG_OPERATORS = {
  prefixOperators: ['-', '*'],
  suffixOperators: ['~', '*'],
  suffixSeparators: [':'],
}

/**
 * @param {*} ctx
 * @return {{tokenize: function(string): string[], normalizeTag: function(string): string, resolveTag: function(string, number=): object, operators: object|null, querySyntaxKeys: Set<string>|null, isQuerySyntaxToken: function(string): boolean}}
 */
function resolveRulesetTagLineOptions(ctx)
{
  let querySyntaxKeys = ctx?.repos?.getQuerySyntaxKeysSync?.() ??
      (typeof GELBOORU_QUERY_SYNTAX_KEYS !== 'undefined' ? GELBOORU_QUERY_SYNTAX_KEYS : null)
  let isQuerySyntaxToken = (token) => {
    if (!querySyntaxKeys || typeof BrazenTagQueryEngine === 'undefined') {
      return false
    }
    return BrazenTagQueryEngine.isQuerySyntaxToken(token, querySyntaxKeys)
  }
  return {
    tokenize: ctx?.tokenize ?? (typeof BrazenViewLayer !== 'undefined' ?
        BrazenViewLayer._defaultRulesetQueryTokenize.bind(BrazenViewLayer) :
        ((query) => String(query ?? '').trim().split(/\s+/).filter(Boolean))),
    normalizeTag: ctx?.normalizeTag ?? ((value) => String(value ?? '').trim()),
    resolveTag: (name, tagEntryId = null) => resolveRulesetTagMeta(name, ctx, tagEntryId),
    operators: ctx?.tagOperators ?? null,
    querySyntaxKeys,
    isQuerySyntaxToken,
  }
}

/**
 * @param {object|null} operators
 * @param {Set<string>|null} querySyntaxKeys
 * @return {object|null}
 */
function mergeRulesetTagOperators(operators, querySyntaxKeys)
{
  if (!operators) {
    return null
  }
  return querySyntaxKeys ? {...operators, querySyntaxKeys} : operators
}

/**
 * Render a ruleset row tag line with shared token coloring (`&` combos, queries, sole tags).
 * @param {string} line
 * @param {*} ctx
 * @param {{tagEntryId?: number|null, tagEntryIds?: Array<number|null>, preferCombo?: boolean}} [options]
 * @return {HTMLElement|Text}
 */
function renderRulesetTagLine(line, ctx, options = {})
{
  let text = String(line ?? '')
  if (!text) {
    return document.createTextNode('')
  }
  if (typeof BrazenViewLayer === 'undefined') {
    return Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: text})
  }
  let lineOptions = resolveRulesetTagLineOptions(ctx)
  // Operator support is opt-in per template (e.g. default tags); other rulesets do not tolerate operators.
  let operators = options.operators ?? lineOptions.operators ?? null
  if (options.preferCombo !== false && text.includes('&')) {
    let parts = text.split('&').map((part) => part.trim()).filter(Boolean)
    return BrazenViewLayer.createRulesetTagTokens(parts, {
      resolveTag: (name, index) => lineOptions.resolveTag(name, options.tagEntryIds?.[index] ?? null),
    })
  }
  if (operators || /\s/.test(text) || text.startsWith('-') || text.startsWith('(') || lineOptions.isQuerySyntaxToken(text)) {
    return BrazenViewLayer.createRulesetTagQueryLine(text, {
      tokenize: lineOptions.tokenize,
      normalizeTag: lineOptions.normalizeTag,
      operators: mergeRulesetTagOperators(operators, lineOptions.querySyntaxKeys),
      querySyntaxKeys: lineOptions.querySyntaxKeys,
      isQuerySyntaxToken: lineOptions.isQuerySyntaxToken,
      resolveTag: (name) => lineOptions.resolveTag(name, options.tagEntryId ?? null),
    })
  }
  return BrazenViewLayer.createRulesetTagToken(text, lineOptions.resolveTag(text, options.tagEntryId ?? null))
}

/**
 * Collect tag names to warm before painting a ruleset row.
 * @param {*} entry
 * @param {string} templateId
 * @param {*} ctx
 * @return {string[]}
 */
function collectRulesetWarmTagNames(entry, templateId, ctx)
{
  let template = RulesetTemplateRegistry.get(templateId)
  if (typeof template?.collectWarmTagNames === 'function') {
    return template.collectWarmTagNames(entry, ctx) ?? []
  }
  let payload = entry?.payload ?? {}
  let lineOptions = resolveRulesetTagLineOptions(ctx)
  if (payload.variant === 'combo' && Array.isArray(payload.tagNames)) {
    return payload.tagNames.map((name) => lineOptions.normalizeTag(name)).filter(Boolean)
  }
  let tagName = payload.tagName ?? entry?.rawLine ?? ''
  if (!tagName) {
    return []
  }
  if (/\s/.test(tagName) || tagName.startsWith('-') || tagName.startsWith('(') || lineOptions.isQuerySyntaxToken(tagName)) {
    return BrazenViewLayer.collectRulesetQueryTagNames(tagName, lineOptions)
  }
  return [lineOptions.normalizeTag(tagName)].filter(Boolean)
}

/**
 * One tag/token per row (no `&` combos).
 *
 * @param {string} id
 * @param {string} label
 * @param {{subject?: string, ensureTag?: boolean, compileAsRawToken?: boolean, formLabel?: string}} [options]
 *   `compileAsRawToken` — when true, `optimize()` stores raw token strings (for default tags / metatags) instead of tag entry ids; row paint uses shared `renderRulesetTagLine`.
 * @return {object}
 */
function createSoleTagRulesetTemplate(id, label, options = {})
{
  let subject = options.subject ?? 'tag'
  let ensureTag = options.ensureTag === true
  let compileAsRawToken = options.compileAsRawToken === true
  let allowOperators = options.allowOperators === true
  let defaultFormLabel = options.formLabel ?? 'Tag name'

  let resolveOperators = (ctx) => {
    if (!allowOperators) {
      return null
    }
    let scheme = ctx?.tagOperators ?? ctx?.templateConfig?.tagOperators ?? DEFAULT_RULESET_TAG_OPERATORS
    let lineOptions = resolveRulesetTagLineOptions(ctx)
    return mergeRulesetTagOperators(scheme, lineOptions.querySyntaxKeys)
  }

  let readTagName = (payload, rawLine = '') => {
    return String(payload?.tagName ?? payload?.token ?? payload?.line ?? rawLine ?? '').trim()
  }

  let resolveFormLabel = (ctx) => {
    let noun = ctx?.rulesetSubject
    if (!noun || noun === 'rule') {
      noun = subject !== 'tag' ? subject : ''
    }
    return noun ? rulesetSubjectFormLabel(noun) : defaultFormLabel
  }

  let resolvePlaceholder = (ctx) => {
    let noun = ctx?.rulesetSubject
    if (!noun || noun === 'rule') {
      noun = subject !== 'tag' ? subject : 'tag name'
    }
    return `Enter ${noun}…`
  }

  return {
    id,
    label,
    subject,

    formatForUI(payload)
    {
      let tagName = readTagName(payload)
      return {label: tagName, rawLine: tagName}
    },

    translateFromUI(formValues, ctx)
    {
      let tagName = String(formValues?.tagName ?? formValues?.token ?? formValues?.line ?? '').trim()
      if (ctx?.templateConfig?.normalizeLine) {
        tagName = ctx.templateConfig.normalizeLine(tagName)
      }
      return tagName ? {tagName} : null
    },

    optimize(payload)
    {
      if (compileAsRawToken) {
        let tagName = readTagName(payload)
        return tagName || null
      }
      if (payload?.tagEntryId != null) {
        return {tagEntryId: payload.tagEntryId, tagName: payload.tagName ?? null}
      }
      return null
    },

    parseImportLine(rawLine, ctx)
    {
      let {rulePart, comment} = splitRulesetImportLine(rawLine)
      if (!rulePart || rulePart.includes('&')) {
        return null
      }
      if (ctx?.templateConfig?.normalizeLine) {
        rulePart = ctx.templateConfig.normalizeLine(rulePart)
      }
      return {payload: {tagName: rulePart}, comment}
    },

    matchRule(stored, target)
    {
      if (ensureTag && stored?.tagEntryId != null && target?.tagEntryId != null &&
          stored.tagEntryId === target.tagEntryId) {
        return true
      }
      let left = readTagName(stored)
      let right = readTagName(target, typeof target === 'string' ? target : '')
      return !!left && !!right && left.toLowerCase() === right.toLowerCase()
    },

    async persist(payload, ctx, entryMeta = {})
    {
      let hydrated = {...payload}
      if (ensureTag && hydrated.tagName && hydrated.tagEntryId == null) {
        let tag = await ctx.repos.tagRuntime.ensureTag(hydrated.tagName)
        hydrated.tagEntryId = tag.entryId
        // Keep hydrated.tagName — the toggle/query-normalized identity. ensureTag may return a
        // registry name that differs in casing or legacy spelling; compile/read must match the UI.
      }
      let formatted = this.formatForUI(hydrated)
      let row = {
        entryId: entryMeta.entryId,
        groupLabel: entryMeta.groupLabel ?? null,
        sortOrder: entryMeta.sortOrder,
        payload: hydrated,
        rawLine: formatted.rawLine ?? '',
        comment: entryMeta.comment ?? '',
      }
      if (row.entryId != null) {
        let existing = await ctx.repos.storage.get(IDB_STORE_RULESET_ENTRIES, row.entryId)
        if (existing) {
          return ctx.repos.rulesetEntries.update({...existing, ...row})
        }
      }
      return ctx.repos.rulesetEntries.add(ctx.fieldKey, row)
    },

    load(entry)
    {
      let payload = entry?.payload ?? {}
      let tagName = readTagName(payload, entry?.rawLine ?? '')
      if (!tagName) {
        return {}
      }
      if (ensureTag && payload?.tagEntryId != null) {
        return {tagName: payload.tagName ?? tagName, tagEntryId: payload.tagEntryId}
      }
      return {tagName}
    },

    createForm(ctx)
    {
      return createMinimalRulesetForm([
        {label: resolveFormLabel(ctx), name: 'tagName', placeholder: resolvePlaceholder(ctx)},
      ], ctx)
    },

    editForm(entry, ctx)
    {
      let tagName = readTagName(entry?.payload ?? {}, entry?.rawLine ?? '')
      return createMinimalRulesetForm([
        {label: resolveFormLabel(ctx), name: 'tagName', value: tagName, placeholder: resolvePlaceholder(ctx)},
      ], ctx)
    },

    renderRowContent(entry, ctx)
    {
      let tagName = readTagName(entry?.payload ?? {}, entry?.rawLine ?? '')
      return renderRulesetTagLine(tagName, ctx, {
        tagEntryId: entry?.payload?.tagEntryId ?? null,
        preferCombo: false,
        operators: resolveOperators(ctx),
      })
    },

    collectWarmTagNames(entry, ctx)
    {
      let tagName = readTagName(entry?.payload ?? {}, entry?.rawLine ?? '')
      if (!tagName) {
        return []
      }
      let operators = resolveOperators(ctx)
      let lineOptions = {...resolveRulesetTagLineOptions(ctx), operators}
      if (operators || /\s/.test(tagName) || tagName.startsWith('-') || tagName.startsWith('(') || lineOptions.isQuerySyntaxToken(tagName)) {
        return BrazenViewLayer.collectRulesetQueryTagNames(tagName, lineOptions)
      }
      return [lineOptions.normalizeTag(tagName)].filter(Boolean)
    },
  }
}

/**
 * @param {{label: string, name: string, type?: string, value?: string, placeholder?: string, help?: string}[]} fields
 * @param {{entryComment?: string, repos?: *, capabilities?: object, config?: object, existingGroups?: string[]}|null} [ctx]
 * @return {HTMLFormElement}
 */
function createMinimalRulesetForm(fields, ctx = null)
{
  let resolvedFields = [...fields]
  if (!resolvedFields.some((field) => field.name === 'comment')) {
    resolvedFields.push({
      label: 'Comment',
      name: 'comment',
      value: ctx?.entryComment ?? '',
      placeholder: 'Optional note…',
    })
  }
  let form = document.createElement('form')
  form.className = 'bv-ruleset-form'
  let searchNames = ctx?.repos?.tags?.searchNames?.bind(ctx.repos.tags)
  for (let field of resolvedFields) {
    let group = document.createElement('div')
    group.className = 'bv-group bv-ruleset-form-field'
    let label = document.createElement('label')
    label.className = 'bv-label bv-text'
    label.textContent = field.label
    let control = null
    let tagInputFields = new Set(['tagName', 'subject', 'replacement', 'line', 'tags'])
    if (tagInputFields.has(field.name) &&
        typeof BrazenViewLayer !== 'undefined' && typeof searchNames === 'function') {
      let segmentMode = false
      if (field.name === 'line') {
        segmentMode = 'combo'
      } else if (field.name === 'tags') {
        segmentMode = 'query'
      }
      let tagInputOptions = {
        name: field.name,
        value: field.value ?? '',
        searchNames,
        segmentMode,
      }
      if (field.placeholder != null && field.placeholder !== '') {
        tagInputOptions.placeholder = field.placeholder
      }
      control = BrazenViewLayer.createTagInput(tagInputOptions)
    } else {
      let input = document.createElement('input')
      input.name = field.name
      let fieldType = field.type ?? 'text'
      input.type = fieldType === 'number' ? 'text' : fieldType
      input.className = 'bv-input bv-text'
      if (fieldType === 'number' && typeof BrazenViewLayer !== 'undefined') {
        BrazenViewLayer._bindNumericTextInput(input)
      }
      if (field.placeholder) {
        input.placeholder = field.placeholder
      }
      if (field.value != null) {
        input.value = field.value
      }
      control = input
    }
    group.append(label, control)
    if (field.help) {
      let helpEl = document.createElement('p')
      helpEl.className = 'bv-detail-field-help'
      helpEl.textContent = field.help
      group.append(helpEl)
    }
    form.appendChild(group)
  }
  if (ctx?.capabilities?.grouping === true && ctx?.config?.groupingEnabled !== false &&
      typeof BrazenViewLayer !== 'undefined') {
    let groupWrap = document.createElement('div')
    groupWrap.className = 'bv-group bv-ruleset-form-field'
    groupWrap.append(BrazenViewLayer.createGroupCombobox({
      groups: ctx.existingGroups ?? [],
    }))
    form.append(groupWrap)
  }
  return form
}

/**
 * @param {string} id
 * @param {string} label
 * @return {object}
 */
function createTagComboRulesetTemplate(id, label)
{
  return {
    id,
    label,
    subject: 'tag',

    formatForUI(payload)
    {
      if (payload?.variant === 'combo') {
        let raw = payload.rawLine ?? (payload.tagNames ?? []).join(' & ')
        return {label: raw, rawLine: raw}
      }
      let tagName = payload?.tagName ?? String(payload?.tagEntryId ?? '')
      return {label: tagName, rawLine: tagName}
    },

    translateFromUI(formValues)
    {
      let line = String(formValues?.line ?? formValues?.rawLine ?? '').trim()
      if (!line) {
        return null
      }
      if (line.includes('&')) {
        let tagNames = line.split('&').map((part) => part.trim()).filter(Boolean)
        return {variant: 'combo', tagNames, rawLine: tagNames.join(' & ')}
      }
      return {variant: 'sole', tagName: line}
    },

    optimize(payload)
    {
      if (payload?.variant === 'combo' && Array.isArray(payload.tagEntryIds) && payload.tagEntryIds.length) {
        return {combo: {tagEntryIds: [...payload.tagEntryIds]}}
      }
      if (payload?.variant === 'sole' && payload.tagEntryId != null) {
        return {combo: {tagEntryIds: [payload.tagEntryId]}}
      }
      return null
    },

    parseImportLine(rawLine)
    {
      let {rulePart, comment} = splitRulesetImportLine(rawLine)
      if (!rulePart) {
        return null
      }
      if (rulePart.includes('&')) {
        let tagNames = rulePart.split('&').map((part) => part.trim()).filter(Boolean)
        return {
          payload: {variant: 'combo', tagNames, rawLine: tagNames.join(' & ')},
          comment,
        }
      }
      return {
        payload: {variant: 'sole', tagName: rulePart},
        comment,
      }
    },

    matchRule(stored, target)
    {
      if (stored?.variant !== target?.variant) {
        return false
      }
      if (stored.variant === 'sole') {
        if (stored.tagEntryId == null || target.tagEntryId == null) {
          console.warn(`[Ruleset:${id}] sole matchRule requires tagEntryId on both sides`, {stored, target})
          return false
        }
        return stored.tagEntryId === target.tagEntryId
      }
      if (!Array.isArray(stored.tagEntryIds) || !stored.tagEntryIds.length ||
          !Array.isArray(target.tagEntryIds) || !target.tagEntryIds.length) {
        console.warn(`[Ruleset:${id}] combo matchRule requires tagEntryIds on both sides`, {stored, target})
        return false
      }
      let leftIds = [...stored.tagEntryIds].sort((a, b) => a - b).join(',')
      let rightIds = [...target.tagEntryIds].sort((a, b) => a - b).join(',')
      return leftIds === rightIds
    },

    async persist(payload, ctx, entryMeta = {})
    {
      let hydrated = {...payload}
      if (hydrated.variant === 'sole' && hydrated.tagName && hydrated.tagEntryId == null) {
        let tag = await ctx.repos.tagRuntime.ensureTag(hydrated.tagName)
        hydrated.tagEntryId = tag.entryId
      }
      if (hydrated.variant === 'combo' && Array.isArray(hydrated.tagNames) && !hydrated.tagEntryIds?.length) {
        hydrated.tagEntryIds = []
        hydrated.tagNames = []
        for (let tagName of payload.tagNames) {
          let tag = await ctx.repos.tagRuntime.ensureTag(tagName)
          hydrated.tagEntryIds.push(tag.entryId)
          hydrated.tagNames.push(tag.name)
        }
        if (!hydrated.rawLine) {
          hydrated.rawLine = hydrated.tagNames.join(' & ')
        }
      }
      if (hydrated.variant === 'sole' && hydrated.tagEntryId == null) {
        console.warn(`[Ruleset:${id}] persist: sole row still missing tagEntryId after hydration`, hydrated)
        throw new Error(`Ruleset ${id}: sole row missing tagEntryId`)
      }
      if (hydrated.variant === 'combo' && (!hydrated.tagEntryIds?.length)) {
        console.warn(`[Ruleset:${id}] persist: combo row still missing tagEntryIds after hydration`, hydrated)
        throw new Error(`Ruleset ${id}: combo row missing tagEntryIds`)
      }
      let formatted = this.formatForUI(hydrated)
      let row = {
        entryId: entryMeta.entryId,
        groupLabel: entryMeta.groupLabel ?? null,
        sortOrder: entryMeta.sortOrder,
        payload: hydrated,
        rawLine: formatted.rawLine ?? '',
        comment: entryMeta.comment ?? '',
      }
      if (row.entryId != null) {
        let existing = await ctx.repos.storage.get(IDB_STORE_RULESET_ENTRIES, row.entryId)
        if (existing) {
          return ctx.repos.rulesetEntries.update({...existing, ...row})
        }
      }
      return ctx.repos.rulesetEntries.add(ctx.fieldKey, row)
    },

    load(entry)
    {
      return entry?.payload ?? {}
    },

    createForm(ctx)
    {
      return createMinimalRulesetForm([
        {
          label: 'Rule line',
          name: 'line',
          placeholder: 'catgirl & solo | duo',
          help: 'Combine with & (and) or | (or). // comments are optional.',
        },
        {label: 'Comment', name: 'comment', placeholder: 'Optional note…'},
      ], ctx)
    },

    editForm(entry, ctx)
    {
      let payload = entry?.payload ?? {}
      let line = payload.variant === 'combo' ?
          (payload.rawLine ?? (payload.tagNames ?? []).join(' & ')) :
          (payload.tagName ?? entry?.rawLine ?? '')
      return createMinimalRulesetForm([
        {
          label: 'Rule line',
          name: 'line',
          value: line,
          placeholder: 'catgirl & solo | duo',
          help: 'Combine with & (and) or | (or). // comments are optional.',
        },
        {label: 'Comment', name: 'comment', value: entry?.comment ?? '', placeholder: 'Optional note…'},
      ], ctx)
    },

    renderRowContent(entry, ctx)
    {
      let payload = entry?.payload ?? {}
      if (payload.variant === 'combo') {
        let names = (payload.tagNames ?? []).filter(Boolean)
        return BrazenViewLayer.createRulesetTagTokens(names, {
          resolveTag: (name, index) => resolveRulesetTagMeta(name, ctx, payload.tagEntryIds?.[index] ?? null),
        })
      }
      return renderRulesetTagLine(payload.tagName ?? entry?.rawLine ?? '', ctx, {
        tagEntryId: payload.tagEntryId ?? null,
        preferCombo: false,
      })
    },

    collectWarmTagNames(entry, ctx)
    {
      let payload = entry?.payload ?? {}
      let lineOptions = resolveRulesetTagLineOptions(ctx)
      if (payload.variant === 'combo' && Array.isArray(payload.tagNames)) {
        return payload.tagNames.map((name) => lineOptions.normalizeTag(name)).filter(Boolean)
      }
      let tagName = payload.tagName ?? entry?.rawLine ?? ''
      if (!tagName) {
        return []
      }
      if (/\s/.test(tagName) || tagName.startsWith('-') || tagName.startsWith('(') || lineOptions.isQuerySyntaxToken(tagName)) {
        return BrazenViewLayer.collectRulesetQueryTagNames(tagName, lineOptions)
      }
      return [lineOptions.normalizeTag(tagName)].filter(Boolean)
    },
  }
}

class RulesetTemplateRegistry
{
  /** @type {Map<string, object>} */
  static _templates = new Map()

  /**
   * @param {object} template
   */
  static register(template)
  {
    if (!template?.id) {
      throw new Error('Ruleset template requires id')
    }
    this._templates.set(template.id, template)
  }

  /**
   * @param {string} id
   * @return {object|null}
   */
  static get(id)
  {
    return this._templates.get(id) ?? null
  }

  /**
   * @return {object[]}
   */
  static list()
  {
    return [...this._templates.values()]
  }
}

RulesetTemplateRegistry.register(createSoleTagRulesetTemplate('default-tag', 'Default tag', {
  subject: 'default tag',
  compileAsRawToken: true,
  allowOperators: true,
}))

RulesetTemplateRegistry.register(createSoleTagRulesetTemplate('tag-sole-ignore', 'Filename tag ignore', {
  ensureTag: true,
}))

RulesetTemplateRegistry.register({
  id: 'plain-line',
  label: 'Plain line',

  formatForUI(payload)
  {
    let line = String(payload?.line ?? '')
    return {label: line, rawLine: line}
  },

  translateFromUI(formValues, ctx)
  {
    let line = String(formValues?.line ?? '').trim()
    if (ctx?.templateConfig?.normalizeLine) {
      line = ctx.templateConfig.normalizeLine(line)
    }
    return line ? {line} : null
  },

  optimize(payload)
  {
    let line = String(payload?.line ?? '').trim()
    return line || null
  },

  parseImportLine(rawLine, ctx)
  {
    let {rulePart, comment} = splitRulesetImportLine(rawLine)
    if (!rulePart) {
      return null
    }
    if (ctx?.templateConfig?.normalizeLine) {
      rulePart = ctx.templateConfig.normalizeLine(rulePart)
    }
    return {payload: {line: rulePart}, comment}
  },

  matchRule(stored, target)
  {
    return String(stored?.line ?? '') === String(target?.line ?? '')
  },

  async persist(payload, ctx, entryMeta = {})
  {
    let formatted = this.formatForUI(payload)
    let row = {
      entryId: entryMeta.entryId,
      groupLabel: entryMeta.groupLabel ?? null,
      sortOrder: entryMeta.sortOrder,
      payload,
      rawLine: formatted.rawLine ?? '',
      comment: entryMeta.comment ?? '',
    }
    if (row.entryId != null) {
      let existing = await ctx.repos.storage.get(IDB_STORE_RULESET_ENTRIES, row.entryId)
      if (existing) {
        return ctx.repos.rulesetEntries.update({...existing, ...row})
      }
    }
    return ctx.repos.rulesetEntries.add(ctx.fieldKey, row)
  },

  load(entry)
  {
    return entry?.payload ?? {line: entry?.rawLine ?? ''}
  },

  createForm(ctx)
  {
    return createMinimalRulesetForm([
      {label: 'Rule', name: 'line', placeholder: 'Enter rule text…'},
    ], ctx)
  },

  editForm(entry, ctx)
  {
    return createMinimalRulesetForm([
      {label: 'Rule', name: 'line', value: entry?.payload?.line ?? entry?.rawLine ?? '', placeholder: 'Enter rule text…'},
    ], ctx)
  },
})

RulesetTemplateRegistry.register({
  id: 'bookmarks',
  label: 'Bookmarks',

  subject: 'bookmark',

  formatForUI(payload)
  {
    let label = String(payload?.label ?? '')
    return {label, rawLine: label}
  },

  translateFromUI(formValues, ctx)
  {
    let label = String(formValues?.label ?? '').trim()
    let tags = String(formValues?.tags ?? '').trim()
    let url = String(formValues?.url ?? '').trim()
    if (!tags && !label) {
      return null
    }
    if (!url && tags && typeof ctx?.templateConfig?.buildUrl === 'function') {
      url = ctx.templateConfig.buildUrl(tags)
    }
    if (!label && tags) {
      label = typeof ctx?.templateConfig?.formatLabel === 'function' ?
          ctx.templateConfig.formatLabel(tags) : tags
    }
    return {label, tags, url}
  },

  optimize()
  {
    return null
  },

  parseImportLine(rawLine)
  {
    let {rulePart, comment} = splitRulesetImportLine(rawLine)
    if (!rulePart) {
      return null
    }
    return {payload: {label: rulePart, tags: rulePart, url: ''}, comment}
  },

  matchRule(stored, target)
  {
    let normalize = typeof target?.normalizeUrl === 'function' ?
        target.normalizeUrl : ((value) => String(value ?? '').trim())
    let left = normalize(stored?.url ?? stored?.payload?.url ?? '')
    let right = normalize(target?.url ?? target ?? '')
    return !!left && left === right
  },

  async persist(payload, ctx, entryMeta = {})
  {
    let formatted = this.formatForUI(payload)
    let row = {
      entryId: entryMeta.entryId,
      groupLabel: entryMeta.groupLabel ?? null,
      sortOrder: entryMeta.sortOrder,
      payload,
      rawLine: formatted.rawLine ?? '',
      comment: entryMeta.comment ?? '',
    }
    if (row.entryId != null) {
      let existing = await ctx.repos.storage.get(IDB_STORE_RULESET_ENTRIES, row.entryId)
      if (existing) {
        return ctx.repos.rulesetEntries.update({...existing, ...row})
      }
    }
    return ctx.repos.rulesetEntries.add(ctx.fieldKey, row)
  },

  load(entry)
  {
    return entry?.payload ?? {}
  },

  createForm(ctx)
  {
    return createMinimalRulesetForm([
      {label: 'Label', name: 'label', placeholder: 'Display name…'},
      {label: 'Tags', name: 'tags', placeholder: 'tag1 tag2…', help: 'Space-separated tags.'},
      {label: 'URL', name: 'url', placeholder: 'https://… (optional)', help: 'Leave blank to build from tags.'},
    ], ctx)
  },

  editForm(entry, ctx)
  {
    let payload = entry?.payload ?? {}
    return createMinimalRulesetForm([
      {label: 'Label', name: 'label', value: payload.label ?? entry?.rawLine ?? '', placeholder: 'Display name…'},
      {label: 'Tags', name: 'tags', value: payload.tags ?? '', placeholder: 'tag1 tag2…', help: 'Space-separated tags.'},
      {label: 'URL', name: 'url', value: payload.url ?? '', placeholder: 'https://… (optional)', help: 'Leave blank to build from tags.'},
    ], ctx)
  },

  renderRowContent(entry, ctx)
  {
    let payload = entry?.payload ?? {}
    let tags = payload.tags ?? ''
    let url = payload.url ?? ''
    let label = payload.label ?? entry?.rawLine ?? ''
    let content = tags.trim() ?
        renderRulesetTagLine(tags, ctx, {preferCombo: false}) :
        Utilities.makeEl('span', {class: 'bv-ruleset-panel-content-text', text: label})
    let navigateUrl = url
    if (!navigateUrl && tags && typeof ctx?.templateConfig?.buildUrl === 'function') {
      navigateUrl = ctx.templateConfig.buildUrl(tags)
    }
    let link
    if (navigateUrl && ctx?.useNativeTabLink !== false) {
      link = Utilities.makeEl('a', {
        class: 'bv-ruleset-panel-nav-btn',
        attrs: {
          href: navigateUrl,
          target: '_blank',
          rel: 'noopener',
          title: tags,
        },
        children: [content],
      })
    } else {
      link = Utilities.makeEl('button', {
        class: 'bv-ruleset-panel-nav-btn',
        attrs: {type: 'button', title: tags},
        children: [content],
        on: {
          click: (event) => {
            event.stopPropagation()
            if (!navigateUrl) {
              return
            }
            if (typeof ctx?.onNavigate === 'function') {
              ctx.onNavigate(navigateUrl)
            } else {
              Utilities.openUrlInNewTab(navigateUrl)
            }
          },
        },
      })
    }
    return Utilities.makeEl('div', {
      class: 'bv-ruleset-panel-content bv-ruleset-panel-nav-wrap',
      attrs: {'data-ruleset-interactive': 'true'},
      children: [link],
    })
  },

  collectWarmTagNames(entry, ctx)
  {
    return BrazenViewLayer.collectRulesetQueryTagNames(entry?.payload?.tags ?? '', resolveRulesetTagLineOptions(ctx))
  },
})

RulesetTemplateRegistry.register(createTagComboRulesetTemplate('tag-blacklist', 'Tag blacklist'))
RulesetTemplateRegistry.register(createTagComboRulesetTemplate('explored-tags', 'Explored tags'))

RulesetTemplateRegistry.register({
  id: 'substitution',
  label: 'Tag substitution',

  formatForUI(payload)
  {
    let subject = payload?.subjectName ?? String(payload?.subjectTagEntryId ?? '')
    let replacement = payload?.replacementName ?? String(payload?.replacementTagEntryId ?? '')
    let rawLine = subject + ' → ' + replacement
    return {label: rawLine, rawLine}
  },

  translateFromUI(formValues)
  {
    let subject = String(formValues?.subject ?? '').trim()
    let replacement = String(formValues?.replacement ?? '').trim()
    if (!subject || !replacement) {
      return null
    }
    return {subjectName: subject, replacementName: replacement}
  },

  optimize(payload)
  {
    if (payload?.subjectTagEntryId == null || payload?.replacementTagEntryId == null) {
      return null
    }
    return {
      subjectTagEntryId: payload.subjectTagEntryId,
      replacementTagEntryId: payload.replacementTagEntryId,
      subjectName: payload.subjectName ?? null,
      replacementName: payload.replacementName ?? null,
    }
  },

  parseImportLine(rawLine)
  {
    let {rulePart, comment} = splitRulesetImportLine(rawLine)
    let parsed = parseSubstitutionTagLine(rulePart)
    if (!parsed) {
      return null
    }
    return {
      payload: {
        subjectName: parsed.subject,
        replacementName: parsed.replacement,
      },
      comment,
    }
  },

  matchRule(stored, target)
  {
    let storedSubject = stored?.subjectTagEntryId ?? stored?.subjectName
    let targetSubject = target?.subjectTagEntryId ?? target?.subjectName
    let subjectMatch = storedSubject === targetSubject ||
        (stored?.subjectName != null && target?.subjectName != null && stored.subjectName === target.subjectName)
    if (!subjectMatch) {
      return false
    }
    let targetReplacement = target?.replacementTagEntryId ?? target?.replacementName
    if (targetReplacement == null) {
      return true
    }
    let storedReplacement = stored?.replacementTagEntryId ?? stored?.replacementName
    return storedReplacement === targetReplacement ||
        (stored?.replacementName != null && target?.replacementName != null &&
            stored.replacementName === target.replacementName)
  },

  async persist(payload, ctx, entryMeta = {})
  {
    let hydrated = {...payload}
    if (hydrated.subjectName && hydrated.subjectTagEntryId == null) {
      let subject = await ctx.repos.tagRuntime.ensureTag(hydrated.subjectName)
      hydrated.subjectTagEntryId = subject.entryId
      hydrated.subjectName = subject.name
    }
    if (hydrated.replacementName && hydrated.replacementTagEntryId == null) {
      let replacement = await ctx.repos.tagRuntime.ensureTag(hydrated.replacementName)
      hydrated.replacementTagEntryId = replacement.entryId
      hydrated.replacementName = replacement.name
    }
    let formatted = this.formatForUI(hydrated)
    let row = {
      entryId: entryMeta.entryId,
      groupLabel: entryMeta.groupLabel ?? null,
      sortOrder: entryMeta.sortOrder,
      payload: hydrated,
      rawLine: formatted.rawLine ?? '',
      comment: entryMeta.comment ?? '',
    }
    if (row.entryId != null) {
      let existing = await ctx.repos.storage.get(IDB_STORE_RULESET_ENTRIES, row.entryId)
      if (existing) {
        return ctx.repos.rulesetEntries.update({...existing, ...row})
      }
    }
    return ctx.repos.rulesetEntries.add(ctx.fieldKey, row)
  },

  load(entry)
  {
    return entry?.payload ?? {}
  },

  createForm(ctx)
  {
    return createMinimalRulesetForm([
      {label: 'Subject tag', name: 'subject', placeholder: 'Subject tag…'},
      {label: 'Replacement tag', name: 'replacement', placeholder: 'Replacement tag…'},
      {label: 'Comment', name: 'comment', placeholder: 'Optional note…'},
    ], ctx)
  },

  editForm(entry, ctx)
  {
    let payload = entry?.payload ?? {}
    return createMinimalRulesetForm([
      {label: 'Subject tag', name: 'subject', value: payload.subjectName ?? '', placeholder: 'Subject tag…'},
      {label: 'Replacement tag', name: 'replacement', value: payload.replacementName ?? '', placeholder: 'Replacement tag…'},
      {label: 'Comment', name: 'comment', value: entry?.comment ?? '', placeholder: 'Optional note…'},
    ], ctx)
  },

  renderRowContent(entry, ctx)
  {
    let payload = entry?.payload ?? {}
    let subject = payload.subjectName ?? entry?.rawLine?.split('→')[0]?.trim() ?? ''
    let replacement = payload.replacementName ?? ''
    let wrap = Utilities.makeEl('span', {class: 'bv-ruleset-tag-tokens'})
    wrap.append(BrazenViewLayer.createRulesetTagToken(subject, resolveRulesetTagMeta(subject, ctx, payload.subjectTagEntryId ?? null)))
    wrap.append(document.createTextNode(' → '))
    wrap.append(BrazenViewLayer.createRulesetTagToken(replacement, resolveRulesetTagMeta(replacement, ctx, payload.replacementTagEntryId ?? null)))
    return wrap
  },

  collectWarmTagNames(entry, ctx)
  {
    let payload = entry?.payload ?? {}
    let normalize = ctx?.normalizeTag ?? ((value) => String(value ?? '').trim())
    return [payload.subjectName, payload.replacementName].
        map((name) => normalize(name)).
        filter(Boolean)
  },
})

/**
 * @param {string} templateId
 * @param {*[]} fragments
 * @return {*}
 */
function aggregateRulesetOptimized(templateId, fragments)
{
  let parts = fragments.filter(Boolean)
  switch (templateId) {
    case 'plain-line':
    case 'default-tag':
      return parts.map((part) => String(part)).filter(Boolean).sort(naturalSortCompare)
    case 'tag-blacklist':
    case 'explored-tags': {
      let combos = parts.map((part) => part.combo).filter((combo) => combo?.tagEntryIds?.length)
      combos.sort((left, right) => left.tagEntryIds.length - right.tagEntryIds.length)
      return {combos}
    }
    case 'tag-sole-ignore':
      return {tagEntryIds: [...new Set(parts.map((part) => part.tagEntryId).filter((id) => id != null))]}
    case 'substitution':
      return parts
    default:
      return parts
  }
}

/**
 * Compile a ruleset field from its main entry and child rows.
 * @param {BrazenStorageRepositories} repos
 * @param {string} fieldKey
 * @return {Promise<{rawLines: string[], optimized: *, templateId: string, updatedAt: number}|null>}
 */
async function compileRulesetField(repos, fieldKey)
{
  let mainEntry = await repos.rulesetFields.get(fieldKey)
  if (!mainEntry?.templateId) {
    return null
  }
  let template = RulesetTemplateRegistry.get(mainEntry.templateId)
  if (!template) {
    return null
  }
  let ctx = buildRulesetContext(mainEntry, repos)
  let rows = await repos.rulesetEntries.listAllForField(fieldKey)
  rows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
  let rawLines = []
  let fragments = []
  repos.meta?.beginTagsRevisionBatch?.()
  try {
    for (let row of rows) {
      let payload = template.load ? template.load(row, ctx) : (row.payload ?? {})
      if (mainEntry.templateId === 'tag-sole-ignore' && payload?.tagName && payload.tagEntryId == null &&
          repos.tagRuntime?.ensureTag) {
        let tag = await repos.tagRuntime.ensureTag(payload.tagName)
        if (tag?.entryId != null) {
          payload = {...payload, tagEntryId: tag.entryId}
        }
      }
      rawLines.push(row.rawLine ?? template.formatForUI(payload, ctx).rawLine ?? '')
      if (typeof template.optimize === 'function') {
        fragments.push(template.optimize(payload, ctx))
      }
    }
  } finally {
    await repos.meta?.endTagsRevisionBatch?.()
  }
  let optimized = aggregateRulesetOptimized(mainEntry.templateId, fragments)
  let compiled = {
    rawLines: rawLines.filter(Boolean),
    optimized,
    templateId: mainEntry.templateId,
    updatedAt: Date.now(),
  }
  return compiled
}

/**
 * @param {BrazenConfigurationManager|null} cm
 * @return {object[]}
 */
function resolveRulesetMigrationSpecs(cm)
{
  let defaultConfig = rulesetUserConfigDefaults()
  if (cm && typeof cm.getFieldSeeds === 'function') {
    let specs = []
    for (let [fieldKey, seed] of cm.getFieldSeeds()) {
      if (seed?.templateId) {
        specs.push({
          fieldKey,
          templateId: seed.templateId,
          templateConfig: seed.templateConfig ?? {},
          config: {...defaultConfig, ...(seed.config ?? {})},
        })
      }
    }
    if (specs.length) {
      return specs
    }
  }
  return RULESET_MIGRATION_FIELD_SPECS.map((spec) => ({
    ...spec,
    config: {...defaultConfig},
  }))
}

/**
 * Re-import ruleset rows from surviving settings / GM legacy when a field has no entries.
 * @param {BrazenStorageRepositories} repos
 * @param {BrazenConfigurationManager|null} [cm]
 * @param {{fieldKeys?: string[], scriptPrefix?: string}} [options]
 * @return {Promise<{repaired: Record<string, number>}>}
 */
async function repairRulesetFieldsFromLegacyIfEmpty(repos, cm = null, options = {})
{
  let fieldKeys = options.fieldKeys ?? RULESET_MIGRATION_FIELD_SPECS.map((spec) => spec.fieldKey)
  let scriptPrefix = options.scriptPrefix ?? cm?._scriptPrefix ?? ''
  /** @type {Record<string, number>} */
  let repaired = {}

  for (let fieldKey of fieldKeys) {
    let rows = await repos.rulesetEntries.listAllForField(fieldKey)
    if (rows.length > 0) {
      continue
    }
    let spec = getRulesetFieldSpec(fieldKey)
    if (!spec) {
      continue
    }

    let lines = []
    let settingsField = await repos.settings.getField(fieldKey)
    if (Array.isArray(settingsField?.value) && settingsField.value.length) {
      if (typeof settingsField.value[0] === 'object' && settingsField.value[0]?.subject) {
        for (let entry of settingsField.value) {
          if (entry?.subject) {
            lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
          }
        }
      } else {
        lines = settingsField.value.map(String)
      }
    }
    if (!lines.length && scriptPrefix) {
      let aggregate = readLegacySettingsAggregate(scriptPrefix)
      let legacyValue = aggregate?.[fieldKey]
      if (Array.isArray(legacyValue) && legacyValue.length) {
        if (typeof legacyValue[0] === 'object' && legacyValue[0]?.subject) {
          for (let entry of legacyValue) {
            if (entry?.subject) {
              lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
            }
          }
        } else {
          lines = legacyValue.map(String)
        }
      }
    }
    if (!lines.length) {
      continue
    }

    await repos.rulesetFields.upsert({
      fieldKey: spec.fieldKey,
      templateId: spec.templateId,
      templateConfig: spec.templateConfig,
      config: spec.config ?? {},
    })
    let template = RulesetTemplateRegistry.get(spec.templateId)
    if (!template) {
      continue
    }
    let mainEntry = await repos.rulesetFields.get(fieldKey)
    let ctx = buildRulesetContext(mainEntry, repos)
    if (cm && typeof cm.getField === 'function' && typeof cm._buildRulesetTemplateCtx === 'function') {
      let field = cm.getField(fieldKey)
      if (field) {
        ctx = {...ctx, ...cm._buildRulesetTemplateCtx(field)}
      }
    }

    let imported = 0
    let sortOrder = 0
    for (let rawLine of lines) {
      let expanded = String(rawLine).includes('|') ? expandOrRuleLine(String(rawLine)) : [String(rawLine)]
      for (let line of expanded) {
        let parsed = template.parseImportLine?.(String(line), ctx)
        if (!parsed) {
          continue
        }
        await template.persist(parsed.payload, ctx, {
          sortOrder: sortOrder++,
          comment: parsed.comment ?? '',
        })
        imported++
      }
    }
    if (imported > 0) {
      repaired[fieldKey] = imported
      await compileRulesetField(repos, fieldKey)
    }
  }
  return {repaired}
}

/**
 * Idempotent migration from legacy settings / TagEntry attributes / tagRules into ruleset stores.
 * @param {BrazenStorageRepositories} repos
 * @param {BrazenConfigurationManager|null} cm
 * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
 * @return {Promise<{skipped?: boolean, imported?: number}>}
 */
async function migrateRulesetFromLegacy(repos, cm = null, onProgress = null)
{
  if (typeof onProgress !== 'function' && typeof cm?._reportMigrationProgress === 'function') {
    onProgress = cm._reportMigrationProgress
  }
  let meta = await repos.meta.get()
  if (meta?.rulesetMigrated) {
    return {skipped: true}
  }

  await reportMigrationProgress(onProgress, {
    phase: 'ruleset-migration',
    label: 'Migrating tag rules…',
    detail: 'Preparing ruleset fields',
    indeterminate: true,
  })

  let imported = 0
  let fieldSpecs = resolveRulesetMigrationSpecs(cm)
  for (let spec of fieldSpecs) {
    await repos.rulesetFields.upsert({
      fieldKey: spec.fieldKey,
      templateId: spec.templateId,
      templateConfig: spec.templateConfig,
      config: spec.config ?? {},
    })
  }

  let existingByField = new Map()
  for (let spec of fieldSpecs) {
    let rows = await repos.rulesetEntries.listAllForField(spec.fieldKey)
    existingByField.set(spec.fieldKey, new Set(rows.map((row) => row.rawLine)))
  }

  let addImportedRow = async (fieldKey, templateId, payload, rawLine, comment = '', sortOrder = null) => {
    let seen = existingByField.get(fieldKey)
    if (seen?.has(rawLine)) {
      return
    }
    if (await rulesetRowConflictsWithOtherField(repos.rulesetEntries, fieldKey, payload, {logSkip: true, repos})) {
      return
    }
    let mainEntry = await repos.rulesetFields.get(fieldKey)
    let template = RulesetTemplateRegistry.get(templateId)
    let ctx = buildRulesetContext(mainEntry, repos)
    await template.persist(payload, ctx, {
      sortOrder: sortOrder ?? Date.now() + imported,
      comment,
    })
    seen?.add(rawLine)
    imported++
  }

  let defaultTagsField = await repos.settings.getField('default-tags')
  let defaultLines = defaultTagsField?.value
  let defaultCtx = buildRulesetContext(await repos.rulesetFields.get('default-tags'), repos)
  if (Array.isArray(defaultLines)) {
    let template = RulesetTemplateRegistry.get('default-tag')
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-migration',
      label: 'Migrating tag rules…',
      detail: 'Importing default tags',
      indeterminate: true,
    })
    for (let rawLine of defaultLines) {
      let parsed = template.parseImportLine(String(rawLine), defaultCtx)
      if (!parsed) {
        continue
      }
      let formatted = template.formatForUI(parsed.payload)
      await addImportedRow('default-tags', 'default-tag', parsed.payload, formatted.rawLine, parsed.comment)
    }
  }

  let tagTotal = await repos.storage.count(IDB_STORE_TAGS)
  let tagsProcessed = 0
  let startAfter = null
  let chunkSize = 100
  while (true) {
    let page = await repos.storage.cursorPage(IDB_STORE_TAGS, null, chunkSize, startAfter)
    if (!page.entries.length) {
      break
    }
    for (let entry of page.entries) {
      normalizeTagEntry(entry)
      let hadLegacy = !!(entry.compliance?.blacklisted || entry.compliance?.explored ||
          entry.download?.filenameIgnored || entry.download?.substitution)
      if (entry.compliance?.blacklisted) {
        await addImportedRow('tag-blacklist', 'tag-blacklist', {
          variant: 'sole',
          tagEntryId: entry.entryId,
          tagName: entry.name,
        }, entry.name)
      }
      if (entry.compliance?.explored) {
        await addImportedRow('explored-tags-tracker', 'explored-tags', {
          variant: 'sole',
          tagEntryId: entry.entryId,
          tagName: entry.name,
        }, entry.name)
      }
      if (entry.download?.filenameIgnored) {
        await addImportedRow('filename-tag-ignore-list', 'tag-sole-ignore', {
          tagEntryId: entry.entryId,
          tagName: entry.name,
        }, entry.name)
      }
      if (entry.download?.substitution) {
        let sub = entry.download.substitution
        let replacementName = sub.replacementName ?? ''
        let rawLine = entry.name + ' → ' + replacementName
        await addImportedRow('filename-tag-substitutions', 'substitution', {
          subjectTagEntryId: entry.entryId,
          subjectName: entry.name,
          replacementTagEntryId: sub.replacementTagEntryId ?? null,
          replacementName,
        }, rawLine)
      }
      if (hadLegacy) {
        if (entry.compliance) {
          entry.compliance.blacklisted = false
          entry.compliance.explored = false
        }
        if (entry.download) {
          entry.download.filenameIgnored = false
          entry.download.substitution = null
        }
        await repos.tags.putTag(entry)
      }
      tagsProcessed++
    }
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-migration',
      label: 'Migrating tag rules…',
      detail: 'Scanning tag registry',
      current: tagsProcessed,
      total: tagTotal || tagsProcessed || 1,
    })
    if (!page.nextCursor) {
      break
    }
    startAfter = page.nextCursor
    await yieldToBrowser()
  }

  let comboImports = [
    {fieldKey: 'tag-blacklist', templateId: 'tag-blacklist', group: 'blacklist'},
    {fieldKey: 'explored-tags-tracker', templateId: 'explored-tags', group: 'explored'},
  ]
  await reportMigrationProgress(onProgress, {
    phase: 'ruleset-migration',
    label: 'Migrating tag rules…',
    detail: 'Importing combo rules',
    indeterminate: true,
  })
  /** @type {Map<string, object[]>} */
  let rulesByGroup = new Map()
  try {
    let rulesStartAfter = null
    while (true) {
      let rulesPage = await repos.storage.cursorPage('tagRules', null, 200, rulesStartAfter)
      for (let rule of rulesPage.entries) {
        let group = rule?.group
        if (!group) {
          continue
        }
        if (!rulesByGroup.has(group)) {
          rulesByGroup.set(group, [])
        }
        rulesByGroup.get(group).push(rule)
      }
      if (!rulesPage.nextCursor) {
        break
      }
      rulesStartAfter = rulesPage.nextCursor
      await yieldToBrowser()
    }
  } catch (error) {
    rulesByGroup = new Map()
  }
  for (let comboSpec of comboImports) {
    let rules = rulesByGroup.get(comboSpec.group) ?? []
    for (let rule of rules) {
      let tagEntryIds = rule.expression?.tagEntryIds
      if (!Array.isArray(tagEntryIds) || tagEntryIds.length < 2) {
        continue
      }
      let tagNames = []
      for (let entryId of tagEntryIds) {
        let tag = await repos.storage.get(IDB_STORE_TAGS, entryId)
        if (tag?.name) {
          tagNames.push(tag.name)
        }
      }
      let rawLine = rule.rawLine?.split('//')[0]?.trim() || tagNames.join(' & ')
      await addImportedRow(comboSpec.fieldKey, comboSpec.templateId, {
        variant: 'combo',
        tagEntryIds: [...tagEntryIds],
        tagNames,
        rawLine,
      }, rawLine, rule.comment ?? '')
    }
  }

  let compileSpecs = fieldSpecs
  for (let index = 0; index < compileSpecs.length; index++) {
    let spec = compileSpecs[index]
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-compile',
      label: 'Compiling rules…',
      detail: spec.fieldKey,
      current: index,
      total: compileSpecs.length,
    })
    await repos.rulesetFields.getCompiledField(spec.fieldKey, repos)
  }
  await repos.tagRuntime?.refreshDownloadRulesetMaps?.()

  for (let spec of fieldSpecs) {
    let settingsField = await repos.settings.getField(spec.fieldKey)
    let settingsValue = settingsField?.value
    let settingsHadLines = Array.isArray(settingsValue) ? settingsValue.length > 0 : !!settingsValue
    let rowCount = (await repos.rulesetEntries.listAllForField(spec.fieldKey)).length
    if (settingsHadLines && rowCount === 0) {
      console.log('[migrateRulesetFromLegacy] keeping settings backup for', spec.fieldKey,
          '(ruleset import produced no rows)')
      continue
    }
    await repos.settings.deleteField(spec.fieldKey)
  }

  if (typeof repairRulesetFieldsFromLegacyIfEmpty === 'function') {
    await repairRulesetFieldsFromLegacyIfEmpty(repos, cm)
  }

  meta = await repos.meta.get() ?? await repos.storage.createDefaultMeta()
  meta.rulesetMigrated = true
  await repos.meta.put(meta)
  await repos.meta.bumpRevision()
  await reportMigrationProgress(onProgress, {
    phase: 'ruleset-migration',
    label: 'Tag rules migrated',
    current: 1,
    total: 1,
  })
  return {imported}
}

/**
 * Migrate legacy bookmarks store rows into rulesetEntries under the bookmarks field.
 * @param {BrazenStorageRepositories} repos
 * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
 * @return {Promise<{skipped?: boolean, imported?: number}>}
 */
async function migrateBookmarksToRuleset(repos, onProgress = null)
{
  let meta = await repos.meta.get()
  if (meta?.bookmarksMigrated) {
    return {skipped: true}
  }

  await reportMigrationProgress(onProgress, {
    phase: 'bookmarks-migration',
    label: 'Migrating bookmarks…',
    detail: 'Preparing bookmarks field',
    indeterminate: true,
  })

  let fieldKey = 'bookmarks'
  await repos.rulesetFields.upsert({
    fieldKey,
    templateId: 'bookmarks',
    templateConfig: {},
    config: rulesetUserConfigDefaults(),
  })

  let bookmarkRows = await repos.storage.getAll(IDB_STORE_BOOKMARKS)
  bookmarkRows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
  let imported = 0
  for (let bookmark of bookmarkRows) {
    let payload = {
      label: bookmark.label ?? '',
      tags: bookmark.tags ?? '',
      url: bookmark.url ?? '',
    }
    await repos.rulesetEntries.add(fieldKey, {
      entryId: bookmark.entryId,
      sortOrder: bookmark.sortOrder ?? imported,
      payload,
      rawLine: payload.label || payload.tags,
      comment: '',
    })
    imported++
  }

  meta = await repos.meta.get() ?? await repos.storage.createDefaultMeta()
  meta.bookmarksMigrated = true
  await repos.meta.put(meta)
  await repos.meta.bumpRevision()
  await reportMigrationProgress(onProgress, {
    phase: 'bookmarks-migration',
    label: 'Bookmarks migrated',
    current: 1,
    total: 1,
  })
  return {imported}
}

/**
 * One-time post-open ruleset user-config migrations (schema v9 defaults + v10 hideTagTypes reset).
 * Single pass over `rulesetFields` when either pending flag is set (CM calls v9 then v10 sequentially).
 * @param {BrazenStorageRepositories} repos
 * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
 * @return {Promise<{ranV9: boolean, ranV10: boolean}>}
 */
async function migrateRulesetUserConfigPostOpen(repos, onProgress = null)
{
  let meta = await repos.meta.get()
  let runV9 = !!meta?.pendingRulesetConfigDefaultsV9
  let runV10 = !!meta?.pendingRulesetHideTagTypesResetV10
  if (!runV9 && !runV10) {
    return {ranV9: false, ranV10: false}
  }

  if (runV9) {
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-config-v9',
      label: 'Applying ruleset configuration defaults…',
      indeterminate: true,
    })
  } else {
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-hidetagtypes-v10',
      label: 'Restoring ruleset tag colors…',
      indeterminate: true,
    })
  }

  let keys = rulesetUserConfigKeys()
  let defaults = rulesetUserConfigDefaults()
  let rows = await repos.storage.getAll(IDB_STORE_RULESET_FIELDS)
  let changed = false
  for (let row of rows) {
    let config = {...(row.config ?? {})}
    let rowChanged = false
    if (runV9) {
      for (let key of keys) {
        if (config[key] === undefined && defaults[key] !== undefined) {
          config[key] = defaults[key]
          rowChanged = true
        }
      }
    }
    if (runV10 && config.hideTagTypes !== false) {
      config.hideTagTypes = false
      rowChanged = true
    }
    if (rowChanged) {
      await repos.storage.put(IDB_STORE_RULESET_FIELDS, {
        ...row,
        config,
        updatedAt: Date.now(),
      })
      changed = true
    }
  }

  meta = await repos.meta.get()
  if (meta) {
    if (runV9) {
      meta.pendingRulesetConfigDefaultsV9 = false
    }
    if (runV10) {
      meta.pendingRulesetHideTagTypesResetV10 = false
    }
    await repos.storage.put(IDB_STORE_META, meta)
  }
  if (changed) {
    await repos.meta.bumpRevision()
  }

  if (runV9) {
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-config-v9',
      label: 'Ruleset configuration defaults applied',
      current: 1,
      total: 1,
    })
  }
  if (runV10) {
    await reportMigrationProgress(onProgress, {
      phase: 'ruleset-hidetagtypes-v10',
      label: 'Ruleset tag colors restored',
      current: 1,
      total: 1,
    })
  }
  return {ranV9: runV9, ranV10: runV10}
}

/**
 * One-time post-open migration: backfill missing ruleset user-config keys on existing rows.
 * @param {BrazenStorageRepositories} repos
 * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
 * @return {Promise<boolean>} True when migration ran.
 */
async function migrateRulesetConfigDefaultsV9(repos, onProgress = null)
{
  let result = await migrateRulesetUserConfigPostOpen(repos, onProgress)
  return result.ranV9
}

/**
 * One-time post-open correction: tag-type colors are native ruleset behavior, so reset any
 * `hideTagTypes` values that a prior default wrote as `true` back to `false` across all ruleset
 * rows. Bookmarks are not special-cased — every ruleset gets the same colors-on default.
 * @param {BrazenStorageRepositories} repos
 * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
 * @return {Promise<boolean>} True when migration ran.
 */
async function migrateRulesetHideTagTypesV10(repos, onProgress = null)
{
  let result = await migrateRulesetUserConfigPostOpen(repos, onProgress)
  return result.ranV10
}

/**
 * @param {*} row
 * @return {*}
 */
function normalizeTagEntry(row)
{
  if (!row) {
    return row
  }
  let now = Date.now()
  row.meta = row.meta ?? {createdAt: now, updatedAt: now}
  if (row.typeEntryId === undefined) {
    row.typeEntryId = null
  }
  if (row.isDiscovered === undefined) {
    row.isDiscovered = null
  }
  return row
}

/**
 * @param {string} rawLine
 * @return {boolean}
 */
function isComboTagLine(rawLine)
{
  let rule = String(rawLine).split('//')[0].trim()
  return rule.includes('&') && !rule.includes('→') && !rule.includes('->')
}

/**
 * @param {string} rawLine
 * @return {{subject: string, replacement: string}|null}
 */
function parseSubstitutionTagLine(rawLine)
{
  let line = String(rawLine).split('//')[0].trim()
  let match = line.match(/^(.+?)\s*(?:→|->)\s*(.+)$/)
  if (!match) {
    return null
  }
  let subject = match[1].trim()
  let replacement = match[2].trim()
  if (!subject || !replacement) {
    return null
  }
  return {subject, replacement}
}

function dbNameFromScriptPrefix(scriptPrefix)
{
  return String(scriptPrefix).replace(/-$/, '')
}

function fieldKeyToProperty(fieldKey)
{
  return String(fieldKey).replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase())
}

function yieldToBrowser()
{
  return new Promise((resolve) => setTimeout(resolve, 0))
}

/**
 * @typedef {{phase: string, label: string, detail?: string, current?: number, total?: number, indeterminate?: boolean}} MigrationProgress
 */

/**
 * @param {MigrationProgress|string|null|undefined} progress
 * @return {MigrationProgress}
 */
function normalizeMigrationProgress(progress)
{
  if (typeof progress === 'string') {
    return {phase: 'generic', label: progress, indeterminate: true}
  }
  if (!progress || typeof progress !== 'object') {
    return {phase: 'generic', label: 'Updating database…', indeterminate: true}
  }
  return progress
}

/**
 * @param {function(MigrationProgress|string): void|Promise<void>|null|undefined} onProgress
 * @param {MigrationProgress|string} progress
 * @return {Promise<void>}
 */
async function reportMigrationProgress(onProgress, progress)
{
  if (typeof onProgress !== 'function') {
    return
  }
  await onProgress(normalizeMigrationProgress(progress))
}

const BACKUP_JSON_PART_SIZE = 500

/**
 * Merge single-file or `.partNNNN.json` chunked backup payloads.
 * @param {Record<string, string>} files
 * @param {string} storeName e.g. `tags`
 * @return {*[]}
 */
function collectBackupJsonArrayFromZipFiles(files, storeName)
{
  let singleKey = storeName + '.json'
  if (files[singleKey] != null) {
    return JSON.parse(files[singleKey] ?? '[]')
  }
  let partPrefix = storeName + '.part'
  let partKeys = Object.keys(files).
      filter((name) => name.startsWith(partPrefix) && name.endsWith('.json')).
      sort()
  if (!partKeys.length) {
    return []
  }
  let rows = []
  for (let key of partKeys) {
    rows.push(...JSON.parse(files[key] ?? '[]'))
  }
  return rows
}

/**
 * Export a row store as multiple small JSON part files (bounded peak memory).
 * @param {BrazenZipWriter} zip
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} storeName
 * @param {number} chunkSize
 * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
 * @param {{phase?: string, label?: string}} [options]
 * @return {Promise<number>} Part count (0 when store empty)
 */
async function addStoreJsonPartsToZip(zip, storage, storeName, chunkSize = BACKUP_JSON_PART_SIZE,
    onProgress = null, options = null)
{
  let total = await storage.count(storeName)
  if (!total) {
    return 0
  }
  let phase = options?.phase ?? 'safety-backup'
  let label = options?.label ?? 'Creating safety backup…'
  let part = 0
  let processed = 0
  let startAfter = null
  while (true) {
    let page = await storage.cursorPage(storeName, null, chunkSize, startAfter)
    if (!page.entries.length) {
      break
    }
    zip.addFile(
        storeName + '.part' + String(part).padStart(4, '0') + '.json',
        JSON.stringify(page.entries),
    )
    processed += page.entries.length
    part++
    await reportMigrationProgress(onProgress, {
      phase,
      label,
      detail: storeName + ' (' + processed + '/' + total + ')',
      current: processed,
      total,
    })
    if (!page.nextCursor) {
      break
    }
    startAfter = page.nextCursor
    await yieldToBrowser()
  }
  return part
}

function promisifyRequest(request)
{
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

function waitTransaction(tx)
{
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve()
    tx.onerror = () => reject(tx.error)
    tx.onabort = () => reject(tx.error || new Error('Transaction aborted'))
  })
}

class BrazenIndexedDBStorage
{
  constructor(scriptPrefix)
  {
    this._scriptPrefix = scriptPrefix
    this._dbName = dbNameFromScriptPrefix(scriptPrefix)
    this._db = null
    /** @type {Promise<IDBDatabase>|null} Coalesces concurrent open() after close(). */
    this._openPromise = null
    this._available = typeof indexedDB !== 'undefined'
    this._revisionId = null
  }

  get available()
  {
    return this._available
  }

  get dbName()
  {
    return this._dbName
  }

  get database()
  {
    return this._db
  }

  /**
   * @return {Promise<IDBDatabase>}
   */
  async open()
  {
    if (!this._available) {
      throw new Error('IndexedDB is not available')
    }
    if (this._db) {
      return this._db
    }
    if (this._openPromise) {
      return this._openPromise
    }

    this._openPromise = new Promise((resolve, reject) => {
      let request = indexedDB.open(this._dbName, IDB_SCHEMA_VERSION)
      request.onupgradeneeded = (event) => {
        let db = event.target.result
        BrazenIndexedDBStorage._upgradeSchema(db, event.oldVersion, event.target.transaction)
      }
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    }).then((db) => {
      this._db = db
      this._db.onversionchange = () => {
        this.close()
      }
      return this._db
    }).finally(() => {
      this._openPromise = null
    })

    return this._openPromise
  }

  /**
   * Drop the open connection so a navigating / bfcache'd document cannot pin the DB
   * against the next page. Callers reopen via {@link open} (async helpers do this).
   */
  close()
  {
    if (!this._db) {
      return
    }
    try {
      this._db.close()
    } catch (e) {
      // ignore already-closed
    }
    this._db = null
  }

  /**
   * @param {IDBDatabase} db
   * @param {number} oldVersion
   * @param {IDBTransaction|null} [upgradeTx]
   * @private
   */
  static _upgradeSchema(db, oldVersion, upgradeTx = null)
  {
    if (!db.objectStoreNames.contains(IDB_STORE_META)) {
      db.createObjectStore(IDB_STORE_META, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_SETTINGS)) {
      db.createObjectStore(IDB_STORE_SETTINGS, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_APIS)) {
      db.createObjectStore(IDB_STORE_APIS, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_TAG_TYPES)) {
      db.createObjectStore(IDB_STORE_TAG_TYPES, {keyPath: 'id'})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_TAGS)) {
      let tags = db.createObjectStore(IDB_STORE_TAGS, {keyPath: 'entryId'})
      tags.createIndex('name', 'name', {unique: true})
      tags.createIndex('typeEntryId_name', ['typeEntryId', 'name'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_BOOKMARKS)) {
      let bookmarks = db.createObjectStore(IDB_STORE_BOOKMARKS, {keyPath: 'entryId'})
      bookmarks.createIndex('sortOrder', 'sortOrder', {unique: false})
      bookmarks.createIndex('label', 'label', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_LEDGER)) {
      let ledger = db.createObjectStore(IDB_STORE_LEDGER, {keyPath: 'entryId'})
      ledger.createIndex('postId', 'postId', {unique: true})
      ledger.createIndex('claimedAt', 'claimedAt', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)) {
      let resolutionQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, {keyPath: 'itemId'})
      resolutionQueue.createIndex('status', 'status', {unique: false})
      resolutionQueue.createIndex('addedAt', 'addedAt', {unique: false})
      resolutionQueue.createIndex('status_addedAt', ['status', 'addedAt'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_QUEUE)) {
      let downloadQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_QUEUE, {keyPath: 'itemId'})
      downloadQueue.createIndex('status', 'status', {unique: false})
      downloadQueue.createIndex('addedAt', 'addedAt', {unique: false})
      downloadQueue.createIndex('status_addedAt', ['status', 'addedAt'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_MANAGER_STATE)) {
      db.createObjectStore(IDB_STORE_DOWNLOAD_MANAGER_STATE, {keyPath: 'id'})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_RULESET_FIELDS)) {
      db.createObjectStore(IDB_STORE_RULESET_FIELDS, {keyPath: 'fieldKey'})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_RULESET_ENTRIES)) {
      let rulesetEntries = db.createObjectStore(IDB_STORE_RULESET_ENTRIES, {keyPath: 'entryId'})
      rulesetEntries.createIndex('fieldKey_sortOrder', ['fieldKey', 'sortOrder'], {unique: false})
      rulesetEntries.createIndex('fieldKey_entryId', ['fieldKey', 'entryId'], {unique: false})
      rulesetEntries.createIndex('fieldKey_groupLabel', ['fieldKey', 'groupLabel'], {unique: false})
      rulesetEntries.createIndex('fieldKey_rawLine', ['fieldKey', 'rawLine'], {unique: false})
    }

    if (oldVersion < 12 && upgradeTx && db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_MANAGER_STATE)) {
      let stateStore = upgradeTx.objectStore(IDB_STORE_DOWNLOAD_MANAGER_STATE)
      let stateReq = stateStore.get('state')
      stateReq.onsuccess = () => {
        let row = stateReq.result
        if (row) {
          stateStore.put(migrateDownloadManagerState_v12(row))
        }
      }
    }

    // Legacy upgrade path (oldVersion < 11): indexes, meta flags, store drops in one block.
    if (oldVersion < 11 && upgradeTx) {
      if (oldVersion < 4) {
        for (let storeName of [IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, IDB_STORE_DOWNLOAD_QUEUE]) {
          if (!db.objectStoreNames.contains(storeName)) {
            continue
          }
          let store = upgradeTx.objectStore(storeName)
          if (!store.indexNames.contains('status_addedAt')) {
            store.createIndex('status_addedAt', ['status', 'addedAt'], {unique: false})
          }
        }
      }

      if (oldVersion < 7) {
        if (db.objectStoreNames.contains('tagRules')) {
          db.deleteObjectStore('tagRules')
        }
        if (db.objectStoreNames.contains('tagRuleSets')) {
          db.deleteObjectStore('tagRuleSets')
        }
      }

      if (db.objectStoreNames.contains(IDB_STORE_RULESET_ENTRIES)) {
        let store = upgradeTx.objectStore(IDB_STORE_RULESET_ENTRIES)
        if (!store.indexNames.contains('fieldKey_rawLine')) {
          store.createIndex('fieldKey_rawLine', ['fieldKey', 'rawLine'], {unique: false})
        }
      }

      if (db.objectStoreNames.contains(IDB_STORE_META)) {
        let metaStore = upgradeTx.objectStore(IDB_STORE_META)
        let getReq = metaStore.get('meta')
        getReq.onsuccess = () => {
          let meta = getReq.result
          if (!meta) {
            return
          }
          if (oldVersion < 5) {
            meta.pendingIsDiscoveredBackfill = true
          }
          if (oldVersion < 9) {
            meta.pendingRulesetConfigDefaultsV9 = true
          }
          if (oldVersion < 10) {
            meta.pendingRulesetHideTagTypesResetV10 = true
          }
          metaStore.put(meta)
        }
      }
    }
  }

  /**
   * @param {DOMException|Error|null|undefined} error
   * @return {boolean}
   */
  static isVersionError(error)
  {
    return !!error && (error.name === 'VersionError' || error.code === 12)
  }

  /**
   * Installed IndexedDB version for this database (0 when absent).
   * Prefers `indexedDB.databases()` so probing never creates a database.
   * @return {Promise<number>}
   */
  async getInstalledSchemaVersion()
  {
    if (!this._available) {
      return 0
    }
    if (typeof indexedDB.databases === 'function') {
      let dbs = await indexedDB.databases()
      let entry = dbs.find((db) => db.name === this._dbName)
      return entry?.version ?? 0
    }
    if (this._db) {
      return this._db.version
    }
    return new Promise((resolve, reject) => {
      let request = indexedDB.open(this._dbName, IDB_SCHEMA_VERSION)
      request.onsuccess = () => {
        request.result.close()
        resolve(0)
      }
      request.onerror = () => {
        let error = request.error
        if (!BrazenIndexedDBStorage.isVersionError(error)) {
          reject(error ?? new Error('IndexedDB open failed'))
          return
        }
        let probe = indexedDB.open(this._dbName)
        probe.onsuccess = () => {
          let version = probe.result.version
          probe.result.close()
          resolve(version)
        }
        probe.onerror = () => reject(probe.error ?? new Error('IndexedDB open failed'))
      }
    })
  }

  /**
   * When the installed database schema is newer than {@link IDB_SCHEMA_VERSION}.
   * @return {Promise<{installed: number, supported: number}|null>}
   */
  async getSchemaVersionConflict()
  {
    if (!this._available) {
      return null
    }
    let installed = await this.getInstalledSchemaVersion()
    if (installed > IDB_SCHEMA_VERSION) {
      return {installed, supported: IDB_SCHEMA_VERSION}
    }
    return null
  }

  /**
   * @return {Promise<boolean>}
   */
  async isHealthy()
  {
    try {
      await this.open()
      for (let storeName of IDB_ALL_STORES) {
        if (!this._db.objectStoreNames.contains(storeName)) {
          return false
        }
      }
      let meta = await this.get(IDB_STORE_META, 'meta')
      if (!meta || meta.setupComplete !== true) {
        return false
      }
      for (let key of ['nextTagEntryId', 'nextRulesetEntryId', 'nextBookmarkEntryId', 'nextLedgerEntryId']) {
        if (typeof meta[key] !== 'number' || meta[key] < 1) {
          return false
        }
      }
      let settings = await this.get(IDB_STORE_SETTINGS, 'settings')
      if (!settings) {
        return false
      }
      return true
    } catch (error) {
      console.log('[BrazenIDB] health check failed:', error)
      return false
    }
  }

  /**
   * @return {Promise<void>}
   */
  async deleteDatabase()
  {
    this.close()
    if (!this._available) {
      return
    }
    // Do not resolve on `onblocked` — that fires while other connections (or this tab's
    // just-closed handle) are still releasing. Resolving early + reload aborts the delete.
    await new Promise((resolve, reject) => {
      let request = indexedDB.deleteDatabase(this._dbName)
      request.onsuccess = () => resolve()
      request.onerror = () => reject(request.error ?? new Error('IndexedDB deleteDatabase failed'))
      request.onblocked = () => {
        console.warn('[BrazenIDB] deleteDatabase blocked; waiting for connections to close:', this._dbName)
      }
    })
  }

  /**
   * @param {string[]} storeNames
   * @param {IDBTransactionMode} mode
   * @return {IDBTransaction}
   */
  transaction(storeNames, mode = 'readonly')
  {
    if (!this._db) {
      throw new Error('IndexedDB is closed; call open() first')
    }
    return this._db.transaction(storeNames, mode)
  }

  /**
   * @param {string} store
   * @param {*} pk
   * @return {Promise<*>}
   */
  async get(store, pk)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).get(pk))
  }

  /**
   * @param {string} store
   * @return {Promise<*[]>}
   */
  async getAll(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).getAll())
  }

  /**
   * @param {string} store
   * @return {Promise<number>}
   */
  async count(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).count())
  }

  /**
   * @return {Promise<number>}
   */
  async countLedgerEntries()
  {
    return this.count(IDB_STORE_LEDGER)
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<number>}
   */
  async countByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).count(IDBKeyRange.only(key)))
  }

  /**
   * @param {string} store
   * @return {Promise<*[]>}
   */
  async getAllKeys(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).getAllKeys())
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<*[]>}
   */
  async getAllKeysByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).getAllKeys(IDBKeyRange.only(key)))
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<*[]>}
   */
  async getAllByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).getAll(IDBKeyRange.only(key)))
  }

  /**
   * First row on an index matching `predicate`, walking in index order.
   * @param {string} store
   * @param {string} indexName
   * @param {IDBKeyRange|null|undefined} range
   * @param {function(*): boolean} predicate
   * @return {Promise<*|null>}
   */
  async findFirstByIndex(store, indexName, range, predicate)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    let index = tx.objectStore(store).index(indexName)
    // Do not pass `null` into openCursor — some environments treat it as an invalid key.
    let request = range == null ? index.openCursor() : index.openCursor(range)
    return new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor) {
          resolve(null)
          return
        }
        if (predicate(cursor.value)) {
          resolve(cursor.value)
          return
        }
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })
  }

  /**
   * @param {string} store
   * @param {*} record
   * @return {Promise<void>}
   */
  async put(store, record)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).put(record)
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @param {*} pk
   * @return {Promise<void>}
   */
  async delete(store, pk)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).delete(pk)
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @return {Promise<void>}
   */
  async clearStore(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).clear()
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @param {*[]} records
   * @param {number} chunkSize
   * @return {Promise<void>}
   */
  async putMany(store, records, chunkSize = IDB_PUT_MANY_CHUNK)
  {
    if (!records.length) {
      return
    }
    await this.open()
    for (let index = 0; index < records.length; index += chunkSize) {
      let chunk = records.slice(index, index + chunkSize)
      let tx = this.transaction([store], 'readwrite')
      let objectStore = tx.objectStore(store)
      for (let record of chunk) {
        objectStore.put(record)
      }
      await waitTransaction(tx)
      await yieldToBrowser()
    }
  }

  /**
   * @param {string} store
   * @param {IDBKeyRange|null} range
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: *}>}
   */
  async cursorPage(store, range = null, limit = 50, startAfter = null)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    let objectStore = tx.objectStore(store)
    let effectiveRange = range
    if (startAfter !== null && startAfter !== undefined) {
      effectiveRange = range ?
          IDBKeyRange.bound(range.lower, range.upper, range.lowerOpen, range.upperOpen) :
          IDBKeyRange.lowerBound(startAfter, true)
    }
    let request = objectStore.openCursor(effectiveRange)
    let entries = []
    let nextCursor = null

    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor || entries.length >= limit) {
          if (cursor) {
            nextCursor = cursor.key
          }
          resolve()
          return
        }
        entries.push(cursor.value)
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })

    return {entries, nextCursor}
  }

  /**
   * @param {string} store
   * @param {number} chunkSize
   * @return {Promise<*[]>}
   */
  async getAllChunked(store, chunkSize = IDB_PUT_MANY_CHUNK)
  {
    let entries = []
    let startAfter = null
    while (true) {
      let page = await this.cursorPage(store, null, chunkSize, startAfter)
      entries.push(...page.entries)
      if (!page.nextCursor) {
        break
      }
      startAfter = page.nextCursor
      await yieldToBrowser()
    }
    return entries
  }

  /**
   * @return {Promise<number>}
   */
  async getRevisionId()
  {
    let meta = await this.getMeta()
    return meta?.revisionId ?? 0
  }

  /**
   * @return {Promise<object>}
   */
  async getMeta()
  {
    return (await this.get(IDB_STORE_META, 'meta')) ?? null
  }

  /**
   * @param {Function} fn
   * @return {Promise<*>}
   */
  async guardedWrite(fn)
  {
    let revisionBefore = await this.getRevisionId()
    let result = await fn()
    let revisionAfter = await this.getRevisionId()
    if (revisionBefore !== revisionAfter) {
      this._revisionId = revisionAfter
    }
    return result
  }

  /**
   * @return {Promise<object>}
   */
  async createDefaultMeta()
  {
    return {
      id: 'meta',
      revisionId: Utilities.generateId(),
      domainConfigSeq: 0,
      domainTagsSeq: 0,
      domainLedgerSeq: 0,
      schemaVersion: IDB_SCHEMA_VERSION,
      scriptPrefix: this._scriptPrefix,
      setupComplete: false,
      setupInProgress: false,
      pendingIsDiscoveredBackfill: false,
      pendingRulesetConfigDefaultsV9: false,
      pendingRulesetHideTagTypesResetV10: false,
      nextTagEntryId: 1,
      nextRulesetEntryId: 1,
      nextBookmarkEntryId: 1,
      nextLedgerEntryId: 1,
      rulesetMigrated: false,
      bookmarksMigrated: false,
      migrationSources: [],
      setupStartedAt: null,
      migrationSafetyBackupAt: null,
    }
  }

  /**
   * Chunked post-open backfill for {@link TagEntry.isDiscovered} when meta.pendingIsDiscoveredBackfill.
   * Awaits `onProgress` before the first page; yields between pages so migration UI can paint/animate.
   * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
   * @return {Promise<boolean>} True when a pending backfill ran.
   */
  async runPendingIsDiscoveredBackfill(onProgress = null)
  {
    await this.open()
    let meta = await this.getMeta()
    if (!meta?.pendingIsDiscoveredBackfill) {
      return false
    }
    let tagTotal = await this.count(IDB_STORE_TAGS)
    let tagsProcessed = 0
    await reportMigrationProgress(onProgress, {
      phase: 'tag-backfill',
      label: 'Updating tag discovery flags…',
      current: 0,
      total: tagTotal || 1,
    })
    let startAfter = null
    let chunkSize = 100
    while (true) {
      let page = await this.cursorPage(IDB_STORE_TAGS, null, chunkSize, startAfter)
      if (!page.entries.length) {
        break
      }
      let toPut = []
      for (let row of page.entries) {
        normalizeTagEntry(row)
        // Legacy typed rows → discovered; untyped stay null.
        let next = row.typeEntryId != null ? true : null
        if (row.isDiscovered !== next) {
          row.isDiscovered = next
          toPut.push(row)
        }
        tagsProcessed++
      }
      if (toPut.length) {
        await this.putMany(IDB_STORE_TAGS, toPut, chunkSize)
      }
      await reportMigrationProgress(onProgress, {
        phase: 'tag-backfill',
        label: 'Updating tag discovery flags…',
        current: Math.min(tagsProcessed, tagTotal || tagsProcessed),
        total: tagTotal || tagsProcessed || 1,
      })
      if (!page.nextCursor) {
        break
      }
      startAfter = page.nextCursor
      await yieldToBrowser()
    }
    meta = await this.getMeta()
    if (meta) {
      meta.pendingIsDiscoveredBackfill = false
      await this.put(IDB_STORE_META, meta)
    }
    return true
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptySettingsDocument()
  {
    return {id: 'settings'}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyApisDocument()
  {
    return {id: 'apis', entries: []}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyTagTypesDocument()
  {
    return {id: 'tagTypes', entries: []}
  }
}

class MetaRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {function(number|string, {source?: string}): void|null} [onRevisionBump]
   */
  constructor(storage, onRevisionBump = null)
  {
    this._storage = storage
    this._onRevisionBump = onRevisionBump
    /** @type {number} Nested batch depth for coalesced tags.revision bumps. */
    this._tagsRevisionBatchDepth = 0
    /** @type {boolean} Whether a deferred tags.revision bump is pending. */
    this._tagsRevisionBatchPending = false
  }

  /**
   * Defer {@link tags.revision} signal bumps until {@link endTagsRevisionBatch}.
   * @return {void}
   */
  beginTagsRevisionBatch()
  {
    this._tagsRevisionBatchDepth++
  }

  /**
   * Flush one coalesced tags.revision bump when the outermost batch closes.
   * @return {Promise<number|string>}
   */
  async endTagsRevisionBatch()
  {
    if (this._tagsRevisionBatchDepth > 0) {
      this._tagsRevisionBatchDepth--
    }
    if (this._tagsRevisionBatchDepth > 0 || !this._tagsRevisionBatchPending) {
      return this._storage._revisionId ?? 0
    }
    this._tagsRevisionBatchPending = false
    return await this.bumpRevision({tagsTouched: true, configTouched: false})
  }

  async get()
  {
    return this._storage.getMeta()
  }

  async put(meta)
  {
    await this._storage.put(IDB_STORE_META, meta)
  }

  /**
   * @param {{source?: string, tagsTouched?: boolean, configTouched?: boolean}} [options]
   *   Optional bump source (e.g. `'ledger'` — config tabs must not treat ledger-only bumps as a
   *   loaded config revision). `{tagsTouched:true}` without `configTouched` advances only
   *   `domainTagsSeq` (tag registry writes). Explicit `{configTouched:true}` with `{tagsTouched:true}`
   *   advances both domains.
   * @return {Promise<number|string>}
   */
  async bumpRevision(options = {})
  {
    if (this._tagsRevisionBatchDepth > 0 && options?.tagsTouched) {
      this._tagsRevisionBatchPending = true
      return this._storage._revisionId ?? 0
    }
    let meta = await this.get()
    if (!meta) {
      return 0
    }
    if (options?.source === 'ledger') {
      meta.domainLedgerSeq = (meta.domainLedgerSeq ?? 0) + 1
    } else {
      let configTouched = options?.configTouched
      if (options?.tagsTouched && configTouched === undefined) {
        configTouched = false
      }
      if (configTouched !== false) {
        meta.domainConfigSeq = (meta.domainConfigSeq ?? 0) + 1
      }
      if (options?.tagsTouched) {
        meta.domainTagsSeq = (meta.domainTagsSeq ?? 0) + 1
      }
    }
    meta.revisionId = Utilities.generateId()
    await this.put(meta)
    this._storage._revisionId = meta.revisionId
    if (this._onRevisionBump) {
      this._onRevisionBump(meta.revisionId, {
        ...options,
        domainConfigSeq: meta.domainConfigSeq ?? 0,
        domainTagsSeq: meta.domainTagsSeq ?? 0,
        domainLedgerSeq: meta.domainLedgerSeq ?? 0,
      })
    }
    return meta.revisionId
  }

  async waitForSetupComplete(timeoutMs = 120000)
  {
    let started = Date.now()
    while (Date.now() - started < timeoutMs) {
      let meta = await this.get()
      if (meta?.setupComplete && !meta?.setupInProgress) {
        return true
      }
      if (meta?.setupInProgress) {
        await Utilities.sleep(200)
        continue
      }
      return false
    }
    throw new Error('Timed out waiting for setup to complete')
  }

  async beginSetup()
  {
    let meta = await this.get() ?? await this._storage.createDefaultMeta()
    if (meta.setupInProgress) {
      let stalledMs = Date.now() - (meta.setupStartedAt ?? 0)
      if (meta.setupStartedAt && stalledMs > 5 * 60 * 1000) {
        console.warn('[BrazenIDB] Clearing stale setupInProgress lock after', stalledMs, 'ms')
        meta.setupInProgress = false
        await this.put(meta)
      } else {
        await this.waitForSetupComplete()
        return this.get()
      }
    }
    meta.setupInProgress = true
    meta.setupStartedAt = Date.now()
    await this.put(meta)
    return meta
  }

  async completeSetup(sources = [])
  {
    let meta = await this.get() ?? await this._storage.createDefaultMeta()
    meta.setupComplete = true
    meta.setupInProgress = false
    meta.setupStartedAt = null
    meta.migratedAt = Date.now()
    meta.migrationSources = sources
    await this.put(meta)
    return meta
  }

  async allocateEntryId(counterKey)
  {
    let meta = await this.get()
    if (!meta) {
      throw new Error('Meta record missing')
    }
    let value = meta[counterKey] ?? 1
    meta[counterKey] = value + 1
    await this.put(meta)
    return value
  }
}

class SettingsRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   */
  constructor(storage)
  {
    this._storage = storage
  }

  async getDocument()
  {
    return (await this._storage.get(IDB_STORE_SETTINGS, 'settings')) ?? {id: 'settings'}
  }

  async putDocument(doc)
  {
    doc.id = 'settings'
    await this._storage.put(IDB_STORE_SETTINGS, doc)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{value: *, optimized: *, updatedAt: number}|null>}
   */
  async getField(fieldKey)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    return doc[property] ?? null
  }

  async getValue(fieldKey)
  {
    let field = await this.getField(fieldKey)
    return field?.value ?? null
  }

  async getOptimized(fieldKey)
  {
    let field = await this.getField(fieldKey)
    return field?.optimized ?? null
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @param {*} optimized
   * @return {Promise<void>}
   */
  async putField(fieldKey, value, optimized = null)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    doc[property] = {
      value,
      optimized,
      updatedAt: Date.now(),
    }
    await this.putDocument(doc)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<void>}
   */
  async deleteField(fieldKey)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    if (property in doc) {
      delete doc[property]
      await this.putDocument(doc)
    }
  }
}

class BookmarkRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('bookmarks')
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(IDB_STORE_BOOKMARKS)
    return rows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
  }

  async get(entryId)
  {
    return this._storage.get(IDB_STORE_BOOKMARKS, entryId)
  }

  async add(entry)
  {
    let entryId = entry.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
    let now = Date.now()
    let row = {
      entryId,
      label: entry.label ?? '',
      tags: entry.tags ?? '',
      url: entry.url ?? '',
      sortOrder: entry.sortOrder ?? 0,
      createdAt: entry.createdAt ?? now,
      updatedAt: now,
    }
    await this._storage.put(IDB_STORE_BOOKMARKS, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return row
  }

  async update(row)
  {
    row.updatedAt = Date.now()
    await this._storage.put(IDB_STORE_BOOKMARKS, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return row
  }

  async remove(entryId)
  {
    await this._storage.delete(IDB_STORE_BOOKMARKS, entryId)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }

  async replaceAll(rows)
  {
    await this._storage.clearStore(IDB_STORE_BOOKMARKS)
    let sortOrder = 0
    let normalized = []
    for (let row of rows) {
      let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
      normalized.push({
        entryId,
        label: row.label ?? '',
        tags: row.tags ?? '',
        url: row.url ?? '',
        sortOrder: row.sortOrder ?? sortOrder++,
        createdAt: row.createdAt ?? Date.now(),
        updatedAt: row.updatedAt ?? Date.now(),
      })
    }
    await this._storage.putMany(IDB_STORE_BOOKMARKS, normalized)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }
}

class LedgerRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('ledger')
    }
  }

  async has(postId)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
    let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
    return !!(await promisifyRequest(index.get(String(postId))))
  }

  /**
   * Batch membership check via the unique `postId` index (no getAll).
   * @param {string[]} postIds
   * @return {Promise<Set<string>>} ids that exist in the ledger
   */
  async hasMany(postIds)
  {
    let unique = [...new Set(postIds.map((id) => String(id).trim()).filter(Boolean))]
    let hits = new Set()
    if (!unique.length) {
      return hits
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
    let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
    await Promise.all(unique.map(async (postId) => {
      let row = await promisifyRequest(index.get(postId))
      if (row) {
        hits.add(postId)
      }
    }))
    return hits
  }

  async claim(postId)
  {
    let normalized = String(postId).trim()
    if (!normalized) {
      return false
    }
    if (await this.has(normalized)) {
      return false
    }
    let entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
    await this._storage.put(IDB_STORE_LEDGER, {
      entryId,
      postId: normalized,
      claimedAt: Date.now(),
    })
    await this._metaRepo.bumpRevision({source: 'ledger'})
    this._notifyChange()
    return true
  }

  async mergeRows(rows, options = {})
  {
    await this._storage.open()
    for (let row of rows) {
      let postId = String(row.postId ?? row.id ?? '').trim()
      if (!postId) {
        continue
      }
      let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
      let existing = await promisifyRequest(tx.objectStore(IDB_STORE_LEDGER).index('postId').get(postId))
      if (!existing || (row.claimedAt ?? 0) >= (existing.claimedAt ?? 0)) {
        if (!row.entryId) {
          row.entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
        }
        row.postId = postId
        await this._storage.put(IDB_STORE_LEDGER, row)
      }
    }
    if (!options.deferRevision) {
      await this.commitMergeRows()
    }
  }

  async commitMergeRows()
  {
    await this._metaRepo.bumpRevision({source: 'ledger'})
    this._notifyChange()
  }

  /**
   * @return {Promise<number>}
   */
  async countLedgerEntries()
  {
    return this._storage.countLedgerEntries()
  }

  async replaceAll(rows)
  {
    await this._storage.clearStore(IDB_STORE_LEDGER)
    let normalized = []
    for (let row of rows) {
      let postId = String(row.postId ?? row.id ?? '').trim()
      if (!postId) {
        continue
      }
      let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextLedgerEntryId')
      normalized.push({
        entryId,
        postId,
        claimedAt: row.claimedAt ?? Date.now(),
      })
    }
    await this._storage.putMany(IDB_STORE_LEDGER, normalized)
    await this._metaRepo.bumpRevision({source: 'ledger'})
    this._notifyChange()
  }
}

/**
 * Drop obsolete processor-leadership fields (Web Locks coordinator replaces them).
 * @param {Record<string, unknown>|null|undefined} record
 * @return {Record<string, unknown>}
 */
function migrateDownloadManagerState_v12(record)
{
  if (!record || typeof record !== 'object') {
    return /** @type {Record<string, unknown>} */ (record ?? {})
  }
  let {processingTabId, processingHeartbeatAt, ...rest} = record
  return rest
}

/**
 * @return {object}
 */
function createDefaultDownloadManagerState()
{
  return {
    id: 'state',
    paused: true,
    resolutionBlocked: false,
    resolutionBlockedItemId: null,
    /**
     * Per-lane human-interaction blocks.
     * Each lane entry is `{ itemId, promptTabId, openUrl, at }` or `null`.
     */
    humanInteraction: {resolution: null, download: null},
    lastResolutionInitiationAt: 0,
    lastDownloadInitiationAt: 0,
    tagDiscoveryEnabled: false,
    tagDiscoveryPanelTabId: null,
    discoveryPanelTags: null,
    discoveryPanelKnownTags: null,
    /** `'unknown'` | `'ignoredPins'` | null — why the discovery panel is open. */
    discoveryReviewMode: null,
    /** When true, resolution processes `discoveryQueued` only (deferred tag discovery). */
    discoveryLanePhaseActive: false,
    completedResolutionCount: 0,
    completedDownloadCount: 0,
  }
}

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

/**
 * Single hydrate fold for humanInteraction (reactor-native v2 G10).
 * @param {object|null|undefined} record
 * @return {object}
 */
function normalizeHumanInteractionState(record)
{
  let state = migrateDownloadManagerState_v12(record)
  if (!state || typeof state !== 'object') {
    return createDefaultDownloadManagerState()
  }
  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
  }
  if (state.humanInteractionContext && (state.humanInteractionItemId || state.humanInteractionOpenUrl)) {
    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(),
      }
    }
  }
  if ('pendingImmediateDownload' in state) {
    let hadPending = !!state.pendingImmediateDownload
    delete state.pendingImmediateDownload
    if (hadPending && state.resolutionBlocked && !state.resolutionBlockedItemId) {
      clearDiscoveryReviewStateFields(state)
    }
  }
  if (!('discoveryLanePhaseActive' in state)) {
    state.discoveryLanePhaseActive = false
  }
  delete state.humanInteractionBlocked
  delete state.humanInteractionContext
  delete state.humanInteractionItemId
  delete state.humanInteractionPromptTabId
  delete state.humanInteractionOpenUrl
  state.id = 'state'
  return state
}

/**
 * True when raw IDB row still carries fields stripped by normalizeHumanInteractionState.
 * @param {object|null|undefined} raw
 * @return {boolean}
 */
function downloadManagerStateRawNeedsNormalizePersist(raw)
{
  if (!raw || typeof raw !== 'object') {
    return false
  }
  if ('pendingImmediateDownload' in raw || 'processingTabId' in raw || 'processingHeartbeatAt' in raw) {
    return true
  }
  if (raw.humanInteractionBlocked) {
    return true
  }
  for (let key of [
    'humanInteractionContext',
    'humanInteractionItemId',
    'humanInteractionPromptTabId',
    'humanInteractionOpenUrl',
  ]) {
    if (key in raw) {
      return true
    }
  }
  if (!('discoveryLanePhaseActive' in raw)) {
    return true
  }
  let map = raw.humanInteraction
  return map == null || typeof map !== 'object' || !('resolution' in map) || !('download' in map)
}

/**
 * Expand dm.state row into reactor-native atom paths.
 * @param {object} state
 * @return {Record<string, unknown>}
 */
function buildDmStateHydrateSnapshot(state)
{
  state = normalizeHumanInteractionState(state)
  /** @type {Record<string, unknown>} */
  let snapshot = {}
  for (let [key, value] of Object.entries(state)) {
    if (key === 'id') {
      continue
    }
    snapshot[`dm.state.${key}`] = structuredClone(value)
  }
  let sansId = structuredClone(state)
  delete sansId.id
  snapshot['dm.state.snapshot'] = sansId
  return snapshot
}

/** Normative command types handled by repos.kernelWriteThrough (v2 §5). */
const REACTOR_KERNEL_COMMAND_TYPES = Object.freeze([
  'enqueue-download',
  'dequeue-download',
  'toggle-paused',
  'clear-download-queue',
  'confirm-tag-discovery',
  'skip-tag-discovery',
  'clear-hi-lane',
  'claim-tag-discovery-panel',
  'claim-hi-prompt',
  'write-setting',
  'config-save',
  'config-sync',
])

/** Terminal resolution-queue statuses (must match Download Manager). */
const RESOLUTION_QUEUE_TERMINAL_STATUSES = ['failed', 'done']
const RESOLUTION_QUEUE_TERMINAL_SET = new Set(RESOLUTION_QUEUE_TERMINAL_STATUSES)
/** Non-terminal resolution-queue statuses used for key listing. */
const RESOLUTION_QUEUE_ACTIVE_STATUSES = ['queued', 'resolving', 'tagReview', 'discoveryQueued']
/** Terminal download-queue statuses (must match Download Manager). */
const DOWNLOAD_QUEUE_TERMINAL_STATUSES = ['done', 'skipped', 'duplicate', 'failed']
const DOWNLOAD_QUEUE_TERMINAL_SET = new Set(DOWNLOAD_QUEUE_TERMINAL_STATUSES)
/** Non-terminal download-queue statuses used for key listing. */
const DOWNLOAD_QUEUE_ACTIVE_STATUSES = ['queued', 'downloading']

/**
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} activeStatuses
 * @param {Set<string>} terminalSet
 * @return {Promise<number>}
 */
async function countActiveQueueRows(storage, store, activeStatuses, terminalSet)
{
  try {
    let active = 0
    for (let status of activeStatuses) {
      active += await storage.countByIndex(store, 'status', status)
    }
    return active
  } catch (error) {
    let rows = await storage.getAll(store)
    return rows.filter((row) => !terminalSet.has(row.status)).length
  }
}

/**
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} activeStatuses
 * @param {Set<string>} terminalSet
 * @return {Promise<string[]>}
 */
async function listActiveQueueItemIds(storage, store, activeStatuses, terminalSet)
{
  try {
    let ids = []
    for (let status of activeStatuses) {
      let keys = await storage.getAllKeysByIndex(store, 'status', status)
      for (let key of keys) {
        ids.push(String(key))
      }
    }
    return ids
  } catch (error) {
    let rows = await storage.getAll(store)
    return rows.filter((row) => !terminalSet.has(row.status)).map((row) => String(row.itemId))
  }
}

/**
 * Oldest row for a queue status. Prefers compound `status_addedAt` (one row) then status index fallback.
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string} status
 * @return {Promise<object|null>}
 */
async function peekNextRowByStatus(storage, store, status)
{
  try {
    await storage.open()
    let tx = storage.transaction([store], 'readonly')
    let objectStore = tx.objectStore(store)
    if (objectStore.indexNames.contains('status_addedAt')) {
      let index = objectStore.index('status_addedAt')
      let range = IDBKeyRange.bound([status, -Infinity], [status, Infinity])
      let row = await new Promise((resolve, reject) => {
        let request = index.openCursor(range)
        request.onsuccess = () => {
          let cursor = request.result
          resolve(cursor ? cursor.value : null)
        }
        request.onerror = () => reject(request.error)
      })
      await waitTransaction(tx)
      return row
    }
    // Compound index missing (pre-v4 DB mid-upgrade) — fall back without holding this tx.
    await waitTransaction(tx)
    let rows = await storage.getAllByIndex(store, 'status', status)
    if (!rows.length) {
      return null
    }
    let best = rows[0]
    for (let index = 1; index < rows.length; index++) {
      let row = rows[index]
      if ((row.addedAt ?? 0) < (best.addedAt ?? 0)) {
        best = row
      }
    }
    return best
  } catch (error) {
    let rows = await storage.getAll(store)
    rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
    return rows.find((row) => row.status === status) ?? null
  }
}

/**
 * Oldest `queued` row. Prefers compound `status_addedAt` (one row) then status index fallback.
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @return {Promise<object|null>}
 */
async function peekNextQueuedRow(storage, store)
{
  return peekNextRowByStatus(storage, store, 'queued')
}

/**
 * Oldest `discoveryQueued` row (deferred tag-discovery lane).
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @return {Promise<object|null>}
 */
async function peekNextDiscoveryQueuedRow(storage, store)
{
  return peekNextRowByStatus(storage, store, 'discoveryQueued')
}

/**
 * Delete all rows whose `status` is terminal (keys only — avoids loading fat payloads).
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} terminalStatuses
 * @return {Promise<number>} number of keys deleted
 */
async function pruneTerminalQueueRows(storage, store, terminalStatuses)
{
  let deleted = 0
  for (let status of terminalStatuses) {
    let keys = await storage.getAllKeysByIndex(store, 'status', status)
    if (!keys.length) {
      continue
    }
    await storage.open()
    let tx = storage.transaction([store], 'readwrite')
    let objectStore = tx.objectStore(store)
    for (let key of keys) {
      objectStore.delete(key)
      deleted++
    }
    await waitTransaction(tx)
  }
  return deleted
}

/**
 * Shared queue repository for resolution + download stores.
 */
class DownloadQueueRepositoryBase
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   * @param {{store: string, changeSource: string, terminalStatuses: string[], terminalSet: Set<string>, activeStatuses: string[]}} options
   */
  constructor(storage, metaRepo, onChange, options)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
    this._store = options.store
    this._changeSource = options.changeSource
    this._terminalStatuses = options.terminalStatuses
    this._terminalSet = options.terminalSet
    this._activeStatuses = options.activeStatuses
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange(this._changeSource)
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(this._store)
    return rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
  }

  /**
   * Non-terminal row count without materializing fat queue payloads.
   * @return {Promise<number>}
   */
  async countActive()
  {
    return countActiveQueueRows(this._storage, this._store, this._activeStatuses, this._terminalSet)
  }

  /**
   * Rows for a single status (index lookup — not a full-table scan).
   * @param {string} status
   * @return {Promise<object[]>}
   */
  async listByStatus(status)
  {
    return this._storage.getAllByIndex(this._store, 'status', status)
  }

  /**
   * Primary keys for non-terminal rows (no row bodies).
   * @return {Promise<string[]>}
   */
  async listActiveItemIds()
  {
    return listActiveQueueItemIds(this._storage, this._store, this._activeStatuses, this._terminalSet)
  }

  /**
   * Oldest `queued` row by `addedAt`, or null.
   * @return {Promise<object|null>}
   */
  async peekNextQueued()
  {
    return peekNextQueuedRow(this._storage, this._store)
  }

  /**
   * @return {Promise<object|null>}
   */
  async peekNextDiscoveryQueued()
  {
    return peekNextDiscoveryQueuedRow(this._storage, this._store)
  }

  /**
   * Remove terminal rows left behind after crashes (no per-item finally).
   * @return {Promise<number>}
   */
  async pruneTerminal()
  {
    let deleted = await pruneTerminalQueueRows(this._storage, this._store, this._terminalStatuses)
    if (deleted) {
      this._notifyChange()
    }
    return deleted
  }

  async get(itemId)
  {
    return this._storage.get(this._store, itemId)
  }

  async put(row)
  {
    await this._storage.put(this._store, row)
    this._notifyChange()
    return row
  }

  async remove(itemId)
  {
    await this._storage.delete(this._store, itemId)
    this._notifyChange()
  }

  async clearAll()
  {
    await this._storage.clearStore(this._store)
    this._notifyChange()
  }
}

class DownloadResolutionQueueRepository extends DownloadQueueRepositoryBase
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    super(storage, metaRepo, onChange, {
      store: IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
      changeSource: 'downloadResolutionQueue',
      terminalStatuses: RESOLUTION_QUEUE_TERMINAL_STATUSES,
      terminalSet: RESOLUTION_QUEUE_TERMINAL_SET,
      activeStatuses: RESOLUTION_QUEUE_ACTIVE_STATUSES,
    })
  }
}

class DownloadQueueRepository extends DownloadQueueRepositoryBase
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    super(storage, metaRepo, onChange, {
      store: IDB_STORE_DOWNLOAD_QUEUE,
      changeSource: 'downloadQueue',
      terminalStatuses: DOWNLOAD_QUEUE_TERMINAL_STATUSES,
      terminalSet: DOWNLOAD_QUEUE_TERMINAL_SET,
      activeStatuses: DOWNLOAD_QUEUE_ACTIVE_STATUSES,
    })
  }
}

class DownloadManagerStateRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('downloadManagerState')
    }
  }

  async get()
  {
    let raw = await this._storage.get(IDB_STORE_DOWNLOAD_MANAGER_STATE, 'state')
    if (!raw) {
      return createDefaultDownloadManagerState()
    }
    return normalizeHumanInteractionState(raw)
  }

  /**
   * Coordinator one-shot: persist normalized state when raw IDB row still has legacy keys.
   * @return {Promise<object>}
   */
  async persistNormalizedIfStale()
  {
    let raw = await this._storage.get(IDB_STORE_DOWNLOAD_MANAGER_STATE, 'state')
    if (!raw) {
      return createDefaultDownloadManagerState()
    }
    let normalized = normalizeHumanInteractionState(raw)
    if (downloadManagerStateRawNeedsNormalizePersist(raw)) {
      return this.put(normalized)
    }
    return normalized
  }

  async put(state)
  {
    state = normalizeHumanInteractionState(state)
    state.id = 'state'
    await this._storage.put(IDB_STORE_DOWNLOAD_MANAGER_STATE, state)
    this._notifyChange()
    return state
  }

  async reset()
  {
    return this.put(createDefaultDownloadManagerState())
  }

  /**
   * Coordinator-only state mutation (single-writer).
   * @param {function(object): (void|Promise<void>)} mutator
   * @return {Promise<object>}
   */
  async mutate(mutator)
  {
    let before = await this.get()
    let state = structuredClone(before)
    state = normalizeHumanInteractionState(state)
    await mutator(state)
    state = normalizeHumanInteractionState(state)
    state.id = 'state'
    if (JSON.stringify(before) === JSON.stringify(state)) {
      return before
    }
    return this.put(state)
  }

  /**
   * @param {boolean} paused
   * @return {Promise<object>}
   */
  async setPaused(paused)
  {
    return this.mutate((state) => {
      state.paused = Boolean(paused)
    })
  }
}

/**
 * Coordinator download queue + state commit APIs (reactor-native v2 §5.4).
 */
class DownloadCoordinatorRepository
{
  /** In-memory guard: block resurrecting queue writes after dequeue until re-enqueue or expiry. */
  static DEQUEUE_TOMBSTONE_MS = 30000

  /**
   * @param {DownloadResolutionQueueRepository} resolutionQueue
   * @param {DownloadQueueRepository} downloadQueue
   * @param {DownloadManagerStateRepository} dmState
   * @param {() => boolean|null|undefined} [isCoordinator]
   */
  constructor(resolutionQueue, downloadQueue, dmState, isCoordinator = null)
  {
    this._resolutionQueue = resolutionQueue
    this._downloadQueue = downloadQueue
    this._dmState = dmState
    this._isCoordinator = typeof isCoordinator === 'function' ? isCoordinator : () => true
    /** @type {Promise<void>} */
    this._coordinatorQueueTail = Promise.resolve()
    /** @type {Map<string, number>} itemId -> dequeue timestamp (ms) */
    this._dequeueTombstones = new Map()
  }

  /**
   * @param {string|null|undefined} itemId
   * @return {boolean}
   * @private
   */
  _isTombstoned(itemId)
  {
    if (itemId == null || itemId === '') {
      return false
    }
    let key = String(itemId)
    let ts = this._dequeueTombstones.get(key)
    if (ts == null) {
      return false
    }
    if (Date.now() - ts > DownloadCoordinatorRepository.DEQUEUE_TOMBSTONE_MS) {
      this._dequeueTombstones.delete(key)
      return false
    }
    return true
  }

  /**
   * @private
   */
  _assertCoordinator()
  {
    if (!this._isCoordinator()) {
      throw new Error('DownloadCoordinatorRepository: coordinator role required')
    }
  }

  /**
   * Serialized state writes (matches legacy DM tail semantics).
   * @param {function(object): (void|Promise<void>)} mutator
   * @param {{skipCoordinatorGuard?: boolean}} [options]
   * @return {Promise<object>}
   * @private
   */
  _mutateState(mutator, options = {})
  {
    if (!options.skipCoordinatorGuard) {
      this._assertCoordinator()
    }
    let run = this._coordinatorQueueTail.then(() => this._dmState.mutate(mutator))
    this._coordinatorQueueTail = run.then(() => undefined, () => undefined)
    return run
  }

  /**
   * Coordinator-only dm.state mutation (v2 §5.4).
   * @param {function(object): (void|Promise<void>)} mutator
   * @return {Promise<object>}
   */
  mutateState(mutator)
  {
    return this._mutateState(mutator)
  }

  /**
   * @return {Promise<object>}
   */
  async resetState()
  {
    this._assertCoordinator()
    return this._dmState.reset()
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   */
  async removeResolution(itemId)
  {
    this._assertCoordinator()
    if (!itemId) {
      return
    }
    await this._resolutionQueue.remove(itemId)
  }

  /**
   * @param {string} itemId
   * @return {Promise<boolean>}
   */
  async removeResolutionIfExists(itemId)
  {
    this._assertCoordinator()
    if (!itemId) {
      return false
    }
    let existing = await this._resolutionQueue.get(itemId)
    if (!existing) {
      return false
    }
    await this._resolutionQueue.remove(itemId)
    return true
  }

  /**
   * @param {string} itemId
   * @param {string|null|undefined} activeClaimItemId
   * @param {Set<string>|string[]} allowedStatuses
   * @return {Promise<boolean>}
   */
  async removeResolutionIfClaimed(itemId, activeClaimItemId, allowedStatuses)
  {
    this._assertCoordinator()
    if (!itemId || String(activeClaimItemId) !== String(itemId)) {
      return false
    }
    let existing = await this._resolutionQueue.get(itemId)
    if (!existing) {
      return false
    }
    let allowed = allowedStatuses instanceof Set ?
        allowedStatuses :
        new Set(allowedStatuses)
    if (!allowed.has(existing.status)) {
      return false
    }
    await this._resolutionQueue.remove(itemId)
    return true
  }

  /**
   * Coordinator HI lane clear (Command path).
   * @param {'resolution'|'download'} ctx
   * @return {Promise<object>}
   */
  async clearHumanInteractionLane(ctx)
  {
    this._assertCoordinator()
    if (ctx !== 'resolution' && ctx !== 'download') {
      return this._dmState.get()
    }
    let now = Date.now()
    return this._mutateState((next) => {
      normalizeHumanInteractionState(next)
      next.humanInteraction[ctx] = null
      delete next.humanInteractionBlocked
      delete next.humanInteractionContext
      delete next.humanInteractionItemId
      delete next.humanInteractionPromptTabId
      delete next.humanInteractionOpenUrl
      if (ctx === 'download') {
        next.lastDownloadInitiationAt = Math.max(next.lastDownloadInitiationAt ?? 0, now)
      } else {
        next.lastResolutionInitiationAt = Math.max(next.lastResolutionInitiationAt ?? 0, now)
      }
    })
  }

  /**
   * Prune terminal rows from both queues (coordinator-only).
   * @return {Promise<void>}
   */
  async pruneAllTerminalRows()
  {
    this._assertCoordinator()
    await Promise.all([
      this._resolutionQueue.pruneTerminal(),
      this._downloadQueue.pruneTerminal(),
    ])
  }

  /**
   * Challenge-tab HI resume (v2 allowlist — no kernel on verification tabs).
   * @param {'resolution'|'download'} ctx
   * @param {object} state
   * @return {Promise<object|null>}
   */
  async clearHumanInteractionLaneFromSnapshot(ctx, state)
  {
    if (!state || (ctx !== 'resolution' && ctx !== 'download')) {
      return null
    }
    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'
    return this._mutateState((draft) => {
      Object.assign(draft, state)
      normalizeHumanInteractionState(draft)
    }, {skipCoordinatorGuard: true})
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   * @private
   */
  async _clearTerminalQueueRows(itemId)
  {
    let resolution = await this._resolutionQueue.get(itemId)
    if (resolution && RESOLUTION_QUEUE_TERMINAL_SET.has(resolution.status)) {
      await this._resolutionQueue.remove(itemId)
    }
    let download = await this._downloadQueue.get(itemId)
    if (download && DOWNLOAD_QUEUE_TERMINAL_SET.has(download.status)) {
      await this._downloadQueue.remove(itemId)
    }
  }

  /**
   * @param {{downloadPending?: number, resolutionPending?: number}} [counts]
   * @return {Promise<void>}
   */
  async resetProgressCountersIfIdle(counts = {})
  {
    let downloadPending = counts.downloadPending ?? await this._downloadQueue.countActive()
    let resolutionPending = counts.resolutionPending ?? await this._resolutionQueue.countActive()
    await this._mutateState((state) => {
      if (resolutionPending === 0 && (Number(state.completedResolutionCount) || 0) !== 0) {
        state.completedResolutionCount = 0
      }
      if (downloadPending <= 0 && resolutionPending <= 0 &&
          (Number(state.completedDownloadCount) || 0) !== 0) {
        state.completedDownloadCount = 0
      }
    })
  }

  /**
   * @param {object} row
   * @return {Promise<object>}
   */
  async enqueueResolution(row)
  {
    this._assertCoordinator()
    if (!row?.itemId) {
      throw new Error('DownloadCoordinatorRepository.enqueueResolution: row.itemId required')
    }
    this._dequeueTombstones.delete(String(row.itemId))
    await this.resetProgressCountersIfIdle()
    await this._clearTerminalQueueRows(row.itemId)
    return this._resolutionQueue.put(row)
  }

  /**
   * @param {{itemId?: string, cancelDiscovery?: boolean, clearHiContexts?: string[]}} payload
   * @return {Promise<void>}
   */
  async dequeue(payload)
  {
    this._assertCoordinator()
    let itemId = payload?.itemId
    if (!itemId) {
      return
    }
    await this._resolutionQueue.remove(itemId)
    await this._downloadQueue.remove(itemId)
    this._dequeueTombstones.set(String(itemId), Date.now())
    if (payload.cancelDiscovery) {
      await this._mutateState((next) => {
        clearDiscoveryReviewStateFields(next)
      })
    }
    for (let ctx of payload.clearHiContexts ?? []) {
      if (ctx !== 'resolution' && ctx !== 'download') {
        continue
      }
      await this._mutateState((next) => {
        if (!next.humanInteraction || typeof next.humanInteraction !== 'object') {
          next.humanInteraction = {resolution: null, download: null}
        }
        next.humanInteraction[ctx] = null
        let now = Date.now()
        if (ctx === 'download') {
          next.lastDownloadInitiationAt = Math.max(next.lastDownloadInitiationAt ?? 0, now)
        } else {
          next.lastResolutionInitiationAt = Math.max(next.lastResolutionInitiationAt ?? 0, now)
        }
      })
    }
  }

  /**
   * @return {Promise<void>}
   */
  async clearDownloadQueue()
  {
    this._assertCoordinator()
    await this._mutateState((state) => {
      state.paused = true
      state.completedDownloadCount = 0
      if (!state.humanInteraction || typeof state.humanInteraction !== 'object') {
        state.humanInteraction = {resolution: null, download: null}
      }
      if (state.humanInteraction.download) {
        state.humanInteraction.download = null
      }
    })
    await this._downloadQueue.clearAll()
  }

  /**
   * @param {object} payload Coordinator-computed mutations (v2 confirm path).
   * @return {Promise<void>}
   */
  async confirmTagDiscovery(payload = {})
  {
    this._assertCoordinator()
    if (payload.removeResolutionId) {
      await this._resolutionQueue.remove(payload.removeResolutionId)
    }
    if (payload.resolutionRow) {
      await this._resolutionQueue.put(payload.resolutionRow)
    }
    if (payload.downloadRow) {
      await this._downloadQueue.put(payload.downloadRow)
    }
    if (payload.statePatch && typeof payload.statePatch === 'object') {
      await this._mutateState((state) => {
        Object.assign(state, payload.statePatch)
        normalizeHumanInteractionState(state)
      })
    } else if (payload.clearGate) {
      await this._mutateState((next) => {
        clearDiscoveryReviewStateFields(next)
      })
    }
    if (payload.incrementResolutionCount) {
      await this._mutateState((state) => {
        state.completedResolutionCount = (Number(state.completedResolutionCount) || 0) + 1
      })
    }
  }

  /**
   * @return {Promise<void>}
   */
  async skipTagDiscovery()
  {
    this._assertCoordinator()
    let state = await this._dmState.get()
    state = normalizeHumanInteractionState(state)
    if (!state.resolutionBlocked) {
      return
    }
    let itemId = state.resolutionBlockedItemId
    if (itemId) {
      await this._resolutionQueue.remove(itemId)
    }
    await this._mutateState((next) => {
      clearDiscoveryReviewStateFields(next)
    })
  }

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

  /**
   * @param {object} row
   * @return {Promise<object|null>}
   */
  async commitResolution(row)
  {
    this._assertCoordinator()
    if (!row?.itemId) {
      throw new Error('DownloadCoordinatorRepository.commitResolution: row.itemId required')
    }
    if (this._isTombstoned(row.itemId)) {
      return null
    }
    return this._resolutionQueue.put(row)
  }

  /**
   * @param {object} item
   * @param {object} resolved
   * @return {Promise<object|null>}
   */
  async promoteToDownload(item, resolved)
  {
    this._assertCoordinator()
    if (!item?.itemId) {
      return null
    }
    if (this._isTombstoned(item.itemId)) {
      return null
    }
    let existing = await this._resolutionQueue.get(item.itemId)
    if (!existing || existing.status === 'done' || existing.status === 'failed') {
      return null
    }
    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._downloadQueue.put(row)
    return row
  }

  /**
   * @param {string} itemId
   * @return {Promise<object|null>}
   */
  async claimDownload(itemId)
  {
    this._assertCoordinator()
    if (this._isTombstoned(itemId)) {
      return null
    }
    let row = await this._downloadQueue.get(itemId)
    if (!row || row.status !== 'queued') {
      return null
    }
    row.status = 'downloading'
    row.error = null
    return this._downloadQueue.put(row)
  }

  /**
   * @param {object} row
   * @return {Promise<object|null>}
   */
  async commitDownload(row)
  {
    this._assertCoordinator()
    if (!row?.itemId) {
      throw new Error('DownloadCoordinatorRepository.commitDownload: row.itemId required')
    }
    if (this._isTombstoned(row.itemId)) {
      return null
    }
    return this._downloadQueue.put(row)
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   */
  async removeTerminal(itemId)
  {
    this._assertCoordinator()
    await this._clearTerminalQueueRows(itemId)
  }

  /**
   * @param {object} row
   * @param {'resolution'|'download'} queue
   * @return {Promise<object|null>}
   */
  async requeue(row, queue)
  {
    this._assertCoordinator()
    if (this._isTombstoned(row?.itemId)) {
      return null
    }
    if (queue === 'resolution') {
      return this._resolutionQueue.put(row)
    }
    return this._downloadQueue.put(row)
  }
}

class TagRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   */
  constructor(storage, metaRepo)
  {
    this._storage = storage
    this._metaRepo = metaRepo
  }

  async getByName(name)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
    let row = await promisifyRequest(tx.objectStore(IDB_STORE_TAGS).index('name').get(name))
    return normalizeTagEntry(row)
  }

  /**
   * @param {string[]} names
   * @return {Promise<Map<string, *>>} name → entry for rows that exist
   */
  async getByNames(names)
  {
    let unique = [...new Set(names.filter(Boolean))]
    let map = new Map()
    if (!unique.length) {
      return map
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAGS).index('name')
    await Promise.all(unique.map(async (name) => {
      let row = await promisifyRequest(index.get(name))
      if (row) {
        map.set(name, normalizeTagEntry(row))
      }
    }))
    return map
  }

  async getByEntryId(entryId)
  {
    return normalizeTagEntry(await this._storage.get(IDB_STORE_TAGS, entryId))
  }

  /**
   * @param {number[]} entryIds
   * @return {Promise<Map<number, *>>}
   */
  async getByEntryIds(entryIds)
  {
    let unique = [...new Set(entryIds.filter((id) => id != null))]
    let map = new Map()
    if (!unique.length) {
      return map
    }
    await Promise.all(unique.map(async (entryId) => {
      let row = await this._storage.get(IDB_STORE_TAGS, entryId)
      if (row) {
        map.set(entryId, normalizeTagEntry(row))
      }
    }))
    return map
  }

  async putTag(row)
  {
    normalizeTagEntry(row)
    row.meta.updatedAt = Date.now()
    await this._storage.guardedWrite(async () => {
      await this._storage.put(IDB_STORE_TAGS, row)
      await this._metaRepo.bumpRevision({tagsTouched: true})
    })
    return row
  }

  async registerTag(name, typeEntryId = null)
  {
    let existing = await this.getByName(name)
    if (existing) {
      if (typeEntryId != null && existing.typeEntryId == null) {
        existing.typeEntryId = typeEntryId
      }
      existing.meta.updatedAt = Date.now()
      return this.putTag(existing)
    }
    let entryId = await this._metaRepo.allocateEntryId('nextTagEntryId')
    let now = Date.now()
    let row = normalizeTagEntry({
      entryId,
      name,
      typeEntryId,
      isDiscovered: null,
      meta: {
        createdAt: now,
        updatedAt: now,
      },
    })
    await this._storage.guardedWrite(async () => {
      await this._storage.put(IDB_STORE_TAGS, row)
      await this._metaRepo.bumpRevision({tagsTouched: true})
    })
    return row
  }

  /**
   * Resolves a canonical tag type name to its {@link IDB_STORE_TAG_TYPES} entryId.
   * Alias entries (those with `aliasOfEntryIds`) return their canonical target's id.
   *
   * @param {string|null|undefined} typeName
   * @return {Promise<number|null>}
   */
  async resolveCanonicalTypeEntryId(typeName)
  {
    if (!typeName) {
      return null
    }
    let doc = await this._storage.get(IDB_STORE_TAG_TYPES, 'tagTypes')
    let entries = doc?.entries ?? []
    let entry = entries.find((row) => row.name === typeName)
    if (!entry) {
      return null
    }
    if (entry.aliasOfEntryIds?.length) {
      return entry.aliasOfEntryIds[0] ?? null
    }
    return entry.entryId ?? null
  }

  /**
   * Resolve any tag-type descriptor to its canonical type name using the seeded
   * {@link IDB_STORE_TAG_TYPES} schema — which owns the site↔canonical mapping and aliases. Matches
   * (case-insensitively) by canonical `name`, site `sidebarClass`, display `label`, or numeric API
   * category (`apiEntryId`), then follows any `aliasOfEntryIds` to the canonical entry's name.
   * Returns null when the descriptor is unknown. Consumers should never hardcode label→type tables;
   * they declare the mapping in the schema and resolve through here.
   *
   * @param {string|number|null|undefined} descriptor
   * @return {Promise<string|null>}
   */
  async resolveCanonicalTypeName(descriptor)
  {
    if (descriptor == null || descriptor === '') {
      return null
    }
    let doc = await this._storage.get(IDB_STORE_TAG_TYPES, 'tagTypes')
    let entries = doc?.entries ?? []
    if (!entries.length) {
      return null
    }
    let byId = new Map(entries.map((row) => [row.entryId, row]))
    let canonicalNameOf = (entry) => {
      if (!entry) {
        return null
      }
      if (entry.aliasOfEntryIds?.length) {
        return byId.get(entry.aliasOfEntryIds[0])?.name ?? null
      }
      return entry.name ?? null
    }
    if (typeof descriptor === 'number' || /^\d+$/.test(String(descriptor))) {
      let apiId = Number(descriptor)
      return canonicalNameOf(entries.find((row) => row.apiEntryId === apiId))
    }
    let needle = String(descriptor).trim().toLowerCase()
    let match = entries.find((row) =>
        String(row.name ?? '').toLowerCase() === needle ||
        String(row.sidebarClass ?? '').toLowerCase() === needle ||
        String(row.label ?? '').toLowerCase() === needle)
    return canonicalNameOf(match)
  }

  /**
   * @param {string} prefix
   * @param {number} [limit]
   * @return {Promise<string[]>}
   */
  async searchNames(prefix, limit = 5)
  {
    let original = String(prefix ?? '').trim()
    let needle = original.toLowerCase()
    if (!needle) {
      return []
    }
    let fetchLimit = Math.max(limit * 3, 15)
    /** @type {Map<string, string>} */
    let byLower = new Map()
    let absorb = (entries) => {
      for (let entry of entries ?? []) {
        let name = entry?.name
        if (!name) {
          continue
        }
        let lower = String(name).toLowerCase()
        if (lower.startsWith(needle) && !byLower.has(lower)) {
          byLower.set(lower, name)
        }
      }
    }
    absorb((await this.listTags({namePrefix: needle, limit: fetchLimit})).entries)
    if (original !== needle) {
      absorb((await this.listTags({namePrefix: original, limit: fetchLimit})).entries)
    }
    return [...byLower.values()].slice(0, limit)
  }

  /**
   * @param {{cursor?: number|string|null, limit?: number, typeEntryId?: number|null, namePrefix?: string|null}} options
   *        For `typeEntryId` pages, `cursor` is the last included tag `name` (exclusive start for the next page).
   * @return {Promise<{entries: *[], nextCursor?: number|string}>}
   */
  async listTags(options = {})
  {
    let limit = options.limit ?? 50
    let store = IDB_STORE_TAGS
    if (options.namePrefix) {
      let prefix = String(options.namePrefix)
      let range = IDBKeyRange.bound(prefix, prefix + '\uffff')
      let page = await this._storage.cursorPage(store, range, limit, options.cursor ?? null)
      return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
    }
    if (options.typeEntryId !== null && options.typeEntryId !== undefined) {
      await this._storage.open()
      let tx = this._storage.transaction([store], 'readonly')
      let index = tx.objectStore(store).index('typeEntryId_name')
      let typeId = options.typeEntryId
      let range = options.cursor != null && options.cursor !== ''
          ? IDBKeyRange.bound([typeId, String(options.cursor)], [typeId, '\uffff'], true, false)
          : IDBKeyRange.bound([typeId, ''], [typeId, '\uffff'])
      let request = index.openCursor(range)
      let entries = []
      let nextCursor = null
      await new Promise((resolve, reject) => {
        request.onsuccess = () => {
          let row = request.result
          if (!row) {
            resolve()
            return
          }
          if (entries.length >= limit) {
            nextCursor = entries[entries.length - 1]?.name ?? null
            resolve()
            return
          }
          entries.push(row.value)
          row.continue()
        }
        request.onerror = () => reject(request.error)
      })
      return {entries, nextCursor: nextCursor ?? undefined}
    }
    let page = await this._storage.cursorPage(store, null, limit, options.cursor ?? null)
    return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
  }

  /**
   * Clear {@link TagEntry.isDiscovered} for every tag that was marked discovered (`true` → `null`).
   * Types and ruleset data are unchanged. Chunked with progress for large registries.
   * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
   * @return {Promise<{updated: number, total: number}>}
   */
  async resetAllTagsDiscovered(onProgress = null)
  {
    await this._storage.open()
    let tagTotal = await this._storage.count(IDB_STORE_TAGS)
    let tagsProcessed = 0
    let updated = 0
    await reportMigrationProgress(onProgress, {
      phase: 'tag-discovery-reset',
      label: 'Resetting tag discovery…',
      current: 0,
      total: tagTotal || 1,
    })
    let startAfter = null
    let chunkSize = 100
    while (true) {
      let page = await this._storage.cursorPage(IDB_STORE_TAGS, null, chunkSize, startAfter)
      if (!page.entries.length) {
        break
      }
      let toPut = []
      for (let row of page.entries) {
        normalizeTagEntry(row)
        if (row.isDiscovered === true) {
          row.isDiscovered = null
          toPut.push(row)
          updated++
        }
        tagsProcessed++
      }
      if (toPut.length) {
        await this._storage.putMany(IDB_STORE_TAGS, toPut, chunkSize)
      }
      await reportMigrationProgress(onProgress, {
        phase: 'tag-discovery-reset',
        label: 'Resetting tag discovery…',
        current: Math.min(tagsProcessed, tagTotal || tagsProcessed),
        total: tagTotal || tagsProcessed || 1,
      })
      if (!page.nextCursor) {
        break
      }
      startAfter = page.nextCursor
      await yieldToBrowser()
    }
    if (updated > 0) {
      await this._metaRepo.bumpRevision({tagsTouched: true})
    }
    return {updated, total: tagTotal}
  }

  async clearAll()
  {
    await this._storage.guardedWrite(async () => {
      await this._storage.clearStore(IDB_STORE_TAGS)
      await this._metaRepo.bumpRevision({tagsTouched: true})
    })
  }
}

class TagRuntime
{
  /**
   * @param {TagRepository} tagRepo
   * @param {BrazenStorageRepositories|null} [repos]
   */
  constructor(tagRepo, repos = null)
  {
    this._tagRepo = tagRepo
    this._repos = repos
    /** @type {Map<string, *>} */
    this._byName = new Map()
    /** @type {Map<string, string>} lower(name) → canonical cache key in `_byName` (legacy casing). */
    this._byNameLower = new Map()
    /** @type {Map<number, *>} */
    this._byEntryId = new Map()
    /** @type {Set<string>} Names confirmed absent from IDB (avoid repeat misses). */
    this._missingNames = new Set()
    /** @type {string[]} Insertion order for LRU eviction of `_byName`. */
    this._cacheOrder = []
    this._warmed = false
    /** @type {Set<number>|null} Compiled filename-ignore entry ids when ruleset migrated. */
    this._downloadIgnoreEntryIds = null
    /** @type {Map<number, number>|null} subject entryId → replacement entryId substitutions. */
    this._substitutionBySubjectId = null
    /** Monotonic tags.revision stamp last applied to `_byName` / `_byEntryId`. */
    this._tagsRevisionSeen = 0
  }

  /**
   * Drop bounded LRU rows when {@link tags.revision} advances.
   * @private
   */
  _ensureTagsRevisionFresh()
  {
    let rev = typeof this._repos?.getTagsRevision === 'function' ? this._repos.getTagsRevision() : 0
    if (rev === this._tagsRevisionSeen) {
      return
    }
    this.clearCache()
    this._tagsRevisionSeen = rev
  }

  /**
   * Mark the current tags.revision as applied after a coherent local write so
   * {@link _ensureTagsRevisionFresh} does not wipe this tab's cache on the next read.
   * Cross-tab followers still lag `_tagsRevisionSeen` and reload normally.
   * @private
   */
  _absorbLocalTagsRevision()
  {
    this._tagsRevisionSeen = typeof this._repos?.getTagsRevision === 'function'
        ? this._repos.getTagsRevision()
        : this._tagsRevisionSeen
  }

  clearCache()
  {
    this._byName.clear()
    this._byNameLower.clear()
    this._byEntryId.clear()
    this._missingNames.clear()
    this._cacheOrder = []
    this._warmed = false
    this._downloadIgnoreEntryIds = null
    this._substitutionBySubjectId = null
    this._tagsRevisionSeen = typeof this._repos?.getTagsRevision === 'function'
        ? this._repos.getTagsRevision()
        : this._tagsRevisionSeen
  }

  /**
   * Refresh compiled download-policy maps from ruleset stores.
   * @return {Promise<void>}
   */
  async refreshDownloadRulesetMaps()
  {
    if (!this._repos) {
      return
    }
    let ignoreIds = new Set()
    let ignoreCompiled = await this._repos.rulesetFields.getCompiledField('filename-tag-ignore-list', this._repos)
    for (let entryId of ignoreCompiled?.optimized?.tagEntryIds ?? []) {
      if (entryId != null) {
        ignoreIds.add(entryId)
      }
    }
    let subMap = new Map()
    let subCompiled = await this._repos.rulesetFields.getCompiledField('filename-tag-substitutions', this._repos)
    for (let row of subCompiled?.optimized ?? []) {
      if (row?.subjectTagEntryId != null && row?.replacementTagEntryId != null) {
        subMap.set(row.subjectTagEntryId, row.replacementTagEntryId)
      }
    }
    this._downloadIgnoreEntryIds = ignoreIds
    this._substitutionBySubjectId = subMap
  }

  /**
   * @param {*} entry
   * @private
   */
  _cacheEntry(entry)
  {
    if (!entry?.name) {
      return
    }
    normalizeTagEntry(entry)
    delete entry._optimistic
    let name = entry.name
    this._missingNames.delete(name)
    this._missingNames.delete(name.toLowerCase())
    if (!this._byName.has(name)) {
      this._cacheOrder.push(name)
    }
    this._byName.set(name, entry)
    this._byNameLower.set(name.toLowerCase(), name)
    if (entry.entryId != null) {
      this._byEntryId.set(entry.entryId, entry)
    }
    this._evictCacheIfNeeded()
  }

  /**
   * Ensure a sync-readable cache row exists for attribute UI (no IDB I/O).
   * Persisted later via {@link ensureTag} / {@link patchAttributes}.
   *
   * @param {string} name
   * @return {*}
   */
  primeOptimisticEntry(name)
  {
    if (!name) {
      return null
    }
    let existing = this.resolveCachedByName(name)
    if (existing) {
      this._missingNames.delete(name)
      this._missingNames.delete(String(name).toLowerCase())
      return existing
    }
    this._missingNames.delete(name)
    this._missingNames.delete(String(name).toLowerCase())
    let now = Date.now()
    let entry = normalizeTagEntry({
      entryId: null,
      name,
      typeEntryId: null,
      isDiscovered: null,
      meta: {createdAt: now, updatedAt: now},
      _optimistic: true,
    })
    this._byName.set(name, entry)
    this._byNameLower.set(String(name).toLowerCase(), name)
    this._cacheOrder.push(name)
    this._evictCacheIfNeeded()
    this._warmed = true
    return entry
  }

  /**
   * @private
   */
  _evictCacheIfNeeded()
  {
    while (this._byName.size > TAG_RUNTIME_CACHE_MAX && this._cacheOrder.length) {
      let evictName = this._cacheOrder.shift()
      let entry = this._byName.get(evictName)
      this._byName.delete(evictName)
      if (evictName) {
        let lower = evictName.toLowerCase()
        if (this._byNameLower.get(lower) === evictName) {
          this._byNameLower.delete(lower)
        }
      }
      if (entry?.entryId != null) {
        this._byEntryId.delete(entry.entryId)
      }
    }
    while (this._missingNames.size > TAG_RUNTIME_CACHE_MAX) {
      let first = this._missingNames.values().next().value
      this._missingNames.delete(first)
    }
  }

  /**
   * Marks the runtime ready for sync readers. Does **not** load the full tags table —
   * use {@link ensureNames} / {@link ensureEntryIds} / {@link ensureComplianceLookups}.
   *
   * @return {Promise<void>}
   */
  async warmCache()
  {
    this._warmed = true
    try {
      await this.refreshDownloadRulesetMaps()
    } catch (_) { /* ignore */ }
  }

  /**
   * Load the given tag names from IndexedDB into the bounded cache (skips already cached / known-missing).
   *
   * @param {Iterable<string>} names
   * @return {Promise<void>}
   */
  async ensureNames(names)
  {
    let missing = []
    for (let name of names) {
      if (!name || this.resolveCachedByName(name) || this._missingNames.has(name) ||
          this._missingNames.has(String(name).toLowerCase())) {
        continue
      }
      missing.push(name)
    }
    if (!missing.length) {
      this._warmed = true
      return
    }
    let found = await this._tagRepo.getByNames(missing)
    for (let name of missing) {
      let entry = found.get(name)
      if (entry) {
        this._cacheEntry(entry)
      } else {
        this._missingNames.add(name)
        this._missingNames.add(String(name).toLowerCase())
      }
    }
    this._evictCacheIfNeeded()
    this._warmed = true
  }

  /**
   * @param {Iterable<number>} entryIds
   * @return {Promise<void>}
   */
  async ensureEntryIds(entryIds)
  {
    let missing = []
    for (let entryId of entryIds) {
      if (entryId == null || this._byEntryId.has(entryId)) {
        continue
      }
      missing.push(entryId)
    }
    if (!missing.length) {
      this._warmed = true
      return
    }
    let found = await this._tagRepo.getByEntryIds(missing)
    for (let entry of found.values()) {
      this._cacheEntry(entry)
    }
    this._warmed = true
  }

  /**
   * Prefetch tags needed for sync compliance evaluation of the given item tag lists.
   *
   * @param {Iterable<string[]>} itemTagNameLists
   * @param {Iterable<{soleAttribute?: *, combos?: {tagEntryIds: number[]}[]}>} specs
   * @return {Promise<void>}
   */
  async ensureComplianceLookups(itemTagNameLists, specs)
  {
    let names = new Set()
    for (let list of itemTagNameLists) {
      if (!Array.isArray(list)) {
        continue
      }
      for (let name of list) {
        if (name) {
          names.add(name)
        }
      }
    }
    let entryIds = new Set()
    for (let spec of specs) {
      if (!spec) {
        continue
      }
      for (let combo of spec.combos ?? []) {
        for (let entryId of combo.tagEntryIds ?? []) {
          if (entryId != null) {
            entryIds.add(entryId)
          }
        }
      }
    }
    await this.ensureNames(names)
    await this.ensureEntryIds(entryIds)
  }

  /**
   * @param {string} name
   * @return {*|null}
   */
  getCachedByName(name)
  {
    return this.resolveCachedByName(name)
  }

  /**
   * Resolve a cached tag row by exact name or legacy different casing.
   * Same lookup used by ignore chrome and filename join.
   * @param {string} name
   * @return {*|null}
   */
  resolveCachedByName(name)
  {
    this._ensureTagsRevisionFresh()
    if (!name) {
      return null
    }
    if (this._missingNames.has(name)) {
      return null
    }
    let direct = this._byName.get(name)
    if (direct) {
      return direct
    }
    let lower = String(name).toLowerCase()
    if (this._missingNames.has(lower)) {
      return null
    }
    let canonical = this._byNameLower.get(lower)
    if (!canonical) {
      return null
    }
    return this._byName.get(canonical) ?? null
  }

  /**
   * @param {number} entryId
   * @return {*|null}
   */
  getCachedByEntryId(entryId)
  {
    this._ensureTagsRevisionFresh()
    return this._byEntryId.get(entryId) ?? null
  }

  /**
   * @param {string} name
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null, source?: string}|null} [context]
   * @return {Promise<*>}
   */
  async ensureTag(name, context = null)
  {
    let resolvedTypeId = context?.typeEntryId ?? null
    let needTypeResolve = resolvedTypeId == null && !!context?.typeName
    let seenOnly = context?.source === 'media' || context?.source === 'sidebar'
    // Discovery Confirm / discovery-off resolution — sole writers of isDiscovered.
    let marksDiscovered = context?.source === 'discovery-confirm' || context?.source === 'resolution'
    let cached = this.getCachedByName(name)
    // Optimistic stubs are sync-only — register a real row, then re-apply stub attrs.
    let optimisticSnapshot = null
    if (cached && (cached._optimistic || cached.entryId == null)) {
      optimisticSnapshot = {
        lastSeenTypeEntryId: cached.meta?.lastSeenTypeEntryId ?? null,
        typeEntryId: cached.typeEntryId ?? null,
        isDiscovered: cached.isDiscovered === true ? true : null,
      }
      cached = null
    }
    if (cached) {
      // Skip type resolve/put when the row already has what this call would write.
      let needsSeenType = seenOnly && needTypeResolve
      let needsConfirmType = !seenOnly && cached.typeEntryId == null && (resolvedTypeId != null || needTypeResolve)
      let needsDiscovered = marksDiscovered && cached.isDiscovered !== true
      if (!needsSeenType && !needsConfirmType && !needsDiscovered && resolvedTypeId == null && !needTypeResolve) {
        return cached
      }
      if (needsSeenType || needsConfirmType || needsDiscovered) {
        if ((needsSeenType || needsConfirmType) && resolvedTypeId == null && context?.typeName) {
          resolvedTypeId = await this._tagRepo.resolveCanonicalTypeEntryId(context.typeName)
        }
        let mutated = false
        if (resolvedTypeId != null && (needsSeenType || needsConfirmType)) {
          if (seenOnly) {
            if (cached.meta.lastSeenTypeEntryId !== resolvedTypeId) {
              cached.meta.lastSeenTypeEntryId = resolvedTypeId
              mutated = true
            }
          } else if (cached.typeEntryId == null) {
            cached.typeEntryId = resolvedTypeId
            mutated = true
          }
        }
        if (needsDiscovered) {
          cached.isDiscovered = true
          mutated = true
        }
        if (mutated) {
          cached = await this._tagRepo.putTag(cached)
          this._cacheEntry(cached)
          this._absorbLocalTagsRevision()
        }
      }
      return cached
    }
    if (this._missingNames.has(name)) {
      this._missingNames.delete(name)
    }
    if (resolvedTypeId == null && context?.typeName) {
      resolvedTypeId = await this._tagRepo.resolveCanonicalTypeEntryId(context.typeName)
    }
    let typeForRegister = seenOnly ? null : resolvedTypeId
    if (!seenOnly && typeForRegister == null && optimisticSnapshot?.typeEntryId != null) {
      typeForRegister = optimisticSnapshot.typeEntryId
    }
    let entry = await this._tagRepo.registerTag(name, typeForRegister)
    let postRegisterMutated = false
    if (resolvedTypeId != null && seenOnly) {
      if (entry.meta.lastSeenTypeEntryId !== resolvedTypeId) {
        entry.meta.lastSeenTypeEntryId = resolvedTypeId
        postRegisterMutated = true
      }
    } else if (seenOnly && optimisticSnapshot?.lastSeenTypeEntryId != null &&
        entry.meta.lastSeenTypeEntryId == null) {
      entry.meta.lastSeenTypeEntryId = optimisticSnapshot.lastSeenTypeEntryId
      postRegisterMutated = true
    }
    if (optimisticSnapshot) {
      if (optimisticSnapshot.typeEntryId != null && entry.typeEntryId == null) {
        entry.typeEntryId = optimisticSnapshot.typeEntryId
        postRegisterMutated = true
      }
      if (optimisticSnapshot.lastSeenTypeEntryId != null &&
          entry.meta.lastSeenTypeEntryId == null) {
        entry.meta.lastSeenTypeEntryId = optimisticSnapshot.lastSeenTypeEntryId
        postRegisterMutated = true
      }
      if (optimisticSnapshot.isDiscovered != null && entry.isDiscovered == null) {
        entry.isDiscovered = optimisticSnapshot.isDiscovered
        postRegisterMutated = true
      }
    }
    // Attribute / media paths leave isDiscovered null; only Confirm / resolution mark it.
    if (marksDiscovered && entry.isDiscovered !== true) {
      entry.isDiscovered = true
      postRegisterMutated = true
    }
    if (postRegisterMutated) {
      entry = await this._tagRepo.putTag(entry)
    }
    this._cacheEntry(entry)
    this._absorbLocalTagsRevision()
    return entry
  }

  /**
   * Register-on-seen for typed tag groups with one coalesced tags.revision bump.
   * @param {Record<string, string[]>} groups Map of type name → tag names.
   * @param {string} [source='media']
   * @return {Promise<void>}
   */
  async registerTypedTagGroups(groups, source = 'media')
  {
    if (!groups || typeof groups !== 'object') {
      return
    }
    this._repos?.meta?.beginTagsRevisionBatch?.()
    try {
      for (let [typeName, names] of Object.entries(groups)) {
        if (!Array.isArray(names)) {
          continue
        }
        for (let name of names) {
          if (name) {
            await this.ensureTag(name, {typeName, source})
          }
        }
      }
    } finally {
      await this._repos?.meta?.endTagsRevisionBatch?.()
      this._absorbLocalTagsRevision()
    }
  }

  /**
   * @param {string[]} names
   * @param {{typeEntryId?: number|null, typeName?: string|null}|null} [context]
   * @return {Promise<Map<string, *>>}
   */
  async ensureTags(names, context = null)
  {
    let map = new Map()
    this._repos?.meta?.beginTagsRevisionBatch?.()
    try {
      for (let name of names) {
        if (!name) {
          continue
        }
        map.set(name, await this.ensureTag(name, context))
      }
    } finally {
      await this._repos?.meta?.endTagsRevisionBatch?.()
      this._absorbLocalTagsRevision()
    }
    return map
  }

  /**
   * @param {string|number} nameOrId
   * @return {Promise<*|null>}
   */
  async getTag(nameOrId)
  {
    if (typeof nameOrId === 'number') {
      let cached = this.getCachedByEntryId(nameOrId)
      if (cached) {
        return cached
      }
      let entry = await this._tagRepo.getByEntryId(nameOrId)
      if (entry) {
        this._cacheEntry(entry)
      }
      return entry
    }
    let cached = this.getCachedByName(nameOrId)
    if (cached) {
      return cached
    }
    if (this._missingNames.has(nameOrId)) {
      return null
    }
    let entry = await this._tagRepo.getByName(nameOrId)
    if (entry) {
      this._cacheEntry(entry)
    } else {
      this._missingNames.add(nameOrId)
      this._evictCacheIfNeeded()
    }
    return entry
  }

  /**
   * @param {string[]} tagNames
   * @param {boolean} [stripCharacterSeries]
   * @param {boolean} [applyIgnore]
   * @return {string[]}
   */
  applyDownloadAttributesSync(tagNames, stripCharacterSeries = false, applyIgnore = true)
  {
    let result = []
    let ignoreIds = applyIgnore ? this._downloadIgnoreEntryIds : null
    let subMap = this._substitutionBySubjectId
    for (let tagName of tagNames) {
      let entry = this.resolveCachedByName(tagName)
      if (!entry) {
        result.push(tagName)
        continue
      }
      if (ignoreIds?.has(entry.entryId)) {
        continue
      }
      let effective = tagName
      if (subMap?.has(entry.entryId)) {
        let replacement = this.getCachedByEntryId(subMap.get(entry.entryId))
        effective = replacement?.name ?? effective
      }
      if (stripCharacterSeries) {
        let stripped = effective.replace(/_\([^)]*\)$/, '')
        if (stripped !== effective) {
          let strippedEntry = this.resolveCachedByName(stripped)
          if (strippedEntry?.entryId != null && ignoreIds?.has(strippedEntry.entryId)) {
            continue
          }
          if (strippedEntry && subMap?.has(strippedEntry.entryId)) {
            let replacement = this.getCachedByEntryId(subMap.get(strippedEntry.entryId))
            effective = replacement?.name ?? stripped
          } else {
            effective = stripped
          }
        }
      }
      result.push(effective)
    }
    return result
  }

  /**
   * @param {{tagEntryIds: number[]}} combo
   * @param {Set<number>} postEntryIds
   * @return {boolean}
   */
  evaluateComboExpression(combo, postEntryIds)
  {
    if (!combo?.tagEntryIds?.length) {
      return false
    }
    for (let entryId of combo.tagEntryIds) {
      if (!postEntryIds.has(entryId)) {
        return false
      }
    }
    return true
  }

  /**
   * @param {string[]} itemTagNames
   * @param {{combos: {tagEntryIds: number[]}[]}} spec
   * @return {{complies: boolean, rule?: string}}
   */
  evaluateComplianceSync(itemTagNames, spec)
  {
    let postEntryIds = new Set()
    for (let name of itemTagNames) {
      let entry = this.getCachedByName(name)
      if (entry?.entryId != null) {
        postEntryIds.add(entry.entryId)
      }
    }
    for (let combo of spec.combos ?? []) {
      if (this.evaluateComboExpression(combo, postEntryIds)) {
        let labels = combo.tagEntryIds.map((entryId) => this.getCachedByEntryId(entryId)?.name ?? String(entryId))
        return {complies: false, rule: labels.join(' & ')}
      }
    }
    return {complies: true}
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{combos: {tagEntryIds: number[]}[]}>}
   */
  async getComplianceSpecForField(fieldKey)
  {
    if (!this._repos) {
      return {combos: []}
    }
    let mainEntry = await this._repos.rulesetFields.get(fieldKey)
    if (mainEntry?.templateId) {
      let compiled = await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
      return {combos: compiled?.optimized?.combos ?? []}
    }
    return {combos: []}
  }
}

/** Max cursor pages scanned per search query (bounded substring scan). */
const RULESET_SEARCH_SCAN_PAGE_CAP = 10

class RulesetFieldRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
    /** @type {Map<string, {revisionId: *, compiled: *}>} */
    this._compiledCache = new Map()
    /** @type {string|null} */
    this._compilingFieldKey = null
  }

  invalidateCompiledCache(fieldKey = null)
  {
    if (fieldKey == null) {
      this._compiledCache.clear()
      return
    }
    this._compiledCache.delete(fieldKey)
  }

  /**
   * @param {string} fieldKey
   * @param {BrazenStorageRepositories} repos
   * @param {boolean} [force]
   * @return {Promise<*|null>}
   */
  async getCompiledField(fieldKey, repos, force = false)
  {
    let meta = await repos.meta.get()
    let revisionId = meta?.revisionId
    let configRev = typeof repos.getConfigRevision === 'function' ? repos.getConfigRevision() : 0
    let cached = this._compiledCache.get(fieldKey)
    if (!force && cached && cached.revisionId === revisionId && cached.configRev === configRev) {
      return cached.compiled
    }
    if (this._compilingFieldKey === fieldKey) {
      return cached?.compiled ?? null
    }
    this._compilingFieldKey = fieldKey
    let compiled = null
    try {
      compiled = await compileRulesetField(repos, fieldKey)
      if (compiled) {
        this._compiledCache.set(fieldKey, {revisionId, configRev, compiled})
      }
    } finally {
      this._compilingFieldKey = null
    }
    if (compiled && (fieldKey === 'filename-tag-ignore-list' ||
        fieldKey === 'filename-tag-substitutions')) {
      await repos.tagRuntime?.refreshDownloadRulesetMaps?.()
    }
    return compiled
  }

  /**
   * @param {string} fieldKey
   * @param {BrazenStorageRepositories} repos
   * @return {Promise<*|null>}
   */
  async compileField(fieldKey, repos)
  {
    this.invalidateCompiledCache(fieldKey)
    return this.getCompiledField(fieldKey, repos, true)
  }

  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('rulesetFields')
    }
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<*|null>}
   */
  async get(fieldKey)
  {
    return this._storage.get(IDB_STORE_RULESET_FIELDS, fieldKey)
  }

  /**
   * @param {*} row
   * @return {Promise<*>}
   */
  async upsert(row)
  {
    let now = Date.now()
    let existing = await this.get(row.fieldKey)
    let merged = {
      fieldKey: row.fieldKey,
      templateId: row.templateId ?? existing?.templateId ?? '',
      templateConfig: row.templateConfig ?? existing?.templateConfig ?? {},
      config: row.config ?? existing?.config ?? {},
      updatedAt: now,
    }
    await this._storage.put(IDB_STORE_RULESET_FIELDS, merged)
    await this._metaRepo.bumpRevision()
    this.invalidateCompiledCache(row.fieldKey)
    this._notifyChange()
    return merged
  }
}

class RulesetEntryRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
    /** @type {RulesetFieldRepository|null} */
    this._rulesetFieldsRepo = null
  }

  _bindRulesetFieldsRepo(rulesetFieldsRepo)
  {
    this._rulesetFieldsRepo = rulesetFieldsRepo
  }

  _invalidateFieldCache(fieldKey)
  {
    this._rulesetFieldsRepo?.invalidateCompiledCache(fieldKey)
  }

  _notifyChange(fieldKey = null)
  {
    if (fieldKey) {
      this._invalidateFieldCache(fieldKey)
    }
    if (this._onChange) {
      this._onChange('rulesetEntries')
    }
  }

  /**
   * @param {string} fieldKey
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: number|null}>}
   */
  async listInitial(fieldKey, limit = 50)
  {
    return this._listByFieldKey(fieldKey, null, limit)
  }

  /**
   * @param {*} entry
   * @param {string} needle lowercased search needle (may be empty)
   * @param {string|null} groupLabel
   * @return {boolean}
   * @private
   */
  _entryMatchesSearchFilters(entry, needle, groupLabel)
  {
    if (groupLabel !== null && entry.groupLabel !== groupLabel) {
      return false
    }
    if (!needle) {
      return true
    }
    let hay = ((entry.rawLine ?? '') + ' ' + (entry.comment ?? '')).toLowerCase()
    return hay.includes(needle)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<number>}
   * @private
   */
  async _countByFieldKeyIndex(fieldKey)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_RULESET_ENTRIES], 'readonly')
    let index = tx.objectStore(IDB_STORE_RULESET_ENTRIES).index('fieldKey_sortOrder')
    let range = IDBKeyRange.bound([fieldKey, 0], [fieldKey, Number.MAX_SAFE_INTEGER])
    return promisifyRequest(index.count(range))
  }

  /**
   * @param {string} fieldKey
   * @param {{query?: string, groupQuery?: string|null}} [options]
   * @return {Promise<number>}
   */
  async countForField(fieldKey, options = {})
  {
    let needle = String(options.query ?? '').trim().toLowerCase()
    let groupLabel = options.groupQuery ?? null
    if (groupLabel !== null && !String(groupLabel).trim()) {
      groupLabel = null
    }
    if (!needle && groupLabel === null) {
      return this._countByFieldKeyIndex(fieldKey)
    }
    let total = 0
    let scanCursor = null
    while (true) {
      let page = await this._listByFieldKey(fieldKey, scanCursor, 200)
      if (!page.entries.length) {
        break
      }
      for (let entry of page.entries) {
        if (this._entryMatchesSearchFilters(entry, needle, groupLabel)) {
          total++
        }
      }
      if (!page.nextCursor) {
        break
      }
      scanCursor = page.nextCursor
    }
    return total
  }

  /**
   * @param {string} fieldKey
   * @param {number|null} cursor entryId cursor
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: number|null}>}
   */
  async _listByFieldKey(fieldKey, cursor = null, limit = 50)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_RULESET_ENTRIES], 'readonly')
    let index = tx.objectStore(IDB_STORE_RULESET_ENTRIES).index('fieldKey_sortOrder')
    let lower = cursor === null ? [fieldKey, 0] : [fieldKey, cursor + 0.0001]
    let range = IDBKeyRange.bound(lower, [fieldKey, Number.MAX_SAFE_INTEGER])
    let request = index.openCursor(range)
    let entries = []
    let nextCursor = null

    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let row = request.result
        if (!row || entries.length >= limit) {
          if (row) {
            nextCursor = row.value.sortOrder
          }
          resolve()
          return
        }
        entries.push(row.value)
        row.continue()
      }
      request.onerror = () => reject(request.error)
    })

    return {entries, nextCursor}
  }

  /**
   * @param {string} fieldKey
   * @param {string} query
   * @param {{groupLabel?: string|null, cursor?: number|null, limit?: number}} [options]
   * @return {Promise<{entries: *[], nextCursor: number|null}>}
   */
  async search(fieldKey, query, options = {})
  {
    let needle = String(query ?? '').trim().toLowerCase()
    let groupLabel = options.groupLabel ?? null
    if (groupLabel !== null && !String(groupLabel).trim()) {
      groupLabel = null
    }
    let limit = options.limit ?? 50
    let startCursor = options.cursor ?? null
    let matched = []
    let nextCursor = null
    let scanCursor = startCursor
    let pagesScanned = 0

    while (matched.length < limit && pagesScanned < RULESET_SEARCH_SCAN_PAGE_CAP) {
      let page = await this._listByFieldKey(fieldKey, scanCursor, 50)
      pagesScanned++
      if (!page.entries.length) {
        break
      }
      for (let entry of page.entries) {
        if (!this._entryMatchesSearchFilters(entry, needle, groupLabel)) {
          continue
        }
        matched.push(entry)
        if (matched.length >= limit) {
          nextCursor = entry.entryId
          break
        }
      }
      if (matched.length >= limit || !page.nextCursor) {
        nextCursor = page.nextCursor
        break
      }
      scanCursor = page.nextCursor
    }

    return {entries: matched.slice(0, limit), nextCursor}
  }

  /**
   * @param {string} fieldKey
   * @param {string} [query]
   * @param {number|null} [cursor]
   * @param {number} [limit]
   * @return {Promise<{entries: string[], nextCursor: string|null}>}
   */
  async listGroups(fieldKey, query = '', cursor = null, limit = 50)
  {
    let needle = String(query ?? '').trim().toLowerCase()
    let seen = new Set()
    let groups = []
    let scanCursor = cursor
    let pagesScanned = 0

    while (groups.length < limit && pagesScanned < RULESET_SEARCH_SCAN_PAGE_CAP) {
      let page = await this._listByFieldKey(fieldKey, scanCursor, 50)
      pagesScanned++
      if (!page.entries.length) {
        break
      }
      for (let entry of page.entries) {
        let label = entry.groupLabel
        if (!label || seen.has(label)) {
          continue
        }
        seen.add(label)
        if (!needle || label.toLowerCase().includes(needle)) {
          groups.push(label)
        }
        if (groups.length >= limit) {
          break
        }
      }
      if (groups.length >= limit || !page.nextCursor) {
        break
      }
      scanCursor = page.nextCursor
    }

    groups.sort(naturalSortCompare)
    return {
      entries: groups.slice(0, limit),
      nextCursor: scanCursor,
    }
  }

  /**
   * @param {string} fieldKey
   * @param {string} rawLine
   * @return {Promise<*|null>}
   */
  async findByRawLine(fieldKey, rawLine)
  {
    let line = String(rawLine ?? '').trim()
    if (!line) {
      return null
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_RULESET_ENTRIES], 'readonly')
    let index = tx.objectStore(IDB_STORE_RULESET_ENTRIES).index('fieldKey_rawLine')
    let request = index.openCursor(IDBKeyRange.only([fieldKey, line]))
    return new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let row = request.result
        resolve(row ? row.value : null)
      }
      request.onerror = () => reject(request.error)
    })
  }

  /**
   * @param {string} fieldKey
   * @param {*} entry
   * @return {Promise<*>}
   */
  async add(fieldKey, entry)
  {
    let payload = entry.payload ?? {}
    if (await rulesetRowConflictsWithOtherField(this, fieldKey, payload)) {
      throw new Error('Ruleset attribute conflict: ' + fieldKey)
    }
    let rawLine = String(entry.rawLine ?? '').trim()
    if (entry.entryId == null && rawLine) {
      let duplicate = await this.findByRawLine(fieldKey, rawLine)
      if (duplicate) {
        let merged = mergeSoleTagRulesetDuplicate(duplicate, entry)
        if (merged) {
          return this.update(merged)
        }
        return duplicate
      }
    }
    let entryId = entry.entryId ?? await this._metaRepo.allocateEntryId('nextRulesetEntryId')
    let now = Date.now()
    let row = {
      entryId,
      fieldKey,
      groupLabel: entry.groupLabel ?? null,
      sortOrder: Number.isFinite(entry.sortOrder) ? entry.sortOrder : now,
      payload: entry.payload ?? {},
      rawLine: entry.rawLine ?? '',
      comment: entry.comment ?? '',
      updatedAt: now,
    }
    await this._storage.put(IDB_STORE_RULESET_ENTRIES, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange(fieldKey)
    return row
  }

  /**
   * @param {*} row
   * @return {Promise<*>}
   */
  async update(row)
  {
    if (await rulesetRowConflictsWithOtherField(this, row.fieldKey, row.payload ?? {}, {logSkip: false})) {
      throw new Error('Ruleset attribute conflict: ' + row.fieldKey)
    }
    if (!Number.isFinite(row.sortOrder)) {
      let existing = row.entryId != null ?
          await this._storage.get(IDB_STORE_RULESET_ENTRIES, row.entryId) : null
      row.sortOrder = Number.isFinite(existing?.sortOrder) ? existing.sortOrder : Date.now()
    }
    row.updatedAt = Date.now()
    await this._storage.put(IDB_STORE_RULESET_ENTRIES, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange(row.fieldKey)
    return row
  }

  /**
   * @param {number} entryId
   * @return {Promise<void>}
   */
  async remove(entryId)
  {
    let existing = await this._storage.get(IDB_STORE_RULESET_ENTRIES, entryId)
    await this._storage.delete(IDB_STORE_RULESET_ENTRIES, entryId)
    await this._metaRepo.bumpRevision()
    this._notifyChange(existing?.fieldKey ?? null)
  }

  /**
   * @param {string} fieldKey
   * @param {string} groupLabel
   * @return {Promise<void>}
   */
  async removeGroup(fieldKey, groupLabel)
  {
    let page = await this._listByFieldKey(fieldKey, null, 1000)
    for (let entry of page.entries) {
      if (entry.groupLabel === groupLabel) {
        await this._storage.delete(IDB_STORE_RULESET_ENTRIES, entry.entryId)
      }
    }
    await this._metaRepo.bumpRevision()
    this._notifyChange(fieldKey)
  }

  /**
   * @param {string} fieldKey
   * @param {number[]} entryIds
   * @return {Promise<void>}
   */
  async reorder(fieldKey, entryIds)
  {
    let rowsById = new Map()
    for (let entryId of entryIds) {
      let row = await this._storage.get(IDB_STORE_RULESET_ENTRIES, entryId)
      if (row && row.fieldKey === fieldKey) {
        rowsById.set(entryId, row)
      }
    }
    let sortOrder = 0
    let changed = false
    for (let entryId of entryIds) {
      let row = rowsById.get(entryId)
      if (!row) {
        continue
      }
      if (row.sortOrder !== sortOrder) {
        row.sortOrder = sortOrder
        await this._storage.put(IDB_STORE_RULESET_ENTRIES, row)
        changed = true
      }
      sortOrder++
    }
    if (!changed) {
      return
    }
    await this._metaRepo.bumpRevision()
    this._notifyChange(fieldKey)
  }

  /**
   * Rows with a non-finite `sortOrder` are omitted from the `fieldKey_sortOrder` index and therefore
   * invisible to list/compile/autosort until healed.
   * @param {string} fieldKey
   * @return {Promise<*[]>}
   * @private
   */
  async _listAllForFieldByEntryId(fieldKey)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_RULESET_ENTRIES], 'readonly')
    let index = tx.objectStore(IDB_STORE_RULESET_ENTRIES).index('fieldKey_entryId')
    let range = IDBKeyRange.bound([fieldKey, 0], [fieldKey, Number.MAX_SAFE_INTEGER])
    let request = index.openCursor(range)
    let all = []
    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor) {
          resolve()
          return
        }
        all.push(cursor.value)
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })
    return all
  }

  /**
   * Rewrite ruleset rows whose `sortOrder` is missing or non-numeric so they re-enter the sort index.
   * @param {string} fieldKey
   * @return {Promise<number>} count of healed rows
   */
  async healSortOrderOrphans(fieldKey)
  {
    let orphans = (await this._listAllForFieldByEntryId(fieldKey))
        .filter((row) => !Number.isFinite(row.sortOrder))
    if (!orphans.length) {
      return 0
    }
    let visible = await this.listAllForField(fieldKey)
    let nextOrder = visible.reduce((max, row) => Math.max(max, row.sortOrder ?? 0), -1) + 1
    for (let row of orphans) {
      row.sortOrder = nextOrder++
      row.updatedAt = Date.now()
      await this._storage.put(IDB_STORE_RULESET_ENTRIES, row)
    }
    await this._metaRepo.bumpRevision()
    this._notifyChange(fieldKey)
    return orphans.length
  }

  async listAllForField(fieldKey)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_RULESET_ENTRIES], 'readonly')
    let index = tx.objectStore(IDB_STORE_RULESET_ENTRIES).index('fieldKey_sortOrder')
    let range = IDBKeyRange.bound([fieldKey, 0], [fieldKey, Number.MAX_SAFE_INTEGER])
    let request = index.openCursor(range)
    let all = []
    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor) {
          resolve()
          return
        }
        all.push(cursor.value)
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })
    return all
  }
}

class BrazenStorageRepositories
{
  /**
   * @param {string} scriptPrefix
   * @param {function(string): void|null} [onRepositoryChange]
   * @param {function(number|string, {source?: string, domainConfigSeq?: number, domainTagsSeq?: number, domainLedgerSeq?: number}): void|null} [onRevisionBump] Called after every local
   *   `meta.revisionId` bump so the script can track config revisions it caused (ledger-only bumps pass
   *   `{source:'ledger'}` and must not advance the config-loaded cursor).
   */
  constructor(scriptPrefix, onRepositoryChange = null, onRevisionBump = null)
  {
    this.storage = new BrazenIndexedDBStorage(scriptPrefix)
    this.meta = new MetaRepository(this.storage, onRevisionBump)
    this.settings = new SettingsRepository(this.storage)
    this.bookmarks = new BookmarkRepository(this.storage, this.meta, onRepositoryChange)
    this.ledger = new LedgerRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadResolutionQueue = new DownloadResolutionQueueRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadQueue = new DownloadQueueRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadManagerState = new DownloadManagerStateRepository(this.storage, this.meta, onRepositoryChange)
    /** @type {(() => boolean)|null} */
    this._coordinatorGuard = null
    this.download = new DownloadCoordinatorRepository(
        this.downloadResolutionQueue,
        this.downloadQueue,
        this.downloadManagerState,
        () => this._coordinatorGuard?.() ?? false,
    )
    this.rulesetFields = new RulesetFieldRepository(this.storage, this.meta, onRepositoryChange)
    this.rulesetEntries = new RulesetEntryRepository(this.storage, this.meta, onRepositoryChange)
    this.rulesetEntries._bindRulesetFieldsRepo(this.rulesetFields)
    this.tags = new TagRepository(this.storage, this.meta)
    this.tagRuntime = new TagRuntime(this.tags, this)
    /** @type {Set<string>|null} */
    this._querySyntaxKeysCache = null
    /** @type {{getConfigRevision?: () => number, getTagsRevision?: () => number, getLedgerRevision?: () => number}|null} */
    this._revisionSignals = null
    /** @type {{persistMountedSettings?: () => Promise<void>}|null} */
    this._configHandlers = null
  }

  /**
   * @param {{getConfigRevision?: () => number, getTagsRevision?: () => number, getLedgerRevision?: () => number}} provider
   */
  bindRevisionSignals(provider)
  {
    this._revisionSignals = provider ?? null
  }

  /**
   * Coordinator config persist hooks (Configuration Manager registers at init).
   * @param {{persistMountedSettings?: () => Promise<void>}|null} handlers
   */
  bindConfigHandlers(handlers)
  {
    this._configHandlers = handlers ?? null
  }

  /**
   * Reactor kernel coordinator predicate (Download Manager binds at init).
   * @param {(() => boolean)|null} guard
   */
  bindCoordinatorGuard(guard)
  {
    this._coordinatorGuard = typeof guard === 'function' ? guard : null
  }

  /**
   * @private
   */
  _assertCoordinatorWrite()
  {
    if (!this._coordinatorGuard?.()) {
      throw new Error('BrazenStorageRepositories: coordinator role required for write')
    }
  }

  /**
   * @return {number}
   */
  getConfigRevision()
  {
    return this._revisionSignals?.getConfigRevision?.() ?? 0
  }

  /**
   * @return {number}
   */
  getTagsRevision()
  {
    return this._revisionSignals?.getTagsRevision?.() ?? 0
  }

  /**
   * @return {number}
   */
  getLedgerRevision()
  {
    return this._revisionSignals?.getLedgerRevision?.() ?? 0
  }

  /**
   * Reactor kernel cold-start snapshot (config revision stamps + full dm.state.* from IDB).
   * @return {Promise<Record<string, unknown>>}
   */
  async kernelHydrate()
  {
    let snapshot = {
      'config.revision': this.getConfigRevision(),
      'config.ledgerRevision': this.getLedgerRevision(),
      'tags.revision': this.getTagsRevision(),
    }
    let state = await this.downloadManagerState.get()
    Object.assign(snapshot, buildDmStateHydrateSnapshot(state))
    let resolutionPending = await this.downloadResolutionQueue.countActive()
    let downloadPending = await this.downloadQueue.countActive()
    snapshot['dm.pendingResolutionCount'] = resolutionPending
    snapshot['dm.pendingDownloadCount'] = downloadPending
    return snapshot
  }

  /**
   * Coordinator-only IDB writes for Reactor Commands (v2 §5 registry).
   * @param {object} command
   * @param {object[]} _patches
   * @param {number} _seq
   * @return {Promise<void>}
   */
  async kernelWriteThrough(command, _patches, _seq)
  {
    this._assertCoordinatorWrite()
    if (!command || typeof command.type !== 'string') {
      throw new Error('BrazenStorageRepositories.kernelWriteThrough: command.type required')
    }
    let type = command.type
    let payload = command.payload ?? {}

    switch (type) {
      case 'enqueue-download':
        await this.download.enqueueResolution(payload.row)
        return
      case 'dequeue-download':
        await this.download.dequeue(payload)
        return
      case 'toggle-paused':
        await this.downloadManagerState.setPaused(Boolean(payload.paused))
        return
      case 'clear-download-queue':
        await this.download.clearDownloadQueue()
        return
      case 'confirm-tag-discovery':
        // Coordinator job runs domain confirm logic (RW-3 DM).
        return
      case 'skip-tag-discovery':
        await this.download.skipTagDiscovery()
        return
      case 'clear-hi-lane': {
        let ctx = payload.context === 'download' ? 'download' : 'resolution'
        await this.download.clearHumanInteractionLane(ctx)
        return
      }
      case 'claim-tag-discovery-panel': {
        let tabId = payload.tabId ?? null
        await this.download.mutateState((state) => {
          state.tagDiscoveryPanelTabId = tabId
        })
        return
      }
      case 'claim-hi-prompt': {
        let ctx = payload.context === 'download' ? 'download' : 'resolution'
        let tabId = payload.tabId ?? null
        await this.download.mutateState((next) => {
          normalizeHumanInteractionState(next)
          let lane = next.humanInteraction[ctx]
          if (!lane) {
            return
          }
          lane.promptTabId = tabId
        })
        return
      }
      case 'write-setting': {
        let fieldKey = payload.fieldKey
        if (!fieldKey) {
          throw new Error('BrazenStorageRepositories.kernelWriteThrough: write-setting requires fieldKey')
        }
        await this.settings.putField(fieldKey, payload.value, payload.optimized ?? null)
        await this.meta.bumpRevision()
        return
      }
      case 'config-save': {
        if (typeof this._configHandlers?.persistMountedSettings !== 'function') {
          throw new Error('BrazenStorageRepositories.kernelWriteThrough: config-save requires bindConfigHandlers')
        }
        await this._configHandlers.persistMountedSettings()
        return
      }
      case 'config-sync':
        // Persist optional dirty fields; coordinator job applies merge (RW-4).
        if (payload.persistDirty && Array.isArray(payload.dirtyFields)) {
          for (let entry of payload.dirtyFields) {
            if (!entry?.fieldKey) {
              continue
            }
            await this.settings.putField(entry.fieldKey, entry.value, entry.optimized ?? null)
          }
          await this.meta.bumpRevision()
        }
        return
      case 'custom':
        if (payload?.name === 'pipeline-pump') {
          return
        }
        break
      case 'spawn-job':
      case 'cancel-job':
      case 'request-coordinator-steal':
        return
      default:
        break
    }
    if (!REACTOR_KERNEL_COMMAND_TYPES.includes(type) &&
        type !== 'custom' && type !== 'spawn-job' && type !== 'cancel-job') {
      throw new Error(`BrazenStorageRepositories.kernelWriteThrough: unhandled command type "${type}"`)
    }
  }

  /**
   * Post-commit patch descriptors for Reactor handleCommand (v2 §5 catalog).
   * @param {object} command
   * @return {Promise<{path: string, value: unknown}[]>}
   */
  async kernelPostWritePatches(command)
  {
    let type = command?.type
    let dmTypes = new Set([
      'enqueue-download',
      'dequeue-download',
      'toggle-paused',
      'clear-download-queue',
      'confirm-tag-discovery',
      'skip-tag-discovery',
      'clear-hi-lane',
      'claim-tag-discovery-panel',
      'claim-hi-prompt',
    ])
    if (!dmTypes.has(type)) {
      return []
    }
    /** @type {{path: string, value: unknown}[]} */
    let patches = []
    let state = await this.downloadManagerState.get()
    state = normalizeHumanInteractionState(state)
    let sansId = structuredClone(state)
    delete sansId.id
    let signals = globalThis.BrazenSignals
    /**
     * @param {string} path
     * @return {unknown}
     */
    let readAtomValue = (path) => {
      let atom = signals?.atom?.(path, null)
      return typeof atom?.read === 'function' ? atom.read() : atom?.value
    }
    /**
     * @param {string} path
     * @param {unknown} next
     * @return {boolean}
     */
    let patchValueChanged = (path, next) => {
      try {
        let prev = readAtomValue(path)
        if (prev == null && next == null) {
          return false
        }
        return JSON.stringify(prev) !== JSON.stringify(next)
      } catch (_e) {
        return true
      }
    }
    /**
     * @param {string} path
     * @param {unknown} value
     */
    let pushIfChanged = (path, value) => {
      if (patchValueChanged(path, value)) {
        patches.push({path, value: structuredClone(value)})
      }
    }
    let redundantSnapshot = !patchValueChanged('dm.state.snapshot', sansId)
    pushIfChanged('dm.state.snapshot', sansId)
    for (let [key, value] of Object.entries(state)) {
      if (key === 'id') {
        continue
      }
      pushIfChanged(`dm.state.${key}`, value)
    }
    let resolutionPending = await this.downloadResolutionQueue.countActive()
    let downloadPending = await this.downloadQueue.countActive()
    pushIfChanged('dm.pendingResolutionCount', resolutionPending)
    pushIfChanged('dm.pendingDownloadCount', downloadPending)
    let profiler = globalThis.__brazenReactor
    let paths = patches.map((patch) => patch.path)
    profiler?.mark?.('idb', 'kernelPostWritePatches', {
      key: type,
      data: {
        paths,
        pathCount: paths.length,
        redundantSnapshot,
        activeCause: profiler?.activeCauseLabel?.(),
      },
    })
    return patches
  }

  /**
   * @return {Set<string>|null}
   */
  getQuerySyntaxKeysSync()
  {
    return this._querySyntaxKeysCache
  }

  /**
   * @param {Set<string>|Iterable<string>} keys
   */
  setQuerySyntaxKeysCache(keys)
  {
    this._querySyntaxKeysCache = keys instanceof Set ? keys : new Set(keys)
  }

  /**
   * @return {Promise<Set<string>>}
   */
  async getQuerySyntaxKeys()
  {
    if (this._querySyntaxKeysCache) {
      return this._querySyntaxKeysCache
    }
    let apis = await this.storage.get(IDB_STORE_APIS, 'apis')
    let gelbooru = apis?.entries?.find((row) => row.name === 'gelbooru')
    let keys = Array.isArray(gelbooru?.querySyntaxKeys) ? gelbooru.querySyntaxKeys : null
    if (keys?.length) {
      this._querySyntaxKeysCache = new Set(keys)
      return this._querySyntaxKeysCache
    }
    if (typeof GELBOORU_QUERY_SYNTAX_KEYS !== 'undefined') {
      this._querySyntaxKeysCache = GELBOORU_QUERY_SYNTAX_KEYS
      return this._querySyntaxKeysCache
    }
    this._querySyntaxKeysCache = new Set()
    return this._querySyntaxKeysCache
  }
}

/**
 * Expand legacy OR rules into separate lines (no | in output).
 * @param {string} line
 * @return {string[]}
 */
function expandOrRuleLine(line)
{
  let commentIndex = line.indexOf('//')
  let rulePart = commentIndex >= 0 ? line.slice(0, commentIndex) : line
  let comment = commentIndex >= 0 ? line.slice(commentIndex) : ''
  let segments = rulePart.split('&')
  let expanded = ['']
  for (let segment of segments) {
    let alternatives = segment.split('|').map((part) => part.trim()).filter(Boolean)
    if (alternatives.length <= 1) {
      expanded = expanded.map((prefix) => prefix ? prefix + '&' + segment.trim() : segment.trim())
      continue
    }
    let next = []
    for (let prefix of expanded) {
      for (let alt of alternatives) {
        next.push(prefix ? prefix + '&' + alt : alt)
      }
    }
    expanded = next
  }
  return expanded.filter(Boolean).map((rule) => rule + comment)
}

/**
 * IEEE / PKZIP CRC-32 over a byte array (polynomial 0xEDB88320).
 * @param {Uint8Array} bytes
 * @return {number}
 */
function zipCrc32(bytes)
{
  let crc = 0xffffffff
  for (let i = 0; i < bytes.length; i++) {
    crc ^= bytes[i]
    for (let bit = 0; bit < 8; bit++) {
      crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
    }
  }
  return (crc ^ 0xffffffff) >>> 0
}

/**
 * Minimal ZIP writer (STORE method, no compression) for backup bundles.
 */
class BrazenZipWriter
{
  constructor()
  {
    this._files = []
  }

  /**
   * @param {string} name
   * @param {string} content
   */
  addFile(name, content)
  {
    this._files.push({name, content})
  }

  /**
   * @return {Blob}
   */
  build()
  {
    let parts = []
    let centralDirectory = []
    let offset = 0

    for (let file of this._files) {
      let nameBytes = new TextEncoder().encode(file.name)
      let dataBytes = new TextEncoder().encode(file.content)
      let crc = zipCrc32(dataBytes)
      let header = new DataView(new ArrayBuffer(30))
      header.setUint32(0, 0x04034b50, true)
      header.setUint16(4, 20, true)
      header.setUint16(6, 0, true)
      header.setUint16(8, 0, true)
      header.setUint16(10, 0, true)
      header.setUint16(12, 0, true)
      header.setUint32(14, crc, true)
      header.setUint32(18, dataBytes.length, true)
      header.setUint32(22, dataBytes.length, true)
      header.setUint16(26, nameBytes.length, true)
      header.setUint16(28, 0, true)

      parts.push(new Uint8Array(header.buffer), nameBytes, dataBytes)

      let cdHeader = new DataView(new ArrayBuffer(46))
      cdHeader.setUint32(0, 0x02014b50, true)
      cdHeader.setUint16(4, 20, true)
      cdHeader.setUint16(6, 20, true)
      cdHeader.setUint16(8, 0, true)
      cdHeader.setUint16(10, 0, true)
      cdHeader.setUint16(12, 0, true)
      cdHeader.setUint16(14, 0, true)
      cdHeader.setUint32(16, crc, true)
      cdHeader.setUint32(20, dataBytes.length, true)
      cdHeader.setUint32(24, dataBytes.length, true)
      cdHeader.setUint16(28, nameBytes.length, true)
      cdHeader.setUint16(30, 0, true)
      cdHeader.setUint16(32, 0, true)
      cdHeader.setUint16(34, 0, true)
      cdHeader.setUint16(36, 0, true)
      cdHeader.setUint32(38, 0, true)
      cdHeader.setUint32(42, offset, true)

      centralDirectory.push(new Uint8Array(cdHeader.buffer), nameBytes)
      offset += 30 + nameBytes.length + dataBytes.length
    }

    let centralStart = offset
    for (let part of centralDirectory) {
      parts.push(part)
      offset += part.length
    }

    let end = new DataView(new ArrayBuffer(22))
    end.setUint32(0, 0x06054b50, true)
    end.setUint16(4, 0, true)
    end.setUint16(6, 0, true)
    end.setUint16(8, this._files.length, true)
    end.setUint16(10, this._files.length, true)
    end.setUint32(12, centralDirectory.reduce((sum, part) => sum + part.length, 0), true)
    end.setUint32(16, centralStart, true)
    end.setUint16(20, 0, true)
    parts.push(new Uint8Array(end.buffer))

    return new Blob(parts, {type: 'application/zip'})
  }
}

/**
 * Minimal ZIP reader (STORE method only) for backup restore.
 */
class BrazenZipReader
{
  /**
   * @param {ArrayBuffer} buffer
   * @return {Record<string, string>}
   */
  static parse(buffer)
  {
    let view = new DataView(buffer)
    let files = {}
    let offset = 0

    while (offset + 30 <= buffer.byteLength) {
      let signature = view.getUint32(offset, true)
      if (signature === 0x04034b50) {
        let compression = view.getUint16(offset + 8, true)
        let compressedSize = view.getUint32(offset + 18, true)
        let nameLength = view.getUint16(offset + 26, true)
        let extraLength = view.getUint16(offset + 28, true)
        let nameStart = offset + 30
        let name = new TextDecoder().decode(new Uint8Array(buffer, nameStart, nameLength))
        let dataStart = nameStart + nameLength + extraLength
        if (compression !== 0) {
          throw new Error('Compressed zip entries are not supported: ' + name)
        }
        let dataBytes = new Uint8Array(buffer, dataStart, compressedSize)
        files[name] = new TextDecoder().decode(dataBytes)
        offset = dataStart + compressedSize
        continue
      }
      if (signature === 0x02014b50 || signature === 0x06054b50) {
        break
      }
      break
    }

    return files
  }
}

class BrazenLegacyImporter
{
  /**
   * @param {BrazenStorageRepositories} repos
   * @param {BrazenConfigurationManager} manager
   */
  constructor(repos, manager)
  {
    this._repos = repos
    this._manager = manager
    this._summary = {}
  }

  /**
   * @return {Promise<string[]>}
   */
  async run()
  {
    let sources = []
    let prefix = this._manager._scriptPrefix
    let legacyPrefix = this._manager._legacyScriptPrefix
    this._errors = []

    await this._importSettingsRevision(prefix, legacyPrefix, sources)
    await this._importSettings(prefix, legacyPrefix, sources)
    await this._importBookmarks(prefix, legacyPrefix, sources)
    await this._verifyAndPurgeLegacy(prefix, legacyPrefix, sources)

    console.log('[BrazenIDB] legacy import summary:', this._summary, 'sources:', sources, 'errors:', this._errors)
    if (this._summary.importedTotal > 0) {
      alert('Brazen script — settings migrated to IndexedDB (' + this._summary.importedTotal + ' records).')
    }
    return sources
  }

  async _importSettingsRevision(prefix, legacyPrefix, sources)
  {
    for (let backend of LEGACY_STORAGE_BACKENDS) {
      for (let activePrefix of [prefix, legacyPrefix]) {
        if (!activePrefix) {
          continue
        }
        let revision = backend.read(activePrefix + 'settings-id', null)
        if (typeof revision === 'number' || (typeof revision === 'string' && revision.trim())) {
          let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
          meta.revisionId = String(revision)
          await this._repos.meta.put(meta)
          if (!sources.includes(backend.source)) {
            sources.push(backend.source)
          }
          this._summary.settingsRevision = meta.revisionId
          return
        }
      }
    }
  }

  async _verifyAndPurgeLegacy(prefix, legacyPrefix, sources)
  {
    let settingsDoc = await this._repos.settings.getDocument()
    let bookmarkCount = await this._repos.storage.count(IDB_STORE_BOOKMARKS)
    let tagCount = await this._repos.storage.count(IDB_STORE_TAGS)

    let expectedSettings = this._summary.settings ?? 0
    if (expectedSettings && Object.keys(settingsDoc).length <= 1) {
      throw new Error('Legacy settings import verification failed')
    }
    if ((this._summary.bookmarksExpected ?? 0) > 0 && bookmarkCount === 0) {
      throw new Error('Legacy bookmark import verification failed')
    }

    this._summary.importedTotal = (this._summary.settings ?? 0) + bookmarkCount + tagCount
    this._summary.migrationSources = [...sources]

    for (let backend of LEGACY_STORAGE_BACKENDS) {
      this._safeRemove(backend, prefix + 'settings')
      this._safeRemove(backend, prefix + 'settings-id')
      this._safeRemove(backend, prefix + 'bookmarks')
      if (legacyPrefix && legacyPrefix !== prefix) {
        this._safeRemove(backend, legacyPrefix + 'settings')
        this._safeRemove(backend, legacyPrefix + 'settings-id')
        this._safeRemove(backend, legacyPrefix + 'bookmarks')
      }
    }
  }

  _safeRemove(backend, key)
  {
    if (!key) {
      return
    }
    try {
      if (backend.read(key, null) !== null) {
        backend.remove(key)
      }
    } catch (error) {
      console.log('[BrazenIDB] failed to purge legacy key:', key, error)
    }
  }

  async _importSettings(prefix, legacyPrefix, sources)
  {
    let aggregate = null
    for (let backend of LEGACY_STORAGE_BACKENDS) {
      let current = this._coerceSettingsBlob(backend.read(prefix + 'settings', null))
      if (current.settings) {
        aggregate = {...(aggregate ?? {}), ...current.settings}
        if (!sources.includes(backend.source)) {
          sources.push(backend.source)
        }
      } else if (current.invalid) {
        this._errors.push('settings-invalid:' + backend.source)
      }
      if (legacyPrefix && legacyPrefix !== prefix) {
        let legacy = this._coerceSettingsBlob(backend.read(legacyPrefix + 'settings', null))
        if (legacy.settings) {
          aggregate = {...legacy.settings, ...(aggregate ?? {})}
          if (!sources.includes(backend.source)) {
            sources.push(backend.source)
          }
        } else if (legacy.invalid) {
          this._errors.push('legacy-settings-invalid:' + backend.source)
        }
      }
    }
    if (!aggregate) {
      this._summary.settings = 0
      return
    }

    let doc = await this._repos.settings.getDocument()
    let count = 0
    for (let fieldKey in aggregate) {
      if (RULESET_FIELD_KEYS.has(fieldKey)) {
        continue
      }
      let property = fieldKeyToProperty(fieldKey)
      let value = aggregate[fieldKey]
      doc[property] = {
        value,
        optimized: this._manager._computeFieldOptimized(fieldKey, value),
        updatedAt: Date.now(),
      }
      count++
    }
    await this._repos.settings.putDocument(doc)
    this._summary.settings = count
  }

  /**
   * Normalize a driver settings blob. Empty objects, arrays, and whitespace strings are treated as absent (not errors).
   * @param {*} blob
   * @return {{settings: Object|null, invalid: boolean}}
   * @private
   */
  _coerceSettingsBlob(blob)
  {
    if (blob === null || blob === undefined) {
      return {settings: null, invalid: false}
    }
    blob = Utilities.reviveGmStoredValue(blob)
    if (typeof blob === 'string') {
      let trimmed = blob.trim()
      if (!trimmed.length) {
        return {settings: null, invalid: false}
      }
      try {
        blob = JSON.parse(trimmed)
      } catch (error) {
        return {settings: null, invalid: true}
      }
    }
    if (blob && typeof blob === 'object' && 'arrays' in blob && 'objects' in blob && 'properties' in blob) {
      try {
        blob = Utilities.objectFromJSON(JSON.stringify(blob))
      } catch (error) {
        return {settings: null, invalid: true}
      }
    }
    if (!blob || typeof blob !== 'object' || Array.isArray(blob) || !Object.keys(blob).length) {
      return {settings: null, invalid: false}
    }
    return {settings: blob, invalid: false}
  }

  async _importBookmarks(prefix, legacyPrefix, sources)
  {
    let rows = []
    let expected = 0
    let keys = [prefix + 'bookmarks']
    if (legacyPrefix) {
      keys.push(legacyPrefix + 'bookmarks')
    }
    for (let key of keys) {
      let stored = readLegacyGm(key, null)
      let list = Utilities.coerceBookmarkArray(stored)
      expected += list.length
      for (let bookmark of list) {
        let normalized = this._normalizeBookmarkRow(bookmark)
        if (normalized) {
          rows.push(normalized)
        }
      }
      if (list.length && !sources.includes(LEGACY_SOURCE_GM)) {
        sources.push(LEGACY_SOURCE_GM)
      }
    }

    for (let backend of LEGACY_STORAGE_BACKENDS) {
      for (let activePrefix of [prefix, legacyPrefix]) {
        if (!activePrefix) {
          continue
        }
        let settings = backend.read(activePrefix + 'settings', null)
        let fromSettings = Utilities.coerceBookmarkArray(settings?.bookmarks)
        expected += fromSettings.length
        for (let bookmark of fromSettings) {
          let normalized = this._normalizeBookmarkRow(bookmark)
          if (normalized) {
            rows.push(normalized)
          }
        }
        if (fromSettings.length && !sources.includes(backend.source)) {
          sources.push(backend.source)
        }
      }
    }

    if (rows.length) {
      await this._repos.bookmarks.replaceAll(rows)
    }
    this._summary.bookmarks = rows.length
    this._summary.bookmarksExpected = expected
  }

  _normalizeBookmarkRow(bookmark)
  {
    if (!bookmark || typeof bookmark !== 'object') {
      return null
    }
    let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
    if (!tags.length) {
      return null
    }
    return this._bookmarkToRow(bookmark)
  }

  _bookmarkToRow(bookmark)
  {
    return {
      entryId: null,
      label: bookmark.label ?? '',
      tags: bookmark.tags ?? '',
      url: bookmark.url ?? '',
      sortOrder: 0,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    }
  }

}