/**
 * @fileoverview Endorser Server Interface and Utilities
 * @author Matthew Raymer
 *
 * This module provides the interface and utilities for interacting with the Endorser server.
 * It handles authentication, data validation, and server communication for claims, contacts,
 * and other core functionality.
 *
 * Key Features:
 * - Deep link URL path constants
 * - DID validation and handling
 * - Contact management utilities
 * - Server authentication
 * - Plan caching
 *
 * @module endorserServer
 */

import { Axios, AxiosRequestConfig } from "axios";
import { Buffer } from "buffer";
import { sha256 } from "ethereum-cryptography/sha256";
import { LRUCache } from "lru-cache";
import * as R from "ramda";

import {
  DEFAULT_IMAGE_API_SERVER,
  NotificationIface,
  APP_SERVER,
  USE_DEXIE_DB,
} from "../constants/app";
import { Contact } from "../db/tables/contacts";
import { accessToken, deriveAddress, nextDerivationPath } from "../libs/crypto";
import { NonsensitiveDexie } from "../db/index";
import { logConsoleAndDb } from "../db/databaseUtil";
import {
  retrieveAccountMetadata,
  retrieveFullyDecryptedAccount,
  getPasskeyExpirationSeconds,
} from "../libs/util";
import { createEndorserJwtForKey } from "../libs/crypto/vc";
import {
  GiveActionClaim,
  JoinActionClaim,
  OfferClaim,
  PlanActionClaim,
  RegisterActionClaim,
  TenureClaim,
} from "../interfaces/claims";

import {
  GenericCredWrapper,
  GenericVerifiableCredential,
  AxiosErrorResponse,
  UserInfo,
  CreateAndSubmitClaimResult,
  ClaimObject,
  VerifiableCredentialClaim,
  QuantitativeValue,
  KeyMetaWithPrivate,
  KeyMetaMaybeWithPrivate,
} from "../interfaces/common";
import { PlanSummaryRecord } from "../interfaces/records";
import { logger } from "../utils/logger";
import { PlatformServiceFactory } from "@/services/PlatformServiceFactory";

/**
 * Standard context for schema.org data
 * @constant {string}
 */
export const SCHEMA_ORG_CONTEXT = "https://schema.org";

/**
 * Service identifier for RegisterAction claims
 * @constant {string}
 */
export const SERVICE_ID = "endorser.ch";

/**
 * Header line format for contacts exported via Endorser Mobile
 * @constant {string}
 */
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";

/**
 * URL path suffix for contact confirmation before import
 * @constant {string}
 */
export const CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact-import/";

/**
 * URL path suffix for the contact URL in this app where a single one gets imported automatically
 * @constant {string}
 */
export const CONTACT_IMPORT_ONE_URL_PATH_TIME_SAFARI = "/contacts?contactJwt=";

/**
 * URL path suffix for the old contact URL -- deprecated Jan 2025, though "endorser.ch/contact?jwt=" shows data on endorser.ch server
 * @constant {string}
 */
export const CONTACT_URL_PATH_ENDORSER_CH_OLD = "/contact?jwt=";

/**
 * URL path suffix for contact confirmation
 * @constant {string}
 */
export const CONTACT_CONFIRM_URL_PATH_TIME_SAFARI = "/contact/confirm/";

/**
 * The prefix for handle IDs, the permanent ID for claims on Endorser
 * @constant {string}
 */
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";

export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
  {
    claim: {
      "@context": SCHEMA_ORG_CONTEXT,
      "@type": "",
    },
    handleId: "",
    id: "",
    issuedAt: "",
    issuer: "",
  };

// This is used to check for hidden info.
// See https://github.com/trentlarson/endorser-ch/blob/0cb626f803028e7d9c67f095858a9fc8542e3dbd/server/api/services/util.js#L6
const HIDDEN_DID = "did:none:HIDDEN";

/**
 * Validates if a string is a valid DID
 * @param {string} did - The DID string to validate
 * @returns {boolean} True if string is a valid DID format
 */
export function isDid(did: string): boolean {
  return did.startsWith("did:");
}

/**
 * Checks if a DID is the special hidden DID value
 * @param {string} did - The DID to check
 * @returns {boolean} True if DID is hidden
 */
export function isHiddenDid(did: string | undefined): boolean {
  return did === HIDDEN_DID;
}

/**
 * Checks if a DID is empty or hidden
 * @param {string} [did] - The DID to check
 * @returns {boolean} True if DID is empty or hidden
 */
export function isEmptyOrHiddenDid(did?: string): boolean {
  return !did || did === HIDDEN_DID;
}

/**
 * Recursively tests strings within an object/array against a test function
 * @param {Function} func - Test function to apply to strings
 * @param {any} input - Object/array to recursively test
 * @returns {boolean} True if any string passes the test function
 *
 * @example
 * testRecursivelyOnStrings(isDid, { user: { id: "did:example:123" } })
 * // Returns: true
 */
/**
 * Recursively tests strings within a nested object/array structure against a test function
 *
 * This function traverses through objects and arrays to find all string values and applies
 * a test function to each string found. It handles:
 * - Direct string values
 * - Strings in objects (at any depth)
 * - Strings in arrays (at any depth)
 * - Mixed nested structures (objects containing arrays containing objects, etc)
 *
 * @param {Function} func - Test function that takes a string and returns boolean
 * @param {any} input - Value to recursively search (can be string, object, array, or other)
 * @returns {boolean} True if any string in the structure passes the test function
 *
 * @example
 * // Test if any string is a DID
 * const obj = {
 *   user: {
 *     id: "did:example:123",
 *     details: ["name", "did:example:456"]
 *   }
 * };
 * testRecursivelyOnStrings(isDid, obj); // Returns: true
 *
 * @example
 * // Test for hidden DIDs
 * const obj = {
 *   visible: "did:example:123",
 *   hidden: ["did:none:HIDDEN"]
 * };
 * testRecursivelyOnStrings(isHiddenDid, obj); // Returns: true
 */
