/**
 * A4 — device enrollment HTTP routes (registerable fastify plugin; wired into `main.ts` later, so it
 * is testable standalone via `app.inject`). Deny-by-default, uniform reject:
 *
 *   POST /device/enroll             [Bearer device:enroll]
 *     verify bearer through the SAME `CapabilityVerifier` seam the admin API uses (boot/verifier.ts)
 *     + assert the `enroll` right → accountId; Zod-validate the body; verify the P-256 CSR PoP;
 *     enforce the per-account device CAP + enroll RATE-LIMIT; register the device; issue the leaf via
 *     `device-issue`; 201 {deviceId, cert, caChain, notBefore, notAfter, renewAfter}.
 *
 *   POST /device/attest/challenge   [Bearer device:enroll]
 *     STUB (attestation verification deferred, §1.1): mint + short-TTL-bind a random challenge.
 *
 * `POST /device/:id/renew` is A6 — NOT built here.
 */
import { z } from 'zod'
import { randomBytes } from 'node:crypto'
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import type { CapabilityVerifier } from './authz.js'
import { DEVICE_ENROLL_AUD, DeviceEnrollAuthError, requireEnrollRight } from '../auth/session.js'
import type { DeviceRegistry, AttestationLevel } from '../registry/devices.js'
import { DeviceLimitError } from '../registry/devices.js'
import type { DeviceLeafSigner } from '../ca/device-issue.js'
import { DeviceLeafSignError } from '../ca/device-issue.js'
import { decodeCsrWire } from '../ca/csr.js'
import { verifyCsrPoPEc } from '../ca/csr-ec.js'
import { normalizeSubdomain, isValidSubdomain } from '../subdomain/assign.js'
import { bytesToBase64 } from '../util/bytes.js'

/** Attestation challenge stub TTL (seconds). */
export const ATTEST_CHALLENGE_TTL_SEC = 120
/** Renew at ~2/3 of the leaf lifetime by default. */
const DEFAULT_RENEW_FRACTION = 2 / 3

/**
 * Subdomain-ownership source of truth (FIX C-native-3 / H-native-4). The enroll route must NOT trust
 * the client-supplied `subdomain`: it is burned verbatim into the device leaf's dNSName SAN, which
 * nginx :8470 treats as the SINGLE load-bearing tenant-isolation control (a cert stamped
 * `alice.<base>` may ONLY reach `alice.*`). So before issuance the route resolves who actually owns
 * the requested subdomain and rejects unless it is the authenticated account. In production this is
 * backed by the host-onboarding registry (`HostStore.getBySubdomain(sub)?.accountId ?? null`); tests
 * inject a fake. Returns `null` for an unclaimed/unknown subdomain (deny-by-default).
 */
export interface SubdomainOwnershipResolver {
  ownerOfSubdomain(subdomain: string): Promise<string | null>
}

/** Client asked for a malformed/reserved subdomain — never allowed to reach a certificate SAN. */
export class SubdomainRejectedError extends Error {
  constructor() {
    super('subdomain rejected') // uniform message — never leak which rule failed
    this.name = 'SubdomainRejectedError'
  }
}

export interface DeviceEnrollDeps {
  /** Same seam the admin API uses (boot/verifier.ts). */
  readonly verifier: CapabilityVerifier
  readonly devices: DeviceRegistry
  readonly signer: DeviceLeafSigner
  /** Subdomain→owner resolver (host-onboarding source of truth). REQUIRED — deny-by-default. */
  readonly ownership: SubdomainOwnershipResolver
  readonly enrollAud?: string
  readonly renewFraction?: number
  /** Clock (epoch seconds) — injectable for tests. */
  readonly now?: () => number
}

const EnrollBodySchema = z
  .object({
    csr: z.string().min(1),
    keyAlg: z.literal('ec-p256'),
    subdomain: z.string().min(1).max(63),
    deviceName: z.string().min(1).max(128),
    attestation: z.string().min(1).optional(),
  })
  .strict()

function extractBearer(req: FastifyRequest): string | null {
  const auth = req.headers['authorization']
  if (typeof auth === 'string' && auth.startsWith('Bearer ')) return auth.slice('Bearer '.length).trim()
  return null
}

/** Map any thrown error to a uniform HTTP reject — never leak which internal check failed. */
function sendError(reply: FastifyReply, err: unknown): void {
  if (err instanceof DeviceEnrollAuthError) {
    void reply.code(err.status).send({ error: 'rejected' })
    return
  }
  if (err instanceof DeviceLimitError) {
    void reply.code(429).send({ error: 'rate_limited' })
    return
  }
  if (err instanceof DeviceLeafSignError || err instanceof SubdomainRejectedError || err instanceof z.ZodError) {
    void reply.code(400).send({ error: 'rejected' })
    return
  }
  void reply.code(400).send({ error: 'rejected' })
}

