import type { CallerIdentity } from './auth.js'
import { parseOperationRequest, type RawOperationRequest } from './contracts.js'

export type AdmissionErrorCode =
  | 'request_expired'
  | 'replay_detected'
  | 'idempotency_conflict'
  | 'invalid_request'
  | 'rate_limited'
  | 'capacity_exhausted'
  | 'service_unavailable'

export class AdmissionError extends Error {
  readonly name = 'AdmissionError'

  constructor(readonly code: AdmissionErrorCode) {
    super('Request admission failed.')
  }
}

export interface OperationRequest {
  request_id: string
  idempotency_key: string
  issued_at: string
  nonce: string
  operation: string
  payload: unknown
}

export interface RatePolicy {
  maxRequests: number
  windowMilliseconds: number
}

export interface AdmissionPolicy {
  maxRequestBytes: number
  maxAgeMilliseconds: number
  maxFutureSkewMilliseconds?: number
  rate: RatePolicy
  maxConcurrent: number
}

export interface CallerAdmissionKey {
  subject: string
}

export interface AdmissionKey extends CallerAdmissionKey {
  operation: string
}

export interface NonceStore {
  consume(key: CallerAdmissionKey, nonce: string, expiresAt: Date): Promise<boolean>
}

export interface IdempotencyStore<Outcome> {
  reserve(input: IdempotencyReservation): Promise<IdempotencyReservationResult<Outcome>>
  complete(reservation: IdempotencyReservation, outcome: Outcome): Promise<void>
  abandon(reservation: IdempotencyReservation): Promise<void>
}

export interface IdempotencyReservation {
  key: CallerAdmissionKey
  idempotencyKey: string
  requestHash: string
  expiresAt: Date
}

export type IdempotencyReservationResult<Outcome> =
  | { kind: 'reserved' }
  | { kind: 'replay'; outcome: Outcome }
  | { kind: 'conflict' }

export interface AdmissionLimiter {
  acquire(key: AdmissionKey, policy: RatePolicy): Promise<boolean>
}

export interface ConcurrencyLimiter {
  acquire(key: AdmissionKey, maximum: number): Promise<(() => Promise<void>) | undefined>
}

export interface AdmissionDependencies<Outcome> {
  nonceStore: NonceStore
  idempotencyStore: IdempotencyStore<Outcome>
  rateLimiter: AdmissionLimiter
  concurrencyLimiter: ConcurrencyLimiter
  now?: () => Date
}

export type AdmissionResult<Outcome> =
  | { kind: 'replay'; outcome: Outcome }
  | { kind: 'accepted'; complete(outcome: Outcome): Promise<void>; abandon(): Promise<void> }

export class RequestAdmission<Outcome> {
  private readonly now: () => Date

  constructor(private readonly dependencies: AdmissionDependencies<Outcome>) {
    this.now = dependencies.now ?? (() => new Date())
  }

  async admit(
    identity: CallerIdentity,
    wireBody: RawOperationRequest,
    policy: AdmissionPolicy,
  ): Promise<AdmissionResult<Outcome>> {
    validatePolicy(policy)
    const request = parseTrustedOperationRequest(wireBody, policy.maxRequestBytes)
    const canonicalRequest = canonicalizeRequest(request)
    const now = this.now()
    const issuedAt = parseIssuedAt(canonicalRequest.issued_at)
    const expiration = new Date(issuedAt.getTime() + policy.maxAgeMilliseconds)
    const futureLimit = policy.maxFutureSkewMilliseconds ?? 30_000
    if (issuedAt.getTime() > now.getTime() + futureLimit || expiration.getTime() <= now.getTime()) {
      throw new AdmissionError('request_expired')
    }

    const callerKey: CallerAdmissionKey = { subject: identity.subject }
    const operationKey: AdmissionKey = { ...callerKey, operation: canonicalRequest.operation }
    const reservation: IdempotencyReservation = {
      key: callerKey,
      idempotencyKey: canonicalRequest.idempotency_key,
      requestHash: await admissionBackend(() => sha256(`${canonicalCallerIdentity(identity)}\n${canonicalRequest.serialized}`)),
      expiresAt: expiration,
    }
    let reserved = false
    try {
      const reservationResult = await this.dependencies.idempotencyStore.reserve(reservation)
      if (reservationResult.kind === 'conflict') {
        throw new AdmissionError('idempotency_conflict')
      }
      if (reservationResult.kind === 'replay') {
        return reservationResult
      }
      reserved = true

      const nonceAccepted = await this.dependencies.nonceStore.consume(callerKey, canonicalRequest.nonce, expiration)
      if (!nonceAccepted) {
        await this.dependencies.idempotencyStore.abandon(reservation)
        reserved = false
        throw new AdmissionError('replay_detected')
      }

      if (!(await this.dependencies.rateLimiter.acquire(operationKey, policy.rate))) {
        throw new AdmissionError('rate_limited')
      }
      const lease = await this.dependencies.concurrencyLimiter.acquire(operationKey, policy.maxConcurrent)
      if (!lease) {
        throw new AdmissionError('capacity_exhausted')
      }
      return createAcceptedResult(this.dependencies.idempotencyStore, reservation, lease)
    } catch (error) {
      if (reserved) {
        await abandonReservation(this.dependencies.idempotencyStore, reservation)
      }
      if (error instanceof AdmissionError) {
        throw error
      }
      throw new AdmissionError('service_unavailable')
    }
  }
}

