/**
 * Native-tunnel cert auto-renew wiring — TASK A5 (PLAN_ZERO_TOUCH_ROLLOUT).
 *
 * The native run-loop (`superviseNative`) used to only MONITOR the frp-client leaf's freshness; the
 * leaf therefore expired at ~24h and the tunnel dropped until a manual re-pair. This module closes
 * that gap by driving `createCertRotator`/`renewCert` (crypto is REUSED, never reimplemented):
 *
 *   - `createMtlsFetch` — the injected `fetchImpl` the rotator hands to `renewCert`. It POSTs /renew
 *     over mTLS presenting the CURRENT keystore leaf (re-read on every call, so the first renewal
 *     after a rotation already authenticates with the freshly issued leaf). mTLS IS the auth — no
 *     token, `rejectUnauthorized` always true (INV4/INV14). The private key stays in-process.
 *   - `wireAutoRenew` — routes the rotator callbacks: rotated → restart frpc onto the new leaf and
 *     log (non-secret); revoked (403) → tear the tunnel down (INV12); error → log + let the rotator
 *     retry with backoff. A failed renewal NEVER crashes the supervisor.
 *   - `startNativeAutoRenew` — the builder `superviseNative` calls: loads the identity, builds the
 *     mTLS fetch + rotator at ~2/3-TTL, and starts it. Returns null (auto-renew disabled) if the host
 *     is not enrolled (no identity), rather than throwing into the run-loop.
 */
import { request as httpsRequest } from 'node:https'
import type { AgentConfig } from '../config/agentConfig.js'
import { resolveHostIdentity } from '../config/hostRecord.js'
import type { Keystore } from '../keys/keystore.js'
import type { Logger } from '../log/logger.js'
import type { TimerLike } from '../transport/seams.js'
import { createBackoff } from '../transport/backoff.js'
import { buildTlsOptions, type CertParser, type TlsClientOptions } from '../transport/dial.js'
import { DEFAULT_CERT_RENEW_WINDOW_MS } from '../health/probe.js'
import {
  createCertRotator,
  type CertExpiredBeyondGraceError,
  type CertRotator,
} from './rotation.js'

/** Non-secret message from an unknown thrown value (never serializes cert/key material). */
function errorMessage(err: unknown): string {
  return err instanceof Error ? err.message : String(err)
}

/**
 * Socket-idle timeout for a /renew request. A stalled or overloaded control-plane (or a NAT that
 * silently drops the connection after the TLS handshake) must NOT leave the renewal Promise pending
 * forever — that would starve the rotator's backoff-retry loop and let the leaf silently expire. On
 * timeout the request is destroyed and the rejection surfaces through the rotator's onError→backoff.
 */
export const RENEW_REQUEST_TIMEOUT_MS = 15_000
/**
 * Hard cap on the buffered /renew response body. The reply is a small `{cert,caChain}` JSON; anything
 * beyond a few KB is malformed or hostile, so we destroy the stream and reject rather than buffer it.
 */
export const MAX_RENEW_RESPONSE_BYTES = 64 * 1024

// --- mTLS fetch --------------------------------------------------------------------------------

/** A single mTLS request the fetch shim delegates to (injectable so the shim is offline-testable). */
export interface MtlsRequestInit {
  readonly method: string
  readonly headers: Record<string, string>
  readonly body?: string
}
export interface MtlsResponse {
  readonly status: number
  readonly body: string
}
export type MtlsRequest = (
  url: string,
  tls: TlsClientOptions,
  init: MtlsRequestInit,
) => Promise<MtlsResponse>

