import type {
  Body,
  DefinePluginOpts,
  Meta,
  PluginOpts,
  Uppy,
  UppyFile,
} from '@uppy/core'
import { BasePlugin, EventManager } from '@uppy/core'
import type { RequestClient } from '@uppy/core/companion-client'
import {
  filterFilesToEmitUploadStarted,
  filterFilesToUpload,
  getAllowedMetaFields,
  hasProperty,
  isNetworkError,
  type LocalUppyFile,
  NetworkError,
  RateLimitedQueue,
} from '@uppy/core/utils'
import * as tus from 'tus-js-client'
import packageJson from '../package.json' with { type: 'json' }
import getFingerprint from './getFingerprint.js'

type RestTusUploadOptions = Omit<
  tus.UploadOptions,
  'onShouldRetry' | 'onBeforeRequest' | 'headers'
>

export type TusDetailedError = tus.DetailedError

export type TusBody = { xhr: XMLHttpRequest }

export interface TusOpts<M extends Meta, B extends Body>
  extends PluginOpts,
    RestTusUploadOptions {
  endpoint?: string
  headers?:
    | Record<string, string>
    | ((file: UppyFile<M, B>) => Record<string, string>)
  limit?: number
  chunkSize?: number
  onBeforeRequest?: (
    req: tus.HttpRequest,
    file: UppyFile<M, B>,
  ) => void | Promise<void>
  onShouldRetry?: (
    err: tus.DetailedError,
    retryAttempt: number,
    options: TusOpts<M, B>,
    next: (e: tus.DetailedError) => boolean,
  ) => boolean
  retryDelays?: number[]
  withCredentials?: boolean
  allowedMetaFields?: boolean | string[]
  rateLimitedQueue?: RateLimitedQueue
}
export type { TusOpts as TusOptions }

/**
 * Extracted from https://github.com/tus/tus-js-client/blob/master/lib/upload.js#L13
 * excepted we removed 'fingerprint' key to avoid adding more dependencies
 */
const tusDefaultOptions = {
  endpoint: '',

  uploadUrl: null,
  metadata: {},
  uploadSize: null,

  onProgress: null,
  onChunkComplete: null,
  onSuccess: null,
  onError: null,

  overridePatchMethod: false,
  headers: {},
  addRequestId: false,

  chunkSize: Infinity,
  retryDelays: [100, 1000, 3000, 5000],
  parallelUploads: 1,
  removeFingerprintOnSuccess: false,
  uploadLengthDeferred: false,
  uploadDataDuringCreation: false,
} satisfies tus.UploadOptions

const defaultOptions = {
  limit: 20,
  retryDelays: tusDefaultOptions.retryDelays,
  withCredentials: false,
  allowedMetaFields: true,
} satisfies Partial<TusOpts<any, any>>

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

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

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

/**
 * Tus resumable file uploader
 */
export default class Tus<M extends Meta, B extends Body> extends BasePlugin<
  Opts<M, B>,
  M,
  B
