/**
 * A4 — device registry (FIX C-native-1 / H-native-4). Mirrors `registry/hosts.ts`: the ownership
 * source of truth for enrolled DEVICES (the mTLS data-path identity), keyed by an unguessable
 * `deviceId` bound to an account. Only the PUBLIC P-256 key is stored (INV4). Status changes are
 * versioned append-only snapshots with an atomic single-row pointer swap (INV8), exactly like
 * `store/memory.ts`'s host store. Adds the per-account controls the enroll surface needs: a device
 * CAP and an enroll RATE-LIMIT (leaked-bootstrap blast-radius, §5).
 *
 * The `DeviceStore` port lives in `store/ports.ts` (add-only); the in-memory adapter is provided
 * here (`createMemoryDeviceStore`) so the device path is self-contained and does not touch the
 * shared `store/memory.ts` `Stores` wiring.
 */
import type { DeviceStore } from '../store/ports.js'
import { newUuid, nowIso } from '../util/ids.js'
import { bytesToHex } from '../util/bytes.js'
import { randomBytes } from 'node:crypto'

/** Lifecycle status (INV8-versioned). */
export type DeviceStatus = 'active' | 'revoked'
/** Best-effort attestation strength recorded at enroll (verification deferred, §1.1). */
export type AttestationLevel = 'none' | 'software' | 'hardware'

/** Immutable device record. Only the PUBLIC P-256 SPKI is stored (INV4). */
export interface DeviceRecord {
  readonly deviceId: string
  readonly accountId: string
  /**
   * The subdomain label the device leaf is bound to (the nginx enforcement key, dNSName SAN). This is
   * SAN-critical and must be a canonical RFC-1123 label the `accountId` actually OWNS — the caller is
   * responsible for validating (subdomain/assign.ts `isValidSubdomain`) + ownership-gating it BEFORE
   * `registerDevice` (see api/device-enroll.ts). The registry stores it verbatim; it does not re-check.
   */
  readonly subdomainScope: string
  /** P-256 SubjectPublicKeyInfo DER (the CSR-embedded public key). */
  readonly ecPubkeySpki: Uint8Array
  /** Issued leaf serial (hex) — authoritative expiry pairing for revocation/CRL. */
  readonly serial: string
  readonly status: DeviceStatus
  /** ISO expiry of the issued leaf. */
  readonly notAfter: string
  readonly attestationLevel: AttestationLevel
  readonly createdAt: string
  readonly revokedAt: string | null
}

/** Append-only companion row for `device.status`/`revoked_at` versioning (INV8). */
export interface DeviceStatusVersionRow {
  readonly deviceId: string
  readonly version: number
  readonly status: DeviceStatus
  readonly revokedAt: string | null
  readonly changedAt: string
  readonly changedBy: string
}

/** Per-account device cap default (leaked-bootstrap blast radius, §5). */
export const DEFAULT_DEVICE_CAP = 20
/** Per-account enroll rate default within the window. */
export const DEFAULT_DEVICE_ENROLL_RATE_MAX = 20
/** Enroll rate window (ms). Mirrors the "enrollPerHour" shape. */
export const DEFAULT_DEVICE_ENROLL_RATE_WINDOW_MS = 60 * 60 * 1000
/** Device leaf TTL bounds (24h floor .. 7d ceiling, §1.3). */
export const DEFAULT_DEVICE_LEAF_TTL_SEC = 24 * 60 * 60
export const MIN_DEVICE_LEAF_TTL_SEC = 24 * 60 * 60
export const MAX_DEVICE_LEAF_TTL_SEC = 7 * 24 * 60 * 60

/** Uniform per-account limit reject → 429 at the route. `code` is machine-readable, not user-facing. */
export class DeviceLimitError extends Error {
  constructor(public readonly code: 'cap_exceeded' | 'rate_limited') {
    super('device enrollment limit') // uniform message — never leak counts/thresholds
    this.name = 'DeviceLimitError'
  }
}

export interface RegisterDeviceInput {
  readonly accountId: string
  readonly subdomainScope: string
  readonly ecPubkeySpki: Uint8Array
  readonly attestationLevel: AttestationLevel
}

