import { useQuery, useQueryClient } from '@tanstack/react-query'
import * as Linking from 'expo-linking'
import { enableAppSwitcherProtectionAsync } from 'expo-screen-capture'
import * as WebBrowser from 'expo-web-browser'
import type { ReactNode } from 'react'
import {
  createContext,
  useCallback,
  useContext,
  useEffect,
  useRef,
  useState,
  useSyncExternalStore,
} from 'react'
import { AppState, Platform } from 'react-native'
import {
  setAccountAuthFailureHandler,
  tryPreemptiveRefresh,
  tryRevokeAccessToken,
} from 'src/lib/api-context'
import type { LoginUserResponse } from 'src/lib/api-types'
import { APP, oauthClientName } from 'src/lib/branding'
import {
  fetchInstanceCapabilities,
  refreshActiveAccountCapabilities,
  refreshAllAccountCapabilities,
} from 'src/lib/capabilities'
import { parseFeatures, persistFeatures, refreshFeatures } from 'src/lib/features'
import {
  formatLoginPreflightMessage,
  type LoginPreflightReason,
  type LoginPreflightSuccess,
  loginPreflightCheck,
} from 'src/lib/login-preflight'
import {
  consumePkceVerifier,
  discardPkceVerifier,
  fetchOAuthMetadata,
  generateOAuthState,
  generatePkcePair,
  scopeStringForType,
  stashPkceVerifier,
} from 'src/lib/oauth'
import {
  refreshStalePushRegistrations,
  registerPushForAccount,
  unregisterPushForAccount,
} from 'src/lib/push'
import { accountQueries } from 'src/lib/queries/accounts'
import { revokeAllOnWatch, revokeOnWatch } from 'src/lib/watch/handoff'
import { get, postForm, verifyCredentials } from 'src/requests'
import {
  AccountStore,
  type AccountSummary,
  type AccountType,
  MAX_ACCOUNTS,
} from './accountStore'
import { secureStore } from './storage'

// Rich result so callers (handleLogin/handleSignup) can surface errors via
// toast instead of AuthProvider firing RN Alerts from above TamaguiProvider.
// `needs2faRetry` lets the caller show a confirm dialog and invoke retry().
//
// Mastodon Pass Phase 1 §2.2 — `ok: true` carries the classified `type` so
// post-login UX (e.g. the Phase 4 hint copy on Settings) can branch without a
// second AccountStore read.
export type LoginOutcome =
  | { ok: true; type: AccountType }
  // Mastodon Pass §6.1 — `preflightReason` and `softwareName` are carried
  // through so the login UI can offer a "Learn more" affordance on the
  // unsupported-fork path (every other preflight reason just shows the
  // formatted message). softwareName is set only for `'unsupported-server'`.
  | {
      ok: false
      reason: 'preflight'
      message: string
      preflightReason: LoginPreflightReason
      softwareName?: string
    }
  | {
      ok: false
      reason: 'cancelled'
      needs2faRetry: true
      retry: () => Promise<LoginOutcome>
    }
  | { ok: false; reason: 'cancelled' }
  | { ok: false; reason: 'callback-error'; message: string }
  | { ok: false; reason: 'cap-reached'; message: string }

type AuthContextValue = {
  isLoading: boolean
  /**
   * The active account, or null when no account is signed in. Most consumers
   * just use this as a truthy/falsy gate (see `Stack.Protected` in
   * `_layout.tsx`); callers that need profile fields read them off the
   * `useActiveProfile()` hook.
   */
  user: AccountSummary | null
  accounts: AccountSummary[]
  activeAccountId: string | null
  /**
   * Triggers the OAuth flow against the given server.
   *
   * Mastodon Pass Phase 1 §2.4 — the scope string is no longer a caller
   * input; it's derived from the preflight-classified `AccountType` via
   * `scopeStringForType`. The Pixelfed-only `admin:*` scopes go to
   * Pixelfed servers; Mastodon servers get `read write follow push`.
   *
   * `opts.isAddAccount` flips two add-account-only behaviours: (a) the
   * iOS webview opens with `preferEphemeralSession: true` so the OAuth
   * session is sandboxed from Safari (the user isn't already logged in
   * as their first account); (b) `force_login=true` is appended to the
   * authorize URL when the server supports it (any Mastodon instance,
   * or a Halflight-patched Pixelfed advertising the
   * `oauth_force_login` nodeinfo capability). Defaults to false — the
   * first-account / general re-auth path keeps shared Safari cookies
   * for the convenience of users already signed into the instance.
   */
  login: (server: string, opts?: { isAddAccount?: boolean }) => Promise<LoginOutcome>
  /** Sign out of the active account. Falls back to next account or login. */
  logout: () => void
  /** Switch active account. */
  switchAccount: (accountId: string) => void
  /** Remove a single account: revoke (best-effort), drop tokens + cache. */
  removeAccount: (accountId: string) => Promise<void>
  /** Wipe every account, every secret, every per-account MMKV scope. */
  signOutAll: () => Promise<void>
  /**
   * Signup completion path. Exchanges a token for an account: hits
   * `verify_credentials`, upserts into the registry, persists per-account
   * tokens, and sets the new account active. Returns `false` if
   * `verify_credentials` rejected (e.g. the token was revoked between
   * onboarding and this call).
   */
  handleRegistration: (
    server: string,
    token: string,
    refreshToken?: string,
    expiresIn?: number
  ) => Promise<boolean>
  invalidateCredentialsCache: () => void
}

