import { Directory, File, Paths } from 'expo-file-system'
import { ACCENT_VARIANT_IDS, type AccentVariantId } from 'src/lib/accentVariants'
import type { LoginUserResponse } from 'src/lib/api-types'
import { devlog } from 'src/lib/devnet/log'
import { clearAllDrafts } from 'src/lib/media/draftRegistry'
import {
  accountKeys,
  accountsStorage,
  clearUserKeysForAccount,
  secureStore,
  userKeys,
  userStorage,
} from './storage'

export const MAX_ACCOUNTS = 8

// Future fork-only account types (Mobilizon, Misskey, …) are explicitly out
// of scope — they're rejected at preflight, so the union does not need to
// grow yet.
export type AccountType = 'pixelfed' | 'mastodon'

export type AccountSummary = {
  accountId: string
  type: AccountType
  instance: string
  userId: string
  username: string
  acct: string
  displayName: string
  avatarUrl: string | null
  addedAt: number
  lastActiveAt: number
  needsReauth: boolean
  // Phase 3: filled from token-exchange `expires_in`. Null until a refresh
  // round-trip lands. Pre-emptive refresh reads this on cold start.
  tokenExpiresAt: number | null
  // Phase 5: ms since epoch the device push token was last registered with
  // this account's server. Stale registrations refresh on cold start.
  pushRegisteredAt: number | null
  // Phase 5: true when the account's instance has push gated server-side
  // (admin must request approval from the Pixelfed team —
  // https://docs.pixelfed.org/running-pixelfed/push-notifications.html). The
  // Phase 6 Accounts UI surfaces this as a "Push unavailable on this server"
  // indicator. Defaults to false; flips true after a 404 from the push
  // endpoint, flips back to false after a successful registration.
  pushUnavailable: boolean
  // Mastodon Pass Phase 1 §2.1: integer from `/api/v2/instance.api_versions
  // .mastodon`. Null on Pixelfed accounts and on the (rejected) below-floor
  // Mastodon path. Phase 1 declares the field; Phase 2 populates it from the
  // capability fetch.
  apiVersionsMastodon: number | null
  // Mastodon Pass Phase 1 §2.1: raw nodeinfo `software.name` if it's a
  // Mastodon-shaped fork that we recognise (`'hometown'`, `'glitch'`, …).
  // Always null in v1 because the strict allow-list rejects forks at preflight
  // (§6.1), but we declare it now so a future fork allow-list addition is a
  // pure additive change with no migration.
  mastodonForkSoftware: string | null
  // Per-account accent variant (1..8). Positional default at upsert; user
  // overrides via the Settings → Accounts swatch picker. Persisted so the
  // colour is stable across launches (and not re-derived from list position
  // on every read, which would shift every other account's colour when one
  // is removed mid-list). Forward-compat: missing on old persisted entries,
  // assigned positionally on first read after upgrade — see `readList()`.
  accentVariant: AccentVariantId
  // Soft-delete marker. Per-account sign-out drops tokens and sets this to
  // true so the row hides from Settings → Accounts and from every iteration
  // path (`list()` filters dormant); the registry entry, profile blob, and
  // account-keyed React Query cache slices stay on disk so a re-auth to the
  // same `(instance, userId)` revives the same accountId and lands warm.
  // `clearAll()` ("Sign out of all accounts") nukes dormant entries along
  // with everything else.
  signedOut: boolean
}

export type AccountSecrets = {
  accessToken: string
  refreshToken: string | null
}

export type InstanceAppCredentials = {
  clientId: string
  clientSecret: string
}

// `verify_credentials.acct` returns just `username` for the local user on
// both Pixelfed and Mastodon, which collides across instances when a user has
// the same handle in two registries. Compose the disambiguating
// `@username@instance` form from the registry fields so identity surfaces
// stay unambiguous across multi-account installs.
export function fullHandle(account: AccountSummary): string {
  return `@${account.username}@${account.instance}`
}