const testRecursivelyOnStrings = (
  input: unknown,
  test: (s: string) => boolean,
): boolean => {
  if (typeof input === "string") {
    return test(input);
  } else if (Array.isArray(input)) {
    return input.some((item) => testRecursivelyOnStrings(item, test));
  } else if (input && typeof input === "object") {
    return Object.values(input as Record<string, unknown>).some((value) =>
      testRecursivelyOnStrings(value, test),
    );
  }
  return false;
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function containsHiddenDid(obj: any) {
  return testRecursivelyOnStrings(isHiddenDid, obj);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const containsNonHiddenDid = (obj: any) => {
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  return testRecursivelyOnStrings((s: any) => isDid(s) && !isHiddenDid(s), obj);
};

export function stripEndorserPrefix(claimId: string) {
  if (claimId && claimId.startsWith(ENDORSER_CH_HANDLE_PREFIX)) {
    return claimId.substring(ENDORSER_CH_HANDLE_PREFIX.length);
  } else {
    return claimId;
  }
}

// similar logic is found in endorser-mobile
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function removeSchemaContext(obj: any) {
  return obj["@context"] === SCHEMA_ORG_CONTEXT
    ? R.omit(["@context"], obj)
    : obj;
}

// similar logic is found in endorser-mobile
export function addLastClaimOrHandleAsIdIfMissing(
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  obj: any,
  lastClaimId?: string,
  handleId?: string,
) {
  if (!obj.identifier && lastClaimId) {
    const result = R.clone(obj);
    result.lastClaimId = lastClaimId;
    return result;
  } else if (!obj.identifier && handleId) {
    const result = R.clone(obj);
    result.identifier = handleId;
    return result;
  } else {
    return obj;
  }
}

// return clone of object without any nested *VisibleToDids keys
// similar code is also contained in endorser-mobile
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function removeVisibleToDids(input: any): any {
  if (input instanceof Object) {
    if (!Array.isArray(input)) {
      // it's an object
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      const result: Record<string, any> = {};
      for (const key in input) {
        if (!key.endsWith("VisibleToDids")) {
          result[key] = removeVisibleToDids(R.clone(input[key]));
        }
      }
      return result;
    } else {
      // it's an array
      return R.map(removeVisibleToDids, input);
    }
  } else {
    return input;
  }
}

export function contactForDid(
  did: string | undefined,
  contacts: Contact[],
): Contact | undefined {
  return isEmptyOrHiddenDid(did)
    ? undefined
    : R.find((c) => c.did === did, contacts);
}

/**
 *
 * Similar logic is found in endorser-mobile.
 *
 * @param did
 * @param activeDid
 * @param contact
 * @param allMyDids
 * @return { known: boolean, displayName: string, profileImageUrl?: string }
 *  where 'known' is true if they are in the contacts
 */
export function didInfoForContact(
  did: string | undefined,
  activeDid: string | undefined,
  contact?: Contact,
  allMyDids: string[] = [],
  showDidForVisible: boolean = false,
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
): { known: boolean; displayName: string; profileImageUrl?: string } {
  if (!did) return { displayName: "Someone Unnamed/Unknown", known: false };
  if (did === activeDid) {
    return { displayName: "You", known: true };
  } else if (contact) {
    return {
      displayName: contact.name || "Contact With No Name",
      known: true,
      profileImageUrl: contact.profileImageUrl,
    };
  } else {
    const myId = R.find(R.equals(did), allMyDids);
    return myId
      ? { displayName: "You (Alt ID)", known: true }
      : isHiddenDid(did)
        ? { displayName: "Someone Outside Your View", known: false }
        : {
            displayName: showDidForVisible
              ? did
              : "Someone Visible But Not In Your Contact List",
            known: false,
          };
  }
}

/**
 * @returns full contact info object (never undefined), where did is searched in contacts and allMyDids
 */
export function didInfoObject(
  did: string | undefined,
  activeDid: string | undefined,
  allMyDids: string[],
  contacts: Contact[],
): { known: boolean; displayName: string; profileImageUrl?: string } {
  const contact = contactForDid(did, contacts);
  return didInfoForContact(did, activeDid, contact, allMyDids);
}

/**
 always returns text, maybe something like "unnamed" or "unknown"

 Now that we're using more informational didInfoForContact under the covers, we might want to consolidate.
 **/
export function didInfo(
  did: string | undefined,
  activeDid: string | undefined,
  allMyDids: string[],
  contacts: Contact[],
): string {
  const contact = contactForDid(did, contacts);
  return didInfoForContact(did, activeDid, contact, allMyDids).displayName;
}

/**
 * return text description without any references to "you" as user
 */
export function didInfoForCertificate(
  did: string | undefined,
  contacts: Contact[],
): string {
  return didInfoForContact(
    did,
    undefined,
    contactForDid(did, contacts),
    [],
    true,
  ).displayName;
}

let passkeyAccessToken: string = "";
let passkeyTokenExpirationEpochSeconds: number = 0;

export function clearPasskeyToken() {
  passkeyAccessToken = "";
  passkeyTokenExpirationEpochSeconds = 0;
}

export function tokenExpiryTimeDescription() {
  if (
    !passkeyAccessToken ||
    passkeyTokenExpirationEpochSeconds < new Date().getTime() / 1000
  ) {
    return "Token has expired";
  } else {
    return (
      "Token expires at " +
      new Date(passkeyTokenExpirationEpochSeconds * 1000).toLocaleString()
    );
  }
}

/**
 * Get the headers for a request, potentially including Authorization
 */
export async function getHeaders(
  did?: string,
  $notify?: (notification: NotificationIface, timeout?: number) => void,
  failureMessage?: string,
) {
  const headers: { "Content-Type": string; Authorization?: string } = {
    "Content-Type": "application/json",
  };
  if (did) {
    try {
      let token;
      const account = await retrieveAccountMetadata(did);
      if (account?.passkeyCredIdHex) {
        if (
          passkeyAccessToken &&
          passkeyTokenExpirationEpochSeconds > Date.now() / 1000
        ) {
          // there's an active current passkey token
          token = passkeyAccessToken;
        } else {
          // there's no current passkey token or it's expired
          token = await accessToken(did);

          passkeyAccessToken = token;
          const passkeyExpirationSeconds = await getPasskeyExpirationSeconds();
          passkeyTokenExpirationEpochSeconds =
            Date.now() / 1000 + passkeyExpirationSeconds;
        }
      } else {
        token = await accessToken(did);
      }
      headers["Authorization"] = "Bearer " + token;
    } catch (error) {
      // This rarely happens: we've seen it when they have account info but the
      // encryption secret got lost. But in most cases we want users to at
      // least see their feed -- and anything else that returns results for
      // anonymous users.

      // We'll continue with an anonymous request... still want to show feed and other things, but ideally let them know.
      logConsoleAndDb(
        "Something failed in getHeaders call (will proceed anonymously" +
          ($notify ? " and notify user" : "") +
          "): " +
          // IntelliJ type system complains about getCircularReplacer() with: Argument of type '(obj: any, key: string, value: any) => any' is not assignable to parameter of type '(this: any, key: string, value: any) => any'.
          //JSON.stringify(error, getCircularReplacer()), // JSON.stringify(error) on a Dexie error throws another error about: Converting circular structure to JSON
          error,
        true,
      );
      if ($notify) {
        // remember: only want to do this if they supplied a DID, expecting personal results
        const notifyMessage =
          failureMessage ||
          "Showing anonymous data. See the Help page for help with personal data.";
        $notify(
          {
            group: "alert",
            type: "danger",
            title: "Personal Data Error",
            text: notifyMessage,
          },
          3000,
        );
      }
    }
  } else {
    // it's usually OK to request without auth; we assume we're only here when allowed
  }
  return headers;
}

/**
 * Cache for storing plan data
 * @constant {LRUCache}
 */
const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
  max: 500,
});

