// Copyright © 2020-2022 Truestamp Inc. All rights reserved.

import { z } from 'zod'
import { Tree } from '@truestamp/tree'
import { decodeUnsafely } from '@truestamp/id'
import { decode as hexDecode } from '@stablelib/hex'
import { decode as base64Decode } from '@stablelib/base64'
import { verify as verifyEd25519 } from '@stablelib/ed25519'

import {
  CanonicalHash,
  Commitment,
  CommitmentVerification,
  CommitProof,
  CommitTransaction,
  EntropyResponse,
  Item,
  SignedKey,
  SignedKeys,
  VerificationProof,
  VerificationTransaction,
} from './types'

import { canonicalizeAndHashData, getEntropyFromHash, publicKeyMatchesKnownPublicKey, timestampMicrosecondsToISO } from './utils'

import { verifyStellar } from './verifyStellar'

function createCommitmentVerificationError(errorMessage: string): CommitmentVerification {
  return {
    verified: false,
    error: `Commitment invalid : ${errorMessage}`,
  }
}

/**
 * Do the work of verifying a Commitment. If any steps fail, the verification
 * will fail.
 * @param commitment The commitment to verify.
 * @param skipItemSignatureVerification If true, do not attempt to verify the item's signatures.
 * @param skipCommitmentSignatureVerification If true, do not attempt to verify the commitment's signatures.
 *
 * @returns A CommitmentVerification object.
 * @throws An error if the commitment is invalid.
 */
