/**
 * B5 · STAGING-ONLY operator token-mint (`POST /auth/mint`).
 *
 * ┌─ STAGING NOTICE ────────────────────────────────────────────────────────────────────────────┐
 * │ The ONLY gate here is a shared `OPERATOR_PASSWORD` (constant-time compared). This is a         │
 * │ deliberate Phase-1 staging shortcut so an operator can mint a browser capability token without │
 * │ the full human-auth stack. Phase 2 REPLACES this password gate with WebAuthn (P5 T5–T8).       │
 * └────────────────────────────────────────────────────────────────────────────────────────────┘
 *
 * Flow: the operator's browser generates its OWN DPoP keypair, computes the base64url SHA-256 JWK
 * thumbprint `jkt`, and POSTs `{ password, jkt, subdomain }`. On a correct password we resolve the
 * subdomain to its host row in the CP `hosts` store and mint a short-lived (<=60 s) §4.3 capability
 * token BOUND to that `jkt` (`cnf.jkt`, RFC 7800 proof-of-possession) via the REAL P5 issue path.
 *
 * SECURITY:
 *  - INV3: `sub`(accountId)/`host`(hostId)/`aud`(subdomain) come SOLELY from the authenticated CP
 *    store row — the request body only NAMES a subdomain and the client's own DPoP thumbprint; it
 *    never supplies an accountId/hostId.
 *  - INV9: the signing private key and the minted token are NEVER logged. `onError` receives only
 *    the thrown error (callers must not log request bodies / tokens either).
 *  - Deny-by-default: unknown/revoked subdomain, bad password, malformed body → 4xx, no token.
 *  - Input is validated at the boundary (Zod) BEFORE any store/crypto work; body size is capped.
 */
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createHash, createHmac, timingSafeEqual } from 'node:crypto'
import { issueCapabilityToken, type AuthenticatedPrincipal, type TokenBucketStore } from 'relay-auth'
import type { CapabilityRight } from 'relay-contracts'
import type { HostStore } from 'control-plane/src/store/ports.js'

/** Route this handler owns. */
const MINT_PATH = '/auth/mint' as const
/** Minted tokens are connect-scoped and short-lived (P5 clamps issue to [30, 60] s). */
const MINT_TTL_SEC = 60
/** Reject an oversized request body before buffering it (DoS guard). */
const MAX_BODY_BYTES = 4096
/** Least-privilege: an operator connecting to their terminal needs only `attach`. */
const MINT_RIGHTS: readonly CapabilityRight[] = ['attach']
/** A base64url SHA-256 JWK thumbprint is exactly 43 chars of [A-Za-z0-9_-]. */
const JKT_RE = /^[A-Za-z0-9_-]{43}$/
/**
 * F1 · public-mint brute-force throttle (per remote IP). Strict & low: a full bucket allows a short
 * burst, then admits ~1 attempt every 5 s — enough for an operator's retries, useless for guessing
 * OPERATOR_PASSWORD. Tuned so a fresh IP gets `MINT_RATE_BURST` immediate tries.
 */
export const MINT_RATE_BURST = 5
export const MINT_RATE_REFILL_PER_SEC = 0.2
/** A single DNS label (tenant subdomain): lowercase alnum + hyphens, 1–63 chars, no leading/trailing '-'. */
const SUBDOMAIN_RE = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/

/** The single CP `hosts` capability this route needs (interface segregation). */
export type SubdomainHostLookup = Pick<HostStore, 'getBySubdomain'>

/**
 * F1 · per-remote-address throttle for the public `/auth/mint`. Reuses the shared (Redis) token
 * bucket. Keyed on a SALTED HASH of the client IP — the raw IP is never used as a key. Omit to
 * disable the throttle (unit tests / trusted-network deployments); production ALWAYS supplies it.
 */
export interface MintRateLimit {
  /** Token-bucket store (Redis-backed in prod; the SAME store the relay's pre-auth limiter uses). */
  readonly buckets: TokenBucketStore
  /** Salt for hashing the client IP into the bucket key — prevents storing/inferring the raw IP. */
  readonly salt: string
  /** Bucket capacity (max immediate burst). Default {@link MINT_RATE_BURST}. */
  readonly burst?: number
  /** Refill tokens/second. Default {@link MINT_RATE_REFILL_PER_SEC}. */
  readonly refillPerSec?: number
}

