/**
 * A4 (FIX C-native-1) — the MINIMAL device:enroll session-token subsystem + a login stub seam.
 *
 * `/device/enroll` is the one endpoint that cannot require a client cert (chicken-and-egg), so it is
 * gated by a short-lived, narrowly-scoped `device:enroll` bearer minted after the user's one-time
 * login. That bearer IS a §4.3 capability token with `rights:['enroll']` (the right added in A2a),
 * `sub = accountId`, a DISTINCT audience (`device-enroll`), and a MINUTES-scale TTL that is
 * deliberately SEPARATE from the 30–60s connect clamp.
 *
 * WHY NOT `issueCapabilityToken`: relay-auth's connect-token issuer hard-clamps TTL to ≤60s (the
 * Finding-4 blast-radius clamp) and requires a single-host + DPoP `cnf.jkt`. An enroll token is not
 * host-scoped and must live for minutes, so we mint via the SAME underlying primitive
 * (`signPaseto`, Ed25519 v4.public — relay-auth crypto, NO new crypto) and build the identical §4.3
 * body shape so the FROZEN verifier (`verifyCapabilityToken`) accepts it unchanged. Verification
 * therefore goes through the exact path the control-plane already uses (boot/verifier.ts), then
 * asserts the `enroll` right — deny-by-default on anything else.
 *
 * LOGIN is a STUB SEAM for the single-tenant MVP (`loginToAccountId`): it resolves an authenticated
 * credential to an `accountId`. A full end-user auth layer (RFC 8628 device grant / OIDC) layers on
 * here later WITHOUT changing the token shape or the verify path.
 */
import type { CapabilityRight, CapabilityToken } from 'relay-contracts'
import { verifyCapabilityToken } from 'relay-auth'
import { signPaseto } from 'relay-auth/src/crypto/paseto.js'
import { createHash, randomBytes, randomUUID } from 'node:crypto'
import { timingSafeEqualBytes } from '../util/bytes.js'

/** Distinct audience for device enrollment (Host-confusion guard — never a subdomain aud). */
export const DEVICE_ENROLL_AUD = 'device-enroll'
/** Default enroll-token TTL: 10 minutes (minutes-scale, separate from the 30–60s connect clamp). */
export const DEFAULT_DEVICE_ENROLL_TTL_SEC = 10 * 60
/** Floor: a device:enroll token must outlive the connect clamp to be meaningfully separate. */
export const MIN_DEVICE_ENROLL_TTL_SEC = 60
/** Ceiling: still short-lived (leaked-bearer blast radius). */
export const MAX_DEVICE_ENROLL_TTL_SEC = 60 * 60

/** Enroll tokens are not host-scoped; the frozen §4.3 shape requires a non-empty `host` — sentinel. */
const ENROLL_HOST_SENTINEL = 'device-enroll'

/** Uniform auth reject for the device-enroll surface. 401 = bad/missing token, 403 = lacks right. */
export class DeviceEnrollAuthError extends Error {
  constructor(
    public readonly status: 401 | 403,
    message = 'device enrollment auth rejected',
  ) {
    super(message)
    this.name = 'DeviceEnrollAuthError'
  }
}

/** The frozen §4.3 body + the additive `cnf.jkt` claim the verifier requires (RFC 7800). */
interface EnrollTokenBody {
  readonly sub: string
  readonly aud: string
  readonly host: string
  readonly rights: readonly CapabilityRight[]
  readonly iat: number
  readonly exp: number
  readonly jti: string
  readonly cnf: { readonly jkt: string }
}

export interface MintDeviceEnrollOpts {
  /** The session subsystem's Ed25519 signing key (WebCrypto). Never held globally (INV9). */
  readonly signingKey: CryptoKey
  readonly ttlSeconds?: number
  readonly now?: number // epoch seconds
  readonly aud?: string
  /** Optional DPoP thumbprint. Enroll-token PoP binding is deferred; a placeholder is used if absent. */
  readonly cnfJkt?: string
  readonly jti?: string
}

function clampTtl(ttl: number): number {
  return Math.min(Math.max(ttl, MIN_DEVICE_ENROLL_TTL_SEC), MAX_DEVICE_ENROLL_TTL_SEC)
}

