/**
 * T7 · OIDC SSO for teams — auth-code flow + PKCE, with `state`/`nonce` validation. PKCE is
 * mandatory; the issuer allow-list comes from config (validated at startup, INV9). `account_id` is
 * derived from the verified `(iss, sub)` mapping via an INJECTED resolver — never from a client
 * claim (INV3). The JIT-vs-pre-linked mapping policy is PLAN §5 open-Q #2 (surfaced to P3), so P5
 * does not hard-code it: the resolver is supplied by the caller/config.
 */
import { createPublicKey, verify as cryptoVerify, type webcrypto } from 'node:crypto'
type JsonWebKey = webcrypto.JsonWebKey
import { decodeBase64UrlString, decodeBase64UrlBytes } from 'relay-contracts'
import type { OidcIdentity } from '../../types.js'

export interface OidcConfig {
  readonly issuer: string
  readonly clientId: string
  readonly redirectUri: string
  readonly scopes: readonly string[]
  readonly authorizationEndpoint: string
  readonly tokenEndpoint: string
  readonly jwks: readonly (JsonWebKey & { kid?: string; alg?: string })[]
  readonly allowedIssuers?: readonly string[]
  /** (iss, sub) → accountId. Policy (JIT vs pre-linked) is P3-owned (open-Q #2). */
  resolveAccount(issuer: string, subject: string): Promise<string>
}

export interface OidcTokenSet {
  readonly accessToken: string
  readonly idToken: string
  readonly tokenType: string
  readonly expiresIn?: number
  readonly refreshToken?: string
}

export class OidcError extends Error {
  constructor(message: string) {
    super(message)
    this.name = 'OidcError'
  }
}

export function buildAuthUrl(
  cfg: OidcConfig,
  state: string,
  nonce: string,
  codeChallenge: string,
): string {
  const u = new URL(cfg.authorizationEndpoint)
  u.searchParams.set('response_type', 'code')
  u.searchParams.set('client_id', cfg.clientId)
  u.searchParams.set('redirect_uri', cfg.redirectUri)
  u.searchParams.set('scope', cfg.scopes.join(' '))
  u.searchParams.set('state', state)
  u.searchParams.set('nonce', nonce)
  u.searchParams.set('code_challenge', codeChallenge)
  u.searchParams.set('code_challenge_method', 'S256')
  return u.toString()
}

export type FetchLike = (url: string, init: RequestInit) => Promise<Response>

export async function exchangeCode(
  cfg: OidcConfig,
  code: string,
  codeVerifier: string,
  fetchImpl: FetchLike = fetch,
): Promise<OidcTokenSet> {
  const body = new URLSearchParams({
    grant_type: 'authorization_code',
    code,
    redirect_uri: cfg.redirectUri,
    client_id: cfg.clientId,
    code_verifier: codeVerifier,
  })
  const res = await fetchImpl(cfg.tokenEndpoint, {
    method: 'POST',
    headers: { 'content-type': 'application/x-www-form-urlencoded' },
    body: body.toString(),
  })
  if (!res.ok) throw new OidcError(`token endpoint returned ${res.status}`)
  const json = (await res.json()) as Record<string, unknown>
  if (typeof json.id_token !== 'string' || typeof json.access_token !== 'string') {
    throw new OidcError('token response missing id_token/access_token')
  }
  return {
    accessToken: json.access_token,
    idToken: json.id_token,
    tokenType: typeof json.token_type === 'string' ? json.token_type : 'Bearer',
    ...(typeof json.expires_in === 'number' ? { expiresIn: json.expires_in } : {}),
    ...(typeof json.refresh_token === 'string' ? { refreshToken: json.refresh_token } : {}),
  }
}

interface JwtHeader {
  readonly alg: string
  readonly kid?: string
}
interface JwtClaims {
  readonly iss: string
  readonly aud: string | readonly string[]
  readonly sub: string
  readonly exp: number
  readonly nonce?: string
}

function decodeSegment<T>(seg: string): T {
  return JSON.parse(decodeBase64UrlString(seg)) as T
}

function verifyJwtSignature(
  header: JwtHeader,
  signingInput: string,
  signature: Uint8Array,
  jwks: OidcConfig['jwks'],
): boolean {
  const jwk = jwks.find((k) => (header.kid === undefined ? true : k.kid === header.kid))
  if (jwk === undefined) return false
  const key = createPublicKey({ key: jwk as JsonWebKey, format: 'jwk' })
  const data = Buffer.from(signingInput)
  if (header.alg === 'RS256') {
    return cryptoVerify('RSA-SHA256', data, key, signature)
  }
  if (header.alg === 'ES256') {
    return cryptoVerify('sha256', data, { key, dsaEncoding: 'ieee-p1363' }, signature)
  }
  return false // no alg:none, no unexpected algs
}

/** Verify id_token: signature + iss allow-list + aud + exp + nonce; map (iss,sub) → accountId. */
export async function verifyIdToken(
  cfg: OidcConfig,
  idToken: string,
  expectedNonce: string,
  now: number,
): Promise<OidcIdentity> {
  const parts = idToken.split('.')
  if (parts.length !== 3) throw new OidcError('malformed id_token')
  const [h, p, s] = parts as [string, string, string]
  const header = decodeSegment<JwtHeader>(h)
  const claims = decodeSegment<JwtClaims>(p)

  const allowed = cfg.allowedIssuers ?? [cfg.issuer]
  if (!allowed.includes(claims.iss)) throw new OidcError('issuer not in allow-list')
  const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud]
  if (!aud.includes(cfg.clientId)) throw new OidcError('aud mismatch')
  if (typeof claims.exp !== 'number' || claims.exp <= now) throw new OidcError('id_token expired')
  if (claims.nonce !== expectedNonce) throw new OidcError('nonce mismatch (replay)')

  let sig: Uint8Array
  try {
    sig = decodeBase64UrlBytes(s)
  } catch {
    throw new OidcError('bad signature encoding')
  }
  if (!verifyJwtSignature(header, `${h}.${p}`, sig, cfg.jwks)) {
    throw new OidcError('id_token signature verification failed')
  }

  const accountId = await cfg.resolveAccount(claims.iss, claims.sub)
  return { issuer: claims.iss, subject: claims.sub, accountId }
}

/** state/nonce/PKCE-verifier CSPRNG helpers. */
export { randomUrlToken, pkceChallenge } from './pkce.js'
