import type {
  Body,
  DefinePluginOpts,
  Meta,
  PluginOpts,
  State,
  Uppy,
  UppyFile,
} from '@uppy/core'
import { BasePlugin, EventManager } from '@uppy/core'
import type { RequestClient } from '@uppy/core/companion-client'
import {
  type FetcherOptions,
  fetcher,
  filterFilesToEmitUploadStarted,
  filterFilesToUpload,
  getAllowedMetaFields,
  isNetworkError,
  type LocalUppyFile,
  NetworkError,
  type RemoteUppyFile,
  TaskQueue,
} from '@uppy/core/utils'
import packageJson from '../package.json' with { type: 'json' }
import locale from './locale.js'

export interface XhrUploadOpts<M extends Meta, B extends Body>
  extends PluginOpts {
  endpoint:
    | string
    | ((
        fileOrBundle: UppyFile<M, B> | UppyFile<M, B>[],
      ) => string | Promise<string>)
  method?:
    | 'GET'
    | 'HEAD'
    | 'POST'
    | 'PUT'
    | 'DELETE'
    | 'OPTIONS'
    | 'PATCH'
    | 'delete'
    | 'get'
    | 'head'
    | 'options'
    | 'post'
    | 'put'
    | string
  formData?: boolean
  fieldName?: string
  headers?:
    | Record<string, string>
    | ((file: UppyFile<M, B>) => Record<string, string>)
  timeout?: number
  limit?: number
  responseType?: XMLHttpRequestResponseType
  withCredentials?: boolean
  onBeforeRequest?: (
    xhr: XMLHttpRequest,
    retryCount: number,
    /** The files to be uploaded. When `bundle` is `false` only one file is in the array.  */
    files: UppyFile<M, B>[],
  ) => void | Promise<void>
  shouldRetry?: FetcherOptions['shouldRetry']
  onAfterResponse?: FetcherOptions['onAfterResponse']
  getResponseData?: (xhr: XMLHttpRequest) => B | Promise<B>
  allowedMetaFields?: boolean | string[]
  bundle?: boolean
}

export type { XhrUploadOpts as XHRUploadOptions }

declare module '@uppy/core/utils' {
  export interface LocalUppyFile<M extends Meta, B extends Body> {
    xhrUpload?: XhrUploadOpts<M, B>
  }
  export interface RemoteUppyFile<M extends Meta, B extends Body> {
    xhrUpload?: XhrUploadOpts<M, B>
  }
}

declare module '@uppy/core' {
  export interface State<M extends Meta, B extends Body> {
    xhrUpload?: XhrUploadOpts<M, B>
  }
}

declare module '@uppy/core' {
  export interface PluginTypeRegistry<M extends Meta, B extends Body> {
    XHRUpload: XHRUpload<M, B>
  }
}

function buildResponseError(
  xhr?: XMLHttpRequest,
  err?: string | Error | NetworkError,
) {
  let error = err
  // No error message
  if (!error) error = new Error('Upload error')
  // Got an error message string
  if (typeof error === 'string') error = new Error(error)
  // Got something else
  if (!(error instanceof Error)) {
    error = Object.assign(new Error('Upload error'), { data: error })
  }

  if (isNetworkError(xhr)) {
    error = new NetworkError(error, xhr)
    return error
  }

  // @ts-expect-error request can only be set on NetworkError
  // but we use NetworkError to distinguish between errors.
  error.request = xhr
  return error
}

/**
 * Set `data.type` in the blob to `file.meta.type`,
 * because we might have detected a more accurate file type in Uppy
 * https://stackoverflow.com/a/50875615
 */
function setTypeInBlob<M extends Meta, B extends Body>(
  file: LocalUppyFile<M, B>,
) {
  const dataWithUpdatedType = file.data!.slice(
    0,
    file.data!.size,
    file.meta.type,
  )
  return dataWithUpdatedType
}

const defaultOptions = {
  formData: true,
  fieldName: 'file',
  method: 'post',
  allowedMetaFields: true,
  bundle: false,
  headers: {},
  timeout: 30 * 1000,
  limit: 5,
  withCredentials: false,
  responseType: '',
} satisfies Partial<XhrUploadOpts<any, any>>