/**
 * Retrieves plan data from cache or server
 * @param {string} handleId - Plan handle ID
 * @param {Axios} axios - Axios instance
 * @param {string} apiServer - API server URL
 * @param {string} [requesterDid] - Optional requester DID for private info
 * @returns {Promise<PlanSummaryRecord|undefined>} Plan data or undefined if not found
 *
 * @throws {Error} If server request fails
 */
export async function getPlanFromCache(
  handleId: string | undefined,
  axios: Axios,
  apiServer: string,
  requesterDid?: string,
): Promise<PlanSummaryRecord | undefined> {
  if (!handleId) {
    return undefined;
  }
  let cred = planCache.get(handleId);
  if (!cred) {
    const url =
      apiServer +
      "/api/v2/report/plans?handleId=" +
      encodeURIComponent(handleId);
    const headers = await getHeaders(requesterDid);
    try {
      const resp = await axios.get(url, { headers });
      if (resp.status === 200 && resp.data?.data?.length > 0) {
        cred = resp.data.data[0];
        planCache.set(handleId, cred);
      } else {
        logger.log(
          "[EndorserServer] Plan cache is empty for handle",
          handleId,
          " Got data:",
          JSON.stringify(resp.data),
        );
      }
    } catch (error) {
      logger.error(
        "[EndorserServer] Failed to load plan with handle",
        handleId,
        " Got error:",
        JSON.stringify(error),
      );
    }
  }
  return cred;
}

/**
 * Updates plan data in cache
 * @param {string} handleId - Plan handle ID
 * @param {PlanSummaryRecord} planSummary - Plan data to cache
 */
export async function setPlanInCache(
  handleId: string,
  planSummary: PlanSummaryRecord,
): Promise<void> {
  planCache.set(handleId, planSummary);
}

/**
 * Extracts user-friendly message from server error
 * @param {any} error - Error thrown from Endorser server call
 * @returns {string|undefined} User-friendly message or undefined if none found
 */
export function serverMessageForUser(error: unknown): string | undefined {
  if (error && typeof error === "object" && "response" in error) {
    const err = error as AxiosErrorResponse;
    return err.response?.data?.error?.message;
  }
  return undefined;
}

