import type { SaveData } from "./types";
import { InvalidSaveData } from "./utils/SaveDataUtils";
import { isSaveData, validateSaveData } from "./utils/TypeAssertion";

export class IndexedDBVersionError extends Error {
  constructor(message: string, options: ErrorOptions) {
    super(message, options);
    this.name = this.constructor.name;
  }
}

function getDB(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    if (!window.indexedDB) {
      reject(new Error("This browser does not support IndexedDB"));
    }
    /**
     * DB is called bitburnerSave
     * Object store is called savestring
     * key for the Object store is called save
     * Version `2` is important. When increasing the version, remember to update the code in electron/export.html.
     *
     * Version 1 is the initial version. We found a bug that caused the database to be missing the expected object
     * store. In order to add the missing object store, we need to either increase the database version or delete and
     * recreate the database. Increasing the version number is simpler. For more information, please check
     * https://github.com/bitburner-official/bitburner-src/pull/2590
     */
    const indexedDbRequest: IDBOpenDBRequest = window.indexedDB.open("bitburnerSave", 2);

    // This is called when there's no db to begin with. It's important, don't remove it.
    indexedDbRequest.onupgradeneeded = function (this: IDBRequest<IDBDatabase>) {
      const db = this.result;
      if (db.objectStoreNames.contains("savestring")) {
        return;
      }
      db.createObjectStore("savestring");
    };

    indexedDbRequest.onerror = function (this: IDBRequest<IDBDatabase>) {
      if (this.error?.name === "VersionError") {
        reject(new IndexedDBVersionError(this.error.message, { cause: this.error }));
      }
      reject(this.error ?? new Error("Cannot open a connection to IndexedDB"));
    };
    indexedDbRequest.onblocked = function (this: IDBRequest<IDBDatabase>) {
      reject(
        new Error("Database in use by another tab. Please close all other Bitburner tabs.", { cause: this.error }),
      );
    };

    indexedDbRequest.onsuccess = function (this: IDBRequest<IDBDatabase>) {
      const db = this.result;
      // This should never happen unless the browser is buggy.
      if (!db) {
        reject(new Error("Database opened successfully, but the result is somehow falsy."));
        return;
      }
      resolve(db);
    };
  });
}

/**
 * Load the save data from IndexedDB.
 *
 * Note that if skipCheckingLoadedData is true, there is no guarantee the resolved data is valid SaveData. Only pass
 * true to skipCheckingLoadedData if you don't care if the loaded data is valid SaveData (e.g., download a backup of the
 * save data, regardless of what it is).
 *
 * @param skipCheckingLoadedData
 * @returns
 */
export async function load(skipCheckingLoadedData = false): Promise<SaveData | undefined> {
  const db = await getDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["savestring"], "readonly");
    const rejectHandler = () => {
      reject(transaction.error ?? new Error("Cannot load game data. Transaction aborted or encountered an error."));
    };
    transaction.onerror = rejectHandler;
    transaction.onabort = rejectHandler;

    const objectStore = transaction.objectStore("savestring");
    const request = objectStore.get("save");

    request.onsuccess = function () {
      const result: unknown = request.result;
      // In some cases, we don't care if the loaded data is valid SaveData.
      if (skipCheckingLoadedData) {
        resolve(result as SaveData | undefined);
        return;
      }
      if (result !== undefined && !isSaveData(result)) {
        console.error("Invalid save data in IndexedDB:", result);
        reject(new Error("Save data exists, but its type is invalid."));
        return;
      }
      resolve(result);
    };
  });
}

export async function save(saveData: SaveData): Promise<void> {
  // Validate save data at runtime. Players may accidentally manipulate the prototypes of built-in JS objects and cause
  // functions in SaveObject.ts to generate invalid save data.
  const validationResult = validateSaveData(saveData);
  if (!validationResult.success) {
    throw new InvalidSaveData(validationResult.message);
  }
  const db = await getDB();
  return new Promise((resolve, reject) => {
    const transaction = db.transaction(["savestring"], "readwrite");
    const rejectHandler = () => {
      reject(transaction.error ?? new Error("Cannot save game data. Transaction aborted or encountered an error."));
    };
    transaction.onerror = rejectHandler;
    transaction.onabort = rejectHandler;

    const objectStore = transaction.objectStore("savestring");
    objectStore.put(saveData, "save");

    // transaction.oncomplete is used instead of request.onsuccess to ensure durability.
    // A request can succeed in memory, but the transaction may still fail to commit due to disk I/O errors, quota
    // limits, or system interruptions.
    transaction.oncomplete = () => resolve();
  });
}

export function deleteGame(): Promise<void> {
  const request = window.indexedDB.deleteDatabase("bitburnerSave");
  return new Promise((resolve, reject) => {
    request.onerror = function () {
      reject(request.error ?? new Error("Cannot delete save data"));
    };
    request.onblocked = function () {
      reject(new Error("Database in use by another tab. Please close all other Bitburner tabs."));
    };

    request.onsuccess = () => resolve();
  });
}
