import { createHmac } from 'node:crypto'
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
import { pathToFileURL } from 'node:url'

import { RequestAdmission, type AdmissionKey, type AdmissionLimiter, type ConcurrencyLimiter, type IdempotencyReservation, type IdempotencyReservationResult, type IdempotencyStore, type NonceStore } from './admission.js'
import { CloudflareAccessAssertionVerifier, DualFactorGatewayAuthenticator, RotatingApplicationCredentialVerifier, type RotatingCredentialRecord } from './auth.js'
import { AuditTrail, InMemoryAuditStore, REJECTED_UNAUTHENTICATED_CREDENTIAL, REJECTED_UNAUTHENTICATED_SUBJECT, type AuditIntegritySigner, type AuditRequest } from './audit.js'
import { GatewayError, parseOperationRequest, type JsonObject } from './contracts.js'
import { createReadOnlyGateway, READ_ONLY_OPERATIONS, type OpaqueResponse, type ReadOnlyGateway, type ReadOnlyRoute } from './routes.js'

export interface GatewayServerRequest {
  readonly authentication: { readonly accessAssertion: string; readonly applicationCredential: string }
  readonly rawBody: Uint8Array
  readonly expectedOperation?: string
}

export interface GatewayServer {
  dispatch(request: GatewayServerRequest): Promise<OpaqueResponse>
}

export interface HttpGatewayServerOptions {
  readonly host?: string
  readonly port: number
  readonly maxBodyBytes: number
  readonly deploymentSha: string
}

export interface RunningHttpGatewayServer {
  readonly address: string
  close(): Promise<void>
}

interface RuntimeConfig {
  readonly host: '127.0.0.1' | '::1'
  readonly port: number
  readonly maxBodyBytes: number
  readonly deploymentSha: string
  readonly issuer: string
  readonly audience: string
  readonly signingKeys: ReadonlyMap<string, JsonWebKey>
  readonly credentials: readonly RotatingCredentialRecord[]
  readonly auditKey: string
  readonly operatorContext: JsonObject
  readonly projects: readonly JsonObject[]
}

const routes = new Map<string, (typeof READ_ONLY_OPERATIONS)[number]>([
  ['/v1/getOperatorContext', 'getOperatorContext'],
  ['/v1/listProjects', 'listProjects'],
  ['/v1/getAuditReceipt', 'getAuditReceipt'],
])

export function createGatewayServer(authenticator: { authenticate(input: GatewayServerRequest['authentication']): Promise<import('./auth.js').CallerIdentity> }, gateway: ReadOnlyGateway, audit: AuditTrail): GatewayServer {
  return {
    async dispatch(request) {
      assertRequest(request)
      if (request.expectedOperation !== undefined) assertExpectedOperation(request.rawBody, request.expectedOperation)
      try {
        const identity = await authenticator.authenticate(request.authentication)
        return await gateway.dispatch(identity, request.rawBody)
      } catch (error) {
        const code = errorCode(error)
        if (code === 'unauthenticated' || code === 'identity_mismatch' || code === 'credential_revoked') {
          await recordUnauthenticatedRejection(audit, request.rawBody, code)
        }
        throw new GatewayError(code)
      }
    },
  }
}

export async function listenGatewayHttp(server: GatewayServer, options: HttpGatewayServerOptions): Promise<RunningHttpGatewayServer> {
  assertOptions(options)
  const listener = createServer((request, response) => {
    void handleHttpRequest(server, options.maxBodyBytes, options.deploymentSha, request, response)
  })
  await new Promise<void>((resolve, reject) => {
    listener.once('error', reject)
    listener.listen(options.port, options.host ?? '127.0.0.1', () => {
      listener.off('error', reject)
      resolve()
    })
  })
  const address = listener.address()
  if (!address || typeof address === 'string') {
    await closeServer(listener)
    throw new GatewayError('service_unavailable')
  }
  return { address: `http://${address.address}:${address.port}`, close: () => closeServer(listener) }
}

