import { describe, expect, it } from 'vitest'
import {
  IdempotencyContentionError,
  IdempotencyValidationError,
  claimIdempotency,
  releaseIdempotencyClaim,
} from './idempotency.js'
import type {
  IdempotencyCompareAndSwapInput,
  IdempotencyRecord,
  IdempotencyStore,
  VersionedIdempotencyRecord,
} from './idempotency.js'

class MemoryStore implements IdempotencyStore {
  private current: VersionedIdempotencyRecord | null = null
  private version = 0

  async read(): Promise<VersionedIdempotencyRecord | null> {
    return this.current
      ? { version: this.current.version, record: { ...this.current.record } }
      : null
  }

  async compareAndSwap(input: IdempotencyCompareAndSwapInput): Promise<boolean> {
    const currentVersion = this.current?.version ?? null
    if (currentVersion !== input.expectedVersion) return false

    if (input.next === null) {
      this.current = null
      return true
    }

    this.version += 1
    this.current = {
      version: String(this.version),
      record: { ...input.next },
    }
    return true
  }

  record(): IdempotencyRecord | null {
    return this.current ? { ...this.current.record } : null
  }
}

describe('idempotency', () => {
  it('claims once and reports an active second claim as duplicate', async () => {
    const store = new MemoryStore()
    let token = 0
    const options = {
      ttlMs: 10_000,
      staleAfterMs: 5_000,
      now: () => 1_000,
      generateToken: () => `token-${++token}`,
    }

    const first = await claimIdempotency(store, 'job:1', options)
    const second = await claimIdempotency(store, 'job:1', options)

    expect(first).toMatchObject({
      status: 'claimed',
      reclaimed: false,
      claim: { token: 'token-1', claimedAtMs: 1_000, staleAtMs: 6_000, expiresAtMs: 11_000 },
    })
    expect(second).toEqual({
      status: 'duplicate',
      existing: { claimedAtMs: 1_000, staleAtMs: 6_000, expiresAtMs: 11_000 },
    })
  })

  it('atomically reclaims stale claims and protects the replacement owner', async () => {
    const store = new MemoryStore()
    let nowMs = 1_000
    let token = 0
    const options = {
      ttlMs: 10_000,
      staleAfterMs: 2_000,
      now: () => nowMs,
      generateToken: () => `token-${++token}`,
    }

    const first = await claimIdempotency(store, 'job:2', options)
    expect(first.status).toBe('claimed')
    if (first.status !== 'claimed') throw new Error('expected first claim')

    nowMs = 3_001
    const replacement = await claimIdempotency(store, 'job:2', options)
    expect(replacement).toMatchObject({ status: 'claimed', reclaimed: true })
    if (replacement.status !== 'claimed') throw new Error('expected replacement claim')

    await expect(releaseIdempotencyClaim(store, first.claim)).resolves.toBe(false)
    expect(store.record()?.token).toBe(replacement.claim.token)
    await expect(releaseIdempotencyClaim(store, replacement.claim)).resolves.toBe(true)
    expect(store.record()).toBeNull()
  })

  it('rejects a reclaim token that would preserve the old owner identity', async () => {
    const store = new MemoryStore()
    let nowMs = 1_000
    const options = {
      ttlMs: 10_000,
      staleAfterMs: 2_000,
      now: () => nowMs,
      generateToken: () => 'same-token',
    }

    const first = await claimIdempotency(store, 'job:ownership', options)
    expect(first).toMatchObject({ status: 'claimed' })

    nowMs = 3_001
    await expect(claimIdempotency(store, 'job:ownership', options)).rejects.toMatchObject({
      name: 'IdempotencyValidationError',
      code: 'reused-token',
      field: 'generateToken',
    })
    expect(store.record()?.token).toBe('same-token')
  })

  it('rejects an empty generated claim token', async () => {
    const store = new MemoryStore()
    await expect(claimIdempotency(store, 'job:empty-token', {
      ttlMs: 10_000,
      staleAfterMs: 5_000,
      now: () => 1_000,
      generateToken: () => '   ',
    })).rejects.toMatchObject({
      name: 'IdempotencyValidationError',
      code: 'invalid-token',
      field: 'generateToken',
    })
    expect(store.record()).toBeNull()
  })

  it('reclaims an expired claim even when the store has not physically evicted it', async () => {
    const store = new MemoryStore()
    let nowMs = 0
    let token = 0
    const options = {
      ttlMs: 1_000,
      staleAfterMs: 1_000,
      now: () => nowMs,
      generateToken: () => `token-${++token}`,
    }

    await claimIdempotency(store, 'job:3', options)
    nowMs = 1_001
    const result = await claimIdempotency(store, 'job:3', options)

    expect(result).toMatchObject({ status: 'claimed', reclaimed: true })
  })

  it('release-on-failure makes the key claimable again', async () => {
    const store = new MemoryStore()
    let token = 0
    const options = {
      ttlMs: 10_000,
      staleAfterMs: 5_000,
      now: () => 1_000,
      generateToken: () => `token-${++token}`,
    }

    const first = await claimIdempotency(store, 'job:4', options)
    if (first.status !== 'claimed') throw new Error('expected claim')
    expect(await releaseIdempotencyClaim(store, first.claim)).toBe(true)

    const second = await claimIdempotency(store, 'job:4', options)
    expect(second).toMatchObject({ status: 'claimed', reclaimed: false })
  })

  it('fails closed when compare-and-swap contention exceeds the retry budget', async () => {
    const store: IdempotencyStore = {
      async read() {
        return null
      },
      async compareAndSwap() {
        return false
      },
    }

    await expect(claimIdempotency(store, 'job:5', {
      ttlMs: 10_000,
      staleAfterMs: 5_000,
      maxAttempts: 2,
      now: () => 1_000,
      generateToken: () => 'token',
    })).rejects.toEqual(expect.objectContaining({
      name: 'IdempotencyContentionError',
      key: 'job:5',
      attempts: 2,
    }))

    await expect(claimIdempotency(store, 'job:5', {
      ttlMs: 10_000,
      staleAfterMs: 5_000,
      maxAttempts: 2,
      now: () => 1_000,
      generateToken: () => 'token',
    })).rejects.toBeInstanceOf(IdempotencyContentionError)
  })

  it('returns typed contextual validation failures before touching the store', async () => {
    const store = new MemoryStore()
    const invalidKey = claimIdempotency(store, '   ', { ttlMs: 1, staleAfterMs: 1 })
    await expect(invalidKey).rejects.toBeInstanceOf(IdempotencyValidationError)
    await expect(invalidKey).rejects.toMatchObject({ code: 'invalid-key', field: 'key' })

    await expect(claimIdempotency(store, 'x', { ttlMs: 0, staleAfterMs: 1 })).rejects.toMatchObject({
      code: 'invalid-duration', field: 'ttlMs',
    })
    await expect(claimIdempotency(store, 'x', { ttlMs: 10, staleAfterMs: 11 })).rejects.toMatchObject({
      code: 'invalid-duration', field: 'staleAfterMs',
    })
    await expect(claimIdempotency(store, 'x', { ttlMs: 10, staleAfterMs: 5, maxAttempts: 1.5 })).rejects.toMatchObject({
      code: 'invalid-attempts', field: 'maxAttempts',
    })
  })

  it('fails typed and closed when the injected clock is non-finite', async () => {
    const store = new MemoryStore()
    await expect(claimIdempotency(store, 'job:clock', {
      ttlMs: 10_000,
      staleAfterMs: 5_000,
      now: () => Number.POSITIVE_INFINITY,
      generateToken: () => 'token',
    })).rejects.toMatchObject({
      name: 'IdempotencyValidationError',
      code: 'invalid-clock',
      field: 'now',
    })
    expect(store.record()).toBeNull()
  })
})
