import { postForm } from 'src/requests'
import { AccountStore, type AccountType } from 'src/state/accountStore'
import { secureStore } from 'src/state/storage'
import { userAgentHeader } from './branding'
import { fetchOAuthMetadata } from './oauth'
import { applyProtocolUrlGates, protocolAppHeader } from './protocol/wireGates'
import { randomKey } from './randomKey'
import { pushHandoffForAccount } from './watch/handoff'

type ApiRequestOptions = {
  idempotency?: true
  searchParams?: Record<string, string | number | boolean | undefined>
}

// ---------------------------------------------------------------------------
// Header observability (Mastodon Pass §6.3).
//
// Three response-header families worth logging in dev so upstream API drift
// surfaces in QA before it ships to users:
//
//   - `Mastodon-Async-Refresh`  (Mastodon ≥ 4.5)  — long-running server task
//      asks the client to poll `/api/v1/async-refreshes/:id`. v1 doesn't act
//      on it; the log line means "we just hit an endpoint that started one,
//      revisit if a feature wants to wait for the task."
//   - `Deprecation`              (RFC 9745, ≥ 4.4) — server is telling us the
//      endpoint is going away. Catch in QA, file the migration before the
//      header turns into a 410.
//   - `X-RateLimit-{Limit,Remaining,Reset}` — visibility on whether anything
//      we do is anywhere near the limit. v1 does not back off automatically;
//      the log lets a developer notice burst-loop bugs in dev.
//
// One line per response that carries any of these headers. `__DEV__` only —
// no production cost, no crash-reporter noise (per plan §6.3).
function logObservabilityHeaders(response: Response, requestUrl: string): void {
  if (!__DEV__) return
  const h = response.headers
  const asyncRefresh = h.get('Mastodon-Async-Refresh') ?? h.get('mastodon-async-refresh')
  const deprecation = h.get('Deprecation') ?? h.get('deprecation')
  const rateLimit = h.get('X-RateLimit-Limit') ?? h.get('x-ratelimit-limit')
  const rateRemaining = h.get('X-RateLimit-Remaining') ?? h.get('x-ratelimit-remaining')
  const rateReset = h.get('X-RateLimit-Reset') ?? h.get('x-ratelimit-reset')
  if (!asyncRefresh && !deprecation && !rateLimit && !rateRemaining && !rateReset) {
    return
  }
  const parts: string[] = []
  if (deprecation) parts.push(`Deprecation=${deprecation}`)
  if (asyncRefresh) parts.push(`Mastodon-Async-Refresh=${asyncRefresh}`)
  if (rateLimit || rateRemaining || rateReset) {
    parts.push(
      `RateLimit=${rateRemaining ?? '?'}/${rateLimit ?? '?'} reset=${rateReset ?? '?'}`
    )
  }
  console.warn(`[api obs] ${requestUrl} → ${parts.join(' ')}`)
}

// Mastodon Pass 3 Phase 1 §2.3 — diagnosis bait. Log the URL + status on
// every 401/403 so a developer running the app against a healthy Mastodon
// account can identify which endpoint is firing transient auth failures.
// Without this hook, the §2.2 "confirm token alive before bouncing" path
// silently absorbs the 401 and the user-visible symptom (request returns
// an error to the caller) does not say which endpoint actually rejected.
// Dev-only; production has zero cost and zero crash-reporter noise.
function logAuthFailure(url: string, status: number): void {
  if (!__DEV__) return
  console.warn(`[api auth] ${status} ← ${url}`)
}

// Per-account auth-failure handler. The AuthProvider registers a single
// callback that knows how to mark `needsReauth=true`, drop the failing
// account's tokens, and pick a fallback active account. Replaces the old
// global-logout slot, which would unconditionally wipe every account.
type AccountAuthFailure = (accountId: string) => void
let onAccountAuthFailure: AccountAuthFailure | null = null

export function setAccountAuthFailureHandler(fn: AccountAuthFailure | null): void {
  onAccountAuthFailure = fn
}