/**
 * Helpful for server errors, to get all the info -- including stuff skipped by toString & JSON.stringify
 * It works with AxiosError, eg handling an error.response intelligently.
 *
 * @param error
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function errorStringForLog(error: unknown) {
  let stringifiedError = "" + error;
  try {
    stringifiedError = JSON.stringify(error);
  } catch (e) {
    // can happen with Dexie, eg:
    // TypeError: Converting circular structure to JSON
    // --> starting at object with constructor 'DexieError2'
    // |     property '_promise' -> object with constructor 'DexiePromise'
    // --- property '_value' closes the circle
  }
  let fullError = "" + error + " - JSON: " + stringifiedError;

  if (error && typeof error === "object" && "response" in error) {
    const err = error as AxiosErrorResponse;
    const errorResponseText = JSON.stringify(err.response);
    // for some reason, error.response is not included in stringify result (eg. for 400 errors on invite redemptions)
    if (!R.empty(errorResponseText) && !fullError.includes(errorResponseText)) {
      // add error.response stuff
      if (
        err.response?.config &&
        err.config &&
        R.equals(err.config, err.response.config)
      ) {
        // but exclude "config" because it's already in there
        const newErrorResponseText = JSON.stringify(
          R.omit(["config"] as never[], err.response),
        );
        fullError +=
          " - .response w/o same config JSON: " + newErrorResponseText;
      } else {
        fullError += " - .response JSON: " + errorResponseText;
      }
    }
  }
  return fullError;
}

/**
 *
 * @returns { data: Array<OfferSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
 */
export async function getNewOffersToUser(
  axios: Axios,
  apiServer: string,
  activeDid: string,
  afterOfferJwtId?: string,
  beforeOfferJwtId?: string,
) {
  let url = `${apiServer}/api/v2/report/offers?recipientDid=${activeDid}`;
  if (afterOfferJwtId) {
    url += "&afterId=" + afterOfferJwtId;
  }
  if (beforeOfferJwtId) {
    url += "&beforeId=" + beforeOfferJwtId;
  }
  const headers = await getHeaders(activeDid);
  const response = await axios.get(url, { headers });
  return response.data;
}
/**
 *
 * @returns { data: Array<OfferToPlanSummaryRecord>, hitLimit: boolean true if maximum was hit and there may be more }
 */
export async function getNewOffersToUserProjects(
  axios: Axios,
  apiServer: string,
  activeDid: string,
  afterOfferJwtId?: string,
  beforeOfferJwtId?: string,
) {
  let url = `${apiServer}/api/v2/report/offersToPlansOwnedByMe`;
  if (afterOfferJwtId) {
    url += "?afterId=" + afterOfferJwtId;
  }
  if (beforeOfferJwtId) {
    url += afterOfferJwtId ? "&" : "?";
    url += "beforeId=" + beforeOfferJwtId;
  }
  const headers = await getHeaders(activeDid);
  const response = await axios.get(url, { headers });
  return response.data;
}

/**
 * Construct GiveAction VC for submission to server
 *
 * @param lastClaimId supplied when editing a previous claim
 */
export function hydrateGive(
  vcClaimOrig?: GiveActionClaim,
  fromDid?: string,
  toDid?: string,
  description?: string,
  amount?: number,
  unitCode?: string,
  fulfillsProjectHandleId?: string,
  fulfillsOfferHandleId?: string,
  isTrade: boolean = false,
  imageUrl?: string,
  providerPlanHandleId?: string,
  lastClaimId?: string,
): GiveActionClaim {
  const vcClaim: GiveActionClaim = vcClaimOrig
    ? R.clone(vcClaimOrig)
    : {
        "@context": SCHEMA_ORG_CONTEXT,
        "@type": "GiveAction",
      };

  if (lastClaimId) {
    vcClaim.lastClaimId = lastClaimId;
    delete vcClaim.identifier;
  }

  if (fromDid) {
    vcClaim.agent = { identifier: fromDid };
  }
  if (toDid) {
    vcClaim.recipient = { identifier: toDid };
  }
  vcClaim.description = description || undefined;

  if (amount && !isNaN(amount)) {
    const quantitativeValue: QuantitativeValue = {
      amountOfThisGood: amount,
      unitCode: unitCode || "HUR",
    };
    vcClaim.object = quantitativeValue;
  }

  // Initialize fulfills array if not present
  if (!Array.isArray(vcClaim.fulfills)) {
    vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
  }

  // Filter and add fulfills elements
  vcClaim.fulfills = vcClaim.fulfills.filter(
    (elem: { "@type": string }) => elem["@type"] !== "PlanAction",
  );

  if (fulfillsProjectHandleId) {
    vcClaim.fulfills.push({
      "@type": "PlanAction",
      identifier: fulfillsProjectHandleId,
    });
  }

  vcClaim.fulfills = vcClaim.fulfills.filter(
    (elem: { "@type": string }) => elem["@type"] !== "Offer",
  );

  if (fulfillsOfferHandleId) {
    vcClaim.fulfills.push({
      "@type": "Offer",
      identifier: fulfillsOfferHandleId,
    });
  }

  vcClaim.fulfills = vcClaim.fulfills.filter(
    (elem: { "@type": string }) =>
      elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction",
  );

  vcClaim.fulfills.push({
    "@type": isTrade ? "TradeAction" : "DonateAction",
  });

  vcClaim.image = imageUrl || undefined;

  if (providerPlanHandleId) {
    vcClaim.provider = {
      "@type": "PlanAction",
      identifier: providerPlanHandleId,
    };
  }

  return vcClaim;
}

/**
 *  For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
 *
 * @param fromDid may be null
 * @param toDid
 * @param description may be null
 * @param amount may be null
 */
export async function createAndSubmitGive(
  axios: Axios,
  apiServer: string,
  issuerDid: string,
  fromDid?: string,
  toDid?: string,
  description?: string,
  amount?: number,
  unitCode?: string,
  fulfillsProjectHandleId?: string,
  fulfillsOfferHandleId?: string,
  isTrade: boolean = false, // remove, because this app is all for gifting
  imageUrl?: string,
  providerPlanHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
  const vcClaim = hydrateGive(
    undefined,
    fromDid,
    toDid,
    description,
    amount,
    unitCode,
    fulfillsProjectHandleId,
    fulfillsOfferHandleId,
    isTrade,
    imageUrl,
    providerPlanHandleId,
    undefined,
  );
  return createAndSubmitClaim(
    vcClaim as GenericVerifiableCredential,
    issuerDid,
    apiServer,
    axios,
  );
}

