import * as SecureStore from 'expo-secure-store'
import { createMMKV } from 'react-native-mmkv'

// --- Tier 1: Secrets (Keychain / Keystore) ---
//
// Per-account tokens and per-instance OAuth app credentials. There is no
// "current" slot — every secret is keyed by accountId or instance hostname.

const secureKeyFor = {
  accessToken: (accountId: string) => `secure.account.${accountId}.access_token`,
  refreshToken: (accountId: string) => `secure.account.${accountId}.refresh_token`,
  clientId: (instance: string) => `secure.instance.${instance}.client_id`,
  clientSecret: (instance: string) => `secure.instance.${instance}.client_secret`,
}

// iOS Keychain accessibility for every write. WHEN_PASSCODE_SET_THIS_DEVICE_ONLY
// upgrades the default kSecAttrAccessibleWhenUnlocked posture in three ways:
//   1. Items disappear when the user removes their device passcode (defends
//      against the "passcode cleared via Face ID bypass" niche).
//   2. Items are NOT migrated to a new device via iCloud Keychain — a phone
//      resold or stolen-and-restored from backup never carries our tokens.
//   3. On A7+ hardware, items are bound to the Secure Enclave.
// Reads still work whenever the device is unlocked, which is every code path
// the app exercises (cold-start verify, app-foreground refresh, every
// foregrounded API call). `requireAuthentication` is intentionally NOT set —
// it would block the cold-start verify path and notification-tap routing
// before the user has ever seen a screen.
//
// Android: `keychainAccessible` is iOS-only; expo-secure-store's Android
// backend is the OEM Keystore, which already gates reads on user
// authentication when `requireAuthentication` is set, and otherwise binds
// keys to the device. Passing the option does no harm on Android.
const SECURE_OPTS: SecureStore.SecureStoreOptions = {
  keychainAccessible: SecureStore.WHEN_PASSCODE_SET_THIS_DEVICE_ONLY,
}

// In-process access-token cache, keyed by accountId. Layered in front of
// `SecureStore.getItem` so the hot path — building an ApiContext on every
// request — does not do a keychain round-trip per call. The two callers
// behind a single React Query tick (`getNotificationUnreadCount` +
// `getNotificationMarker` per minute, plus every other dual-fetch
// composition) collapse to one keychain read per accountId per process
// lifetime instead of one per ApiContext construction.
//
// Write-through: `setAccessToken` updates the cache and keychain in one
// step. Invalidate on delete and on the multi-account wipe. The token
// is already in JS memory for the lifetime of the session (every
// `ApiContext.token` field, React Query cached responses, etc.), so the
// cache does not meaningfully change the security posture.
const accessTokenCache = new Map<string, string>()

