/**
 * This is the original IndexedDB version of the database.
 * It will eventually be replaced fully by the SQL version in databaseUtil.ts.
 * Turn this on or off with the USE_DEXIE_DB constant in constants/app.ts.
 */

import BaseDexie, { Table } from "dexie";
import { encrypted, Encryption } from "@pvermeer/dexie-encrypted-addon";
import * as R from "ramda";

import { Account, AccountsSchema } from "./tables/accounts";
import { Contact, ContactSchema } from "./tables/contacts";
import { Log, LogSchema } from "./tables/logs";
import { MASTER_SECRET_KEY, Secret, SecretSchema } from "./tables/secret";
import {
  MASTER_SETTINGS_KEY,
  Settings,
  SettingsSchema,
} from "./tables/settings";
import { Temp, TempSchema } from "./tables/temp";
import { DEFAULT_ENDORSER_API_SERVER } from "../constants/app";
import { logger } from "../utils/logger";

// Define types for tables that hold sensitive and non-sensitive data
type SecretTable = { secret: Table<Secret> };
type SensitiveTables = { accounts: Table<Account> };
type NonsensitiveTables = {
  contacts: Table<Contact>;
  logs: Table<Log>;
  settings: Table<Settings>;
  temp: Table<Temp>;
};

// Using 'unknown' instead of 'any' for stricter typing and to avoid TypeScript warnings
type SecretDexie<T extends unknown = SecretTable> = BaseDexie & T;
type SensitiveDexie<T extends unknown = SensitiveTables> = BaseDexie & T;
export type NonsensitiveDexie<T extends unknown = NonsensitiveTables> =
  BaseDexie & T;

//// Initialize the DBs, starting with the sensitive ones.

// Initialize Dexie database for secret, which is then used to encrypt accountsDB
export const secretDB = new BaseDexie("TimeSafariSecret") as SecretDexie;
secretDB.version(1).stores(SecretSchema);

// Initialize Dexie database for accounts
const accountsDexie = new BaseDexie("TimeSafariAccounts") as SensitiveDexie;

// Instead of accountsDBPromise, use libs/util retrieveAccountMetadata or retrieveFullyDecryptedAccount
// so that it's clear whether the usage needs the private key inside.
//
// This is a promise because the decryption key comes from IndexedDB
// and someday it may come from a password or keystore or external wallet.
// It's important that usages take into account that there may be a delay due
// to a user action required to unlock the data.
export const accountsDBPromise = useSecretAndInitializeAccountsDB(
  secretDB,
  accountsDexie,
);

//// Now initialize the other DB.

// Initialize Dexie databases for non-sensitive data
export const db = new BaseDexie("TimeSafari") as NonsensitiveDexie;

// Only the tables with index modifications need listing. https://dexie.org/docs/Tutorial/Design#database-versioning

// v1 also had contacts & settings
// v2 added Log
db.version(2).stores({
  ...ContactSchema,
  ...LogSchema,
  ...{ settings: "id" }, // old Settings schema
});
// v3 added Temp
db.version(3).stores(TempSchema);
db.version(4)
  .stores(SettingsSchema)
  .upgrade((tx) => {
    return tx
      .table("settings")
      .toCollection()
      .modify((settings) => {
        settings.accountDid = ""; // make it non-null for the default master settings, but still indexable
      });
  });

const DEFAULT_SETTINGS: Settings = {
  id: MASTER_SETTINGS_KEY,
  activeDid: undefined,
  apiServer: DEFAULT_ENDORSER_API_SERVER,
};

// Event handler to initialize the non-sensitive database with default settings
db.on("populate", async () => {
  try {
    await db.settings.add(DEFAULT_SETTINGS);
  } catch (error) {
    logger.error("Error populating the database with default settings:", error);
  }
});

// Helper function to safely open the database with retries
async function safeOpenDatabase(retries = 1, delay = 500): Promise<void> {
  // logger.log("Starting safeOpenDatabase with retries:", retries);
  for (let i = 0; i < retries; i++) {
    try {
      // logger.log(`Attempt ${i + 1}: Checking if database is open...`);
      if (!db.isOpen()) {
        // logger.log(`Attempt ${i + 1}: Database is closed, attempting to open...`);

        // Create a promise that rejects after 5 seconds
        const timeoutPromise = new Promise((_, reject) => {
          setTimeout(() => reject(new Error("Database open timed out")), 500);
        });

        // Race between the open operation and the timeout
        const openPromise = db.open();
        // logger.log(`Attempt ${i + 1}: Waiting for db.open() promise...`);
        await Promise.race([openPromise, timeoutPromise]);

        // If we get here, the open succeeded
        // logger.log(`Attempt ${i + 1}: Database opened successfully`);
        return;
      }
      // logger.log(`Attempt ${i + 1}: Database was already open`);
      return;
    } catch (error) {
      logger.error(`Attempt ${i + 1}: Database open failed:`, error);
      if (i < retries - 1) {
        logger.log(`Attempt ${i + 1}: Waiting ${delay}ms before retry...`);
        await new Promise((resolve) => setTimeout(resolve, delay));
      } else {
        throw error;
      }
    }
  }
}