export async function runGatewayFromEnvironment(environment: NodeJS.ProcessEnv = process.env): Promise<void> {
  const config = readRuntimeConfig(environment)
  const audit = new AuditTrail(new InMemoryAuditStore(), hmacSigner(config.auditKey))
  const gateway = createReadOnlyGateway(runtimeRoutes(config, audit), runtimeAdmission(), audit)
  const server = createGatewayServer(
    new DualFactorGatewayAuthenticator(
      new CloudflareAccessAssertionVerifier({
        issuer: config.issuer,
        audience: config.audience,
        resolveSigningKey: async (keyId) => config.signingKeys.get(keyId),
      }),
      new RotatingApplicationCredentialVerifier(config.credentials),
    ),
    gateway,
    audit,
  )
  const listener = await listenGatewayHttp(server, config)
  const shutdown = () => {
    void listener.close().then(() => process.exit(0), () => process.exit(1))
  }
  process.once('SIGINT', shutdown)
  process.once('SIGTERM', shutdown)
  process.stdout.write(`actions-gateway listening on ${listener.address}\n`)
}

async function handleHttpRequest(server: GatewayServer, maxBodyBytes: number, deploymentSha: string, request: IncomingMessage, response: ServerResponse): Promise<void> {
  try {
    if (request.method === 'GET' && request.url === '/health') {
      sendJson(response, 200, { ok: true, deployedSha: deploymentSha })
      return
    }
    const operation = routes.get(request.url ?? '')
    if (request.method !== 'POST' || !operation) throw new GatewayError('operation_not_found')
    const rawBody = await readBody(request, maxBodyBytes)
    const result = await server.dispatch({
      authentication: {
        accessAssertion: singleHeader(request, 'cf-access-jwt-assertion'),
        applicationCredential: singleHeader(request, 'x-overdeck-application-credential'),
      },
      rawBody,
      expectedOperation: operation,
    })
    sendJson(response, 200, result)
  } catch (error) {
    const code = errorCode(error)
    sendJson(response, statusFor(code), { error: { code } })
  }
}

async function readBody(request: IncomingMessage, maxBodyBytes: number): Promise<Uint8Array> {
  const chunks: Buffer[] = []
  let size = 0
  for await (const chunk of request) {
    const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
    size += bytes.byteLength
    if (size > maxBodyBytes) {
      request.destroy()
      throw new GatewayError('invalid_request')
    }
    chunks.push(bytes)
  }
  return new Uint8Array(Buffer.concat(chunks))
}

function singleHeader(request: IncomingMessage, name: string): string {
  const value = request.headers[name]
  if (typeof value !== 'string' || value.length === 0 || value.length > 16_384) return ''
  return value
}

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

async function recordUnauthenticatedRejection(audit: AuditTrail, rawBody: Uint8Array, code: 'unauthenticated' | 'identity_mismatch' | 'credential_revoked'): Promise<void> {
  try {
    await audit.recordRejected(unauthenticatedAuditRequest(rawBody), code)
  } catch {
    throw new GatewayError('service_unavailable')
  }
}

function unauthenticatedAuditRequest(rawBody: Uint8Array): AuditRequest {
  let requestId = 'unauthenticated-rejection'
  let operation = 'authentication-rejected'
  try {
    const parsed = parseOperationRequest(rawBody, 65_536)
    requestId = parsed.requestId
    operation = routes.has(`/v1/${parsed.operation}`) ? `authentication-${toKebabCase(parsed.operation)}` : operation
  } catch {
    // Use only fixed markers when the request envelope cannot be read.
  }
  return {
    requestId,
    callerSubject: REJECTED_UNAUTHENTICATED_SUBJECT,
    applicationCredentialId: REJECTED_UNAUTHENTICATED_CREDENTIAL,
    operation,
  }
}

const FULL_GIT_SHA = /^[0-9a-f]{40}$/

