import { decrypt, generatePrivate, getPublic } from "@toruslabs/eccrypto";
import { Data, generateJsonRPCObject, post, setEmbedHost } from "@toruslabs/http-helpers";
import BN from "bn.js";
import { ec as EC } from "elliptic";
import stringify from "json-stable-stringify";

import {
  CommitmentRequestResult,
  GetOrSetNonceResult,
  JRPCResponse,
  MetadataParams,
  PrivateKeyResponse,
  RetrieveSharesParams,
  RetrieveSharesResponse,
  SetCustomKeyOptions,
  ShareCommitmentParams,
  ShareRequestParams,
  ShareRequestResponse,
  MultifactorsCtorOptions,
} from "./interfaces";
import log from "./loglevel";
import { allSettled, GetOrSetNonceError, keccak256, thresholdSame } from "./utils";

// Implement threshold logic wrappers around public APIs
// of Multifactors nodes to handle malicious node responses
class Multifactors {
  public metadataHost: string;

  public serverTimeOffset: number;

  public enableOneKey: boolean;

  public network: string;

  protected ec: EC;

  public blsdkg: {
    init: () => Promise<void>;
    interpolate: (indexes: Uint8Array[], shares: Uint8Array[]) => Uint8Array;
    get_pk: (privKey: Uint8Array) => Uint8Array;
  };

  constructor({
    enableOneKey = false,
    metadataHost = "https://metadata.social-login.orai.io",
    serverTimeOffset = 0,
    network = "mainnet",
    blsdkg,
  }: MultifactorsCtorOptions = {}) {
    this.ec = new EC("secp256k1");
    this.metadataHost = metadataHost;
    this.enableOneKey = enableOneKey;
    this.serverTimeOffset = serverTimeOffset || 0; // ms
    this.network = network;
    this.blsdkg = blsdkg;
  }

  static enableLogging(v = true): void {
    if (v) log.enableAll();
    else log.disableAll();
  }

  static setEmbedHost(embedHost: string): void {
    setEmbedHost(embedHost);
  }

  static isGetOrSetNonceError(err: unknown): boolean {
    return err instanceof GetOrSetNonceError;
  }
  /* istanbul ignore next @preserve */
  async setCustomKey({ privKeyHex, metadataNonce, oraiKeyHex, customKeyHex }: SetCustomKeyOptions): Promise<void> {
    let oraiKey: BN;
    if (oraiKeyHex) {
      oraiKey = new BN(oraiKeyHex, 16);
    } else {
      const privKey = new BN(privKeyHex as string, 16);
      oraiKey = privKey.sub(metadataNonce as BN).umod(this.ec.curve.n);
    }
    const customKey = new BN(customKeyHex, 16);
    const newMetadataNonce = customKey.sub(oraiKey).umod(this.ec.curve.n);
    const data = this.generateMetadataParams(newMetadataNonce.toString(16), oraiKey);
    await this.setMetadata(data);
  }

  async retrieveShares(args: RetrieveSharesParams): Promise<PrivateKeyResponse> {
    if (!this.blsdkg) throw new Error("Undefined blsdkg");
    const { sharesIndexes, shares, thresholdPublicKey } = await this._retrieveShares(args);

    const privateKey: Uint8Array = await new Promise((resolve, reject) => {
      this.blsdkg
        .init()
        .then(() => {
          const blsPrivKey = this.blsdkg.interpolate(sharesIndexes.slice(0, 3), shares.slice(0, 3));
          const blsPubKey = Buffer.from(this.blsdkg.get_pk(blsPrivKey));
          log.info(blsPubKey.toString("hex"));
          log.info("ThresholdPubkey", thresholdPublicKey);

          if (blsPubKey.toString("hex") !== thresholdPublicKey) {
            reject("Public key not same");
          }

          resolve(blsPrivKey);
        })
        .catch(() => reject(new Error("Blsdkg process has been failed")));
    });

    return {
      privKey: Buffer.from(privateKey).toString("hex"),
    };
  }

  async retrieveSharesMobile(args: RetrieveSharesParams): Promise<RetrieveSharesResponse> {
    return this._retrieveShares(args);
  }

