import { AbstractAccessor } from "../AbstractAccessor";
import {
  isBlob,
  isBuffer,
  toArrayBuffer,
  toBase64,
  toBlob,
} from "../BinaryConverter";
import {
  AbstractFileError,
  InvalidModificationError,
  NotFoundError,
} from "../FileError";
import { DIR_SEPARATOR } from "../FileSystemConstants";
import { Record } from "../FileSystemIndex";
import { FileSystemObject } from "../FileSystemObject";
import { FileSystemOptions } from "../FileSystemOptions";
import { getName, getSize } from "../FileSystemUtil";
import { objectToText } from "../ObjectUtil";
import { textToUint8Array } from "../TextConverter";
import { IdbFileSystem } from "./IdbFileSystem";
import { countSlash, getRange } from "./IdbUtil";

const ENTRY_STORE = "entries";
const CONTENT_STORE = "contents";

// eslint-disable-next-line
const indexedDB: IDBFactory =
  window.indexedDB || window.mozIndexedDB || window.msIndexedDB;

export class IdbAccessor extends AbstractAccessor {
  private static SUPPORTS_ARRAY_BUFFER: boolean;
  private static SUPPORTS_BLOB: boolean;

  public filesystem: IdbFileSystem;

  constructor(private dbName: string, options: FileSystemOptions) {
    super(options);
    this.filesystem = new IdbFileSystem(this);
  }

  public get name() {
    return this.dbName;
  }

  public async doDelete(fullPath: string, _isFile: boolean) {
    try {
      await this.doGetObject(fullPath);
    } catch {
      // NotFoundError
      return;
    }

    await this.doDeleteWithStore(ENTRY_STORE, fullPath);
    await this.doDeleteWithStore(CONTENT_STORE, fullPath);
  }

  public doGetObject(fullPath: string) {
    return new Promise<FileSystemObject>((resolve, reject) => {
      void (async () => {
        const db = await this.open(this.dbName);
        const tx = db.transaction([ENTRY_STORE], "readonly");
        const range = IDBKeyRange.only(fullPath);
        const onError = (ev: Event) => {
          const req = ev.target as IDBRequest;
          db.close();
          reject(new NotFoundError(this.name, fullPath, req.error || ev));
        };
        tx.onabort = onError;
        tx.onerror = onError;
        const request = tx.objectStore(ENTRY_STORE).get(range);
        const onSuccess = () => {
          db.close();
          if (request.result != null) {
            resolve(request.result); // eslint-disable-line
          } else {
            reject(new NotFoundError(this.name, fullPath));
          }
        };
        tx.oncomplete = onSuccess;
        request.onerror = onError;
      })();
    });
  }

  public doGetObjects(fullPath: string) {
    return new Promise<FileSystemObject[]>((resolve, reject) => {
      void (async () => {
        const db = await this.open(this.dbName);
        const tx = db.transaction([ENTRY_STORE], "readonly");
        const onError = (ev: Event) => {
          const req = ev.target as IDBRequest;
          db.close();
          reject(new NotFoundError(this.name, fullPath, req.error || ev));
        };
        tx.onabort = onError;
        tx.onerror = onError;
        const objects: FileSystemObject[] = [];
        tx.oncomplete = () => {
          db.close();
          resolve(objects);
        };

        let slashCount: number;
        if (fullPath === DIR_SEPARATOR) {
          slashCount = 1;
        } else {
          slashCount = countSlash(fullPath) + 1; // + 1 is the last slash for directory
        }
        const range = getRange(fullPath);
        const request = tx.objectStore(ENTRY_STORE).openCursor(range);
        request.onsuccess = (ev) => {
          const cursor = <IDBCursorWithValue>(<IDBRequest>ev.target).result;
          if (cursor) {
            const obj = cursor.value as FileSystemObject;

            if (slashCount === countSlash(obj.fullPath)) {
              objects.push(obj);
            }

            cursor.continue();
          }
        };
        request.onerror = onError;
      })();
    });
  }

  public doMakeDirectory(fullPath: string) {
    return this.doPutObjectIDB({ fullPath, name: getName(fullPath) });
  }

  public doPutObjectIDB(obj: FileSystemObject) {
    return new Promise<void>((resolve, reject) => {
      void (async () => {
        const db = await this.open(this.dbName);
        const entryTx = db.transaction([ENTRY_STORE], "readwrite");
        const onError = (ev: Event) => {
          db.close();
          reject(new InvalidModificationError(this.name, obj.fullPath, ev));
        };
        entryTx.onabort = onError;
        entryTx.onerror = onError;
        entryTx.oncomplete = () => {
          db.close();
          resolve();
        };
        const entryReq = entryTx
          .objectStore(ENTRY_STORE)
          .put(obj, obj.fullPath);
        entryReq.onerror = onError;
      })();
    });
  }

