import type { RangeResponse, Source } from 'pmtiles'
/**
 * Offline PMTiles source for PWAs, storing files in IndexedDB.
 *
 * Unlike the original pmtiles-offline (which stored each file as a single
 * Blob and did `store.get(filename)` on every byte-range request), this
 * splits each file into fixed-size chunks and reads only the chunks a
 * range actually touches. On engines that materialize IndexedDB Blobs into
 * memory on read (notably Safari), the old approach pulled the entire
 * file — potentially >1GB — into memory for every tile fetch. Chunking
 * caps the per-read cost at a couple of CHUNK_SIZE slices regardless of
 * how large the stored file is.
 *
 * Writes are streamed: `setSourceFromStream` consumes a ReadableStream and
 * persists chunks as they arrive, so a multi-hundred-MB download never has
 * to be buffered into a single in-memory ArrayBuffer.
 */

// Per-read cost is bounded by the chunks a range spans. Smaller chunks
// reduce over-read on Safari (where each chunk is materialized whole) at
// the cost of more records per file. 256 KiB comfortably holds a typical
// MVT tile in one chunk while keeping india.pmtiles to ~6.5k records.
const CHUNK_SIZE = 256 * 1024

// Chunks per write transaction. Bounds the streaming writer's working set
// to ~WRITE_BATCH * CHUNK_SIZE (2 MiB) while amortizing transaction cost.
const WRITE_BATCH = 8

export interface OfflineSource extends Source {
  setSource(source: PMTilesBlob): Promise<void>
  exists(): Promise<boolean>
}

export interface PMTilesBlob {
  filename: string
  blob: Blob
}

export interface Options {
  logLevel?: string
}

interface MetaRecord {
  filename: string
  size: number
  chunkSize: number
  chunks: number
}

interface ChunkRecord {
  filename: string
  /** Chunk bytes. Written as an ArrayBuffer by current versions. */
  data?: ArrayBuffer
  /**
   * Legacy records stored chunks as Blobs. WebKit keeps IDB Blobs as
   * references to files on disk and can permanently lose the backing file
   * (`NotReadableError: The I/O read operation failed`), so new writes use
   * `data`; this field remains only so pre-existing downloads keep reading.
   */
  blob?: Blob
}

const logLevels = ['debug', 'warn', 'error', 'silent']
enum LogLevel {
  Debug = 0,
  Warn = 1,
  Error = 2,
  Silent = 3,
}

export class IndexedDBSource implements OfflineSource {
  public db: IDBDatabase
  public filename: string
  public tablename: string
  public logLevel: LogLevel = LogLevel.Debug
  #meta: MetaRecord | undefined

  constructor(
    db: IDBDatabase,
    filename: string,
    tablename = 'offline-pmtiles',
    options: Options = { logLevel: 'debug' },
  ) {
    this.filename = filename
    this.db = db
    this.tablename = tablename
    const logLevelNum = options.logLevel
      ? logLevels.indexOf(options.logLevel)
      : -1
    if (logLevelNum != -1) this.logLevel = logLevelNum
  }