/**
 * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
 *
 * @param fromDid may be null
 * @param toDid may be null if project is provided
 * @param description may be null
 * @param amount may be null
 */
export async function editAndSubmitGive(
  axios: Axios,
  apiServer: string,
  fullClaim: GenericCredWrapper<GiveActionClaim>,
  issuerDid: string,
  fromDid?: string,
  toDid?: string,
  description?: string,
  amount?: number,
  unitCode?: string,
  fulfillsProjectHandleId?: string,
  fulfillsOfferHandleId?: string,
  isTrade: boolean = false,
  imageUrl?: string,
  providerPlanHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
  const vcClaim = hydrateGive(
    fullClaim.claim,
    fromDid,
    toDid,
    description,
    amount,
    unitCode,
    fulfillsProjectHandleId,
    fulfillsOfferHandleId,
    isTrade,
    imageUrl,
    providerPlanHandleId,
    fullClaim.id,
  );
  return createAndSubmitClaim(
    vcClaim as GenericVerifiableCredential,
    issuerDid,
    apiServer,
    axios,
  );
}

/**
 * Construct Offer VC for submission to server
 *
 * @param lastClaimId supplied when editing a previous claim
 */
export function hydrateOffer(
  vcClaimOrig?: OfferClaim,
  fromDid?: string,
  toDid?: string,
  itemDescription?: string,
  amount?: number,
  unitCode?: string,
  conditionDescription?: string,
  fulfillsProjectHandleId?: string,
  validThrough?: string,
  lastClaimId?: string,
): OfferClaim {
  const vcClaim: OfferClaim = vcClaimOrig
    ? R.clone(vcClaimOrig)
    : {
        "@context": SCHEMA_ORG_CONTEXT,
        "@type": "Offer",
      };

  if (lastClaimId) {
    // this is an edit
    vcClaim.lastClaimId = lastClaimId;
    delete vcClaim.identifier;
  }

  if (fromDid) {
    vcClaim.offeredBy = { identifier: fromDid };
  }
  if (toDid) {
    vcClaim.recipient = { identifier: toDid };
  }
  vcClaim.description = conditionDescription || undefined;

  if (amount && !isNaN(amount)) {
    vcClaim.includesObject = {
      amountOfThisGood: amount,
      unitCode: unitCode || "HUR",
    };
  }

  if (itemDescription || fulfillsProjectHandleId) {
    vcClaim.itemOffered = vcClaim.itemOffered || {};
    vcClaim.itemOffered.description = itemDescription || undefined;
    if (fulfillsProjectHandleId) {
      vcClaim.itemOffered.isPartOf = {
        "@type": "PlanAction",
        identifier: fulfillsProjectHandleId,
      };
    }
  }

  vcClaim.validThrough = validThrough || undefined;

  return vcClaim;
}

/**
 * For result, see https://api.endorser.ch/api-docs/#/claims/post_api_v2_claim
 *
 * @param identity
 * @param description may be null
 * @param amount may be null
 * @param validThrough ISO 8601 date string YYYY-MM-DD (may be null)
 * @param fulfillsProjectHandleId ID of project to which this contributes (may be null)
 */
export async function createAndSubmitOffer(
  axios: Axios,
  apiServer: string,
  issuerDid: string,
  itemDescription: string,
  amount?: number,
  unitCode?: string,
  conditionDescription?: string,
  validThrough?: string,
  recipientDid?: string,
  fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
  const vcClaim = hydrateOffer(
    undefined,
    issuerDid,
    recipientDid,
    itemDescription,
    amount,
    unitCode,
    conditionDescription,
    fulfillsProjectHandleId,
    validThrough,
    undefined,
  );
  return createAndSubmitClaim(
    vcClaim as OfferClaim,
    issuerDid,
    apiServer,
    axios,
  );
}

export async function editAndSubmitOffer(
  axios: Axios,
  apiServer: string,
  fullClaim: GenericCredWrapper<OfferClaim>,
  issuerDid: string,
  itemDescription: string,
  amount?: number,
  unitCode?: string,
  conditionDescription?: string,
  validThrough?: string,
  recipientDid?: string,
  fulfillsProjectHandleId?: string,
): Promise<CreateAndSubmitClaimResult> {
  const vcClaim = hydrateOffer(
    fullClaim.claim,
    issuerDid,
    recipientDid,
    itemDescription,
    amount,
    unitCode,
    conditionDescription,
    fulfillsProjectHandleId,
    validThrough,
    fullClaim.id,
  );
  return createAndSubmitClaim(
    vcClaim as OfferClaim,
    issuerDid,
    apiServer,
    axios,
  );
}

// similar logic is found in endorser-mobile
export const createAndSubmitConfirmation = async (
  issuerDid: string,
  claim: GenericVerifiableCredential,
  lastClaimId: string, // used to set the lastClaimId
  handleId: string | undefined,
  apiServer: string,
  axios: Axios,
): Promise<CreateAndSubmitClaimResult> => {
  const goodClaim = removeSchemaContext(
    removeVisibleToDids(
      addLastClaimOrHandleAsIdIfMissing(claim, lastClaimId, handleId),
    ),
  );
  const confirmationClaim: GenericVerifiableCredential = {
    "@context": SCHEMA_ORG_CONTEXT,
    "@type": "AgreeAction",
    object: goodClaim,
  };
  return createAndSubmitClaim(confirmationClaim, issuerDid, apiServer, axios);
};