export const secureStore = {
  getAccessToken: (accountId: string) => {
    const cached = accessTokenCache.get(accountId)
    if (cached !== undefined) return cached
    const v = SecureStore.getItem(secureKeyFor.accessToken(accountId), SECURE_OPTS)
    if (v != null) accessTokenCache.set(accountId, v)
    return v
  },
  setAccessToken: (accountId: string, v: string) => {
    accessTokenCache.set(accountId, v)
    return SecureStore.setItem(secureKeyFor.accessToken(accountId), v, SECURE_OPTS)
  },
  deleteAccessToken: (accountId: string) => {
    accessTokenCache.delete(accountId)
    return SecureStore.deleteItemAsync(secureKeyFor.accessToken(accountId), SECURE_OPTS)
  },

  getRefreshToken: (accountId: string) =>
    SecureStore.getItem(secureKeyFor.refreshToken(accountId), SECURE_OPTS),
  setRefreshToken: (accountId: string, v: string) =>
    SecureStore.setItem(secureKeyFor.refreshToken(accountId), v, SECURE_OPTS),
  deleteRefreshToken: (accountId: string) =>
    SecureStore.deleteItemAsync(secureKeyFor.refreshToken(accountId), SECURE_OPTS),

  getClientId: (instance: string) =>
    SecureStore.getItem(secureKeyFor.clientId(instance), SECURE_OPTS),
  setClientId: (instance: string, v: string) =>
    SecureStore.setItem(secureKeyFor.clientId(instance), v, SECURE_OPTS),
  deleteClientId: (instance: string) =>
    SecureStore.deleteItemAsync(secureKeyFor.clientId(instance), SECURE_OPTS),

  getClientSecret: (instance: string) =>
    SecureStore.getItem(secureKeyFor.clientSecret(instance), SECURE_OPTS),
  setClientSecret: (instance: string, v: string) =>
    SecureStore.setItem(secureKeyFor.clientSecret(instance), v, SECURE_OPTS),
  deleteClientSecret: (instance: string) =>
    SecureStore.deleteItemAsync(secureKeyFor.clientSecret(instance), SECURE_OPTS),

  /**
   * Wipe every secret on the device. Reserved for the "Sign out of all"
   * action — per-account remove uses targeted delete*() calls so account A
   * can't accidentally take account B's tokens with it.
   */
  clearAllAccounts: async (accountIds: string[], instances: string[]) => {
    accessTokenCache.clear()
    await Promise.all([
      ...accountIds.flatMap((id) => [
        SecureStore.deleteItemAsync(secureKeyFor.accessToken(id)),
        SecureStore.deleteItemAsync(secureKeyFor.refreshToken(id)),
      ]),
      ...instances.flatMap((host) => [
        SecureStore.deleteItemAsync(secureKeyFor.clientId(host)),
        SecureStore.deleteItemAsync(secureKeyFor.clientSecret(host)),
      ]),
    ])
  },
} as const

// --- Tier 2: User-specific cache (per-account) ---

export const userStorage = createMMKV({ id: 'user' })

export const userKeys = {
  profile: (accountId: string) => `user.account.${accountId}.profile`,
  credentialsVerifiedAt: (accountId: string) =>
    `user.account.${accountId}.credentials_verified_at`,
} as const

export function clearUserKeysForAccount(accountId: string): void {
  const prefix = `user.account.${accountId}.`
  for (const k of userStorage.getAllKeys()) {
    if (k.startsWith(prefix)) userStorage.remove(k)
  }
}

// --- Tier 3: App-level (persists across logout) ---

export const appStorage = createMMKV({ id: 'app', compareBeforeSet: true })

export const appKeys = {
  features: 'app.features',
  // Per-instance escape hatch: set to true after a 404 on the RSVP path so
  // the UI stops offering RSVP even if nodeinfo still claims the flag is on.
  rsvpMissing: (instance: string) => `app.rsvpMissing.${instance}` as const,
  // Android prominent-disclosure (D.2): one boolean per permission kind so
  // the in-app explanation modal fires once per device, not every time. iOS
  // uses the NS*UsageDescription strings instead and never reads these.
  permissionDisclosed: (kind: 'camera' | 'microphone') =>
    `app.permissions.disclosed.${kind}` as const,
  // Acceptable Use first-touch timestamp. Set on the first composer mount
  // per install; once present, the non-blocking toast (see
  // `useAcceptableUseToastOnce`) never re-fires. Existing installs that saw
  // the earlier blocking AlertDialog already have this set, so they are not
  // re-prompted with the toast.
  acceptableUseAcceptedAt: 'app.acceptableUseAcceptedAt',
  // Mastodon Pass §3.3: per-(accountType, host) capability cache. Stored as
  // JSON `InstanceCapabilities` blobs with a 24 h TTL (the read-side checks
  // `fetchedAt`). Keyed by `accountType` first so Pixelfed and Mastodon
  // entries on the same host (rare but possible across federation) never
  // collide, and so the type-shaped defaults fall out cleanly when no entry
  // exists yet.
  capabilities: (accountType: string, instance: string) =>
    `app.capabilities.${accountType}.${instance}` as const,
  // Holistic Pass 4 Phase 3.1: per-account local mirror of the server-side
  // notifications marker. JSON-encoded `{ lastReadId, updatedAt }`. The local
  // mirror writes through synchronously on every tap (so the per-row "new"
  // indicator clears instantly) while the server-side `setNotificationMarker`
  // writeback is debounced — see `src/lib/notifications/marker.ts`.
  notificationsLocalMarker: (accountId: string) =>
    `notifications.localMarker.${accountId}` as const,
  // Last successful WCSession `updateApplicationContext` push for the
  // active account, ms since epoch. Persisted so the Settings →
  // Accounts → Apple Watch row's "Synced N min ago" line survives JS
  // hot-reloads and cold launches; the in-process counterpart lives
  // in `src/lib/watch/handoff.ts` and reads this on `enableWatchHandoff`.
  watchLastHandoffAt: 'app.watch.lastHandoffAt',
  watchLastHandoffAccountId: 'app.watch.lastHandoffAccountId',
  // Signature of the most-recently-pushed handoff payload (accountId +
  // truncated token + instance). Persisted so a cold launch can short-
  // circuit when the watch already holds an identical payload — the
  // in-process dedupe is module-scoped and resets on every JS process
  // restart, otherwise causing one redundant WC bridge round-trip per
  // cold launch even when nothing has changed.
  watchLastHandoffSignature: 'app.watch.lastHandoffSignature',
} as const

