import { DID, getDateFromUnixTimestamp, Id } from '@iden3/js-iden3-core';
import { DocumentLoader, getDocumentLoader, Path } from '@iden3/js-jsonld-merklization';
import { Hash } from '@iden3/js-merkletree';
import { IStateStorage, RootInfo } from '../../storage';
import { byteEncoder } from '../../utils';
import { calculateCoreSchemaHash, ProofQuery, ProofType } from '../../verifiable';
import { AtomicQueryMTPV2PubSignals } from '../../circuits/atomic-query-mtp-v2';
import { AtomicQuerySigV2PubSignals } from '../../circuits/atomic-query-sig-v2';
import { AtomicQueryV3PubSignals } from '../../circuits/atomic-query-v3';
import { AuthV2PubSignals } from '../../circuits/auth-v2';
import { BaseConfig } from '../../circuits/common';
import {
  LinkedMultiQueryPubSignals,
  LinkedMultiQueryInputs
} from '../../circuits/linked-multi-query';
import { CircuitId } from '../../circuits/models';
import {
  checkQueryRequest,
  ClaimOutputs,
  VerifyOpts,
  fieldValueFromVerifiablePresentation,
  validateDisclosureV2Circuit,
  validateEmptyCredentialSubjectV2Circuit,
  validateOperators,
  verifyFieldValueInclusionV2,
  validateDisclosureNativeSDSupport,
  validateEmptyCredentialSubjectNoopNativeSupport,
  verifyFieldValueInclusionNativeExistsSupport,
  checkCircuitOperator
} from './query';
import { parseQueriesMetadata, QueryMetadata } from '../common';
import { Operators } from '../../circuits';
import { calculateQueryHashV3 } from './query-hash';
import { JsonLd } from 'jsonld/jsonld-spec';
import {
  PROTOCOL_CONSTANTS,
  JSONObject,
  VerifiablePresentation,
  JsonDocumentObject
} from '../../iden3comm';
import { isRootDoesNotExistError } from '../../storage/blockchain/errors';

/**
 *  Verify Context - params for pub signal verification
 * @type VerifyContext
 */
export type VerifyContext = {
  pubSignals: string[];
  query: ProofQuery;
  verifiablePresentation?: VerifiablePresentation;
  sender: string;
  challenge: bigint;
  opts?: VerifyOpts;
  params?: JSONObject;
};

export const userStateError = new Error(`user state is not valid`);
const zeroInt = 0n;

/**
 * PubSignalsVerifier provides verify method
 * @public
 * @class PubSignalsVerifier
 */
export class PubSignalsVerifier {
  /**
   * Creates an instance of PubSignalsVerifier.
   * @param {DocumentLoader} _documentLoader document loader
   * @param {IStateStorage} _stateStorage state storage
   */

  constructor(
    private readonly _documentLoader: DocumentLoader,
    private readonly _stateStorage: IStateStorage
  ) {}

  /**
   * verify public signals
   *
   * @param {string} circuitId circuit id
   * @param {VerifyContext} ctx verification parameters
   * @returns `Promise<BaseConfig>`
   */
  async verify(circuitId: string, ctx: VerifyContext): Promise<BaseConfig> {
    const fnName = `${circuitId.split('-')[0]}Verify`;
    const fn = (this as unknown as { [k: string]: (ctx: VerifyContext) => Promise<BaseConfig> })[
      fnName
    ];
    if (!fn) {
      throw new Error(`public signals verifier for ${circuitId} not found`);
    }
    return fn(ctx);
  }