/** Default mTLS transport: a `node:https` POST presenting the client cert/key + pinned CA. */
const defaultMtlsRequest: MtlsRequest = (url, tls, init) =>
  new Promise<MtlsResponse>((resolve, reject) => {
    const req = httpsRequest(
      url,
      {
        method: init.method,
        headers: init.headers,
        cert: tls.cert,
        key: tls.key,
        // ca omitted ⇒ verify the server against the system roots (LE-fronted CP). Present only when a
        // private CA is pinned (not for /renew).
        ...(tls.ca !== undefined ? { ca: tls.ca } : {}),
        rejectUnauthorized: tls.rejectUnauthorized, // always true (anti-MITM, INV14)
      },
      (res) => {
        const chunks: Buffer[] = []
        let total = 0
        res.on('data', (c: Buffer) => {
          total += c.length
          if (total > MAX_RENEW_RESPONSE_BYTES) {
            res.destroy() // MEDIUM: refuse an unbounded body — a renew reply is a few-KB JSON
            reject(new Error(`renew response body exceeded ${MAX_RENEW_RESPONSE_BYTES} byte cap`))
            return
          }
          chunks.push(c)
        })
        res.on('end', () =>
          resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') }),
        )
        res.on('error', reject) // a mid-stream socket error must reject, not hang
      },
    )
    // HIGH: bound the request so a peer that accepts the connection but never replies rejects (and the
    // rotator re-enters backoff) instead of pending forever — destroy(err) emits 'error' → reject below.
    req.setTimeout(RENEW_REQUEST_TIMEOUT_MS, () => {
      req.destroy(new Error(`renew request timed out after ${RENEW_REQUEST_TIMEOUT_MS}ms`))
    })
    req.on('error', reject)
    if (init.body !== undefined) req.write(init.body)
    req.end()
  })

function toHeaderRecord(headers: RequestInit['headers']): Record<string, string> {
  if (!headers) return {}
  if (headers instanceof Headers) {
    const out: Record<string, string> = {}
    headers.forEach((v, k) => {
      out[k] = v
    })
    return out
  }
  if (Array.isArray(headers)) return Object.fromEntries(headers)
  return { ...(headers as Record<string, string>) }
}

/**
 * Build the `fetch`-shaped shim `renewCert` uses. Each call re-reads the CURRENT keystore leaf via
 * `buildTlsOptions` (which fail-fast throws NotEnrolled/CertExpired — the rotator then logs + retries
 * with backoff, never crashing) and delegates to the mTLS transport, mapping the result to a real
 * `Response` (so `res.ok`/`res.status`/`res.json()` behave exactly as `renewCert` expects).
 */
export function createMtlsFetch(
  ks: Keystore,
  opts: { request?: MtlsRequest; certParser?: CertParser } = {},
): typeof fetch {
  const request = opts.request ?? defaultMtlsRequest
  const shim = async (input: Parameters<typeof fetch>[0], init?: RequestInit): Promise<Response> => {
    const url = typeof input === 'string' ? input : input.toString()
    // Present the current frp-client leaf (client auth), but verify the /renew SERVER cert against the
    // SYSTEM roots — its host (the LE-fronted control-plane) is publicly trusted; pinning the private
    // enroll caChain here fails with "unable to get local issuer certificate". So drop `ca` (absent →
    // node uses the default roots); rejectUnauthorized stays true.
    // Deliberately still fail-closed on an EXPIRED leaf: nginx would refuse to forward it anyway, so
    // a lapsed leaf is routed to the plain `/recover` endpoint by the rotator instead of through here.
    const full = buildTlsOptions(ks, { ...(opts.certParser ? { certParser: opts.certParser } : {}) })
    const tls: TlsClientOptions = { cert: full.cert, key: full.key, rejectUnauthorized: full.rejectUnauthorized }
    const reqInit: MtlsRequestInit = {
      method: init?.method ?? 'GET',
      headers: toHeaderRecord(init?.headers),
      ...(typeof init?.body === 'string' ? { body: init.body } : {}),
    }
    const { status, body } = await request(url, tls, reqInit)
    return new Response(body, { status })
  }
  return shim as typeof fetch
}

// --- rotator wiring ----------------------------------------------------------------------------

/** Non-secret identifiers logged alongside renew events (INV9). */
export interface AutoRenewLogIds {
  readonly subdomain: string | null
  readonly hostId: string | null
}

/** The two run-loop effects the rotator drives. */
export interface AutoRenewHooks {
  /** Restart the supervised frpc so it re-reads the rotated cert (a leaf rotation only). */
  restartChild(): void
  /** Tear the tunnel down (host revoked ⇒ never reconnect, INV12). */
  stop(): void
}

/** Handle for the wired auto-renew loop. */
export interface AutoRenewController {
  stop(): void
}

/**
 * Wire a rotator's callbacks to the run-loop and start it. Rotated → restart frpc; revoked → stop;
 * error → log (non-secret) and let the rotator retry with backoff. Returns a controller that stops
 * the rotator's scheduled timer.
 */