// ---------------------------------------------------------------------------
// Token refresh (Phase 3)
//
// Pixelfed runs Laravel Passport with refresh-token grant enabled by default
// (365 d access / 400 d refresh, both overridable per-instance). We persist
// `tokenExpiresAt` from the token-exchange response on the AccountSummary
// so the pre-emptive refresh path has real numbers to read; we never
// hard-code 365 d.
//
// The lock map coalesces parallel 401s on the same account: every caller
// awaits the same in-flight `/oauth/token` round-trip rather than firing N
// duplicate refreshes. The lock is keyed by accountId so a 401 on account A
// does not block a refresh on account B.
// ---------------------------------------------------------------------------

export type RefreshResult = { ok: true; accessToken: string } | { ok: false }

const refreshLocks = new Map<string, Promise<RefreshResult>>()

const PREEMPTIVE_REFRESH_THRESHOLD_MS = 10 * 60 * 1000 // 10 minutes

/**
 * Refresh the access token for `accountId`. Coalesces concurrent callers via
 * `refreshLocks` so only one `/oauth/token` round-trip is in flight per
 * account at a time.
 *
 * On a hard server rejection (HTTP error, missing refresh token, missing
 * client credentials, malformed response) the account is patched with
 * `needsReauth = true` so the Settings UI surfaces a "needs re-sign-in"
 * cue. On a transient network failure we leave registry state alone — the
 * caller's normal error path fires and the next attempt can succeed.
 */
export function refreshAccessToken(accountId: string): Promise<RefreshResult> {
  const existing = refreshLocks.get(accountId)
  if (existing) return existing
  const promise = doRefresh(accountId).finally(() => {
    refreshLocks.delete(accountId)
  })
  refreshLocks.set(accountId, promise)
  return promise
}

async function doRefresh(accountId: string): Promise<RefreshResult> {
  const account = AccountStore.get(accountId)
  if (!account) return { ok: false }

  const refreshToken = secureStore.getRefreshToken(accountId)
  if (!refreshToken) {
    // No refresh capability available for this account. On Pixelfed this is
    // the legacy signup-completion path; on Mastodon it is the rule, not the
    // exception — Mastodon's `/oauth/token` does not issue a refresh_token
    // under the standard authorization_code grant, and access tokens are
    // non-expiring by spec. Either way, do NOT pre-emptively brick the
    // account here. The 401-retry path's confirmation hook (§2.2) probes
    // `verify_credentials` before falling through to handleUnauthorized, so
    // a single endpoint-specific 401 on a still-valid token can no longer
    // strand the account in `needsReauth`.
    return { ok: false }
  }

  const clientId = secureStore.getClientId(account.instance)
  const clientSecret = secureStore.getClientSecret(account.instance)
  if (!clientId || !clientSecret) {
    AccountStore.patch(accountId, { needsReauth: true })
    return { ok: false }
  }

  // Mastodon Pass Phase 1 §2.4 — use the discovered token endpoint when the
  // server advertises one; fall back to the documented default path
  // otherwise. The metadata helper caches per-host so this is one fetch per
  // process per host.
  const endpoints = await fetchOAuthMetadata(account.instance)

  let resp: Response
  try {
    resp = await postForm(endpoints.tokenEndpoint, {
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: clientId,
      client_secret: clientSecret,
    })
  } catch {
    // Transient network failure — don't poison the registry. The caller
    // (request()) will fall through to its 401 path and the user re-tries
    // when connectivity returns.
    return { ok: false }
  }

  if (!resp.ok) {
    AccountStore.patch(accountId, { needsReauth: true })
    return { ok: false }
  }

  let json: { access_token?: unknown; refresh_token?: unknown; expires_in?: unknown }
  try {
    json = await resp.json()
  } catch {
    AccountStore.patch(accountId, { needsReauth: true })
    return { ok: false }
  }

  if (typeof json.access_token !== 'string' || json.access_token.length === 0) {
    AccountStore.patch(accountId, { needsReauth: true })
    return { ok: false }
  }

  secureStore.setAccessToken(accountId, json.access_token)
  if (typeof json.refresh_token === 'string' && json.refresh_token.length > 0) {
    secureStore.setRefreshToken(accountId, json.refresh_token)
  }

  const tokenExpiresAt =
    typeof json.expires_in === 'number' ? Date.now() + json.expires_in * 1000 : null
  AccountStore.patch(accountId, {
    tokenExpiresAt,
    lastActiveAt: Date.now(),
  })

  // Watch handoff (Phase B): push the freshly-rotated token to the
  // paired Apple Watch when this account is the active one. The helper
  // short-circuits on Android, when no watch is paired/installed, and
  // when accountId !== activeId. Fire-and-forget — `updateApplication
  // Context` is async on the native side and a slow watch must not
  // block the API caller's retry path.
  pushHandoffForAccount(accountId)

  return { ok: true, accessToken: json.access_token }
}