  private credentialAtomicQueryMTPV2Verify = async ({
    query,
    verifiablePresentation,
    sender,
    challenge,
    pubSignals,
    opts
  }: VerifyContext): Promise<BaseConfig> => {
    let mtpv2PubSignals = new AtomicQueryMTPV2PubSignals();
    mtpv2PubSignals = mtpv2PubSignals.pubSignalsUnmarshal(
      byteEncoder.encode(JSON.stringify(pubSignals))
    );

    if (!mtpv2PubSignals.userID) {
      throw new Error('user id is not presented in proof public signals');
    }

    if (!mtpv2PubSignals.requestID) {
      throw new Error('requestId is not presented in proof public signals');
    }

    // verify query
    const outs: ClaimOutputs = {
      issuerId: mtpv2PubSignals.issuerID,
      schemaHash: mtpv2PubSignals.claimSchema,
      slotIndex: mtpv2PubSignals.slotIndex,
      operator: mtpv2PubSignals.operator,
      value: mtpv2PubSignals.value,
      timestamp: mtpv2PubSignals.timestamp,
      merklized: mtpv2PubSignals.merklized,
      claimPathKey: mtpv2PubSignals.claimPathKey,
      claimPathNotExists: mtpv2PubSignals.claimPathNotExists,
      valueArraySize: mtpv2PubSignals.getValueArrSize(),
      isRevocationChecked: mtpv2PubSignals.isRevocationChecked
    };

    await this.checkQueryV2Circuits(
      CircuitId.AtomicQueryMTPV2,
      query,
      outs,
      opts,
      verifiablePresentation
    );

    // verify state
    await this.checkStateExistenceForId(
      mtpv2PubSignals.issuerID,
      mtpv2PubSignals.issuerClaimIdenState
    );

    if (mtpv2PubSignals.isRevocationChecked !== 0) {
      await this.checkRevocationState(
        mtpv2PubSignals.issuerID,
        mtpv2PubSignals.issuerClaimNonRevState,
        opts
      );
    }

    // verify ID ownership
    this.verifyIdOwnership(sender, challenge, mtpv2PubSignals.userID, mtpv2PubSignals.requestID);
    return mtpv2PubSignals;
  };

  private credentialAtomicQuerySigV2Verify = async ({
    query,
    verifiablePresentation,
    sender,
    challenge,
    pubSignals,
    opts
  }: VerifyContext): Promise<BaseConfig> => {
    let sigV2PubSignals = new AtomicQuerySigV2PubSignals();
    sigV2PubSignals = sigV2PubSignals.pubSignalsUnmarshal(
      byteEncoder.encode(JSON.stringify(pubSignals))
    );

    // verify query
    const outs: ClaimOutputs = {
      issuerId: sigV2PubSignals.issuerID,
      schemaHash: sigV2PubSignals.claimSchema,
      slotIndex: sigV2PubSignals.slotIndex,
      operator: sigV2PubSignals.operator,
      value: sigV2PubSignals.value,
      timestamp: sigV2PubSignals.timestamp,
      merklized: sigV2PubSignals.merklized,
      claimPathKey: sigV2PubSignals.claimPathKey,
      claimPathNotExists: sigV2PubSignals.claimPathNotExists,
      valueArraySize: sigV2PubSignals.getValueArrSize(),
      isRevocationChecked: sigV2PubSignals.isRevocationChecked
    };

    await this.checkQueryV2Circuits(
      CircuitId.AtomicQuerySigV2,
      query,
      outs,
      opts,
      verifiablePresentation
    );

    // verify state
    await this.checkStateExistenceForId(sigV2PubSignals.issuerID, sigV2PubSignals.issuerAuthState);

    if (sigV2PubSignals.isRevocationChecked !== 0) {
      await this.checkRevocationState(
        sigV2PubSignals.issuerID,
        sigV2PubSignals.issuerClaimNonRevState,
        opts
      );
    }
    // verify Id ownership
    this.verifyIdOwnership(sender, challenge, sigV2PubSignals.userID, sigV2PubSignals.requestID);

    return sigV2PubSignals;
  };