async function verifier(
  commitment: Commitment,
  keys: SignedKeys | undefined = undefined,
  entropyFromHashFunction?: ((hash: string) => Promise<EntropyResponse | undefined>) | undefined,
  skipItemSignatureVerification = false,
  skipCommitmentSignatureVerification = false,
): Promise<CommitmentVerification> {
  try {
    Commitment.parse(commitment)

    const { commitmentData, commitmentDataSignatures } = commitment
    const { id, itemData, itemDataSignatures, itemSignals, proofs, transactions } = commitmentData

    // //////////////////////////////////////////////////////////////////////////////
    // Verify Id Validity
    // //////////////////////////////////////////////////////////////////////////////

    const decodedId = decodeUnsafely(id)

    // //////////////////////////////////////////////////////////////////////////////
    // Verify ItemData Signature(s)
    // //////////////////////////////////////////////////////////////////////////////

    if (!itemData) {
      throw new Error('Commitment is missing itemData.')
    }

    // Canonicalize the Item data for signature verification.
    const canonicalItemDataHash: CanonicalHash = canonicalizeAndHashData(itemData)

    if (!skipItemSignatureVerification) {
      // Verify each ed25519 signature.
      for (const sig of itemDataSignatures ?? []) {
        const { publicKey, signature } = sig
        const publicKeyDecoded: Uint8Array = base64Decode(publicKey)
        const signatureIsVerified: boolean = verifyEd25519(publicKeyDecoded, canonicalItemDataHash.hash, base64Decode(signature))
        if (!signatureIsVerified) {
          throw new Error(`ItemData signature verification failed for public key ${publicKey}.`)
        }
      }
    }

    // //////////////////////////////////////////////////////////////////////////////
    // Collect itemData hashes
    // //////////////////////////////////////////////////////////////////////////////

    // Create an Array of the itemData hashes collected from each itemData entry
    // to be listed in the hashes that the commitment 'commitsTo'.
    const itemDataHashes: { hash: string; hashType: string }[] = []
    for (const element of itemData ?? []) {
      const { hash, hashType } = element
      if (hash !== undefined) {
        itemDataHashes.push({ hash, hashType })
      }
    }

    // //////////////////////////////////////////////////////////////////////////////
    // Construct Item Hash to verify against first proof later
    // //////////////////////////////////////////////////////////////////////////////

    const item: Item = {
      itemData: itemData,
      itemDataSignatures: itemDataSignatures,
      itemSignals: itemSignals,
    }

    const canonicalItemHash: CanonicalHash = canonicalizeAndHashData(item)

    // //////////////////////////////////////////////////////////////////////////////
    // Fetch timestamp associated with the item.observableEntropy hash
    // //////////////////////////////////////////////////////////////////////////////

    let observableEntropyCreatedAt: Date | undefined = undefined
    if (itemSignals?.observableEntropy) {
      let entropy: EntropyResponse | undefined

      if (entropyFromHashFunction) {
        // external fetch function
        entropy = await entropyFromHashFunction(itemSignals?.observableEntropy)
      } else {
        entropy = await getEntropyFromHash(itemSignals?.observableEntropy)
      }

      const result = EntropyResponse.safeParse(entropy)
      if (result.success) {
        observableEntropyCreatedAt = new Date(result.data.data.timestamp.capturedAt)
      }
    }

    // //////////////////////////////////////////////////////////////////////////////
    // Verify CommitmentData Signature(s)
    // //////////////////////////////////////////////////////////////////////////////

    // Canonicalize the Commitment data for signature verification.
    const canonicalCommitmentDataHash: CanonicalHash = canonicalizeAndHashData(commitmentData)

    if (!skipCommitmentSignatureVerification) {
      // Verify each ed25519 signature.
      for (const sig of commitmentDataSignatures) {
        const { publicKey, signature } = sig
        const publicKeyDecoded: Uint8Array = base64Decode(publicKey)
        const signatureVerified = verifyEd25519(publicKeyDecoded, canonicalCommitmentDataHash.hash, base64Decode(signature))
        if (!signatureVerified) {
          throw new Error(`CommitmentData signature verification failed for public key '${publicKey}'`)
        }

        // Verify that the public key used for the signature matches one
        // of the known authoritative public keys and is validly self-signed.
        const publicKeyVerified = await publicKeyMatchesKnownPublicKey(publicKeyDecoded, keys)
        if (!publicKeyVerified) {
          throw new Error(`CommitmentData signature public key verification failed for public key '${publicKey}'`)
        }
      }
    }

    // //////////////////////////////////////////////////////////////////////////////
    // Verify Merkle Inclusion Proofs
    // //////////////////////////////////////////////////////////////////////////////

    if (!proofs || !Array.isArray(proofs) || proofs.length === 0) {
      throw new Error('Commitment is missing proofs')
    }

    const verificationProofs: VerificationProof[] = []

    // Verify that each proof is chained correctly and that each
    // is provable using the Merkle tree inclusion proof and Merkle root.
    for (let i = 0; i < proofs.length; i++) {
      CommitProof.parse(proofs[i])

      const proof: CommitProof = proofs[i]

      const vp: VerificationProof = {
        inputHash: proof.inputHash,
        merkleRoot: proof.merkleRoot,
      }

      // Verify that the inputHash of the first proof matches the
      // canonical hash of the Item data.
      if (i === 0) {
        if (proof.inputHash !== canonicalItemHash.hashHex) {
          throw new Error(
            `Proof [${i}] inputHash '${proof.inputHash}' must match hash of canonical itemData, itemSignatures, itemSignals [${canonicalItemHash.hashHex}]`,
          )
        }
      }

      // Proof 2..n inputHash must match the Merkle root of the previous proof
      // and must be an independently verifiable proof.
      if (i >= 1) {
        const previousProofIndex = i - 1
        const previousProof: CommitProof = proofs[previousProofIndex]
        if (proof.inputHash !== previousProof.merkleRoot) {
          throw new Error(
            `Proof [${i}] inputHash '${proof.inputHash}' must match previous proof [${previousProofIndex}] merkleRoot '${previousProof.merkleRoot}'`,
          )
        }
      }

      // Verify that the proof is valid for the given data, inclusion proof, and Merkle root.
      try {
        const isTreeVerified = Tree.verify(hexDecode(proof.merkleRoot), proof.inclusionProof, hexDecode(proof.inputHash))
        if (!isTreeVerified) {
          throw new Error(`Proof [${i}] is not valid for the given data, inclusion proof, and Merkle root.`)
        }
      } catch (error) {
        if (error instanceof Error) {
          throw new Error(`Proof [${i}] is not valid for the given data, inclusion proof, and Merkle root: ${error.message}`)
        }
      }

      verificationProofs.push(vp)
    }

    const proofMerkleRoots: string[] = proofs.map((proof: CommitProof): string => {
      return proof.merkleRoot
    })

    // //////////////////////////////////////////////////////////////////////////////
    // Verify Commitment Transactions
    // //////////////////////////////////////////////////////////////////////////////

    if (!proofMerkleRoots || !Array.isArray(proofMerkleRoots) || proofMerkleRoots.length === 0) {
      throw new Error('Commitment is missing proof Merkle roots.')
    }

    const verificationTransactions: VerificationTransaction[] = []

    for (const merkleRoot of proofMerkleRoots) {
      const transactionsForMerkleRoot: CommitTransaction[] = transactions[merkleRoot] || []

      for (let i = 0; i < transactionsForMerkleRoot.length; i++) {
        try {
          const transaction: CommitTransaction = transactionsForMerkleRoot[i]
          let verificationResult

          // discriminate which union type to verify
          switch (transaction.intent) {
            case 'bitcoin':
              // TODO: verify bitcoin
              break

            case 'ethereum':
              // TODO: verify ethereum
              break

            case 'stellar':
              verificationResult = await verifyStellar(
                transaction,
                decodedId.test, // verify against a test network?
              )
              break

            case 'twitter':
              // TODO: verify twitter
              break

            default:
              // Ensure exhaustive checking of all union types.
              // See : https://medium.com/@ahsan.ayaz/understanding-discriminated-unions-in-typescript-1ccc0e053cf5
              // eslint-disable-next-line no-case-declarations ,@typescript-eslint/no-unused-vars
              const invalidCommitTransaction: never = transaction
              throw new Error(`Unknown transaction discriminant`)
          }

          verificationTransactions.push(VerificationTransaction.parse(verificationResult))
        } catch (error) {
          if (error instanceof z.ZodError) {
            const joinedIssues: string = error.issues
              .map((issue: z.ZodIssue) => {
                return `${issue.code} : [${issue.path.join(', ')}] : ${issue.message}`
              })
              .join('; ')

            // Return an error object with the transaction's info and the error message.
            const v: VerificationTransaction = {
              verified: false,
              intent: transactionsForMerkleRoot[i].intent,
              transaction: transactionsForMerkleRoot[0], // FIXME is this [0] correct in all cases?
              error: `Transaction verification for '${transactionsForMerkleRoot[0].intent}' inputHash '${transactionsForMerkleRoot[i].inputHash}' failed : ${joinedIssues}`,
            }
            verificationTransactions.push(v)
          } else if (error instanceof Error) {
            // Return an error object with the transaction's info and the error message.
            const v: VerificationTransaction = {
              verified: false,
              intent: transactionsForMerkleRoot[i].intent,
              transaction: transactionsForMerkleRoot[0], // FIXME is this [0] correct in all cases?
              error: `Transaction verification for '${transactionsForMerkleRoot[0].intent}' inputHash '${transactionsForMerkleRoot[i].inputHash}' failed : ${error.message}`,
            }
            verificationTransactions.push(v)
          }
        }
      }
    }

    // Were ANY (some) of the transactions successfully verified against their external service?
    const anyTransactionsVerified = verificationTransactions.some((v: VerificationTransaction) => {
      return v.verified === true
    })

    // Collect all of the timestamps from the verified transactions. The timestamps
    // are retrieved from the transaction's block/ledger.
    const allVerifiedTransactionTimestamps: string[] = []
    verificationTransactions.forEach(tx => {
      if (tx.verified && tx.timestamp) {
        allVerifiedTransactionTimestamps.push(tx.timestamp)
      }
    })

    // Check if the timestamps are in ascending order. Need to convert them to Date objects for proper comparison.
    // String comparison is not enough, as the timestamps are in ISO 8601 format and may have differing end-of-string forms.
    // TypeScript forced to add '+' (Unary operator) to each of the Date objects being compared to coerce them to numbers. :-(
    // See : https://github.com/Microsoft/TypeScript/issues/5710
    const allVerifiedTransactionTimestampsSorted: string[] = allVerifiedTransactionTimestamps.sort(
      (a: string, b: string): number => +new Date(a) - +new Date(b),
    )

    // //////////////////////////////////////////////////////////////////////////////
    // Construct Commitment Verification Result
    // //////////////////////////////////////////////////////////////////////////////

    const verificationResult: CommitmentVerification = {
      id: id,
      idData: decodedId,
      verified: anyTransactionsVerified,
      itemData: {
        hash: canonicalItemDataHash.hashHex,
        hashType: canonicalItemDataHash.hashType,
        signaturesCount: itemDataSignatures ? itemDataSignatures.length : 0,
      },
      item: {
        hash: canonicalItemHash.hashHex,
        hashType: 'sha-256',
      },
      commitmentData: {
        hash: canonicalCommitmentDataHash.hashHex,
        hashType: 'sha-256',
        signaturesCount: commitmentDataSignatures ? commitmentDataSignatures.length : 0,
      },
      proofs: verificationProofs,
      transactions: verificationTransactions,
    }

    // Include the attributes the commitment verifiably commits to only if
    // the commitment is known to be fully verified by at least one on-chain transaction.
    if (anyTransactionsVerified) {
      verificationResult.commitsTo = {
        hashes: itemDataHashes,
        observableEntropy: observableEntropyCreatedAt ? item.itemSignals?.observableEntropy : undefined,
        timestamps: {
          submittedAfter: observableEntropyCreatedAt?.toISOString(),
          submittedAt: timestampMicrosecondsToISO(decodedId.timestamp),
          submittedBefore: allVerifiedTransactionTimestampsSorted, // Array of ISO 8601 timestamps of all verified transactions.
          submitWindowMilliseconds:
            observableEntropyCreatedAt && allVerifiedTransactionTimestampsSorted[0]
              ? +new Date(allVerifiedTransactionTimestampsSorted[0]) - +new Date(observableEntropyCreatedAt)
              : undefined,
        },
      }
    }

    return CommitmentVerification.parse(verificationResult)
  } catch (error) {
    if (error instanceof z.ZodError) {
      const joinedIssues: string = error.issues
        .map((issue: z.ZodIssue) => {
          return `${issue.code} : [${issue.path.join(', ')}] : ${issue.message}`
        })
        .join('; ')
      return createCommitmentVerificationError(joinedIssues)
    } else if (error instanceof Error) {
      return createCommitmentVerificationError(error.message)
    } else {
      return createCommitmentVerificationError('Unknown error')
    }
  }
}