  public static openDb(
    dbname: string,
    tablename: string,
  ): Promise<IDBDatabase> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(dbname, 1)
      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result
        if (!db.objectStoreNames.contains(tablename)) {
          db.createObjectStore(tablename, { keyPath: 'filename' })
        }
      }
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    })
  }

  // Key under which chunk `i` of this file is stored. Filenames never
  // contain a space, so chunk keys can't collide with the meta record
  // (keyed by the bare filename).
  #chunkKey(i: number): string {
    return `${this.filename} ${i}`
  }

  // Range covering every chunk key for this file: `${filename} 0` ..
  // `${filename} <max>`. Used to bulk-clear a file's chunks.
  #chunkRange(): IDBKeyRange {
    return IDBKeyRange.bound(`${this.filename} `, `${this.filename} `)
  }

  #txDone(tx: IDBTransaction): Promise<void> {
    return new Promise((resolve, reject) => {
      tx.oncomplete = () => resolve()
      tx.onerror = () => reject(tx.error)
      tx.onabort = () => reject(tx.error)
    })
  }

  #get<T>(store: IDBObjectStore, key: IDBValidKey): Promise<T | undefined> {
    return new Promise((resolve) => {
      const request = store.get(key)
      request.onsuccess = () => resolve(request.result as T | undefined)
      request.onerror = () => {
        if (this.logLevel <= LogLevel.Warn) {
          console.info(
            `error retrieving ${key} from ${this.tablename} in IndexedDB.`,
            request.error,
          )
        }
        resolve(undefined)
      }
    })
  }

  async #getMeta(): Promise<MetaRecord | undefined> {
    if (this.#meta) return this.#meta
    const tx = this.db.transaction(this.tablename, 'readonly')
    const store = tx.objectStore(this.tablename)
    const rec = await this.#get<MetaRecord>(store, this.filename)
    // A record without `chunks` is legacy single-blob data (or absent);
    // treat as not-present so it gets re-downloaded in chunked form.
    if (rec && typeof rec.chunks === 'number') this.#meta = rec
    return this.#meta
  }

  // Drop the meta record and every chunk for this file in one transaction.
  async #clear(): Promise<void> {
    const tx = this.db.transaction(this.tablename, 'readwrite')
    const store = tx.objectStore(this.tablename)
    store.delete(this.filename)
    store.delete(this.#chunkRange())
    await this.#txDone(tx)
    this.#meta = undefined
  }

  async exists(): Promise<boolean> {
    return (await this.#getMeta()) !== undefined
  }

  async setSource({ blob }: PMTilesBlob): Promise<void> {
    // Stream the blob rather than reading it whole; blob.stream() yields
    // it in pieces, so even this path stays memory-bounded.
    await this.setSourceFromStream(blob.stream())
  }

  /**
   * Persist a file from a byte stream, repacking the stream's arbitrary
   * read sizes into exact CHUNK_SIZE records (the read path assumes every
   * chunk but the last is exactly CHUNK_SIZE). The meta record is written
   * last and acts as the commit marker: if the stream aborts midway,
   * exists() returns false and the partial chunks are overwritten on the
   * next attempt.
   */
  async setSourceFromStream(stream: ReadableStream<Uint8Array>): Promise<void> {
    // Clear any prior/partial data so a shrunk chunk count can't leave
    // stale tail chunks behind.
    await this.#clear()

    const reader = stream.getReader()
    let index = 0
    let size = 0
    let carry: Uint8Array<ArrayBufferLike> = new Uint8Array(0)
    let batch: ChunkRecord[] = []

    const flush = async () => {
      if (batch.length === 0) return
      const tx = this.db.transaction(this.tablename, 'readwrite')
      const store = tx.objectStore(this.tablename)
      for (const rec of batch) store.put(rec)
      batch = []
      await this.#txDone(tx)
    }
    const emit = async (bytes: Uint8Array) => {
      batch.push({
        filename: this.#chunkKey(index++),
        data: toExactBuffer(bytes),
      })
      if (batch.length >= WRITE_BATCH) await flush()
    }

    try {
      while (true) {
        const { done, value } = await reader.read()
        if (done) break
        size += value.length
        carry = carry.length === 0 ? value : concat(carry, value)
        while (carry.length >= CHUNK_SIZE) {
          await emit(carry.slice(0, CHUNK_SIZE))
          carry = carry.slice(CHUNK_SIZE)
        }
      }
    } finally {
      reader.releaseLock()
    }
    if (carry.length > 0) await emit(carry)
    await flush()

    const tx = this.db.transaction(this.tablename, 'readwrite')
    const meta: MetaRecord = {
      filename: this.filename,
      size,
      chunkSize: CHUNK_SIZE,
      chunks: index,
    }
    tx.objectStore(this.tablename).put(meta)
    await this.#txDone(tx)
    this.#meta = meta
  }

  async getBytes(
    offset: number,
    length: number,
    signal?: AbortSignal,
  ): Promise<RangeResponse> {
    signal?.throwIfAborted()
    const startTime = this.logLevel <= LogLevel.Debug ? performance.now() : 0

    const meta = await this.#getMeta()
    if (!meta) return { data: new ArrayBuffer(0) }

    const { chunkSize, size } = meta
    const start = offset
    const end = Math.min(offset + length, size) // clamp over-reads to EOF
    if (end <= start) return { data: new ArrayBuffer(0) }

    const firstChunk = Math.floor(start / chunkSize)
    const lastChunk = Math.floor((end - 1) / chunkSize)

    // Issue all chunk reads synchronously within one transaction so it
    // stays active until every request is queued.
    const tx = this.db.transaction(this.tablename, 'readonly')
    const store = tx.objectStore(this.tablename)
    const reqs: Promise<ChunkRecord | undefined>[] = []
    for (let i = firstChunk; i <= lastChunk; i++) {
      reqs.push(this.#get<ChunkRecord>(store, this.#chunkKey(i)))
    }
    const records = await Promise.all(reqs)

    const out = new Uint8Array(end - start)
    for (let i = firstChunk; i <= lastChunk; i++) {
      const rec = records[i - firstChunk]
      if (!rec) continue
      const chunkStart = i * chunkSize
      const sliceStart = Math.max(start, chunkStart) - chunkStart
      const sliceEnd = Math.min(end, chunkStart + chunkSize) - chunkStart
      let buf: Uint8Array
      if (rec.data) {
        buf = new Uint8Array(rec.data, sliceStart, sliceEnd - sliceStart)
      } else if (rec.blob) {
        try {
          buf = new Uint8Array(
            await rec.blob.slice(sliceStart, sliceEnd).arrayBuffer(),
          )
        } catch (err) {
          // WebKit loses IDB-stored Blob bodies permanently (the backing file
          // is gone; every future read fails the same way). Drop the file so
          // `exists()` turns false and the app re-offers the download instead
          // of erroring forever.
          await this.#clear()
          throw new Error(
            `Stored chunks for ${this.filename} are unreadable; cleared for re-download.`,
            { cause: err },
          )
        }
      } else {
        continue
      }
      out.set(buf, chunkStart + sliceStart - start)
    }

    if (this.logLevel <= LogLevel.Debug) {
      const elapsed = performance.now() - startTime
      console.debug(
        `[IndexedDB] range=${start}-${end} (${end - start} bytes, ${
          lastChunk - firstChunk + 1
        } chunk(s)) in ${elapsed.toFixed(2)}ms`,
      )
    }
    return { data: out.buffer }
  }

  async deleteFile(): Promise<void> {
    await this.#clear()
  }

  deleteDatabase(): Promise<void> {
    const dbname = this.db.name
    return new Promise((resolve, reject) => {
      const request = indexedDB.deleteDatabase(dbname)
      request.onsuccess = () => resolve(undefined)
      request.onerror = () => reject(request.error)
      request.onblocked = () => {
        if (this.logLevel <= LogLevel.Warn) {
          console.warn(
            `Deletion of database ${dbname} is blocked. Close all connections to proceed.`,
          )
        }
      }
    })
  }

  getKey(): string {
    return `${this.filename}`
  }
}

function concat(a: Uint8Array, b: Uint8Array): Uint8Array {
  const out = new Uint8Array(a.length + b.length)
  out.set(a)
  out.set(b, a.length)
  return out
}

/**
 * The chunk's bytes as an exactly-sized ArrayBuffer, copying only when the
 * view doesn't already own its whole buffer (stream reads can hand out views
 * into larger allocations, and storing those would persist the excess).
 */
function toExactBuffer(bytes: Uint8Array): ArrayBuffer {
  if (bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
    return bytes.buffer as ArrayBuffer
  }
  return bytes.slice().buffer as ArrayBuffer
}
