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

type JsonResponseInit = ResponseInit & {
  status?: number
}

function jsonResponse(data: unknown, init: JsonResponseInit = {}) {
  return new Response(JSON.stringify(data), {
    status: 200,
    headers: {
      'Content-Type': 'application/json',
    },
    ...init,
  })
}

async function loadModules() {
  vi.resetModules()

  const session = await import('@/lib/http/auth-session')
  const storage = await import('@/lib/storage')
  const csrf = await import('@/lib/http/csrf')
  const errors = await import('@/lib/errors')
  const client = await import('@/lib/http/client')

  return {
    ...session,
    ...storage,
    ...csrf,
    ...errors,
    ...client,
  }
}

describe('http client', () => {
  beforeEach(() => {
    vi.clearAllMocks()
    vi.useRealTimers()
    vi.unstubAllEnvs()
    vi.unstubAllGlobals()
    vi.stubGlobal('fetch', vi.fn())
  })

  afterEach(() => {
    vi.useRealTimers()
    vi.unstubAllEnvs()
    vi.unstubAllGlobals()
  })

  it('builds query-string urls and skips undefined params without auth headers', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { ok: true },
      }),
    )

    const { get } = await loadModules()
    const result = await get(
      '/users',
      { page: 2, active: true, keyword: undefined },
      { auth: false },
    )

    expect(result).toEqual({ ok: true })
    expect(fetchMock).toHaveBeenCalledTimes(1)

    const [requestUrl, requestInit] = fetchMock.mock.calls[0]

    expect(String(requestUrl)).toBe(`${window.location.origin}/api/v1/users?page=2&active=true`)
    expect(requestInit?.headers).not.toMatchObject({
      Authorization: expect.any(String),
    })
  })

  it('supports relative api base urls without a leading slash', async () => {
    vi.stubEnv('VITE_API_BASE_URL', 'api/custom')
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { ok: true },
      }),
    )

    const { get } = await loadModules()
    await get('/status', undefined, { auth: false })

    expect(fetchMock).toHaveBeenCalledWith(
      `${window.location.origin}/api/custom/status`,
      expect.any(Object),
    )
  })

  it('supports absolute api base urls', async () => {
    vi.stubEnv('VITE_API_BASE_URL', 'https://api.example.com/base')
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { ok: true },
      }),
    )

    const { get } = await loadModules()
    await get('/status', undefined, { auth: false })

    expect(fetchMock).toHaveBeenCalledWith(
      'https://api.example.com/base/status',
      expect.any(Object),
    )
  })

  it('sends FormData without forcing a JSON content type', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { uploaded: true },
      }),
    )

    const { post } = await loadModules()
    const formData = new FormData()
    formData.append('file', new Blob(['demo'], { type: 'text/plain' }), 'demo.txt')

    const result = await post('/upload', formData, { auth: false })

    expect(result).toEqual({ uploaded: true })
    expect(fetchMock).toHaveBeenCalledTimes(1)

    const [requestUrl, requestInit] = fetchMock.mock.calls[0]
    const headers = requestInit?.headers as Record<string, string> | undefined

    expect(String(requestUrl)).toContain('/api/v1/upload')
    expect(requestInit?.body).toBe(formData)
    expect(requestInit?.credentials).toBe('include')
    expect(headers?.['Content-Type']).toBeUndefined()
  })

  it('adds csrf and json headers for protected write requests', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { saved: true },
      }),
    )

    const { CSRF_HEADER_NAME, put, setCSRFToken } = await loadModules()
    setCSRFToken('csrf-token')

    const result = await put('/users/1', { nickname: 'Demo' }, { auth: false })

    expect(result).toEqual({ saved: true })
    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(fetchMock.mock.calls[0][1]).toMatchObject({
      method: 'PUT',
      body: JSON.stringify({ nickname: 'Demo' }),
      headers: {
        'Content-Type': 'application/json',
        [CSRF_HEADER_NAME]: 'csrf-token',
      },
    })
  })

  it('adds csrf headers to delete requests', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { deleted: true },
      }),
    )

    const { CSRF_HEADER_NAME, del, setCSRFToken } = await loadModules()
    setCSRFToken('csrf-token')

    const result = await del('/users/1', { auth: false })

    expect(result).toEqual({ deleted: true })
    expect(fetchMock.mock.calls[0][1]).toMatchObject({
      method: 'DELETE',
      headers: {
        [CSRF_HEADER_NAME]: 'csrf-token',
      },
    })
  })

  it('refreshes an expired access token before sending the business request', async () => {
    const fetchMock = vi.mocked(fetch)

    fetchMock
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: {
            access_token: 'access-token-new',
            refresh_token: 'refresh-token-new',
            expires_in: 3600,
            user: {
              id: 1,
              username: 'admin',
              email: 'admin@example.com',
              phone: '13800138000',
              nickname: 'Admin',
              avatar: '',
              status: 1,
            },
          },
        }),
      )
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: { ok: true },
        }),
      )

    const { get, setAccessToken, setRefreshToken } = await loadModules()
    setAccessToken('access-token-old', -1)
    setRefreshToken('refresh-token-old')

    const data = await get('/protected')

    expect(data).toEqual({ ok: true })
    expect(fetchMock).toHaveBeenCalledTimes(2)
    expect(String(fetchMock.mock.calls[0][0])).toContain('/api/v1/auth/refresh')
    expect(fetchMock.mock.calls[0][1]).toMatchObject({
      credentials: 'include',
      method: 'POST',
      body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
    })
    expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
      Authorization: 'Bearer access-token-new',
    })
  })

  it('waits for an in-flight refresh promise before sending the request', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { ok: true },
      }),
    )

    const { get, setAccessToken, setRefreshPromise, startRefreshing } = await loadModules()
    setAccessToken('queued-access-token', 3600)
    startRefreshing()
    setRefreshPromise(Promise.resolve())

    const result = await get('/protected')

    expect(result).toEqual({ ok: true })
    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(fetchMock.mock.calls[0][1]?.headers).toMatchObject({
      Authorization: 'Bearer queued-access-token',
    })
  })

  it('clears the local session when refresh fails before the business request is sent', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(new Response(null, { status: 401 }))

    const {
      ErrorType,
      get,
      getAccessToken,
      getRefreshToken,
      setAccessToken,
      setRefreshToken,
    } = await loadModules()
    setAccessToken('expired-access-token', -1)
    setRefreshToken('refresh-token-old')

    await expect(get('/protected')).rejects.toMatchObject({
      status: 401,
      type: ErrorType.AUTH,
    })

    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(getAccessToken()).toBeNull()
    expect(getRefreshToken()).toBeNull()
  })

  it('clears the local session when refresh returns a business error payload', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 10001,
        message: 'refresh failed',
        data: null,
      }),
    )

    const {
      ErrorType,
      get,
      getAccessToken,
      getRefreshToken,
      setAccessToken,
      setRefreshToken,
    } = await loadModules()
    setAccessToken('expired-access-token', -1)
    setRefreshToken('refresh-token-old')

    await expect(get('/protected')).rejects.toMatchObject({
      status: 401,
      type: ErrorType.AUTH,
    })

    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(getAccessToken()).toBeNull()
    expect(getRefreshToken()).toBeNull()
  })

  it('retries once after a 401 response and rotates the in-memory refresh token', async () => {
    const fetchMock = vi.mocked(fetch)
    const capturedHeaders: Array<Record<string, string> | undefined> = []

    fetchMock
      .mockImplementationOnce(async (_url, requestInit) => {
        capturedHeaders.push(
          requestInit?.headers
            ? { ...(requestInit.headers as Record<string, string>) }
            : undefined,
        )
        return new Response(null, { status: 401 })
      })
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: {
            access_token: 'access-token-retried',
            refresh_token: 'refresh-token-retried',
            expires_in: 3600,
            user: {
              id: 1,
              username: 'admin',
              email: 'admin@example.com',
              phone: '13800138000',
              nickname: 'Admin',
              avatar: '',
              status: 1,
            },
          },
        }),
      )
      .mockImplementationOnce(async (_url, requestInit) => {
        capturedHeaders.push(
          requestInit?.headers
            ? { ...(requestInit.headers as Record<string, string>) }
            : undefined,
        )

        return jsonResponse({
          code: 0,
          message: 'ok',
          data: { retried: true },
        })
      })

    const { get, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
    setAccessToken('access-token-old', 3600)
    setRefreshToken('refresh-token-old')

    const data = await get('/protected')

    expect(data).toEqual({ retried: true })
    expect(fetchMock).toHaveBeenCalledTimes(3)
    expect(capturedHeaders[0]).toMatchObject({
      Authorization: 'Bearer access-token-old',
    })
    expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
    expect(fetchMock.mock.calls[1][1]).toMatchObject({
      credentials: 'include',
      method: 'POST',
      body: JSON.stringify({ refresh_token: 'refresh-token-old' }),
    })
    expect(capturedHeaders[1]).toMatchObject({
      Authorization: 'Bearer access-token-retried',
    })
    expect(getRefreshToken()).toBe('refresh-token-retried')
  })

  it('reuses an in-flight refresh token when a 401 retry happens during another refresh', async () => {
    const fetchMock = vi.mocked(fetch)
    const {
      get,
      setAccessToken,
      setRefreshPromise,
      startRefreshing,
    } = await loadModules()

    fetchMock
      .mockImplementationOnce(async () => {
        startRefreshing()
        setAccessToken('shared-refresh-token', 3600)
        setRefreshPromise(Promise.resolve())
        return new Response(null, { status: 401 })
      })
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: { retried: true },
        }),
      )

    setAccessToken('access-token-old', 3600)

    const data = await get('/protected')

    expect(data).toEqual({ retried: true })
    expect(fetchMock).toHaveBeenCalledTimes(2)
    expect(fetchMock.mock.calls[1][1]?.headers).toMatchObject({
      Authorization: 'Bearer shared-refresh-token',
    })
  })

  it('fails the 401 retry when the shared refresh finishes without an access token', async () => {
    const fetchMock = vi.mocked(fetch)
    const {
      clearAccessToken,
      ErrorType,
      get,
      getAccessToken,
      getRefreshToken,
      setAccessToken,
      setRefreshPromise,
      setRefreshToken,
      startRefreshing,
    } = await loadModules()

    fetchMock.mockImplementationOnce(async () => {
      startRefreshing()
      clearAccessToken()
      setRefreshPromise(Promise.resolve())
      return new Response(null, { status: 401 })
    })

    setAccessToken('access-token-old', 3600)
    setRefreshToken('refresh-token-old')

    await expect(get('/protected')).rejects.toMatchObject({
      status: 401,
      type: ErrorType.AUTH,
    })

    expect(fetchMock).toHaveBeenCalledTimes(1)
    expect(getAccessToken()).toBeNull()
    expect(getRefreshToken()).toBeNull()
  })

  it('clears the local session when the retried request still returns 401', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock
      .mockResolvedValueOnce(new Response(null, { status: 401 }))
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: {
            access_token: 'access-token-retried',
            refresh_token: 'refresh-token-retried',
            expires_in: 3600,
            user: {
              id: 1,
              username: 'admin',
              email: 'admin@example.com',
              phone: '13800138000',
              nickname: 'Admin',
              avatar: '',
              status: 1,
            },
          },
        }),
      )
      .mockResolvedValueOnce(new Response(null, { status: 401 }))

    const {
      ErrorType,
      get,
      getAccessToken,
      getRefreshToken,
      setAccessToken,
      setRefreshToken,
    } = await loadModules()
    setAccessToken('access-token-old', 3600)
    setRefreshToken('refresh-token-old')

    await expect(get('/protected')).rejects.toMatchObject({
      status: 401,
      type: ErrorType.AUTH,
    })

    expect(fetchMock).toHaveBeenCalledTimes(3)
    expect(getAccessToken()).toBeNull()
    expect(getRefreshToken()).toBeNull()
  })

  it('maps 403 responses to forbidden errors', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(new Response(null, { status: 403 }))

    const { ErrorType, get } = await loadModules()

    await expect(get('/forbidden', undefined, { auth: false })).rejects.toMatchObject({
      status: 403,
      type: ErrorType.FORBIDDEN,
    })
  })

  it('maps 404 responses to not-found errors', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(new Response(null, { status: 404 }))

    const { ErrorType, get } = await loadModules()

    await expect(get('/missing', undefined, { auth: false })).rejects.toMatchObject({
      status: 404,
      type: ErrorType.NOT_FOUND,
    })
  })

  it('maps other non-ok responses to network errors', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))

    const { ErrorType, get } = await loadModules()

    await expect(get('/broken', undefined, { auth: false })).rejects.toMatchObject({
      status: 0,
      type: ErrorType.NETWORK,
    })
  })

  it('maps non-zero business responses to AppError.fromResponse', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 10001,
        message: 'business failure',
        data: null,
      }),
    )

    const { ErrorType, get } = await loadModules()

    await expect(get('/business', undefined, { auth: false })).rejects.toMatchObject({
      code: 10001,
      status: 200,
      type: ErrorType.BUSINESS,
    })
  })

  it('converts aborted requests into timeout AppErrors', async () => {
    vi.useFakeTimers()
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockImplementation(
      (_url, requestInit) =>
        new Promise((_, reject) => {
          ;(requestInit?.signal as AbortSignal).addEventListener(
            'abort',
            () => reject(new DOMException('Aborted', 'AbortError')),
            { once: true },
          )
        }),
    )

    const { ErrorType, request } = await loadModules()
    const requestPromise = expect(request('/slow', { auth: false })).rejects.toMatchObject({
      status: 0,
      type: ErrorType.NETWORK,
    })

    await vi.advanceTimersByTimeAsync(30_000)
    await requestPromise
  })

  it('propagates a caller abort signal into the request timeout controller', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockImplementation(
      (_url, requestInit) =>
        new Promise((_, reject) => {
          ;(requestInit?.signal as AbortSignal).addEventListener(
            'abort',
            () => reject(new DOMException('Aborted', 'AbortError')),
            { once: true },
          )
        }),
    )

    const controller = new AbortController()
    const { ErrorType, request } = await loadModules()
    const requestPromise = expect(
      request('/slow', { auth: false, signal: controller.signal }),
    ).rejects.toMatchObject({
      status: 0,
      type: ErrorType.NETWORK,
    })

    await Promise.resolve()
    controller.abort()
    await requestPromise
  })

  it('retries downloads after a 401 and returns the blob payload', async () => {
    const fetchMock = vi.mocked(fetch)
    const downloadedBlob = { kind: 'downloaded-blob' } as unknown as Blob
    const successResponse = {
      ok: true,
      status: 200,
      blob: vi.fn().mockResolvedValue(downloadedBlob),
    } as unknown as Response

    fetchMock
      .mockResolvedValueOnce(new Response(null, { status: 401 }))
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: {
            access_token: 'download-access-token',
            refresh_token: 'download-refresh-token',
            expires_in: 3600,
            user: {
              id: 1,
              username: 'admin',
              email: 'admin@example.com',
              phone: '13800138000',
              nickname: 'Admin',
              avatar: '',
              status: 1,
            },
          },
        }),
      )
      .mockResolvedValueOnce(successResponse)

    const { download, getRefreshToken, setAccessToken, setRefreshToken } = await loadModules()
    setAccessToken('access-token-old', 3600)
    setRefreshToken('refresh-token-old')

    const blob = await download('/export')

    expect(blob).toBe(downloadedBlob)
    expect(fetchMock).toHaveBeenCalledTimes(3)
    expect(String(fetchMock.mock.calls[1][0])).toContain('/api/v1/auth/refresh')
    expect(fetchMock.mock.calls[2][1]?.headers).toMatchObject({
      Authorization: 'Bearer download-access-token',
    })
    expect(getRefreshToken()).toBe('download-refresh-token')
  })

  it('maps failed downloads to network AppErrors', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(new Response(null, { status: 500 }))

    const { ErrorType, download } = await loadModules()

    await expect(download('/export', undefined, { auth: false })).rejects.toMatchObject({
      status: 0,
      type: ErrorType.NETWORK,
    })
  })

  it('clears the local session when a download retry still returns 401', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock
      .mockResolvedValueOnce(new Response(null, { status: 401 }))
      .mockResolvedValueOnce(
        jsonResponse({
          code: 0,
          message: 'ok',
          data: {
            access_token: 'download-access-token',
            refresh_token: 'download-refresh-token',
            expires_in: 3600,
            user: {
              id: 1,
              username: 'admin',
              email: 'admin@example.com',
              phone: '13800138000',
              nickname: 'Admin',
              avatar: '',
              status: 1,
            },
          },
        }),
      )
      .mockResolvedValueOnce(new Response(null, { status: 401 }))

    const {
      ErrorType,
      download,
      getAccessToken,
      getRefreshToken,
      setAccessToken,
      setRefreshToken,
    } = await loadModules()
    setAccessToken('access-token-old', 3600)
    setRefreshToken('refresh-token-old')

    await expect(download('/export')).rejects.toMatchObject({
      status: 401,
      type: ErrorType.AUTH,
    })

    expect(fetchMock).toHaveBeenCalledTimes(3)
    expect(getAccessToken()).toBeNull()
    expect(getRefreshToken()).toBeNull()
  })

  it('converts aborted downloads into timeout AppErrors', async () => {
    vi.useFakeTimers()
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockImplementation(
      (_url, requestInit) =>
        new Promise((_, reject) => {
          ;(requestInit?.signal as AbortSignal).addEventListener(
            'abort',
            () => reject(new DOMException('Aborted', 'AbortError')),
            { once: true },
          )
        }),
    )

    const { ErrorType, download } = await loadModules()
    const downloadPromise = expect(
      download('/export', undefined, { auth: false }),
    ).rejects.toMatchObject({
      status: 0,
      type: ErrorType.NETWORK,
    })

    await vi.advanceTimersByTimeAsync(30_000)
    await downloadPromise
  })

  it('builds upload form data with additional fields', async () => {
    const fetchMock = vi.mocked(fetch)
    fetchMock.mockResolvedValueOnce(
      jsonResponse({
        code: 0,
        message: 'ok',
        data: { uploaded: true },
      }),
    )

    const { upload } = await loadModules()
    const file = new File(['demo'], 'avatar.png', { type: 'image/png' })

    const result = await upload(
      '/upload',
      file,
      'asset',
      { folder: 'avatars' },
      { auth: false },
    )

    expect(result).toEqual({ uploaded: true })
    expect(fetchMock).toHaveBeenCalledTimes(1)

    const requestInit = fetchMock.mock.calls[0][1]
    const body = requestInit?.body as FormData

    expect(requestInit?.method).toBe('POST')
    expect(body.get('folder')).toBe('avatars')
    expect(body.get('asset')).toBeInstanceOf(File)
    expect((body.get('asset') as File).name).toBe('avatar.png')
  })
})
