import { Readable, Stream } from 'node:stream'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
  APIError,
  AuthenticationError,
  FileUploadError,
  NetworkError,
  RateLimitError,
  TimeoutError,
  ValidationError,
} from '../errors/dify-error'
import { HttpClient } from './client'

const stubFetch = (): ReturnType<typeof vi.fn> => {
  const fetchMock = vi.fn()
  vi.stubGlobal('fetch', fetchMock)
  return fetchMock
}

const getFetchCall = (
  fetchMock: ReturnType<typeof vi.fn>,
  index = 0,
): [string, RequestInit | undefined] => {
  const call = fetchMock.mock.calls[index]
  if (!call) {
    throw new Error(`Missing fetch call at index ${index}`)
  }
  return call as [string, RequestInit | undefined]
}

const getDuplex = (init: RequestInit | undefined) =>
  init && 'duplex' in init ? init.duplex : undefined

const toHeaderRecord = (headers: HeadersInit | undefined): Record<string, string> =>
  Object.fromEntries(new Headers(headers).entries())

const jsonResponse = (body: unknown, init: ResponseInit = {}): Response =>
  new Response(JSON.stringify(body), {
    ...init,
    headers: {
      'content-type': 'application/json',
      ...init.headers,
    },
  })

const textResponse = (body: string, init: ResponseInit = {}): Response =>
  new Response(body, {
    ...init,
    headers: {
      ...init.headers,
    },
  })