  private credentialAtomicQueryV3Verify = async ({
    query,
    verifiablePresentation,
    sender,
    challenge,
    pubSignals,
    opts,
    params
  }: VerifyContext): Promise<BaseConfig> => {
    let v3PubSignals = new AtomicQueryV3PubSignals();
    v3PubSignals = v3PubSignals.pubSignalsUnmarshal(byteEncoder.encode(JSON.stringify(pubSignals)));

    // verify query
    const outs: ClaimOutputs = {
      issuerId: v3PubSignals.issuerID,
      schemaHash: v3PubSignals.claimSchema,
      slotIndex: v3PubSignals.slotIndex,
      operator: v3PubSignals.operator,
      value: v3PubSignals.value,
      timestamp: v3PubSignals.timestamp,
      merklized: v3PubSignals.merklized,
      claimPathKey: v3PubSignals.claimPathKey,
      valueArraySize: v3PubSignals.getValueArrSize(),
      operatorOutput: v3PubSignals.operatorOutput,
      isRevocationChecked: v3PubSignals.isRevocationChecked
    };

    if (!query.type) {
      throw new Error(`proof query type is undefined`);
    }

    const loader = this._documentLoader ?? getDocumentLoader();

    // validate schema
    let context: JsonLd;
    try {
      context = (await loader(query.context ?? '')).document;
    } catch (e) {
      throw new Error(`can't load schema for request query`);
    }

    const queriesMetadata = await parseQueriesMetadata(
      query.type,
      JSON.stringify(context),
      query.credentialSubject as JsonDocumentObject,
      {
        documentLoader: loader
      }
    );

    const circuitId = CircuitId.AtomicQueryV3;
    await checkQueryRequest(
      query,
      queriesMetadata,
      context,
      outs,
      circuitId,
      this._documentLoader,
      opts
    );

    const queryMetadata = queriesMetadata[0]; // only one query is supported

    checkCircuitOperator(circuitId, outs.operator);
    // validate selective disclosure
    if (queryMetadata.operator === Operators.SD) {
      try {
        await validateDisclosureNativeSDSupport(
          queryMetadata,
          outs,
          verifiablePresentation,
          loader
        );
      } catch (e) {
        throw new Error(`failed to validate selective disclosure: ${(e as Error).message}`);
      }
    } else if (!queryMetadata.fieldName && queryMetadata.operator == Operators.NOOP) {
      try {
        await validateEmptyCredentialSubjectNoopNativeSupport(outs);
      } catch (e: unknown) {
        throw new Error(`failed to validate operators: ${(e as Error).message}`);
      }
    } else {
      try {
        await validateOperators(queryMetadata, outs);
      } catch (e) {
        throw new Error(`failed to validate operators: ${(e as Error).message}`);
      }
    }

    // verify field inclusion / non-inclusion

    verifyFieldValueInclusionNativeExistsSupport(outs, queryMetadata);

    const { proofType, verifierID, nullifier, nullifierSessionID, linkID } = v3PubSignals;

    switch (query.proofType) {
      case ProofType.BJJSignature:
        if (proofType !== 1) {
          throw new Error('wrong proof type for BJJSignature');
        }
        break;
      case ProofType.Iden3SparseMerkleTreeProof:
        if (proofType !== 2) {
          throw new Error('wrong proof type for Iden3SparseMerkleTreeProof');
        }
        break;
      default:
      // if proof type is not specified in query any proof type in signals is OK.
    }

    const nSessionId = BigInt((params?.nullifierSessionId as string) ?? 0);

    if (nSessionId !== 0n) {
      if (BigInt(nullifier ?? 0) === 0n) {
        throw new Error('nullifier should be provided for nullification and should not be 0');
      }
      // verify nullifier information
      const verifierDIDParam = params?.verifierDid;
      if (!verifierDIDParam) {
        throw new Error('verifierDid is required');
      }

      const id = DID.idFromDID(verifierDIDParam as DID);

      if (verifierID.bigInt() != id.bigInt()) {
        throw new Error('wrong verifier is used for nullification');
      }

      if (nullifierSessionID !== nSessionId) {
        throw new Error(
          `wrong verifier session id is used for nullification, expected ${nSessionId}, got ${nullifierSessionID}`
        );
      }
    } else if (nullifierSessionID !== 0n) {
      throw new Error(`Nullifier id is generated but wasn't requested`);
    }

    if (!query.groupId && linkID !== 0n) {
      throw new Error(`proof contains link id, but group id is not provided`);
    }

    if (query.groupId && linkID === 0n) {
      throw new Error("proof doesn't contain link id, but group id is provided");
    }

    // verify state
    await this.checkStateExistenceForId(v3PubSignals.issuerID, v3PubSignals.issuerState);

    if (v3PubSignals.isRevocationChecked !== 0) {
      await this.checkRevocationState(
        v3PubSignals.issuerID,
        v3PubSignals.issuerClaimNonRevState,
        opts
      );
    }
    // verify Id ownership
    this.verifyIdOwnership(sender, challenge, v3PubSignals.userID, v3PubSignals.requestID);

    return v3PubSignals;
  };