export function wireAutoRenew(
  rotator: CertRotator,
  hooks: AutoRenewHooks,
  logger: Logger,
  ids: AutoRenewLogIds,
): AutoRenewController {
  const meta = { subdomain: ids.subdomain, hostId: ids.hostId }
  rotator.onRotated(() => {
    logger.log('info', 'frp-client cert rotated; restarting frpc onto the fresh leaf', meta)
    hooks.restartChild()
  })
  rotator.onRevoked(() => {
    logger.log('warn', 'frp-client cert renewal refused (host revoked); tearing down tunnel', meta)
    hooks.stop()
  })
  rotator.onError((err) => {
    logger.log('warn', 'frp-client cert renewal failed; will retry with backoff', {
      ...meta,
      error: errorMessage(err),
    })
  })
  // Terminal: the grace window is spent, so every further attempt is guaranteed to fail. Say so once,
  // at error level, naming the fix — and deliberately do NOT stop the supervisor: `pair` writes fresh
  // cert files that the restart-on-exit frpc child picks up without a manual service restart.
  rotator.onExhausted((err) => {
    logger.log('error', 'frp-client cert expired beyond recovery grace — run `web-terminal-agent pair <CODE>` to re-pair this host', {
      ...meta,
      expiredForMs: err.expiredForMs,
      graceMs: err.graceMs,
    })
  })
  rotator.start()
  return { stop: () => rotator.stop() }
}

// --- builder -----------------------------------------------------------------------------------

/** Injection seams for `startNativeAutoRenew` (all optional; unset ⇒ real transport/timers). */
export interface NativeAutoRenewOpts {
  readonly mtlsRequest?: MtlsRequest
  readonly certParser?: CertParser
  /** Window in which an already-expired leaf may still be recovered via `/recover`. */
  readonly expiredGraceMs?: number
  /** Plain (NON-mTLS) fetch for the `/recover` call; unset ⇒ global fetch. */
  readonly recoverFetchImpl?: typeof fetch
  readonly timer?: TimerLike
  readonly renewBeforeMs?: number
  readonly retryBaseMs?: number
  readonly now?: () => Date
  readonly parseCert?: (pem: string) => Date
}

/**
 * Build + start native cert auto-renew for `superviseNative`. Renews at ~2/3 of the leaf TTL
 * (default `DEFAULT_CERT_RENEW_WINDOW_MS`, the same window the health probe alarms on). Returns null
 * (auto-renew disabled, logged) when the host has no identity — an unenrolled run-loop must not throw.
 */
export function startNativeAutoRenew(
  cfg: AgentConfig,
  ks: Keystore,
  hooks: AutoRenewHooks,
  logger: Logger,
  opts: NativeAutoRenewOpts = {},
): AutoRenewController | null {
  const id = ks.loadIdentity()
  if (id === null) {
    logger.log('warn', 'no identity in keystore — cert auto-renew disabled', {})
    return null
  }
  const fetchImpl = createMtlsFetch(ks, {
    ...(opts.mtlsRequest ? { request: opts.mtlsRequest } : {}),
    ...(opts.certParser ? { certParser: opts.certParser } : {}),
  })
  const rotator = createCertRotator(cfg, id, ks, {
    fetchImpl,
    ...(opts.expiredGraceMs !== undefined ? { expiredGraceMs: opts.expiredGraceMs } : {}),
    ...(opts.recoverFetchImpl ? { recoverFetchImpl: opts.recoverFetchImpl } : {}),
    renewBeforeMs: opts.renewBeforeMs ?? DEFAULT_CERT_RENEW_WINDOW_MS,
    ...(opts.timer ? { timer: opts.timer } : {}),
    ...(opts.now ? { now: opts.now } : {}),
    ...(opts.parseCert ? { parseCert: opts.parseCert } : {}),
    ...(opts.retryBaseMs !== undefined
      ? { retryBackoff: createBackoff({ baseMs: opts.retryBaseMs, jitter: false }) }
      : {}),
  })
  // Prefer the resolved identity (config > enrolment record > leaf SPIFFE SAN) so renewal warnings
  // actually name the host — `cfg` alone is null on every install that predates the record.
  const ids = resolveHostIdentity(cfg, () => ks.loadCert()?.certPem ?? null)
  return wireAutoRenew(rotator, hooks, logger, ids)
}