function createAcceptedResult<Outcome>(
  store: IdempotencyStore<Outcome>,
  reservation: IdempotencyReservation,
  release: () => Promise<void>,
): AdmissionResult<Outcome> {
  type SettlementAction =
    | { kind: 'complete'; outcome: Outcome }
    | { kind: 'abandon' }

  let action: SettlementAction | undefined
  let durableWriteComplete = false
  let releaseComplete = false
  let attempt: Promise<void> | undefined

  function chooseAction(next: SettlementAction): SettlementAction {
    if (!action) {
      action = next
      return action
    }
    if (action.kind === 'complete' && next.kind === 'complete' && outcomesEqual(action.outcome, next.outcome)) {
      return action
    }
    if (action.kind === 'abandon' && next.kind === 'abandon') {
      return action
    }
    throw new AdmissionError('invalid_request')
  }

  async function settle(next: SettlementAction): Promise<void> {
    const chosen = chooseAction(next)
    if (!attempt) {
      const currentAttempt = (async () => {
        try {
          if (!durableWriteComplete) {
            if (chosen.kind === 'complete') {
              await store.complete(reservation, chosen.outcome)
            } else {
              await store.abandon(reservation)
            }
            durableWriteComplete = true
          }
        } finally {
          if (!releaseComplete) {
            await release()
            releaseComplete = true
          }
        }
      })()
      attempt = currentAttempt
      void currentAttempt.finally(() => {
        if (attempt === currentAttempt) {
          attempt = undefined
        }
      }).catch(() => {})
    }
    try {
      await attempt
    } catch {
      throw new AdmissionError('service_unavailable')
    }
  }

  return {
    kind: 'accepted',
    async complete(outcome: Outcome): Promise<void> {
      await settle({ kind: 'complete', outcome })
    },
    async abandon(): Promise<void> {
      await settle({ kind: 'abandon' })
    },
  }
}

function outcomesEqual<Outcome>(left: Outcome, right: Outcome): boolean {
  if (Object.is(left, right)) return true
  try {
    const leftWriter = new CanonicalJsonWriter()
    const rightWriter = new CanonicalJsonWriter()
    leftWriter.write(left)
    rightWriter.write(right)
    return leftWriter.result() === rightWriter.result()
  } catch {
    return false
  }
}

async function abandonReservation<Outcome>(store: IdempotencyStore<Outcome>, reservation: IdempotencyReservation): Promise<void> {
  try {
    await store.abandon(reservation)
  } catch {
    throw new AdmissionError('service_unavailable')
  }
}

async function admissionBackend<T>(operation: () => Promise<T>): Promise<T> {
  try {
    return await operation()
  } catch {
    throw new AdmissionError('service_unavailable')
  }
}

const textEncoder = new TextEncoder()
const requestFields = new Set(['request_id', 'idempotency_key', 'issued_at', 'nonce', 'operation', 'payload'])
const maximumCanonicalDepth = 128

function validatePolicy(policy: AdmissionPolicy): void {
  if (!Number.isSafeInteger(policy.maxRequestBytes) || policy.maxRequestBytes <= 0
    || !Number.isSafeInteger(policy.maxAgeMilliseconds) || policy.maxAgeMilliseconds <= 0
    || !Number.isSafeInteger(policy.maxConcurrent) || policy.maxConcurrent <= 0
    || !Number.isSafeInteger(policy.rate.maxRequests) || policy.rate.maxRequests <= 0
    || !Number.isSafeInteger(policy.rate.windowMilliseconds) || policy.rate.windowMilliseconds <= 0
    || (policy.maxFutureSkewMilliseconds !== undefined && (!Number.isSafeInteger(policy.maxFutureSkewMilliseconds) || policy.maxFutureSkewMilliseconds < 0))) {
    throw new TypeError('Invalid admission policy.')
  }
}

function parseTrustedOperationRequest(wireBody: RawOperationRequest, maximumBytes: number): OperationRequest {
  try {
    const request = parseOperationRequest(wireBody, maximumBytes)
    return {
      request_id: request.requestId,
      idempotency_key: request.idempotencyKey,
      issued_at: request.issuedAt,
      nonce: request.nonce,
      operation: request.operation,
      payload: request.payload,
    }
  } catch {
    throw new AdmissionError('invalid_request')
  }
}