/** A 43-char base64url placeholder satisfying the shared verifier's `cnf.jkt` (min-1) requirement. */
function placeholderJkt(): string {
  return Buffer.from(randomBytes(32)).toString('base64url')
}

/**
 * Mint a `device:enroll` capability token: `rights:['enroll']`, `sub = accountId`, `aud = device-enroll`,
 * minutes-scale TTL. Signed with the caller-supplied Ed25519 key via relay-auth's PASETO primitive.
 */
export async function mintDeviceEnrollToken(accountId: string, opts: MintDeviceEnrollOpts): Promise<string> {
  if (accountId.length === 0) throw new DeviceEnrollAuthError(401, 'empty accountId')
  const now = opts.now ?? Math.floor(Date.now() / 1000)
  const ttl = clampTtl(opts.ttlSeconds ?? DEFAULT_DEVICE_ENROLL_TTL_SEC)
  const body: EnrollTokenBody = {
    sub: accountId,
    aud: opts.aud ?? DEVICE_ENROLL_AUD,
    host: ENROLL_HOST_SENTINEL,
    rights: ['enroll'],
    iat: now,
    exp: now + ttl,
    jti: opts.jti ?? randomUUID(),
    cnf: { jkt: opts.cnfJkt ?? placeholderJkt() },
  }
  return signPaseto(body, opts.signingKey)
}

/** Least-privilege check: the bearer must carry the `enroll` right, else 403 (uniform). */
export function requireEnrollRight(token: CapabilityToken): void {
  if (!token.rights.includes('enroll')) {
    throw new DeviceEnrollAuthError(403, 'token scope lacks the enroll right')
  }
}

export interface VerifyDeviceEnrollOpts {
  readonly now?: number // epoch seconds
  readonly aud?: string
}

/**
 * Verify a `device:enroll` bearer through the FROZEN §4.3 verifier (same path as boot/verifier.ts):
 * Ed25519 signature (startup key), `aud === device-enroll`, and `iat`/`exp` window — then assert the
 * `enroll` right. Returns `{ accountId }` (from the token `sub`, never a client field). Uniform reject:
 * any signature/audience/expiry failure → 401; a missing `enroll` right → 403.
 */
export async function verifyDeviceEnrollToken(
  raw: string,
  opts: VerifyDeviceEnrollOpts = {},
): Promise<{ accountId: string }> {
  const aud = opts.aud ?? DEVICE_ENROLL_AUD
  const now = opts.now ?? Math.floor(Date.now() / 1000)
  let token: CapabilityToken
  try {
    token = await verifyCapabilityToken(raw, aud, now)
  } catch {
    throw new DeviceEnrollAuthError(401, 'device enroll token rejected')
  }
  requireEnrollRight(token)
  return { accountId: token.sub }
}

/**
 * LOGIN STUB SEAM (single-tenant MVP). Resolves an authenticated credential to an `accountId`.
 * Deny-by-default: an empty credential, an unresolved credential, or an unconfigured seam all reject.
 * A full end-user auth layer (RFC 8628 device grant / OIDC) replaces the body here without touching
 * callers. Two supported bindings:
 *   - `resolve(credential) → accountId | null` — an injectable resolver (the real login layer);
 *   - `{ operatorCredential, accountId }` — the single operator credential of the MVP fleet.
 */
export interface LoginSeamConfig {
  readonly resolve?: (credential: string) => string | null
  readonly operatorCredential?: string
  readonly accountId?: string
}

export function loginToAccountId(credential: string, config: LoginSeamConfig): string {
  if (credential.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
  if (config.resolve !== undefined) {
    const acct = config.resolve(credential)
    if (acct === null || acct.length === 0) throw new DeviceEnrollAuthError(401, 'login rejected')
    return acct
  }
  if (config.operatorCredential !== undefined && config.accountId !== undefined) {
    // SHA-256 both to a fixed 32 bytes BEFORE comparing, so the compare is
    // length-independent — timingSafeEqualBytes short-circuits on unequal length,
    // which would otherwise leak the operator credential's length via timing.
    const a = createHash('sha256').update(credential, 'utf8').digest()
    const b = createHash('sha256').update(config.operatorCredential, 'utf8').digest()
    if (timingSafeEqualBytes(a, b)) return config.accountId
    throw new DeviceEnrollAuthError(401, 'login rejected')
  }
  throw new DeviceEnrollAuthError(401, 'login not configured')
}
