import type {
  Envelope,
  Event,
  EventItem,
  Exception,
  StackFrame,
  Stacktrace,
} from "@sentry/core";

import * as path from "node:path";

// This file is executed in a subprocess, and it is always run in this context.
// Therefore, it is acceptable to avoid using dynamic imports and instead import all necessary modules at the beginning.
import * as czech from "ethereum-cryptography/bip39/wordlists/czech.js";
import * as english from "ethereum-cryptography/bip39/wordlists/english.js";
import * as french from "ethereum-cryptography/bip39/wordlists/french.js";
import * as italian from "ethereum-cryptography/bip39/wordlists/italian.js";
import * as japanese from "ethereum-cryptography/bip39/wordlists/japanese.js";
import * as korean from "ethereum-cryptography/bip39/wordlists/korean.js";
import * as simplifiedChinese from "ethereum-cryptography/bip39/wordlists/simplified-chinese.js";
import * as SPANISH from "ethereum-cryptography/bip39/wordlists/spanish.js";
import * as traditionalChinese from "ethereum-cryptography/bip39/wordlists/traditional-chinese.js";

import { ANONYMIZED_PATH, anonymizeUserPaths } from "./anonymize-paths.js";
import { GENERIC_SERVER_NAME } from "./constants.js";

interface WordMatch {
  index: number;
  word: string;
}

export type AnonymizeEnvelopeResult =
  | { success: true; envelope: Envelope }
  | { success: false; error: string };

export type AnonymizeEventResult =
  | { success: true; event: Event }
  | { success: false; error: string };

const ANONYMIZED_MNEMONIC = "<mnemonic>";
const MNEMONIC_PHRASE_LENGTH_THRESHOLD = 7;
const MINIMUM_AMOUNT_OF_WORDS_TO_ANONYMIZE = 4;

export class Anonymizer {
  readonly #configPath?: string;

  constructor(configPath?: string) {
    this.#configPath = configPath;
  }

  /**
   * Anonymizes the events in the envelope in place, modifying the envelope.
   */
  public async anonymizeEventsFromEnvelope(
    envelope: Envelope,
  ): Promise<AnonymizeEnvelopeResult> {
    for (const item of envelope[1]) {
      if (item[0].type === "event") {
        /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions
          -- We know that the item is an event item */
        const eventItem = item as EventItem;

        const anonymizedEvent = await this.anonymizeEvent(eventItem[1]);
        if (anonymizedEvent.success) {
          eventItem[1] = anonymizedEvent.event;
        } else {
          return { success: false, error: anonymizedEvent.error };
        }
      }
    }

    return { success: true, envelope };
  }

  /**
   * Given a sentry serialized exception
   * (https://develop.sentry.dev/sdk/event-payloads/exception/), return an
   * anonymized version of the event.
   */
  public async anonymizeEvent(event: Event): Promise<AnonymizeEventResult> {
    const result: Event = {
      event_id: event.event_id,
      platform: event.platform,
      timestamp: event.timestamp,
      extra: event.extra,
      release: event.release,
      contexts: event.contexts,
      sdk: event.sdk,
      level: event.level,
      server_name: GENERIC_SERVER_NAME,
      environment: event.environment,
    };

    if (event.exception !== undefined && event.exception.values !== undefined) {
      const anonymizedExceptions = await this.#anonymizeExceptions(
        event.exception.values,
      );
      result.exception = {
        values: anonymizedExceptions,
      };
    }