  private authV2Verify = async ({
    sender,
    challenge,
    pubSignals,
    opts
  }: VerifyContext): Promise<BaseConfig> => {
    let authV2PubSignals = new AuthV2PubSignals();
    authV2PubSignals = authV2PubSignals.pubSignalsUnmarshal(
      byteEncoder.encode(JSON.stringify(pubSignals))
    );

    // no query verification
    // verify state
    const gist = await this.checkGlobalState(authV2PubSignals.GISTRoot, authV2PubSignals.userID);

    let acceptedStateTransitionDelay = PROTOCOL_CONSTANTS.DEFAULT_AUTH_VERIFY_DELAY;
    if (opts?.acceptedStateTransitionDelay) {
      acceptedStateTransitionDelay = opts.acceptedStateTransitionDelay;
    }

    if (!gist.latest) {
      const timeDiff =
        Date.now() - getDateFromUnixTimestamp(Number(gist.transitionTimestamp)).getTime();
      if (timeDiff > acceptedStateTransitionDelay) {
        throw new Error('global state is outdated');
      }
    }

    // verify Id ownership
    this.verifyIdOwnership(sender, challenge, authV2PubSignals.userID, authV2PubSignals.challenge);
    return new BaseConfig();
  };

  private linkedMultiQuery10Verify = async ({
    query,
    verifiablePresentation,
    pubSignals
  }: VerifyContext): Promise<BaseConfig> => {
    let multiQueryPubSignals = new LinkedMultiQueryPubSignals();

    multiQueryPubSignals = multiQueryPubSignals.pubSignalsUnmarshal(
      byteEncoder.encode(JSON.stringify(pubSignals))
    );

    // verify query
    let schema: JsonDocumentObject;
    const ldOpts = { documentLoader: this._documentLoader };
    try {
      schema = (await ldOpts.documentLoader(query.context || '')).document as JsonDocumentObject;
    } catch (e) {
      throw new Error(`can't load schema for request query`);
    }
    const ldContextJSON = JSON.stringify(schema);
    const credentialSubject = query.credentialSubject as JsonDocumentObject;
    const schemaId: string = await Path.getTypeIDFromContext(
      ldContextJSON,
      query.type || '',
      ldOpts
    );
    const schemaHash = calculateCoreSchemaHash(byteEncoder.encode(schemaId));

    const queriesMetadata = await parseQueriesMetadata(
      query.type || '',
      ldContextJSON,
      credentialSubject,
      ldOpts
    );

    const request: { queryHash: bigint; queryMeta: QueryMetadata }[] = [];
    const merklized = queriesMetadata[0]?.merklizedSchema ? 1 : 0;
    for (let i = 0; i < LinkedMultiQueryInputs.queryCount; i++) {
      const queryMeta = queriesMetadata[i];
      const values = queryMeta?.values ?? [];
      const valArrSize = values.length;

      const queryHash = calculateQueryHashV3(
        values,
        schemaHash,
        queryMeta?.slotIndex ?? 0,
        queryMeta?.operator ?? 0,
        queryMeta?.claimPathKey.toString() ?? 0,
        valArrSize,
        merklized,
        0,
        0,
        0
      );
      request.push({ queryHash, queryMeta });
    }

    const queryHashCompare = (a: { queryHash: bigint }, b: { queryHash: bigint }): number => {
      if (a.queryHash < b.queryHash) return -1;
      if (a.queryHash > b.queryHash) return 1;
      return 0;
    };

    const pubSignalsMeta = multiQueryPubSignals.circuitQueryHash.map((queryHash, index) => ({
      queryHash,
      operatorOutput: multiQueryPubSignals.operatorOutput[index]
    }));

    pubSignalsMeta.sort(queryHashCompare);
    request.sort(queryHashCompare);

    for (let i = 0; i < LinkedMultiQueryInputs.queryCount; i++) {
      if (request[i].queryHash != pubSignalsMeta[i].queryHash) {
        throw new Error('query hashes do not match');
      }

      if (request[i].queryMeta?.operator === Operators.SD) {
        const disclosedValue = await fieldValueFromVerifiablePresentation(
          request[i].queryMeta.fieldName,
          verifiablePresentation,
          this._documentLoader
        );
        if (disclosedValue != pubSignalsMeta[i].operatorOutput) {
          throw new Error('disclosed value is not in the proof outputs');
        }
      }
    }

    return multiQueryPubSignals as unknown as BaseConfig;
  };

