import type { UppyFileId } from '@uppy/core/utils'

const indexedDB =
  typeof window !== 'undefined' &&
  (window.indexedDB ||
    // @ts-expect-error unknown
    window.webkitIndexedDB ||
    // @ts-expect-error unknown
    window.mozIndexedDB ||
    // @ts-expect-error unknown
    window.OIndexedDB ||
    // @ts-expect-error unknown
    window.msIndexedDB)

const isSupported = !!indexedDB

export const DB_NAME = 'uppy-blobs'
const STORE_NAME = 'files' // maybe have a thumbnail store in the future

export const METADATA_STORE_NAME = 'metadata'
const DEFAULT_EXPIRY = 24 * 60 * 60 * 1000 // 24 hours
const DB_VERSION = 4
const MiB = 0x10_00_00

/**
 * Set default `expires` dates on existing stored blobs.
 */
function migrateExpiration(store: IDBObjectStore): void {
  const request = store.openCursor()
  request.onsuccess = (event) => {
    const cursor = (event.target as IDBRequest).result
    if (!cursor) {
      return
    }
    const entry = cursor.value
    entry.expires = Date.now() + DEFAULT_EXPIRY
    cursor.update(entry)
  }
}

export function connect(dbName: string): Promise<IDBDatabase> {
  const request = (indexedDB as IDBFactory).open(dbName, DB_VERSION)
  return new Promise((resolve, reject) => {
    request.onupgradeneeded = (event) => {
      const db: IDBDatabase = (event.target as IDBOpenDBRequest).result
      closeOnVersionChange(db)
      const transaction = (event.currentTarget as IDBOpenDBRequest)
        .transaction as IDBTransaction

      if (event.oldVersion < 2) {
        // Added in v2: DB structure changed to a single shared object store
        const store = db.createObjectStore(STORE_NAME, { keyPath: 'id' })
        store.createIndex('store', 'store', { unique: false })
      }

      if (event.oldVersion < 3) {
        // Added in v3
        const store = transaction.objectStore(STORE_NAME)
        store.createIndex('expires', 'expires', { unique: false })

        migrateExpiration(store)
      }

      if (event.oldVersion < 4) {
        // Added in v4: a store for GoldenRetriever's recovery snapshot, which
        // moved out of localStorage to escape its ~5MB quota. See issue #6280.
        const store = db.createObjectStore(METADATA_STORE_NAME, {
          keyPath: 'id',
        })
        store.createIndex('expires', 'expires', { unique: false })
      }

      transaction.oncomplete = () => {
        resolve(db)
      }
    }
    request.onsuccess = (event) => {
      const db = (event.target as IDBRequest).result as IDBDatabase
      closeOnVersionChange(db)
      resolve(db)
    }
    request.onerror = reject
  })
}

/**
 * Close this connection when another tab/instance requests a higher DB version,
 * so we never block its upgrade (and it never blocks ours). The connection is
 * unusable afterwards, but recovery persistence is best-effort.
 */
function closeOnVersionChange(db: IDBDatabase): void {
  db.onversionchange = () => db.close()
}

export function waitForRequest<T>(request: IDBRequest): Promise<T> {
  return new Promise((resolve, reject) => {
    request.onsuccess = (event) => {
      resolve((event.target as IDBRequest).result)
    }
    request.onerror = reject
  })
}

type AddFilePayload = { id: UppyFileId; data: Blob }

type IndexedDBStoredFile = {
  id: string
  fileID: string
  store: string
  expires: number
  data: Blob
}

type IndexedDBStoreOptions = {
  dbName?: string
  storeName?: string
  expires?: number
  maxFileSize?: number
  maxTotalSize?: number
}

let cleanedUp = false
class IndexedDBStore {
  #ready: Promise<IDBDatabase> | IDBDatabase

  opts: Required<IndexedDBStoreOptions>

  name: string

  static isSupported: boolean