type OptionsType = {
  keys?: SignedKey[]
  entropyFromHashFunction?: ((hash: string) => Promise<EntropyResponse | undefined>) | undefined
  skipItemSignatureVerification?: boolean
  skipCommitmentSignatureVerification?: boolean
}

/**
 * A function to check if a commitment is valid. If there are any errors,
 * the appropriate 'success' property will be set to 'false' but no error will be
 * thrown.
 *
 * You can provide a list of signed keys from https://keys.truestamp.com that were
 * previously saved.
 *
 * @param commitment A commitment object to verify.
 * @param options.keys Force use of a set of keys.
 * @param options.entropyFromHashFunction A function that returns the entropy for a given hash. Useful to pass when using Cloudflare workers service bindings.
 * @param options.skipItemSignatureVerification Skip verification of the item signature.
 * @param options.skipCommitmentSignatureVerification Skip verification of the commitment signature.
 * @returns A promise that resolves to an Object. The top-level `success` property will be 'true' if the entire proof is verified.
 */
export async function verify(commitment: Commitment, options?: OptionsType): Promise<CommitmentVerification> {
  return await verifier(
    commitment,
    options?.keys ?? [],
    options?.entropyFromHashFunction,
    options?.skipItemSignatureVerification ?? false,
    options?.skipCommitmentSignatureVerification ?? false,
  )
}