function canonicalizeRequest(request: OperationRequest): OperationRequest & { serialized: string } {
  if (!request || typeof request !== 'object' || Array.isArray(request)
    || Object.keys(request).some((field) => !requestFields.has(field))
    || !boundedString(request.request_id, 128) || !boundedString(request.idempotency_key, 256)
    || !boundedString(request.issued_at, 64) || !boundedString(request.nonce, 512)
    || !boundedString(request.operation, 128)) {
    throw new AdmissionError('invalid_request')
  }
  try {
    const writer = new CanonicalJsonWriter()
    writer.writeObject([
      ['idempotency_key', request.idempotency_key],
      ['issued_at', request.issued_at],
      ['nonce', request.nonce],
      ['operation', request.operation],
      ['payload', request.payload],
      ['request_id', request.request_id],
    ])
    return { ...request, serialized: writer.result() }
  } catch {
    throw new AdmissionError('invalid_request')
  }
}

class CanonicalJsonWriter {
  private readonly chunks: string[] = []
  private readonly activeObjects = new WeakSet<object>()

  result(): string {
    return this.chunks.join('')
  }

  write(value: unknown, depth = 0): void {
    if (depth > maximumCanonicalDepth) {
      throw new Error('Maximum canonical depth exceeded.')
    }
    if (value === null) {
      this.append('null')
      return
    }
    if (typeof value === 'string') {
      this.writeString(value)
      return
    }
    if (typeof value === 'boolean') {
      this.append(value ? 'true' : 'false')
      return
    }
    if (typeof value === 'number') {
      if (!Number.isFinite(value)) {
        throw new Error('Non-finite number.')
      }
      this.append(JSON.stringify(value))
      return
    }
    if (Array.isArray(value)) {
      this.writeArray(value, depth)
      return
    }
    if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) {
      throw new Error('Unsupported value.')
    }
    this.writePlainObject(value as Record<string, unknown>, depth)
  }

  writeObject(entries: readonly (readonly [string, unknown])[]): void {
    this.append('{')
    for (let index = 0; index < entries.length; index += 1) {
      if (index > 0) this.append(',')
      this.writeString(entries[index][0])
      this.append(':')
      this.write(entries[index][1])
    }
    this.append('}')
  }

  private writeArray(value: readonly unknown[], depth: number): void {
    this.enter(value)
    try {
      this.append('[')
      for (let index = 0; index < value.length; index += 1) {
        if (index > 0) this.append(',')
        this.write(value[index], depth + 1)
      }
      this.append(']')
    } finally {
      this.activeObjects.delete(value)
    }
  }

  private writePlainObject(value: Record<string, unknown>, depth: number): void {
    this.enter(value)
    try {
      const keys: string[] = []
      for (const key in value) {
        if (Object.hasOwn(value, key)) {
          keys.push(key)
        }
      }
      keys.sort()
      this.append('{')
      for (let index = 0; index < keys.length; index += 1) {
        if (index > 0) this.append(',')
        const key = keys[index]
        this.writeString(key)
        this.append(':')
        this.write(value[key], depth + 1)
      }
      this.append('}')
    } finally {
      this.activeObjects.delete(value)
    }
  }

  private writeString(value: string): void {
    this.append('"')
    for (let index = 0; index < value.length; index += 1) {
      const code = value.charCodeAt(index)
      if (code === 0x22) {
        this.append('\\"')
      } else if (code === 0x5c) {
        this.append('\\\\')
      } else if (code === 0x08) {
        this.append('\\b')
      } else if (code === 0x0c) {
        this.append('\\f')
      } else if (code === 0x0a) {
        this.append('\\n')
      } else if (code === 0x0d) {
        this.append('\\r')
      } else if (code === 0x09) {
        this.append('\\t')
      } else if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff && !isSurrogatePair(value, index))) {
        this.append(`\\u${code.toString(16).padStart(4, '0')}`)
      } else {
        const length = isSurrogatePair(value, index) ? 2 : 1
        this.append(value.slice(index, index + length))
        index += length - 1
      }
    }
    this.append('"')
  }

  private enter(value: object): void {
    if (this.activeObjects.has(value)) {
      throw new Error('Circular value.')
    }
    this.activeObjects.add(value)
  }

  private append(value: string): void {
    this.chunks.push(value)
  }
}

function isSurrogatePair(value: string, index: number): boolean {
  const code = value.charCodeAt(index)
  return code >= 0xd800 && code <= 0xdbff
    && index + 1 < value.length
    && value.charCodeAt(index + 1) >= 0xdc00
    && value.charCodeAt(index + 1) <= 0xdfff
}

function canonicalCallerIdentity(identity: CallerIdentity): string {
  return JSON.stringify({
    application_credential_id: identity.application_credential_id,
    edge_session_id: identity.edge_session_id,
    email: identity.email,
    subject: identity.subject,
  })
}

function parseIssuedAt(value: string): Date {
  const issuedAt = new Date(value)
  if (Number.isNaN(issuedAt.getTime()) || issuedAt.toISOString() !== value) {
    throw new AdmissionError('invalid_request')
  }
  return issuedAt
}

function boundedString(value: unknown, maximumLength: number): value is string {
  return typeof value === 'string' && value.length > 0 && value.length <= maximumLength
}

async function sha256(value: string): Promise<string> {
  const digest = await crypto.subtle.digest('SHA-256', textEncoder.encode(value))
  let output = ''
  for (const byte of new Uint8Array(digest)) {
    output += byte.toString(16).padStart(2, '0')
  }
  return output
}