  public doReadContent(
    fullPath: string
  ): Promise<Blob | BufferSource | string> {
    // eslint-disable-next-line @typescript-eslint/no-unsafe-return
    return new Promise<any>((resolve, reject) => {
      void (async () => {
        const db = await this.open(this.dbName);
        const onError = (ev: Event) => {
          const req = ev.target as IDBRequest;
          db.close();
          reject(new NotFoundError(this.name, fullPath, req.error || ev));
        };
        const tx = db.transaction([CONTENT_STORE], "readonly");
        const range = IDBKeyRange.only(fullPath);
        tx.onabort = onError;
        tx.onerror = onError;
        const request = tx.objectStore(CONTENT_STORE).get(range);
        request.onerror = onError;
        const name = this.name;
        tx.oncomplete = () => {
          db.close();
          if (request.result != null) {
            resolve(request.result);
          } else {
            reject(new NotFoundError(name, fullPath));
          }
        };
      })();
    });
  }

  public async doWriteContent(
    fullPath: string,
    content: Blob | BufferSource | string
  ) {
    try {
      const obj: FileSystemObject = {
        fullPath: fullPath,
        name: getName(fullPath),
        lastModified: Date.now(),
        size: getSize(content),
      };
      await this.doPutObjectIDB(obj);

      if (typeof content === "string") {
        await this.doWriteBase64(fullPath, content);
      } else if (isBlob(content)) {
        await this.doWriteBlob(fullPath, content);
      } else if (isBuffer(content)) {
        await this.doWriteBuffer(fullPath, content);
      } else if (ArrayBuffer.isView(content)) {
        await this.doWriteUint8Array(fullPath, content as Uint8Array);
      } else {
        await this.doWriteArrayBuffer(fullPath, content);
      }
    } catch (e) {
      if (e instanceof AbstractFileError) {
        throw e;
      }
      throw new InvalidModificationError(this.name, fullPath, e);
    }
  }

  public async open(dbName: string) {
    if (
      IdbAccessor.SUPPORTS_BLOB == null ||
      IdbAccessor.SUPPORTS_ARRAY_BUFFER == null
    ) {
      await this.initializeDB();
    }

    return new Promise<IDBDatabase>((resolve, reject) => {
      const onError = (ev: Event) => {
        const req = ev.target as IDBRequest;
        const db = req.result as IDBDatabase;
        db?.close();
        reject(`open failure (${req.error || ev}): ${dbName}`); // eslint-disable-line
      };
      const request = indexedDB.open(dbName.replace(":", "_"));
      request.onerror = onError;
      request.onblocked = onError;
      request.onupgradeneeded = (ev) => {
        const request = ev.target as IDBRequest;
        /* eslint-disable */
        const db = request.result;
        if (!db.objectStoreNames.contains(ENTRY_STORE)) {
          db.createObjectStore(ENTRY_STORE);
        }
        if (!db.objectStoreNames.contains(CONTENT_STORE)) {
          db.createObjectStore(CONTENT_STORE);
        }
        /* eslint-enable */
      };
      request.onsuccess = (e) => {
        const db = (e.target as IDBRequest).result; // eslint-disable-line
        resolve(db); // eslint-disable-line
      };
    });
  }

  public async purge() {
    await this.drop();
  }

  protected async doSaveRecord(indexPath: string, record: Record) {
    const text = objectToText(record);
    const u8 = textToUint8Array(text);
    await this.doWriteContent(indexPath, u8);

    await this.doPutObjectIDB({
      fullPath: indexPath,
      name: getName(indexPath),
      lastModified: record.modified,
      size: getSize(u8),
    });
  }

  protected async doWriteArrayBuffer(
    fullPath: string,
    ab: ArrayBuffer
  ): Promise<void> {
    let content: Blob | Uint8Array | ArrayBuffer | string;
    if (IdbAccessor.SUPPORTS_ARRAY_BUFFER) {
      content = ab;
    } else if (IdbAccessor.SUPPORTS_BLOB) {
      content = toBlob(ab); // eslint-disable-line
    } else {
      content = await toBase64(ab);
    }
    await this.doWriteContentToIdb(fullPath, content);
  }

  protected async doWriteBase64(
    fullPath: string,
    base64: string
  ): Promise<void> {
    await this.doWriteContentToIdb(fullPath, base64);
  }