/**
 * Lightweight pre-emptive refresh. Called from AuthProvider on cold start,
 * account switch, and app foreground — best-effort; failures are silent.
 *
 * Skips accounts that are already marked `needsReauth` (no point), or that
 * have no `tokenExpiresAt` (legacy/signup path — we don't know when to
 * refresh, so leave it for the on-401 path), or whose tokens still have
 * more than 10 minutes left.
 */
export async function tryPreemptiveRefresh(accountId: string): Promise<void> {
  const account = AccountStore.get(accountId)
  if (!account || account.needsReauth) return
  if (account.tokenExpiresAt == null) return
  const remaining = account.tokenExpiresAt - Date.now()
  if (remaining > PREEMPTIVE_REFRESH_THRESHOLD_MS) return
  await refreshAccessToken(accountId)
}

/**
 * Best-effort access-token revoke against the account's instance. Used by
 * the Remove Account flow so the server can drop its session for this
 * client. Never throws — a server outage, missing endpoint, or absent
 * client credentials must not block local cleanup.
 *
 * Pixelfed runs Laravel Passport, which exposes a Mastodon-style
 * `/oauth/revoke` accepting `client_id`, `client_secret`, `token`. Older
 * deployments may 404 — fine, we still tear down locally.
 */
export async function tryRevokeAccessToken(accountId: string): Promise<void> {
  const account = AccountStore.get(accountId)
  if (!account) return

  const accessToken = secureStore.getAccessToken(accountId)
  const clientId = secureStore.getClientId(account.instance)
  const clientSecret = secureStore.getClientSecret(account.instance)
  if (!accessToken || !clientId || !clientSecret) return

  // Mastodon Pass Phase 1 §2.4 — discover the revocation endpoint when the
  // server advertises one; fall back to the documented default path
  // otherwise. The metadata helper caches per-host so this is at most one
  // discovery fetch per process per host.
  let revocationEndpoint: string
  try {
    const endpoints = await fetchOAuthMetadata(account.instance)
    revocationEndpoint = endpoints.revocationEndpoint
  } catch {
    revocationEndpoint = `https://${account.instance}/oauth/revoke`
  }

  try {
    await postForm(revocationEndpoint, {
      client_id: clientId,
      client_secret: clientSecret,
      token: accessToken,
    })
  } catch {
    // Server unreachable / endpoint missing — not our problem; the user
    // can revoke from the web UI if they want belt-and-braces cleanup.
  }
}

export class ApiError extends Error {
  // Mastodon Pass §9.1.9: carry the originating server type so error UX (toast
  // copy, retry policy, fork-specific recovery hints) can branch cleanly. All
  // construction happens inside `ApiContext.request` / `fetchUrl`, so the
  // value is always set when an `ApiError` originates from the request layer.
  // Optional only because `instanceof ApiError` checks at outer-call-site
  // catch handlers used to construct stand-in errors without a context.
  public readonly serverType?: AccountType
  constructor(
    public status: number,
    public code?: string,
    message?: string,
    serverType?: AccountType
  ) {
    super(message ?? `API request failed with status ${status}`)
    this.serverType = serverType
  }
}

