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

import { useAuthStore } from '@/stores/auth'
import { apiClient } from './client'
import { ApiError } from './types'

// Mock fetch globally
const mockFetch = vi.fn()
vi.stubGlobal('fetch', mockFetch)

describe('apiClient', () => {
  beforeEach(() => {
    setActivePinia(createPinia())
    mockFetch.mockReset()
  })

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

  describe('get', () => {
    it('makes GET request to correct URL', async () => {
      /**
       * Test that GET requests are sent to the correct URL.
       *
       * The API client must correctly build URLs from the base URL
       * and path, which is fundamental to all API operations.
       */
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({ id: '1', name: 'Test' }),
      })

      const result = await apiClient.get('/api/users/me')

      expect(mockFetch).toHaveBeenCalledWith(
        expect.stringContaining('/api/users/me'),
        expect.objectContaining({ method: 'GET' })
      )
      expect(result).toEqual({ id: '1', name: 'Test' })
    })

    it('adds query parameters to URL', async () => {
      /**
       * Test that query parameters are correctly appended.
       *
       * Many API endpoints accept query parameters for filtering,
       * pagination, etc. These must be properly encoded.
       */
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({ items: [] }),
      })

      await apiClient.get('/api/cards', { params: { page: 1, limit: 20 } })

      const calledUrl = mockFetch.mock.calls[0][0] as string
      expect(calledUrl).toContain('page=1')
      expect(calledUrl).toContain('limit=20')
    })

    it('skips undefined query parameters', async () => {
      /**
       * Test that undefined parameters are not included in URL.
       *
       * Optional parameters should be omitted entirely rather than
       * being sent as "undefined" strings.
       */
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({}),
      })

      await apiClient.get('/api/cards', {
        params: { page: 1, search: undefined },
      })

      const calledUrl = mockFetch.mock.calls[0][0] as string
      expect(calledUrl).toContain('page=1')
      expect(calledUrl).not.toContain('search')
    })
  })

  describe('post', () => {
    it('makes POST request with JSON body', async () => {
      /**
       * Test that POST requests include JSON body.
       *
       * POST requests typically send data to create resources.
       * The body must be JSON serialized.
       */
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 201,
        json: async () => ({ id: '1' }),
      })

      const body = { name: 'New Deck', cards: [] }
      await apiClient.post('/api/decks', body)

      expect(mockFetch).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({
          method: 'POST',
          body: JSON.stringify(body),
        })
      )
    })
  })

  describe('authentication', () => {
    it('adds Authorization header when authenticated', async () => {
      /**
       * Test that auth header is automatically added.
       *
       * Authenticated requests must include the Bearer token
       * so the backend can identify the user.
       */
      const auth = useAuthStore()
      auth.setTokens({
        accessToken: 'test-token',
        refreshToken: 'refresh-token',
        expiresAt: Date.now() + 3600000,
      })

      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({}),
      })

      await apiClient.get('/api/users/me')

      expect(mockFetch).toHaveBeenCalledWith(
        expect.any(String),
        expect.objectContaining({
          headers: expect.objectContaining({
            Authorization: 'Bearer test-token',
          }),
        })
      )
    })

    it('skips auth header when skipAuth is true', async () => {
      /**
       * Test that skipAuth option prevents auth header.
       *
       * Some endpoints (like login/register) don't need auth
       * and should not send the token.
       */
      const auth = useAuthStore()
      auth.setTokens({
        accessToken: 'test-token',
        refreshToken: 'refresh-token',
        expiresAt: Date.now() + 3600000,
      })

      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({}),
      })

      await apiClient.get('/api/public/health', { skipAuth: true })

      const calledOptions = mockFetch.mock.calls[0][1] as RequestInit
      expect(calledOptions.headers).not.toHaveProperty('Authorization')
    })

    it('does not add auth header when not authenticated', async () => {
      /**
       * Test that unauthenticated requests have no auth header.
       *
       * Without a token, there's nothing to send.
       */
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({}),
      })

      await apiClient.get('/api/public/status')

      const calledOptions = mockFetch.mock.calls[0][1] as RequestInit
      const headers = calledOptions.headers as Record<string, string>
      expect(headers['Authorization']).toBeUndefined()
    })
  })

  describe('error handling', () => {
    it('throws ApiError on non-2xx response', async () => {
      /**
       * Test that API errors are properly thrown.
       *
       * Non-successful responses should throw ApiError with
       * status code and message for proper error handling.
       */
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 404,
        statusText: 'Not Found',
        json: async () => ({ detail: 'Deck not found' }),
      })

      try {
        await apiClient.get('/api/decks/999')
        expect.fail('Should have thrown')
      } catch (e) {
        expect(e).toBeInstanceOf(ApiError)
        const error = e as ApiError
        expect(error.status).toBe(404)
        expect(error.detail).toBe('Deck not found')
        expect(error.isNotFound).toBe(true)
      }
    })

    it('handles non-JSON error responses', async () => {
      /**
       * Test that non-JSON errors are handled gracefully.
       *
       * Some server errors may return HTML or plain text.
       * The client should still create a proper ApiError.
       */
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 500,
        statusText: 'Internal Server Error',
        json: async () => {
          throw new Error('Not JSON')
        },
      })

      await expect(apiClient.get('/api/crash')).rejects.toThrow(ApiError)
    })

    it('handles 204 No Content responses', async () => {
      /**
       * Test that 204 responses return undefined.
       *
       * DELETE and some PUT/PATCH endpoints return 204 with no body.
       * The client should handle this without trying to parse JSON.
       */
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 204,
      })

      const result = await apiClient.delete('/api/decks/1')

      expect(result).toBeUndefined()
    })
  })

  describe('token refresh', () => {
    it('retries request after successful token refresh on 401', async () => {
      /**
       * Test that 401 triggers token refresh and retry.
       *
       * When the access token expires, the client should automatically
       * refresh it and retry the failed request transparently.
       */
      const auth = useAuthStore()
      auth.setTokens({
        accessToken: 'expired-token',
        refreshToken: 'valid-refresh-token',
        expiresAt: Date.now() + 3600000,
      })

      // First call returns 401
      mockFetch.mockResolvedValueOnce({
        ok: false,
        status: 401,
        statusText: 'Unauthorized',
        json: async () => ({ detail: 'Token expired' }),
      })

      // Refresh token call succeeds
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({
          access_token: 'new-token',
          expires_in: 3600,
        }),
      })

      // Retry succeeds
      mockFetch.mockResolvedValueOnce({
        ok: true,
        status: 200,
        json: async () => ({ id: '1', name: 'Test User' }),
      })

      const result = await apiClient.get('/api/users/me')

      expect(mockFetch).toHaveBeenCalledTimes(3)
      expect(result).toEqual({ id: '1', name: 'Test User' })
    })
  })
})
