// many of these are also found in endorser-mobile utility.ts

import axios, { AxiosResponse } from "axios";
import { Buffer } from "buffer";
import * as R from "ramda";
import { useClipboard } from "@vueuse/core";

import {
  DEFAULT_PUSH_SERVER,
  NotificationIface,
  USE_DEXIE_DB,
} from "../constants/app";
import {
  accountsDBPromise,
  retrieveSettingsForActiveAccount,
  updateAccountSettings,
  updateDefaultSettings,
} from "../db/index";
import { Account, AccountEncrypted } from "../db/tables/accounts";
import { Contact, ContactWithJsonStrings } from "../db/tables/contacts";
import * as databaseUtil from "../db/databaseUtil";
import { DEFAULT_PASSKEY_EXPIRATION_MINUTES } from "../db/tables/settings";
import {
  arrayBufferToBase64,
  base64ToArrayBuffer,
  deriveAddress,
  generateSeed,
  newIdentifier,
  simpleDecrypt,
  simpleEncrypt,
} from "../libs/crypto";
import * as serverUtil from "../libs/endorserServer";
import { containsHiddenDid } from "../libs/endorserServer";
import {
  GenericCredWrapper,
  GenericVerifiableCredential,
  KeyMetaWithPrivate,
} from "../interfaces/common";
import { GiveSummaryRecord } from "../interfaces/records";
import { OfferClaim } from "../interfaces/claims";
import { createPeerDid } from "../libs/crypto/vc/didPeer";
import { registerCredential } from "../libs/crypto/vc/passkeyDidPeer";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";
import { sha256 } from "ethereum-cryptography/sha256";
import { IIdentifier } from "@veramo/core";
import { insertDidSpecificSettings, parseJsonField } from "../db/databaseUtil";
import { DEFAULT_ROOT_DERIVATION_PATH } from "./crypto";

export interface GiverReceiverInputInfo {
  did?: string;
  name?: string;
}

export enum OnboardPage {
  Home = "HOME",
  Discover = "DISCOVER",
  Create = "CREATE",
  Contact = "CONTACT",
  Account = "ACCOUNT",
}

export const PRIVACY_MESSAGE =
  "The data you send will be visible to the world -- except: your IDs and the IDs of anyone you tag will stay private, only visible to them and others you explicitly allow.";
export const SHARED_PHOTO_BASE64_KEY = "shared-photo-base64";

/* eslint-disable prettier/prettier */
export const UNIT_SHORT: Record<string, string> = {
  "BTC": "BTC",
  "BX": "BX",
  "ETH": "ETH",
  "HUR": "Hours",
  "USD": "US $",
};
/* eslint-enable prettier/prettier */

/* eslint-disable prettier/prettier */
export const UNIT_LONG: Record<string, string> = {
  "BTC": "Bitcoin",
  "BX": "Buxbe",
  "ETH": "Ethereum",
  "HUR": "hours",
  "USD": "dollars",
};
/* eslint-enable prettier/prettier */

const UNIT_CODES: Record<
  string,
  { name: string; faIcon: string; decimals: number }
> = {
  BTC: {
    name: "Bitcoin",
    faIcon: "bitcoin-sign",
    decimals: 4,
  },
  HUR: {
    name: "hours",
    faIcon: "clock",
    decimals: 0,
  },
  USD: {
    name: "US Dollars",
    faIcon: "dollar",
    decimals: 2,
  },
};

export function iconForUnitCode(unitCode: string) {
  return UNIT_CODES[unitCode]?.faIcon || "question";
}

export function formattedAmount(amount: number, unitCode: string) {
  const unit = UNIT_CODES[unitCode];
  const amountStr = amount.toFixed(unit?.decimals ?? 4);
  const unitName = unit?.name || "?";
  return amountStr + " " + unitName;
}

// from https://stackoverflow.com/a/175787/845494
// ... though it appears even this isn't precisely right so keep doing "|| 0" or something in sensitive places
//
export function isNumeric(str: string): boolean {
  // This ignore commentary is because typescript complains when you pass a string to isNaN.
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  // @ts-ignore
  return !isNaN(str) && !isNaN(parseFloat(str));
}

export function numberOrZero(str: string): number {
  return isNumeric(str) ? +str : 0;
}

