export type AuditRejectionCode =
  | 'unauthenticated'
  | 'identity_mismatch'
  | 'credential_revoked'
  | 'request_expired'
  | 'replay_detected'
  | 'idempotency_conflict'
  | 'invalid_request'
  | 'operation_forbidden'
  | 'approval_required'
  | 'rate_limited'
  | 'capacity_exhausted'
  | 'service_unavailable'
  | 'operation_not_found'

export type AuditState = 'accepted' | 'rejected' | 'completed' | 'failed'

export interface AuditLimits {
  readonly requestBytes?: number
  readonly rateLimit?: number
  readonly concurrencyLimit?: number
}

export interface AuditReceipt {
  readonly version: 1
  readonly sequence: number
  readonly previousHash: string | null
  readonly hash: string
  readonly operationSequence?: number
  readonly operationPreviousHash?: string | null
  readonly recordedAt: string
  readonly requestId: string
  readonly callerSubject: string
  readonly applicationCredentialId: string
  readonly operation: string
  readonly operationId?: string
  readonly targetRef?: string
  readonly state: AuditState
  readonly rejectionCode?: AuditRejectionCode
  readonly limits?: AuditLimits
}

export interface AuditRequest {
  readonly requestId: string
  readonly callerSubject: string
  readonly applicationCredentialId: string
  readonly operation: string
  readonly targetRef?: string
  readonly limits?: AuditLimits
}

export interface AuditTerminalRequest extends AuditRequest {
  readonly operationId: string
  readonly state: 'completed' | 'failed'
  readonly rejectionCode?: AuditRejectionCode
}

export interface AuditIntegritySigner {
  sign(payload: string): string
  verify(payload: string, signature: string): boolean
}

export interface AuditStore {
  readHead(): Promise<AuditReceipt | null>
  readOperationHead(callerSubject: string, operationId: string): Promise<AuditReceipt | null>
  compareAndAppend(
    receipt: AuditReceipt,
    expectedPreviousHash: string | null,
    expectedOperationPreviousHash: string | null,
  ): Promise<'appended' | 'conflict'>
  getByOperation(callerSubject: string, operationId: string, limit: number): Promise<readonly AuditReceipt[]>
}

export class AuditStorageError extends Error {
  constructor(message = 'Audit storage is unavailable') {
    super(message)
    this.name = 'AuditStorageError'
  }
}

export class AuditIntegrityError extends Error {
  constructor(message = 'Audit receipt integrity check failed') {
    super(message)
    this.name = 'AuditIntegrityError'
  }
}

export interface AuditTrailOptions {
  readonly now?: () => Date
  readonly maxAppendRetries?: number
}

export const REJECTED_UNAUTHENTICATED_SUBJECT = 'unauthenticated-rejected'
export const REJECTED_UNAUTHENTICATED_CREDENTIAL = 'unauthenticated-rejected'

const opaqueIdentifier = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/
const operationName = /^[a-z][a-z0-9]*(?:[._-][a-z0-9]+)*$/
const hashPattern = /^[a-f0-9]{64}$/
const rejectionCodes = new Set<AuditRejectionCode>([
  'unauthenticated',
  'identity_mismatch',
  'credential_revoked',
  'request_expired',
  'replay_detected',
  'idempotency_conflict',
  'invalid_request',
  'operation_forbidden',
  'approval_required',
  'rate_limited',
  'capacity_exhausted',
  'service_unavailable',
  'operation_not_found',
])

export class AuditTrail {
  private readonly now: () => Date
  private readonly maxAppendRetries: number

  constructor(
    private readonly store: AuditStore,
    private readonly signer: AuditIntegritySigner,
    options: AuditTrailOptions = {},
  ) {
    if (signer === null || typeof signer !== 'object' || typeof signer.sign !== 'function' || typeof signer.verify !== 'function') {
      throw new TypeError('Audit integrity signer is required')
    }
    this.now = options.now ?? (() => new Date())
    this.maxAppendRetries = options.maxAppendRetries ?? 3
    if (!Number.isInteger(this.maxAppendRetries) || this.maxAppendRetries < 1 || this.maxAppendRetries > 10) {
      throw new RangeError('maxAppendRetries must be an integer between 1 and 10')
    }
  }

  recordAccepted(request: AuditRequest): Promise<AuditReceipt> {
    return this.append(request, 'accepted')
  }