> {
  static VERSION = packageJson.version

  #retryDelayIterator

  requests: RateLimitedQueue

  uploaders: Record<string, tus.Upload | null>

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

  constructor(uppy: Uppy<M, B>, opts: TusOpts<M, B>) {
    super(uppy, { ...defaultOptions, ...opts })
    this.type = 'uploader'
    this.id = this.opts.id || 'Tus'

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

    if ('autoRetry' in opts) {
      throw new Error(
        'The `autoRetry` option was deprecated and has been removed.',
      )
    }

    /**
     * Simultaneous upload limiting is shared across all uploads with this plugin.
     *
     * @type {RateLimitedQueue}
     */
    this.requests =
      this.opts.rateLimitedQueue ?? new RateLimitedQueue(this.opts.limit)
    this.#retryDelayIterator = this.opts.retryDelays?.values()

    this.uploaders = Object.create(null)
    this.uploaderEvents = Object.create(null)
  }

  /**
   * Stop the upload in Tus. If `terminate` is `true`, it will also terminate the
   * upload on the Tus server by sending a `DELETE` request. If not, it will just
   * cancel any current upload request and leave the upload in a half-uploaded state.
   *
   * @param fileID
   * @param terminate Whether to terminate the upload on the server.
   */
  #abortUploader(fileID: string, terminate?: boolean) {
    const uploader = this.uploaders[fileID]
    uploader?.abort(terminate)
  }

  /**
   * Clean up all references for a file's upload: the tus.Upload instance,
   * any events related to the file, and the Companion WebSocket connection.
   */
  #resetUploaderReferences(fileID: string): void {
    if (this.uploaders[fileID]) {
      this.uploaders[fileID] = null
    }
    if (this.uploaderEvents[fileID]) {
      this.uploaderEvents[fileID]!.remove()
      this.uploaderEvents[fileID] = null
    }
  }

  /**
   * Create a new Tus upload.
   *
   * A lot can happen during an upload, so this is quite hard to follow!
   * - First, the upload is started. If the file was already paused by the time the upload starts, nothing should happen.
   *   If the `limit` option is used, the upload must be queued onto the `this.requests` queue.
   *   When an upload starts, we store the tus.Upload instance, and an EventManager instance that manages the event listeners
   *   for pausing, cancellation, removal, etc.
   * - While the upload is in progress, it may be paused or cancelled.
   *   Pausing aborts the underlying tus.Upload, and removes the upload from the `this.requests` queue. All other state is
   *   maintained.
   *   Cancelling removes the upload from the `this.requests` queue, and completely aborts the upload-- the `tus.Upload`
   *   instance is aborted and discarded, the EventManager instance is destroyed (removing all listeners).
   *   Resuming the upload uses the `this.requests` queue as well, to prevent selectively pausing and resuming uploads from
   *   bypassing the limit.
   * - After completing an upload, the tus.Upload and EventManager instances are cleaned up, and the upload is marked as done
   *   in the `this.requests` queue.
   * - When an upload completed with an error, the same happens as on successful completion, but the `upload()` promise is
   *   rejected.
   *
   * When working on this function, keep in mind:
   *  - When an upload is completed or cancelled for any reason, the tus.Upload and EventManager instances need to be cleaned
   *    up using this.#resetUploaderReferences() and this.#abortUploader().
   *  - When an upload is cancelled or paused, for any reason, it needs to be removed from the `this.requests` queue using
   *    `queuedRequest.abort()`.
   *  - When an upload is completed for any reason, including errors, it needs to be marked as such using
   *    `queuedRequest.done()`.
   *  - When an upload is started or resumed, it needs to go through the `this.requests` queue. The `queuedRequest` variable
   *    must be updated so the other uses of it are valid.
   *  - Before replacing the `queuedRequest` variable, the previous `queuedRequest` must be aborted, else it will keep taking
   *    up a spot in the queue.
   *
   */
  async #uploadLocalFile(
    file: LocalUppyFile<M, B>,
  ): Promise<tus.Upload | string> {
    this.#abortUploader(file.id)
    this.#resetUploaderReferences(file.id)

    // Captured in `onError` and forwarded to the `upload-error` event in the
    // `.catch` below, so consumers can read the failing server response.
    let errorResponse:
      | Omit<NonNullable<UppyFile<M, B>['response']>, 'uploadURL'>
      | undefined

    // Create a new tus upload
    return new Promise<tus.Upload | string>((resolve, reject) => {
      let queuedRequest: ReturnType<RateLimitedQueue['run']>
      let qRequest: () => () => void
      let upload: tus.Upload

      const opts = {
        ...this.opts,
        ...(file.tus || {}),
      }

      if (typeof opts.headers === 'function') {
        opts.headers = opts.headers(file)
      }

      const { onShouldRetry, onBeforeRequest, ...commonOpts } = opts

      const uploadOptions: tus.UploadOptions = {
        ...tusDefaultOptions,
        ...commonOpts,
      }

      // We override tus fingerprint to uppy’s `file.id`, since the `file.id`
      // now also includes `relativePath` for files added from folders.
      // This means you can add 2 identical files, if one is in folder a,
      // the other in folder b.
      uploadOptions.fingerprint = getFingerprint(file)

      uploadOptions.onBeforeRequest = async (req) => {
        const xhr = req.getUnderlyingObject()
        if (xhr) {
          xhr.withCredentials = !!opts.withCredentials
        }

        let userProvidedPromise: Promise<void> | void
        if (typeof onBeforeRequest === 'function') {
          userProvidedPromise = onBeforeRequest(req, file)
        }

        if (hasProperty(queuedRequest, 'shouldBeRequeued')) {
          if (!queuedRequest.shouldBeRequeued) return Promise.reject()
          // TODO: switch to `Promise.withResolvers` on the next major if available.
          let done: () => void
          const p = new Promise<void>((res) => {
            done = res
          })
          queuedRequest = this.requests.run(() => {
            if (file.isPaused) {
              queuedRequest.abort()
            }
            done()
            return () => {}
          })
          // If the request has been requeued because it was rate limited by the
          // remote server, we want to wait for `RateLimitedQueue` to dispatch
          // the re-try request.
          // Therefore we create a promise that the queue will resolve when
          // enough time has elapsed to expect not to be rate-limited again.
          // This means we can hold the Tus retry here with a `Promise.all`,
          // together with the returned value of the user provided
          // `onBeforeRequest` option callback (in case it returns a promise).
          // @ts-expect-error it's fine
          await Promise.all([p, userProvidedPromise])
          return undefined
        }
        // @ts-expect-error it's fine
        return userProvidedPromise
      }

      uploadOptions.onError = (err) => {
        this.uppy.log(err)

        // tus-js-client only calls `onError` once it has given up retrying, so
        // the request has already completed. Capture the server response (status
        // + body) and forward it to the `upload-error` event and `file.response`,
        // mirroring the shape emitted by `onSuccess`.
        const originalResponse = (err as tus.DetailedError).originalResponse
        if (originalResponse != null) {
          errorResponse = {
            status: originalResponse.getStatus(),
            body: {
              // We have to put `as XMLHttpRequest` because tus-js-client
              // returns `any`, as the type differs in Node.js and the browser.
              // In the browser it's always `XMLHttpRequest`.
              xhr: originalResponse.getUnderlyingObject() as XMLHttpRequest,
            } as unknown as B,
          }
        }

        const xhr =
          (err as tus.DetailedError).originalRequest != null
            ? (err as tus.DetailedError).originalRequest.getUnderlyingObject()
            : null
        if (isNetworkError(xhr)) {
          err = new NetworkError(err, xhr)
        }

        // Do not abort the request here: it has already completed, and aborting
        // it would reset the underlying `xhr` (status `0`, empty body) and
        // discard the response we just captured. We still drop our references
        // and remove the event listeners.
        this.#resetUploaderReferences(file.id)
        queuedRequest?.abort()

        if (typeof opts.onError === 'function') {
          opts.onError(err)
        }
        reject(err)
      }

      uploadOptions.onProgress = (bytesUploaded, bytesTotal) => {
        this.onReceiveUploadUrl(file, upload.url)
        if (typeof opts.onProgress === 'function') {
          opts.onProgress(bytesUploaded, bytesTotal)
        }
        const latestFile = this.uppy.getFile(file.id)
        this.uppy.emit('upload-progress', latestFile, {
          uploadStarted: latestFile.progress.uploadStarted ?? 0,
          bytesUploaded,
          bytesTotal,
        })
      }

      uploadOptions.onSuccess = (payload) => {
        const uploadResp: UppyFile<M, B>['response'] = {
          uploadURL: upload.url ?? undefined,
          status: 200,
          body: {
            // We have to put `as XMLHttpRequest` because tus-js-client
            // returns `any`, as the type differs in Node.js and the browser.
            // In the browser it's always `XMLHttpRequest`.
            xhr: payload.lastResponse.getUnderlyingObject() as XMLHttpRequest,
            // Body extends Record<string, unknown> and thus `xhr` is not known
            // but we export the `TusBody` type, which people pass as a generic into the Uppy class,
            // so on the implementer side it works as expected.
          } as unknown as B,
        }

        this.uppy.emit('upload-success', this.uppy.getFile(file.id), uploadResp)

        this.#abortUploader(file.id)
        this.#resetUploaderReferences(file.id)
        queuedRequest.done()

        if (upload.url) {
          this.uppy.log(`Download ${upload.url}`)
        }
        if (typeof opts.onSuccess === 'function') {
          opts.onSuccess(payload)
        }

        resolve(upload)
      }

      const defaultOnShouldRetry = (err: tus.DetailedError) => {
        const status = err?.originalResponse?.getStatus()

        if (status === 429) {
          // HTTP 429 Too Many Requests => to avoid the whole download to fail, pause all requests.
          if (!this.requests.isPaused) {
            const next = this.#retryDelayIterator?.next()
            if (next == null || next.done) {
              return false
            }
            this.requests.rateLimit(next.value)
          }
        } else if (
          status != null &&
          status >= 400 &&
          status < 500 &&
          status !== 409 &&
          status !== 423
        ) {
          // HTTP 4xx, the server won't send anything, it's doesn't make sense to retry
          // HTTP 409 Conflict (happens if the Upload-Offset header does not match the one on the server)
          // HTTP 423 Locked (happens when a paused download is resumed too quickly)
          return false
        } else if (
          typeof navigator !== 'undefined' &&
          navigator.onLine === false
        ) {
          // The navigator is offline, let's wait for it to come back online.
          if (!this.requests.isPaused) {
            this.requests.pause()
            window.addEventListener(
              'online',
              () => {
                this.requests.resume()
              },
              { once: true },
            )
          }
        }
        queuedRequest.abort()
        queuedRequest = {
          shouldBeRequeued: true,
          abort() {
            this.shouldBeRequeued = false
          },
          done() {
            throw new Error(
              'Cannot mark a queued request as done: this indicates a bug',
            )
          },
          fn() {
            throw new Error('Cannot run a queued request: this indicates a bug')
          },
        }
        return true
      }

      if (onShouldRetry != null) {
        uploadOptions.onShouldRetry = (
          error: tus.DetailedError,
          retryAttempt: number,
        ) => onShouldRetry(error, retryAttempt, opts, defaultOnShouldRetry)
      } else {
        uploadOptions.onShouldRetry = defaultOnShouldRetry
      }

      const copyProp = (
        obj: Record<string, unknown>,
        srcProp: string,
        destProp: string,
      ) => {
        if (hasProperty(obj, srcProp) && !hasProperty(obj, destProp)) {
          obj[destProp] = obj[srcProp]
        }
      }

      // We can't use `allowedMetaFields` to index generic M
      // and we also don't care about the type specifically here,
      // we just want to pass the meta fields along.
      const meta: Record<string, string> = {}
      const allowedMetaFields = getAllowedMetaFields(
        opts.allowedMetaFields,
        file.meta,
      )
      allowedMetaFields.forEach((item) => {
        // tus type definition for metadata only accepts `Record<string, string>`
        // but in reality (at runtime) it accepts `Record<string, unknown>`
        // tus internally converts everything into a string, but let's do it here instead to be explicit.
        // because Uppy can have anything inside meta values, (for example relativePath: null is often sent by uppy)
        meta[item] = String(file.meta[item])
      })

      // tusd uses metadata fields 'filetype' and 'filename'
      copyProp(meta, 'type', 'filetype')
      copyProp(meta, 'name', 'filename')

      uploadOptions.metadata = meta

      if (file.data == null) throw new Error('File data is empty')
      upload = new tus.Upload(file.data, uploadOptions)
      this.uploaders[file.id] = upload
      const eventManager = new EventManager(this.uppy)
      this.uploaderEvents[file.id] = eventManager

      qRequest = () => {
        if (!file.isPaused) {
          upload.start()
        }
        // Don't do anything here, the caller will take care of cancelling the upload itself
        // using #resetUploaderReferences(). This is because #resetUploaderReferences() has to be
        // called when this request is still in the queue, and has not been started yet, too. At
        // that point this cancellation function is not going to be called.
        // Also, we need to remove the request from the queue _without_ destroying everything
        // related to this upload to handle pauses.
        return () => {}
      }

      upload.findPreviousUploads().then((previousUploads) => {
        const previousUpload = previousUploads[0]
        if (previousUpload) {
          this.uppy.log(
            `[Tus] Resuming upload of ${file.id} started at ${previousUpload.creationTime}`,
          )
          upload.resumeFromPreviousUpload(previousUpload)
        }
        queuedRequest = this.requests.run(qRequest)
      })

      eventManager.onFileRemove(file.id, (targetFileID) => {
        queuedRequest.abort()
        this.#abortUploader(file.id, !!upload.url)
        this.#resetUploaderReferences(file.id)
        resolve(`upload ${targetFileID} was removed`)
      })

      eventManager.onPause(file.id, (isPaused) => {
        queuedRequest.abort()
        if (isPaused) {
          // Remove this file from the queue so another file can start in its place.
          upload.abort()
        } else {
          // Resuming an upload should be queued, else you could pause and then
          // resume a queued upload to make it skip the queue.
          queuedRequest = this.requests.run(qRequest)
        }
      })

      eventManager.onPauseAll(file.id, () => {
        queuedRequest.abort()
        upload.abort()
      })

      eventManager.onCancelAll(file.id, () => {
        queuedRequest.abort()
        this.#abortUploader(file.id, !!upload.url)
        this.#resetUploaderReferences(file.id)
        resolve(`upload ${file.id} was canceled`)
      })

      eventManager.onResumeAll(file.id, () => {
        queuedRequest.abort()
        if (file.error) {
          upload.abort()
        }
        queuedRequest = this.requests.run(qRequest)
      })
    }).catch((err) => {
      // `errorResponse` is captured in the `onError` handler above (the request
      // is intentionally not aborted there), so the server response is still
      // available here to forward to the `upload-error` event.
      this.uppy.emit('upload-error', file, err, errorResponse)
      throw err
    })
  }

  /**
   * Store the uploadUrl on the file options, so that when Golden Retriever
   * restores state, we will continue uploading to the correct URL.
   */
  onReceiveUploadUrl(file: UppyFile<M, B>, uploadURL: string | null): void {
    const currentFile = this.uppy.getFile(file.id)
    if (!currentFile) return
    // Only do the update if we didn't have an upload URL yet.
    if (!currentFile.tus || currentFile.tus.uploadUrl !== uploadURL) {
      this.uppy.log('[Tus] Storing upload url')
      this.uppy.setFileState(currentFile.id, {
        tus: { ...currentFile.tus, uploadUrl: uploadURL },
      })
    }
  }

  #getCompanionClientArgs(file: UppyFile<M, B>) {
    const opts = { ...this.opts }

    if (file.tus) {
      // Install file-specific upload overrides.
      Object.assign(opts, file.tus)
    }

    if (typeof opts.headers === 'function') {
      opts.headers = opts.headers(file)
    }

    return {
      ...('remote' in file && file.remote.body),
      endpoint: opts.endpoint,
      uploadUrl: opts.uploadUrl,
      protocol: 'tus',
      size: file.data!.size,
      headers: opts.headers,
      metadata: file.meta,
    }
  }

  async #uploadFiles(files: UppyFile<M, B>[]) {
    const filesFiltered = filterFilesToUpload(files)
    const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered)
    this.uppy.emit('upload-start', filesToEmit)

    await Promise.allSettled(
      filesFiltered.map((file) => {
        if (file.isRemote) {
          const getQueue = () => this.requests
          const controller = new AbortController()

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

          const uploadPromise = this.uppy
            .getRequestClientForFile<RequestClient<M, B>>(file)
            .uploadRemoteFile(file, this.#getCompanionClientArgs(file), {
              signal: controller.signal,
              getQueue,
            })

          this.requests.wrapSyncFunction(
            () => {
              this.uppy.off('file-removed', removedHandler)
            },
            { priority: -1 },
          )()

          return uploadPromise
        }

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

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

    if (this.opts.limit === 0) {
      this.uppy.log(
        '[Tus] 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/tus/#limit-0',
        'warning',
      )
    }

    this.uppy.log('[Tus] Uploading...')
    const filesToUpload = this.uppy.getFilesByIds(fileIDs)

    await this.#uploadFiles(filesToUpload)
  }

  install(): void {
    this.uppy.setState({
      capabilities: {
        ...this.uppy.getState().capabilities,
        resumableUploads: true,
      },
    })
    this.uppy.addUploader(this.#handleUpload)
  }

  uninstall(): void {
    this.uppy.setState({
      capabilities: {
        ...this.uppy.getState().capabilities,
        resumableUploads: false,
      },
    })
    this.uppy.removeUploader(this.#handleUpload)
  }
}