// Cache credentials verification for 1 hour (in milliseconds)
const CREDENTIALS_CACHE_DURATION = 60 * 60 * 1000

function isCredentialsCacheValid(accountId: string): boolean {
  const lastVerified = AccountStore.getCredentialsVerifiedAt(accountId)
  if (!lastVerified) return false
  return Date.now() - lastVerified < CREDENTIALS_CACHE_DURATION
}

// Read AccountStore through useSyncExternalStore so React re-renders on any
// registry mutation (add / switch / remove / patch).
function useAccountList(): AccountSummary[] {
  return useSyncExternalStore(
    AccountStore.subscribe,
    AccountStore.list,
    AccountStore.list
  )
}

function useActiveAccountId(): string | null {
  return useSyncExternalStore(
    AccountStore.subscribe,
    AccountStore.activeId,
    AccountStore.activeId
  )
}

function useActiveAccount(): AccountSummary | null {
  return useSyncExternalStore(
    AccountStore.subscribe,
    AccountStore.activeAccount,
    AccountStore.activeAccount
  )
}

// Phase 2.1: read userCache from AccountStore via the same subscribe channel
// as activeId/list, so all three reads come from the same atomic snapshot.
// Replaces the old AuthProvider `useState` mirror that lagged the active-id
// flip by one effect tick — that gap was the source of "empty profile on
// switch" and the disappearing reblog chrome during the swap window.
function useActiveUserCache(): LoginUserResponse | null {
  return useSyncExternalStore(
    AccountStore.subscribe,
    AccountStore.activeUserCache,
    AccountStore.activeUserCache
  )
}

export const AuthContext = createContext<AuthContextValue>({
  isLoading: true,
  user: null,
  accounts: [],
  activeAccountId: null,
  login: () => Promise.resolve({ ok: false, reason: 'cancelled' as const }),
  logout: () => {},
  switchAccount: () => {},
  removeAccount: () => Promise.resolve(),
  signOutAll: () => Promise.resolve(),
  handleRegistration: () => Promise.resolve(false),
  invalidateCredentialsCache: () => {},
})

export function useAuth() {
  const ctx = useContext(AuthContext)
  if (!ctx) throw new Error('useAuth must be used within a <AuthProvider />')
  return ctx
}