  recordRejected(request: AuditRequest, rejectionCode: AuditRejectionCode): Promise<AuditReceipt> {
    assertRejectionCode(rejectionCode)
    return this.append(request, 'rejected', { rejectionCode })
  }

  recordTerminal(request: AuditTerminalRequest): Promise<AuditReceipt> {
    if (request.state === 'completed' && request.rejectionCode !== undefined) {
      throw new TypeError('Completed audit receipts cannot include a rejection code')
    }
    if (request.state === 'failed' && request.rejectionCode === undefined) {
      throw new TypeError('Failed audit receipts require a rejection code')
    }
    if (request.rejectionCode !== undefined) {
      assertRejectionCode(request.rejectionCode)
    }
    return this.append(request, request.state, {
      operationId: request.operationId,
      rejectionCode: request.rejectionCode,
    })
  }

  async getOperationReceipts(
    callerSubject: string,
    operationId: string,
    limit = 100,
  ): Promise<readonly AuditReceipt[]> {
    assertOpaqueIdentifier(callerSubject, 'callerSubject')
    assertOpaqueIdentifier(operationId, 'operationId')
    if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
      throw new RangeError('limit must be an integer between 1 and 100')
    }

    const receipts = await this.store.getByOperation(callerSubject, operationId, limit)
    let previous: AuditReceipt | undefined
    for (const receipt of receipts) {
      assertReceipt(receipt, this.signer)
      if (receipt.callerSubject !== callerSubject || receipt.operationId !== operationId) {
        throw new AuditIntegrityError('Audit storage returned a receipt outside the requested scope')
      }
      if (receipt.operationSequence === undefined || receipt.operationPreviousHash === undefined) {
        throw new AuditIntegrityError('Audit operation chain metadata is missing')
      }
      if (previous === undefined) {
        if (receipt.operationSequence !== 1 || receipt.operationPreviousHash !== null) {
          throw new AuditIntegrityError('Audit operation chain does not start at its authenticated root')
        }
      } else if (
        receipt.operationSequence !== previous.operationSequence! + 1
        || receipt.operationPreviousHash !== previous.hash
      ) {
        throw new AuditIntegrityError('Audit storage returned a broken operation chain')
      }
      previous = receipt
    }
    return receipts.map(copyReceipt)
  }

  private async append(
    request: AuditRequest,
    state: AuditState,
    terminal: Pick<AuditReceipt, 'operationId' | 'rejectionCode'> = {},
  ): Promise<AuditReceipt> {
    assertRequest(request)
    assertRejectedUnauthenticatedMarker(request, state)
    for (let attempt = 0; attempt < this.maxAppendRetries; attempt += 1) {
      const head = await this.store.readHead()
      const operationHead = terminal.operationId === undefined
        ? null
        : await this.store.readOperationHead(request.callerSubject, terminal.operationId)
      if (head !== null) {
        assertReceipt(head, this.signer)
      }
      if (operationHead !== null) {
        assertReceipt(operationHead, this.signer)
        if (
          operationHead.callerSubject !== request.callerSubject
          || operationHead.operationId !== terminal.operationId
        ) {
          throw new AuditIntegrityError('Audit storage returned an operation head outside the requested scope')
        }
        if (operationHead.state === 'completed' || operationHead.state === 'failed') {
          throw new AuditIntegrityError('Audit operation is already terminal')
        }
      }
      const receipt = createReceipt(request, state, terminal, head, operationHead, this.now, this.signer)
      if (await this.store.compareAndAppend(
        receipt,
        head?.hash ?? null,
        operationHead?.hash ?? null,
      ) === 'appended') {
        return copyReceipt(receipt)
      }
    }
    throw new AuditStorageError('Audit append conflicted repeatedly')
  }
}

export class InMemoryAuditStore implements AuditStore {
  private readonly receipts: AuditReceipt[] = []

  async readHead(): Promise<AuditReceipt | null> {
    const head = this.receipts.at(-1)
    return head === undefined ? null : copyReceipt(head)
  }

  async readOperationHead(callerSubject: string, operationId: string): Promise<AuditReceipt | null> {
    const head = findOperationHead(this.receipts, callerSubject, operationId)
    return head === undefined ? null : copyReceipt(head)
  }