  private verifyIdOwnership = (
    sender: string,
    challenge: bigint,
    expectedUserId: Id,
    expectedChallenge: bigint
  ): void => {
    const senderId = DID.idFromDID(DID.parse(sender));
    if (senderId.string() !== expectedUserId.string()) {
      throw new Error(
        `sender id is not used for proof creation, expected ${sender}, user from public signals: ${expectedUserId.string()}`
      );
    }
    if (challenge !== expectedChallenge) {
      throw new Error(
        `challenge is not used for proof creation, expected ${challenge}, challenge from public signals: ${expectedChallenge}  `
      );
    }
  };

  private async checkQueryV2Circuits(
    circuitId: CircuitId.AtomicQueryMTPV2 | CircuitId.AtomicQuerySigV2,
    query: ProofQuery,
    outs: ClaimOutputs,
    opts: VerifyOpts | undefined,
    verifiablePresentation: VerifiablePresentation | undefined
  ) {
    if (!query.type) {
      throw new Error(`proof query type is undefined`);
    }

    const loader = this._documentLoader ?? getDocumentLoader();

    // validate schema
    let context: JsonLd;
    try {
      context = (await loader(query.context ?? '')).document;
    } catch (e) {
      throw new Error(`can't load schema for request query`);
    }

    const queriesMetadata = await parseQueriesMetadata(
      query.type,
      JSON.stringify(context),
      query.credentialSubject as JsonDocumentObject,
      {
        documentLoader: loader
      }
    );

    await checkQueryRequest(
      query,
      queriesMetadata,
      context,
      outs,
      circuitId,
      this._documentLoader,
      opts
    );

    const queryMetadata = queriesMetadata[0]; // only one query is supported

    checkCircuitOperator(circuitId, outs.operator);

    // validate selective disclosure
    if (queryMetadata.operator === Operators.SD) {
      try {
        await validateDisclosureV2Circuit(queryMetadata, outs, verifiablePresentation, loader);
      } catch (e) {
        throw new Error(`failed to validate selective disclosure: ${(e as Error).message}`);
      }
    } else if (!queryMetadata.fieldName && queryMetadata.operator == Operators.NOOP) {
      try {
        await validateEmptyCredentialSubjectV2Circuit(queryMetadata, outs);
      } catch (e: unknown) {
        throw new Error(`failed to validate operators: ${(e as Error).message}`);
      }
    } else {
      try {
        await validateOperators(queryMetadata, outs);
      } catch (e) {
        throw new Error(`failed to validate operators: ${(e as Error).message}`);
      }
    }

    // verify field inclusion
    verifyFieldValueInclusionV2(outs, queryMetadata);
  }

