import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

import { ApiError, UNAUTHENTICATED_EVENT, api } from './api'

/**
 * The API client is the single seam between the SPA and every marketplace
 * operation, so its error translation, CSRF handling and session-expiry
 * behaviour are worth pinning. None of this was covered before -- the frontend
 * had no tests at all.
 */

/**
 * Reads one recorded fetch call.
 *
 * `noUncheckedIndexedAccess` is on, and rightly so -- but asserting on a call
 * that was never made should fail with a clear message rather than a type error,
 * so the lookup is checked once here.
 */
function callAt(mock: ReturnType<typeof vi.fn>, index: number): [string, RequestInit] {
  const call = mock.mock.calls[index]

  if (call === undefined) {
    throw new Error(`Expected a fetch call at index ${index}, but only ${mock.mock.calls.length} were made.`)
  }

  return call as [string, RequestInit]
}

function jsonResponse(body: unknown, status = 200, headers: Record<string, string> = {}): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { 'Content-Type': 'application/json', ...headers },
  })
}

describe('api client', () => {
  let fetchMock: ReturnType<typeof vi.fn>

  beforeEach(() => {
    document.cookie = 'XSRF-TOKEN=test-token; path=/'
    fetchMock = vi.fn()
    vi.stubGlobal('fetch', fetchMock)
  })

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

  it('sends credentials and asks for JSON', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ data: [] }))

    await api.get('/tlds')

    const [url, init] = callAt(fetchMock, 0)
    const headers = init.headers as Record<string, string>

    expect(url).toBe('/api/v1/tlds')
    expect(init.credentials).toBe('include')
    expect(headers.Accept).toBe('application/json')
    // Without this, Laravel answers auth failures with a redirect, not a 401.
    expect(headers['X-Requested-With']).toBe('XMLHttpRequest')
  })

  it('echoes the CSRF cookie back as a header on writes', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ ok: true }))

    await api.post('/account/seller', { display_name: 'Everest' })

    const [, init] = callAt(fetchMock, 0)
    expect((init.headers as Record<string, string>)['X-XSRF-TOKEN']).toBe('test-token')
    expect(init.method).toBe('POST')
    expect(init.body).toBe(JSON.stringify({ display_name: 'Everest' }))
  })

  it('does not send a CSRF header on reads', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ data: [] }))

    await api.get('/plans')

    const [, init] = callAt(fetchMock, 0)
    expect((init.headers as Record<string, string>)['X-XSRF-TOKEN']).toBeUndefined()
  })

  it('drops empty query parameters instead of sending blanks', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ data: [] }))

    await api.get('/domains', { query: { q: 'coffee', page: 2, tld: null, category: undefined, sort: '' } })

    expect(callAt(fetchMock, 0)[0]).toBe('/api/v1/domains?q=coffee&page=2')
  })

  it('turns a validation failure into field errors', async () => {
    fetchMock.mockResolvedValue(
      jsonResponse(
        { message: 'Check the fields.', errors: { email: ['That address is taken.'] } },
        422,
      ),
    )

    const error = await api.post('/auth/register', {}).catch((e: unknown) => e)

    expect(error).toBeInstanceOf(ApiError)
    const apiError = error as ApiError
    expect(apiError.isValidation).toBe(true)
    expect(apiError.errorFor('email')).toBe('That address is taken.')
    expect(apiError.errorFor('name')).toBeUndefined()
  })

  it('surfaces a two-factor challenge as its own signal, not a failure', async () => {
    fetchMock.mockResolvedValue(
      jsonResponse({ message: 'Enter your code.', two_factor_required: true }, 423),
    )

    const error = (await api.post('/auth/login', {}).catch((e: unknown) => e)) as ApiError

    expect(error.twoFactorRequired).toBe(true)
    expect(error.isValidation).toBe(false)
  })

  it('carries the account standing from a 403', async () => {
    fetchMock.mockResolvedValue(
      jsonResponse({ message: 'This account is suspended.', status: 'suspended' }, 403),
    )

    const error = (await api.get('/account').catch((e: unknown) => e)) as ApiError

    expect(error.isForbidden).toBe(true)
    expect(error.accountStatus).toBe('suspended')
  })

  it('reports a throttled request distinctly', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ message: 'Slow down.', retry_after: 42 }, 429))

    const error = (await api.post('/auth/login', {}).catch((e: unknown) => e)) as ApiError

    expect(error.isThrottled).toBe(true)
    expect(error.retryAfter).toBe(42)
  })

  /**
   * A long-idle tab is the ordinary cause of a 419, so the client refreshes the
   * token and retries once rather than showing a confusing failure.
   */
  it('refreshes the CSRF token and retries once on a 419', async () => {
    fetchMock
      .mockResolvedValueOnce(jsonResponse({ message: 'CSRF token mismatch.' }, 419))
      .mockResolvedValueOnce(new Response(null, { status: 204 }))
      .mockResolvedValueOnce(jsonResponse({ ok: true }))

    const result = await api.post<{ ok: boolean }>('/account/seller', {})

    expect(result.ok).toBe(true)
    // The failed write, the cookie refresh, then the successful retry.
    expect(fetchMock).toHaveBeenCalledTimes(3)
    expect(callAt(fetchMock, 1)[0]).toBe('/sanctum/csrf-cookie')
  })

  it('does not retry a 419 more than once', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ message: 'CSRF token mismatch.' }, 419))

    const error = (await api.post('/account/seller', {}).catch((e: unknown) => e)) as ApiError

    expect(error.status).toBe(419)
  })

  /**
   * A session that expired server-side must not leave the UI showing a
   * signed-in shell whose every action fails.
   */
  it('broadcasts an event on 401 so auth state can be cleared', async () => {
    fetchMock.mockResolvedValue(jsonResponse({ message: 'Unauthenticated.' }, 401))

    const listener = vi.fn()
    window.addEventListener(UNAUTHENTICATED_EVENT, listener)

    await api.get('/account').catch(() => undefined)

    expect(listener).toHaveBeenCalledOnce()
    window.removeEventListener(UNAUTHENTICATED_EVENT, listener)
  })

  it('reports an unreachable server as a connection problem, not a crash', async () => {
    fetchMock.mockRejectedValue(new TypeError('Failed to fetch'))

    const error = (await api.get('/tlds').catch((e: unknown) => e)) as ApiError

    expect(error).toBeInstanceOf(ApiError)
    expect(error.status).toBe(0)
    expect(error.message).toContain('Cannot reach')
  })

  /**
   * An abort is the caller's own doing, so it must pass through untouched for
   * React Query to treat as a cancellation rather than a failure.
   */
  it('passes an abort through rather than wrapping it', async () => {
    fetchMock.mockRejectedValue(new DOMException('Aborted', 'AbortError'))

    const error = await api.get('/tlds').catch((e: unknown) => e)

    expect(error).toBeInstanceOf(DOMException)
    expect(error).not.toBeInstanceOf(ApiError)
  })

  it('supplies a default message when the server sends none', async () => {
    fetchMock.mockResolvedValue(new Response('', { status: 500 }))

    const error = (await api.get('/tlds').catch((e: unknown) => e)) as ApiError

    expect(error.message).toContain('went wrong on our side')
  })

  it('returns null for an empty 204 response', async () => {
    fetchMock.mockResolvedValue(new Response(null, { status: 204 }))

    await expect(api.delete('/account/devices/1')).resolves.toBeNull()
  })
})