  constructor(opts?: IndexedDBStoreOptions) {
    this.opts = {
      dbName: DB_NAME,
      storeName: 'default',
      expires: DEFAULT_EXPIRY, // 24 hours
      maxFileSize: 10 * MiB,
      maxTotalSize: 300 * MiB,
      ...opts,
    }

    this.name = this.opts.storeName

    const createConnection = async () => {
      const db = await connect(this.opts.dbName)
      this.#ready = db
      return db
    }

    if (!cleanedUp) {
      cleanedUp = true
      this.#ready = IndexedDBStore.cleanup().then(
        createConnection,
        createConnection,
      )
    } else {
      this.#ready = createConnection()
    }
  }

  get ready(): Promise<IDBDatabase> {
    return Promise.resolve(this.#ready)
  }

  key(fileID: UppyFileId): string {
    return `${this.name}!${fileID}`
  }

  /**
   * List all file blobs currently in the store.
   */
  async list(): Promise<Record<UppyFileId, IndexedDBStoredFile['data']>> {
    const db = await this.#ready
    const transaction = db.transaction([STORE_NAME], 'readonly')
    const store = transaction.objectStore(STORE_NAME)
    const request = store.index('store').getAll(IDBKeyRange.only(this.name))
    const files = await waitForRequest<IndexedDBStoredFile[]>(request)
    return Object.fromEntries(files.map((file) => [file.fileID, file.data]))
  }

  /**
   * Get one file blob from the store.
   */
  async get(fileID: string): Promise<{ id: string; data: Blob }> {
    const db = await this.#ready
    const transaction = db.transaction([STORE_NAME], 'readonly')
    const request = transaction.objectStore(STORE_NAME).get(this.key(fileID))
    const { data } = await waitForRequest<{
      data: { data: Blob; fileID: string }
    }>(request)
    return {
      id: data.fileID,
      data: data.data,
    }
  }

  /**
   * Get the total size of all stored files.
   */
  async getSize(): Promise<number> {
    const db = await this.#ready
    const transaction = db.transaction([STORE_NAME], 'readonly')
    const store = transaction.objectStore(STORE_NAME)
    const request = store.index('store').openCursor(IDBKeyRange.only(this.name))
    return new Promise((resolve, reject) => {
      let size = 0
      request.onsuccess = (event) => {
        const cursor = (event.target as IDBRequest).result
        if (cursor) {
          size += cursor.value.data.size
          cursor.continue()
        } else {
          resolve(size)
        }
      }
      request.onerror = () => {
        reject(new Error('Could not retrieve stored blobs size'))
      }
    })
  }

  /**
   * Save a file in the store.
   */
  async put<T>(file: AddFilePayload): Promise<T> {
    if (file.data.size != null && file.data.size > this.opts.maxFileSize) {
      throw new Error('File is too big to store.')
    }
    const size = await this.getSize()
    if (size > this.opts.maxTotalSize) {
      throw new Error('No space left')
    }
    const db = await this.#ready
    const transaction = db.transaction([STORE_NAME], 'readwrite')
    const request = transaction.objectStore(STORE_NAME).add({
      id: this.key(file.id),
      fileID: file.id,
      store: this.name,
      expires: Date.now() + this.opts.expires,
      data: file.data,
    })
    return waitForRequest(request)
  }

  /**
   * Delete a file blob from the store.
   */
  async delete(fileID: UppyFileId): Promise<unknown> {
    const db = await this.#ready
    const transaction = db.transaction([STORE_NAME], 'readwrite')
    const request = transaction.objectStore(STORE_NAME).delete(this.key(fileID))
    return waitForRequest(request)
  }

  /**
   * Delete every expired entry in a store, using its `expires` index.
   */
  static #deleteExpired(store: IDBObjectStore): Promise<void> {
    const request = store
      .index('expires')
      .openCursor(IDBKeyRange.upperBound(Date.now()))
    return new Promise<void>((resolve, reject) => {
      request.onsuccess = (event) => {
        const cursor = (event.target as IDBRequest).result
        if (cursor) {
          cursor.delete() // Ignoring return value … it's not terrible if this goes wrong.
          cursor.continue()
        } else {
          resolve()
        }
      }
      request.onerror = reject
    })
  }

  /**
   * Delete all stored blobs and recovery snapshots that have an expiry date
   * before Date.now(). This is a static method because it deletes expired data
   * from _all_ Uppy instances.
   */
  static async cleanup(): Promise<void> {
    const db = await connect(DB_NAME)
    try {
      const transaction = db.transaction(
        [STORE_NAME, METADATA_STORE_NAME],
        'readwrite',
      )
      await Promise.all([
        IndexedDBStore.#deleteExpired(transaction.objectStore(STORE_NAME)),
        IndexedDBStore.#deleteExpired(
          transaction.objectStore(METADATA_STORE_NAME),
        ),
      ])
    } finally {
      // Always release the connection, even if expiry fails — otherwise a
      // rejected delete would leak the connection (and could block upgrades).
      db.close()
    }
  }
}

IndexedDBStore.isSupported = isSupported

export default IndexedDBStore
