import { createHmac } from 'node:crypto'
import { once } from 'node:events'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { createServer, request as httpRequest } from 'node:http'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { spawn } from 'node:child_process'
import { describe, expect, it } from 'vitest'

import { RequestAdmission, type IdempotencyReservation, type IdempotencyStore } from '../src/admission.js'
import { AuditTrail, InMemoryAuditStore, type AuditIntegritySigner } from '../src/audit.js'
import type { CallerIdentity } from '../src/contracts.js'
import type { GatewayAuthenticator } from '../src/auth.js'
import { createReadOnlyGateway, READ_ONLY_OPERATIONS, type OpaqueResponse, type ReadOnlyRoute } from '../src/routes.js'
import { createGatewayServer, listenGatewayHttp, resolveDeploymentSha } from '../src/server.js'

const DEPLOYED_SHA = '1234567890abcdef1234567890abcdef12345678'

const now = new Date('2026-08-14T12:00:00.000Z')
const identity: CallerIdentity = { subject: 'caller-1', email: 'caller@example.test', edge_session_id: 'edge-1', application_credential_id: 'credential-1' }

class Store implements IdempotencyStore<OpaqueResponse> {
  private readonly values = new Map<string, { hash: string; outcome?: OpaqueResponse }>()
  async reserve(reservation: IdempotencyReservation) {
    const key = `${reservation.key.subject}:${reservation.idempotencyKey}`
    const existing = this.values.get(key)
    if (!existing) { this.values.set(key, { hash: reservation.requestHash }); return { kind: 'reserved' as const } }
    if (existing.hash !== reservation.requestHash || !existing.outcome) return { kind: 'conflict' as const }
    return { kind: 'replay' as const, outcome: existing.outcome }
  }
  async complete(reservation: IdempotencyReservation, outcome: OpaqueResponse) { const value = this.values.get(`${reservation.key.subject}:${reservation.idempotencyKey}`); if (!value) throw new Error('missing'); value.outcome = outcome }
  async abandon(reservation: IdempotencyReservation) { this.values.delete(`${reservation.key.subject}:${reservation.idempotencyKey}`) }
}

const signer: AuditIntegritySigner = {
  sign(payload) { return createHmac('sha256', 'test-key').update(payload).digest('hex') },
  verify(payload, signature) { return this.sign(payload) === signature },
}

function body(operation: string): Uint8Array {
  return new TextEncoder().encode(JSON.stringify({ request_id: `request-${operation}`, idempotency_key: `key-${operation}`, issued_at: now.toISOString(), nonce: `nonce-${operation}`, operation, payload: {} }))
}

function gateway(authenticator: GatewayAuthenticator = { async authenticate() { return identity } }) {
  const receipts = new InMemoryAuditStore()
  const audit = new AuditTrail(receipts, signer, { now: () => now })
  const admission = new RequestAdmission<OpaqueResponse>({ nonceStore: { async consume() { return true } }, idempotencyStore: new Store(), rateLimiter: { acquire: async () => true }, concurrencyLimiter: { acquire: async () => async () => {} }, now: () => now })
  const routes = READ_ONLY_OPERATIONS.map((operation) => ({ operation, policy: { maxRequestBytes: 2048, maxAgeMilliseconds: 60_000, rate: { maxRequests: 10, windowMilliseconds: 60_000 }, maxConcurrent: 1 }, parsePayload: (value: unknown) => value, parseResponse: (value: unknown) => value as OpaqueResponse, adapter: { async dispatch() { return { id: `safe-${operation}` } } } })) as readonly ReadOnlyRoute<unknown>[]
  return { audit, receipts, server: createGatewayServer(authenticator, createReadOnlyGateway(routes, admission, audit), audit) }
}

function post(url: URL, path: string, rawBody: Uint8Array, headers: Record<string, string> = {}): Promise<{ status: number; body: unknown }> {
  return new Promise((resolve, reject) => {
    const request = httpRequest(url, { method: 'POST', path, headers: { 'content-type': 'application/json', 'content-length': String(rawBody.byteLength), ...headers } }, (response) => {
      const chunks: Buffer[] = []
      response.on('data', (chunk: Buffer) => chunks.push(chunk))
      response.on('end', () => resolve({ status: response.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown }))
    })
    request.on('error', reject)
    request.end(rawBody)
  })
}

function get(url: URL, path: string): Promise<{ status: number; body: unknown }> {
  return new Promise((resolve, reject) => {
    const request = httpRequest(url, { method: 'GET', path }, (response) => {
      const chunks: Buffer[] = []
      response.on('data', (chunk: Buffer) => chunks.push(chunk))
      response.on('end', () => resolve({ status: response.statusCode ?? 0, body: JSON.parse(Buffer.concat(chunks).toString('utf8')) as unknown }))
    })
    request.on('error', reject)
    request.end()
  })
}