// Generate a UUIDv4. Prefer the global crypto.randomUUID() (available in
// React Native ≥0.74); fall back to Math.random — accountIds are local
// registry handles, not security tokens, so a non-CSPRNG fallback is fine.
function generateAccountId(): string {
  const g = globalThis as { crypto?: { randomUUID?: () => string } }
  if (typeof g.crypto?.randomUUID === 'function') return g.crypto.randomUUID()
  // RFC 4122 v4 fallback.
  const hex: string[] = []
  for (let i = 0; i < 256; i++) hex.push((i + 0x100).toString(16).slice(1))
  const r = new Array(16)
  for (let i = 0; i < 16; i++) r[i] = (Math.random() * 256) | 0
  r[6] = (r[6] & 0x0f) | 0x40
  r[8] = (r[8] & 0x3f) | 0x80
  return (
    `${hex[r[0]]}${hex[r[1]]}${hex[r[2]]}${hex[r[3]]}-` +
    `${hex[r[4]]}${hex[r[5]]}-${hex[r[6]]}${hex[r[7]]}-` +
    `${hex[r[8]]}${hex[r[9]]}-` +
    `${hex[r[10]]}${hex[r[11]]}${hex[r[12]]}${hex[r[13]]}${hex[r[14]]}${hex[r[15]]}`
  )
}

function readList(): AccountSummary[] {
  const raw = accountsStorage.getString(accountKeys.list)
  if (!raw) return []
  try {
    const parsed = JSON.parse(raw)
    if (!Array.isArray(parsed)) return []
    // Mastodon Pass Phase 1 §2.1: old persisted entries decode cleanly —
    // missing `apiVersionsMastodon` / `mastodonForkSoftware` (added in this
    // phase) are normalized to null so consumers can `=== null`-test rather
    // than dancing around `undefined`. The forward-compat note in the plan
    // calls this out explicitly: "Boot wipe does not need to fire — this is
    // a forward-compat additive change."
    return (parsed as AccountSummary[]).map((a, idx) => ({
      ...a,
      apiVersionsMastodon: a.apiVersionsMastodon ?? null,
      mastodonForkSoftware: a.mastodonForkSoftware ?? null,
      // Existing accounts predate the variant field — assign positionally on
      // first read after upgrade. The next mutation (`patch`/`upsert`) writes
      // the now-set value through, so this fallback is only consulted once.
      accentVariant:
        a.accentVariant ?? (((idx % ACCENT_VARIANT_IDS.length) + 1) as AccentVariantId),
      signedOut: a.signedOut ?? false,
    }))
  } catch {
    return []
  }
}

// Dev-only mirror of the AccountStore registry into the iOS Documents
// container so `scripts/dev/snapshot.sh` can rsync it off-device for
// offline inspection. `AccountSummary` carries no tokens by design
// (those live in the keychain), so the mirror is safe as-is. Best-
// effort: the underlying MMKV write is the source of truth, and a
// failed disk mirror must never block the registry mutation.
let _accountsMirrorDir: Directory | null = null
function mirrorAccountsListToDisk(list: AccountSummary[]): void {
  if (!__DEV__) return
  try {
    if (!_accountsMirrorDir) {
      _accountsMirrorDir = new Directory(Paths.document, 'devnet')
      _accountsMirrorDir.create({ intermediates: true, idempotent: true })
    }
    const file = new File(_accountsMirrorDir, 'accounts.json')
    if (file.exists) file.delete()
    file.create()
    file.write(JSON.stringify(list, null, 2))
  } catch {
    // Best-effort.
  }
}

function writeList(list: AccountSummary[]): void {
  accountsStorage.set(accountKeys.list, JSON.stringify(list))
  mirrorAccountsListToDisk(list)
}

type Listener = () => void
const listeners = new Set<Listener>()

// Snapshot caches for useSyncExternalStore. The underlying MMKV reads return
// fresh objects/arrays on every call (JSON.parse + .find), which violates
// useSyncExternalStore's "same reference unless data changed" contract and
// throws "result of getSnapshot should be cached" → infinite render loop.
// Refresh all three on every `emit()` so subscribers see a new reference iff
// a mutation has actually occurred. The userCache snapshot lives here (rather
// than mirrored as React state in AuthProvider) so `(activeId, userCache)` is
// an atomic read on every render — no double-flip window between the active-
// pointer flip and the React-state effect that would otherwise leave query
// keys built from a mismatched (newId, oldUserCache) pair.
let _listSnapshot: AccountSummary[] = readList()
// Visible-list snapshot — `_listSnapshot` minus dormant (`signedOut`) entries.
// Cached separately because `useAccountList()` reads `AccountStore.list` as a
// useSyncExternalStore snapshot getter; recomputing the filter on every read
// would mint a fresh array reference and trip the "result of getSnapshot
// should be cached" loop. Refreshed in `rebuildSnapshots()`.
let _visibleListSnapshot: AccountSummary[] = _listSnapshot.filter((a) => !a.signedOut)
let _activeAccountSnapshot: AccountSummary | null = computeActiveAccount(_listSnapshot)
let _activeUserCacheSnapshot: LoginUserResponse | null = computeActiveUserCache()