  async compareAndAppend(
    receipt: AuditReceipt,
    expectedPreviousHash: string | null,
    expectedOperationPreviousHash: string | null,
  ): Promise<'appended' | 'conflict'> {
    const head = this.receipts.at(-1)
    if ((head?.hash ?? null) !== expectedPreviousHash) {
      return 'conflict'
    }
    const operationHead = receipt.operationId === undefined
      ? undefined
      : findOperationHead(this.receipts, receipt.callerSubject, receipt.operationId)
    if ((operationHead?.hash ?? null) !== expectedOperationPreviousHash) {
      return 'conflict'
    }
    if (receipt.previousHash !== expectedPreviousHash || receipt.sequence !== (head?.sequence ?? 0) + 1) {
      throw new AuditIntegrityError('Audit receipt does not continue the chain')
    }
    if (receipt.operationId !== undefined && (
      receipt.operationPreviousHash !== expectedOperationPreviousHash
      || receipt.operationSequence !== (operationHead?.operationSequence ?? 0) + 1
      || operationHead?.state === 'completed'
      || operationHead?.state === 'failed'
    )) {
      throw new AuditIntegrityError('Audit receipt does not continue the operation chain')
    }
    this.receipts.push(copyReceipt(receipt))
    return 'appended'
  }

  async getByOperation(callerSubject: string, operationId: string, limit: number): Promise<readonly AuditReceipt[]> {
    return this.receipts
      .filter((receipt) => receipt.callerSubject === callerSubject && receipt.operationId === operationId)
      .slice(0, limit)
      .map(copyReceipt)
  }
}

function createReceipt(
  request: AuditRequest,
  state: AuditState,
  terminal: Pick<AuditReceipt, 'operationId' | 'rejectionCode'>,
  head: AuditReceipt | null,
  operationHead: AuditReceipt | null,
  now: () => Date,
  signer: AuditIntegritySigner,
): AuditReceipt {
  const recordedAt = now().toISOString()
  if (Number.isNaN(Date.parse(recordedAt))) {
    throw new AuditStorageError('Audit clock produced an invalid timestamp')
  }
  const unsigned = {
    version: 1 as const,
    sequence: (head?.sequence ?? 0) + 1,
    previousHash: head?.hash ?? null,
    ...(terminal.operationId === undefined ? {} : {
      operationSequence: (operationHead?.operationSequence ?? 0) + 1,
      operationPreviousHash: operationHead?.hash ?? null,
    }),
    recordedAt,
    requestId: request.requestId,
    callerSubject: request.callerSubject,
    applicationCredentialId: request.applicationCredentialId,
    operation: request.operation,
    ...(terminal.operationId === undefined ? {} : { operationId: terminal.operationId }),
    ...(request.targetRef === undefined ? {} : { targetRef: request.targetRef }),
    state,
    ...(terminal.rejectionCode === undefined ? {} : { rejectionCode: terminal.rejectionCode }),
    ...(request.limits === undefined ? {} : { limits: { ...request.limits } }),
  }
  const payload = canonicalJson(unsigned)
  const signature = signer.sign(payload)
  if (!hashPattern.test(signature) || !signer.verify(payload, signature)) {
    throw new AuditIntegrityError('Audit integrity signer produced an invalid signature')
  }
  return { ...unsigned, hash: signature }
}

function assertRequest(request: AuditRequest): void {
  assertOpaqueIdentifier(request.requestId, 'requestId')
  assertOpaqueIdentifier(request.callerSubject, 'callerSubject')
  assertOpaqueIdentifier(request.applicationCredentialId, 'applicationCredentialId')
  if (typeof request.operation !== 'string' || request.operation.length > 128 || !operationName.test(request.operation)) {
    throw new TypeError('operation must be a safe operation name')
  }
  if (request.targetRef !== undefined) {
    assertOpaqueIdentifier(request.targetRef, 'targetRef')
  }
  if (request.limits !== undefined) {
    assertLimits(request.limits)
  }
}

function assertRejectedUnauthenticatedMarker(request: AuditRequest, state: AuditState): void {
  const hasSubject = request.callerSubject === REJECTED_UNAUTHENTICATED_SUBJECT
  const hasCredential = request.applicationCredentialId === REJECTED_UNAUTHENTICATED_CREDENTIAL
  if (hasSubject !== hasCredential || (hasSubject && state !== 'rejected')) {
    throw new TypeError('Unauthenticated audit markers are valid only for rejected receipts')
  }
}