/**
 * Narrow renewal-time capability: recompute + persist a device's leaf expiry. Split out so the leaf
 * renewer (`ca/rotate.ts`) depends only on this, not the whole registry.
 */
export interface DeviceExpiryRenewer {
  /**
   * Recompute + persist a fresh leaf expiry (`now()+ttl`, mirroring the host path) for a KNOWN, ACTIVE
   * device; returns the updated record. Unknown/revoked devices are NOT mutated (deny-by-default) —
   * the value passed through unchanged (`null` for unknown) and the downstream leaf gate rejects.
   */
  renewDeviceExpiry(deviceId: string): Promise<DeviceRecord | null>
}

export interface DeviceRegistry extends DeviceExpiryRenewer {
  registerDevice(input: RegisterDeviceInput): Promise<DeviceRecord>
  getDevice(deviceId: string): Promise<DeviceRecord | null>
  listDevices(accountId: string): Promise<readonly DeviceRecord[]>
  setDeviceStatus(deviceId: string, status: DeviceStatus): Promise<DeviceRecord>
  ownsDevice(accountId: string, deviceId: string): Promise<boolean>
  /** Throws `DeviceLimitError('cap_exceeded')` when the account already holds `deviceCap` ACTIVE devices. */
  assertUnderCap(accountId: string): Promise<void>
  /** Records + checks the per-account enroll rate; throws `DeviceLimitError('rate_limited')` when over. */
  checkRateLimit(accountId: string): void
}

function clampLeafTtl(ttl: number): number {
  return Math.min(Math.max(ttl, MIN_DEVICE_LEAF_TTL_SEC), MAX_DEVICE_LEAF_TTL_SEC)
}

export interface DeviceRegistryDeps {
  readonly devices: DeviceStore
  readonly actor?: string
  readonly deviceCap?: number
  readonly rateMax?: number
  readonly rateWindowMs?: number
  readonly leafTtlSec?: number
  /** Clock (ms) — injectable for tests. */
  readonly now?: () => number
}

// NOTE: device lifecycle audit is intentionally NOT written here — the `AuditAction` enum
// (audit/log.ts) has no `device.*` members and extending it is out of this task's scope (a shared
// coordination point). Wire device audit once the enum gains `device.enroll`/`device.revoke`.
export function createDeviceRegistry(deps: DeviceRegistryDeps): DeviceRegistry {
  const actor = deps.actor ?? 'system'
  const deviceCap = deps.deviceCap ?? DEFAULT_DEVICE_CAP
  const rateMax = deps.rateMax ?? DEFAULT_DEVICE_ENROLL_RATE_MAX
  const rateWindowMs = deps.rateWindowMs ?? DEFAULT_DEVICE_ENROLL_RATE_WINDOW_MS
  const leafTtlSec = clampLeafTtl(deps.leafTtlSec ?? DEFAULT_DEVICE_LEAF_TTL_SEC)
  const now = deps.now ?? (() => Date.now())
  // Per-account sliding-window enroll timestamps (in-process rate-limiter, mirrors memRouteStore state).
  const rateHits = new Map<string, number[]>()

  return {
    async registerDevice(input) {
      const ts = now()
      const rec: DeviceRecord = {
        deviceId: newUuid(), // unguessable UUIDv4 (INV1)
        accountId: input.accountId,
        subdomainScope: input.subdomainScope,
        // Defensive copy → fresh ArrayBuffer-backed array (immutability + INV4 public-key-only).
        ecPubkeySpki: new Uint8Array(input.ecPubkeySpki),
        serial: bytesToHex(randomBytes(16)),
        status: 'active',
        notAfter: new Date(ts + leafTtlSec * 1000).toISOString(),
        attestationLevel: input.attestationLevel,
        createdAt: nowIso(),
        revokedAt: null,
      }
      await deps.devices.insert(rec)
      return rec
    },
    async getDevice(deviceId) {
      return deps.devices.get(deviceId)
    },
    async listDevices(accountId) {
      return deps.devices.listByAccount(accountId) // ownership-scoped
    },
    async setDeviceStatus(deviceId, status) {
      const revokedAt = status === 'revoked' ? nowIso() : null
      return deps.devices.swapStatus(deviceId, status, revokedAt, actor)
    },
    async renewDeviceExpiry(deviceId) {
      // Fresh expiry EACH renewal (mirrors the host `now()+ttl`). Without this the device leaf would
      // reproduce the ORIGINAL enrollment expiry on every renewal — eventually issuing already-expired
      // certs. Deny-by-default: only KNOWN + ACTIVE devices are extended; unknown/revoked are left
      // untouched (the downstream device-leaf gate produces the canonical uniform reject).
      const record = await deps.devices.get(deviceId)
      if (record === null || record.status === 'revoked') return record
      const notAfter = new Date(now() + leafTtlSec * 1000).toISOString()
      return deps.devices.renewLeafExpiry(deviceId, notAfter)
    },
    async ownsDevice(accountId, deviceId) {
      const dev = await deps.devices.get(deviceId)
      // Deny-by-default: unknown device or account mismatch ⇒ false (INV1/INV6).
      return dev !== null && dev.accountId === accountId
    },
    async assertUnderCap(accountId) {
      const active = await deps.devices.countActiveByAccount(accountId)
      if (active >= deviceCap) throw new DeviceLimitError('cap_exceeded')
    },
    checkRateLimit(accountId) {
      const ts = now()
      const cutoff = ts - rateWindowMs
      const hits = (rateHits.get(accountId) ?? []).filter((t) => t > cutoff)
      if (hits.length >= rateMax) {
        rateHits.set(accountId, hits) // persist the pruned window; do NOT record this rejected attempt
        throw new DeviceLimitError('rate_limited')
      }
      hits.push(ts)
      rateHits.set(accountId, hits)
    },
  }
}