export async function createAndSubmitClaim(
  vcClaim: GenericVerifiableCredential,
  issuerDid: string,
  apiServer: string,
  axios: Axios,
): Promise<CreateAndSubmitClaimResult> {
  try {
    const vcPayload = {
      vc: {
        "@context": ["https://www.w3.org/2018/credentials/v1"],
        type: ["VerifiableCredential"],
        credentialSubject: vcClaim,
      },
    };

    const vcJwt: string = await createEndorserJwtForDid(issuerDid, vcPayload);

    // Make the xhr request payload
    const payload = JSON.stringify({ jwtEncoded: vcJwt });
    const url = `${apiServer}/api/v2/claim`;

    const response = await axios.post(url, payload, {
      headers: {
        "Content-Type": "application/json",
      },
    });

    return { success: true, handleId: response.data?.handleId };
  } catch (error: unknown) {
    logger.error("Error submitting claim:", error);
    const errorMessage: string =
      serverMessageForUser(error) ||
      (error && typeof error === "object" && "message" in error
        ? String(error.message)
        : undefined) ||
      "Got some error submitting the claim. Check your permissions, network, and error logs.";

    return {
      success: false,
      error: errorMessage,
    };
  }
}

export async function generateEndorserJwtUrlForAccount(
  account: KeyMetaMaybeWithPrivate,
  isRegistered: boolean,
  givenName: string,
  profileImageUrl: string,
  isContact: boolean,
) {
  const publicKeyHex = account.publicKeyHex;
  const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");

  const contactInfo = {
    iat: Date.now(),
    iss: account.did,
    own: {
      did: account.did,
      name: givenName ?? "",
      publicEncKey,
      registered: !!isRegistered,
    } as UserInfo,
  };
  if (profileImageUrl) {
    contactInfo.own.profileImageUrl = profileImageUrl;
  }

  // Add the next key -- not recommended for the QR code for such a high resolution
  if (isContact && account.derivationPath && account.mnemonic) {
    const newDerivPath = nextDerivationPath(account.derivationPath);
    const nextPublicHex = deriveAddress(account.mnemonic, newDerivPath)[2];
    const nextPublicEncKey = Buffer.from(nextPublicHex, "hex");
    const nextPublicEncKeyHash = sha256(nextPublicEncKey);
    const nextPublicEncKeyHashBase64 =
      Buffer.from(nextPublicEncKeyHash).toString("base64");
    contactInfo.own.nextPublicEncKeyHash = nextPublicEncKeyHashBase64;
  }

  const vcJwt = await createEndorserJwtForDid(account.did, contactInfo);

  const viewPrefix =
    APP_SERVER + "/deep-link" + CONTACT_IMPORT_CONFIRM_URL_PATH_TIME_SAFARI;
  return viewPrefix + vcJwt;
}

export async function createEndorserJwtForDid(
  issuerDid: string,
  payload: object,
  expiresIn?: number,
) {
  const account = await retrieveFullyDecryptedAccount(issuerDid);
  return createEndorserJwtForKey(
    account as KeyMetaWithPrivate,
    payload,
    expiresIn,
  );
}

/**
 * An AcceptAction is when someone accepts some contract or pledge.
 *
 * @param claim has properties '@context' & '@type'
 * @return true if the claim is a schema.org AcceptAction
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isAccept = (claim: Record<string, any>) => {
  return (
    claim &&
    claim["@context"] === SCHEMA_ORG_CONTEXT &&
    claim["@type"] === "AcceptAction"
  );
};

// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const isOffer = (claim: Record<string, any>) => {
  return (
    claim &&
    claim["@context"] === SCHEMA_ORG_CONTEXT &&
    claim["@type"] === "Offer"
  );
};

export function currencyShortWordForCode(unitCode: string, single: boolean) {
  return unitCode === "HUR" ? (single ? "hour" : "hours") : unitCode;
}

export function displayAmount(code: string, amt: number) {
  return "" + amt + " " + currencyShortWordForCode(code, amt === 1);
}

// insert a space before any capital letters except the initial letter
// (and capitalize initial letter, just in case)
export const capitalizeAndInsertSpacesBeforeCaps = (text: string) => {
  return !text
    ? ""
    : text[0].toUpperCase() + text.substr(1).replace(/([A-Z])/g, " $1");
};

/**
 return readable summary of claim, or something generic

 similar code is also contained in endorser-mobile
 **/
const claimSummary = (
  claim:
    | GenericVerifiableCredential
    | GenericCredWrapper<GenericVerifiableCredential>,
) => {
  if (!claim) {
    return "something";
  }
  let specificClaim: GenericVerifiableCredential;
  if ("claim" in claim) {
    // It's a GenericCredWrapper
    specificClaim = claim.claim as GenericVerifiableCredential;
  } else {
    // It's already a GenericVerifiableCredential
    specificClaim = claim;
  }
  if (Array.isArray(specificClaim)) {
    if (specificClaim.length === 1) {
      specificClaim = specificClaim[0];
    } else {
      return "multiple claims";
    }
  }
  const type = specificClaim["@type"];
  if (!type) {
    return "a claim";
  } else {
    let typeExpl = capitalizeAndInsertSpacesBeforeCaps(type);
    if (typeExpl === "Person") {
      typeExpl += " claim";
    }
    return "a " + typeExpl;
  }
};

/**
 return readable description of claim if possible, as a past-tense action

 identifiers is a list of objects with a 'did' field, each representing the user
 contacts is a list of objects with a 'did' field for others and a 'name' field for their name

 similar code is also contained in endorser-mobile
 **/
