// Stateless WebAuthn challenge store backed by HMAC-signed cookies.
//
// Why not an in-memory Map: Nuxt runs under PM2 cluster mode (multiple
// workers). A challenge stored in worker A's memory is invisible to worker B,
// so login-options and login-verify can land on different workers and fail
// with "Challenge expired or invalid". Signing the challenge into a cookie is
// stateless across workers and processes.
//
// Single-use: the cookie is deleted on every consumeChallenge() call, so a
// challenge cannot be replayed within the same browser. The native app gets no
// cookies, so issueChallenge() also RETURNS the signed token and login-options
// hands it back in the body; consumeChallenge() accepts that body token as an
// alternative. Body tokens are additionally recorded in webauthnReplayDb.ts
// (login-verify) so a captured request cannot be replayed within the TTL.

import { createHmac, timingSafeEqual as nodeTimingSafeEqual } from 'node:crypto'

const CHALLENGE_TTL_MS = 5 * 60 * 1000
const COOKIE_PATH = '/api/idempiere-auth/webauthn'

export type ChallengeKind = 'register' | 'login'

interface SignedPayload {
  c: string            // challenge (the base64url string @simplewebauthn issued)
  k: ChallengeKind
  u?: number           // userId (only set for register — the user the key is enrolled FOR)
  a?: number           // acting admin userId (admin enrolment on behalf of `u`)
  e: number            // expiresAt (ms epoch)
}

export interface ChallengeEntry {
  userId?: number
  adminId?: number
  expiresAt: number
}

// Domain-separated subkey: don't reuse the AES-GCM key bytes as an HMAC key.
function getSigningKey(): Buffer {
  const hex = process.env.CREDENTIAL_ENCRYPTION_KEY
  if (!hex) {
    throw new Error('CREDENTIAL_ENCRYPTION_KEY env var is not set — cannot sign WebAuthn challenges')
  }
  return createHmac('sha256', Buffer.from(hex, 'hex')).update('wa-challenge-v1').digest()
}

function b64urlEncode(buf: Buffer): string {
  return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}

function b64urlDecode(s: string): Buffer {
  const pad = s.length % 4 === 0 ? '' : '='.repeat(4 - (s.length % 4))
  return Buffer.from(s.replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64')
}

function sign(payloadB64: string): string {
  return b64urlEncode(createHmac('sha256', getSigningKey()).update(payloadB64).digest())
}

function safeEqual(a: string, b: string): boolean {
  const ab = Buffer.from(a)
  const bb = Buffer.from(b)
  if (ab.length !== bb.length) return false
  return nodeTimingSafeEqual(ab, bb)
}

function cookieName(kind: ChallengeKind): string {
  return kind === 'register' ? 'logship_wa_reg' : 'logship_wa_login'
}

/** Issues the signed challenge as a cookie AND returns the token (for cookie-less clients). */
export function issueChallenge(
  event: any,
  challenge: string,
  opts: { kind: ChallengeKind; userId?: number; adminId?: number },
): string {
  const payload: SignedPayload = {
    c: challenge,
    k: opts.kind,
    u: opts.userId,
    a: opts.adminId,
    e: Date.now() + CHALLENGE_TTL_MS,
  }
  const payloadB64 = b64urlEncode(Buffer.from(JSON.stringify(payload), 'utf8'))
  const value = payloadB64 + '.' + sign(payloadB64)
  setCookie(event, cookieName(opts.kind), value, {
    httpOnly: true,
    sameSite: 'strict',
    secure: !import.meta.dev,
    path: COOKIE_PATH,
    maxAge: Math.floor(CHALLENGE_TTL_MS / 1000),
  })
  return value
}

/**
 * Verifies and consumes a challenge. Looks at the cookie first, then at `bodyToken`
 * (native app). The cookie is always cleared, even when verification fails.
 */
export function consumeChallenge(
  event: any,
  kind: ChallengeKind,
  expectedChallenge: string,
  bodyToken?: string | null,
): ChallengeEntry | null {
  const name = cookieName(kind)
  const cookieRaw = getCookie(event, name)
  // Single-use: clear the cookie regardless of whether verification succeeds.
  if (cookieRaw) deleteCookie(event, name, { path: COOKIE_PATH })
  const fromCookie = cookieRaw ? parseToken(cookieRaw, kind, expectedChallenge) : null
  if (fromCookie) return fromCookie
  const token = typeof bodyToken === 'string' ? bodyToken.trim() : ''
  return token ? parseToken(token, kind, expectedChallenge) : null
}

function parseToken(raw: string, kind: ChallengeKind, expectedChallenge: string): ChallengeEntry | null {
  const dot = raw.indexOf('.')
  if (dot < 0) return null
  const payloadB64 = raw.slice(0, dot)
  const sig = raw.slice(dot + 1)
  if (!safeEqual(sig, sign(payloadB64))) return null
  let payload: SignedPayload
  try {
    payload = JSON.parse(b64urlDecode(payloadB64).toString('utf8'))
  } catch {
    return null
  }
  if (payload.k !== kind) return null
  if (payload.e < Date.now()) return null
  if (payload.c !== expectedChallenge) return null
  return { userId: payload.u, adminId: payload.a, expiresAt: payload.e }
}
