import { Axios, AxiosRequestConfig, AxiosResponse } 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 } from "@/constants/app";
import { Contact } from "@/db/tables/contacts";
import { accessToken, deriveAddress, nextDerivationPath } from "@/libs/crypto";
import { logConsoleAndDb, NonsensitiveDexie } from "@/db/index";
import {
  retrieveAccountMetadata,
  retrieveFullyDecryptedAccount,
  getPasskeyExpirationSeconds,
  GiverReceiverInputInfo,
} from "@/libs/util";
import { createEndorserJwtForKey, KeyMeta } from "@/libs/crypto/vc";
import { Account } from "@/db/tables/accounts";

export const SCHEMA_ORG_CONTEXT = "https://schema.org";
// the object in RegisterAction claims
export const SERVICE_ID = "endorser.ch";
// the header line for contacts exported via Endorser Mobile
export const CONTACT_CSV_HEADER = "name,did,pubKeyBase64,seesMe,registered";
// the prefix for the contact URL
export const CONTACT_URL_PREFIX = "https://endorser.ch";
// the suffix for the contact URL
export const ENDORSER_JWT_URL_LOCATION = "/contact?jwt=";
// the prefix for handle IDs, the permanent ID for claims on Endorser
export const ENDORSER_CH_HANDLE_PREFIX = "https://endorser.ch/entity/";

export interface AgreeVerifiableCredential {
  "@context": string;
  "@type": string;
  // "any" because arbitrary objects can be subject of agreement
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
  object: Record<string, any>;
}

export interface GiverOutputInfo {
  action: string;
  giver?: GiverReceiverInputInfo;
  description?: string;
  amount?: number;
  unitCode?: string;
}

export interface ClaimResult {
  success: { claimId: string; handleId: string };
  error: { code: string; message: string };
}