    return { success: true, event: result };
  }

  /**
   * Return the anonymized filename and a boolean indicating if the content of
   * the file should be anonymized
   */
  public async anonymizeFilename(filename: string): Promise<{
    anonymizedFilename: string;
    anonymizeContent: boolean;
  }> {
    if (filename === this.#configPath) {
      return {
        anonymizedFilename: path.basename(filename),
        anonymizeContent: true,
      };
    }

    const anonymizedFilename = anonymizeUserPaths(filename);

    // We anonymize the content if the file path is entirely anonymized, or if
    // it wasn't anonymized because it's just a basename
    const anonymizeContent =
      anonymizedFilename === ANONYMIZED_PATH ||
      path.basename(filename) === filename;

    return {
      anonymizedFilename,
      anonymizeContent,
    };
  }

  public anonymizeErrorMessage(errorMessage: string): string {
    errorMessage = this.#anonymizeMnemonic(errorMessage);

    // We intentionally replace the config path with its own
    // anonymized token, to help differentiate the config
    // file in exception reports while keeping it anonymized.
    if (this.#configPath !== undefined) {
      errorMessage = errorMessage.replaceAll(
        this.#configPath,
        "<hardhat-config-file>",
      );
    }

    // hide hex strings of 20 chars or more
    const hexRegex = /(0x)?[0-9A-Fa-f]{20,}/g;

    return anonymizeUserPaths(errorMessage).replace(hexRegex, (match) =>
      match.replace(/./g, "x"),
    );
  }

  async #anonymizeExceptions(exceptions: Exception[]): Promise<Exception[]> {
    const anonymizedExceptions = await Promise.all(
      exceptions.map((exception) => this.#anonymizeException(exception)),
    );
    return anonymizedExceptions;
  }

  async #anonymizeException(value: Exception): Promise<Exception> {
    const result: Exception = {
      type: value.type,
      mechanism: value.mechanism,
    };

    if (value.value !== undefined) {
      result.value = this.anonymizeErrorMessage(value.value);
    }

    if (value.stacktrace !== undefined) {
      result.stacktrace = await this.#anonymizeStacktrace(value.stacktrace);
    }

    return result;
  }

  async #anonymizeStacktrace(stacktrace: Stacktrace): Promise<Stacktrace> {
    if (stacktrace.frames !== undefined) {
      const anonymizedFrames = await this.#anonymizeFrames(stacktrace.frames);
      return {
        frames: anonymizedFrames,
      };
    }

    return {};
  }

  async #anonymizeFrames(frames: StackFrame[]): Promise<StackFrame[]> {
    const anonymizedFrames = await Promise.all(
      frames.map(async (frame) => {
        return await this.#anonymizeFrame(frame);
      }),
    );
    return anonymizedFrames;
  }

  async #anonymizeFrame(frame: StackFrame): Promise<StackFrame> {
    const result: StackFrame = {
      lineno: frame.lineno,
      colno: frame.colno,
      function: frame.function,
    };

    let anonymizeContent = true;
    if (frame.filename !== undefined) {
      const anonymizationResult = await this.anonymizeFilename(frame.filename);
      result.filename = anonymizationResult.anonymizedFilename;
      anonymizeContent = anonymizationResult.anonymizeContent;
    }

    if (!anonymizeContent) {
      result.context_line = frame.context_line;
      result.pre_context = frame.pre_context;
      result.post_context = frame.post_context;
      result.vars = frame.vars;
    }

    return result;
  }

  #anonymizeMnemonic(errorMessage: string): string {
    const matches = getAllWordMatches(errorMessage);

    // If there are enough consecutive words, there's a good chance of there being a mnemonic phrase
    if (matches.length < MNEMONIC_PHRASE_LENGTH_THRESHOLD) {
      return errorMessage;
    }

    const mnemonicWordList = [
      czech,
      english,
      french,
      italian,
      japanese,
      korean,
      simplifiedChinese,
      SPANISH,
      traditionalChinese,
    ]
      .map((wordlistModule) => wordlistModule.wordlist)
      .flat();

    let anonymizedMessage = errorMessage.slice(0, matches[0].index);

    // Determine all mnemonic phrase maximal fragments.
    // We check sequences of n consecutive words just in case there is a typo
    let wordIndex = 0;
    while (wordIndex < matches.length) {
      const maximalPhrase = getMaximalMnemonicPhrase(
        matches,
        errorMessage,
        wordIndex,
        mnemonicWordList,
      );

      if (maximalPhrase.length >= MINIMUM_AMOUNT_OF_WORDS_TO_ANONYMIZE) {
        const lastAnonymizedWord = maximalPhrase[maximalPhrase.length - 1];
        const nextWordIndex =
          wordIndex + maximalPhrase.length < matches.length
            ? matches[wordIndex + maximalPhrase.length].index
            : errorMessage.length;
        const sliceUntilNextWord = errorMessage.slice(
          lastAnonymizedWord.index + lastAnonymizedWord.word.length,
          nextWordIndex,
        );
        anonymizedMessage += `${ANONYMIZED_MNEMONIC}${sliceUntilNextWord}`;
        wordIndex += maximalPhrase.length;
      } else {
        const thisWord = matches[wordIndex];
        const nextWordIndex =
          wordIndex + 1 < matches.length
            ? matches[wordIndex + 1].index
            : errorMessage.length;
        const sliceUntilNextWord = errorMessage.slice(
          thisWord.index,
          nextWordIndex,
        );
        anonymizedMessage += sliceUntilNextWord;
        wordIndex++;
      }
    }

    return anonymizedMessage;
  }
}

function getMaximalMnemonicPhrase(
  matches: WordMatch[],
  originalMessage: string,
  startIndex: number,
  mnemonicWordList: string[],
): WordMatch[] {
  const maximalPhrase: WordMatch[] = [];
  for (let i = startIndex; i < matches.length; i++) {
    const thisMatch = matches[i];
    if (!mnemonicWordList.includes(thisMatch.word)) {
      break;
    }

    if (maximalPhrase.length > 0) {
      // Check that there's only whitespace until this word.
      const lastMatch = maximalPhrase[maximalPhrase.length - 1];
      const lastIndex = lastMatch.index + lastMatch.word.length;
      const sliceBetweenWords = originalMessage.slice(
        lastIndex,
        thisMatch.index,
      );
      if (!/\s+/u.test(sliceBetweenWords)) {
        break;
      }
    }

    maximalPhrase.push(thisMatch);
  }
  return maximalPhrase;
}

function getAllWordMatches(errorMessage: string) {
  const matches: WordMatch[] = [];
  const re = /\p{Letter}+/gu;
  let match = re.exec(errorMessage);
  while (match !== null) {
    matches.push({
      word: match[0],
      index: match.index,
    });
    match = re.exec(errorMessage);
  }
  return matches;
}
