// Mastodon Pass Phase 1 §2.4 — OAuth best-practice baseline.
//
// PKCE S256 unconditionally on every login (Pixelfed and Mastodon — plan
// §9.16 calls out adopting PKCE on Pixelfed too as a free security win that
// drops a per-type branch). RFC 8414 metadata discovery to fetch the actual
// authorize / token / revoke endpoints from the server, with the hardcoded
// defaults as a fallback for older servers that don't expose the well-known
// document. Per-type scope template so Pixelfed keeps its `admin:*` scopes
// while Mastodon doesn't ask for them (most public Mastodon instances would
// silently strip or reject them).

import * as Crypto from 'expo-crypto'
import { getJsonWithTimeout } from 'src/requests'
import type { AccountType } from 'src/state/accountStore'

// ---------------------------------------------------------------------------
// Scopes (plan §2.4 / item 1.5.1)

/**
 * OAuth scope string sent to the server in `POST /api/v1/apps`,
 * `/oauth/authorize`, and the `/oauth/token` exchange. The same string is
 * used at all three points within a single login flow.
 *
 * Pixelfed: keeps the existing `admin:*` scopes — Halflight ships an admin
 * console for instance moderators (see `src/app/(auth)/admin/*`) and the
 * scopes are required by that surface.
 *
 * Mastodon: drops `admin:*`. Public Mastodon instances do not grant
 * admin scopes to general OAuth clients (the docs describe them as
 * "highly restricted") and Halflight has no Mastodon admin UI in v1
 * — asking for them invites confusing consent screens or outright
 * rejection.
 */
export function scopeStringForType(type: AccountType): string {
  if (type === 'mastodon') return 'read write follow push'
  return 'read write follow push admin:read admin:write'
}

// ---------------------------------------------------------------------------
// PKCE (plan §2.4 / item 1.5.1, RFC 7636)

export type PkcePair = {
  /**
   * The high-entropy secret. Held in module-level state across the
   * `WebBrowser.openAuthSessionAsync` round-trip and sent in the
   * `/oauth/token` request body. Never persisted to keychain — short-lived
   * (plan §9.15).
   */
  codeVerifier: string
  /** `BASE64URL(SHA256(codeVerifier))`. Sent as a query param on `/oauth/authorize`. */
  codeChallenge: string
  /** Always `'S256'` — RFC 7636 §4.3 requires a constant-time-comparable method. */
  codeChallengeMethod: 'S256'
}

/**
 * Generate a fresh PKCE pair. The verifier is 64 hex chars (= 32 bytes of
 * CSPRNG entropy) — RFC 7636 §4.1 allows any unreserved-char string of
 * length 43–128, and hex satisfies "unreserved"; the longer-than-minimum
 * length keeps the entropy safely above the 256-bit floor the RFC suggests.
 */
export async function generatePkcePair(): Promise<PkcePair> {
  const bytes = await Crypto.getRandomBytesAsync(32)
  const verifier = bytesToHex(bytes)
  // expo-crypto's `digestStringAsync` hashes the input as UTF-8 bytes; the
  // verifier is ASCII (hex alphabet), so UTF-8 and ASCII coincide. Output is
  // standard base64; we convert to base64url for the URL parameter.
  const digestBase64 = await Crypto.digestStringAsync(
    Crypto.CryptoDigestAlgorithm.SHA256,
    verifier,
    { encoding: Crypto.CryptoEncoding.BASE64 }
  )
  return {
    codeVerifier: verifier,
    codeChallenge: base64ToBase64Url(digestBase64),
    codeChallengeMethod: 'S256',
  }
}

function bytesToHex(bytes: Uint8Array): string {
  let out = ''
  for (let i = 0; i < bytes.length; i++) {
    const v = bytes[i]
    out += (v < 16 ? '0' : '') + v.toString(16)
  }
  return out
}

