import {describe, expect, test} from 'bun:test'

import {ApiError} from '@/lib/errors'
import {
  backoffDelay,
  classifyFailure,
  deadEntries,
  deadLetter,
  enqueue,
  lastInScope,
  nextAttempt,
  nextWakeAt,
  park,
  pendingCount,
  readyHeads,
  redrive,
  remove,
  retryLater,
  scopeMatches,
  type OutboxEntry,
} from '@/lib/outbox'

const entry = (over: Partial<OutboxEntry> = {}): OutboxEntry => ({
  id: 'm1',
  kind: 'crumb.create',
  scope: 'c1',
  payload: {},
  attempts: 0,
  nextAttemptAt: 0,
  createdAt: 0,
  ...over,
})

describe('outbox transitions', () => {
  test('enqueue appends without mutating', () => {
    const queue = [entry({id: 'a'})]
    const next = enqueue(queue, entry({id: 'b'}))
    expect(next.map((e) => e.id)).toEqual(['a', 'b'])
    expect(queue).toHaveLength(1)
  })

  test('remove drops an acked entry', () => {
    const queue = [entry({id: 'a'}), entry({id: 'b'})]
    expect(remove(queue, 'a').map((e) => e.id)).toEqual(['b'])
  })

  test('retryLater bumps attempts and gates the next try', () => {
    const [e] = retryLater([entry()], 'm1', 5000, 'offline')
    expect(e).toMatchObject({
      attempts: 1,
      nextAttemptAt: 5000,
      lastError: 'offline',
    })
  })

  test('park gates the next try without burning an attempt', () => {
    const [e] = park([entry({attempts: 2})], 'm1', 5000, 'no handler')
    expect(e).toMatchObject({
      attempts: 2,
      nextAttemptAt: 5000,
      lastError: 'no handler',
    })
  })

  test('deadLetter parks an entry, redrive resurrects it', () => {
    const dead = deadLetter(
      [entry({attempts: 6})],
      'm1',
      'bad_request',
    )
    expect(dead[0]).toMatchObject({
      dead: true,
      lastError: 'bad_request',
    })
    const live = redrive(dead, 'm1')
    expect(live[0]).toMatchObject({
      dead: false,
      attempts: 0,
      nextAttemptAt: 0,
    })
    expect(live[0]!.lastError).toBeUndefined()
  })
})

describe('readyHeads', () => {
  test('yields the oldest live entry of each scope', () => {
    const queue = [
      entry({id: 'a1', scope: 'a', createdAt: 2}),
      entry({id: 'a0', scope: 'a', createdAt: 1}),
      entry({id: 'b0', scope: 'b', createdAt: 5}),
    ]
    expect(
      readyHeads(queue, 100)
        .map((e) => e.id)
        .sort(),
    ).toEqual(['a0', 'b0'])
  })

  test('a backed-off head holds back its whole scope', () => {
    const queue = [
      entry({id: 'a0', scope: 'a', createdAt: 1, nextAttemptAt: 900}),
      entry({id: 'a1', scope: 'a', createdAt: 2}),
    ]
    // even though a1 is eligible by time, a0 is the head and still in backoff
    expect(readyHeads(queue, 500)).toEqual([])
    expect(readyHeads(queue, 1000).map((e) => e.id)).toEqual(['a0'])
  })

  test('a dead head blocks its scope but not others', () => {
    const queue = [
      entry({id: 'a0', scope: 'a', createdAt: 1, dead: true}),
      entry({id: 'a1', scope: 'a', createdAt: 2}),
      entry({id: 'b0', scope: 'b', createdAt: 1}),
    ]
    expect(readyHeads(queue, 100).map((e) => e.id)).toEqual(['b0'])
  })
})

describe('backoffDelay', () => {
  test('grows exponentially and is bounded by the cap', () => {
    const opts = {base: 1000, cap: 20_000, random: () => 1}
    expect(backoffDelay(0, opts)).toBe(1000)
    expect(backoffDelay(3, opts)).toBe(8000)
    // 1000·2^5 = 32000, capped to 20000
    expect(backoffDelay(5, opts)).toBe(20_000)
  })

  test('jitter scales the delay down toward zero', () => {
    expect(
      backoffDelay(4, {base: 1000, cap: 20_000, random: () => 0}),
    ).toBe(0)
    expect(
      backoffDelay(4, {base: 1000, cap: 20_000, random: () => 0.5}),
    ).toBe(8000)
  })
})

describe('classifyFailure', () => {
  test('a network failure is offline — no verdict, retry forever', () => {
    // fetch rejects with a TypeError offline: the request never reached
    // a server
    expect(
      classifyFailure(new TypeError('Network request failed')),
    ).toBe('offline')
    expect(classifyFailure(new TypeError('Failed to fetch'))).toBe(
      'offline',
    )
  })

  test('an unknown throw is server — capped retries, never wedges', () => {
    // a programming error or fs failure recurs identically on every
    // replay; classing it offline would retry forever and block its scope
    expect(classifyFailure(new Error('boom'))).toBe('server')
    expect(classifyFailure(new TypeError('x is undefined'))).toBe(
      'server',
    )
    expect(classifyFailure(undefined)).toBe('server')
  })

  test('a 4xx the client owns is terminal — resending will not help', () => {
    for (const status of [400, 401, 403, 404, 409, 422])
      expect(classifyFailure(new ApiError(status))).toBe('terminal')
  })

  test('a 5xx and the two transient 4xx are server — retryable', () => {
    for (const status of [500, 502, 503, 408, 429])
      expect(classifyFailure(new ApiError(status))).toBe('server')
  })
})