export class ApiContext {
  constructor(
    public readonly instanceHostName: string,
    public readonly token: string,
    public readonly accountId: string,
    // Mastodon Pass Phase 1 §2.3 — required, no default. The wire format
    // diverges per type (`_pe=1` query, `X-PIXELFED-APP` header on Pixelfed;
    // neither on Mastodon) and a missing or wrong type silently mis-routes
    // requests. Both context factories below thread it through from the
    // registry entry, which is itself populated from the preflight
    // classification at sign-in.
    public readonly accountType: AccountType
  ) {
    if (instanceHostName.length === 0) {
      throw new Error('failed to create ApiContext: instance host name missing')
    }
    if (token.length === 0) {
      throw new Error('failed to create ApiContext: token missing')
    }
    if (accountId.length === 0) {
      throw new Error('failed to create ApiContext: accountId missing')
    }
  }

  private handleUnauthorized() {
    // Clear this account's credentials cache so the AuthProvider's
    // verify-credentials path re-runs against the server.
    AccountStore.invalidateCredentialsCache(this.accountId)

    // Hand off to the per-account failure handler. A 401 from account B
    // does NOT log out account A — the handler is account-scoped.
    if (onAccountAuthFailure) {
      onAccountAuthFailure(this.accountId)
    }
  }

  // Mastodon Pass 3 Phase 1 §2.2 — confirm the current token is actually
  // dead before bouncing. The only callers are the 401/403 retry paths
  // below, and only on Mastodon contexts (Pixelfed has refresh-token
  // recovery; Mastodon does not, so the original 401 is the only signal).
  // A direct fetch (no refresh-and-retry, no `_pe=1` for Mastodon) keeps
  // the probe surgical: 2xx → token alive, 401/403 → token dead, anything
  // else (network error, 5xx, etc.) → assume dead (same default as the
  // pre-existing auth-failure path; we'd rather make the user re-sign-in
  // once than leave them in a stuck-but-alive state).
  private async confirmTokenAlive(): Promise<boolean> {
    try {
      const resp = await fetch(
        `https://${this.instanceHostName}/api/v1/accounts/verify_credentials`,
        {
          method: 'GET',
          headers: {
            Accept: 'application/json',
            Authorization: `Bearer ${this.token}`,
            'User-Agent': userAgentHeader(),
          },
        }
      )
      return resp.ok
    } catch {
      return false
    }
  }

  private buildUrl(
    path: string,
    params?: Record<string, string | number | boolean | undefined>
  ): string {
    const url = new URL(path, `https://${this.instanceHostName}`)
    // Wire gates (`_pe=1`, …) are owned by `protocol/wireGates.ts` — the same
    // helper feeds the pre-auth `verify_credentials` probe so the gate logic
    // lives in one place. See §3.3 of the multi-account protocol spec.
    applyProtocolUrlGates(url, this.accountType)
    if (params) {
      for (const [k, v] of Object.entries(params)) {
        if (v === undefined) url.searchParams.delete(k)
        else url.searchParams.set(k, String(v))
      }
    }
    return url.toString()
  }