/**
 * Predicate function to check if a commitment is valid and returning true|false. Throws no Errors.
 * @param commitment A commitment object to verify.
 * @param options.keys Force use of a set of keys.
 * @param options.entropyFromHashFunction A function that returns the entropy for a given hash. Useful to pass when using Cloudflare workers service bindings.
 * @param options.skipItemSignatureVerification Skip verification of the item signature.
 * @param options.skipCommitmentSignatureVerification Skip verification of the commitment signature.
 * @returns A promise that resolves to a boolean indicating if the commitment is valid.
 */
export async function isVerified(commitment: Commitment, options?: OptionsType): Promise<boolean> {
  try {
    const verification: CommitmentVerification = await verifier(
      commitment,
      options?.keys ?? [],
      options?.entropyFromHashFunction,
      options?.skipItemSignatureVerification ?? false,
      options?.skipCommitmentSignatureVerification ?? false,
    )

    return verification.verified
  } catch (error) {
    return false
  }
}

/**
 * Assert that the commitment is valid. If not, throw an Error.
 * @param commitment A commitment object to verify.
 * @param options.keys Force use of a set of keys.
 * @param options.entropyFromHashFunction A function that returns the entropy for a given hash. Useful to pass when using Cloudflare workers service bindings.
 * @param options.skipItemSignatureVerification Skip verification of the item signature.
 * @param options.skipCommitmentSignatureVerification Skip verification of the commitment signature.
 * @returns A promise that resolves to void when the commitment is valid.
 */
export async function assertVerified(commitment: Commitment, options?: OptionsType): Promise<void> {
  const verification: CommitmentVerification = await verifier(
    commitment,
    options?.keys ?? [],
    options?.entropyFromHashFunction,
    options?.skipItemSignatureVerification ?? false,
    options?.skipCommitmentSignatureVerification ?? false,
  )

  // The verify() function should always return a commitment and
  // never throw an error. So we just need to check if the commitment
  // is successful and throw if not.
  if (!verification.verified) {
    throw new Error(verification.error)
  }
}