function base64ToBase64Url(b64: string): string {
  return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

// ---------------------------------------------------------------------------
// PKCE verifier holding (plan §9.15)
//
// The OAuth flow opens a system browser tab via `WebBrowser.openAuthSession
// Async`, which means the verifier has to outlive a single function call. We
// hold it in a module-level Map keyed on the OAuth `state` parameter so the
// callback can match the redirect to its originating verifier even if two
// login attempts overlap (e.g. user backgrounds the app mid-flow and starts
// over). The Map is a process-lifetime cache — if the app is killed while a
// browser tab is open, the user just re-runs the flow.

const pendingPkce = new Map<string, string>()

/**
 * Stash a verifier under an OAuth `state` value. The `state` param is the
 * canonical correlator we already send on `/oauth/authorize`; reusing it as
 * the lookup key means we don't introduce a parallel correlation channel.
 */
export function stashPkceVerifier(state: string, verifier: string): void {
  pendingPkce.set(state, verifier)
}

/**
 * Pop the verifier saved under `state`. One-shot: a successful retrieval
 * removes the entry so a stale callback can't replay it. Returns `null` if
 * there is no entry (which would only happen on a process restart between
 * authorize and callback — in that case the caller falls through to a
 * non-PKCE token exchange, which the server may reject; that's fine,
 * better than silently shipping a stale verifier).
 */
export function consumePkceVerifier(state: string): string | null {
  const v = pendingPkce.get(state) ?? null
  if (v !== null) pendingPkce.delete(state)
  return v
}

/** Clear a verifier without consuming the result — used on user-cancel. */
export function discardPkceVerifier(state: string): void {
  pendingPkce.delete(state)
}

/**
 * Generate a CSRF-grade `state` value for the OAuth round-trip. 32 hex chars
 * is more than enough — Mastodon's docs use 32-byte random strings, but
 * Pixelfed accepts shorter values without issue. Non-secret per RFC 6749, but
 * unguessable enough that an attacker can't forge a callback.
 */
export async function generateOAuthState(): Promise<string> {
  const bytes = await Crypto.getRandomBytesAsync(16)
  return bytesToHex(bytes)
}

// ---------------------------------------------------------------------------
// OAuth metadata discovery (plan §2.4 / item 1.5.2, RFC 8414)
//
// `/.well-known/oauth-authorization-server` advertises the actual endpoint
// URLs and supported PKCE methods. Mastodon ≥ 4.3 always serves this; older
// Mastodon and current Pixelfed do not. The fallback list below mirrors the
// hardcoded defaults the codebase already uses, so a 404 on the well-known
// path is non-fatal (defensive only — the 4.4 floor guarantees Mastodon
// instances respond to it).

export type OAuthEndpoints = {
  authorizationEndpoint: string
  tokenEndpoint: string
  revocationEndpoint: string
  /**
   * True iff the server explicitly advertised support for `S256` in
   * `code_challenge_methods_supported`. False either when the field is
   * absent (likely fallback-defaults path) or when the server omitted
   * `S256`. Mastodon 4.3+ always lists S256; older or non-discovering
   * servers are assumed to support it (Pixelfed Passport supports PKCE but
   * does not currently expose RFC 8414 metadata).
   */
  pkceS256Supported: boolean
  /** True if these were synthesised from defaults (404 / network error / parse failure). */
  fromFallback: boolean
}

type OAuthMetadataResponse = {
  authorization_endpoint?: string
  token_endpoint?: string
  revocation_endpoint?: string
  code_challenge_methods_supported?: unknown
}

const metadataCache = new Map<string, OAuthEndpoints>()

function defaultEndpoints(host: string): OAuthEndpoints {
  const base = `https://${host}`
  return {
    authorizationEndpoint: `${base}/oauth/authorize`,
    tokenEndpoint: `${base}/oauth/token`,
    revocationEndpoint: `${base}/oauth/revoke`,
    pkceS256Supported: true,
    fromFallback: true,
  }
}

/**
 * Fetch and cache OAuth endpoints for `host`. Plan §2.4 / item 1.5.2 — the
 * cache is per-process; we don't pin to disk because the data is cheap to
 * re-fetch (single small JSON document) and the disk-cache plumbing is more
 * surface area than the saving justifies in v1. A future revisit could
 * promote it onto `appStorage.app.features.<host>.oauth_metadata`.
 */
export async function fetchOAuthMetadata(host: string): Promise<OAuthEndpoints> {
  const cached = metadataCache.get(host)
  if (cached) return cached

  let json: OAuthMetadataResponse | undefined
  try {
    const res = await getJsonWithTimeout(
      `https://${host}/.well-known/oauth-authorization-server`,
      undefined,
      false,
      undefined,
      5000
    )
    if (!res.ok) {
      const fallback = defaultEndpoints(host)
      metadataCache.set(host, fallback)
      return fallback
    }
    json = (await res.json()) as OAuthMetadataResponse
  } catch {
    const fallback = defaultEndpoints(host)
    metadataCache.set(host, fallback)
    return fallback
  }

  const auth =
    typeof json?.authorization_endpoint === 'string' ? json.authorization_endpoint : null
  const token = typeof json?.token_endpoint === 'string' ? json.token_endpoint : null
  const revoke =
    typeof json?.revocation_endpoint === 'string' ? json.revocation_endpoint : null

  // The token endpoint is the only one we cannot synthesise without
  // breaking the protocol — the others have well-known paths that every
  // current implementation honours. If the server returned a malformed
  // body, treat it as a discovery miss and fall back wholesale rather
  // than mixing partial advertised values with defaults.
  if (!auth || !token) {
    const fallback = defaultEndpoints(host)
    metadataCache.set(host, fallback)
    return fallback
  }

  const methods = json?.code_challenge_methods_supported
  const advertisesS256 =
    Array.isArray(methods) && methods.some((m) => typeof m === 'string' && m === 'S256')

  const result: OAuthEndpoints = {
    authorizationEndpoint: auth,
    tokenEndpoint: token,
    revocationEndpoint: revoke ?? `https://${host}/oauth/revoke`,
    pkceS256Supported: advertisesS256,
    fromFallback: false,
  }
  metadataCache.set(host, result)
  return result
}