describe('HttpClient', () => {
  beforeEach(() => {
    vi.restoreAllMocks()
    vi.unstubAllGlobals()
  })

  it('builds requests with auth headers and JSON content type', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      jsonResponse({ ok: true }, { status: 200, headers: { 'x-request-id': 'req' } }),
    )

    const client = new HttpClient({ apiKey: 'test' })
    const response = await client.request({
      method: 'POST',
      path: '/chat-messages',
      data: { user: 'u' },
    })

    expect(response.requestId).toBe('req')
    expect(fetchMock).toHaveBeenCalledTimes(1)
    const [url, init] = getFetchCall(fetchMock)
    expect(url).toBe('https://api.dify.ai/v1/chat-messages')
    expect(toHeaderRecord(init?.headers)).toMatchObject({
      authorization: 'Bearer test',
      'content-type': 'application/json',
      'user-agent': 'dify-client-node',
    })
    expect(init?.body).toBe(JSON.stringify({ user: 'u' }))
  })

  it('serializes array query params', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))

    const client = new HttpClient({ apiKey: 'test' })
    await client.requestRaw({
      method: 'GET',
      path: '/datasets',
      query: { tag_ids: ['a', 'b'], limit: 2 },
    })

    const [url] = getFetchCall(fetchMock)
    expect(new URL(url).searchParams.toString()).toBe('tag_ids=a&tag_ids=b&limit=2')
  })

  it('returns SSE stream helpers', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      new Response('data: {"text":"hi"}\n\n', {
        status: 200,
        headers: { 'x-request-id': 'req' },
      }),
    )

    const client = new HttpClient({ apiKey: 'test' })
    const stream = await client.requestStream({
      method: 'POST',
      path: '/chat-messages',
      data: { user: 'u' },
    })

    expect(stream.status).toBe(200)
    expect(stream.requestId).toBe('req')
    await expect(stream.toText()).resolves.toBe('hi')
  })

  it('returns binary stream helpers', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      new Response('chunk', {
        status: 200,
        headers: { 'x-request-id': 'req' },
      }),
    )

    const client = new HttpClient({ apiKey: 'test' })
    const stream = await client.requestBinaryStream({
      method: 'POST',
      path: '/text-to-audio',
      data: { user: 'u', text: 'hi' },
    })

    expect(stream.status).toBe(200)
    expect(stream.requestId).toBe('req')
    expect(stream.data).toBeInstanceOf(Readable)
  })

  it('respects form-data headers', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))

    const client = new HttpClient({ apiKey: 'test' })
    const form = new FormData()
    form.append('file', new Blob(['abc']), 'file.txt')

    await client.requestRaw({
      method: 'POST',
      path: '/files/upload',
      data: form,
    })

    const [, init] = getFetchCall(fetchMock)
    expect(toHeaderRecord(init?.headers)).toMatchObject({
      authorization: 'Bearer test',
    })
    expect(toHeaderRecord(init?.headers)['content-type']).toBeUndefined()
  })

  it('sends legacy form-data as a readable request body', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))

    const client = new HttpClient({ apiKey: 'test' })
    const legacyForm = Object.assign(Readable.from(['chunk']), {
      append: vi.fn(),
      getHeaders: () => ({
        'content-type': 'multipart/form-data; boundary=test',
      }),
    })

    await client.requestRaw({
      method: 'POST',
      path: '/files/upload',
      data: legacyForm,
    })

    const [, init] = getFetchCall(fetchMock)
    expect(toHeaderRecord(init?.headers)).toMatchObject({
      authorization: 'Bearer test',
      'content-type': 'multipart/form-data; boundary=test',
    })
    expect(getDuplex(init)).toBe('half')
    expect(init?.body).not.toBe(legacyForm)
  })

  it('rejects legacy form-data objects that are not readable streams', async () => {
    const fetchMock = stubFetch()
    const client = new HttpClient({ apiKey: 'test' })
    const legacyForm = {
      append: vi.fn(),
      getHeaders: () => ({
        'content-type': 'multipart/form-data; boundary=test',
      }),
    }

    await expect(
      client.requestRaw({
        method: 'POST',
        path: '/files/upload',
        data: legacyForm,
      }),
    ).rejects.toBeInstanceOf(FileUploadError)

    expect(fetchMock).not.toHaveBeenCalled()
  })

  it('accepts legacy pipeable streams that are not Readable instances', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))
    const client = new HttpClient({ apiKey: 'test' })

    const legacyStream = new Stream() as Stream &
      NodeJS.ReadableStream & {
        append: ReturnType<typeof vi.fn>
        getHeaders: () => Record<string, string>
      }
    legacyStream.readable = true
    legacyStream.pause = () => legacyStream
    legacyStream.resume = () => legacyStream
    legacyStream.append = vi.fn()
    legacyStream.getHeaders = () => ({
      'content-type': 'multipart/form-data; boundary=test',
    })
    queueMicrotask(() => {
      legacyStream.emit('data', Buffer.from('chunk'))
      legacyStream.emit('end')
    })

    await client.requestRaw({
      method: 'POST',
      path: '/files/upload',
      data: legacyStream as unknown as FormData,
    })

    const [, init] = getFetchCall(fetchMock)
    expect(getDuplex(init)).toBe('half')
  })

  it('returns buffers for byte responses', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      new Response(Uint8Array.from([1, 2, 3]), {
        status: 200,
        headers: { 'content-type': 'application/octet-stream' },
      }),
    )

    const client = new HttpClient({ apiKey: 'test' })
    const response = await client.request<Buffer, 'bytes'>({
      method: 'GET',
      path: '/files/file-1/preview',
      responseType: 'bytes',
    })

    expect(Buffer.isBuffer(response.data)).toBe(true)
    expect(Array.from(response.data.values())).toEqual([1, 2, 3])
  })

  it('keeps arraybuffer as a backward-compatible binary alias', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      new Response(Uint8Array.from([4, 5, 6]), {
        status: 200,
        headers: { 'content-type': 'application/octet-stream' },
      }),
    )

    const client = new HttpClient({ apiKey: 'test' })
    const response = await client.request<Buffer, 'arraybuffer'>({
      method: 'GET',
      path: '/files/file-1/preview',
      responseType: 'arraybuffer',
    })

    expect(Buffer.isBuffer(response.data)).toBe(true)
    expect(Array.from(response.data.values())).toEqual([4, 5, 6])
  })

  it('returns null for empty no-content responses', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(new Response(null, { status: 204 }))

    const client = new HttpClient({ apiKey: 'test' })
    const response = await client.requestRaw({
      method: 'GET',
      path: '/meta',
    })

    expect(response.data).toBeNull()
  })

  it('maps 401 and 429 errors', async () => {
    const fetchMock = stubFetch()
    fetchMock
      .mockResolvedValueOnce(jsonResponse({ message: 'unauthorized' }, { status: 401 }))
      .mockResolvedValueOnce(
        jsonResponse({ message: 'rate' }, { status: 429, headers: { 'retry-after': '2' } }),
      )
    const client = new HttpClient({ apiKey: 'test', maxRetries: 0 })

    await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf(
      AuthenticationError,
    )

    const error = await client
      .requestRaw({ method: 'GET', path: '/meta' })
      .catch((err: unknown) => err)
    expect(error).toBeInstanceOf(RateLimitError)
    expect((error as RateLimitError).retryAfter).toBe(2)
  })

  it('maps validation and upload errors', async () => {
    const fetchMock = stubFetch()
    fetchMock
      .mockResolvedValueOnce(jsonResponse({ message: 'invalid' }, { status: 422 }))
      .mockResolvedValueOnce(jsonResponse({ message: 'bad upload' }, { status: 400 }))
    const client = new HttpClient({ apiKey: 'test', maxRetries: 0 })

    await expect(
      client.requestRaw({ method: 'POST', path: '/chat-messages', data: { user: 'u' } }),
    ).rejects.toBeInstanceOf(ValidationError)

    await expect(
      client.requestRaw({ method: 'POST', path: '/files/upload', data: { user: 'u' } }),
    ).rejects.toBeInstanceOf(FileUploadError)
  })

  it('maps timeout and network errors', async () => {
    const fetchMock = stubFetch()
    fetchMock
      .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' }))
      .mockRejectedValueOnce(new Error('network'))
    const client = new HttpClient({ apiKey: 'test', maxRetries: 0 })

    await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf(
      TimeoutError,
    )

    await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf(
      NetworkError,
    )
  })

  it('maps unknown transport failures to NetworkError', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockRejectedValueOnce('boom')
    const client = new HttpClient({ apiKey: 'test', maxRetries: 0 })

    await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toMatchObject({
      name: 'NetworkError',
      message: 'Unexpected network error',
    })
  })

  it('retries on timeout errors', async () => {
    const fetchMock = stubFetch()
    fetchMock
      .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' }))
      .mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))
    const client = new HttpClient({ apiKey: 'test', maxRetries: 1, retryDelay: 0 })

    await client.requestRaw({ method: 'GET', path: '/meta' })
    expect(fetchMock).toHaveBeenCalledTimes(2)
  })

  it('does not retry non-replayable readable request bodies', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockRejectedValueOnce(new Error('network'))
    const client = new HttpClient({ apiKey: 'test', maxRetries: 2, retryDelay: 0 })

    await expect(
      client.requestRaw({
        method: 'POST',
        path: '/chat-messages',
        data: Readable.from(['chunk']),
      }),
    ).rejects.toBeInstanceOf(NetworkError)

    expect(fetchMock).toHaveBeenCalledTimes(1)
    const [, init] = getFetchCall(fetchMock)
    expect(getDuplex(init)).toBe('half')
  })

  it('validates query parameters before request', async () => {
    const fetchMock = stubFetch()
    const client = new HttpClient({ apiKey: 'test' })

    await expect(
      client.requestRaw({ method: 'GET', path: '/meta', query: { user: 1 } }),
    ).rejects.toBeInstanceOf(ValidationError)
    expect(fetchMock).not.toHaveBeenCalled()
  })

  it('returns APIError for other http failures', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'server' }, { status: 500 }))
    const client = new HttpClient({ apiKey: 'test', maxRetries: 0 })

    await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toBeInstanceOf(
      APIError,
    )
  })

  it('uses plain text bodies when json parsing is not possible', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      textResponse('plain text', {
        status: 200,
        headers: { 'content-type': 'text/plain' },
      }),
    )
    const client = new HttpClient({ apiKey: 'test' })

    const response = await client.requestRaw({
      method: 'GET',
      path: '/info',
    })

    expect(response.data).toBe('plain text')
  })

  it('keeps invalid json error bodies as API errors', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(
      textResponse('{invalid', {
        status: 500,
        headers: { 'content-type': 'application/json', 'x-request-id': 'req-500' },
      }),
    )
    const client = new HttpClient({ apiKey: 'test', maxRetries: 0 })

    await expect(client.requestRaw({ method: 'GET', path: '/meta' })).rejects.toMatchObject({
      name: 'APIError',
      statusCode: 500,
      requestId: 'req-500',
      responseBody: '{invalid',
    })
  })

  it('sends raw string bodies without additional json encoding', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))
    const client = new HttpClient({ apiKey: 'test' })

    await client.requestRaw({
      method: 'POST',
      path: '/meta',
      data: '{"pre":"serialized"}',
      headers: { 'Content-Type': 'application/custom+json' },
    })

    const [, init] = getFetchCall(fetchMock)
    expect(init?.body).toBe('{"pre":"serialized"}')
    expect(toHeaderRecord(init?.headers)).toMatchObject({
      'content-type': 'application/custom+json',
    })
  })

  it('preserves explicit user-agent headers', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 }))
    const client = new HttpClient({ apiKey: 'test' })

    await client.requestRaw({
      method: 'GET',
      path: '/meta',
      headers: { 'User-Agent': 'custom-agent' },
    })

    const [, init] = getFetchCall(fetchMock)
    expect(toHeaderRecord(init?.headers)).toMatchObject({
      'user-agent': 'custom-agent',
    })
  })

  it('logs requests and responses when enableLogging is true', async () => {
    const fetchMock = stubFetch()
    fetchMock.mockResolvedValueOnce(jsonResponse({ ok: true }, { status: 200 }))
    const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {})

    const client = new HttpClient({ apiKey: 'test', enableLogging: true })
    await client.requestRaw({ method: 'GET', path: '/meta' })

    expect(consoleInfo).toHaveBeenCalledWith(
      expect.stringContaining('dify-client-node response 200 GET'),
    )
  })

  it('logs retry attempts when enableLogging is true', async () => {
    const fetchMock = stubFetch()
    fetchMock
      .mockRejectedValueOnce(Object.assign(new Error('timeout'), { name: 'AbortError' }))
      .mockResolvedValueOnce(jsonResponse('ok', { status: 200 }))
    const consoleInfo = vi.spyOn(console, 'info').mockImplementation(() => {})

    const client = new HttpClient({
      apiKey: 'test',
      maxRetries: 1,
      retryDelay: 0,
      enableLogging: true,
    })

    await client.requestRaw({ method: 'GET', path: '/meta' })

    expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining('dify-client-node retry'))
  })
})