  async request(
    path: string,
    fetch_options: RequestInit,
    options?: ApiRequestOptions
  ): Promise<Response> {
    const url = this.buildUrl(path, options?.searchParams)

    // Idempotency key is computed once per logical request — a retry after a
    // refresh must reuse it so the server can dedupe if the original
    // actually reached the backend. Sourced from `Crypto.getRandomBytesAsync`
    // so the value isn't predictable — see `src/lib/randomKey.ts`.
    const idempotencyKey = options?.idempotency ? await randomKey(40) : undefined

    const performFetch = (token: string) =>
      fetch(url, {
        ...fetch_options,
        headers: {
          ...fetch_options.headers,
          Accept: 'application/json',
          Authorization: `Bearer ${token}`,
          // Mastodon Pass Phase 1 §2.4 / item 12 — identifying user-agent on
          // every request, regardless of account type. Mastodon clients are
          // expected to identify themselves; the header is harmless on
          // Pixelfed.
          'User-Agent': userAgentHeader(),
          ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}),
        },
      })

    let response = await performFetch(this.token)

    // 401/403: try refresh once, retry once, then fall through to the
    // per-account auth-failure handler. Refresh coalesces via the lock map
    // so parallel 401s don't fire N `/oauth/token` round-trips.
    if (response.status === 401 || response.status === 403) {
      logAuthFailure(url, response.status)
      const refreshed = await refreshAccessToken(this.accountId)
      if (refreshed.ok) {
        response = await performFetch(refreshed.accessToken)
      } else if (this.accountType === 'mastodon') {
        // Mastodon Pass 3 Phase 1 §2.2 — refresh always fails on Mastodon
        // (no refresh_token by spec), so a naive fall-through would brick
        // the account on every transient endpoint-specific 401. Probe
        // verify_credentials with the current token: if it succeeds the
        // original 401 was endpoint-specific (rate-limit edge case,
        // Pixelfed-shaped path returning a non-2xx, etc.) and we surface
        // the original error to the caller without flipping needsReauth.
        const tokenAlive = await this.confirmTokenAlive()
        if (tokenAlive) {
          throw new ApiError(
            response.status,
            undefined,
            'Endpoint rejected this request',
            this.accountType
          )
        }
      }
    }

    // Mastodon Pass §6.3 — log dev-only observability headers (Deprecation,
    // Mastodon-Async-Refresh, rate-limit). Runs on the final response after
    // any refresh-and-retry, so repeated logs from a single logical call
    // don't spam.
    logObservabilityHeaders(response, url)

    if (response.status === 401 || response.status === 403) {
      // A 403 that survives refresh-and-retry is almost always an
      // *authorization* verdict — a forbidden action, a role gate, an
      // insufficient-scope token, Pixelfed's "empty status" rule — not a dead
      // session. Probe verify_credentials before tearing the account down: if
      // the token still authenticates, surface the endpoint's rejection to the
      // caller instead of signing the user out. A 401 always means the token
      // itself is rejected, so it skips the probe.
      if (response.status === 403 && (await this.confirmTokenAlive())) {
        throw new ApiError(
          response.status,
          undefined,
          'Endpoint rejected this request',
          this.accountType
        )
      }
      this.handleUnauthorized()
      throw new ApiError(
        response.status,
        undefined,
        'Authentication failed - user has been logged out',
        this.accountType
      )
    }

    if (!response.ok) {
      let errorResponse
      try {
        errorResponse = await response.json()
        // Pixelfed's Laravel `ValidationException` returns `{ message, errors }`
        // (see `app/Exceptions/Handler.php` 422 branch); other Pixelfed and
        // every Mastodon error path uses `{ error }` / `{ error_code, msg }`.
        // Falling back to `message` keeps `explainPostError`'s 422 mapping
        // useful on Pixelfed validation failures rather than collapsing to
        // the default "API request failed with status N".
        const fallbackMessage =
          typeof errorResponse?.message === 'string' && errorResponse.message.length > 0
            ? (errorResponse.message as string)
            : undefined
        if (
          !errorResponse.error_code &&
          !errorResponse.code &&
          (typeof errorResponse.error === 'undefined' || errorResponse.error.length === 0)
        ) {
          if (fallbackMessage) {
            throw new ApiError(
              response.status,
              undefined,
              fallbackMessage,
              this.accountType
            )
          }
          throw new ApiError(response.status, undefined, undefined, this.accountType)
        }
      } catch (e) {
        if (e instanceof ApiError) throw e
        throw new ApiError(response.status, undefined, undefined, this.accountType)
      }
      if (errorResponse.error_code) {
        throw new ApiError(
          response.status,
          errorResponse.error_code,
          `${errorResponse.error_code}: ${errorResponse.msg}`,
          this.accountType
        )
      }
      throw new ApiError(
        response.status,
        errorResponse.code,
        errorResponse.error,
        this.accountType
      )
    }

    return response
  }

  /** request in json data and gets back json data */
  async jsonRequest(
    method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
    path: string,
    data = {},
    searchParams: ApiRequestOptions['searchParams'] = {}
  ) {
    return await (
      await this.request(
        path,
        {
          method,
          body: JSON.stringify(data),
          headers: { 'Content-Type': 'application/json' },
        },
        { searchParams }
      )
    ).json()
  }

  async get(url: string, searchParams: ApiRequestOptions['searchParams'] = {}) {
    const response = await this.request(
      url,
      {
        method: 'GET',
        headers: { 'Content-Type': 'application/json' },
      },
      { searchParams }
    )
    return await response.json()
  }

  async post(path: string, body?: unknown) {
    return await (
      await this.request(path, {
        method: 'POST',
        body: body !== undefined ? JSON.stringify(body) : undefined,
        headers: { 'Content-Type': 'application/json' },
      })
    ).json()
  }

  async postForm(path: string, formData: FormData) {
    return await (
      await this.request(path, {
        method: 'POST',
        body: formData,
        headers: { 'Content-Type': 'multipart/form-data' },
      })
    ).json()
  }

  async postIdempotent(path: string, body?: unknown) {
    return await (
      await this.request(
        path,
        {
          method: 'POST',
          body: body !== undefined ? JSON.stringify(body) : undefined,
          headers: { 'Content-Type': 'application/json' },
        },
        { idempotency: true }
      )
    ).json()
  }

  /**
   * POST a raw body (no JSON envelope, no idempotency key). Resolves to the
   * raw `Response` so callers that don't need a parsed body can drop it.
   *
   * No `Idempotency-Key` is sent — a legitimate retry of the same payload
   * (e.g. Tapestry `"LLLL"`) must append on the server, not dedup.
   */
  async postRaw(
    path: string,
    body: string,
    contentType = 'text/plain'
  ): Promise<Response> {
    return await this.request(path, {
      method: 'POST',
      body,
      headers: { 'Content-Type': contentType },
    })
  }

  async delete(path: string, body?: unknown) {
    return await (
      await this.request(path, {
        method: 'DELETE',
        body: body !== undefined ? JSON.stringify(body) : undefined,
        headers: { 'Content-Type': 'application/json' },
      })
    ).json()
  }

  async put(path: string, body?: unknown) {
    return await (
      await this.request(path, {
        method: 'PUT',
        body: body !== undefined ? JSON.stringify(body) : undefined,
        headers: { 'Content-Type': 'application/json' },
      })
    ).json()
  }

  async patch(path: string, body?: unknown) {
    return await (
      await this.request(path, {
        method: 'PATCH',
        body: body !== undefined ? JSON.stringify(body) : undefined,
        headers: { 'Content-Type': 'application/json' },
      })
    ).json()
  }

  /**
   * POST with form data, idempotency key, and optional app header.
   *
   * `X-PIXELFED-APP` gating is owned by `protocolAppHeader` in
   * `protocol/wireGates.ts`; the helper returns an empty object on Mastodon
   * even when the caller asks for it, so this method stays type-agnostic.
   */
  async postFormIdempotent(path: string, formData: FormData, appHeader = false) {
    return await (
      await this.request(
        path,
        {
          method: 'POST',
          body: formData,
          headers: {
            'Content-Type': 'multipart/form-data',
            ...protocolAppHeader(this.accountType, appHeader),
          },
        },
        { idempotency: true }
      )
    ).json()
  }

  /**
   * POST with app header. The endpoints that consume this are all
   * Pixelfed-only (`api/v1.1/push/*`, etc. — Phase 4 of the Mastodon Pass
   * plan splits push into a Mastodon-specific RFC 8030 path). On a Mastodon
   * context the header is omitted defensively (`protocolAppHeader` returns
   * an empty object), even though no production code path should be hitting
   * `postWithAppHeader` against a Mastodon account in v1.
   */
  async postWithAppHeader(path: string, body?: unknown) {
    return await (
      await this.request(path, {
        method: 'POST',
        body: body !== undefined ? JSON.stringify(body) : undefined,
        headers: {
          'Content-Type': 'application/json',
          ...protocolAppHeader(this.accountType, true),
        },
      })
    ).json()
  }

  /** GET with raw response (for pagination parsing) */
  async getRaw(
    path: string,
    searchParams: ApiRequestOptions['searchParams'] = {}
  ): Promise<Response> {
    return await this.request(
      path,
      {
        method: 'GET',
        headers: { 'Content-Type': 'application/json' },
      },
      { searchParams }
    )
  }

  /** Fetch a full absolute URL with auth (for paginated Link-header URLs) */
  async fetchUrl(url: string): Promise<Response> {
    // The URL comes from a Link-header value the home server fully controls.
    // A malicious or compromised server could point `next` / `prev` cursors
    // at an attacker domain to harvest the bearer; require the URL to point
    // back at the configured instance over HTTPS before forwarding the token.
    let parsed: URL
    try {
      parsed = new URL(url)
    } catch {
      throw new ApiError(0, undefined, 'Malformed pagination URL', this.accountType)
    }
    if (
      parsed.protocol !== 'https:' ||
      parsed.hostname.toLowerCase() !== this.instanceHostName.toLowerCase()
    ) {
      throw new ApiError(
        0,
        'cross-host-pagination',
        `Refusing to follow Link-header pointing at ${parsed.hostname}; expected ${this.instanceHostName}`,
        this.accountType
      )
    }

    // Wire gates need to apply on the Link-header pagination path too, so
    // paginated Pixelfed responses keep their `pf_type` / `pf_event` /
    // `pf_article` extensions. Same helper as `buildUrl` — idempotent,
    // Mastodon-no-op. See `protocol/wireGates.ts`.
    applyProtocolUrlGates(parsed, this.accountType)
    const target = parsed.toString()
    const performFetch = (token: string) =>
      fetch(target, {
        method: 'GET',
        headers: {
          Accept: 'application/json',
          Authorization: `Bearer ${token}`,
          'Content-Type': 'application/json',
          // Mastodon Pass Phase 1 §2.4 / item 12 — same identification on the
          // Link-header pagination path as on the main request path.
          'User-Agent': userAgentHeader(),
        },
      })

    let response = await performFetch(this.token)

    if (response.status === 401 || response.status === 403) {
      logAuthFailure(target, response.status)
      const refreshed = await refreshAccessToken(this.accountId)
      if (refreshed.ok) {
        response = await performFetch(refreshed.accessToken)
      } else if (this.accountType === 'mastodon') {
        // Mastodon Pass 3 Phase 1 §2.2 — same confirm-then-bounce shape
        // as `request()` above. The Link-header pagination path is the
        // surface most likely to surface a transient 401 (older endpoints,
        // rate-limit boundaries) on a still-valid Mastodon token.
        const tokenAlive = await this.confirmTokenAlive()
        if (tokenAlive) {
          throw new ApiError(
            response.status,
            undefined,
            'Endpoint rejected this request',
            this.accountType
          )
        }
      }
    }

    // Mastodon Pass §6.3 — same observability hook on the Link-header
    // pagination path. Pagination calls are exactly the surface the
    // Deprecation header is most likely to fire on (paginators tend to be
    // older endpoints), so it's worth logging here too.
    logObservabilityHeaders(response, target)

    if (response.status === 401 || response.status === 403) {
      // Same authorization-vs-authentication guard as `request()` above: a
      // 403 on the pagination path (forbidden, role-gated, scope-limited)
      // must not sign the user out while the token still authenticates.
      if (response.status === 403 && (await this.confirmTokenAlive())) {
        throw new ApiError(
          response.status,
          undefined,
          'Endpoint rejected this request',
          this.accountType
        )
      }
      this.handleUnauthorized()
      throw new ApiError(
        response.status,
        undefined,
        'Authentication failed - user has been logged out',
        this.accountType
      )
    }

    if (!response.ok) {
      throw new ApiError(response.status, undefined, undefined, this.accountType)
    }

    return response
  }
}

