import * as fs from "fs/promises";
import path from "path";
import { execSync, spawnSync } from "child_process";

/** Version that is used in bench data file */
export type Version = "unreleased" | (`${number}.${number}.${number}` & {});

/** Persistent benchmark data(mapping of `Version -> Data`) */
type Bench = {
  [key: string]: {
    /**
     * Storing Solana version used in the release to:
     * - Be able to build older versions
     * - Adjust for the changes in platform-tools
     */
    solanaVersion: Version;
    /** Benchmark results for a version */
    result: BenchResult;
  };
};

/** Benchmark result per version */
export type BenchResult = {
  /** Benchmark result for program binary size */
  binarySize: BinarySize;
  /** Benchmark result for compute units consumed */
  computeUnits: ComputeUnits;
  /** Benchmark result for stack memory usage */
  stackMemory: StackMemory;
};

/** `program name -> binary size` */
export type BinarySize = { [programName: string]: number };

/** `instruction name -> compute units consumed` */
export type ComputeUnits = { [ixName: string]: number };

/** `instruction name -> stack memory used` */
export type StackMemory = { [ixName: string]: number };

/**
 * How much of a percentage difference between the current and the previous data
 * should be significant. Any difference above this number should be noted in
 * the benchmark file.
 */
export const THRESHOLD_PERCENTAGE = 1;

/** Path to the benchmark Markdown files */
export const BENCH_DIR_PATH = path.join("..", "..", "bench");

/** Command line argument for Anchor version */
export const ANCHOR_VERSION_ARG = "--anchor-version";

/** Utility class to handle benchmark data related operations */
export class BenchData {
  /** Benchmark data filepath */
  static #PATH = "bench.json";

  /** Benchmark data */
  #data: Bench;

  constructor(data: Bench) {
    this.#data = data;
  }

  /** Open the benchmark data file. */
  static async open() {
    let bench: Bench;
    try {
      const benchFile = await fs.readFile(BenchData.#PATH, {
        encoding: "utf8",
      });
      bench = JSON.parse(benchFile);
    } catch {
      bench = {};
    }

