import { createHmac } from 'node:crypto'
import { describe, expect, it, vi } from 'vitest'

import { RequestAdmission, type AdmissionDependencies, type IdempotencyReservation, type IdempotencyStore } from '../src/admission.js'
import { AuditTrail, InMemoryAuditStore, type AuditIntegritySigner, type AuditStore } from '../src/audit.js'
import type { CallerIdentity } from '../src/contracts.js'
import { GatewayError } from '../src/contracts.js'
import { createReadOnlyGateway, READ_ONLY_OPERATIONS, type OpaqueResponse, type ReadOnlyRoute } from '../src/routes.js'

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

function wire(operation: string, overrides: Record<string, unknown> = {}): Uint8Array {
  return new TextEncoder().encode(JSON.stringify({
    request_id: `request-${operation}`, idempotency_key: `key-${operation}`, issued_at: now.toISOString(), nonce: `nonce-${operation}`,
    operation, payload: {}, ...overrides,
  }))
}

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 reservation')
    value.outcome = outcome
  }
  async abandon(reservation: IdempotencyReservation) { this.values.delete(`${reservation.key.subject}:${reservation.idempotencyKey}`) }
}

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

function dependencies(overrides: Partial<AdmissionDependencies<OpaqueResponse>> = {}): AdmissionDependencies<OpaqueResponse> {
  const nonces = new Set<string>()
  return {
    nonceStore: { async consume(key, nonce) { const value = `${key.subject}:${nonce}`; if (nonces.has(value)) return false; nonces.add(value); return true } },
    idempotencyStore: new Store(),
    rateLimiter: { acquire: async () => true },
    concurrencyLimiter: { acquire: async () => async () => {} },
    now: () => now,
    ...overrides,
  }
}

function gateway(overrides: Partial<AdmissionDependencies<OpaqueResponse>> = {}) {
  const dispatch = vi.fn(async (payload: unknown, _context: unknown): Promise<OpaqueResponse> => ({ operation: 'safe', payload: payload as never }))
  const policy = { maxRequestBytes: 2048, maxAgeMilliseconds: 60_000, rate: { maxRequests: 10, windowMilliseconds: 60_000 }, maxConcurrent: 1 }
  const routes = READ_ONLY_OPERATIONS.map((operation) => ({
    operation, policy, parsePayload: (payload: unknown) => payload, parseResponse: (value: unknown) => value as OpaqueResponse, adapter: { dispatch },
  })) as readonly ReadOnlyRoute<unknown>[]
  return { gateway: createReadOnlyGateway(routes, new RequestAdmission(dependencies(overrides)), new AuditTrail(new InMemoryAuditStore(), signer(), { now: () => now })), dispatch }
}