/**
 * Build an ApiContext for a specific account. Used when the caller needs to
 * target a non-active account (re-auth flows, push-driven account switch,
 * future background refresh).
 *
 * Mastodon Pass Phase 1 §2.3 — `accountType` is sourced here from the
 * registry rather than the caller, so any code path that already has an
 * `accountId` automatically gets the right wire format. There's only one
 * source of truth (the AccountSummary).
 */
export function ContextForAccount(accountId: string): ApiContext {
  const summary = AccountStore.get(accountId)
  if (!summary) throw new Error(`unknown accountId: ${accountId}`)
  const token = secureStore.getAccessToken(accountId)
  if (!token) throw new Error(`no access token for accountId: ${accountId}`)
  return new ApiContext(summary.instance, token, accountId, summary.type)
}

/**
 * Build an ApiContext for the active account. The common case — every
 * `src/lib/api/*.ts` helper goes through this. Throws if there is no active
 * account.
 */
export function ContextForActiveAccount(): ApiContext {
  const accountId = AccountStore.activeIdOrThrow()
  return ContextForAccount(accountId)
}

// ---------------------------------------------------------------------------
// Type-narrowing helpers (Mastodon Pass §3.1 / §8.1).
//
// The api.ts split lives in three modules — `shared.ts`, `pixelfed.ts`,
// `mastodon.ts`. The shortcut from §8.1 — keep `ApiContext` as one class
// with a `type` discriminant and gate at runtime — has the right cost/value
// trade-off for v1 (the per-type subclass split would force every consumer
// of `ApiContext` to be re-typed). The two helpers below narrow the
// discriminant for the per-type modules so a Pixelfed-only function called
// with a Mastodon context throws loudly in development rather than silently
// hitting a 404 or, worse, succeeding against a misshapen endpoint.