describe('nextAttempt', () => {
  const policy = {
    maxAttempts: 5,
    base: 1000,
    cap: 20_000,
    floor: 1000,
    random: () => 1,
  }

  test('offline retries forever, never dead-letters', () => {
    // even past the attempt cap, an offline mutation keeps waiting
    const e = entry({attempts: 99})
    expect(nextAttempt(e, 'offline', 0, policy)).toEqual({
      retry: 20_000,
    })
  })

  test('server errors retry until the cap, then dead-letter', () => {
    expect(
      nextAttempt(entry({attempts: 3}), 'server', 0, policy),
    ).toEqual({retry: 8000})
    // attempts+1 === maxAttempts → give up
    expect(
      nextAttempt(entry({attempts: 4}), 'server', 0, policy),
    ).toEqual({
      dead: true,
    })
  })

  test('terminal errors dead-letter immediately', () => {
    expect(nextAttempt(entry(), 'terminal', 0, policy)).toEqual({
      dead: true,
    })
  })

  test('the floor guards against a zero-jitter hot loop', () => {
    const at = nextAttempt(entry({attempts: 0}), 'offline', 500, {
      ...policy,
      random: () => 0,
    })
    expect(at).toEqual({retry: 500 + 1000})
  })
})

describe('lastInScope', () => {
  test("true when the running entry is its scope's only one", () => {
    const queue = [
      entry({id: 'a', scope: 's'}),
      entry({id: 'b', scope: 'other'}),
    ]
    expect(lastInScope(queue, 's')).toBe(true)
  })

  test('false while queued followers remain — their optimistic state is fresher', () => {
    const queue = [
      entry({id: 'create', scope: 's', createdAt: 1}),
      entry({id: 'rename', scope: 's', createdAt: 2}),
    ]
    expect(lastInScope(queue, 's')).toBe(false)
  })

  test('a dead follower also suppresses the reconcile', () => {
    const queue = [
      entry({id: 'create', scope: 's', createdAt: 1}),
      entry({id: 'rename', scope: 's', createdAt: 2, dead: true}),
    ]
    expect(lastInScope(queue, 's')).toBe(false)
  })
})

describe('scopeMatches', () => {
  test('matches a bare scope exactly', () => {
    expect(scopeMatches('t1', 't1')).toBe(true)
    expect(scopeMatches('t1', 't2')).toBe(false)
  })

  test('matches any segment of a composite scope', () => {
    expect(scopeMatches('t1:c1', 't1')).toBe(true)
    expect(scopeMatches('t1:c1', 'c1')).toBe(true)
    expect(scopeMatches('t1:c1', 'c2')).toBe(false)
  })
})

describe('selectors', () => {
  test('nextWakeAt is the soonest live gate, ignoring dead', () => {
    const queue = [
      entry({id: 'a', scope: 'a', nextAttemptAt: 5000}),
      entry({id: 'b', scope: 'b', nextAttemptAt: 2000}),
      entry({id: 'c', scope: 'c', nextAttemptAt: 100, dead: true}),
    ]
    expect(nextWakeAt(queue)).toBe(2000)
    expect(nextWakeAt([entry({dead: true})])).toBeUndefined()
    expect(nextWakeAt([])).toBeUndefined()
  })

  test("nextWakeAt is the head's gate, not a follower's", () => {
    // only the scope head is runnable: a zero-gated follower behind a
    // backed-off head must not wake the runner into a hot loop
    const queue = [
      entry({id: 'a0', scope: 'a', createdAt: 1, nextAttemptAt: 900}),
      entry({id: 'a1', scope: 'a', createdAt: 2}),
    ]
    expect(nextWakeAt(queue)).toBe(900)
  })

  test('a dead head schedules no wake for its followers', () => {
    // the follower can never run until a manual redrive, which flushes
    // explicitly — a timer would spin on its zero gate forever
    const queue = [
      entry({id: 'a0', scope: 'a', createdAt: 1, dead: true}),
      entry({id: 'a1', scope: 'a', createdAt: 2}),
    ]
    expect(nextWakeAt(queue)).toBeUndefined()
  })

  test('nextWakeAt is the soonest gate across scope heads', () => {
    const queue = [
      entry({
        id: 'a0',
        scope: 'a',
        createdAt: 1,
        nextAttemptAt: 3000,
      }),
      entry({
        id: 'b0',
        scope: 'b',
        createdAt: 1,
        nextAttemptAt: 1500,
      }),
    ]
    expect(nextWakeAt(queue)).toBe(1500)
  })

  test('pendingCount and deadEntries split live from parked', () => {
    const queue = [
      entry({id: 'a'}),
      entry({id: 'b', dead: true}),
      entry({id: 'c'}),
    ]
    expect(pendingCount(queue)).toBe(2)
    expect(deadEntries(queue).map((e) => e.id)).toEqual(['b'])
  })
})