describe('read-only route gateway', () => {
  it('exposes exactly the three R0 operations and no deferred routes', async () => {
    const instance = gateway()
    expect(instance.gateway.operations).toEqual(['getOperatorContext', 'listProjects', 'getAuditReceipt'])
    await expect(instance.gateway.dispatch(identity, wire('deployProject'))).rejects.toMatchObject({ code: 'operation_not_found' })
    expect(instance.dispatch).not.toHaveBeenCalled()
  })

  it.each(['getOperatorContext', 'listProjects', 'getAuditReceipt'])('routes %s through admission before adapter', async (operation) => {
    const instance = gateway()
    await expect(instance.gateway.dispatch(identity, wire(operation))).resolves.toMatchObject({ operation: 'safe' })
    expect(instance.dispatch).toHaveBeenCalledOnce()
  })

  it('blocks expiry, replay, rate, and capacity before adapter side effects', async () => {
    const cases: readonly [string, Partial<AdmissionDependencies<OpaqueResponse>>, Record<string, unknown>, string][] = [
      ['expiry', {}, { issued_at: '2026-08-14T11:58:00.000Z' }, 'request_expired'],
      ['rate', { rateLimiter: { acquire: async () => false } }, {}, 'rate_limited'],
      ['capacity', { concurrencyLimiter: { acquire: async () => undefined } }, {}, 'capacity_exhausted'],
    ]
    for (const [_name, dependencyOverrides, requestOverrides, code] of cases) {
      const instance = gateway(dependencyOverrides)
      await expect(instance.gateway.dispatch(identity, wire('listProjects', requestOverrides))).rejects.toMatchObject({ code })
      expect(instance.dispatch).not.toHaveBeenCalled()
    }
    const instance = gateway()
    await instance.gateway.dispatch(identity, wire('listProjects'))
    await expect(instance.gateway.dispatch(identity, wire('getAuditReceipt', { idempotency_key: 'other-key', nonce: 'nonce-listProjects' }))).rejects.toMatchObject({ code: 'replay_detected' })
    expect(instance.dispatch).toHaveBeenCalledOnce()
  })

  it('passes only validated payload and opaque operation context to adapters', async () => {
    const instance = gateway()
    await instance.gateway.dispatch(identity, wire('getOperatorContext', { payload: { unexpected: 'safe' } }))
    const [, context] = instance.dispatch.mock.calls[0]!
    expect(context).toEqual({ callerSubject: 'owner-1', applicationCredentialId: 'credential-1', requestId: 'request-getOperatorContext', operation: 'getOperatorContext' })
    expect(JSON.stringify(context)).not.toContain('session-1')
    expect(JSON.stringify(context)).not.toContain('owner@example.test')
  })

  it('releases admission capacity when accepted audit storage fails', async () => {
    let releases = 0
    const audit = new AuditTrail({
      async readHead() { return null },
      async readOperationHead() { return null },
      async compareAndAppend() { throw new Error('storage unavailable') },
      async getByOperation() { return [] },
    }, signer(), { now: () => now })
    const dispatch = vi.fn(async (): Promise<OpaqueResponse> => ({ operation: 'safe' }))
    const policy = { maxRequestBytes: 2048, maxAgeMilliseconds: 60_000, rate: { maxRequests: 10, windowMilliseconds: 60_000 }, maxConcurrent: 1 }
    const routes = READ_ONLY_OPERATIONS.map((operation) => ({
      operation, policy, parsePayload: (payload: unknown) => payload, parseResponse: (value: unknown) => value as OpaqueResponse, adapter: { dispatch },
    })) as readonly ReadOnlyRoute<unknown>[]
    const instance = createReadOnlyGateway(routes, new RequestAdmission(dependencies({
      concurrencyLimiter: { acquire: async () => async () => { releases += 1 } },
    })), audit)

    await expect(instance.dispatch(identity, wire('listProjects'))).rejects.toEqual(new GatewayError('service_unavailable'))
    expect(releases).toBe(1)
    expect(dispatch).not.toHaveBeenCalled()
  })

  it('does not abandon an accepted request after adapter side effect when completion fails', async () => {
    const store = new Store()
    const abandon = vi.spyOn(store, 'abandon')
    const instance = gateway({ idempotencyStore: {
      reserve: store.reserve.bind(store),
      complete: async () => { throw new Error('storage unavailable') },
      abandon: store.abandon.bind(store),
    } })

    await expect(instance.gateway.dispatch(identity, wire('listProjects'))).rejects.toEqual(new GatewayError('service_unavailable'))
    expect(instance.dispatch).toHaveBeenCalledOnce()
    expect(abandon).not.toHaveBeenCalled()
  })

  it('repairs a missing terminal audit receipt on replay without rerunning the adapter', async () => {
    let failTerminal = true
    const backing = new InMemoryAuditStore()
    const store: AuditStore = {
      readHead: () => backing.readHead(),
      readOperationHead: (callerSubject, operationId) => backing.readOperationHead(callerSubject, operationId),
      async compareAndAppend(receipt, previousHash, operationPreviousHash) {
        if (failTerminal && receipt.state === 'completed') {
          failTerminal = false
          throw new Error('terminal storage unavailable')
        }
        return backing.compareAndAppend(receipt, previousHash, operationPreviousHash)
      },
      getByOperation: (callerSubject, operationId, limit) => backing.getByOperation(callerSubject, operationId, limit),
    }
    const dispatch = vi.fn(async (): Promise<OpaqueResponse> => ({ operation: 'safe' }))
    const policy = { maxRequestBytes: 2048, maxAgeMilliseconds: 60_000, rate: { maxRequests: 10, windowMilliseconds: 60_000 }, maxConcurrent: 1 }
    const routes = READ_ONLY_OPERATIONS.map((operation) => ({
      operation, policy, parsePayload: (payload: unknown) => payload, parseResponse: (value: unknown) => value as OpaqueResponse, adapter: { dispatch },
    })) as readonly ReadOnlyRoute<unknown>[]
    const instance = createReadOnlyGateway(routes, new RequestAdmission(dependencies()), new AuditTrail(store, signer(), { now: () => now }))

    await expect(instance.dispatch(identity, wire('listProjects'))).rejects.toEqual(new GatewayError('service_unavailable'))
    await expect(instance.dispatch(identity, wire('listProjects'))).resolves.toEqual({ operation: 'safe' })
    expect(dispatch).toHaveBeenCalledOnce()
    const receipts = await backing.getByOperation('owner-1', 'request-listProjects', 10)
    expect(receipts.filter((receipt) => receipt.state === 'completed')).toHaveLength(1)
  })

  it('verifies terminal audit repair after a concurrent replay wins append race', async () => {
    let holdFirstTerminal: (() => void) | undefined
    let firstTerminalStarted: (() => void) | undefined
    const firstTerminal = new Promise<void>((resolve) => { holdFirstTerminal = resolve })
    const terminalStarted = new Promise<void>((resolve) => { firstTerminalStarted = resolve })
    let terminalAttempts = 0
    let failInitialTerminal = true
    const backing = new InMemoryAuditStore()
    const store: AuditStore = {
      readHead: () => backing.readHead(),
      readOperationHead: (callerSubject, operationId) => backing.readOperationHead(callerSubject, operationId),
      async compareAndAppend(receipt, previousHash, operationPreviousHash) {
        if (receipt.state === 'completed' && failInitialTerminal) {
          failInitialTerminal = false
          throw new Error('terminal storage unavailable')
        }
        if (receipt.state === 'completed' && terminalAttempts++ === 0) {
          firstTerminalStarted?.()
          await firstTerminal
        }
        return backing.compareAndAppend(receipt, previousHash, operationPreviousHash)
      },
      getByOperation: (callerSubject, operationId, limit) => backing.getByOperation(callerSubject, operationId, limit),
    }
    const dispatch = vi.fn(async (): Promise<OpaqueResponse> => ({ operation: 'safe' }))
    const policy = { maxRequestBytes: 2048, maxAgeMilliseconds: 60_000, rate: { maxRequests: 10, windowMilliseconds: 60_000 }, maxConcurrent: 2 }
    const routes = READ_ONLY_OPERATIONS.map((operation) => ({
      operation, policy, parsePayload: (payload: unknown) => payload, parseResponse: (value: unknown) => value as OpaqueResponse, adapter: { dispatch },
    })) as readonly ReadOnlyRoute<unknown>[]
    const instance = createReadOnlyGateway(routes, new RequestAdmission(dependencies()), new AuditTrail(store, signer(), { now: () => now }))

    await expect(instance.dispatch(identity, wire('listProjects'))).rejects.toEqual(new GatewayError('service_unavailable'))
    const firstReplay = instance.dispatch(identity, wire('listProjects'))
    await terminalStarted
    const secondReplay = instance.dispatch(identity, wire('listProjects'))
    holdFirstTerminal?.()
    await expect(Promise.all([firstReplay, secondReplay])).resolves.toEqual([{ operation: 'safe' }, { operation: 'safe' }])
    expect(dispatch).toHaveBeenCalledOnce()
    const receipts = await backing.getByOperation('owner-1', 'request-listProjects', 10)
    expect(receipts.filter((receipt) => receipt.state === 'completed')).toHaveLength(1)
  })

  it('does not expose paths or secrets from failed adapter results', async () => {
    const instance = gateway()
    instance.dispatch.mockRejectedValueOnce(new Error('/private/path secret-value'))
    await expect(instance.gateway.dispatch(identity, wire('getOperatorContext'))).rejects.toEqual(new GatewayError('service_unavailable'))
  })
})
