import type {
  BinaryStream,
  DifyClientConfig,
  DifyResponse,
  DifyStream,
  Headers,
  JsonValue,
  QueryParams,
  RequestMethod,
} from '../types/common'
import type { SdkFormData } from './form-data'
import { Readable } from 'node:stream'
import { validateParams } from '../client/validation'
import {
  APIError,
  AuthenticationError,
  DifyError,
  FileUploadError,
  NetworkError,
  RateLimitError,
  TimeoutError,
  ValidationError,
} from '../errors/dify-error'
import { hasStringProperty, isRecord } from '../internal/type-guards'
import {
  DEFAULT_BASE_URL,
  DEFAULT_MAX_RETRIES,
  DEFAULT_RETRY_DELAY_SECONDS,
  DEFAULT_TIMEOUT_SECONDS,
} from '../types/common'
import { getFormDataHeaders, isFormData } from './form-data'
import { getRetryDelayMs, shouldRetry, sleep } from './retry'
import { createBinaryStream, createSseStream } from './sse'

const DEFAULT_USER_AGENT = 'dify-client-node'

export type HttpResponseType = 'json' | 'bytes' | 'stream' | 'arraybuffer'

export type HttpRequestBody =
  | JsonValue
  | Readable
  | SdkFormData
  | URLSearchParams
  | ArrayBuffer
  | ArrayBufferView
  | Blob
  | string
  | null

export type ResponseDataFor<TResponseType extends HttpResponseType> = TResponseType extends 'stream'
  ? Readable
  : TResponseType extends 'bytes' | 'arraybuffer'
    ? Buffer
    : JsonValue | string | null

export type RawHttpResponse<TData = unknown> = {
  data: TData
  status: number
  headers: Headers
  requestId?: string
  url: string
}

export type RequestOptions<TResponseType extends HttpResponseType = 'json'> = {
  method: RequestMethod
  path: string
  query?: QueryParams
  data?: HttpRequestBody
  headers?: Headers
  responseType?: TResponseType
}

export type HttpClientSettings = Required<Omit<DifyClientConfig, 'apiKey'>> & {
  apiKey: string
}

type FetchRequestInit = RequestInit & {
  duplex?: 'half'
}

type PreparedRequestBody = {
  body?: BodyInit | null
  headers: Headers
  duplex?: 'half'
  replayable: boolean
}

type TimeoutContext = {
  cleanup: () => void
  reason: Error
  signal: AbortSignal
}

const normalizeSettings = (config: DifyClientConfig): HttpClientSettings => ({
  apiKey: config.apiKey,
  baseUrl: config.baseUrl ?? DEFAULT_BASE_URL,
  timeout: config.timeout ?? DEFAULT_TIMEOUT_SECONDS,
  maxRetries: config.maxRetries ?? DEFAULT_MAX_RETRIES,
  retryDelay: config.retryDelay ?? DEFAULT_RETRY_DELAY_SECONDS,
  enableLogging: config.enableLogging ?? false,
})

const normalizeHeaders = (headers: globalThis.Headers): Headers => {
  const result: Headers = {}
  headers.forEach((value, key) => {
    result[key.toLowerCase()] = value
  })
  return result
}

const resolveRequestId = (headers: Headers): string | undefined =>
  headers['x-request-id'] ?? headers['x-requestid']

const buildRequestUrl = (baseUrl: string, path: string, query?: QueryParams): string => {
  const trimmed = baseUrl.replace(/\/+$/, '')
  const url = new URL(`${trimmed}${path}`)
  const queryString = buildQueryString(query)
  if (queryString) {
    url.search = queryString
  }
  return url.toString()
}

const buildQueryString = (params?: QueryParams): string => {
  if (!params) {
    return ''
  }
  const searchParams = new URLSearchParams()
  Object.entries(params).forEach(([key, value]) => {
    if (value === undefined || value === null) {
      return
    }
    if (Array.isArray(value)) {
      value.forEach((item) => {
        searchParams.append(key, String(item))
      })
      return
    }
    searchParams.append(key, String(value))
  })
  return searchParams.toString()
}