// similar to VerifiableCredentialSubject... maybe rename this
export interface GenericVerifiableCredential {
  "@context"?: string; // optional when embedded, eg. in an Agree
  "@type": string;
  [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}

export interface GenericCredWrapper<T extends GenericVerifiableCredential> {
  claim: T;
  claimType?: string;
  handleId: string;
  id: string;
  issuedAt: string;
  issuer: string;
  publicUrls?: Record<string, string>; // only for IDs that want to be public
}
export const BLANK_GENERIC_SERVER_RECORD: GenericCredWrapper<GenericVerifiableCredential> =
  {
    claim: { "@type": "" },
    handleId: "",
    id: "",
    issuedAt: "",
    issuer: "",
  };

// a summary record; the VC is found the fullClaim field
export interface GiveSummaryRecord {
  agentDid: string;
  amount: number;
  amountConfirmed: number;
  description: string;
  fullClaim: GiveVerifiableCredential;
  fulfillsHandleId: string;
  fulfillsPlanHandleId?: string;
  fulfillsType?: string;
  handleId: string;
  issuedAt: string;
  issuerDid: string;
  jwtId: string;
  providerPlanHandleId?: string;
  recipientDid: string;
  unit: string;
}

// a summary record; the VC is found the fullClaim field
export interface OfferSummaryRecord {
  amount: number;
  amountGiven: number;
  amountGivenConfirmed: number;
  fullClaim: OfferVerifiableCredential;
  fulfillsPlanHandleId: string;
  handleId: string;
  issuerDid: string;
  jwtId: string;
  nonAmountGivenConfirmed: number;
  objectDescription: string;
  offeredByDid: string;
  recipientDid: string;
  requirementsMet: boolean;
  unit: string;
  validThrough: string;
}

export interface OfferToPlanSummaryRecord extends OfferSummaryRecord {
  planName: string;
}

// a summary record; the VC is not currently part of this record
export interface PlanSummaryRecord {
  agentDid?: string; // optional, if the issuer wants someone else to manage as well
  description: string;
  endTime?: string;
  fulfillsPlanHandleId: string;
  handleId: string;
  image?: string;
  issuerDid: string;
  locLat?: number;
  locLon?: number;
  name?: string;
  startTime?: string;
  url?: string;
}

// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id4
export interface GiveVerifiableCredential extends GenericVerifiableCredential {
  "@context"?: string; // optional when embedded, eg. in an Agree
  "@type": "GiveAction";
  agent?: { identifier: string };
  description?: string;
  fulfills?: { "@type": string; identifier?: string; lastClaimId?: string }[];
  identifier?: string;
  image?: string;
  object?: { amountOfThisGood: number; unitCode: string };
  provider?: GenericVerifiableCredential; // typically @type & identifier
  recipient?: { identifier: string };
}

// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id8
export interface OfferVerifiableCredential extends GenericVerifiableCredential {
  "@context"?: string; // optional when embedded... though it doesn't make sense to agree to an offer
  "@type": "Offer";
  description?: string; // conditions for the offer
  includesObject?: { amountOfThisGood: number; unitCode: string };
  itemOffered?: {
    description?: string; // description of the item
    isPartOf?: { identifier?: string; lastClaimId?: string; "@type"?: string };
  };
  offeredBy?: { identifier: string };
  recipient?: { identifier: string };
  validThrough?: string;
}

// Note that previous VCs may have additional fields.
// https://endorser.ch/doc/html/transactions.html#id7
export interface PlanVerifiableCredential extends GenericVerifiableCredential {
  "@context": "https://schema.org";
  "@type": "PlanAction";
  name: string;
  agent?: { identifier: string };
  description?: string;
  identifier?: string;
  lastClaimId?: string;
  location?: {
    geo: { "@type": "GeoCoordinates"; latitude: number; longitude: number };
  };
}

/**
 * Represents data about a project
 *
 * @deprecated
 * We should use PlanSummaryRecord instead.
 **/
export interface PlanData {
  /**
   * Name of the project
   **/
  name: string;
  /**
   * Description of the project
   **/
  description: string;
  /**
   * URL referencing information about the project
   **/
  handleId: string;
  image?: string;
  /**
   * The DID of the issuer
   */
  issuerDid: string;
  /**
   * The identifier of the project -- different from jwtId, needs to be fixed
   **/
  rowid?: string;
}

export interface EndorserRateLimits {
  doneClaimsThisWeek: string;
  doneRegistrationsThisMonth: string;
  maxClaimsPerWeek: string;
  maxRegistrationsPerMonth: string;
  nextMonthBeginDateTime: string;
  nextWeekBeginDateTime: string;
}

export interface ImageRateLimits {
  doneImagesThisWeek: string;
  maxImagesPerWeek: string;
  nextWeekBeginDateTime: string;
}

export interface VerifiableCredential {
  exp?: number;
  iat: number;
  iss: string;
  vc: {
    "@context": string[];
    type: string[];
    credentialSubject: VerifiableCredentialSubject;
  };
}

// similar to GenericVerifiableCredential... maybe replace that one
export interface VerifiableCredentialSubject {
  "@context": string;
  "@type": string;
  [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}

export interface WorldProperties {
  startTime?: string;
  endTime?: string;
}

// AKA Registration & RegisterAction
export interface RegisterVerifiableCredential {
  "@context": typeof SCHEMA_ORG_CONTEXT;
  "@type": "RegisterAction";
  agent: { identifier: string };
  identifier?: string; // used for invites (when participant ID isn't known)
  object: string;
  participant?: { identifier: string }; // used when person is known (not an invite)
}

// now for some of the error & other wrapper types

export interface ResultWithType {
  type: string;
}

export interface SuccessResult extends ResultWithType {
  type: "success";
  response: AxiosResponse<ClaimResult>;
}

export interface ErrorResponse {
  error?: {
    message?: string;
  };
}

export interface InternalError {
  error: string; // for system logging
  userMessage?: string; // for user display
}

export interface ErrorResult extends ResultWithType {
  type: "error";
  error: InternalError;
}

export type CreateAndSubmitClaimResult = SuccessResult | ErrorResult;

export interface UserInfo {
  name: string;
  publicEncKey: string;
  registered: boolean;
  profileImageUrl?: string;
  nextPublicEncKeyHash?: string;
}

// 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";

export function isDid(did: string) {
  return did.startsWith("did:");
}

export function isHiddenDid(did: string) {
  return did === HIDDEN_DID;
}

export function isEmptyOrHiddenDid(did?: string) {
  return !did || did === HIDDEN_DID; // catching empty string as well
}

/**
 * @return true for any string within this primitive/object/array where func(input) === true
 *
 * Similar logic is found in endorser-mobile.
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function testRecursivelyOnStrings(func: (arg0: any) => boolean, input: any) {
  if (Object.prototype.toString.call(input) === "[object String]") {
    return func(input);
  } else if (input instanceof Object) {
    if (!Array.isArray(input)) {
      // it's an object
      for (const key in input) {
        if (testRecursivelyOnStrings(func, input[key])) {
          return true;
        }
      }
    } else {
      // it's an array
      for (const value of input) {
        if (testRecursivelyOnStrings(func, value)) {
          return true;
        }
      }
    }
    return false;
  } else {
    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[] = [],
  // 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: !!contact,
      profileImageUrl: contact.profileImageUrl,
    };
  } else {
    const myId = R.find(R.equals(did), allMyDids);
    return myId
      ? { displayName: "You (Alt ID)", known: true }
      : isHiddenDid(did)
        ? { displayName: "Someone Totally Outside Your View", known: false }
        : {
            displayName: "Someone Visible But Outside Your Contact List",
            known: false,
          };
  }
}

/**
 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;
}

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;
}

const planCache: LRUCache<string, PlanSummaryRecord> = new LRUCache({
  max: 500,
});

/**
 * @param handleId nullable, in which case "undefined" will be returned
 * @param requesterDid optional, in which case no private info will be returned
 * @param axios
 * @param apiServer
 */
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 {
        console.error(
          "Failed to load plan with handle",
          handleId,
          " Got data:",
          resp.data,
        );
      }
    } catch (error) {
      console.error(
        "Failed to load plan with handle",
        handleId,
        " Got error:",
        error,
      );
    }
  }
  return cred;
}