  protected async doWriteBlob(fullPath: string, blob: Blob): Promise<void> {
    let content: Blob | BufferSource | string;
    if (IdbAccessor.SUPPORTS_BLOB) {
      content = blob;
    } else if (IdbAccessor.SUPPORTS_ARRAY_BUFFER) {
      content = await toArrayBuffer(blob);
    } else {
      content = await toBase64(blob);
    }
    await this.doWriteContentToIdb(fullPath, content);
  }

  protected async doWriteBuffer(
    fullPath: string,
    buffer: Buffer
  ): Promise<void> {
    const arrayBuffer = buffer.buffer.slice(
      buffer.byteOffset,
      buffer.byteOffset + buffer.byteLength
    );
    await this.doWriteArrayBuffer(fullPath, arrayBuffer);
  }

  private async doDeleteWithStore(storeName: string, fullPath: string) {
    await new Promise<void>((resolve, reject) => {
      void (async () => {
        const db = await this.open(this.dbName);
        const entryTx = db.transaction([storeName], "readwrite");
        const onError = (ev: Event) => {
          const req = ev.target as IDBRequest;
          db.close();
          reject(
            `doDeleteWithStore failure (${
              req.error || ev // eslint-disable-line
            }): ${fullPath} of ${storeName}`
          );
        };
        entryTx.onabort = onError;
        entryTx.onerror = onError;
        entryTx.oncomplete = () => {
          db.close();
          resolve();
        };
        let range = IDBKeyRange.only(fullPath); // eslint-disable-line
        const request = entryTx.objectStore(storeName).delete(range);
        request.onerror = onError;
      })();
    });
  }

  private doWriteContentToIdb(fullPath: string, content: any) {
    return new Promise<void>((resolve, reject) => {
      void (async () => {
        const db = await this.open(this.dbName);
        const contentTx = db.transaction([CONTENT_STORE], "readwrite");
        const onError = (ev: Event) => {
          db.close();
          reject(new InvalidModificationError(this.name, fullPath, ev));
        };
        contentTx.onabort = onError;
        contentTx.onerror = onError;
        contentTx.oncomplete = () => {
          db.close();
          resolve();
        };
        const contentReq = contentTx
          .objectStore(CONTENT_STORE)
          .put(content, fullPath);
        contentReq.onerror = onError;
      })();
    });
  }

  private drop() {
    return new Promise<void>((resolve) => {
      void (async () => {
        const dbName = this.dbName;
        const db = await this.open(dbName);
        const onError = (ev: Event) => {
          db.close();
          console.debug(ev); // Not Found
          resolve();
        };
        const request = indexedDB.deleteDatabase(dbName);
        request.onblocked = onError;
        request.onerror = onError;
        request.onsuccess = () => {
          db.close();
          resolve();
        };
      })();
    });
  }

  private async initializeDB() {
    await new Promise<void>((resolve, reject) => {
      const dbName = "blob-support";
      indexedDB.deleteDatabase(dbName).onsuccess = function () {
        const request = indexedDB.open(dbName, 1);
        const onError = (ev: Event) => {
          const req = ev.target as IDBRequest;
          const db = req.result as IDBDatabase;
          db?.close();
          reject(ev);
        };
        request.onupgradeneeded = () =>
          request.result.createObjectStore("store");
        request.onsuccess = () => {
          const db = request.result;
          try {
            const blob = new Blob(["test"]);
            const transaction = db.transaction("store", "readwrite");
            transaction.objectStore("store").put(blob, "key");
            IdbAccessor.SUPPORTS_BLOB = true;
          } catch (err) {
            IdbAccessor.SUPPORTS_BLOB = false;
          } finally {
            db.close();
            indexedDB.deleteDatabase(dbName);
          }
          resolve();
        };
        request.onerror = onError;
        request.onblocked = onError;
      };
    });
    await new Promise<void>((resolve, reject) => {
      const dbName = "arraybuffer-support";
      indexedDB.deleteDatabase(dbName).onsuccess = function () {
        const request = indexedDB.open(dbName, 1);
        const onError = (ev: Event) => {
          const req = ev.target as IDBRequest;
          const db = req.result as IDBDatabase;
          db?.close();
          reject(ev);
        };
        request.onupgradeneeded = () =>
          request.result.createObjectStore("store");
        request.onsuccess = () => {
          const db = request.result;
          try {
            const buffer = new ArrayBuffer(10);
            const transaction = db.transaction("store", "readwrite");
            transaction.objectStore("store").put(buffer, "key");
            IdbAccessor.SUPPORTS_ARRAY_BUFFER = true;
          } catch (err) {
            IdbAccessor.SUPPORTS_ARRAY_BUFFER = false;
          } finally {
            db.close();
            indexedDB.deleteDatabase(dbName);
          }
          resolve();
        };
        request.onerror = onError;
        request.onblocked = onError;
      };
    });
  }
}