const parseRetryAfterSeconds = (headerValue?: string): number | undefined => {
  if (!headerValue) {
    return undefined
  }
  const asNumber = Number.parseInt(headerValue, 10)
  if (!Number.isNaN(asNumber)) {
    return asNumber
  }
  const asDate = Date.parse(headerValue)
  if (!Number.isNaN(asDate)) {
    const diff = asDate - Date.now()
    return diff > 0 ? Math.ceil(diff / 1000) : 0
  }
  return undefined
}

const isPipeableStream = (value: unknown): value is { pipe: (destination: unknown) => unknown } => {
  if (!value || typeof value !== 'object') {
    return false
  }
  return typeof (value as { pipe?: unknown }).pipe === 'function'
}

const toNodeReadable = (value: unknown): Readable | null => {
  if (value instanceof Readable) {
    return value
  }
  if (!isPipeableStream(value)) {
    return null
  }
  const readable = new Readable({
    read() {},
  })
  return readable.wrap(value as NodeJS.ReadableStream)
}

const isBinaryBody = (value: unknown): value is ArrayBuffer | ArrayBufferView | Blob => {
  if (value instanceof Blob) {
    return true
  }
  if (value instanceof ArrayBuffer) {
    return true
  }
  return ArrayBuffer.isView(value)
}

const isJsonBody = (value: unknown): value is Exclude<JsonValue, string> =>
  value === null ||
  typeof value === 'boolean' ||
  typeof value === 'number' ||
  Array.isArray(value) ||
  isRecord(value)

const isUploadLikeRequest = (path: string): boolean => {
  const normalizedPath = path.toLowerCase()
  return (
    normalizedPath.includes('upload') ||
    normalizedPath.includes('/files/') ||
    normalizedPath.includes('audio-to-text') ||
    normalizedPath.includes('create_by_file') ||
    normalizedPath.includes('update_by_file')
  )
}

const resolveErrorMessage = (status: number, responseBody: unknown): string => {
  if (typeof responseBody === 'string' && responseBody.trim().length > 0) {
    return responseBody
  }
  if (hasStringProperty(responseBody, 'message')) {
    const message = responseBody.message.trim()
    if (message.length > 0) {
      return message
    }
  }
  return `Request failed with status code ${status}`
}

const parseJsonLikeText = (
  value: string,
  contentType?: string | null,
): JsonValue | string | null => {
  if (value.length === 0) {
    return null
  }
  const shouldParseJson =
    contentType?.includes('application/json') === true || contentType?.includes('+json') === true
  if (!shouldParseJson) {
    try {
      return JSON.parse(value) as JsonValue
    } catch {
      return value
    }
  }
  return JSON.parse(value) as JsonValue
}

const prepareRequestBody = (
  method: RequestMethod,
  data: HttpRequestBody | undefined,
): PreparedRequestBody => {
  if (method === 'GET' || data === undefined) {
    return {
      body: undefined,
      headers: {},
      replayable: true,
    }
  }

  if (isFormData(data)) {
    if ('getHeaders' in data && typeof data.getHeaders === 'function') {
      const readable = toNodeReadable(data)
      if (!readable) {
        throw new FileUploadError('Legacy FormData must be a readable stream when used with fetch')
      }
      return {
        body: Readable.toWeb(readable) as BodyInit,
        headers: getFormDataHeaders(data),
        duplex: 'half',
        replayable: false,
      }
    }
    return {
      body: data as BodyInit,
      headers: getFormDataHeaders(data),
      replayable: true,
    }
  }

  if (typeof data === 'string') {
    return {
      body: data,
      headers: {},
      replayable: true,
    }
  }

  const readable = toNodeReadable(data)
  if (readable) {
    return {
      body: Readable.toWeb(readable) as BodyInit,
      headers: {},
      duplex: 'half',
      replayable: false,
    }
  }

  if (data instanceof URLSearchParams || isBinaryBody(data)) {
    const body =
      ArrayBuffer.isView(data) && !(data instanceof Uint8Array)
        ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength)
        : data
    return {
      body: body as BodyInit,
      headers: {},
      replayable: true,
    }
  }

  if (isJsonBody(data)) {
    return {
      body: JSON.stringify(data),
      headers: {
        'Content-Type': 'application/json',
      },
      replayable: true,
    }
  }

  throw new ValidationError('Unsupported request body type')
}