export function resolveDeploymentSha(value: string | undefined): string {
  if (!value || !FULL_GIT_SHA.test(value)) {
    throw new Error('OVERDECK_DEPLOY_SHA must be a full lowercase 40-character git SHA')
  }
  return value
}

function readRuntimeConfig(environment: NodeJS.ProcessEnv): RuntimeConfig {
  const port = parseInteger(environment.OVERDECK_ACTIONS_GATEWAY_PORT ?? '31401', 1, 65_535)
  const maxBodyBytes = parseInteger(environment.OVERDECK_ACTIONS_GATEWAY_MAX_BODY_BYTES ?? '65536', 1, 1_048_576)
  const host = environment.OVERDECK_ACTIONS_GATEWAY_HOST ?? '127.0.0.1'
  if (host !== '127.0.0.1' && host !== '::1') throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_HOST.')
  const issuer = requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_EDGE_ISSUER')
  const audience = requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_EDGE_AUDIENCE')
  const auditKey = requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_AUDIT_HMAC_KEY')
  return {
    host,
    port,
    maxBodyBytes,
    deploymentSha: resolveDeploymentSha(environment.OVERDECK_DEPLOY_SHA),
    issuer,
    audience,
    auditKey,
    signingKeys: parseSigningKeys(requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_JWKS_JSON')),
    credentials: parseCredentials(requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_CREDENTIALS_JSON')),
    operatorContext: parseJsonObject(requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_OPERATOR_CONTEXT_JSON')),
    projects: parseProjects(requiredEnvironment(environment, 'OVERDECK_ACTIONS_GATEWAY_PROJECTS_JSON')),
  }
}

function runtimeRoutes(config: RuntimeConfig, audit: AuditTrail): readonly ReadOnlyRoute<unknown>[] {
  const policy = { maxRequestBytes: config.maxBodyBytes, maxAgeMilliseconds: 300_000, rate: { maxRequests: 60, windowMilliseconds: 60_000 }, maxConcurrent: 4 }
  return [
    {
      operation: 'getOperatorContext', policy,
      parsePayload: parseEmptyPayload,
      parseResponse: parseOpaqueResponse,
      adapter: { async dispatch() { return config.operatorContext } },
    },
    {
      operation: 'listProjects', policy,
      parsePayload: parseEmptyPayload,
      parseResponse: parseOpaqueResponse,
      adapter: { async dispatch() { return { projects: config.projects } } },
    },
    {
      operation: 'getAuditReceipt', policy,
      parsePayload: parseAuditPayload,
      parseResponse: parseOpaqueResponse,
      adapter: {
        async dispatch(payload: unknown, context) {
          const request = payload as { operationId: string; limit: number }
          return JSON.parse(JSON.stringify({ receipts: await audit.getOperationReceipts(context.callerSubject, request.operationId, request.limit) })) as JsonObject
        },
      },
    },
  ]
}

function runtimeAdmission(): RequestAdmission<OpaqueResponse> {
  const nonceStore = new Set<string>()
  const idempotencyStore = new Map<string, { hash: string; outcome?: OpaqueResponse }>()
  const requestTimes = new Map<string, number[]>()
  const concurrent = new Map<string, number>()
  const nonce: NonceStore = { async consume(key, value) { const item = `${key.subject}\n${value}`; if (nonceStore.has(item)) return false; nonceStore.add(item); return true } }
  const idempotency: IdempotencyStore<OpaqueResponse> = {
    async reserve(reservation): Promise<IdempotencyReservationResult<OpaqueResponse>> {
      const key = `${reservation.key.subject}\n${reservation.idempotencyKey}`
      const current = idempotencyStore.get(key)
      if (!current) { idempotencyStore.set(key, { hash: reservation.requestHash }); return { kind: 'reserved' } }
      if (current.hash !== reservation.requestHash || current.outcome === undefined) return { kind: 'conflict' }
      return { kind: 'replay', outcome: current.outcome }
    },
    async complete(reservation, outcome) { const current = idempotencyStore.get(`${reservation.key.subject}\n${reservation.idempotencyKey}`); if (!current) throw new Error('Reservation is missing.'); current.outcome = outcome },
    async abandon(reservation) { idempotencyStore.delete(`${reservation.key.subject}\n${reservation.idempotencyKey}`) },
  }
  const rateLimiter: AdmissionLimiter = {
    async acquire(key, policy) {
      const item = admissionKey(key); const now = Date.now(); const active = (requestTimes.get(item) ?? []).filter((time) => time > now - policy.windowMilliseconds)
      if (active.length >= policy.maxRequests) return false
      active.push(now); requestTimes.set(item, active); return true
    },
  }
  const concurrencyLimiter: ConcurrencyLimiter = {
    async acquire(key, maximum) {
      const item = admissionKey(key); const count = concurrent.get(item) ?? 0
      if (count >= maximum) return undefined
      concurrent.set(item, count + 1)
      let released = false
      return async () => { if (!released) { released = true; const remaining = (concurrent.get(item) ?? 1) - 1; if (remaining === 0) concurrent.delete(item); else concurrent.set(item, remaining) } }
    },
  }
  return new RequestAdmission({ nonceStore: nonce, idempotencyStore: idempotency, rateLimiter, concurrencyLimiter })
}

function parseEmptyPayload(payload: JsonObject): JsonObject {
  if (Object.keys(payload).length !== 0) throw new GatewayError('invalid_request')
  return {}
}

function parseAuditPayload(payload: JsonObject): { operationId: string; limit: number } {
  if (Object.keys(payload).some((key) => key !== 'operation_id' && key !== 'limit') || typeof payload.operation_id !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(payload.operation_id)) throw new GatewayError('invalid_request')
  const limitValue = payload.limit
  const limit = limitValue === undefined ? 20 : limitValue
  if (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 1 || limit > 100) throw new GatewayError('invalid_request')
  return { operationId: payload.operation_id, limit }
}

function parseOpaqueResponse(response: unknown): OpaqueResponse {
  if (!isJsonObject(response)) throw new GatewayError('service_unavailable')
  return response
}

function parseSigningKeys(value: string): ReadonlyMap<string, JsonWebKey> {
  const parsed = parseJsonObject(value)
  const keys = new Map<string, JsonWebKey>()
  for (const [keyId, key] of Object.entries(parsed)) {
    if (!/^[A-Za-z0-9._-]{1,128}$/.test(keyId) || !isJsonObject(key)) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_JWKS_JSON.')
    keys.set(keyId, key as JsonWebKey)
  }
  if (keys.size === 0) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_JWKS_JSON.')
  return keys
}

function parseCredentials(value: string): readonly RotatingCredentialRecord[] {
  const parsed: unknown = JSON.parse(value)
  if (!Array.isArray(parsed) || parsed.length === 0) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_CREDENTIALS_JSON.')
  return parsed.map((record) => {
    if (!isJsonObject(record) || typeof record.id !== 'string' || typeof record.subject !== 'string' || typeof record.secretDigest !== 'string' || typeof record.expiresAt !== 'string' || (record.revokedAt !== undefined && typeof record.revokedAt !== 'string')) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_CREDENTIALS_JSON.')
    const expiresAt = new Date(record.expiresAt)
    const revokedAtValue = record.revokedAt
    if (revokedAtValue !== undefined && typeof revokedAtValue !== 'string') throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_CREDENTIALS_JSON.')
    const revokedAt = typeof revokedAtValue === 'string' ? new Date(revokedAtValue) : undefined
    if (Number.isNaN(expiresAt.getTime()) || (revokedAt && Number.isNaN(revokedAt.getTime()))) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_CREDENTIALS_JSON.')
    return { id: record.id, subject: record.subject, secretDigest: record.secretDigest, expiresAt, ...(revokedAt ? { revokedAt } : {}) }
  })
}

function parseProjects(value: string): readonly JsonObject[] {
  const parsed: unknown = JSON.parse(value)
  if (!Array.isArray(parsed)) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_PROJECTS_JSON.')
  return parsed.map((project) => {
    if (!isJsonObject(project) || Object.keys(project).some((key) => key !== 'id' && key !== 'name' && key !== 'description') || typeof project.id !== 'string' || typeof project.name !== 'string' || (project.description !== undefined && typeof project.description !== 'string')) throw new Error('Invalid OVERDECK_ACTIONS_GATEWAY_PROJECTS_JSON.')
    return project
  })
}

function parseJsonObject(value: string): JsonObject {
  let parsed: unknown
  try { parsed = JSON.parse(value) } catch { throw new Error('Invalid gateway JSON configuration.') }
  if (!isJsonObject(parsed)) throw new Error('Invalid gateway JSON configuration.')
  return parsed
}

function requiredEnvironment(environment: NodeJS.ProcessEnv, name: string): string {
  const value = environment[name]
  if (typeof value !== 'string' || value.length === 0 || value.length > 65_536) throw new Error(`Missing ${name}.`)
  return value
}

function parseInteger(value: string, minimum: number, maximum: number): number {
  if (!/^[0-9]+$/.test(value)) throw new Error('Invalid gateway numeric configuration.')
  const parsed = Number(value)
  if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) throw new Error('Invalid gateway numeric configuration.')
  return parsed
}

function hmacSigner(key: string): AuditIntegritySigner {
  return { sign: (payload) => createHmac('sha256', key).update(payload).digest('hex'), verify(payload, signature) { return this.sign(payload) === signature } }
}

function admissionKey(key: AdmissionKey): string { return `${key.subject}\n${key.operation}` }
function isJsonObject(value: unknown): value is JsonObject { return typeof value === 'object' && value !== null && !Array.isArray(value) }
function toKebabCase(value: string): string { return value.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`) }

function assertRequest(request: GatewayServerRequest): asserts request is GatewayServerRequest {
  if (!request || typeof request !== 'object' || !request.authentication || typeof request.authentication !== 'object' || !(request.rawBody instanceof Uint8Array)) throw new GatewayError('invalid_request')
}

function assertExpectedOperation(rawBody: Uint8Array, expectedOperation: string): void {
  let parsed: ReturnType<typeof parseOperationRequest>
  try { parsed = parseOperationRequest(rawBody, 1_048_576) } catch { throw new GatewayError('invalid_request') }
  if (parsed.operation !== expectedOperation) throw new GatewayError('operation_not_found')
}

function assertOptions(options: HttpGatewayServerOptions): void {
  if (!options || typeof options !== 'object' || !Number.isInteger(options.port) || options.port < 0 || options.port > 65_535 || !Number.isSafeInteger(options.maxBodyBytes) || options.maxBodyBytes < 1 || options.maxBodyBytes > 1_048_576 || (options.host !== undefined && options.host !== '127.0.0.1' && options.host !== '::1')) throw new GatewayError('invalid_request')
  resolveDeploymentSha(options.deploymentSha)
}

function statusFor(code: ReturnType<typeof errorCode>): number {
  if (code === 'unauthenticated' || code === 'identity_mismatch' || code === 'credential_revoked') return 401
  if (code === 'operation_not_found') return 404
  if (code === 'rate_limited' || code === 'capacity_exhausted') return 429
  if (code === 'service_unavailable') return 503
  return 400
}

function errorCode(error: unknown): GatewayError['code'] {
  if (error instanceof GatewayError) return error.code
  if (error && typeof error === 'object' && 'code' in error) {
    const code = (error as { code?: unknown }).code
    if (code === 'unauthenticated' || code === 'identity_mismatch' || code === 'credential_revoked') return code
  }
  return 'service_unavailable'
}

function closeServer(server: Server): Promise<void> {
  return new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
}

if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
  void runGatewayFromEnvironment().catch((error: unknown) => {
    process.stderr.write(`actions-gateway failed: ${error instanceof Error ? error.message : 'invalid configuration'}\n`)
    process.exitCode = 1
  })
}