export const claimSpecialDescription = (
  record: GenericCredWrapper<GenericVerifiableCredential>,
  activeDid: string,
  identifiers: Array<string>,
  contacts: Array<Contact>,
) => {
  let claim:
    | GenericVerifiableCredential
    | GenericCredWrapper<GenericVerifiableCredential> = record.claim;
  if ("claim" in claim) {
    // it's a nested GenericCredWrapper
    claim = claim.claim as GenericVerifiableCredential;
  }

  const issuer = didInfo(record.issuer, activeDid, identifiers, contacts);
  const type = claim["@type"] || "UnknownType";

  if (type === "AgreeAction") {
    return (
      issuer +
      " agreed with " +
      claimSummary(claim.object as GenericVerifiableCredential)
    );
  } else if (isAccept(claim)) {
    return (
      issuer +
      " accepted " +
      claimSummary(claim.object as GenericVerifiableCredential)
    );
  } else if (type === "GiveAction") {
    const giveClaim = claim as GiveActionClaim;
    // @ts-expect-error because .did may be found in legacy data, before March 2023
    const legacyGiverDid = giveClaim.agent?.did;
    const giver = giveClaim.agent?.identifier || legacyGiverDid;
    const giverInfo = didInfo(giver, activeDid, identifiers, contacts);
    let gaveAmount = giveClaim.object?.amountOfThisGood
      ? displayAmount(
          giveClaim.object.unitCode as string,
          giveClaim.object.amountOfThisGood as number,
        )
      : "";
    if (giveClaim.description) {
      if (gaveAmount) {
        gaveAmount = gaveAmount + ", and also: ";
      }
      gaveAmount = gaveAmount + giveClaim.description;
    }
    if (!gaveAmount) {
      gaveAmount = "something not described";
    }
    // @ts-expect-error because .did may be found in legacy data, before March 2023
    const legacyRecipDid = giveClaim.recipient?.did;
    const gaveRecipientId = giveClaim.recipient?.identifier || legacyRecipDid;
    const gaveRecipientInfo = gaveRecipientId
      ? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts)
      : "";
    return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
  } else if (type === "JoinAction") {
    const joinClaim = claim as JoinActionClaim;
    // @ts-expect-error because .did may be found in legacy data, before March 2023
    const legacyDid = joinClaim.agent?.did;
    const agent = joinClaim.agent?.identifier || legacyDid;
    const contactInfo = didInfo(agent, activeDid, identifiers, contacts);

    let eventOrganizer =
      joinClaim.event &&
      joinClaim.event.organizer &&
      joinClaim.event.organizer.name;
    eventOrganizer = eventOrganizer || "";
    let eventName = joinClaim.event && joinClaim.event.name;
    eventName = eventName ? " " + eventName : "";
    let fullEvent = eventOrganizer + eventName;
    fullEvent = fullEvent ? " attended the " + fullEvent : "";

    let eventDate = joinClaim.event && joinClaim.event.startTime;
    eventDate = eventDate ? " at " + eventDate : "";
    return contactInfo + fullEvent + eventDate;
  } else if (isOffer(claim)) {
    const offerClaim = claim as OfferClaim;
    const offerer = offerClaim.offeredBy?.identifier;
    const contactInfo = didInfo(offerer, activeDid, identifiers, contacts);
    let offering = "";
    if (offerClaim.includesObject) {
      offering +=
        " " +
        displayAmount(
          offerClaim.includesObject.unitCode,
          offerClaim.includesObject.amountOfThisGood,
        );
    }
    if (offerClaim.itemOffered?.description) {
      offering += ", saying: " + offerClaim.itemOffered?.description;
    }
    // @ts-expect-error because .did may be found in legacy data, before March 2023
    const legacyDid = offerClaim.recipient?.did;
    const offerRecipientId = offerClaim.recipient?.identifier || legacyDid;
    const offerRecipientInfo = offerRecipientId
      ? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts)
      : "";
    return contactInfo + " offered" + offering + offerRecipientInfo;
  } else if (type === "PlanAction") {
    const planClaim = claim as PlanActionClaim;
    const claimer = planClaim.agent?.identifier || record.issuer;
    const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts);
    return claimerInfo + " announced a project: " + planClaim.name;
  } else if (type === "Tenure") {
    const tenureClaim = claim as TenureClaim;
    // @ts-expect-error because .did may be found in legacy data, before March 2023
    const legacyDid = tenureClaim.party?.did;
    const claimer = tenureClaim.party?.identifier || legacyDid;
    const contactInfo = didInfo(claimer, activeDid, identifiers, contacts);
    const polygon = tenureClaim.spatialUnit?.geo?.polygon || "";
    return (
      contactInfo +
      " possesses [" +
      polygon.substring(0, polygon.indexOf(" ")) +
      "...]"
    );
  } else {
    return issuer + " declared " + claimSummary(claim);
  }
};

export const BVC_MEETUPS_PROJECT_CLAIM_ID =
  import.meta.env.VITE_BVC_MEETUPS_PROJECT_CLAIM_ID ||
  "https://endorser.ch/entity/01GXYPFF7FA03NXKPYY142PY4H"; // production value, which seems like the safest value if forgotten

export const bvcMeetingJoinClaim = (did: string, startTime: string) => {
  return {
    "@context": SCHEMA_ORG_CONTEXT,
    "@type": "JoinAction",
    agent: {
      identifier: did,
    },
    event: {
      organizer: {
        name: "Bountiful Voluntaryist Community",
      },
      name: "Saturday Morning Meeting",
      startTime: startTime,
    },
  };
};