const createTimeoutContext = (timeoutMs: number): TimeoutContext => {
  const controller = new AbortController()
  const reason = new Error('Request timed out')
  const timer = setTimeout(() => {
    controller.abort(reason)
  }, timeoutMs)
  return {
    signal: controller.signal,
    reason,
    cleanup: () => {
      clearTimeout(timer)
    },
  }
}

const parseResponseBody = async <TResponseType extends HttpResponseType>(
  response: Response,
  responseType: TResponseType,
): Promise<ResponseDataFor<TResponseType>> => {
  if (responseType === 'stream') {
    if (!response.body) {
      throw new NetworkError('Response body is empty')
    }
    return Readable.fromWeb(
      response.body as unknown as Parameters<typeof Readable.fromWeb>[0],
    ) as ResponseDataFor<TResponseType>
  }

  if (responseType === 'bytes' || responseType === 'arraybuffer') {
    const bytes = Buffer.from(await response.arrayBuffer())
    return bytes as ResponseDataFor<TResponseType>
  }

  if (response.status === 204 || response.status === 205 || response.status === 304) {
    return null as ResponseDataFor<TResponseType>
  }

  const text = await response.text()
  try {
    return parseJsonLikeText(
      text,
      response.headers.get('content-type'),
    ) as ResponseDataFor<TResponseType>
  } catch (error) {
    if (!response.ok && error instanceof SyntaxError) {
      return text as ResponseDataFor<TResponseType>
    }
    throw error
  }
}

const mapHttpError = (response: RawHttpResponse, path: string): DifyError => {
  const status = response.status
  const responseBody = response.data
  const message = resolveErrorMessage(status, responseBody)

  if (status === 401) {
    return new AuthenticationError(message, {
      statusCode: status,
      responseBody,
      requestId: response.requestId,
    })
  }

  if (status === 429) {
    const retryAfter = parseRetryAfterSeconds(response.headers['retry-after'])
    return new RateLimitError(message, {
      statusCode: status,
      responseBody,
      requestId: response.requestId,
      retryAfter,
    })
  }

  if (status === 422) {
    return new ValidationError(message, {
      statusCode: status,
      responseBody,
      requestId: response.requestId,
    })
  }

  if (status === 400 && isUploadLikeRequest(path)) {
    return new FileUploadError(message, {
      statusCode: status,
      responseBody,
      requestId: response.requestId,
    })
  }

  return new APIError(message, {
    statusCode: status,
    responseBody,
    requestId: response.requestId,
  })
}

const mapTransportError = (error: unknown, timeoutContext: TimeoutContext): DifyError => {
  if (error instanceof DifyError) {
    return error
  }

  if (timeoutContext.signal.aborted && timeoutContext.signal.reason === timeoutContext.reason) {
    return new TimeoutError('Request timed out', { cause: error })
  }

  if (error instanceof Error) {
    if (error.name === 'AbortError' || error.name === 'TimeoutError') {
      return new TimeoutError('Request timed out', { cause: error })
    }
    return new NetworkError(error.message, { cause: error })
  }

  return new NetworkError('Unexpected network error', { cause: error })
}

export class HttpClient {
  private settings: HttpClientSettings

  constructor(config: DifyClientConfig) {
    this.settings = normalizeSettings(config)
  }

  updateApiKey(apiKey: string): void {
    this.settings.apiKey = apiKey
  }

  getSettings(): HttpClientSettings {
    return { ...this.settings }
  }