// Features are keyed per instance so accounts on different servers never see
// stale flags. Stored as `app.features.<instance>` → JSON blob. Accounts on
// the same instance correctly share these — features encode server
// capability, not user state.
export function featureKeyFor(instance: string): string {
  return `${appKeys.features}.${instance}`
}

export function setFeatures(instance: string, features: Record<string, boolean>): void {
  appStorage.set(featureKeyFor(instance), JSON.stringify(features))
}

export function getFeatures<T extends Record<string, boolean> = Record<string, boolean>>(
  instance: string
): T | null {
  const raw = appStorage.getString(featureKeyFor(instance))
  if (!raw) return null
  try {
    return JSON.parse(raw) as T
  } catch {
    return null
  }
}

export const uiKeys = {
  theme: 'ui.theme',
  forceSensitive: 'ui.forceSensitive',
  hideCaptions: 'ui.hideCaptions',
  hideStories: 'ui.hideStories',
  showAltText: 'ui.showAltText',
  requireSelfAltText: 'ui.requireSelfAltText',
  mediaEdit: 'ui.mediaEdit',
  // Global pref (not per-account) for how http(s) links inside post bodies,
  // bios, link-preview cards, and admin escape hatches open. `external`
  // hands off to `Linking.openURL` (the OS picks the user's default
  // browser — Safari, Chrome, Firefox, Brave, Arc, … per iOS 14+
  // Settings → Default Browser App); `inApp` opens an SFSafariViewController
  // / Chrome Custom Tab in-process. Default `external`.
  openLinks: 'ui.openLinks',
  // Show tiny site favicons next to links in post bodies, link-preview cards,
  // and the profile website row. Direct-fetch only (`https://<host>/favicon.ico`),
  // host-keyed cache lives in `app.favicon.<host>`. Default true; user can
  // disable in Settings → App Behavior if they don't want the once-per-host
  // probe to reveal "this IP loaded a post linking to you" to the host.
  showLinkFavicons: 'ui.showLinkFavicons',
  storiesOnboardingCompleted: 'ui+xs.v0.1.2.stories_onboarding_completed',
  homeLens: 'ui.homeLens',
  // Legacy global key kept readable for one-time fallback in
  // `getHomeMastodonList`. New writes go through the per-account key below.
  homeMastodonListLegacy: 'ui.home.mastodonList',
  // Holistic Pass 4 Phase 5.3: per-account selected list filter on the home
  // tab. Migrated from the legacy global `ui.home.mastodonList` so switching
  // between two Mastodon accounts no longer cross-contaminates picks.
  homeMastodonList: (accountId: string) => `ui.home.mastodonList.${accountId}` as const,
  // Holistic Pass 4 Phase 5.3: per-account preferred list ordering on the
  // home filter picker and Settings → Lists. JSON `string[]` of list ids;
  // missing-from-server ids drop out at apply time, new server-side lists
  // append at the bottom.
  listsOrder: (accountId: string) => `ui.lists.order.${accountId}` as const,
  // List Organizer (Surface E): per-account `{ listId → paletteIndex }` map
  // backing the OKLab list-colour palette. Reconciled against the live list
  // catalogue on every organizer mount; persisted so a list keeps its colour
  // across launches. Per-account because two accounts have different list
  // catalogues — the colour is a property of the (account, list) pair.
  listColorIndices: (accountId: string) => `ui.lists.colorIndices.${accountId}` as const,
  // List Organizer: remembered All/Listless filter selection, per-account, so
  // re-entering the organizer lands on the last-used view rather than always
  // resetting to "All".
  organizerLastFilter: (accountId: string) =>
    `ui.lists.organizer.filter.${accountId}` as const,
} as const