  async _retrieveShares({ typeOfLogin, endpoints, verifierParams, idToken, extraParams }: RetrieveSharesParams): Promise<RetrieveSharesResponse> {
    // generate temporary private and public key that is used to secure receive shares
    const tmpKey = generatePrivate();
    const pubKey = getPublic(tmpKey).toString("hex");
    const pubKeyX = pubKey.slice(2, 66);
    const pubKeyY = pubKey.slice(66);
    const tokenCommitment = keccak256(idToken);

    const commitmentResponses = await this.getSharesCommitment({
      endpoints,
      tokencommitment: tokenCommitment.slice(2),
      temppubx: pubKeyX,
      temppuby: pubKeyY,
    });

    if (commitmentResponses.length < ~~(endpoints.length / 2) + 1) {
      throw new Error("Insufficient commitment responses");
    }

    const shareResponsesAndIndex = await this.getShareRequestResponseWithIndex({
      endpoints,
      typeOfLogin,
      verifierParams,
      idtoken: idToken,
      nodesignatures: commitmentResponses,
      extraParams,
    });

    if (shareResponsesAndIndex.length < ~~(endpoints.length / 2) + 1) {
      throw new Error("Insufficient share responses");
    }
    const thresholdPublicKey = thresholdSame(
      shareResponsesAndIndex.map((x) => x && x.share.PublicKey),
      ~~(endpoints.length / 2) + 1
    );

    if (!thresholdPublicKey) throw new Error("Threshold pubkey is undefined");

    const sharePromises = shareResponsesAndIndex.map(async (x): Promise<void | Buffer> => {
      const key = x.share;
      if (key.Metadata) {
        const metadata = {
          ephemPublicKey: Buffer.from(key.Metadata.ephemPublicKey, "hex"),
          iv: Buffer.from(key.Metadata.iv, "hex"),
          mac: Buffer.from(key.Metadata.mac, "hex"),
        };
        return decrypt(tmpKey, {
          ...metadata,
          ciphertext: Buffer.from(key.Share, "base64"),
        }).catch((err) => log.debug("share decryption", err));
      }
      return Promise.resolve(Buffer.from(key.Share, "hex"));
    });

    const sharesIndexes = shareResponsesAndIndex.map((x) => Buffer.from([x.index + 1]));

    const shareResolved = (await Promise.all(sharePromises)).filter((x) => x) as Buffer[];

    return {
      shares: shareResolved,
      sharesIndexes,
      thresholdPublicKey,
    };
  }

  async getSharesCommitment({ endpoints, tokencommitment, temppubx, temppuby }: Readonly<ShareCommitmentParams>): Promise<CommitmentRequestResult[]> {
    const commitmentRequestPromises = endpoints.map((endpoint) =>
      post<JRPCResponse<CommitmentRequestResult>>(
        endpoint,
        generateJsonRPCObject("CommitmentRequest", {
          tokencommitment,
          temppubx,
          temppuby,
        })
      )
    );

    const commitmentResponses = await allSettled<JRPCResponse<CommitmentRequestResult>>(commitmentRequestPromises);
    const fulfillRespone = commitmentResponses.filter((x) => x.status === "fulfilled");
    return fulfillRespone.map((x) => (x as PromiseFulfilledResult<JRPCResponse<CommitmentRequestResult>>).value.result).filter((x) => x);
  }

  async getShareRequestResponseWithIndex({
    endpoints,
    typeOfLogin,
    verifierParams,
    nodesignatures,
    idtoken,
    extraParams,
  }: ShareRequestParams): Promise<{ share: ShareRequestResponse; index: number }[]> {
    const shareRequestPromises = endpoints.map((endpoint) =>
      post<JRPCResponse<ShareRequestResponse>>(
        endpoint,
        generateJsonRPCObject("ShareRequest", {
          ...verifierParams,
          typeOfLogin,
          idtoken,
          nodesignatures,
          ...extraParams,
        })
      )
    );

    const shareResponses = await allSettled<JRPCResponse<ShareRequestResponse>>(shareRequestPromises);

    const fulfillRespones = shareResponses.map((x, index) => {
      if (x.status === "rejected") return undefined;
      return {
        share: x.value.result,
        index,
      };
    });

    return fulfillRespones.filter((x) => x);
  }
  /* istanbul ignore next @preserve */
  async getPrivKey(key: BN): Promise<PrivateKeyResponse> {
    const privateKey = key;
    if (!privateKey) throw new Error("Invalid private key returned");
    return {
      privKey: privateKey.toString("hex", 64),
    };
  }

