export interface IdempotencyRecord {
  token: string
  claimedAtMs: number
  staleAtMs: number
  expiresAtMs: number
}

export interface VersionedIdempotencyRecord {
  version: string
  record: IdempotencyRecord
}

export interface IdempotencyCompareAndSwapInput {
  key: string
  expectedVersion: string | null
  next: IdempotencyRecord | null
  ttlMs?: number
}

export interface IdempotencyStore {
  read(key: string): Promise<VersionedIdempotencyRecord | null>
  compareAndSwap(input: IdempotencyCompareAndSwapInput): Promise<boolean>
}

export interface IdempotencyOptions {
  ttlMs: number
  staleAfterMs: number
  now?: () => number
  generateToken?: () => string
  maxAttempts?: number
}

export interface IdempotencyClaim {
  key: string
  token: string
  claimedAtMs: number
  staleAtMs: number
  expiresAtMs: number
}

export type IdempotencyClaimResult =
  | { status: 'claimed'; claim: IdempotencyClaim; reclaimed: boolean }
  | {
      status: 'duplicate'
      existing: Omit<IdempotencyRecord, 'token'>
    }

export type IdempotencyValidationErrorCode =
  | 'invalid-key'
  | 'invalid-duration'
  | 'invalid-attempts'
  | 'invalid-clock'
  | 'invalid-token'
  | 'reused-token'
  | 'token-generation-failed'

export class IdempotencyValidationError extends Error {
  readonly code: IdempotencyValidationErrorCode
  readonly field: string

  constructor(code: IdempotencyValidationErrorCode, field: string, message: string) {
    super(message)
    this.name = 'IdempotencyValidationError'
    this.code = code
    this.field = field
  }
}

export class IdempotencyContentionError extends Error {
  readonly key: string
  readonly attempts: number

  constructor(key: string, attempts: number) {
    super(`Idempotency claim contention exceeded ${attempts} attempts for key ${key}`)
    this.name = 'IdempotencyContentionError'
    this.key = key
    this.attempts = attempts
  }
}

const DEFAULT_MAX_ATTEMPTS = 8

function failValidation(
  code: IdempotencyValidationErrorCode,
  field: string,
  message: string,
): never {
  throw new IdempotencyValidationError(code, field, message)
}

function assertPositiveFinite(name: string, value: number): void {
  if (!Number.isFinite(value) || value <= 0) {
    failValidation('invalid-duration', name, `${name} must be a finite positive number`)
  }
}

function resolveMaxAttempts(value: number | undefined): number {
  const attempts = value ?? DEFAULT_MAX_ATTEMPTS
  if (!Number.isInteger(attempts) || attempts <= 0) {
    failValidation('invalid-attempts', 'maxAttempts', 'maxAttempts must be a positive integer')
  }
  return attempts
}

function assertKey(key: unknown): asserts key is string {
  if (typeof key !== 'string' || key.trim().length === 0) {
    failValidation('invalid-key', 'key', 'key must be a non-empty string')
  }
}

function assertToken(token: unknown, field = 'token'): asserts token is string {
  if (typeof token !== 'string' || token.trim().length === 0) {
    failValidation('invalid-token', field, `${field} must be a non-empty string`)
  }
}

function createClaimToken(generateToken: () => string, currentToken?: string): string {
  let token: unknown
  try {
    token = generateToken()
  } catch {
    failValidation(
      'token-generation-failed',
      'generateToken',
      'generateToken failed to produce a claim token',
    )
  }
  assertToken(token, 'generateToken')
  if (currentToken !== undefined && token === currentToken) {
    failValidation(
      'reused-token',
      'generateToken',
      'generateToken must return a token distinct from the current claim token',
    )
  }
  return token
}

function duplicateResult(record: IdempotencyRecord): IdempotencyClaimResult {
  return {
    status: 'duplicate',
    existing: {
      claimedAtMs: record.claimedAtMs,
      staleAtMs: record.staleAtMs,
      expiresAtMs: record.expiresAtMs,
    },
  }
}

function readNow(now: () => number): number {
  let nowMs: unknown
  try {
    nowMs = now()
  } catch {
    failValidation('invalid-clock', 'now', 'now failed to produce a timestamp')
  }
  if (typeof nowMs !== 'number' || !Number.isFinite(nowMs)) {
    failValidation('invalid-clock', 'now', 'now must return a finite timestamp')
  }
  return nowMs
}

export async function claimIdempotency(
  store: IdempotencyStore,
  key: string,
  options: IdempotencyOptions,
): Promise<IdempotencyClaimResult> {
  assertKey(key)
  assertPositiveFinite('ttlMs', options.ttlMs)
  assertPositiveFinite('staleAfterMs', options.staleAfterMs)
  if (options.staleAfterMs > options.ttlMs) {
    failValidation(
      'invalid-duration',
      'staleAfterMs',
      'staleAfterMs must not exceed ttlMs',
    )
  }

  const maxAttempts = resolveMaxAttempts(options.maxAttempts)
  const now = options.now ?? Date.now
  const generateToken = options.generateToken ?? (() => crypto.randomUUID())

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const current = await store.read(key)
    const nowMs = readNow(now)

    if (
      current &&
      nowMs < current.record.staleAtMs &&
      nowMs < current.record.expiresAtMs
    ) {
      return duplicateResult(current.record)
    }

    const staleAtMs = nowMs + options.staleAfterMs
    const expiresAtMs = nowMs + options.ttlMs
    if (!Number.isFinite(staleAtMs) || !Number.isFinite(expiresAtMs)) {
      failValidation(
        'invalid-clock',
        'now',
        'now plus configured durations must produce finite timestamps',
      )
    }

    const record: IdempotencyRecord = {
      token: createClaimToken(generateToken, current?.record.token),
      claimedAtMs: nowMs,
      staleAtMs,
      expiresAtMs,
    }

    const claimed = await store.compareAndSwap({
      key,
      expectedVersion: current?.version ?? null,
      next: record,
      ttlMs: options.ttlMs,
    })

    if (claimed) {
      return {
        status: 'claimed',
        claim: { key, ...record },
        reclaimed: current !== null,
      }
    }
  }

  throw new IdempotencyContentionError(key, maxAttempts)
}

export async function releaseIdempotencyClaim(
  store: IdempotencyStore,
  claim: IdempotencyClaim,
  options: { maxAttempts?: number } = {},
): Promise<boolean> {
  assertKey(claim.key)
  assertToken(claim.token, 'claim.token')
  const maxAttempts = resolveMaxAttempts(options.maxAttempts)

  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const current = await store.read(claim.key)
    if (!current || current.record.token !== claim.token) return false

    const released = await store.compareAndSwap({
      key: claim.key,
      expectedVersion: current.version,
      next: null,
    })
    if (released) return true
  }

  throw new IdempotencyContentionError(claim.key, maxAttempts)
}