export interface AuthMintDeps {
  /** P5 capability signing PRIVATE key (Ed25519 CryptoKey with ['sign']). NEVER logged. */
  readonly signingKey: CryptoKey
  /** CP hosts store — the ONLY source of accountId/hostId (INV3). */
  readonly hosts: SubdomainHostLookup
  /** Shared staging operator password (constant-time compared). Phase 2 → WebAuthn. */
  readonly operatorPassword: string
  /** Epoch-SECONDS clock (token iat/exp). */
  readonly now: () => number
  /** F1 per-IP brute-force throttle. When set, exhaustion returns 429 BEFORE the password compare. */
  readonly rateLimit?: MintRateLimit
  /** Observability seam — receives thrown errors only (never bodies/tokens/keys). */
  readonly onError?: (e: unknown) => void
}

interface MintRequest {
  readonly password: string
  readonly jkt: string
  readonly subdomain: string
}

/** Validate the untrusted JSON body at the boundary. Returns the typed request or `null` to reject. */
function parseMintRequest(json: unknown): MintRequest | null {
  if (typeof json !== 'object' || json === null) return null
  const { password, jkt, subdomain } = json as Record<string, unknown>
  if (typeof password !== 'string' || password.length === 0) return null
  if (typeof jkt !== 'string' || !JKT_RE.test(jkt)) return null
  if (typeof subdomain !== 'string' || !SUBDOMAIN_RE.test(subdomain)) return null
  return { password, jkt, subdomain }
}

/** Length-independent constant-time string equality (compares fixed-size SHA-256 digests). */
function safeEqual(a: string, b: string): boolean {
  const ha = createHash('sha256').update(a).digest()
  const hb = createHash('sha256').update(b).digest()
  return timingSafeEqual(ha, hb)
}

/** Salted HMAC-SHA256 of the client IP → an OPAQUE, non-reversible bucket key (never the raw IP). */
function mintBucketKey(salt: string, remoteAddr: string): string {
  return 'mint:' + createHmac('sha256', salt).update(remoteAddr).digest('hex').slice(0, 32)
}

/**
 * F1 · consume one token from the per-IP mint bucket. Returns true when the request may proceed,
 * false when the IP is throttled. A thrown store error is NOT swallowed here — it propagates to
 * handleMint's outer catch, which denies (500, no token) and reports via `onError` (deny-by-default).
 */
async function allowMintAttempt(rl: MintRateLimit, remoteAddr: string, now: number): Promise<boolean> {
  const key = mintBucketKey(rl.salt, remoteAddr)
  const burst = rl.burst ?? MINT_RATE_BURST
  const refillPerSec = rl.refillPerSec ?? MINT_RATE_REFILL_PER_SEC
  return rl.buckets.take(key, refillPerSec, burst, now)
}

function sendJson(res: ServerResponse, status: number, body: unknown): void {
  const payload = JSON.stringify(body)
  res.writeHead(status, {
    'content-type': 'application/json; charset=utf-8',
    'cache-control': 'no-store',
  })
  res.end(payload)
}

/** Path portion of a URL (query/hash stripped) — the route matches on path only. */
function pathOf(url: string): string {
  return url.split('?', 1)[0].split('#', 1)[0]
}

/** Buffer the request body up to `maxBytes`; reject (and destroy the stream) if it exceeds the cap. */
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
  return new Promise<string>((resolve, reject) => {
    let size = 0
    const chunks: Buffer[] = []
    req.on('data', (chunk: Buffer) => {
      size += chunk.length
      if (size > maxBytes) {
        req.destroy()
        reject(new Error('request body too large'))
        return
      }
      chunks.push(chunk)
    })
    req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
    req.on('error', (e) => reject(e))
  })
}

function operatorPrincipal(accountId: string, authAt: number): AuthenticatedPrincipal {
  // Minimal authenticated principal — issueCapabilityToken reads only `.accountId` (INV3).
  return {
    kind: 'human',
    accountId,
    principalId: `operator:${accountId}`,
    amr: ['passkey'],
    authAt,
    stepUpAt: null,
  }
}