type Opts<M extends Meta, B extends Body> = DefinePluginOpts<
  XhrUploadOpts<M, B>,
  keyof typeof defaultOptions
>

interface OptsWithHeaders<M extends Meta, B extends Body> extends Opts<M, B> {
  headers: Record<string, string>
}

export default class XHRUpload<
  M extends Meta,
  B extends Body,
> extends BasePlugin<Opts<M, B>, M, B> {
  static VERSION = packageJson.version

  #getFetcher

  #queue: TaskQueue

  uploaderEvents: Record<string, EventManager<M, B> | null>

  constructor(uppy: Uppy<M, B>, opts: XhrUploadOpts<M, B>) {
    super(uppy, {
      ...defaultOptions,
      fieldName: opts.bundle ? 'files[]' : 'file',
      ...opts,
    })
    this.type = 'uploader'
    this.id = this.opts.id || 'XHRUpload'

    this.defaultLocale = locale

    this.i18nInit()

    this.#queue = new TaskQueue({ concurrency: this.opts.limit })

    if (this.opts.bundle && !this.opts.formData) {
      throw new Error(
        '`opts.formData` must be true when `opts.bundle` is enabled.',
      )
    }

    if (this.opts.bundle && typeof this.opts.headers === 'function') {
      throw new Error(
        '`opts.headers` can not be a function when the `bundle: true` option is set.',
      )
    }

    if (opts?.allowedMetaFields === undefined && 'metaFields' in this.opts) {
      throw new Error(
        'The `metaFields` option has been renamed to `allowedMetaFields`.',
      )
    }

    this.uploaderEvents = Object.create(null)
    /**
     * xhr-upload wrapper for `fetcher` to handle user options
     * `validateStatus`, `getResponseError`, `getResponseData`
     * and to emit `upload-progress`, `upload-error`, and `upload-success` events.
     */
    this.#getFetcher = (files: UppyFile<M, B>[]) => {
      return async (
        url: string,
        options: Omit<FetcherOptions, 'onBeforeRequest'> & {
          onBeforeRequest?: Opts<M, B>['onBeforeRequest']
        },
      ) => {
        try {
          const res = await fetcher(url, {
            ...options,
            onBeforeRequest: (xhr, retryCount) =>
              this.opts.onBeforeRequest?.(xhr, retryCount, files),
            shouldRetry: this.opts.shouldRetry,
            onAfterResponse: this.opts.onAfterResponse,
            onTimeout: (timeout) => {
              const seconds = Math.ceil(timeout / 1000)
              const error = new Error(this.i18n('uploadStalled', { seconds }))
              this.uppy.emit('upload-stalled', error, files)
            },
            onUploadProgress: (event) => {
              if (event.lengthComputable) {
                for (const { id } of files) {
                  const file = this.uppy.getFile(id)
                  if (file != null) {
                    this.uppy.emit('upload-progress', file, {
                      uploadStarted: file.progress.uploadStarted ?? 0,
                      bytesUploaded: (event.loaded / event.total) * file.size!,
                      bytesTotal: file.size,
                    })
                  }
                }
              }
            },
          })

          let body = await this.opts.getResponseData?.(res)

          if (res.responseType === 'json') {
            body ??= res.response
          } else {
            try {
              body ??= JSON.parse(res.responseText) as B
            } catch (cause) {
              throw new Error(
                '@uppy/xhr-upload expects a JSON response (with a `url` property). To parse non-JSON responses, use `getResponseData` to turn your response into JSON.',
                { cause },
              )
            }
          }

          const uploadURL = typeof body?.url === 'string' ? body.url : undefined

          for (const { id } of files) {
            this.uppy.emit('upload-success', this.uppy.getFile(id), {
              status: res.status,
              body,
              uploadURL,
            })
          }

          return res
        } catch (error) {
          if (error.name === 'AbortError') {
            return undefined
          }
          const request = error.request as XMLHttpRequest | undefined

          for (const file of files) {
            this.uppy.emit(
              'upload-error',
              this.uppy.getFile(file.id),
              buildResponseError(request, error),
              request,
            )
          }

          throw error
        }
      }
    }
  }

  getOptions(file: UppyFile<M, B>): OptsWithHeaders<M, B> {
    const overrides = this.uppy.getState().xhrUpload
    const { headers } = this.opts

    const opts = {
      ...this.opts,
      ...(overrides || {}),
      ...(file.xhrUpload || {}),
      headers: {},
    }
    // Support for `headers` as a function, only in the XHRUpload settings.
    // Options set by other plugins in Uppy state or on the files themselves are still merged in afterward.
    //
    // ```js
    // headers: (file) => ({ expires: file.meta.expires })
    // ```
    if (typeof headers === 'function') {
      opts.headers = headers(file)
    } else {
      Object.assign(opts.headers, this.opts.headers)
    }

    if (overrides) {
      Object.assign(opts.headers, overrides.headers)
    }
    if (file.xhrUpload) {
      Object.assign(opts.headers, file.xhrUpload.headers)
    }

    return opts
  }

  addMetadata(
    formData: FormData,
    meta: State<M, B>['meta'],
    opts: Opts<M, B>,
  ): void {
    const allowedMetaFields = getAllowedMetaFields(opts.allowedMetaFields, meta)

    allowedMetaFields.forEach((item) => {
      const value = meta[item]
      if (Array.isArray(value)) {
        // In this case we don't transform `item` to add brackets, it's up to
        // the user to add the brackets so it won't be overridden.
        value.forEach((subItem) => {
          formData.append(item, subItem)
        })
      } else {
        formData.append(item, value as string)
      }
    })
  }

  createFormDataUpload(file: LocalUppyFile<M, B>, opts: Opts<M, B>): FormData {
    const formPost = new FormData()

    this.addMetadata(formPost, file.meta, opts)

    const dataWithUpdatedType = setTypeInBlob(file)

    if (file.name) {
      formPost.append(opts.fieldName, dataWithUpdatedType, file.meta.name)
    } else {
      formPost.append(opts.fieldName, dataWithUpdatedType)
    }

    return formPost
  }

  createBundledUpload(
    files: LocalUppyFile<M, B>[],
    opts: Opts<M, B>,
  ): FormData {
    const formPost = new FormData()

    const { meta } = this.uppy.getState()
    this.addMetadata(formPost, meta, opts)

    files.forEach((file) => {
      const options = this.getOptions(file)

      const dataWithUpdatedType = setTypeInBlob(file)

      if (file.name) {
        formPost.append(options.fieldName, dataWithUpdatedType, file.name)
      } else {
        formPost.append(options.fieldName, dataWithUpdatedType)
      }
    })

    return formPost
  }

  async #uploadLocalFile(file: LocalUppyFile<M, B>) {
    const events = new EventManager(this.uppy)
    const controller = new AbortController()

    events.onFileRemove(file.id, () => controller.abort())
    events.onCancelAll(file.id, () => controller.abort())

    try {
      await this.#queue.add(async (signal) => {
        const opts = this.getOptions(file)
        const fetch = this.#getFetcher([file])
        const body = opts.formData
          ? this.createFormDataUpload(file, opts)
          : file.data
        const endpoint =
          typeof opts.endpoint === 'string'
            ? opts.endpoint
            : await opts.endpoint(file)
        return fetch(endpoint, {
          ...opts,
          body,
          signal: AbortSignal.any([signal, controller.signal]),
        })
      })
    } catch (error) {
      if (error.name === 'AbortError') {
        return
      }
      throw error
    } finally {
      events.remove()
    }
  }

  async #uploadBundle(files: LocalUppyFile<M, B>[]) {
    const controller = new AbortController()

    function abort() {
      controller.abort()
    }

    // We only need to abort on cancel all because
    // individual cancellations are not possible with bundle: true
    this.uppy.once('cancel-all', abort)

    try {
      await this.#queue.add(async (signal) => {
        const optsFromState = this.uppy.getState().xhrUpload ?? {}
        const fetch = this.#getFetcher(files)
        const body = this.createBundledUpload(files, {
          ...this.opts,
          ...optsFromState,
        })
        const endpoint =
          typeof this.opts.endpoint === 'string'
            ? this.opts.endpoint
            : await this.opts.endpoint(files)
        return fetch(endpoint, {
          // headers can't be a function with bundle: true
          ...(this.opts as OptsWithHeaders<M, B>),
          body,
          signal: AbortSignal.any([signal, controller.signal]),
        })
      })
    } catch (error) {
      if (error.name === 'AbortError') {
        return
      }
      throw error
    } finally {
      this.uppy.off('cancel-all', abort)
    }
  }

  #getCompanionClientArgs(file: RemoteUppyFile<M, B>) {
    const opts = this.getOptions(file)
    const allowedMetaFields = getAllowedMetaFields(
      opts.allowedMetaFields,
      file.meta,
    )
    return {
      ...file.remote?.body,
      protocol: 'multipart',
      endpoint: opts.endpoint,
      size: file.data.size,
      fieldname: opts.fieldName,
      metadata: Object.fromEntries(
        allowedMetaFields.map((name) => [name, file.meta[name]]),
      ),
      httpMethod: opts.method,
      useFormData: opts.formData,
      headers: opts.headers,
    }
  }

  async #uploadFiles(files: UppyFile<M, B>[]) {
    await Promise.allSettled(
      files.map((file) => {
        if (file.isRemote) {
          const getQueue = () => this.#queue
          const controller = new AbortController()

          const removedHandler = (removedFile: UppyFile<M, B>) => {
            if (removedFile.id === file.id) controller.abort()
          }
          this.uppy.on('file-removed', removedHandler)

          return this.uppy
            .getRequestClientForFile<RequestClient<M, B>>(file)
            .uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
              signal: controller.signal,
              getQueue,
            })
            .finally(() => {
              this.uppy.off('file-removed', removedHandler)
            })
        }

        return this.#uploadLocalFile(file)
      }),
    )
  }

  #handleUpload = async (fileIDs: string[]) => {
    if (fileIDs.length === 0) {
      this.uppy.log('[XHRUpload] No files to upload!')
      return
    }

    // No limit configured by the user
    if (this.opts.limit === 0) {
      this.uppy.log(
        '[XHRUpload] When uploading multiple files at once, consider setting the `limit` option (to `10` for example), to limit the number of concurrent uploads, which helps prevent memory and network issues: https://uppy.io/docs/xhr-upload/#limit-0',
        'warning',
      )
    }

    this.uppy.log('[XHRUpload] Uploading...')
    const files = this.uppy.getFilesByIds(fileIDs)

    const filesFiltered = filterFilesToUpload(files)
    const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
    this.uppy.emit('upload-start', filesToEmit)

    if (this.opts.bundle) {
      // if bundle: true, we don’t support remote uploads
      const isSomeFileRemote = filesFiltered.some((file) => file.isRemote)
      if (isSomeFileRemote) {
        throw new Error(
          'Can’t upload remote files when the `bundle: true` option is set',
        )
      }

      if (typeof this.opts.headers === 'function') {
        throw new TypeError(
          '`headers` may not be a function when the `bundle: true` option is set',
        )
      }

      await this.#uploadBundle(filesFiltered as LocalUppyFile<M, B>[])
    } else {
      await this.#uploadFiles(filesFiltered)
    }
  }

  install(): void {
    if (this.opts.bundle) {
      const { capabilities } = this.uppy.getState()
      this.uppy.setState({
        capabilities: {
          ...capabilities,
          individualCancellation: false,
        },
      })
    }

    this.uppy.addUploader(this.#handleUpload)
  }

  uninstall(): void {
    if (this.opts.bundle) {
      const { capabilities } = this.uppy.getState()
      this.uppy.setState({
        capabilities: {
          ...capabilities,
          individualCancellation: true,
        },
      })
    }

    this.uppy.removeUploader(this.#handleUpload)
  }
}