  private async resolve(
    id: Id,
    state: bigint
  ): Promise<{
    latest: boolean;
    transitionTimestamp: number | string;
  }> {
    const idBigInt = id.bigInt();
    const contractState = await this._stateStorage.getStateInfoByIdAndState(idBigInt, state);

    if (!contractState.id || contractState.id.toString() !== idBigInt.toString()) {
      throw new Error(`state was recorded for another identity`);
    }

    if (!contractState.state || contractState.state.toString() !== state.toString()) {
      if (
        !contractState.replacedAtTimestamp ||
        contractState.replacedAtTimestamp.toString() === zeroInt.toString()
      ) {
        throw new Error(`no information about state transition`);
      }
      return {
        latest: false,
        transitionTimestamp: contractState.replacedAtTimestamp.toString()
      };
    }

    return {
      latest:
        !contractState.replacedAtTimestamp ||
        contractState.replacedAtTimestamp.toString() === zeroInt.toString(),
      transitionTimestamp: contractState.replacedAtTimestamp?.toString() ?? 0
    };
  }

  private async rootResolve(
    state: bigint,
    id: bigint
  ): Promise<{
    latest: boolean;
    transitionTimestamp: number | string;
  }> {
    let globalStateInfo: RootInfo;
    try {
      globalStateInfo = await this._stateStorage.getGISTRootInfo(state, id);
    } catch (e: unknown) {
      if (isRootDoesNotExistError(e)) {
        throw new Error('GIST root does not exist in the smart contract');
      }
      throw e;
    }

    if (globalStateInfo.root.toString() !== state.toString()) {
      throw new Error(`gist info contains invalid state`);
    }

    if (globalStateInfo.replacedByRoot.toString() !== zeroInt.toString()) {
      if (globalStateInfo.replacedAtTimestamp.toString() === zeroInt.toString()) {
        throw new Error(`state was replaced, but replaced time unknown`);
      }
      return {
        latest: false,
        transitionTimestamp: globalStateInfo.replacedAtTimestamp.toString()
      };
    }

    return {
      latest: true,
      transitionTimestamp: 0
    };
  }

  private checkStateExistenceForId = async (userId: Id, userState: Hash): Promise<void> => {
    await this.resolve(userId, userState.bigInt());
  };

  private checkGlobalState = async (
    state: Hash,
    id: Id
  ): Promise<{
    latest: boolean;
    transitionTimestamp: number | string;
  }> => {
    return this.rootResolve(state.bigInt(), id.bigInt());
  };

  private checkRevocationStateForId = async (
    issuerId: Id,
    issuerClaimNonRevState: Hash
  ): Promise<{
    latest: boolean;
    transitionTimestamp: number | string;
  }> => {
    const issuerNonRevStateResolved = await this.resolve(issuerId, issuerClaimNonRevState.bigInt());
    return issuerNonRevStateResolved;
  };

  private checkRevocationState = async (
    issuerID: Id,
    issuerClaimNonRevState: Hash,
    opts: VerifyOpts | undefined
  ) => {
    const issuerNonRevStateResolved = await this.checkRevocationStateForId(
      issuerID,
      issuerClaimNonRevState
    );

    const acceptedStateTransitionDelay =
      opts?.acceptedStateTransitionDelay ?? PROTOCOL_CONSTANTS.DEFAULT_PROOF_VERIFY_DELAY;

    if (!issuerNonRevStateResolved.latest) {
      const timeDiff =
        Date.now() -
        getDateFromUnixTimestamp(Number(issuerNonRevStateResolved.transitionTimestamp)).getTime();
      if (timeDiff > acceptedStateTransitionDelay) {
        throw new Error('issuer state is outdated');
      }
    }
  };
}