/**
 * from https://tools.ietf.org/html/rfc3986#section-3
 * also useful is https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Definition
 **/
export const isGlobalUri = (uri: string) => {
  return uri && uri.match(new RegExp(/^[A-Za-z][A-Za-z0-9+.-]+:/));
};

export const isGiveClaimType = (claimType?: string) => {
  return claimType === "GiveAction";
};

export const isGiveAction = (
  veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
  return isGiveClaimType(veriClaim.claimType);
};

export const shortDid = (did: string) => {
  if (did.startsWith("did:peer:")) {
    return (
      did.substring(0, "did:peer:".length + 2) +
      "..." +
      did.substring("did:peer:".length + 18, "did:peer:".length + 25) +
      "..."
    );
  } else if (did.startsWith("did:ethr:")) {
    return did.substring(0, "did:ethr:".length + 9) + "...";
  } else {
    return did.substring(0, did.indexOf(":", 4) + 7) + "...";
  }
};

export const nameForDid = (
  activeDid: string,
  contacts: Array<Contact>,
  did: string,
): string => {
  if (did === activeDid) {
    return "you";
  }
  const contact = R.find((con) => con.did == did, contacts);
  return nameForContact(contact);
};

export const nameForContact = (
  contact?: Contact,
  capitalize?: boolean,
): string => {
  return (
    (contact?.name as string) ||
    (capitalize ? "This" : "this") + " unnamed user"
  );
};

export const doCopyTwoSecRedo = (text: string, fn: () => void) => {
  fn();
  useClipboard()
    .copy(text)
    .then(() => setTimeout(fn, 2000));
};

export interface ConfirmerData {
  confirmerIdList: string[];
  confsVisibleToIdList: string[];
  numConfsNotVisible: number;
}

// // This is meant to be a second argument to JSON.stringify to avoid circular references.
// // Usage: JSON.stringify(error, getCircularReplacer())
// // Beware: we've seen this return "undefined" when there is actually a message, eg: DatabaseClosedError: Error DEXIE ENCRYPT ADDON: Encryption key has changed
// function getCircularReplacer() {
//   const seen = new WeakSet();
//   // eslint-disable-next-line @typescript-eslint/no-explicit-any
//   return (obj: any, key: string, value: any): any => {
//     if (typeof value === "object" && value !== null) {
//       if (seen.has(value)) {
//         return "[circular ref]";
//       }
//       seen.add(value);
//     }
//     return value;
//   };
// }

/**
 * @return only confirmers, excluding the issuer and hidden DIDs
 */
export async function retrieveConfirmerIdList(
  apiServer: string,
  claimId: string,
  claimIssuerId: string,
  userDid: string,
): Promise<ConfirmerData | undefined> {
  const confirmUrl =
    apiServer +
    "/api/report/issuersWhoClaimedOrConfirmed?claimId=" +
    encodeURIComponent(serverUtil.stripEndorserPrefix(claimId));
  const confirmHeaders = await serverUtil.getHeaders(userDid);
  const response = await axios.get(confirmUrl, {
    headers: confirmHeaders,
  });
  if (response.status === 200) {
    const resultList1 = response.data.result || [];
    //const publicUrls = resultList.publicUrls || [];
    delete resultList1.publicUrls;
    // exclude hidden DIDs
    const resultList2 = R.reject(serverUtil.isHiddenDid, resultList1);
    // exclude the issuer
    const resultList3 = R.reject(
      (did: string) => did === claimIssuerId,
      resultList2,
    );
    const confirmerIdList = resultList3;
    let numConfsNotVisible = resultList1.length - resultList2.length;
    if (resultList3.length === resultList2.length) {
      // the issuer was not in the "visible" list so they must be hidden
      // so subtract them from the non-visible confirmers count
      numConfsNotVisible = numConfsNotVisible - 1;
    }
    const confsVisibleToIdList = response.data.result.resultVisibleToDids || [];
    const result: ConfirmerData = {
      confirmerIdList,
      confsVisibleToIdList,
      numConfsNotVisible,
    };
    return result;
  } else {
    logger.error(
      "Bad response status of",
      response.status,
      "for confirmers:",
      response,
    );
    return undefined;
  }
}

/**
 * @returns true if the user can confirm the claim
 * @param veriClaim is expected to have fields: claim, claimType, and issuer
 */