export async function updateDefaultSettings(
  settingsChanges: Settings,
): Promise<number> {
  delete settingsChanges.accountDid; // just in case
  // ensure there is no "id" that would override the key
  delete settingsChanges.id;
  try {
    try {
      // logger.log("Database state before open:", db.isOpen() ? "open" : "closed");
      // logger.log("Database name:", db.name);
      // logger.log("Database version:", db.verno);
      await safeOpenDatabase();
    } catch (openError: unknown) {
      logger.error("Failed to open database:", openError, String(openError));
      throw new Error(
        `The database connection failed. We recommend you try again or restart the app.`,
      );
    }
    const result = await db.settings.update(
      MASTER_SETTINGS_KEY,
      settingsChanges,
    );
    return result;
  } catch (error) {
    logger.error("Error updating default settings:", error);
    if (error instanceof Error) {
      throw error; // Re-throw if it's already an Error with a message
    } else {
      throw new Error(
        `Failed to update settings. We recommend you try again or restart the app.`,
      );
    }
  }
}

// Manage the encryption key.

// It's not really secure to maintain the secret next to the user's data.
// However, until we have better hooks into a real wallet or reliable secure
// storage, we'll do this for user convenience. As they sign more records
// and integrate with more people, they'll value it more and want to be more
// secure, so we'll prompt them to take steps to back it up, properly encrypt,
// etc. At the beginning, we'll prompt for a password, then we'll prompt for a
// PWA so it's not in a browser... and then we hope to be integrated with a
// real wallet or something else more secure.

// One might ask: why encrypt at all? We figure a basic encryption is better
// than none. Plus, we expect to support their own password or keystore or
// external wallet as better signing options in the future, so it's gonna be
// important to have the structure where each account access might require
// user action.

// (Once upon a time we stored the secret in localStorage, but it frequently
// got erased, even though the IndexedDB still had the identity data. This
// ended up throwing lots of errors to the user... and they'd end up in a state
// where they couldn't take action because they couldn't unlock that identity.)

async function useSecretAndInitializeAccountsDB(
  secretDB: SecretDexie,
  accountsDB: SensitiveDexie,
): Promise<SensitiveDexie> {
  return secretDB
    .open()
    .then(() => {
      return secretDB.secret.get(MASTER_SECRET_KEY);
    })
    .then((secretRow?: Secret) => {
      let secret = secretRow?.secret;
      if (secret != null) {
        // they already have it in IndexedDB, so just pass it along
        return secret;
      } else {
        // check localStorage (for users before v 0.3.37)
        const localSecret = localStorage.getItem("secret");
        if (localSecret != null) {
          // they had one, so we want to move it to IndexedDB
          secret = localSecret;
        } else {
          // they didn't have one, so let's generate one
          secret = Encryption.createRandomEncryptionKey();
        }
        // it is not in IndexedDB, so add it now
        return secretDB.secret
          .add({ id: MASTER_SECRET_KEY, secret })
          .then(() => {
            return secret;
          });
      }
    })
    .then((secret?: string) => {
      if (secret == null) {
        throw new Error("No secret found or created.");
      } else {
        // apply encryption to the sensitive database using the secret key
        encrypted(accountsDB, { secretKey: secret });
        accountsDB.version(1).stores(AccountsSchema);
        accountsDB.open();
        return accountsDB;
      }
    })
    .catch((error) => {
      logConsoleAndDb("Error processing secret & encrypted accountsDB.", error);
      // alert("There was an error processing encrypted data. See the Help page.");
      throw error;
    });
}

// retrieves default settings
// calls db.open()
export async function retrieveSettingsForDefaultAccount(): Promise<Settings> {
  await db.open();
  return (await db.settings.get(MASTER_SETTINGS_KEY)) || DEFAULT_SETTINGS;
}

export async function retrieveSettingsForActiveAccount(): Promise<Settings> {
  const defaultSettings = await retrieveSettingsForDefaultAccount();
  if (!defaultSettings.activeDid) {
    return defaultSettings;
  } else {
    const overrideSettings =
      (await db.settings
        .where("accountDid")
        .equals(defaultSettings.activeDid)
        .first()) || {};
    return R.mergeDeepRight(defaultSettings, overrideSettings);
  }
}

export async function updateAccountSettings(
  accountDid: string,
  settingsChanges: Settings,
): Promise<void> {
  settingsChanges.accountDid = accountDid;
  delete settingsChanges.id; // key off account, not ID
  const result = await db.settings
    .where("accountDid")
    .equals(settingsChanges.accountDid)
    .modify(settingsChanges);
  if (result === 0) {
    if (!settingsChanges.id) {
      // It is unfortunate that we have to set this explicitly.
      // We didn't make id a "++id" at the beginning and Dexie won't let us change it,
      // plus we made our first settings objects MASTER_SETTINGS_KEY = 1 instead of 0
      settingsChanges.id = (await db.settings.count()) + 1;
    }
    await db.settings.add(settingsChanges);
  }
}

export async function logToDb(message: string): Promise<void> {
  await db.open();
  const todayKey = new Date().toDateString();
  // only keep one day's worth of logs
  const previous = await db.logs.get(todayKey);
  if (!previous) {
    // when this is today's first log, clear out everything previous
    // to avoid the log table getting too large
    // (let's limit a different way someday)
    await db.logs.clear();
  }
  const prevMessages = (previous && previous.message) || "";
  const fullMessage = `${prevMessages}\n${new Date().toISOString()} ${message}`;
  await db.logs.update(todayKey, { message: fullMessage });
}

// similar method is in the sw_scripts/additional-scripts.js file
export async function logConsoleAndDb(
  message: string,
  isError = false,
): Promise<void> {
  if (isError) {
    logger.error(`${new Date().toISOString()} ${message}`);
  } else {
    logger.log(`${new Date().toISOString()} ${message}`);
  }
  await logToDb(message);
}