export async function createEndorserJwtVcFromClaim(
  issuerDid: string,
  claim: object,
) {
  // Make a payload for the claim
  const vcPayload = {
    vc: {
      "@context": ["https://www.w3.org/2018/credentials/v1"],
      type: ["VerifiableCredential"],
      credentialSubject: claim,
    },
  };
  return createEndorserJwtForDid(issuerDid, vcPayload);
}

/**
 * Create a JWT for a RegisterAction claim.
 *
 * @param activeDid - The DID of the user creating the invite
 * @param contact - The contact to register, with a 'did' field (all optional for invites)
 * @param identifier - The identifier for the invite, usually random
 * @param expiresIn - The number of seconds until the invite expires
 * @returns The JWT for the RegisterAction claim
 */
export async function createInviteJwt(
  activeDid: string,
  contact?: Contact,
  identifier?: string,
  expiresIn?: number, // in seconds
): Promise<string> {
  const vcClaim: RegisterActionClaim = {
    "@context": SCHEMA_ORG_CONTEXT,
    "@type": "RegisterAction",
    agent: { identifier: activeDid },
    object: SERVICE_ID,
    identifier: identifier,
  };
  if (contact?.did) {
    vcClaim.participant = { identifier: contact.did };
  }

  // Make a payload for the claim
  const vcPayload: { vc: VerifiableCredentialClaim } = {
    vc: {
      "@context": ["https://www.w3.org/2018/credentials/v1"],
      "@type": "VerifiableCredential",
      type: ["VerifiableCredential"],
      credentialSubject: vcClaim as unknown as ClaimObject, // Type assertion needed due to object being string
    },
  };

  // Create a signature using private key of identity
  const vcJwt = await createEndorserJwtForDid(activeDid, vcPayload, expiresIn);
  return vcJwt;
}

export async function register(
  activeDid: string,
  apiServer: string,
  axios: Axios,
  contact: Contact,
): Promise<{ success?: boolean; error?: string }> {
  try {
    const vcJwt = await createInviteJwt(activeDid, contact);
    const url = apiServer + "/api/v2/claim";
    const resp = await axios.post<{
      success?: {
        handleId?: string;
        embeddedRecordError?: string;
      };
      error?: string;
      message?: string;
    }>(url, { jwtEncoded: vcJwt });

    if (resp.data?.success?.handleId) {
      return { success: true };
    } else if (resp.data?.success?.embeddedRecordError) {
      let message =
        "There was some problem with the registration and so it may not be complete.";
      if (typeof resp.data.success.embeddedRecordError === "string") {
        message += " " + resp.data.success.embeddedRecordError;
      }
      return { error: message };
    } else {
      logger.error("Registration error:", JSON.stringify(resp.data));
      return { error: "Got a server error when registering." };
    }
  } catch (error: unknown) {
    if (error && typeof error === "object") {
      const err = error as AxiosErrorResponse;
      const errorMessage =
        err.message ||
        (err.response?.data &&
        typeof err.response.data === "object" &&
        "message" in err.response.data
          ? (err.response.data as { message: string }).message
          : undefined);
      logger.error("Registration error:", errorMessage || JSON.stringify(err));
      return { error: errorMessage || "Got a server error when registering." };
    }
    return { error: "Got a server error when registering." };
  }
}

export async function setVisibilityUtil(
  activeDid: string,
  apiServer: string,
  axios: Axios,
  db: NonsensitiveDexie,
  contact: Contact,
  visibility: boolean,
) {
  if (!activeDid) {
    return { error: "Cannot set visibility without an identifier." };
  }
  const url =
    apiServer + "/api/report/" + (visibility ? "canSeeMe" : "cannotSeeMe");
  const headers = await getHeaders(activeDid);
  const payload = JSON.stringify({ did: contact.did });

  try {
    const resp = await axios.post(url, payload, { headers });
    if (resp.status === 200) {
      const success = resp.data.success;
      if (success) {
        const platformService = PlatformServiceFactory.getInstance();
        await platformService.dbExec(
          "UPDATE contacts SET seesMe = ? WHERE did = ?",
          [visibility, contact.did],
        );
        if (USE_DEXIE_DB) {
          db.contacts.update(contact.did, { seesMe: visibility });
        }
      }
      return { success };
    } else {
      logger.error(
        "Got some bad server response when setting visibility: ",
        resp.status,
        resp,
      );
      const message =
        resp.data.error?.message || "Got some error setting visibility.";
      return { error: message };
    }
  } catch (err) {
    logger.error("Got some error when setting visibility:", err);
    return { error: "Check connectivity and try again." };
  }
}

/**
 * Fetches rate limits from the Endorser server.
 *
 * @param apiServer endorser server URL string
 * @param axios Axios instance
 * @param {string} issuerDid - The DID for which to check rate limits.
 * @returns {Promise<AxiosResponse>} The Axios response object.
 */
export async function fetchEndorserRateLimits(
  apiServer: string,
  axios: Axios,
  issuerDid: string,
) {
  const url = `${apiServer}/api/report/rateLimits`;
  const headers = await getHeaders(issuerDid);
  return await axios.get(url, { headers } as AxiosRequestConfig);
}

/**
 * Fetches rate limits from the image server.
 *
 * @param apiServer image server URL string
 * @param axios Axios instance
 * @param {string} issuerDid - The DID for which to check rate limits.
 * @returns {Promise<AxiosResponse>} The Axios response object.
 */
export async function fetchImageRateLimits(axios: Axios, issuerDid: string) {
  const url = DEFAULT_IMAGE_API_SERVER + "/image-limits";
  const headers = await getHeaders(issuerDid);
  return await axios.get(url, { headers } as AxiosRequestConfig);
}