export function isGiveRecordTheUserCanConfirm(
  isRegistered: boolean,
  veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
  activeDid: string,
  confirmerIdList: string[] = [],
): boolean {
  return (
    isRegistered &&
    isGiveAction(veriClaim) &&
    !confirmerIdList.includes(activeDid) &&
    veriClaim.issuer !== activeDid &&
    !containsHiddenDid(veriClaim.claim)
  );
}

export function notifyWhyCannotConfirm(
  notifyFun: (notification: NotificationIface, timeout: number) => void,
  isRegistered: boolean,
  claimType: string | undefined,
  giveDetails: GiveSummaryRecord | undefined,
  activeDid: string,
  confirmerIdList: string[] = [],
) {
  if (!isRegistered) {
    notifyFun(
      {
        group: "alert",
        type: "info",
        title: "Not Registered",
        text: "Someone needs to register you before you can confirm.",
      },
      3000,
    );
  } else if (!isGiveClaimType(claimType)) {
    notifyFun(
      {
        group: "alert",
        type: "info",
        title: "Not A Give",
        text: "This is not a giving action to confirm.",
      },
      3000,
    );
  } else if (confirmerIdList.includes(activeDid)) {
    notifyFun(
      {
        group: "alert",
        type: "info",
        title: "Already Confirmed",
        text: "You already confirmed this claim.",
      },
      3000,
    );
  } else if (giveDetails?.issuerDid == activeDid) {
    notifyFun(
      {
        group: "alert",
        type: "info",
        title: "Cannot Confirm",
        text: "You cannot confirm this because you issued this claim.",
      },
      3000,
    );
  } else if (serverUtil.containsHiddenDid(giveDetails?.fullClaim)) {
    notifyFun(
      {
        group: "alert",
        type: "info",
        title: "Cannot Confirm",
        text: "You cannot confirm this because some people are hidden.",
      },
      3000,
    );
  } else {
    notifyFun(
      {
        group: "alert",
        type: "info",
        title: "Cannot Confirm",
        text: "You cannot confirm this claim. There are no other details -- we can help more if you contact us and send us screenshots.",
      },
      3000,
    );
  }
}

export async function blobToBase64(blob: Blob): Promise<string> {
  return new Promise((resolve, reject) => {
    const reader = new FileReader();
    reader.onloadend = () => resolve(reader.result as string); // potential problem if it returns an ArrayBuffer?
    reader.onerror = reject;
    reader.readAsDataURL(blob);
  });
}

export function base64ToBlob(base64DataUrl: string, sliceSize = 512) {
  // Extract the content type and the Base64 data
  const [metadata, base64] = base64DataUrl.split(",");
  const contentTypeMatch = metadata.match(/data:(.*?);base64/);
  const contentType = contentTypeMatch ? contentTypeMatch[1] : "";

  const byteCharacters = atob(base64);
  const byteArrays = [];

  for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
    const slice = byteCharacters.slice(offset, offset + sliceSize);

    const byteNumbers = new Array(slice.length);
    for (let i = 0; i < slice.length; i++) {
      byteNumbers[i] = slice.charCodeAt(i);
    }

    const byteArray = new Uint8Array(byteNumbers);
    byteArrays.push(byteArray);
  }
  return new Blob(byteArrays, { type: contentType });
}

/**
 * @returns the DID of the person who offered, or undefined if hidden
 * @param veriClaim is expected to have fields: claim and issuer
 */
export function offerGiverDid(
  veriClaim: GenericCredWrapper<OfferClaim>,
): string | undefined {
  const innerClaim = veriClaim.claim as OfferClaim;
  let giver: string | undefined = undefined;

  giver = innerClaim.offeredBy?.identifier;
  if (giver && !serverUtil.isHiddenDid(giver)) {
    return giver;
  }

  giver = veriClaim.issuer;
  if (giver && !serverUtil.isHiddenDid(giver)) {
    return giver;
  }
  return giver;
}

/**
 * @returns true if the user can fulfill the offer
 * @param veriClaim is expected to have fields: claim, claimType, and issuer
 */
export const canFulfillOffer = (
  veriClaim: GenericCredWrapper<GenericVerifiableCredential>,
  isRegistered: boolean,
) => {
  return (
    isRegistered &&
    veriClaim.claimType === "Offer" &&
    !!offerGiverDid(veriClaim as GenericCredWrapper<OfferClaim>)
  );
};