function computeActiveAccount(list: AccountSummary[]): AccountSummary | null {
  const id = accountsStorage.getString(accountKeys.activeId) ?? null
  if (!id) return null
  return list.find((a) => a.accountId === id) ?? null
}

function computeActiveUserCache(): LoginUserResponse | null {
  const id = accountsStorage.getString(accountKeys.activeId) ?? null
  if (!id) return null
  const blob = userStorage.getString(userKeys.profile(id)) ?? null
  if (!blob) return null
  let parsed: LoginUserResponse | null = null
  try {
    parsed = JSON.parse(blob) as LoginUserResponse
  } catch {
    userStorage.remove(userKeys.profile(id))
    return null
  }
  // Self-heal against pre-Phase-2.1 cross-account writes. The blob's `id` is
  // the server-issued user id; it must match the registry's `userId` for the
  // active account. A mismatch means a prior switch race wrote a different
  // account's verify_credentials response into this slot — every consumer of
  // `useUserCache()` (avatar, bio, post-ownership checks, follow attribution,
  // admin chrome, …) would then read the wrong identity, and the
  // `selfProfile` query keyed by `userCache.id` would 404 against the active
  // account's server, so the writeback at `useQuerySelfProfile` never fires
  // and the bad blob lives forever. Wiping here lets the cold-start verify
  // path rebuild from `verify_credentials`.
  const summary = _listSnapshot.find((a) => a.accountId === id) ?? null
  if (summary && parsed && String(parsed.id) !== String(summary.userId)) {
    userStorage.remove(userKeys.profile(id))
    return null
  }
  return parsed
}

function rebuildSnapshots(): void {
  _listSnapshot = readList()
  _visibleListSnapshot = _listSnapshot.filter((a) => !a.signedOut)
  _activeAccountSnapshot = computeActiveAccount(_listSnapshot)
  _activeUserCacheSnapshot = computeActiveUserCache()
}

function emit(): void {
  rebuildSnapshots()
  for (const fn of listeners) fn()
}

// --- Switch overlay state (Phase 6 §11.5) -------------------------------
//
// The Accounts screen (and notification-tap routing once the server emits
// recipient metadata) call `beginSwitch(id)` instead of bare `setActive`.
// That flips a module-level flag the AccountSwitchOverlay subscribes to via
// `subscribeSwitch` + `useSyncExternalStore`. The flag clears the moment the
// next `verify_credentials` round-trip settles (driven by AuthProvider's
// activeAccountId effect calling `endSwitch`), or after 600 ms — whichever
// is sooner. The timeout exists so a slow/offline network doesn't strand
// the overlay; switching itself is purely a local pointer flip and is
// already complete from the user's perspective.

let _isSwitching = false
let _switchTimer: ReturnType<typeof setTimeout> | null = null
const switchListeners = new Set<Listener>()

function emitSwitch(): void {
  for (const fn of switchListeners) fn()
}

const SWITCH_OVERLAY_TIMEOUT_MS = 600

/**
 * Account registry. Owns the on-disk shape (`accounts.list`,
 * `accounts.activeId`) and exposes the small set of operations every other
 * layer goes through.
 *
 * Reads (`list`, `get`, `activeId`, `findByInstanceAndUserId`) are MMKV-only
 * and synchronous. Mutations (`upsert`, `remove`, `switch`, …) update MMKV
 * and notify subscribers; the AuthProvider mirrors this state into React
 * via `subscribe()`.
 */