    return new BenchData(bench);
  }

  /** Save the benchmark data file. */
  async save() {
    await fs.writeFile(BenchData.#PATH, JSON.stringify(this.#data, null, 2));
  }

  /** Get the stored results based on version. */
  get(version: Version) {
    return this.#data[version];
  }

  /** Get all versions. */
  getVersions() {
    return Object.keys(this.#data) as Version[];
  }

  /** Compare benchmark changes. */
  compare<K extends keyof BenchResult>({
    newResult,
    oldResult,
    changeCb,
    noChangeCb,
    treshold = 0,
  }: {
    /** New bench result */
    newResult: BenchResult[K];
    /** Old bench result */
    oldResult: BenchResult[K];
    /** Callback to run when there is a change(considering `threshold`) */
    changeCb: (args: {
      name: string;
      newValue: number | null;
      oldValue: number | null;
    }) => void;
    /** Callback to run when there is no change(considering `threshold`) */
    noChangeCb?: (args: { name: string; value: number }) => void;
    /** Change threshold percentage(maximum allowed difference between results) */
    treshold?: number;
  }) {
    let needsUpdate = false;
    const executeChangeCb = (...args: Parameters<typeof changeCb>) => {
      changeCb(...args);
      needsUpdate = true;
    };

    const compare = (
      compareFrom: BenchResult[K],
      compareTo: BenchResult[K],
      cb: (name: string, value: number) => void
    ) => {
      for (const name in compareFrom) {
        if (compareTo[name] === undefined) {
          cb(name, compareFrom[name]);
        }
      }
    };

    // New key
    compare(newResult, oldResult, (name, value) => {
      console.log(`New key '${name}'`);
      executeChangeCb({
        name,
        newValue: value,
        oldValue: null,
      });
    });

    // Deleted key
    compare(oldResult, newResult, (name, value) => {
      console.log(`Deleted key '${name}'`);
      executeChangeCb({
        name,
        newValue: null,
        oldValue: value,
      });
    });

    // Compare compute units changes
    for (const name in newResult) {
      const oldValue = oldResult[name];
      const newValue = newResult[name];

      const percentage = treshold / 100;
      const oldMaximumAllowedDelta = oldValue * percentage;
      const newMaximumAllowedDelta = newValue * percentage;

      const delta = newValue - oldValue;
      const absDelta = Math.abs(delta);

      if (
        absDelta > oldMaximumAllowedDelta ||
        absDelta > newMaximumAllowedDelta
      ) {
        // Throw in CI
        if (process.env.CI) {
          throw new Error(
            [
              `Key '${name}' has changed more than ${treshold}% but is not saved.`,
              "Run `anchor test --skip-lint` in tests/bench and commit the changes.",
            ].join(" ")
          );
        }

        console.log(`'${name}' (${oldValue} -> ${newValue})`);

        executeChangeCb({
          name,
          newValue,
          oldValue,
        });
      } else {
        noChangeCb?.({ name, value: newValue });
      }
    }

    return { needsUpdate };
  }

  /** Compare and update benchmark changes. */
  async update(result: Partial<BenchResult>) {
    const resultType = Object.keys(result)[0] as keyof typeof result;
    const newResult = result[resultType]!;

    // Compare and update benchmark changes
    const version = getVersionFromArgs();
    const oldResult = this.get(version).result[resultType];
    const { needsUpdate } = this.compare({
      newResult,
      oldResult,
      changeCb: ({ name, newValue }) => {
        if (newValue === null) delete oldResult[name];
        else oldResult[name] = newValue;
      },
      treshold: THRESHOLD_PERCENTAGE,
    });

    if (needsUpdate) {
      console.log("Updating benchmark files...");

      // Save bench data file
      // (needs to happen before running the `sync-markdown` script)
      await this.save();

      // Only update markdown files on `unreleased` version
      if (version === "unreleased") {
        spawn("anchor", ["run", "sync-markdown"]);
      }
    }
  }

  /** Bump benchmark data version to the given version. */
  bumpVersion(newVersion: string) {
    if (this.#data[newVersion]) {
      throw new Error(`Version '${newVersion}' already exists!`);
    }

    const versions = this.getVersions();
    const unreleasedVersion = versions[versions.length - 1];

    // Add the new version
    this.#data[newVersion] = this.get(unreleasedVersion);

    // Delete the unreleased version
    delete this.#data[unreleasedVersion];

    // Add the new unreleased version
    this.#data[unreleasedVersion] = this.#data[newVersion];
  }

  /**
   * Loop through all of the markdown files and run the given callback before
   * saving the file.
   */
  static async forEachMarkdown(
    cb: (markdown: Markdown, fileName: string) => void
  ) {
    const fileNames = await fs.readdir(BENCH_DIR_PATH);
    const markdownFileNames = fileNames.filter((n) => n.endsWith(".md"));

    for (const fileName of markdownFileNames) {
      const markdown = await Markdown.open(path.join(BENCH_DIR_PATH, fileName));
      cb(markdown, fileName);
      await markdown.save();
    }

    // Format
    spawn("yarn", [
      "run",
      "prettier",
      "--write",
      path.join(BENCH_DIR_PATH, "*.md"),
    ]);
  }
}

/** Utility class to handle markdown related operations */
export class Markdown {
  /** Unreleased version string */
  static #UNRELEASED_VERSION = "[Unreleased]";

  /** Markdown filepath */
  #path: string;

  /** Markdown text */
  #text: string;

  constructor(path: string, text: string) {
    this.#path = path;
    this.#text = text;
  }

  /** Open the markdown file. */
  static async open(path: string) {
    const text = await fs.readFile(path, { encoding: "utf8" });
    return new Markdown(path, text);
  }

  /** Create a markdown table. */
  static createTable(...args: string[]) {
    return new MarkdownTable([args]);
  }

  /** Save the markdown file. */
  async save() {
    await fs.writeFile(this.#path, this.#text);
  }

  /** Change the version's content with the given `solanaVersion` and `table`. */
  updateVersion(params: {
    version: Version;
    solanaVersion: string;
    table: MarkdownTable;
  }) {
    const md = this.#text;

    const title = `[${params.version}]`;
    let titleStartIndex = md.indexOf(title);
    if (titleStartIndex === -1) {
      titleStartIndex = md.indexOf(Markdown.#UNRELEASED_VERSION);
    }

    const titleContentStartIndex = titleStartIndex + title.length + 1;

    const tableStartIndex =
      titleStartIndex + md.slice(titleStartIndex).indexOf("|");
    const tableRowStartIndex =
      tableStartIndex + md.slice(tableStartIndex).indexOf("\n");
    const tableEndIndex =
      tableStartIndex + md.slice(tableStartIndex).indexOf("\n\n");

    this.#text =
      md.slice(0, titleContentStartIndex) +
      `Solana version: ${params.solanaVersion}\n\n` +
      md.slice(tableStartIndex, tableRowStartIndex - 1) +
      params.table.toString() +
      md.slice(tableEndIndex + 1);
  }

  /** Bump the version to the given version. */
  bumpVersion(newVersion: string) {
    newVersion = `[${newVersion}]`;
    if (this.#text.includes(newVersion)) {
      throw new Error(`Version '${newVersion}' already exists!`);
    }

    const startIndex = this.#text.indexOf(`## ${Markdown.#UNRELEASED_VERSION}`);
    const endIndex =
      startIndex + this.#text.slice(startIndex).indexOf("\n---") + 4;
    let unreleasedSection = this.#text.slice(startIndex, endIndex);

    // Update unreleased version to `newVersion`
    const newSection = unreleasedSection.replace(
      Markdown.#UNRELEASED_VERSION,
      newVersion
    );

    // Reset unreleased version changes
    unreleasedSection = unreleasedSection
      .split("\n")
      .map((line, i) => {
        // First 4 lines don't change
        if ([0, 1, 2, 3].includes(i)) return line;

        const regex = /\|.*\|.*\|(.*)\|/;
        const result = regex.exec(line);

        const changeStr = result?.[1];
        if (!changeStr) {
          if (line.startsWith("#")) return line;
          else if (line.startsWith("---")) return line + "\n";
          else return "";
        }

        return line.replace(changeStr, "-");
      })
      .join("\n");

    // Update the text
    this.#text =
      this.#text.slice(0, startIndex) +
      unreleasedSection +
      newSection +
      this.#text.slice(endIndex);
  }
}

/** Utility class to handle markdown table related operations */
class MarkdownTable {
  /** Markdown rows stored as array of arrays */
  #rows: string[][];

  constructor(rows: string[][]) {
    this.#rows = rows;
    this.insert("-", "-", "-");
  }

  /** Insert a new row to the markdown table. */
  insert(...args: string[]) {
    this.#rows.push(args);
  }

  /** Convert the stored rows to a markdown table. */
  toString() {
    return this.#rows.reduce(
      (acc, row) =>
        acc + row.reduce((acc, cur) => `${acc} ${cur} |`, "|") + "\n",
      ""
    );
  }
}

/** Utility class to handle TOML related operations */
export class Toml {
  /** TOML filepath */
  #path: string;

  /** TOML text */
  #text: string;

  constructor(path: string, text: string) {
    this.#path = path;
    this.#text = text;
  }

  /** Open the TOML file. */
  static async open(tomlPath: string) {
    tomlPath = path.join(__dirname, tomlPath);
    const text = await fs.readFile(tomlPath, {
      encoding: "utf8",
    });
    return new Toml(tomlPath, text);
  }

  /** Save the TOML file. */
  async save() {
    await fs.writeFile(this.#path, this.#text);
  }

  /** Replace the value for the given key. */
  replaceValue(
    key: string,
    cb: (previous: string) => string,
    opts?: { insideQuotes: boolean }
  ) {
    this.#text = this.#text.replace(
      new RegExp(`${key}\\s*=\\s*${opts?.insideQuotes ? `"(.*)"` : "(.*)"}`),
      (line, value) => line.replace(value, cb(value))
    );
  }
}

/** Utility class to handle Cargo.lock file related operations */
export class LockFile {
  /** Cargo lock file name */
  static #CARGO_LOCK = "Cargo.lock";

  /** Replace the Cargo.lock with the given version's cached lock file. */
  static async replace(version: Version) {
    // Remove Cargo.lock
    try {
      await fs.rm(this.#CARGO_LOCK);
    } catch {}

    // `unreleased` version shouldn't have a cached lock file
    if (version !== "unreleased") {
      const lockFile = await fs.readFile(this.#getLockPath(version));
      await fs.writeFile(this.#CARGO_LOCK, lockFile);
    }
  }

  /** Cache the current Cargo.lock in `./locks`. */
  static async cache(version: Version) {
    try {
      await fs.rename(this.#CARGO_LOCK, this.#getLockPath(version));
    } catch {
      // Lock file doesn't exist
      // Run the tests to create the lock file
      const result = runAnchorTest();

      // Check failure
      if (result.status !== 0) {
        throw new Error(`Failed to create ${this.#CARGO_LOCK}`);
      }

      await this.cache(version);
    }
  }

  /** Get the lock file path from the given version. */
  static #getLockPath(version: Version) {
    return path.join("locks", `${version}.lock`);
  }
}

/** Utility class to manage versions */
export class VersionManager {
  /** Set the active Solana version with `solana-install init` command. */
  static setSolanaVersion(version: Version) {
    const activeVersion = this.#getSolanaVersion();
    if (activeVersion === version) return;

    spawn("solana-install", ["init", version], {
      logOutput: true,
      throwOnError: { msg: `Failed to set Solana version to ${version}` },
    });
  }

  /** Get the active Solana version. */
  static #getSolanaVersion() {
    // `solana-cli 1.14.16 (src:0fb2ffda; feat:3488713414)\n`
    const result = execSync("solana --version");
    const output = Buffer.from(result.buffer).toString();
    const solanaVersion = /(\d\.\d{1,3}\.\d{1,3})/.exec(output)![1].trim();
    return solanaVersion as Version;
  }
}

/**
 * Get Anchor version from the passed arguments.
 *
 * Defaults to `unreleased`.
 */
export const getVersionFromArgs = () => {
  const args = process.argv;
  const anchorVersionArgIndex = args.indexOf(ANCHOR_VERSION_ARG);
  return anchorVersionArgIndex === -1
    ? "unreleased"
    : (args[anchorVersionArgIndex + 1] as Version);
};

/** Spawn a blocking process. */
export const spawn = (
  cmd: string,
  args: string[],
  opts?: { logOutput?: boolean; throwOnError?: { msg: string } }
) => {
  const result = spawnSync(cmd, args);
  if (opts?.logOutput) {
    console.log(result.output.toString());
  }

  if (opts?.throwOnError && result.status !== 0) {
    throw new Error(opts.throwOnError.msg);
  }

  return result;
};

/** Run `anchor test` command. */
export const runAnchorTest = () => spawn("anchor", ["test", "--skip-lint"]);

/** Format number with `en-US` locale. */
export const formatNumber = (number: number) => number.toLocaleString("en-US");
