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

import {ApiError} from '@/lib/errors'
import {
  type Outbox,
  type OutboxEntry,
  type RetryPolicy,
} from '@/lib/outbox'
import {createOutboxRunner} from '@/lib/outbox-runner'

const POLICY: RetryPolicy = {
  maxAttempts: 3,
  base: 1000,
  cap: 20_000,
  floor: 1000,
  // no jitter, so backoff gates are deterministic
  random: () => 1,
}

const NOW = 1000

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

const tick = () => new Promise((r) => setTimeout(r, 0))

// a runner over an in-memory queue + fake handlers + a frozen clock, so every
// ordering / retry / dead-letter path is exercised with no native i/o
const harness = (
  initial: Outbox,
  handlers: Record<string, (payload: unknown) => Promise<void>> = {},
) => {
  let queue = initial
  let settles = 0
  const runner = createOutboxRunner({
    getQueue: () => queue,
    setQueue: (map) => {
      queue = map(queue)
    },
    getHandler: (kind) =>
      handlers[kind] ? {run: handlers[kind]} : undefined,
    now: () => NOW,
    policy: POLICY,
    concurrency: 6,
    describeError: (err) => String(err),
    onSettle: () => {
      settles++
    },
  })
  return {runner, queue: () => queue, settles: () => settles}
}

// a handler that records the payload's id, so tests can assert run order
const recorder = (into: string[]) => async (payload: unknown) => {
  into.push((payload as {id: string}).id)
}

describe('createOutboxRunner drain', () => {
  test('acks and removes an entry whose handler resolves', async () => {
    const ran: string[] = []
    const h = harness([entry({id: 'a', payload: {id: 'a'}})], {
      ok: recorder(ran),
    })
    await h.runner.flush()
    expect(ran).toEqual(['a'])
    expect(h.queue()).toEqual([])
  })

  test('drains one scope in fifo order across re-derives', async () => {
    const ran: string[] = []
    const h = harness(
      [
        entry({
          id: 'a1',
          scope: 's',
          createdAt: 2,
          payload: {id: 'a1'},
        }),
        entry({
          id: 'a0',
          scope: 's',
          createdAt: 1,
          payload: {id: 'a0'},
        }),
      ],
      {ok: recorder(ran)},
    )
    await h.runner.flush()
    // a0 (older) acks first, exposing a1 as the next head — strict order
    expect(ran).toEqual(['a0', 'a1'])
    expect(h.queue()).toEqual([])
  })

  test('drains independent scopes in one pass', async () => {
    const ran: string[] = []
    const h = harness(
      [
        entry({id: 'a', scope: 'sa', payload: {id: 'a'}}),
        entry({id: 'b', scope: 'sb', payload: {id: 'b'}}),
      ],
      {ok: recorder(ran)},
    )
    await h.runner.flush()
    expect([...ran].sort()).toEqual(['a', 'b'])
    expect(h.queue()).toEqual([])
  })

  test('an offline failure retries later, runs once, stays queued', async () => {
    let calls = 0
    const h = harness([entry({id: 'a'})], {
      ok: async () => {
        calls++
        throw new TypeError('Network request failed')
      },
    })
    await h.runner.flush()
    const [e] = h.queue()
    expect(calls).toBe(1) // gated by backoff, does not spin
    expect(e?.dead).toBeUndefined()
    expect(e?.attempts).toBe(1)
    // full-jitter with random()=1 → now + max(floor, base·2^0) = 1000 + 1000
    expect(e?.nextAttemptAt).toBe(NOW + 1000)
  })

  test('a server failure dead-letters once past the attempt cap', async () => {
    const h = harness([entry({id: 'a', attempts: 2})], {
      ok: async () => {
        throw new ApiError(500)
      },
    })
    await h.runner.flush()
    // attempts+1 === maxAttempts(3) → terminal
    expect(h.queue()[0]?.dead).toBe(true)
  })

  test('a terminal 4xx dead-letters immediately', async () => {
    const h = harness([entry({id: 'a'})], {
      ok: async () => {
        throw new ApiError(400)
      },
    })
    await h.runner.flush()
    expect(h.queue()[0]?.dead).toBe(true)
  })

  test('an unknown kind is parked, not run or dropped', async () => {
    const h = harness([entry({id: 'a', kind: 'unregistered'})], {})
    await h.runner.flush()
    const [e] = h.queue()
    expect(e?.dead).toBeUndefined()
    expect(e?.nextAttemptAt).toBe(NOW + POLICY.cap)
    expect(e?.lastError).toBe('no handler')
    // waiting on registration never eats into the retry budget
    expect(e?.attempts).toBe(0)
  })

  test('a dead head blocks its scope but never others', async () => {
    const ran: string[] = []
    const h = harness(
      [
        entry({id: 'd', scope: 's', createdAt: 1, dead: true}),
        entry({
          id: 'after',
          scope: 's',
          createdAt: 2,
          payload: {id: 'after'},
        }),
        entry({
          id: 'other',
          scope: 'x',
          createdAt: 1,
          payload: {id: 'other'},
        }),
      ],
      {ok: recorder(ran)},
    )
    await h.runner.flush()
    // the dead head holds back 'after'; the unrelated scope still drains
    expect(ran).toEqual(['other'])
    expect(
      h
        .queue()
        .map((e) => e.id)
        .sort(),
    ).toEqual(['after', 'd'])
  })

  test('onSettle runs once when the drain settles', async () => {
    const h = harness(
      [entry({id: 'a', scope: 'sa'}), entry({id: 'b', scope: 'sb'})],
      {ok: async () => {}},
    )
    await h.runner.flush()
    expect(h.settles()).toBe(1)
  })

  test('a trigger during a drain re-runs the flush once afterward', async () => {
    let release!: () => void
    const gate = new Promise<void>((r) => (release = r))
    let calls = 0
    const h = harness([entry({id: 'a', kind: 'slow'})], {
      slow: async () => {
        calls++
        await gate
      },
    })
    const first = h.runner.flush()
    await tick() // the handler is now blocked mid-drain
    await h.runner.flush() // flushing → records a rerun, returns at once
    release()
    await first
    await tick()
    expect(calls).toBe(1) // 'a' acked before the rerun, so it runs once
    expect(h.settles()).toBe(2) // the drain, then the rerun
  })
})
