import type {
  Body,
  Meta,
  RateLimitedQueue,
  UppyFile,
  WrapPromiseFunctionType,
} from '@uppy/core/utils'
import { fetchWithNetworkError } from '@uppy/core/utils'
import {
  type AssemblyResponse,
  getAssemblyUrlSsl,
  type OptionsWithRestructuredFields,
} from './index.js'

const ASSEMBLIES_ENDPOINT = '/assemblies'

type Opts = {
  client?: string
  service: string
  rateLimitedQueue: RateLimitedQueue
  errorReporting: boolean
}

export class AssemblyError extends Error {
  details: string | undefined

  assembly: AssemblyResponse

  constructor(
    message: string,
    details: string | undefined,
    assembly: AssemblyResponse,
  ) {
    super(message)
    this.details = details
    this.assembly = assembly
  }
}

/**
 * A Barebones HTTP API client for Transloadit.
 */
export default class Client<M extends Meta, B extends Body> {
  #headers: Record<string, string> = {}

  #fetchWithNetworkError: WrapPromiseFunctionType<typeof fetchWithNetworkError>

  opts: Opts

  constructor(opts: Opts) {
    this.opts = opts

    if (this.opts.client != null) {
      this.#headers['Transloadit-Client'] = this.opts.client
    }

    this.#fetchWithNetworkError =
      this.opts.rateLimitedQueue.wrapPromiseFunction(fetchWithNetworkError)
  }

  async #fetchJSON(
    ...args: Parameters<typeof fetchWithNetworkError>
  ): Promise<AssemblyResponse> {
    const response = await this.#fetchWithNetworkError(...args)

    if (response.status === 429) {
      this.opts.rateLimitedQueue.rateLimit(2_000)
      return this.#fetchJSON(...args)
    }

    if (!response.ok) {
      const serverError = new Error(response.statusText)
      // @ts-expect-error statusCode is not a standard property
      serverError.statusCode = response.status

      if (!`${args[0]}`.endsWith(ASSEMBLIES_ENDPOINT))
        return Promise.reject(serverError)

      // Failed assembly requests should return a more detailed error in JSON.
      return response.json().then(
        (assembly: AssemblyResponse) => {
          if (!assembly.error) throw serverError

          const error = new AssemblyError(
            assembly.error,
            assembly.message,
            assembly,
          )

          if (assembly.assembly_id) {
            error.details += ` Assembly ID: ${assembly.assembly_id}`
          }
          throw error
        },
        (err) => {
          err.cause = serverError
          throw err
        },
      )
    }

    return response.json()
  }

  async createAssembly({
    params,
    fields,
    signature,
    expectedFiles,
  }: OptionsWithRestructuredFields & {
    expectedFiles: number
  }): Promise<AssemblyResponse> {
    const data = new FormData()
    data.append(
      'params',
      typeof params === 'string' ? params : JSON.stringify(params),
    )
    if (signature) {
      data.append('signature', signature)
    }

    Object.keys(fields).forEach((key) => {
      data.append(key, String(fields[key]))
    })
    data.append('num_expected_upload_files', String(expectedFiles))

    const url = new URL(ASSEMBLIES_ENDPOINT, `${this.opts.service}`).href
    return this.#fetchJSON(url, {
      method: 'POST',
      headers: this.#headers,
      body: data,
    }).catch((err) => this.#reportError(err, { url, type: 'API_ERROR' }))
  }

  /**
   * Reserve resources for a file in an Assembly. Then addFile can be used later.
   */
  async reserveFile(
    assembly: AssemblyResponse,
    file: UppyFile<M, B>,
  ): Promise<AssemblyResponse> {
    const size = encodeURIComponent(file.size!)
    const assemblyUrl = getAssemblyUrlSsl(assembly)
    const url = `${assemblyUrl}/reserve_file?size=${size}`
    return this.#fetchJSON(url, {
      method: 'POST',
      headers: this.#headers,
    }).catch((err) =>
      this.#reportError(err, { assembly, file, url, type: 'API_ERROR' }),
    )
  }

  /**
   * Import a remote file to an Assembly.
   */
  async addFile(
    assembly: AssemblyResponse,
    file: UppyFile<M, B>,
  ): Promise<AssemblyResponse> {
    if (!file.uploadURL) {
      return Promise.reject(new Error('File does not have an `uploadURL`.'))
    }
    const size = encodeURIComponent(file.size!)
    const uploadUrl = encodeURIComponent(file.uploadURL)
    const filename = encodeURIComponent(file.name ?? 'Unnamed')
    const fieldname = 'file'

    const qs = `size=${size}&filename=${filename}&fieldname=${fieldname}&s3Url=${uploadUrl}`
    const assemblyUrl = getAssemblyUrlSsl(assembly)
    const url = `${assemblyUrl}/add_file?${qs}`
    return this.#fetchJSON(url, {
      method: 'POST',
      headers: this.#headers,
    }).catch((err) =>
      this.#reportError(err, { assembly, file, url, type: 'API_ERROR' }),
    )
  }

  /**
   * Cancel a running Assembly.
   */
  async cancelAssembly(assembly: AssemblyResponse): Promise<void> {
    const url = getAssemblyUrlSsl(assembly)
    await this.#fetchWithNetworkError(url, {
      method: 'DELETE',
      headers: this.#headers,
    }).catch((err) => this.#reportError(err, { url, type: 'API_ERROR' }))
  }

  /**
   * Get the current status for an assembly.
   */
  async getAssemblyStatus(url: string): Promise<AssemblyResponse> {
    return this.#fetchJSON(url, { headers: this.#headers }).catch((err) =>
      this.#reportError(err, { url, type: 'STATUS_ERROR' }),
    )
  }

  async submitError(
    err: { message?: string; details?: string },
    {
      endpoint,
      instance,
      assembly,
    }: {
      endpoint?: string | URL
      instance?: string
      assembly?: string
    } = {},
  ): Promise<AssemblyResponse> {
    const message = err.details
      ? `${err.message} (${err.details})`
      : err.message

    return this.#fetchJSON('https://transloaditstatus.com/client_error', {
      method: 'POST',
      body: JSON.stringify({
        endpoint,
        instance,
        assembly_id: assembly,
        agent: typeof navigator !== 'undefined' ? navigator.userAgent : '',
        client: this.opts.client,
        error: message,
      }),
    })
  }

  #reportError = (
    err: AssemblyError,
    params: {
      assembly?: AssemblyResponse
      url?: URL | string
      file?: UppyFile<M, B>
      type: string
    },
  ) => {
    if (this.opts.errorReporting === false) {
      throw err
    }

    const opts: {
      type: string
      assembly?: string
      instance?: string
      endpoint?: URL | string
    } = {
      type: params.type,
    }
    if (params.assembly) {
      opts.assembly = params.assembly.assembly_id
      opts.instance = params.assembly.instance
    }
    if (params.url) {
      opts.endpoint = params.url
    }

    this.submitError(err, opts).catch(() => {
      // not much we can do then is there
    })

    throw err
  }
}