async function handleMint(
  req: IncomingMessage,
  res: ServerResponse,
  deps: AuthMintDeps,
): Promise<void> {
  try {
    // F1: per-IP throttle BEFORE any body buffering / password compare — blunts a brute-force of
    // OPERATOR_PASSWORD on the public :443 mint. Keyed on the SALTED IP hash (raw IP never stored).
    if (deps.rateLimit !== undefined) {
      const remoteAddr = req.socket?.remoteAddress ?? ''
      if (!(await allowMintAttempt(deps.rateLimit, remoteAddr, deps.now()))) {
        sendJson(res, 429, { error: 'rate_limited' })
        return
      }
    }

    let raw: string
    try {
      raw = await readBody(req, MAX_BODY_BYTES)
    } catch {
      sendJson(res, 413, { error: 'payload_too_large' })
      return
    }

    let json: unknown
    try {
      json = JSON.parse(raw)
    } catch {
      sendJson(res, 400, { error: 'invalid_json' })
      return
    }

    const parsed = parseMintRequest(json)
    if (parsed === null) {
      sendJson(res, 400, { error: 'invalid_request' })
      return
    }
    const { password, jkt, subdomain } = parsed

    // STAGING gate. Constant-time to avoid a password-length/prefix timing oracle.
    if (!safeEqual(password, deps.operatorPassword)) {
      sendJson(res, 401, { error: 'unauthorized' })
      return
    }

    const host = await deps.hosts.getBySubdomain(subdomain)
    if (host === null) {
      sendJson(res, 404, { error: 'unknown_subdomain' })
      return
    }
    if (host.status === 'revoked') {
      sendJson(res, 403, { error: 'host_revoked' })
      return
    }

    const nowSec = deps.now()
    // INV3: accountId/hostId/subdomain are the store row's, NEVER the request body's.
    const token = await issueCapabilityToken(
      {
        principal: operatorPrincipal(host.accountId, nowSec),
        aud: host.subdomain,
        host: host.hostId,
        rights: MINT_RIGHTS,
        ttlSeconds: MINT_TTL_SEC,
        cnfJkt: jkt,
      },
      deps.signingKey,
      nowSec,
    )
    // INV9: the token is a bearer secret — return it, NEVER log it.
    sendJson(res, 200, { token })
  } catch (e: unknown) {
    deps.onError?.(e)
    if (!res.headersSent) sendJson(res, 500, { error: 'internal_error' })
  }
}

/**
 * Build the `POST /auth/mint` pre-router for `startBrowserServer`'s `onRequest` hook.
 *
 * Returns `true` when it CLAIMS the request (its path is `/auth/mint`) so the caller must not also
 * write a response — the actual mint completes asynchronously. Returns `false` for any other path so
 * the request falls through to the static/landing handler.
 */
export function createAuthMintRoute(
  deps: AuthMintDeps,
): (req: IncomingMessage, res: ServerResponse) => boolean {
  return (req, res) => {
    if (pathOf(req.url ?? '/') !== MINT_PATH) return false // not our route → fall through to static
    if (req.method !== 'POST') {
      sendJson(res, 405, { error: 'method_not_allowed' })
      return true
    }
    void handleMint(req, res, deps)
    return true
  }
}

// ── P5 capability signing-key loader ──────────────────────────────────────────────────────────────

const PEM_BODY_RE = /-----BEGIN [^-]+-----|-----END [^-]+-----/g

/** Coerce to an ArrayBuffer-backed view (WebCrypto's importKey wants `Uint8Array<ArrayBuffer>`). */
function toArrayBufferView(u: Uint8Array): Uint8Array<ArrayBuffer> {
  const out = new Uint8Array(u.byteLength)
  out.set(u)
  return out
}

/** Decode standard-or-URL base64 (padding optional) to bytes. */
function base64AnyToBytes(s: string): Uint8Array {
  const std = s.replace(/-/g, '+').replace(/_/g, '/')
  return new Uint8Array(Buffer.from(std, 'base64'))
}

/** Strip PEM armor + whitespace and base64-decode the body to DER bytes. */
function pemBodyToDer(pem: string): Uint8Array {
  const b64 = pem.replace(PEM_BODY_RE, '').replace(/\s+/g, '')
  return base64AnyToBytes(b64)
}

/**
 * Load the P5 capability SIGNING key (Ed25519 private) from the `CAPABILITY_SIGN_PRIVKEY` env value.
 * Accepts either a PKCS#8 PEM (the `gen-capability-key.sh` output — contains `-----BEGIN`) or a
 * base64/base64url encoding of the PKCS#8 DER. Imported non-extractable with only `['sign']` usage.
 *
 * INV9: the raw key material is never logged; this throws a generic error on a malformed value.
 */
export async function loadSigningKeyFromEnv(raw: string): Promise<CryptoKey> {
  const trimmed = raw.trim()
  if (trimmed.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY is empty')
  const der = trimmed.includes('BEGIN') ? pemBodyToDer(trimmed) : base64AnyToBytes(trimmed)
  if (der.length === 0) throw new Error('CAPABILITY_SIGN_PRIVKEY did not decode to any key bytes')
  try {
    return await globalThis.crypto.subtle.importKey(
      'pkcs8',
      toArrayBufferView(der),
      { name: 'Ed25519' },
      false,
      ['sign'],
    )
  } catch {
    // Never surface the underlying material in the error.
    throw new Error('CAPABILITY_SIGN_PRIVKEY is not a valid PKCS#8 Ed25519 private key')
  }
}