// ── In-memory DeviceStore adapter (mirrors store/memory.ts memHostStore, INV8) ────────────────────
interface DeviceCell {
  record: DeviceRecord
  version: number
  versions: DeviceStatusVersionRow[]
}

export function createMemoryDeviceStore(): DeviceStore {
  const cells = new Map<string, DeviceCell>()
  return {
    async insert(rec) {
      if (cells.has(rec.deviceId)) throw new Error('duplicate deviceId')
      cells.set(rec.deviceId, {
        record: rec,
        version: 1,
        versions: [
          {
            deviceId: rec.deviceId,
            version: 1,
            status: rec.status,
            revokedAt: rec.revokedAt,
            changedAt: rec.createdAt,
            changedBy: 'system',
          },
        ],
      })
    },
    async get(id) {
      return cells.get(id)?.record ?? null
    },
    async listByAccount(accountId) {
      return [...cells.values()].filter((c) => c.record.accountId === accountId).map((c) => c.record)
    },
    async countActiveByAccount(accountId) {
      let n = 0
      for (const c of cells.values()) {
        if (c.record.accountId === accountId && c.record.status === 'active') n++
      }
      return n
    },
    async swapStatus(id, status, revokedAt, changedBy) {
      const cell = cells.get(id)
      if (cell === undefined) throw new Error('device not found')
      // --- atomic critical section (no await) ---
      const nextVersion = cell.version + 1
      const changedAt = new Date().toISOString()
      const nextRecord: DeviceRecord = { ...cell.record, status, revokedAt }
      cell.versions.push({ deviceId: id, version: nextVersion, status, revokedAt, changedAt, changedBy })
      cell.record = nextRecord
      cell.version = nextVersion
      // --- end critical section ---
      return nextRecord
    },
    async renewLeafExpiry(id, notAfter) {
      const cell = cells.get(id)
      if (cell === undefined) throw new Error('device not found')
      // --- atomic critical section (no await) ---
      const nextRecord: DeviceRecord = { ...cell.record, notAfter } // immutable: fresh expiry, new record
      cell.record = nextRecord
      // --- end critical section ---
      return nextRecord
    },
    async versions(id) {
      return (cells.get(id)?.versions ?? []).slice()
    },
  }
}