// return object with paths and arrays of DIDs for any keys ending in "VisibleToDid"
export function findAllVisibleToDids(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  input: any,
  humanReadable = false,
): Record<string, Array<string>> {
  if (Array.isArray(input)) {
    const result: Record<string, Array<string>> = {};
    for (let i = 0; i < input.length; i++) {
      const inside = findAllVisibleToDids(input[i], humanReadable);
      for (const key in inside) {
        const pathKey = humanReadable
          ? "#" + (i + 1) + " " + key
          : "[" + i + "]" + key;
        result[pathKey] = inside[key];
      }
    }
    return result;
  } else if (input instanceof Object) {
    // regular map (non-array) object
    const result: Record<string, Array<string>> = {};
    for (const key in input) {
      if (key.endsWith("VisibleToDids")) {
        const newKey = key.slice(0, -"VisibleToDids".length);
        const pathKey = humanReadable ? newKey : "." + newKey;
        result[pathKey] = input[key];
      } else {
        const inside = findAllVisibleToDids(input[key], humanReadable);
        for (const insideKey in inside) {
          const pathKey = humanReadable
            ? key + "'s " + insideKey
            : "." + key + insideKey;
          result[pathKey] = inside[insideKey];
        }
      }
    }
    return result;
  } else {
    return {};
  }
}

/**
 * Test findAllVisibleToDids
 *

 pkgx +deno.land sh

 deno

 import * as R from 'ramda';
 //import { findAllVisibleToDids } from './src/libs/util'; // doesn't work because other dependencies fail so gotta copy-and-paste function

 console.log(R.equals(findAllVisibleToDids(null), {}));
 console.log(R.equals(findAllVisibleToDids(9), {}));
 console.log(R.equals(findAllVisibleToDids([]), {}));
 console.log(R.equals(findAllVisibleToDids({}), {}));
 console.log(R.equals(findAllVisibleToDids({ issuer: "abc" }), {}));
 console.log(R.equals(findAllVisibleToDids({ issuerVisibleToDids: ["abc"] }), { ".issuer": ["abc"] }));
 console.log(R.equals(findAllVisibleToDids([{ issuerVisibleToDids: ["abc"] }]), { "[0].issuer": ["abc"] }));
 console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] } }]), { "[1].fluff.issuer": ["abc"] }));
 console.log(R.equals(findAllVisibleToDids(["xyz", { fluff: { issuerVisibleToDids: ["abc"] }, stuff: [ { did: "HIDDEN", agentDidVisibleToDids: ["def", "ghi"] } ] }]), { "[1].fluff.issuer": ["abc"], "[1].stuff[0].agentDid": ["def", "ghi"] }));

 *
 **/

export type AccountKeyInfo = Account & KeyMetaWithPrivate;

export const retrieveAccountCount = async (): Promise<number> => {
  let result = 0;
  const platformService = PlatformServiceFactory.getInstance();
  const dbResult = await platformService.dbQuery(
    `SELECT COUNT(*) FROM accounts`,
  );
  if (dbResult?.values?.[0]?.[0]) {
    result = dbResult.values[0][0] as number;
  }

  if (USE_DEXIE_DB) {
    // one of the few times we use accountsDBPromise directly; try to avoid more usage
    const accountsDB = await accountsDBPromise;
    result = await accountsDB.accounts.count();
  }
  return result;
};

export const retrieveAccountDids = async (): Promise<string[]> => {
  const platformService = PlatformServiceFactory.getInstance();
  const dbAccounts = await platformService.dbQuery(`SELECT did FROM accounts`);
  let allDids =
    databaseUtil
      .mapQueryResultToValues(dbAccounts)
      ?.map((row) => row[0] as string) || [];
  if (USE_DEXIE_DB) {
    // this is the old way
    // one of the few times we use accountsDBPromise directly; try to avoid more usage
    const accountsDB = await accountsDBPromise;
    const allAccounts = await accountsDB.accounts.toArray();
    allDids = allAccounts.map((acc) => acc.did);
  }
  return allDids;
};

/**
 * This is provided and recommended when the full key is not necessary so that
 * future work could separate this info from the sensitive key material.
 *
 * If you need the private key data, use retrieveFullyDecryptedAccount instead.
 */