export function buildDeviceEnrollRouter(deps: DeviceEnrollDeps): FastifyPluginAsync {
  const enrollAud = deps.enrollAud ?? DEVICE_ENROLL_AUD
  const renewFraction = deps.renewFraction ?? DEFAULT_RENEW_FRACTION
  const now = deps.now ?? (() => Math.floor(Date.now() / 1000))

  /** Verify + right-check the device:enroll bearer via the injected verifier. Returns accountId. */
  async function requireEnrollPrincipal(req: FastifyRequest): Promise<string> {
    const raw = extractBearer(req)
    if (raw === null) throw new DeviceEnrollAuthError(401, 'missing device enroll token')
    let token
    try {
      token = await deps.verifier.verify(raw, enrollAud, now())
    } catch {
      throw new DeviceEnrollAuthError(401, 'device enroll token rejected')
    }
    requireEnrollRight(token) // 403 if the token lacks the enroll right
    return token.sub // accountId derives ONLY from the verified token (INV3)
  }

  return async (app) => {
    app.post('/device/enroll', async (req, reply) => {
      try {
        const accountId = await requireEnrollPrincipal(req)
        const body = EnrollBodySchema.parse(req.body)

        // Canonicalize + gate the requested subdomain BEFORE it can reach a certificate SAN. Charset
        // (RFC-1123) + reserved-name rules are shared with host onboarding (subdomain/assign.ts) so an
        // infra label like 'admin'/'www'/'api' can never be minted into a leaf. → 400.
        const subdomain = normalizeSubdomain(body.subdomain)
        if (!isValidSubdomain(subdomain)) throw new SubdomainRejectedError()

        // Tenant-isolation gate (FIX C-native-3 / H-native-4): the device leaf's dNSName SAN is nginx's
        // SINGLE load-bearing tenant boundary, so a device:enroll bearer for account A must NOT be able
        // to mint a cert scoped to account B's (or a reserved) subdomain. Assert the authenticated
        // account OWNS the subdomain against the host-onboarding source of truth; deny-by-default with a
        // uniform reject (unknown vs. foreign-owned are indistinguishable — no cross-tenant oracle). 403.
        const owner = await deps.ownership.ownerOfSubdomain(subdomain)
        if (owner === null || owner !== accountId) {
          throw new DeviceEnrollAuthError(403, 'subdomain not owned by account')
        }

        // Proof-of-possession: a valid P-256 PKCS#10 self-signature. Uniform reject on any failure.
        const pop = await verifyCsrPoPEc(decodeCsrWire(body.csr))
        if (!pop.ok) throw new DeviceLeafSignError('csr_rejected')

        // Per-account resource controls BEFORE consuming a device slot (leaked-bootstrap blast radius).
        await deps.devices.assertUnderCap(accountId)
        deps.devices.checkRateLimit(accountId)

        const attestationLevel: AttestationLevel = body.attestation !== undefined ? 'software' : 'none'
        const record = await deps.devices.registerDevice({
          accountId,
          subdomainScope: subdomain, // the normalized, ownership-verified label (never the raw client field)
          ecPubkeySpki: pop.embeddedPubSpki,
          attestationLevel,
        })

        const leaf = await deps.signer.signDeviceLeaf(record.deviceId, decodeCsrWire(body.csr))
        const lifeMs = leaf.notAfter.getTime() - leaf.notBefore.getTime()
        const renewAfter = new Date(leaf.notBefore.getTime() + Math.floor(lifeMs * renewFraction)).toISOString()

        await reply.code(201).send({
          deviceId: record.deviceId,
          cert: bytesToBase64(leaf.cert),
          caChain: leaf.caChain.map((c) => bytesToBase64(c)),
          notBefore: leaf.notBefore.toISOString(),
          notAfter: leaf.notAfter.toISOString(),
          renewAfter,
        })
      } catch (err) {
        sendError(reply, err)
      }
    })

    app.post('/device/attest/challenge', async (req, reply) => {
      try {
        await requireEnrollPrincipal(req)
        // STUB: mint a random challenge with a short TTL. Attestation VERIFICATION is deferred (§1.1);
        // the challenge is issued here so the client flow (challenge → keygen → CSR) is already shaped.
        const challenge = Buffer.from(randomBytes(32)).toString('base64url')
        await reply.code(200).send({ challenge, expires_in: ATTEST_CHALLENGE_TTL_SEC })
      } catch (err) {
        sendError(reply, err)
      }
    })
  }
}