  /* istanbul ignore next @preserve */
  async getMetadata(data: Omit<MetadataParams, "set_data" | "signature">, options: RequestInit = {}): Promise<BN> {
    try {
      const metadataResponse = await post<{ message?: string }>(`${this.metadataHost}/get`, data, options, { useAPIKey: true });
      if (!metadataResponse || !metadataResponse.message) {
        return new BN(0);
      }
      return new BN(metadataResponse.message, 16); // nonce
    } catch (error) {
      log.error("get metadata error", error);
      return new BN(0);
    }
  }

  /* istanbul ignore next @preserve */
  generateMetadataParams(message: string, privateKey: BN): MetadataParams {
    const key = this.ec.keyFromPrivate(privateKey.toString("hex", 64));
    const setData = {
      data: message,
      timestamp: new BN(~~(this.serverTimeOffset + Date.now() / 1000)).toString(16),
    };
    const sig = key.sign(keccak256(stringify(setData)).slice(2));
    return {
      pub_key_X: key.getPublic().getX().toString("hex"),
      pub_key_Y: key.getPublic().getY().toString("hex"),
      set_data: setData,
      signature: Buffer.from(sig.r.toString(16, 64) + sig.s.toString(16, 64) + new BN("").toString(16, 2), "hex").toString("base64"),
    };
  }

  /* istanbul ignore next @preserve */
  async setMetadata(data: MetadataParams, options: RequestInit = {}): Promise<string> {
    try {
      const metadataResponse = await post<{ message: string }>(`${this.metadataHost}/set`, data, options, { useAPIKey: true });
      return metadataResponse.message; // IPFS hash
    } catch (error) {
      log.error("set metadata error", error);
      return "";
    }
  }

  /* istanbul ignore next @preserve */
  lagrangeInterpolation(shares: BN[], nodeIndex: BN[]): BN | null {
    if (shares.length !== nodeIndex.length) {
      return null;
    }
    let secret = new BN(0);
    for (let i = 0; i < shares.length; i += 1) {
      let upper = new BN(1);
      let lower = new BN(1);
      for (let j = 0; j < shares.length; j += 1) {
        if (i !== j) {
          upper = upper.mul(nodeIndex[j].neg());
          upper = upper.umod(this.ec.curve.n);
          let temp = nodeIndex[i].sub(nodeIndex[j]);
          temp = temp.umod(this.ec.curve.n);
          lower = lower.mul(temp).umod(this.ec.curve.n);
        }
      }
      let delta = upper.mul(lower.invm(this.ec.curve.n)).umod(this.ec.curve.n);
      delta = delta.mul(shares[i]).umod(this.ec.curve.n);
      secret = secret.add(delta);
    }
    return secret.umod(this.ec.curve.n);
  }

  /* istanbul ignore next @preserve */
  async getOrSetNonce(X: string, Y: string, privKey?: BN, getOnly = false): Promise<GetOrSetNonceResult> {
    let data: Data;
    const msg = getOnly ? "getNonce" : "getOrSetNonce";
    if (privKey) {
      data = this.generateMetadataParams(msg, privKey);
    } else {
      data = {
        pub_key_X: X,
        pub_key_Y: Y,
        set_data: { data: msg },
      };
    }
    return post<GetOrSetNonceResult>(`${this.metadataHost}/get_or_set_nonce`, data, undefined, { useAPIKey: true });
  }

  /* istanbul ignore next @preserve */
  async getNonce(X: string, Y: string, privKey?: BN): Promise<GetOrSetNonceResult> {
    return this.getOrSetNonce(X, Y, privKey, true);
  }

  /* istanbul ignore next @preserve */
  getPostboxKeyFrom1OutOf1(privKey: string, nonce: string): string {
    const privKeyBN = new BN(privKey, 16);
    const nonceBN = new BN(nonce, 16);
    return privKeyBN.sub(nonceBN).umod(this.ec.curve.n).toString("hex");
  }
}

export default Multifactors;