export default function AuthProvider({ children }: { children: ReactNode }) {
  const accounts = useAccountList()
  const activeAccountId = useActiveAccountId()
  const activeAccount = activeAccountId ? AccountStore.get(activeAccountId) : null
  const queryClient = useQueryClient()

  const [isLoading, setIsLoading] = useState<boolean>(true)

  /**
   * Cold-start / active-account-changed verification. Replaces the old
   * `useProtectedRoute` hook. Runs whenever `activeAccountId` changes.
   *   - If there's no active account, finish loading (router redirects to
   *     /login).
   *   - If creds cache is fresh, refresh feature flags fire-and-forget.
   *   - Otherwise hit `verify_credentials` — on 401 the per-account
   *     auth-failure handler kicks in below.
   */
  useEffect(() => {
    let cancelled = false

    // Phase 6 §11.5: every exit path from the post-active-account-change
    // settle must clear the switch overlay. The flag is a no-op when no
    // switch is in flight, so unconditionally calling it here is safe and
    // avoids per-branch bookkeeping.
    const settle = () => {
      if (cancelled) return
      setIsLoading(false)
      AccountStore.endSwitch()
    }

    const run = async () => {
      if (!activeAccountId) {
        settle()
        return
      }
      const account = AccountStore.get(activeAccountId)
      const token = secureStore.getAccessToken(activeAccountId)
      if (!account || !token) {
        settle()
        return
      }

      // The userCache snapshot can be null even when creds are cache-valid:
      // the self-heal in `computeActiveUserCache` wipes blobs whose `id`
      // doesn't match the registry's `userId` (pre-Phase-2.1 cross-account
      // contamination). Falling through to a fresh `verify_credentials`
      // round-trip is the only way to refill the blob for the active
      // account; otherwise the cached-creds shortcut would strand the app
      // with `useUserCache()` throwing on every consumer.
      if (isCredentialsCacheValid(activeAccountId) && AccountStore.activeUserCache()) {
        // Cached creds path — opportunistic feature-flag refresh, no
        // network round-trip on the auth gate.
        refreshFeatures(account.instance)
        settle()
        return
      }

      try {
        const userInfo = await verifyCredentials(account.instance, token, account.type)
        if (cancelled) return
        if (userInfo) {
          // Persist the fresh response as the canonical profile blob so the
          // cold-start verify path doubles as the rebuild path for any
          // wiped/missing userCache. Without this write, the self-heal at
          // read time would clear a corrupted blob but nothing would refill
          // it until the next sign-in or registration.
          AccountStore.setProfileBlob(activeAccountId, JSON.stringify(userInfo))
          AccountStore.markCredentialsVerified(activeAccountId)
          refreshFeatures(account.instance)
        } else {
          AccountStore.invalidateCredentialsCache(activeAccountId)
        }
      } catch {
        // Network error — fall back to the cached blob if we have one.
        // Auth failures are routed through the per-account handler below.
      } finally {
        settle()
      }
    }

    // Only flip to loading when there's an account to verify. When
    // `activeAccountId` is null (sign-out, sign-out-of-all, no-fallback
    // auth-failure), keep isLoading false so `AuthenticatedStack` stays
    // mounted — otherwise the Stack tears down for one render cycle and
    // any `router.replace('/(public)/login')` queued by the sign-out
    // handler is lost across the unmount.
    if (activeAccountId) setIsLoading(true)
    run()

    return () => {
      cancelled = true
    }
  }, [activeAccountId])

  // Pre-emptive token refresh (Phase 3). Fires on cold start, account
  // switch, and app foregrounding — best-effort, no-ops when the active
  // account's token still has >10 min left or has no `tokenExpiresAt`.
  // Eliminates the otherwise-jarring 401-bounce when the user opens the app
  // shortly after their access token expires.
  useEffect(() => {
    if (!activeAccountId) return
    tryPreemptiveRefresh(activeAccountId)
    const sub = AppState.addEventListener('change', (state) => {
      if (state === 'active') tryPreemptiveRefresh(activeAccountId)
    })
    return () => sub.remove()
  }, [activeAccountId])

  // Mastodon Pass §3.3: best-effort capability refresh on cold start, account
  // switch, and app foregrounding. The cache itself is per-(accountType,
  // host) so two accounts on the same instance share one entry; the helper
  // short-circuits when the cached blob is still within the 24 h TTL. The
  // helper also patches the active AccountSummary's `apiVersionsMastodon`
  // when a Mastodon refresh reveals a new value — Phase 1 captured this at
  // preflight; Phase 2 keeps it current.
  useEffect(() => {
    if (!activeAccountId) return
    refreshActiveAccountCapabilities().catch(() => {})
    // Backfill capability cache for non-active accounts so the first switch
    // to a previously-cold account doesn't render against the protocol's
    // default fallback. Per-`(type, host)` deduped inside the helper.
    refreshAllAccountCapabilities().catch(() => {})
    const sub = AppState.addEventListener('change', (state) => {
      if (state === 'active') {
        refreshActiveAccountCapabilities().catch(() => {})
      }
    })
    return () => sub.remove()
  }, [activeAccountId])

  // iOS app-switcher / background-snapshot blur. Renders a privacy blur over
  // the app's preview in the iOS task switcher and over the home-indicator
  // background-snapshot frame, so a bystander picking up the device can't see
  // the user's last-viewed feed / DM thread / draft post. Android gets the
  // same protection implicitly via FLAG_SECURE on the credential surfaces
  // (P1d) — the app-switcher protection only adds value on iOS, hence the
  // platform gate. Wired once at AuthProvider mount; the OS toggles the blur
  // as the app moves between foreground and inactive/background states.
  useEffect(() => {
    if (Platform.OS !== 'ios') return
    enableAppSwitcherProtectionAsync(0.6).catch(() => {})
  }, [])

  // Cold-start push refresh (Phase 5, §9.2 step 5). Fires once per cold
  // start as soon as we have at least one account: re-registers the device
  // token with every signed-in account's server whose `pushRegisteredAt` is
  // older than 24 h (or null). Servers periodically GC stale device tokens,
  // so without this a long-lived install could silently miss pushes for
  // accounts that haven't been used in a while. Best-effort throughout —
  // no-ops cheaply when the user hasn't granted notification permission.
  const didRefreshPush = useRef(false)
  useEffect(() => {
    if (didRefreshPush.current) return
    if (accounts.length === 0) return
    didRefreshPush.current = true
    refreshStalePushRegistrations().catch(() => {})
  }, [accounts.length])

  // Per-account 401/403 handler. Replaces the old global-logout slot.
  // When ApiContext sees a 401:
  //   1. Mark the failing account `needsReauth=true`
  //   2. Drop only that account's tokens (other accounts are untouched)
  //   3. If it was the active account, fall back to the most-recently-active
  //      surviving account, or land on `/(public)/login` if none remain.
  useEffect(() => {
    const handler = async (failedAccountId: string) => {
      AccountStore.patch(failedAccountId, { needsReauth: true })
      AccountStore.invalidateCredentialsCache(failedAccountId)
      await Promise.all([
        secureStore.deleteAccessToken(failedAccountId),
        secureStore.deleteRefreshToken(failedAccountId),
      ])
      if (AccountStore.activeId() === failedAccountId) {
        const fallback = AccountStore.pickFallbackActiveId(failedAccountId)
        if (fallback) {
          AccountStore.setActive(fallback)
        } else {
          // No surviving account — clear the pointer so the router falls
          // through to /(public)/login. The registry entry stays under
          // `needsReauth: true` so the eventual re-auth lands warm via the
          // `(instance, userId)` match in upsert().
          AccountStore.clearActive()
        }
      }
    }
    setAccountAuthFailureHandler(handler)
    return () => setAccountAuthFailureHandler(null)
  }, [])

  const handleRegistration = useCallback(
    async (
      server: string,
      token: string,
      refreshToken?: string,
      expiresIn?: number
    ): Promise<boolean> => {
      // Signup is Pixelfed-only in v1 (`handleRegistration` is reached only
      // from the email-verification flow, which hits `api/auth/onboarding` —
      // a Pixelfed-only endpoint). When a future Mastodon-signup path lands,
      // thread the registered protocol through here.
      const profile = await verifyCredentials(server, token, 'pixelfed')
      if (!profile) return false

      // Persist the per-account access token before upserting the registry —
      // ApiContext lookups during the next render need both halves present.
      // The per-instance OAuth credentials were already stored by
      // `verifyEmail.tsx`. Pixelfed's onboarding endpoint may or may not
      // return a refresh token depending on instance config; persist it
      // when present so Phase 3's refresh path has something to work with.
      const tokenExpiresAt =
        typeof expiresIn === 'number' ? Date.now() + expiresIn * 1000 : null

      // Mastodon Pass Phase 1 §2.1: signup is Pixelfed-only in v1 (plan
      // §9.14: "Mastodon signup — redirect to instance, hide in-app form").
      // Pass `type` explicitly anyway so a future Mastodon-signup adapter
      // is a one-arg change instead of an upsert-default chase.
      const summary = AccountStore.upsert({
        type: 'pixelfed',
        instance: server,
        userId: profile.id,
        username: profile.username,
        acct: profile.acct,
        displayName: profile.display_name,
        avatarUrl: profile.avatar ?? null,
        tokenExpiresAt,
      })
      secureStore.setAccessToken(summary.accountId, token)
      if (refreshToken) {
        secureStore.setRefreshToken(summary.accountId, refreshToken)
      }
      AccountStore.setProfileBlob(summary.accountId, JSON.stringify(profile))
      AccountStore.markCredentialsVerified(summary.accountId)
      refreshFeatures(server)
      AccountStore.setActive(summary.accountId)
      // Mastodon Pass §3.3: warm the capability cache for the new account's
      // host. Signup is Pixelfed-only in v1 (`type: 'pixelfed'` above), so
      // this seeds the Pixelfed-shape `InstanceCapabilities`.
      fetchInstanceCapabilities(server, 'pixelfed').catch(() => {})
      // Best-effort per-account push registration. No-ops when the user
      // hasn't granted notification permission yet (signup flow rarely
      // has) — cold-start refresh and the Settings → Notifications enable
      // path will pick this account up later. (Phase 5, §9.2 step 1)
      registerPushForAccount(summary.accountId).catch(() => {})
      return true
    },
    []
  )

  const login = useCallback(
    async (server: string, opts?: { isAddAccount?: boolean }): Promise<LoginOutcome> => {
      const isAddAccount = opts?.isAddAccount === true
      // Pre-flight (nodeinfo, reachability, server-type allow-list) before
      // opening a browser tab.
      const precheck = await loginPreflightCheck(server)
      if (!precheck.ok) {
        return {
          ok: false,
          reason: 'preflight',
          message: formatLoginPreflightMessage(precheck, server),
          preflightReason: precheck.reason,
          softwareName: precheck.softwareName,
        }
      }
      if (precheck.nodeinfo) persistFeatures(server, precheck.nodeinfo)

      // Halflight `twofactor_intended_redirect` capability — when the server
      // advertises it, /oauth/authorize + /i/auth/checkpoint round-trips
      // preserve `url.intended` through 2FA, so a dismissed webview really is
      // a user cancellation. On unpatched servers (vanilla Pixelfed, older
      // Halflight, anything that doesn't publish the flag) the upstream bug
      // strands the webview on `/`, and the existing retry path below
      // recovers by re-opening the authorize URL with persistent browser
      // cookies. Booleanise strictly — never trust truthy defaults from
      // foreign nodeinfo.
      const features = parseFeatures(precheck.nodeinfo)
      const serverPreservesIntendedUrl = features.twofactor_intended_redirect === true
      // `force_login=true` on /oauth/authorize tells the server to invalidate
      // the current browser session and force a fresh login form. Mastodon
      // documents the parameter natively; Halflight-patched Pixelfed instances
      // opt in by publishing the `oauth_force_login` nodeinfo capability
      // (see HALFLIGHT_OAUTH_FORCE_LOGIN server patch). Vanilla Pixelfed and
      // unpatched Halflight installs ignore the unknown parameter — no harm,
      // those clients fall back to Lever 1 (ephemeral webview) for the same
      // effect at the browser layer.
      const shouldForceLogin =
        isAddAccount &&
        (precheck.type === 'mastodon' || features.oauth_force_login === true)

      // Account cap is enforced *before* we open the browser so the user
      // doesn't authenticate against a server only to bounce off the
      // registry write.
      if (
        AccountStore.list().length >= MAX_ACCOUNTS &&
        !AccountStore.findByInstanceAndUserId(server, '__placeholder__')
      ) {
        return {
          ok: false,
          reason: 'cap-reached',
          message: `You're at the ${MAX_ACCOUNTS}-account limit. Remove one before adding another.`,
        }
      }

      const REDIRECT_URI = Linking.createURL('login')

      // Mastodon Pass Phase 1 §2.4 — scope template per server type. Pixelfed
      // gets the existing `admin:*` scopes the in-app admin console depends on;
      // Mastodon does not.
      const scopes = scopeStringForType(precheck.type)

      // Mastodon Pass Phase 1 §2.4 / RFC 8414 — discover the actual OAuth
      // endpoints from the server. Mastodon ≥ 4.3 always serves the well-known
      // metadata document; Pixelfed currently does not (the helper falls back
      // to the documented defaults in that case).
      const endpoints = await fetchOAuthMetadata(server)

      // Reuse per-instance OAuth credentials when present (§6.2): a second
      // account on the same instance should not re-register.
      let clientId = secureStore.getClientId(server)
      let clientSecret = secureStore.getClientSecret(server)

      if (!clientId || !clientSecret) {
        const formBody = {
          client_name: oauthClientName(Platform.OS === 'android' ? 'Android' : 'iOS'),
          redirect_uris: REDIRECT_URI,
          scopes,
          website: APP.website,
        }
        // App registration is the first request the user-typed server has to
        // answer correctly post-preflight. A throw here was previously caught
        // by handleLogin's outer try/catch and surfaced as the generic "An
        // error occurred during authentication" — replace with a descriptive
        // callback-error so the toast tells the user what failed and where.
        try {
          const app = await postForm(`https://${server}/api/v1/apps`, formBody).then(
            (resp: any) => resp.json()
          )
          clientId = app.client_id as string
          clientSecret = app.client_secret as string
          if (!clientId || !clientSecret) {
            return {
              ok: false,
              reason: 'callback-error',
              message: `${server} didn't return valid app credentials. Please try again or pick a different server.`,
            }
          }
          secureStore.setClientId(server, clientId)
          secureStore.setClientSecret(server, clientSecret)
        } catch {
          return {
            ok: false,
            reason: 'callback-error',
            message: `Couldn't register on ${server}. The server may be down — please try again in a moment.`,
          }
        }
      }

      // Mastodon Pass Phase 1 §2.4 / item 1.5.1 — PKCE S256 unconditionally.
      // The verifier is held under the OAuth `state` value and consumed in the
      // callback. A `state` collision would only happen in an in-process race
      // (very unlikely on a phone, but cheap to defend), and stashing per-state
      // also gives a clean cancel hook below.
      const state = await generateOAuthState()
      const pkce = await generatePkcePair()
      stashPkceVerifier(state, pkce.codeVerifier)

      const authUrl = (() => {
        const u = new URL(endpoints.authorizationEndpoint)
        u.searchParams.set('client_id', clientId)
        u.searchParams.set('scope', scopes)
        u.searchParams.set('redirect_uri', REDIRECT_URI)
        u.searchParams.set('response_type', 'code')
        u.searchParams.set('state', state)
        u.searchParams.set('code_challenge', pkce.codeChallenge)
        u.searchParams.set('code_challenge_method', pkce.codeChallengeMethod)
        if (shouldForceLogin) u.searchParams.set('force_login', 'true')
        return u.toString()
      })()

      const preflightForCallback: LoginPreflightSuccess = precheck

      const runAuth = async (): Promise<LoginOutcome> => {
        if (Platform.OS === 'android') WebBrowser.warmUpAsync()
        // Add-account: ask iOS for an ephemeral ASWebAuthenticationSession
        // so the webview doesn't inherit Safari's cookies for this
        // instance. Without this, a user with account A already signed in
        // (and a live Safari session cookie) hits /oauth/authorize already
        // authenticated as A — there's no "switch user" affordance on
        // Pixelfed's auth pages, so the second account flow is stranded.
        // Android Custom Tabs have no equivalent flag; the force_login URL
        // parameter (Lever 2) is the cross-platform companion.
        const res = await WebBrowser.openAuthSessionAsync(authUrl, REDIRECT_URI, {
          preferEphemeralSession: isAddAccount,
        })
        if (res.type === 'success') {
          const callback = await _loginCallback(
            res.url,
            server,
            REDIRECT_URI,
            preflightForCallback,
            endpoints.tokenEndpoint
          )
          if (Platform.OS === 'android') WebBrowser.coolDownAsync()
          return callback
        }
        if (Platform.OS === 'android') WebBrowser.coolDownAsync()
        // The user dismissed the browser before the callback — drop the
        // pending verifier so a stale entry doesn't pile up in the Map.
        // (The same `state` will get a fresh verifier on the retry below.)
        discardPkceVerifier(state)
        // Capability gate: when the server preserves the in-flight URL
        // through 2FA (Halflight `twofactor_intended_redirect`), dismissal
        // is a real cancellation — no retry recovery is needed, and a
        // 2FA-flavoured confirm dialog would lie about why login ended.
        if (serverPreservesIntendedUrl) {
          return { ok: false, reason: 'cancelled' }
        }
        // Unpatched server — expose a `retry()` the caller can invoke
        // after confirming with the user. The upstream /oauth/authorize +
        // /i/auth/checkpoint bug strands the webview on /; the retry's
        // second authorize round-trip usually completes because Safari
        // cookies persist across the session.
        return {
          ok: false,
          reason: 'cancelled',
          needs2faRetry: true,
          retry: async () => {
            // Fresh PKCE pair + state for the retry — never reuse a verifier
            // across a cancelled session.
            const retryState = await generateOAuthState()
            const retryPkce = await generatePkcePair()
            stashPkceVerifier(retryState, retryPkce.codeVerifier)
            const retryAuthUrl = (() => {
              const u = new URL(endpoints.authorizationEndpoint)
              u.searchParams.set('client_id', clientId as string)
              u.searchParams.set('scope', scopes)
              u.searchParams.set('redirect_uri', REDIRECT_URI)
              u.searchParams.set('response_type', 'code')
              u.searchParams.set('state', retryState)
              u.searchParams.set('code_challenge', retryPkce.codeChallenge)
              u.searchParams.set('code_challenge_method', retryPkce.codeChallengeMethod)
              if (shouldForceLogin) u.searchParams.set('force_login', 'true')
              return u.toString()
            })()
            if (Platform.OS === 'android') WebBrowser.warmUpAsync()
            const res2 = await WebBrowser.openAuthSessionAsync(
              retryAuthUrl,
              REDIRECT_URI,
              {
                preferEphemeralSession: isAddAccount,
              }
            )
            if (Platform.OS === 'android') WebBrowser.coolDownAsync()
            if (res2.type === 'success') {
              return _loginCallback(
                res2.url,
                server,
                REDIRECT_URI,
                preflightForCallback,
                endpoints.tokenEndpoint
              )
            }
            discardPkceVerifier(retryState)
            return { ok: false, reason: 'cancelled' }
          },
        }
      }

      return runAuth()
    },
    []
  )

  const _loginCallback = async (
    callbackUrl: string,
    instance: string,
    redirectUri: string,
    preflight: LoginPreflightSuccess,
    tokenEndpoint: string
  ): Promise<LoginOutcome> => {
    const { queryParams } = Linking.parse(callbackUrl)

    if (queryParams?.message || queryParams?.error) {
      const msg = Array.isArray(queryParams.message)
        ? queryParams.message[0]
        : queryParams.message
      const err = Array.isArray(queryParams.error)
        ? queryParams.error[0]
        : queryParams.error
      return { ok: false, reason: 'callback-error', message: (msg || err) ?? 'Error' }
    }

    if (!queryParams?.code) {
      return {
        ok: false,
        reason: 'callback-error',
        message: 'Authentication failed: missing authorization code',
      }
    }

    // Mastodon Pass Phase 1 §2.4 / RFC 7636 — pull the verifier matching the
    // `state` we sent on `/oauth/authorize`. A missing verifier means the
    // process restarted between authorize and callback; we treat that as a
    // hard error rather than silently shipping a non-PKCE token request,
    // because servers that require PKCE (Mastodon does at v4.4+) would
    // reject the exchange anyway.
    const stateValue = Array.isArray(queryParams.state)
      ? queryParams.state[0]
      : queryParams.state
    const codeVerifier =
      typeof stateValue === 'string' ? consumePkceVerifier(stateValue) : null
    if (!codeVerifier) {
      return {
        ok: false,
        reason: 'callback-error',
        message: 'Authentication failed: PKCE state mismatch — please try again',
      }
    }

    const api = `https://${instance}`
    const clientId = secureStore.getClientId(instance)
    const clientSecret = secureStore.getClientSecret(instance)
    if (!clientId || !clientSecret) {
      return {
        ok: false,
        reason: 'callback-error',
        message: 'Authentication failed: missing client credentials',
      }
    }

    const tokenRequestBody = {
      client_id: clientId,
      client_secret: clientSecret,
      redirect_uri: redirectUri,
      grant_type: 'authorization_code',
      code: queryParams.code,
      scope: scopeStringForType(preflight.type),
      code_verifier: codeVerifier,
    }

    // Token exchange and verify_credentials each get a try/catch returning a
    // descriptive callback-error. Without this a network blip after the user
    // already authorized in the browser tab would surface as the generic
    // "An error occurred during authentication" toast — opaque, especially
    // when the user has no idea which step failed.
    let token: { access_token?: string; refresh_token?: string; expires_in?: number }
    try {
      token = await postForm(tokenEndpoint, tokenRequestBody).then((resp) => resp.json())
    } catch {
      return {
        ok: false,
        reason: 'callback-error',
        message: `Sign-in didn't complete on ${instance}. Please try again.`,
      }
    }
    if (!token?.access_token) {
      return {
        ok: false,
        reason: 'callback-error',
        message: `${instance} didn't return an access token. Please try again.`,
      }
    }

    // Mastodon Pass Phase 1 §2.3 — `_pe=1` is Pixelfed-only. Mastodon's
    // `/api/v1/accounts/verify_credentials` ignores unknown query params today
    // but the parameter is meaningless on that path anyway.
    const profileQuery = preflight.type === 'pixelfed' ? { _pe: 1 } : undefined
    let profile: LoginUserResponse
    try {
      profile = await get(
        `${api}/api/v1/accounts/verify_credentials`,
        token.access_token,
        profileQuery
      ).then((resp: any) => resp.json())
    } catch {
      return {
        ok: false,
        reason: 'callback-error',
        message: `Couldn't load your account info from ${instance}. Please try again.`,
      }
    }
    if (!profile?.id || !profile?.username) {
      return {
        ok: false,
        reason: 'callback-error',
        message: `${instance} returned an incomplete account profile. Please try again.`,
      }
    }

    const tokenExpiresAt =
      typeof token.expires_in === 'number' ? Date.now() + token.expires_in * 1000 : null

    const summary = AccountStore.upsert({
      type: preflight.type,
      apiVersionsMastodon: preflight.apiVersionsMastodon,
      instance,
      userId: profile.id,
      username: profile.username,
      acct: profile.acct,
      displayName: profile.display_name,
      avatarUrl: profile.avatar ?? null,
      tokenExpiresAt,
    })

    secureStore.setAccessToken(summary.accountId, token.access_token)
    if (token.refresh_token) {
      secureStore.setRefreshToken(summary.accountId, token.refresh_token)
    }

    AccountStore.setProfileBlob(summary.accountId, JSON.stringify(profile))
    AccountStore.markCredentialsVerified(summary.accountId)
    AccountStore.setActive(summary.accountId)

    // Mastodon Pass §3.3: warm the per-(type, host) capability cache as soon
    // as we know the type. The activeAccountId effect would pick this up on
    // the next foreground anyway, but firing it here means the first feed/
    // compose touch already sees real limits instead of `defaultCapabilities`.
    fetchInstanceCapabilities(instance, preflight.type).catch(() => {})

    // Best-effort per-account push registration (Phase 5, §9.2 step 1).
    // No-ops when notification permission hasn't been granted yet — the
    // user will pick this up the first time they enable push in Settings.
    registerPushForAccount(summary.accountId).catch(() => {})

    return { ok: true, type: preflight.type }
  }

  const switchAccount = useCallback((accountId: string) => {
    // beginSwitch flips the active pointer AND drives the AccountSwitch
    // Overlay (§11.5). The activeAccountId effect above re-runs
    // verify_credentials and clears the overlay on settle, doubling as
    // the §6.3 "background verify on switch" — no extra wiring.
    AccountStore.beginSwitch(accountId)
  }, [])

  const removeAccount = useCallback(async (accountId: string) => {
    // Pick the fallback BEFORE the soft-delete so `pickFallbackActiveId`
    // sees every candidate in its pre-mutation state. `markSignedOut`
    // threads the fallback so the active-id flip and the registry write
    // land in a single emit cycle — no `(newList, null)` intermediate that
    // would throw inside `acct()` for any key built mid-flight (Phase R4).
    const fallback = AccountStore.pickFallbackActiveId(accountId)

    // Best-effort server-side revoke + push-unregister BEFORE local
    // teardown — both helpers read the access token synchronously up
    // front (the closures capture it before `markSignedOut` deletes it
    // from the keychain). Fire-and-forget: a server outage must not block
    // local cleanup.
    tryRevokeAccessToken(accountId).catch(() => {})
    unregisterPushForAccount(accountId).catch(() => {})
    // Watch handoff (Phase B): tell the paired Apple Watch to drop its
    // local copy of this account's credentials. Fired before
    // `markSignedOut` so the watch starts wiping in parallel with the
    // phone — an unreachable watch silently drops the message and is
    // re-synced on the next foreground/AccountStore push.
    revokeOnWatch(accountId)

    // Soft-delete: tokens go, registry entry stays under `signedOut: true`
    // and is hidden from `list()`. The account's profile blob, drafts,
    // and per-account React Query cache slices stay on disk so a future
    // re-auth via the (instance, userId) match in `upsert()` revives the
    // same accountId and surfaces every cache slice intact.
    await AccountStore.markSignedOut(accountId, fallback)
  }, [])

  const signOutAll = useCallback(async () => {
    // Best-effort revoke + push-unregister for every signed-in account.
    // Don't block on any of them — the local wipe runs regardless. Both
    // helpers read tokens synchronously before the upcoming clearAll
    // deletes them. (§6.5 / §6.4 step 2 + Phase 5, §9.2 step 2)
    for (const account of AccountStore.list()) {
      tryRevokeAccessToken(account.accountId).catch(() => {})
      unregisterPushForAccount(account.accountId).catch(() => {})
    }
    // Watch handoff (Phase B): one wipe message covers every account
    // the watch might hold. The watch's WCSession receiver clears its
    // keychain unconditionally and lands on EmptyAuthView.
    revokeAllOnWatch()
    queryClient.clear()
    await AccountStore.clearAll()
    // No isLoading toggle: an `AuthenticatedStack` unmount across the
    // wipe drops the caller's `router.replace('/(public)/login')` on the
    // floor. The activeAccountId effect handles its own settle() now.
  }, [queryClient])

  // Backwards-compatible "Log out" — now means "sign out of the active
  // account" only. The Settings screen still wires `logout` to a single
  // button; rename in Phase 6 once the Accounts screen offers a separate
  // "Sign out of all" entry. No-op when nothing is signed in.
  const logout = useCallback(() => {
    const activeId = AccountStore.activeId()
    if (!activeId) return
    setIsLoading(true)
    removeAccount(activeId).finally(() => {
      setTimeout(() => setIsLoading(false), 200)
    })
  }, [removeAccount])

  const invalidateCredentialsCache = useCallback(() => {
    const id = AccountStore.activeId()
    if (id) AccountStore.invalidateCredentialsCache(id)
  }, [])

  return (
    <AuthContext
      value={{
        isLoading,
        user: activeAccount,
        accounts,
        activeAccountId,
        login,
        logout,
        switchAccount,
        removeAccount,
        signOutAll,
        handleRegistration,
        invalidateCredentialsCache,
      }}
    >
      {children}
    </AuthContext>
  )
}