export async function setPlanInCache(
  handleId: string,
  planSummary: PlanSummaryRecord,
) {
  planCache.set(handleId, planSummary);
}

/**
 *
 * @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);
  console.log("Using headers: ", headers);
  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?: GiveVerifiableCredential,
  fromDid?: string,
  toDid?: string,
  description?: string,
  amount?: number,
  unitCode?: string,
  fulfillsProjectHandleId?: string,
  fulfillsOfferHandleId?: string,
  isTrade: boolean = false,
  imageUrl?: string,
  providerPlanHandleId?: string,
  lastClaimId?: string,
): GiveVerifiableCredential {
  // Remember: replace values or erase if it's null

  const vcClaim: GiveVerifiableCredential = vcClaimOrig
    ? R.clone(vcClaimOrig)
    : {
        "@context": SCHEMA_ORG_CONTEXT,
        "@type": "GiveAction",
      };

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

  vcClaim.agent = fromDid ? { identifier: fromDid } : undefined;
  vcClaim.recipient = toDid ? { identifier: toDid } : undefined;
  vcClaim.description = description || undefined;
  vcClaim.object =
    amount && !isNaN(amount)
      ? { amountOfThisGood: amount, unitCode: unitCode || "HUR" }
      : undefined;

  // ensure fulfills is an array
  if (!Array.isArray(vcClaim.fulfills)) {
    vcClaim.fulfills = vcClaim.fulfills ? [vcClaim.fulfills] : [];
  }
  // ... and replace or add each element, ending with Trade or Donate
  // I realize the following doesn't change any elements that are not PlanAction or Offer or Trade/Action.
  vcClaim.fulfills = vcClaim.fulfills.filter(
    (elem) => elem["@type"] !== "PlanAction",
  );
  if (fulfillsProjectHandleId) {
    vcClaim.fulfills.push({
      "@type": "PlanAction",
      identifier: fulfillsProjectHandleId,
    });
  }
  vcClaim.fulfills = vcClaim.fulfills.filter(
    (elem) => elem["@type"] !== "Offer",
  );
  if (fulfillsOfferHandleId) {
    vcClaim.fulfills.push({
      "@type": "Offer",
      identifier: fulfillsOfferHandleId,
    });
  }
  // do Trade/Donate last because current endorser.ch only looks at the first for plans & offers
  vcClaim.fulfills = vcClaim.fulfills.filter(
    (elem) =>
      elem["@type"] !== "DonateAction" && elem["@type"] !== "TradeAction",
  );
  vcClaim.fulfills.push({ "@type": isTrade ? "TradeAction" : "DonateAction" });

  vcClaim.image = imageUrl || undefined;

  vcClaim.provider = providerPlanHandleId
    ? { "@type": "PlanAction", identifier: providerPlanHandleId }
    : undefined;

  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,
  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<GiveVerifiableCredential>,
  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?: OfferVerifiableCredential,
  fromDid?: string,
  toDid?: string,
  itemDescription?: string,
  amount?: number,
  unitCode?: string,
  conditionDescription?: string,
  fulfillsProjectHandleId?: string,
  validThrough?: string,
  lastClaimId?: string,
): OfferVerifiableCredential {
  // Remember: replace values or erase if it's null

  const vcClaim: OfferVerifiableCredential = vcClaimOrig
    ? R.clone(vcClaimOrig)
    : {
        "@context": SCHEMA_ORG_CONTEXT,
        "@type": "Offer",
      };

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

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

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

  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 OfferVerifiableCredential,
    issuerDid,
    apiServer,
    axios,
  );
}

export async function editAndSubmitOffer(
  axios: Axios,
  apiServer: string,
  fullClaim: GenericCredWrapper<OfferVerifiableCredential>,
  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 OfferVerifiableCredential,
    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,
) => {
  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 { type: "success", response };
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
  } catch (error: any) {
    console.error("Error submitting claim:", error);
    const errorMessage: string =
      error.response?.data?.error?.message ||
      error.message ||
      "Got some error submitting the claim. Check your permissions, network, and error logs.";

    return {
      type: "error",
      error: {
        error: errorMessage,
      },
    };
  }
}

export async function generateEndorserJwtForAccount(
  account: Account,
  isRegistered?: boolean,
  name?: string,
  profileImageUrl?: string,
  // note that including the next key pushes QR codes to the next resolution smaller
  includeNextKeyIfDerived?: boolean,
) {
  const publicKeyHex = account.publicKeyHex;
  const publicEncKey = Buffer.from(publicKeyHex, "hex").toString("base64");

  const contactInfo = {
    iat: Date.now(),
    iss: account.did,
    own: {
      name: name ?? "",
      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 (includeNextKeyIfDerived && account?.mnemonic && account?.derivationPath) {
    const newDerivPath = nextDerivationPath(account.derivationPath as string);
    const nextPublicHex = deriveAddress(
      account.mnemonic as string,
      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 = CONTACT_URL_PREFIX + ENDORSER_JWT_URL_LOCATION;
  return viewPrefix + vcJwt;
}

export async function createEndorserJwtForDid(
  issuerDid: string,
  payload: object,
  expiresIn?: number,
) {
  const account = await retrieveFullyDecryptedAccount(issuerDid);
  return createEndorserJwtForKey(account as KeyMeta, 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
 **/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const claimSummary = (
  claim: GenericCredWrapper<GenericVerifiableCredential>,
) => {
  if (!claim) {
    // to differentiate from "something" above
    return "something";
  }
  let specificClaim:
    | GenericVerifiableCredential
    | GenericCredWrapper<GenericVerifiableCredential> = claim;
  if (claim.claim) {
    // probably a Verified Credential
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    specificClaim = claim.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 = record.claim;
  if (claim.claim) {
    // it's probably a Verified Credential
    claim = claim.claim;
  }

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

  if (type === "AgreeAction") {
    return issuer + " agreed with " + claimSummary(claim.object);
  } else if (isAccept(claim)) {
    return issuer + " accepted " + claimSummary(claim.object);
  } else if (type === "GiveAction") {
    // agent.did is for legacy data, before March 2023
    const giver = claim.agent?.identifier || claim.agent?.did;
    const giverInfo = didInfo(giver, activeDid, identifiers, contacts);
    let gaveAmount = claim.object?.amountOfThisGood
      ? displayAmount(claim.object.unitCode, claim.object.amountOfThisGood)
      : "";
    if (claim.description) {
      if (gaveAmount) {
        gaveAmount = gaveAmount + ", and also: ";
      }
      gaveAmount = gaveAmount + claim.description;
    }
    if (!gaveAmount) {
      gaveAmount = "something not described";
    }
    // recipient.did is for legacy data, before March 2023
    const gaveRecipientId = claim.recipient?.identifier || claim.recipient?.did;
    const gaveRecipientInfo = gaveRecipientId
      ? " to " + didInfo(gaveRecipientId, activeDid, identifiers, contacts)
      : "";
    return giverInfo + " gave" + gaveRecipientInfo + ": " + gaveAmount;
  } else if (type === "JoinAction") {
    // agent.did is for legacy data, before March 2023
    const agent = claim.agent?.identifier || claim.agent?.did;
    const contactInfo = didInfo(agent, activeDid, identifiers, contacts);

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

    let eventDate = claim.event && claim.event.startTime;
    eventDate = eventDate ? " at " + eventDate : "";
    return contactInfo + fullEvent + eventDate;
  } else if (isOffer(claim)) {
    const offerer = claim.offeredBy?.identifier;
    const contactInfo = didInfo(offerer, activeDid, identifiers, contacts);
    let offering = "";
    if (claim.includesObject) {
      offering +=
        " " +
        displayAmount(
          claim.includesObject.unitCode,
          claim.includesObject.amountOfThisGood,
        );
    }
    if (claim.itemOffered?.description) {
      offering += ", saying: " + claim.itemOffered?.description;
    }
    // recipient.did is for legacy data, before March 2023
    const offerRecipientId =
      claim.recipient?.identifier || claim.recipient?.did;
    const offerRecipientInfo = offerRecipientId
      ? " to " + didInfo(offerRecipientId, activeDid, identifiers, contacts)
      : "";
    return contactInfo + " offered" + offering + offerRecipientInfo;
  } else if (type === "PlanAction") {
    const claimer = claim.agent?.identifier || record.issuer;
    const claimerInfo = didInfo(claimer, activeDid, identifiers, contacts);
    return claimerInfo + " announced a project: " + claim.name;
  } else if (type === "Tenure") {
    // party.did is for legacy data, before March 2023
    const claimer = claim.party?.identifier || claim.party?.did;
    const contactInfo = didInfo(claimer, activeDid, identifiers, contacts);
    const polygon = claim.spatialUnit?.geo?.polygon || "";
    return (
      contactInfo +
      " possesses [" +
      polygon.substring(0, polygon.indexOf(" ")) +
      "...]"
    );
  } else {
    return (
      issuer +
      " declared " +
      claimSummary(claim as GenericCredWrapper<GenericVerifiableCredential>)
    );
  }
};

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);
}

export async function createInviteJwt(
  activeDid: string,
  contact?: Contact,
  inviteId?: string,
  expiresIn?: number,
): Promise<string> {
  const vcClaim: RegisterVerifiableCredential = {
    "@context": SCHEMA_ORG_CONTEXT,
    "@type": "RegisterAction",
    agent: { identifier: activeDid },
    object: SERVICE_ID,
  };
  if (contact) {
    vcClaim.participant = { identifier: contact.did };
  }
  if (inviteId) {
    vcClaim.identifier = inviteId;
  }
  // Make a payload for the claim
  const vcPayload = {
    vc: {
      "@context": ["https://www.w3.org/2018/credentials/v1"],
      type: ["VerifiableCredential"],
      credentialSubject: vcClaim,
    },
  };
  // 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 }> {
  const vcJwt = await createInviteJwt(activeDid, contact);

  const url = apiServer + "/api/v2/claim";
  const resp = await axios.post(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 {
    console.error(resp);
    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) {
        db.contacts.update(contact.did, { seesMe: visibility });
      }
      return { success };
    } else {
      console.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) {
    console.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);
}