  async request<T, TResponseType extends HttpResponseType = 'json'>(
    options: RequestOptions<TResponseType>,
  ): Promise<DifyResponse<T>> {
    const response = await this.requestRaw(options)
    return {
      data: response.data as T,
      status: response.status,
      headers: response.headers,
      requestId: response.requestId,
    }
  }

  async requestStream<T>(options: RequestOptions): Promise<DifyStream<T>> {
    const response = await this.requestRaw({
      ...options,
      responseType: 'stream',
    })
    return createSseStream<T>(response.data, {
      status: response.status,
      headers: response.headers,
      requestId: response.requestId,
    })
  }

  async requestBinaryStream(options: RequestOptions): Promise<BinaryStream> {
    const response = await this.requestRaw({
      ...options,
      responseType: 'stream',
    })
    return createBinaryStream(response.data, {
      status: response.status,
      headers: response.headers,
      requestId: response.requestId,
    })
  }

  async requestRaw<TResponseType extends HttpResponseType = 'json'>(
    options: RequestOptions<TResponseType>,
  ): Promise<RawHttpResponse<ResponseDataFor<TResponseType>>> {
    const responseType = options.responseType ?? 'json'
    const { method, path, query, data, headers } = options
    const { apiKey, enableLogging, maxRetries, retryDelay, timeout } = this.settings

    if (query) {
      validateParams(query)
    }

    if (isRecord(data) && !Array.isArray(data) && !isFormData(data) && !isPipeableStream(data)) {
      validateParams(data)
    }

    const url = buildRequestUrl(this.settings.baseUrl, path, query)

    if (enableLogging) {
      console.info(`dify-client-node request ${method} ${url}`)
    }

    let attempt = 0
    while (true) {
      const preparedBody = prepareRequestBody(method, data)
      const requestHeaders: Headers = {
        Authorization: `Bearer ${apiKey}`,
        ...preparedBody.headers,
        ...headers,
      }

      if (
        typeof process !== 'undefined' &&
        !!process.versions?.node &&
        !requestHeaders['User-Agent'] &&
        !requestHeaders['user-agent']
      ) {
        requestHeaders['User-Agent'] = DEFAULT_USER_AGENT
      }

      const timeoutContext = createTimeoutContext(timeout * 1000)
      const requestInit: FetchRequestInit = {
        method,
        headers: requestHeaders,
        body: preparedBody.body,
        signal: timeoutContext.signal,
      }

      if (preparedBody.duplex) {
        requestInit.duplex = preparedBody.duplex
      }

      try {
        const fetchResponse = await fetch(url, requestInit)
        const responseHeaders = normalizeHeaders(fetchResponse.headers)
        const parsedBody = (await parseResponseBody(
          fetchResponse,
          responseType,
        )) as ResponseDataFor<TResponseType>
        const response: RawHttpResponse<ResponseDataFor<TResponseType>> = {
          data: parsedBody,
          status: fetchResponse.status,
          headers: responseHeaders,
          requestId: resolveRequestId(responseHeaders),
          url,
        }

        if (!fetchResponse.ok) {
          throw mapHttpError(response, path)
        }

        if (enableLogging) {
          console.info(`dify-client-node response ${response.status} ${method} ${url}`)
        }

        return response
      } catch (error) {
        const mapped = mapTransportError(error, timeoutContext)
        const shouldRetryRequest =
          preparedBody.replayable && shouldRetry(mapped, attempt, maxRetries)
        if (!shouldRetryRequest) {
          throw mapped
        }
        const retryAfterSeconds = mapped instanceof RateLimitError ? mapped.retryAfter : undefined
        const delay = getRetryDelayMs(attempt + 1, retryDelay, retryAfterSeconds)
        if (enableLogging) {
          console.info(`dify-client-node retry ${attempt + 1} in ${delay}ms for ${method} ${url}`)
        }
        attempt += 1
        await sleep(delay)
      } finally {
        timeoutContext.cleanup()
      }
    }
  }
}
