import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { configure, request } from '@/api/client'
import { ApiException } from '@/api/types'

describe('api client', () => {
  let unauthorizedCalled = 0

  beforeEach(() => {
    unauthorizedCalled = 0
    configure({
      tokenProvider: () => 'tok',
      onUnauthorized: () => {
        unauthorizedCalled++
      },
    })
  })

  afterEach(() => {
    vi.restoreAllMocks()
  })

  it('attaches bearer token and parses JSON on 200', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ x: 1 }), { status: 200 }),
    )
    const out = await request<{ x: number }>('/api/test')
    expect(out.x).toBe(1)
    const init = fetchMock.mock.calls[0][1] as RequestInit
    const headers = init.headers as Record<string, string>
    expect(headers['Authorization']).toBe('Bearer tok')
    expect(headers['X-Client-Request-ID']).toBeDefined()
  })

  it('returns undefined on 204', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(new Response(null, { status: 204 }))
    const out = await request('/api/test', { method: 'DELETE' })
    expect(out).toBeUndefined()
  })

  it('invokes onUnauthorized for 401 and throws', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ code: 'unauthorized', message: 'no' }), { status: 401 }),
    )
    await expect(request('/api/test')).rejects.toBeInstanceOf(ApiException)
    expect(unauthorizedCalled).toBe(1)
  })

  it('throws ApiException with body for 4xx/5xx', async () => {
    vi.spyOn(globalThis, 'fetch').mockResolvedValue(
      new Response(JSON.stringify({ code: 'invalid_input', message: 'bad' }), { status: 400 }),
    )
    try {
      await request('/api/test')
      throw new Error('should have thrown')
    } catch (e) {
      const ex = e as ApiException
      expect(ex.status).toBe(400)
      expect(ex.body.code).toBe('invalid_input')
    }
  })

  it('schedules a default 30s timeout and forwards abort to fetch', async () => {
    // Replace setTimeout with a stub that captures the scheduled callback,
    // so we can assert the default-timeout scheduling and trigger it manually
    // without waiting wall-clock 30 seconds.
    const captured: Array<{ cb: () => void; ms: number }> = []
    const realSetTimeout = globalThis.setTimeout
    vi.stubGlobal('setTimeout', ((cb: () => void, ms?: number, ...args: unknown[]) => {
      if (ms === 30_000) {
        captured.push({ cb, ms })
        return 0 as unknown as ReturnType<typeof setTimeout>
      }
      return realSetTimeout(cb, ms, ...args)
    }) as typeof setTimeout)

    try {
      const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(
        (_input, init) =>
          new Promise((_resolve, reject) => {
            const sig = init?.signal as AbortSignal | undefined
            sig?.addEventListener('abort', () => {
              const err = new Error('aborted')
              err.name = 'AbortError'
              reject(err)
            })
          }),
      )

      let rejected: unknown = null
      const promise = request('/api/test').catch((e) => {
        rejected = e
      })

      // Allow the request to register its setTimeout + start the fetch.
      await Promise.resolve()

      expect(captured.length).toBe(1)
      expect(captured[0].ms).toBe(30_000)

      // Fire the captured timeout callback to simulate elapsed 30s.
      captured[0].cb()
      await promise

      expect(rejected).not.toBeNull()
      const init = fetchMock.mock.calls[0][1] as RequestInit
      expect((init.signal as AbortSignal).aborted).toBe(true)
    } finally {
      vi.unstubAllGlobals()
    }
  })

  it('forwards a caller-supplied signal abort to the fetch', async () => {
    const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation(
      (_input, init) =>
        new Promise((_resolve, reject) => {
          const sig = init?.signal as AbortSignal | undefined
          if (sig) {
            sig.addEventListener('abort', () => {
              const err = new Error('aborted')
              err.name = 'AbortError'
              reject(err)
            })
          }
        }),
    )

    const ctl = new AbortController()
    const promise = request('/api/test', { signal: ctl.signal })
    const settled = expect(promise).rejects.toThrow()
    ctl.abort()
    await settled

    const init = fetchMock.mock.calls[0][1] as RequestInit
    expect((init.signal as AbortSignal).aborted).toBe(true)
  })
})