export const retrieveAccountMetadata = async (
  activeDid: string,
): Promise<Account | undefined> => {
  let result: Account | undefined = undefined;
  const platformService = PlatformServiceFactory.getInstance();
  const dbAccount = await platformService.dbQuery(
    `SELECT * FROM accounts WHERE did = ?`,
    [activeDid],
  );
  const account = databaseUtil.mapQueryResultToValues(dbAccount)[0] as Account;
  if (account) {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { identity, mnemonic, ...metadata } = account;
    result = metadata;
  } else {
    result = undefined;
  }
  if (USE_DEXIE_DB) {
    // one of the few times we use accountsDBPromise directly; try to avoid more usage
    const accountsDB = await accountsDBPromise;
    const account = (await accountsDB.accounts
      .where("did")
      .equals(activeDid)
      .first()) as Account;
    if (account) {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const { identity, mnemonic, ...metadata } = account;
      result = metadata;
    } else {
      result = undefined;
    }
  }
  return result;
};

/**
 * This contains sensitive data. If possible, use retrieveAccountMetadata instead.
 *
 * @param activeDid
 * @returns account info with private key data decrypted
 */
export const retrieveFullyDecryptedAccount = async (
  activeDid: string,
): Promise<Account | undefined> => {
  let result: Account | undefined = undefined;
  const platformService = PlatformServiceFactory.getInstance();
  const dbSecrets = await platformService.dbQuery(
    `SELECT secretBase64 from secret`,
  );
  if (
    !dbSecrets ||
    dbSecrets.values.length === 0 ||
    dbSecrets.values[0].length === 0
  ) {
    throw new Error(
      "No secret found. We recommend you clear your data and start over.",
    );
  }
  const secretBase64 = dbSecrets.values[0][0] as string;
  const secret = base64ToArrayBuffer(secretBase64);
  const dbAccount = await platformService.dbQuery(
    `SELECT * FROM accounts WHERE did = ?`,
    [activeDid],
  );
  if (
    !dbAccount ||
    dbAccount.values.length === 0 ||
    dbAccount.values[0].length === 0
  ) {
    throw new Error("Account not found.");
  }
  const fullAccountData = databaseUtil.mapQueryResultToValues(
    dbAccount,
  )[0] as AccountEncrypted;
  const identityEncr = base64ToArrayBuffer(fullAccountData.identityEncrBase64);
  const mnemonicEncr = base64ToArrayBuffer(fullAccountData.mnemonicEncrBase64);
  fullAccountData.identity = await simpleDecrypt(identityEncr, secret);
  fullAccountData.mnemonic = await simpleDecrypt(mnemonicEncr, secret);
  result = fullAccountData;

  if (USE_DEXIE_DB) {
    // one of the few times we use accountsDBPromise directly; try to avoid more usage
    const accountsDB = await accountsDBPromise;
    const account = (await accountsDB.accounts
      .where("did")
      .equals(activeDid)
      .first()) as Account;
    result = account;
  }
  return result;
};

export const retrieveAllAccountsMetadata = async (): Promise<
  AccountEncrypted[]
> => {
  const platformService = PlatformServiceFactory.getInstance();
  const dbAccounts = await platformService.dbQuery(`SELECT * FROM accounts`);
  const accounts = databaseUtil.mapQueryResultToValues(dbAccounts) as Account[];
  let result = accounts.map((account) => {
    return account as AccountEncrypted;
  });
  if (USE_DEXIE_DB) {
    // one of the few times we use accountsDBPromise directly; try to avoid more usage
    const accountsDB = await accountsDBPromise;
    const array = await accountsDB.accounts.toArray();
    result = array.map((account) => {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const { identity, mnemonic, ...metadata } = account;
      // This is not accurate because they can't be decrypted, but we're removing Dexie anyway.
      const identityStr = JSON.stringify(identity);
      const encryptedAccount = {
        identityEncrBase64: sha256(
          new TextEncoder().encode(identityStr),
        ).toString(),
        mnemonicEncrBase64: sha256(
          new TextEncoder().encode(account.mnemonic),
        ).toString(),
        ...metadata,
      };
      return encryptedAccount as AccountEncrypted;
    });
  }
  return result;
};

/**
 * Saves a new identity to both SQL and Dexie databases
 */