// --- Account registry ---
//
// MMKV instance dedicated to the account list and active-id pointer. Stays
// separate from `app`/`user` so the schema is easier to reason about and so
// `compareBeforeSet` semantics apply only here (the active-id setter relies
// on listeners *not* firing on equal-value writes).

export const accountsStorage = createMMKV({ id: 'accounts', compareBeforeSet: true })

export const accountKeys = {
  list: 'accounts.list', // JSON: AccountSummary[]
  activeId: 'accounts.activeId', // string | undefined
} as const

// User-selectable themes. `system` defers to the OS scheme; the resolver
// lives in _layout.tsx. Stored values outside this set fall back to `system`.
export const VALID_THEMES = new Set(['system', 'light', 'dark'])

export type ThemeChoice = 'system' | 'light' | 'dark'

/**
 * Single writer for the active theme preference. Returns true when the
 * stored value actually changed, so callers can gate side-effects (haptic,
 * Tapestry `T` emit) on real user-driven changes rather than no-op writes.
 * `useMMKVString(uiKeys.theme, appStorage)` consumers still re-render via
 * the underlying MMKV listener — this helper is just sugar over
 * `appStorage.set(uiKeys.theme, …)` with an equality short-circuit.
 *
 * The Tapestry `T` letter is emitted at the call sites (Appearance settings,
 * the login-screen dark-mode toggle) rather than here, to keep `storage.ts`
 * free of a dep on `src/lib/tapestry/emit`, which itself imports from this
 * file (`appStorage`, `getFeatures`, `AccountStore`).
 */
export function setTheme(value: ThemeChoice): boolean {
  if (appStorage.getString(uiKeys.theme) === value) return false
  appStorage.set(uiKeys.theme, value)
  return true
}

// --- Open-links target (global UI pref) -----------------------------------

export type OpenLinksTarget = 'external' | 'inApp'

export function getOpenLinksTarget(): OpenLinksTarget {
  const v = appStorage.getString(uiKeys.openLinks)
  return v === 'inApp' ? 'inApp' : 'external'
}

export function setOpenLinksTarget(value: OpenLinksTarget): void {
  appStorage.set(uiKeys.openLinks, value)
}

// --- Inline favicons (global UI pref) -------------------------------------

const SHOW_LINK_FAVICONS_DEFAULT = true

export function showLinkFaviconsEnabled(): boolean {
  const v = appStorage.getBoolean(uiKeys.showLinkFavicons)
  return v ?? SHOW_LINK_FAVICONS_DEFAULT
}

export function setShowLinkFavicons(value: boolean): void {
  appStorage.set(uiKeys.showLinkFavicons, value)
}

export function cleanupStaleThemeIfNeeded() {
  const stored = appStorage.getString(uiKeys.theme)
  if (!stored || VALID_THEMES.has(stored)) return
  appStorage.set(uiKeys.theme, 'system')
}