function assertReceipt(receipt: AuditReceipt, signer: AuditIntegritySigner): void {
  if (receipt.version !== 1 || !Number.isSafeInteger(receipt.sequence) || receipt.sequence < 1) {
    throw new AuditIntegrityError()
  }
  if (receipt.previousHash !== null && !hashPattern.test(receipt.previousHash)) {
    throw new AuditIntegrityError()
  }
  if (!hashPattern.test(receipt.hash) || Number.isNaN(Date.parse(receipt.recordedAt))) {
    throw new AuditIntegrityError()
  }
  assertRequest(receipt)
  assertRejectedUnauthenticatedMarker(receipt, receipt.state)
  if (!['accepted', 'rejected', 'completed', 'failed'].includes(receipt.state)) {
    throw new AuditIntegrityError()
  }
  if (receipt.operationId === undefined) {
    if (receipt.operationSequence !== undefined || receipt.operationPreviousHash !== undefined) {
      throw new AuditIntegrityError()
    }
  } else {
    assertOpaqueIdentifier(receipt.operationId, 'operationId')
    if (
      receipt.operationSequence === undefined
      || !Number.isSafeInteger(receipt.operationSequence)
      || receipt.operationSequence < 1
    ) {
      throw new AuditIntegrityError()
    }
    if (
      receipt.operationPreviousHash === undefined
      || (receipt.operationPreviousHash !== null && !hashPattern.test(receipt.operationPreviousHash))
    ) {
      throw new AuditIntegrityError()
    }
  }
  if (receipt.rejectionCode !== undefined) {
    assertRejectionCode(receipt.rejectionCode)
  }
  if (receipt.state === 'rejected' && receipt.rejectionCode === undefined) {
    throw new AuditIntegrityError()
  }
  if (receipt.state === 'completed' && (receipt.operationId === undefined || receipt.rejectionCode !== undefined)) {
    throw new AuditIntegrityError()
  }
  if (receipt.state === 'failed' && (receipt.operationId === undefined || receipt.rejectionCode === undefined)) {
    throw new AuditIntegrityError()
  }
  const { hash: receiptHash, ...unsigned } = receipt
  if (!signer.verify(canonicalJson(unsigned), receiptHash)) {
    throw new AuditIntegrityError()
  }
}

function assertLimits(limits: AuditLimits): void {
  const allowed = new Set(['requestBytes', 'rateLimit', 'concurrencyLimit'])
  for (const [name, value] of Object.entries(limits)) {
    if (!allowed.has(name) || !Number.isSafeInteger(value) || value < 0) {
      throw new TypeError('limits must contain only non-negative integer admission values')
    }
  }
}

function assertOpaqueIdentifier(value: string, name: string): void {
  if (typeof value !== 'string' || !opaqueIdentifier.test(value)) {
    throw new TypeError(`${name} must be an opaque identifier`)
  }
}

function assertRejectionCode(value: AuditRejectionCode): void {
  if (!rejectionCodes.has(value)) {
    throw new TypeError('rejectionCode must be a stable gateway error code')
  }
}

function findOperationHead(
  receipts: readonly AuditReceipt[],
  callerSubject: string,
  operationId: string,
): AuditReceipt | undefined {
  for (let index = receipts.length - 1; index >= 0; index -= 1) {
    const receipt = receipts[index]
    if (receipt.callerSubject === callerSubject && receipt.operationId === operationId) {
      return receipt
    }
  }
  return undefined
}

function canonicalJson(value: unknown): string {
  if (value === null || typeof value === 'boolean' || typeof value === 'string') {
    return JSON.stringify(value)
  }
  if (typeof value === 'number') {
    if (!Number.isFinite(value)) {
      throw new TypeError('Audit record contains a non-finite number')
    }
    return JSON.stringify(value)
  }
  if (Array.isArray(value)) {
    return `[${value.map(canonicalJson).join(',')}]`
  }
  if (typeof value === 'object' && value !== null) {
    const record = value as Record<string, unknown>
    return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`
  }
  throw new TypeError('Audit record contains an unsupported value')
}

function copyReceipt(receipt: AuditReceipt): AuditReceipt {
  return {
    ...receipt,
    ...(receipt.limits === undefined ? {} : { limits: Object.freeze({ ...receipt.limits }) }),
  }
}