export async function saveNewIdentity(
  identity: IIdentifier,
  mnemonic: string,
  derivationPath: string,
): Promise<void> {
  try {
    // add to the new sql db
    const platformService = PlatformServiceFactory.getInstance();
    const secrets = await platformService.dbQuery(
      `SELECT secretBase64 FROM secret`,
    );
    if (!secrets?.values?.length || !secrets.values[0]?.length) {
      throw new Error(
        "No initial encryption supported. We recommend you clear your data and start over.",
      );
    }
    const secretBase64 = secrets.values[0][0] as string;
    const secret = base64ToArrayBuffer(secretBase64);
    const identityStr = JSON.stringify(identity);
    const encryptedIdentity = await simpleEncrypt(identityStr, secret);
    const encryptedMnemonic = await simpleEncrypt(mnemonic, secret);
    const encryptedIdentityBase64 = arrayBufferToBase64(encryptedIdentity);
    const encryptedMnemonicBase64 = arrayBufferToBase64(encryptedMnemonic);
    const sql = `INSERT INTO accounts (dateCreated, derivationPath, did, identityEncrBase64, mnemonicEncrBase64, publicKeyHex)
       VALUES (?, ?, ?, ?, ?, ?)`;
    const params = [
      new Date().toISOString(),
      derivationPath,
      identity.did,
      encryptedIdentityBase64,
      encryptedMnemonicBase64,
      identity.keys[0].publicKeyHex,
    ];
    await platformService.dbExec(sql, params);
    await databaseUtil.updateDefaultSettings({ activeDid: identity.did });
    await databaseUtil.insertDidSpecificSettings(identity.did);

    if (USE_DEXIE_DB) {
      // one of the few times we use accountsDBPromise directly; try to avoid more usage
      const accountsDB = await accountsDBPromise;
      await accountsDB.accounts.add({
        dateCreated: new Date().toISOString(),
        derivationPath: derivationPath,
        did: identity.did,
        identity: identityStr,
        mnemonic: mnemonic,
        publicKeyHex: identity.keys[0].publicKeyHex,
      });
      await updateDefaultSettings({ activeDid: identity.did });
      await insertDidSpecificSettings(identity.did);
    }
  } catch (error) {
    logger.error("Failed to update default settings:", error);
    throw new Error(
      "Failed to set default settings. Please try again or restart the app.",
    );
  }
}

/**
 * Generates a new identity, saves it to the database, and sets it as the active identity.
 * @return {Promise<string>} with the DID of the new identity
 */
export const generateSaveAndActivateIdentity = async (): Promise<string> => {
  const mnemonic = generateSeed();
  // address is 0x... ETH address, without "did:eth:"
  const [address, privateHex, publicHex, derivationPath] =
    deriveAddress(mnemonic);

  const newId = newIdentifier(address, publicHex, privateHex, derivationPath);

  await saveNewIdentity(newId, mnemonic, derivationPath);
  await databaseUtil.updateDidSpecificSettings(newId.did, {
    isRegistered: false,
  });
  if (USE_DEXIE_DB) {
    await updateAccountSettings(newId.did, { isRegistered: false });
  }
  return newId.did;
};

export const registerAndSavePasskey = async (
  keyName: string,
): Promise<Account> => {
  const cred = await registerCredential(keyName);
  const publicKeyBytes = cred.publicKeyBytes;
  const did = createPeerDid(publicKeyBytes as Uint8Array);
  const passkeyCredIdHex = cred.credIdHex as string;

  const account = {
    dateCreated: new Date().toISOString(),
    did,
    passkeyCredIdHex,
    publicKeyHex: Buffer.from(publicKeyBytes).toString("hex"),
  };
  const insertStatement = databaseUtil.generateInsertStatement(
    account,
    "accounts",
  );
  await PlatformServiceFactory.getInstance().dbExec(
    insertStatement.sql,
    insertStatement.params,
  );
  if (USE_DEXIE_DB) {
    // one of the few times we use accountsDBPromise directly; try to avoid more usage
    const accountsDB = await accountsDBPromise;
    await accountsDB.accounts.add(account);
  }
  return account;
};