// --- Mastodon Lists (Holistic Pass 4 Phase 5.3) -------------------------
//
// `getListOrder` / `setListOrder` persist the user's preferred ordering of
// their Mastodon lists per-account. The stored value is a JSON `string[]` of
// list ids; consumers compose it with the server-side `getMastodonLists()`
// result via `applyListOrder` so removed-server-side lists drop out and new
// ones append.
//
// `getHomeMastodonList` / `setHomeMastodonList` persist the home tab's
// selected list filter per-account. On first read where the per-account key
// is unset, the legacy global `ui.home.mastodonList` value is read once as a
// fallback so the user's existing pick survives the upgrade.

export function getListOrder(accountId: string): string[] {
  const raw = appStorage.getString(uiKeys.listsOrder(accountId))
  if (!raw) return []
  try {
    const parsed = JSON.parse(raw)
    return Array.isArray(parsed)
      ? (parsed as string[]).filter((s) => typeof s === 'string')
      : []
  } catch {
    return []
  }
}

export function setListOrder(accountId: string, ids: string[]): void {
  appStorage.set(uiKeys.listsOrder(accountId), JSON.stringify(ids))
}

export function getHomeMastodonList(accountId: string): string {
  const v = appStorage.getString(uiKeys.homeMastodonList(accountId))
  if (v) return v
  // One-time fallback to the legacy global key. Don't migrate-and-delete —
  // a second Mastodon account on the same device would lose its first-read
  // fallback otherwise. The legacy key ages out naturally as accounts
  // overwrite their per-account slot.
  return appStorage.getString(uiKeys.homeMastodonListLegacy) ?? 'all'
}

export function setHomeMastodonList(accountId: string, listId: string): void {
  appStorage.set(uiKeys.homeMastodonList(accountId), listId)
}

// --- List Organizer (Surface E) ----------------------------------------
//
// `getListColorIndices` / `setListColorIndices` persist the per-account OKLab
// palette index map. The pure reconcile/lookup engine lives in
// `src/lib/lists/listColors.ts` (no RN imports); these helpers are the only
// place the map touches MMKV.

export function getListColorIndices(accountId: string): Record<string, number> {
  const raw = appStorage.getString(uiKeys.listColorIndices(accountId))
  if (!raw) return {}
  try {
    const parsed = JSON.parse(raw)
    if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
      const out: Record<string, number> = {}
      for (const [k, v] of Object.entries(parsed)) {
        if (typeof v === 'number' && Number.isInteger(v)) out[k] = v
      }
      return out
    }
    return {}
  } catch {
    return {}
  }
}

export function setListColorIndices(
  accountId: string,
  indices: Record<string, number>
): void {
  appStorage.set(uiKeys.listColorIndices(accountId), JSON.stringify(indices))
}

export type OrganizerFilter = 'all' | 'listless'

export function getOrganizerLastFilter(accountId: string): OrganizerFilter {
  return appStorage.getString(uiKeys.organizerLastFilter(accountId)) === 'listless'
    ? 'listless'
    : 'all'
}

export function setOrganizerLastFilter(accountId: string, filter: OrganizerFilter): void {
  appStorage.set(uiKeys.organizerLastFilter(accountId), filter)
}

/**
 * Compose the user's stored ordering with the server-side list catalogue:
 * stored ids that no longer exist on the server drop out, server-side lists
 * not yet in the stored order append at the bottom (so a newly-created list
 * is visible without a manual reorder).
 */
export function applyListOrder<T extends { id: string }>(
  serverLists: T[],
  storedOrder: string[]
): T[] {
  if (storedOrder.length === 0) return serverLists
  const byId = new Map(serverLists.map((l) => [l.id, l]))
  const ordered: T[] = []
  const seen = new Set<string>()
  for (const id of storedOrder) {
    const list = byId.get(id)
    if (list && !seen.has(id)) {
      ordered.push(list)
      seen.add(id)
    }
  }
  for (const list of serverLists) {
    if (!seen.has(list.id)) ordered.push(list)
  }
  return ordered
}