export const AccountStore = {
  /**
   * Visible-list snapshot. Dormant (`signedOut: true`) entries are filtered
   * out so every UI surface, push iteration, and tapestry flush only sees
   * accounts the user is currently signed in to. Internal lookups that need
   * the full registry (the `(instance, userId)` re-auth match in
   * `upsert()` / `findByInstanceAndUserId`) read from `readList()` directly.
   *
   * Returns the cached snapshot — stable between `emit()` calls so it can
   * back `useSyncExternalStore` without tripping the "result of getSnapshot
   * should be cached" loop.
   */
  list(): AccountSummary[] {
    return _visibleListSnapshot
  },

  get(accountId: string): AccountSummary | null {
    return readList().find((a) => a.accountId === accountId) ?? null
  },

  activeId(): string | null {
    return accountsStorage.getString(accountKeys.activeId) ?? null
  },

  activeIdOrThrow(): string {
    const id = AccountStore.activeId()
    if (!id) throw new Error('no active account')
    return id
  },

  activeAccount(): AccountSummary | null {
    return _activeAccountSnapshot
  },

  activeAccountOrThrow(): AccountSummary {
    const a = AccountStore.activeAccount()
    if (!a) throw new Error('no active account')
    return a
  },

  /**
   * The active account's parsed `verify_credentials` profile blob — the
   * traditional "userCache" surface. Returns the same object reference until
   * either the active id flips or the profile blob is rewritten via
   * `setProfileBlob`, so this is safe to feed into `useSyncExternalStore`.
   */
  activeUserCache(): LoginUserResponse | null {
    return _activeUserCacheSnapshot
  },

  findByInstanceAndUserId(instance: string, userId: string): AccountSummary | null {
    // `readList()` (not `list()`) so re-signing in to a dormant
    // (`signedOut: true`) entry revives the same accountId rather than
    // minting a fresh one — `upsert()`'s re-auth merge clears the flag.
    return readList().find((a) => a.instance === instance && a.userId === userId) ?? null
  },

  /**
   * Add a new account or update an existing one (re-auth path). Returns the
   * resulting AccountSummary. Throws if the 8-account cap would be exceeded.
   */
  upsert(input: {
    instance: string
    userId: string
    username: string
    acct: string
    displayName: string
    avatarUrl: string | null
    tokenExpiresAt?: number | null
    // Mastodon Pass Phase 1 §2.1: required; the login-completion path passes
    // it from the preflight classification result. No default — guessing
    // `'pixelfed'` would silently mis-route Mastodon traffic through the
    // `_pe=1` / `X-PIXELFED-APP` Pixelfed transport.
    type: AccountType
    apiVersionsMastodon?: number | null
    mastodonForkSoftware?: string | null
  }): AccountSummary {
    const list = readList()
    const existing = list.find(
      (a) => a.instance === input.instance && a.userId === input.userId
    )
    const now = Date.now()
    if (existing) {
      const updated: AccountSummary = {
        ...existing,
        // The server-side software is a property of the instance, not the
        // login — even on a re-auth path we accept the freshly classified
        // type (cheap way to recover if a Pixelfed instance was upgraded to
        // a different software, or if a stale registry entry's type drifted).
        type: input.type,
        username: input.username,
        acct: input.acct,
        displayName: input.displayName,
        avatarUrl: input.avatarUrl,
        lastActiveAt: now,
        needsReauth: false,
        // Revive a dormant entry: re-signing in to a previously soft-deleted
        // (instance, userId) restores the row into the visible list and
        // surfaces every cache slice that was kept on disk.
        signedOut: false,
        tokenExpiresAt: input.tokenExpiresAt ?? existing.tokenExpiresAt,
        apiVersionsMastodon:
          input.apiVersionsMastodon ?? existing.apiVersionsMastodon ?? null,
        mastodonForkSoftware:
          input.mastodonForkSoftware ?? existing.mastodonForkSoftware ?? null,
      }
      const next = list.map((a) => (a.accountId === existing.accountId ? updated : a))
      writeList(next)
      devlog.auth(
        `upsert re-auth @${updated.username}@${updated.instance} accountId=${updated.accountId}`
      )
      emit()
      return updated
    }
    if (list.length >= MAX_ACCOUNTS) {
      throw new Error(`Cannot add more than ${MAX_ACCOUNTS} accounts. Remove one first.`)
    }
    // Positional accent default — first unused slot in 1..MAX_ACCOUNTS. The
    // cap is MAX_ACCOUNTS so by the time `list.length === MAX_ACCOUNTS - 1`
    // exactly one slot is unused; the `?? 1` is defensive only.
    const used = new Set(list.map((a) => a.accentVariant))
    const accentVariant: AccentVariantId =
      ACCENT_VARIANT_IDS.find((n) => !used.has(n)) ?? 1
    const created: AccountSummary = {
      accountId: generateAccountId(),
      type: input.type,
      instance: input.instance,
      userId: input.userId,
      username: input.username,
      acct: input.acct,
      displayName: input.displayName,
      avatarUrl: input.avatarUrl,
      addedAt: now,
      lastActiveAt: now,
      needsReauth: false,
      tokenExpiresAt: input.tokenExpiresAt ?? null,
      pushRegisteredAt: null,
      pushUnavailable: false,
      apiVersionsMastodon: input.apiVersionsMastodon ?? null,
      mastodonForkSoftware: input.mastodonForkSoftware ?? null,
      accentVariant,
      signedOut: false,
    }
    writeList([...list, created])
    devlog.auth(
      `upsert new @${created.username}@${created.instance} accountId=${created.accountId} type=${created.type}`
    )
    emit()
    return created
  },

  /**
   * Patch a single account in the registry. Used for `needsReauth`,
   * `tokenExpiresAt`, `pushRegisteredAt`, etc.
   */
  patch(accountId: string, patch: Partial<AccountSummary>): AccountSummary | null {
    const list = readList()
    const existing = list.find((a) => a.accountId === accountId)
    if (!existing) return null
    const updated = { ...existing, ...patch }
    writeList(list.map((a) => (a.accountId === accountId ? updated : a)))
    emit()
    return updated
  },

  setAccentVariant(accountId: string, variant: AccentVariantId): void {
    AccountStore.patch(accountId, { accentVariant: variant })
  },

  setActive(accountId: string): void {
    const summary = AccountStore.get(accountId)
    if (!summary) throw new Error(`unknown accountId: ${accountId}`)
    accountsStorage.set(accountKeys.activeId, accountId)
    AccountStore.patch(accountId, { lastActiveAt: Date.now() })
    devlog.auth(
      `setActive @${summary.username}@${summary.instance} accountId=${accountId}`
    )
    emit()
  },

  /**
   * Pick the most-recently-active account other than the given one. Used
   * after remove/auth-failure to fall back to a still-valid account.
   * Skips dormant (`signedOut`) and `needsReauth` entries — neither has a
   * usable token, so neither is a valid landing spot.
   */
  pickFallbackActiveId(excludeAccountId?: string): string | null {
    const candidates = readList()
      .filter((a) => a.accountId !== excludeAccountId && !a.needsReauth && !a.signedOut)
      .sort((a, b) => b.lastActiveAt - a.lastActiveAt)
    return candidates[0]?.accountId ?? null
  },

  /**
   * Soft-delete a single account: drop tokens, set `signedOut: true`, hide
   * the row from every visible-list consumer. The registry entry, profile
   * blob, drafts, and account-keyed React Query cache slices stay on disk
   * so a re-auth to the same `(instance, userId)` revives the same
   * accountId via `upsert()` and lands warm.
   *
   * Per-instance OAuth `client_id` / `client_secret` are intentionally
   * preserved — soft-deleted accounts still count as "on that instance",
   * and keeping the credentials means a subsequent re-sign-in skips the
   * `/api/v1/apps` registration round-trip.
   *
   * Atomic active-id flip: when soft-deleting the active account, callers
   * pass `fallbackAccountId` to nominate the new active id. The list-write
   * (with the flag flipped) and the activeId-write land in a single
   * `emit()` cycle, so subscribers never observe a `(newList, null)`
   * intermediate — closes the same window the old `remove()` documented
   * (Phase R4). A null/missing/dormant fallback clears the pointer; the
   * router falls through to `/(public)/login`.
   */
  async markSignedOut(
    accountId: string,
    fallbackAccountId?: string | null
  ): Promise<void> {
    const list = readList()
    const target = list.find((a) => a.accountId === accountId)
    if (!target || target.signedOut) return

    await Promise.all([
      secureStore.deleteAccessToken(accountId),
      secureStore.deleteRefreshToken(accountId),
    ])

    const next = list.map((a) =>
      a.accountId === accountId
        ? {
            ...a,
            signedOut: true,
            needsReauth: false,
            tokenExpiresAt: null,
            pushRegisteredAt: null,
          }
        : a
    )
    writeList(next)

    if (AccountStore.activeId() === accountId) {
      const fallbackOk =
        !!fallbackAccountId &&
        next.some((a) => a.accountId === fallbackAccountId && !a.signedOut)
      if (fallbackOk) {
        accountsStorage.set(accountKeys.activeId, fallbackAccountId as string)
      } else {
        accountsStorage.remove(accountKeys.activeId)
      }
    }
    devlog.auth(
      `markSignedOut @${target.username}@${target.instance} accountId=${accountId} fallback=${fallbackAccountId ?? 'null'}`
    )
    emit()
  },

  /**
   * Clear the active-id pointer without mutating the registry. Used by the
   * auth-failure handler when the failed account is the active one and no
   * fallback is available — the entry stays under `needsReauth: true` so
   * the next OAuth round-trip lands warm via the `(instance, userId)`
   * match in `upsert()`.
   */
  clearActive(): void {
    if (!AccountStore.activeId()) return
    accountsStorage.remove(accountKeys.activeId)
    emit()
  },

  /**
   * Wipe everything: every account's secrets, every per-instance OAuth
   * registration, every per-account MMKV scope, and the registry itself.
   * Equivalent to today's destructive logout — used by "Sign out of all".
   */
  async clearAll(): Promise<void> {
    const list = readList()
    const accountIds = list.map((a) => a.accountId)
    const instances = Array.from(new Set(list.map((a) => a.instance)))

    await secureStore.clearAllAccounts(accountIds, instances)

    for (const id of accountIds) {
      clearUserKeysForAccount(id)
    }
    // userStorage may still hold legacy keys from before the migration cleared
    // — drop anything left so a Sign-out-of-all is fully clean.
    userStorage.clearAll()

    // Mastodon Pass 3 Phase 1 §2.5 — drop every per-account composer draft.
    // Walks `appStorage.getAllKeys()` rather than iterating `accountIds`
    // because we want belt-and-braces cleanup against orphan keys (e.g. a
    // crash mid-remove() that left a draft behind). The walk-and-remove is
    // bounded by the number of draft keys, which equals the live account
    // count plus any orphans — all small.
    clearAllDrafts()

    accountsStorage.remove(accountKeys.list)
    accountsStorage.remove(accountKeys.activeId)
    devlog.auth(`clearAll wiped=${accountIds.length} accounts`)
    emit()
  },

  /**
   * Subscribe to registry/active-id changes. Returns an unsubscribe fn.
   * The AuthProvider uses this with `useSyncExternalStore` to mirror the
   * registry into React state.
   */
  subscribe(fn: Listener): () => void {
    listeners.add(fn)
    return () => listeners.delete(fn)
  },

  // -------- switch-overlay state (Phase 6 §11.5) --------

  /**
   * User-facing account switch: flips the active pointer AND drives the
   * AccountSwitchOverlay. Use this anywhere a brief loading/destination
   * preview is desired — Settings → Accounts row tap, future notification-
   * tap-with-recipient-mismatch flow. Internal callers that just need to
   * activate an account silently (login completion, auth-failure fallback,
   * boot fallback) keep using `setActive` and skip the overlay.
   */
  beginSwitch(accountId: string): void {
    devlog.auth(`beginSwitch accountId=${accountId}`)
    AccountStore.setActive(accountId)
    if (_switchTimer) clearTimeout(_switchTimer)
    if (!_isSwitching) {
      _isSwitching = true
      emitSwitch()
    }
    _switchTimer = setTimeout(() => {
      AccountStore.endSwitch()
    }, SWITCH_OVERLAY_TIMEOUT_MS)
  },

  /**
   * Clear the overlay. Called by AuthProvider when verify_credentials
   * settles (success or fail), and as the 600 ms safety-net timeout.
   * Idempotent — extra calls after the first are no-ops.
   */
  endSwitch(): void {
    if (_switchTimer) {
      clearTimeout(_switchTimer)
      _switchTimer = null
    }
    if (!_isSwitching) return
    _isSwitching = false
    emitSwitch()
  },

  isSwitching(): boolean {
    return _isSwitching
  },

  subscribeSwitch(fn: Listener): () => void {
    switchListeners.add(fn)
    return () => switchListeners.delete(fn)
  },

  // -------- per-account profile cache (the old `userKeys.profile` blob) --------

  getProfileBlob(accountId: string): string | null {
    return userStorage.getString(userKeys.profile(accountId)) ?? null
  },

  setProfileBlob(accountId: string, blob: string): void {
    userStorage.set(userKeys.profile(accountId), blob)
    // Refresh the userCache snapshot + notify subscribers. When the write
    // targets the active account, useSyncExternalStore consumers re-render
    // with the new blob; when it targets a non-active account, the snapshot
    // recompute is a no-op but the emit is harmless.
    emit()
  },

  getCredentialsVerifiedAt(accountId: string): number | undefined {
    return userStorage.getNumber(userKeys.credentialsVerifiedAt(accountId))
  },

  markCredentialsVerified(accountId: string): void {
    userStorage.set(userKeys.credentialsVerifiedAt(accountId), Date.now())
  },

  invalidateCredentialsCache(accountId: string): void {
    userStorage.remove(userKeys.credentialsVerifiedAt(accountId))
  },
}

export type AccountStoreType = typeof AccountStore