export const registerSaveAndActivatePasskey = async (
  keyName: string,
): Promise<Account> => {
  const account = await registerAndSavePasskey(keyName);
  await databaseUtil.updateDefaultSettings({ activeDid: account.did });
  await databaseUtil.updateDidSpecificSettings(account.did, {
    isRegistered: false,
  });
  if (USE_DEXIE_DB) {
    await updateDefaultSettings({ activeDid: account.did });
    await updateAccountSettings(account.did, { isRegistered: false });
  }
  return account;
};

export const getPasskeyExpirationSeconds = async (): Promise<number> => {
  let settings = await databaseUtil.retrieveSettingsForActiveAccount();
  if (USE_DEXIE_DB) {
    settings = await retrieveSettingsForActiveAccount();
  }
  return (
    (settings?.passkeyExpirationMinutes ?? DEFAULT_PASSKEY_EXPIRATION_MINUTES) *
    60
  );
};

// These are shared with the service worker and should be a constant. Look for the same name in additional-scripts.js
export const DAILY_CHECK_TITLE = "DAILY_CHECK";
// This is a special value that tells the service worker to send a direct notification to the device, skipping filters.
export const DIRECT_PUSH_TITLE = "DIRECT_NOTIFICATION";

export const sendTestThroughPushServer = async (
  subscriptionJSON: PushSubscriptionJSON,
  skipFilter: boolean,
): Promise<AxiosResponse> => {
  let settings = await databaseUtil.retrieveSettingsForActiveAccount();
  if (USE_DEXIE_DB) {
    settings = await retrieveSettingsForActiveAccount();
  }
  let pushUrl: string = DEFAULT_PUSH_SERVER as string;
  if (settings?.webPushServer) {
    pushUrl = settings.webPushServer;
  }

  const newPayload = {
    ...subscriptionJSON,
    // ... overridden with the following
    // eslint-disable-next-line prettier/prettier
    message: `Test, where you will see this message ${ skipFilter ? "un" : "" }filtered.`,
    title: skipFilter ? DIRECT_PUSH_TITLE : "Your Web Push",
  };
  logger.log("Sending a test web push message:", newPayload);
  const payloadStr = JSON.stringify(newPayload);
  const response = await axios.post(
    pushUrl + "/web-push/send-test",
    payloadStr,
    {
      headers: {
        "Content-Type": "application/json",
      },
    },
  );

  logger.log("Got response from web push server:", response);
  return response;
};

/**
 * Converts a Contact object to a CSV line string following the established format.
 * The format matches CONTACT_CSV_HEADER: "name,did,pubKeyBase64,seesMe,registered,contactMethods"
 * where contactMethods is stored as a stringified JSON array.
 *
 * @param contact - The Contact object to convert
 * @returns A CSV-formatted string representing the contact
 * @throws {Error} If the contact object is missing required fields
 */
export const contactToCsvLine = (contact: Contact): string => {
  if (!contact.did) {
    throw new Error("Contact must have a did field");
  }

  // Escape fields that might contain commas or quotes
  const escapeField = (field: string | boolean | undefined): string => {
    if (field === undefined) return "";
    const str = String(field);
    if (str.includes(",") || str.includes('"')) {
      return `"${str.replace(/"/g, '""')}"`;
    }
    return str;
  };

  // Handle contactMethods array by stringifying it
  const contactMethodsStr = contact.contactMethods
    ? escapeField(JSON.stringify(parseJsonField(contact.contactMethods, [])))
    : "";

  const fields = [
    escapeField(contact.name),
    escapeField(contact.did),
    escapeField(contact.publicKeyBase64),
    escapeField(contact.seesMe),
    escapeField(contact.registered),
    contactMethodsStr,
  ];

  return fields.join(",");
};

/**
 * Parses a CSV line into a Contact object. See contactToCsvLine for the format.
 * @param lineRaw - The CSV line to parse
 * @returns A Contact object
 */