describe('gateway HTTP runtime', () => {
  it('accepts only a full lowercase deployment revision', () => {
    expect(resolveDeploymentSha(DEPLOYED_SHA)).toBe(DEPLOYED_SHA)
    expect(() => resolveDeploymentSha(undefined)).toThrow('full lowercase 40-character git SHA')
    expect(() => resolveDeploymentSha('abc123')).toThrow('full lowercase 40-character git SHA')
    expect(() => resolveDeploymentSha(DEPLOYED_SHA.toUpperCase())).toThrow('full lowercase 40-character git SHA')
  })

  it('binds localhost and serves exact deployment health plus three authenticated R0 operations', async () => {
    const instance = gateway()
    const listener = await listenGatewayHttp(instance.server, { port: 0, maxBodyBytes: 2048, deploymentSha: DEPLOYED_SHA })
    try {
      await expect(get(new URL(listener.address), '/health')).resolves.toEqual({ status: 200, body: { ok: true, deployedSha: DEPLOYED_SHA } })
      for (const operation of READ_ONLY_OPERATIONS) {
        await expect(post(new URL(listener.address), `/v1/${operation}`, body(operation), { 'cf-access-jwt-assertion': 'edge', 'x-overdeck-application-credential': 'app' })).resolves.toEqual({ status: 200, body: { id: `safe-${operation}` } })
      }
      await expect(post(new URL(listener.address), '/v1/deployProject', body('deployProject'))).resolves.toMatchObject({ status: 404, body: { error: { code: 'operation_not_found' } } })
    } finally {
      await listener.close()
    }
  })

  it('fails boundedly when installed smoke request receives no response', async () => {
    const root = await mkdtemp(join(tmpdir(), 'actions-gateway-smoke-timeout-'))
    const config = join(root, 'smoke.env')
    const script = resolve(import.meta.dirname, '../../../packaging/test-actions-gateway-r0.mjs')
    const listener = createServer(() => {})
    await new Promise<void>((resolve, reject) => listener.listen(0, '127.0.0.1', (error?: Error) => error ? reject(error) : resolve()))
    const address = listener.address()
    if (!address || typeof address === 'string') throw new Error('listener address missing')
    await writeFile(config, [
      `OVERDECK_ACTIONS_GATEWAY_SMOKE_ENDPOINT=http://127.0.0.1:${address.port}`,
      'OVERDECK_ACTIONS_GATEWAY_SMOKE_EDGE_ASSERTION=edge',
      'OVERDECK_ACTIONS_GATEWAY_SMOKE_APPLICATION_CREDENTIAL=app',
    ].join('\n'))
    const startedAt = Date.now()
    const child = spawn(process.execPath, [script, '--endpoint', `http://127.0.0.1:${address.port}`, '--config', config], { stdio: 'pipe' })
    const errors: Buffer[] = []
    child.stderr.on('data', (chunk: Buffer) => errors.push(chunk))
    try {
      const [code] = await once(child, 'exit') as [number | null]
      expect(code).not.toBe(0)
      expect(Date.now() - startedAt).toBeLessThan(3_000)
      expect(Buffer.concat(errors).toString('utf8')).toContain('request deadline')
    } finally {
      listener.close()
      await rm(root, { recursive: true, force: true })
    }
  })

  it('deploys through immutable release activation instead of mutable dist rollback', async () => {
    const deployScript = await readFile(resolve(import.meta.dirname, '../../../packaging/deploy-local.sh'), 'utf8')
    expect(deployScript).toContain('stage-backend-release.sh\" actions-gateway')
    expect(deployScript).toContain('backend-release.sh\" activate actions-gateway')
    expect(deployScript).toContain('actions-gateway-readiness.sh')
    expect(deployScript).not.toContain('rollback_actions_gateway()')
    expect(deployScript).not.toContain('modules/actions-gateway/dist')
  })

  it('writes unauthenticated rejection using fixed opaque audit identity', async () => {
    const instance = gateway({ async authenticate() { throw { code: 'unauthenticated' } } })
    await expect(instance.server.dispatch({ authentication: { accessAssertion: 'bad-edge', applicationCredential: 'bad-app' }, rawBody: body('listProjects') })).rejects.toMatchObject({ code: 'unauthenticated' })
    const receipt = await instance.receipts.readHead()
    expect(receipt).toMatchObject({
      callerSubject: 'unauthenticated-rejected',
      applicationCredentialId: 'unauthenticated-rejected',
      state: 'rejected',
      rejectionCode: 'unauthenticated',
    })
  })

  it('smoke uses explicit installed endpoint and config without launching a server', async () => {
    const root = await mkdtemp(join(tmpdir(), 'actions-gateway-smoke-'))
    const config = join(root, 'smoke.env')
    const script = resolve(import.meta.dirname, '../../../packaging/test-actions-gateway-r0.mjs')
    const received: string[] = []
    const listener = createServer((request, response) => {
      received.push(request.url ?? '')
      response.writeHead(200, { 'content-type': 'application/json' }).end('{}')
    })
    await new Promise<void>((resolve, reject) => listener.listen(0, '127.0.0.1', (error?: Error) => error ? reject(error) : resolve()))
    const address = listener.address()
    if (!address || typeof address === 'string') throw new Error('listener address missing')
    await writeFile(config, [
      `OVERDECK_ACTIONS_GATEWAY_SMOKE_ENDPOINT=http://127.0.0.1:${address.port}`,
      'OVERDECK_ACTIONS_GATEWAY_SMOKE_EDGE_ASSERTION=edge',
      'OVERDECK_ACTIONS_GATEWAY_SMOKE_APPLICATION_CREDENTIAL=app',
    ].join('\n'))
    const child = spawn(process.execPath, [script, '--endpoint', `http://127.0.0.1:${address.port}`, '--config', config], { stdio: 'pipe' })
    const errors: Buffer[] = []
    child.stderr.on('data', (chunk: Buffer) => errors.push(chunk))
    try {
      const [code] = await once(child, 'exit') as [number | null]
      expect([code, Buffer.concat(errors).toString('utf8')]).toEqual([0, ''])
      expect(received).toEqual(['/v1/getOperatorContext', '/v1/listProjects', '/v1/getAuditReceipt'])
    } finally {
      listener.close()
      await rm(root, { recursive: true, force: true })
    }
  })
})