/**
 * Single source of truth for the active account's identity. Replaces
 * `useUserCache` / `usePreUserCache` / `useQuerySelfProfile`. Keys the
 * server query on the **registry**'s `userId`, never on the cached blob's
 * `id`, so cache corruption can't leak into the queryKeyspace.
 *
 *   - `profile`: merged `verify_credentials` + freshly-fetched account blob.
 *     Briefly null during cold start (before the first verify lands) and
 *     during self-heal-then-rebuild — consumers should optional-chain.
 *   - `summary`: registry view of the active account; null when signed out.
 *   - `isFetching`: true while the underlying query is fetching.
 *
 * The writeback effect reads the cache snapshot inside the effect body so
 * the closure-vs-deps foot-gun can't reintroduce itself, and early-returns
 * on null current to avoid stripping `LoginUserResponse`-only fields
 * (`is_admin`, `email`, `locked`) that the cached blob carries but the wire
 * `Account` does not.
 */
export type ActiveProfile = {
  profile: LoginUserResponse | null
  summary: AccountSummary | null
  isFetching: boolean
}

export function useActiveProfile(): ActiveProfile {
  const summary = useActiveAccount()
  const cached = useActiveUserCache()
  const activeAccountId = useActiveAccountId()
  const userId = summary?.userId ?? ''

  const { data, isFetching, isFetchedAfterMount } = useQuery({
    ...accountQueries.selfProfile(userId),
    enabled: userId.length > 0,
    placeholderData: cached ?? undefined,
  })

  useEffect(() => {
    if (!data || !isFetchedAfterMount || !activeAccountId) return
    const current = AccountStore.activeUserCache()
    if (!current) return
    AccountStore.setProfileBlob(activeAccountId, JSON.stringify({ ...current, ...data }))
  }, [data, isFetchedAfterMount, activeAccountId])

  // Merge cached over data so editable fields the public account endpoint
  // omits (`source.note` on Mastodon, `note_text` / `email` / `is_admin` /
  // `locked` from the `verify_credentials` shape) survive after the public
  // refetch resolves. Without the merge the hook flips from the cached blob
  // to the bare `Account` once `data` lands, blanking Edit Profile's bio
  // field and the admin-only / private settings rows. The writeback effect
  // above does the same merge into MMKV, but on the React side we need to
  // surface it directly — placeholderData only carries cached until the
  // query first resolves.
  const profile = (
    cached || data ? ({ ...(cached ?? {}), ...(data ?? {}) } as LoginUserResponse) : null
  ) as LoginUserResponse | null
  return { profile, summary, isFetching }
}