export const csvLineToContact = (lineRaw: string): Contact => {
  // Note that Endorser Mobile puts name first, then did, etc.
  let line = lineRaw.trim();
  let did, publicKeyInput, seesMe, registered;
  let name;
  let commaPos1 = -1;
  if (line.startsWith('"')) {
    let doubleDoubleQuotePos = line.lastIndexOf('""') + 2;
    if (doubleDoubleQuotePos === -1) {
      doubleDoubleQuotePos = 1;
    }
    const quote2Pos = line.indexOf('"', doubleDoubleQuotePos);
    if (quote2Pos > -1) {
      commaPos1 = line.indexOf(",", quote2Pos);
      name = line.substring(1, quote2Pos).trim();
      name = name.replace(/""/g, '"');
    } else {
      // something is weird with one " to start, so ignore it and start after "
      line = line.substring(1);
      commaPos1 = line.indexOf(",");
      name = line.substring(0, commaPos1).trim();
    }
  } else {
    commaPos1 = line.indexOf(",");
    name = line.substring(0, commaPos1).trim();
  }
  if (commaPos1 > -1) {
    did = line.substring(commaPos1 + 1).trim();
    const commaPos2 = line.indexOf(",", commaPos1 + 1);
    if (commaPos2 > -1) {
      did = line.substring(commaPos1 + 1, commaPos2).trim();
      publicKeyInput = line.substring(commaPos2 + 1).trim();
      const commaPos3 = line.indexOf(",", commaPos2 + 1);
      if (commaPos3 > -1) {
        publicKeyInput = line.substring(commaPos2 + 1, commaPos3).trim();
        seesMe = line.substring(commaPos3 + 1).trim() == "true";
        const commaPos4 = line.indexOf(",", commaPos3 + 1);
        if (commaPos4 > -1) {
          seesMe = line.substring(commaPos3 + 1, commaPos4).trim() == "true";
          registered = line.substring(commaPos4 + 1).trim() == "true";
        }
      }
    }
  }
  // help with potential mistakes while this sharing requires copy-and-paste
  let publicKeyBase64 = publicKeyInput;
  if (publicKeyBase64 && /^[0-9A-Fa-f]{66}$/i.test(publicKeyBase64)) {
    // it must be all hex (compressed public key), so convert
    publicKeyBase64 = Buffer.from(publicKeyBase64, "hex").toString("base64");
  }
  const newContact: Contact = {
    did: did || "",
    name,
    publicKeyBase64,
    seesMe,
    registered,
  };
  return newContact;
};

/**
 * Interface for the JSON export format of database tables
 */
export interface TableExportData {
  tableName: string;
  rows: Array<Record<string, unknown>>;
}

/**
 * Interface for the complete database export format
 */
export interface DatabaseExport {
  data: {
    data: Array<TableExportData>;
  };
}

/**
 * Converts an array of contacts to the standardized database export JSON format.
 * This format is used for data migration and backup purposes.
 *
 * @param contacts - Array of Contact objects to convert
 * @returns DatabaseExport object in the standardized format
 */
export const contactsToExportJson = (contacts: Contact[]): DatabaseExport => {
  // Convert each contact to a plain object and ensure all fields are included
  const rows = contacts.map((contact) => {
    const exContact: ContactWithJsonStrings = R.omit(
      ["contactMethods"],
      contact,
    );
    exContact.contactMethods = contact.contactMethods
      ? JSON.stringify(contact.contactMethods, [])
      : undefined;
    return exContact;
  });

  return {
    data: {
      data: [
        {
          tableName: "contacts",
          rows,
        },
      ],
    },
  };
};

/**
 * Imports an account from a mnemonic phrase
 * @param mnemonic - The seed phrase to import from
 * @param derivationPath - The derivation path to use (defaults to DEFAULT_ROOT_DERIVATION_PATH)
 * @param shouldErase - Whether to erase existing accounts before importing
 * @returns Promise that resolves when import is complete
 * @throws Error if mnemonic is invalid or import fails
 */
export async function importFromMnemonic(
  mnemonic: string,
  derivationPath: string = DEFAULT_ROOT_DERIVATION_PATH,
  shouldErase: boolean = false,
): Promise<void> {
  const mne: string = mnemonic.trim().toLowerCase();

  // Derive address and keys from mnemonic
  const [address, privateHex, publicHex] = deriveAddress(mne, derivationPath);

  // Create new identifier
  const newId = newIdentifier(address, publicHex, privateHex, derivationPath);

  // Handle erasures
  if (shouldErase) {
    const platformService = PlatformServiceFactory.getInstance();
    await platformService.dbExec("DELETE FROM accounts");
    if (USE_DEXIE_DB) {
      const accountsDB = await accountsDBPromise;
      await accountsDB.accounts.clear();
    }
  }

  // Save the new identity
  await saveNewIdentity(newId, mne, derivationPath);
}