/**
 * Branded `ApiContext` known to target a Pixelfed instance. Functions in
 * `src/lib/api/pixelfed.ts` accept this type; the helper below produces it.
 */
export type PixelfedApiContext = ApiContext & { readonly __serverType: 'pixelfed' }

/**
 * Branded `ApiContext` known to target a Mastodon instance. Functions in
 * `src/lib/api/mastodon.ts` accept this type; the helper below produces it.
 */
export type MastodonApiContext = ApiContext & { readonly __serverType: 'mastodon' }

/**
 * Build a `PixelfedApiContext` for the active account, or throw if the
 * active account is not Pixelfed. Pixelfed-only callers (Stories, Newsroom,
 * Admin, Pixelfed-shaped DMs, the `api/v1.1/push/*` family, …) use this in
 * place of `ContextForActiveAccount()` so misuse fails loudly.
 *
 * Phase 4 hides every UI surface that consumes these endpoints when a
 * Mastodon account is active; this assertion is the type-system seatbelt
 * for any code path that slips through that gate during development.
 */
export function pixelfedActiveContext(): PixelfedApiContext {
  const ctx = ContextForActiveAccount()
  if (ctx.accountType !== 'pixelfed') {
    throw new Error(
      `Pixelfed-only endpoint called against ${ctx.accountType} account; check call site or Phase 4 UI gating`
    )
  }
  return ctx as PixelfedApiContext
}

/**
 * Build a `MastodonApiContext` for the active account, or throw if the
 * active account is not Mastodon.
 */
export function mastodonActiveContext(): MastodonApiContext {
  const ctx = ContextForActiveAccount()
  if (ctx.accountType !== 'mastodon') {
    throw new Error(`Mastodon-only endpoint called against ${ctx.accountType} account`)
  }
  return ctx as MastodonApiContext
}
// For debugging — dev-only so the token-leak primitive disappears from
// release bundles (Hermes minifier dead-codes the `__DEV__` branch out).
if (__DEV__) {
  ;(global as any).API = ContextForActiveAccount
}
