/**
 * Regression testing framework for individual ZkProgram examples.
 *
 * Stores and compares metadata such as compile, proving, and verifying times.
 * Can run in two modes:
 * - **Dump**: write baseline results into a per-backend JSON file
 *   (e.g. `perf-regression-wasm.json` or `perf-regression-native.json`)
 * - **Check**: validate current results against the stored baselines
 *
 * For regression testing of constraint systems (CS) and zkApps,
 * see {@link tests/perf-regression/perf-regression.ts}.
 *
 * @note
 * Command-line arguments:
 * - `--dump` (alias `-d`): dump performance data into the baseline file.
 * - `--check` (alias `-c`): check performance against the existing baseline.
 * - `--file` (alias `-f`): specify a custom JSON path (default: `./tests/perf-regression/perf-regression-wasm.json`).
 * - `--silent`: suppress all console output.
 *
 * These flags are mutually exclusive for modes (`--dump` and `--check` cannot be used together).
 * When neither is provided, the script runs in log-only mode.
 */

import fs from 'fs';
import minimist from 'minimist';
import path from 'path';
import { ConstraintSystemSummary } from '../provable/core/provable-context.js';

export { PerfRegressionEntry, Performance, logPerf };

type MethodsInfo = Record<
  string,
  {
    rows: number;
    digest: string;
    proveTime?: number;
    verifyTime?: number;
  }
>;

type PerfRegressionEntry = {
  digest?: string;
  compileTime?: number;
  methods: MethodsInfo;
};

type PerfStack = {
  start: number;
  label?: 'compile' | 'prove' | 'verify' | string;
  programName?: string;
  methodsSummary?: Record<string, ConstraintSystemSummary>;
  methodName?: string; // required for prove/verify; optional for compile
};

// per-program tolerance overrides (multiplier, e.g. 1.20 = 20%)
// use for programs whose timings are known-flaky on top of run-to-run noise
const PROGRAM_TOLERANCES: Record<
  string,
  Partial<Record<'compile' | 'prove' | 'verify', number>>
> = {
  // sha256 compile timing fluctuates ~10-15% on the new toolchain
  sha256: { compile: 1.2 },
  'sha256.sha256': { compile: 1.2 },
};

const argv = minimist(process.argv.slice(2), {
  boolean: ['dump', 'check', 'silent'],
  string: ['file'],
  alias: {
    f: 'file',
    d: 'dump',
    c: 'check',
  },
});

const DUMP = Boolean(argv.dump);
const CHECK = Boolean(argv.check);
const SILENT = Boolean(argv.silent);

// Cannot use both dump and check
if (DUMP && CHECK) {
  console.error('Error: You cannot use both --dump and --check at the same time!');
  process.exit(1);
}

const FILE_PATH = path.isAbsolute(argv.file ?? '')
  ? (argv.file as string)
  : path.join(
      process.cwd(),
      argv.file ? (argv.file as string) : './tests/perf-regression/perf-regression-wasm.json'
    );

// Create directory & file if missing (only on dump)
if (DUMP) {
  const dir = path.dirname(FILE_PATH);
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
  if (!fs.existsSync(FILE_PATH)) fs.writeFileSync(FILE_PATH, '{}', 'utf8');
}

/**
 * Create a new performance tracking session for a program.
 *
 * @param programName Name of the program (key in the baseline JSON)
 * @param methodsSummary Optional methods analysis (required for prove/verify checks)
 * @param log Optional boolean (default: true). If `--silent` is passed via CLI,
 *            it overrides this and disables all logs.
 * @returns An object with `start()` and `end()` methods
 */
function createPerformanceSession(
  programName?: string,
  methodsSummary?: Record<string, ConstraintSystemSummary>,
  log = true
) {
  const perfStack: PerfStack[] = [];
  const shouldLog = SILENT ? false : log;

  return {
    /**
     * Start measuring performance for a given phase.
     *
     * @param label The phase label: `'compile' | 'prove' | 'verify' | string`
     * @param methodName Method name (required for `prove` and `verify`)
     */
    start(label?: 'compile' | 'prove' | 'verify' | string, methodName?: string) {
      perfStack.push({
        label,
        start: performance.now(),
        programName,
        methodsSummary,
        methodName,
      });
    },

    /**
     * End the most recent measurement and:
     * - Logs results to the console by default. This can be disabled by setting `log` to `false`
     *   when creating the session, or by passing the `--silent` flag when running the file.
     * - Dump into baseline JSON (if `--dump`)
     * - Check against baseline (if `--check`)
     */
    end() {
      const frame = perfStack.pop()!;
      const { label, start, programName } = frame;
      let { methodsSummary: cs, methodName } = frame;

      const time = (performance.now() - start) / 1000;

      // Base logging — only if log is enabled
      //              — shows contract.method for prove/verify
      if (shouldLog && label) {
        console.log(
          `${label} ${programName ?? ''}${
            (label === 'prove' || label === 'verify') && methodName ? '.' + methodName : ''
          }... ${time.toFixed(3)} sec`
        );
      }

      // If neither --dump nor --check, we’re done.
      if (!DUMP && !CHECK) return;

      // Only act for compile/prove/verify with required context
      if (!programName || (label !== 'compile' && label !== 'prove' && label !== 'verify')) return;

      // Load the baseline JSON used for both DUMP and CHECK modes.
      const raw = fs.readFileSync(FILE_PATH, 'utf8');
      const perfRegressionJson: Record<string, PerfRegressionEntry> = JSON.parse(raw);

      // --- compile ---
      if (label === 'compile') {
        if (DUMP) {
          dumpCompile(perfRegressionJson, programName, time);
          return;
        }
        if (CHECK) {
          checkCompile(perfRegressionJson, programName, time);
          return;
        }
      }

      // --- prove / verify (shared validation + separate actions) ---
      if (label === 'prove' || label === 'verify') {
        const info = validateMethodContext(label, cs, methodName, programName);

        if (DUMP) {
          if (label === 'prove') {
            dumpMethodTime(perfRegressionJson, programName, methodName!, info, time, 'prove');
          } else {
            dumpMethodTime(perfRegressionJson, programName, methodName!, info, time, 'verify');
          }
          return;
        }

        if (CHECK) {
          if (label === 'prove') {
            checkMethodTime(
              perfRegressionJson,
              programName,
              methodName!,
              info.digest,
              time,
              'prove'
            );
          } else {
            checkMethodTime(
              perfRegressionJson,
              programName,
              methodName!,
              info.digest,
              time,
              'verify'
            );
          }
          return;
        }
      }
    },
  };
}

const Performance = {
  /**
   * Initialize a new performance session.
   *
   * @param programName Optional identifier for the program or label.
   *   - With a ZkProgram name and its `methodsSummary`, the session benchmarks
   *     compile, prove, and verify phases, storing or checking results against
   *     the per-backend baseline JSON.
   *   - Without a ZkProgram, `programName` acts as a freeform label and the session
   *     can be used like `console.time` / `console.timeEnd` to log timestamps.
   * @param methodsSummary Optional analysis of ZkProgram methods, required when
   *   measuring prove/verify performance.
   * @param log Optional boolean flag (default: `true`).
   *   - When set to `false`, disables all console output for both general labels
   *     and compile/prove/verify phase logs.
   *   - When the `--silent` flag is provided, it overrides this setting and disables
   *     all logging regardless of the `log` value.
   */
  create(
    programName?: string,
    methodsSummary?: Record<string, ConstraintSystemSummary>,
    log?: boolean
  ) {
    return createPerformanceSession(programName, methodsSummary, log);
  },
};

/// HELPERS (dump/check)

function dumpCompile(
  perfRegressionJson: Record<string, PerfRegressionEntry>,
  programName: string,
  time: number
) {
  const prev = perfRegressionJson[programName];
  const merged: PerfRegressionEntry = prev
    ? { ...prev, compileTime: time }
    : { compileTime: time, methods: {} };

  perfRegressionJson[programName] = merged;
  fs.writeFileSync(FILE_PATH, JSON.stringify(perfRegressionJson, null, 2));
}

function dumpMethodTime(
  perfRegressionJson: Record<string, PerfRegressionEntry>,
  programName: string,
  methodName: string,
  info: ConstraintSystemSummary,
  time: number,
  label: 'prove' | 'verify'
) {
  const prev = perfRegressionJson[programName];
  const merged: PerfRegressionEntry = prev
    ? { ...prev, methods: { ...prev.methods } }
    : { methods: {} };

  const prevMethod = merged.methods[methodName] ?? {};

  merged.methods[methodName] = {
    rows: info.rows,
    digest: info.digest,
    proveTime: label === 'prove' ? time : prevMethod.proveTime,
    verifyTime: label === 'verify' ? time : prevMethod.verifyTime,
  };

  perfRegressionJson[programName] = merged;
  fs.writeFileSync(FILE_PATH, JSON.stringify(perfRegressionJson, null, 2));
}

function checkCompile(
  perfRegressionJson: Record<string, PerfRegressionEntry>,
  programName: string,
  actualTime: number
) {
  checkAgainstBaseline({
    perfRegressionJson,
    programName,
    label: 'compile',
    methodName: '',
    digest: '',
    actualTime,
  });
}

function checkMethodTime(
  perfRegressionJson: Record<string, PerfRegressionEntry>,
  programName: string,
  methodName: string,
  digest: string,
  actualTime: number,
  label: 'prove' | 'verify'
) {
  checkAgainstBaseline({
    perfRegressionJson,
    programName,
    label,
    methodName,
    digest,
    actualTime,
  });
}

// -------------------------
// HELPERS (validation + baselines)
// -------------------------

function validateMethodContext(
  label: string,
  cs: Record<string, ConstraintSystemSummary> | undefined,
  methodName: string | undefined,
  programName?: string
): ConstraintSystemSummary {
  if (!cs || typeof cs !== 'object') {
    throw new Error(
      `methodsSummary is required for this label: ${label}. Pass it to Performance.create(programName, methodsSummary).`
    );
  }
  if (!methodName) {
    throw new Error(`Please provide the method name (start(${label}, methodName)).`);
  }
  const info = cs[methodName];
  if (!info) {
    const available = Object.keys(cs);
    throw new Error(
      `The method "${methodName}" does not exist in the analyzed constraint system for "${programName}". ` +
        `Available: ${available.length ? available.join(', ') : '(none)'}`
    );
  }
  return info;
}

/**
 * Compare a measured time/digest against stored baselines.
 * Throws an error if regression exceeds tolerance.
 */
function checkAgainstBaseline(params: {
  perfRegressionJson: Record<string, PerfRegressionEntry>;
  programName: string;
  label: 'compile' | 'prove' | 'verify';
  methodName: string;
  digest: string;
  actualTime: number;
}) {
  const { perfRegressionJson, programName, label, methodName, digest, actualTime } = params;

  const baseline = perfRegressionJson[programName];
  if (!baseline) {
    throw new Error(`No baseline for "${programName}". Seed it with --dump first.`);
  }

  // tolerances
  const compileTol = 1.1; // 10%
  const compileTiny = 1.12; // 12% for near-zero baselines (< 5e-5s)
  const timeTolDefault = 1.12; // 12% for prove/verify
  const timeTolSmall = 1.27; // 27% for very small times (<0.2s)

  // per-program tolerance override (PROGRAM_TOLERANCES) for known-flaky programs
  const override = PROGRAM_TOLERANCES[programName]?.[label];

  const labelPretty = label[0].toUpperCase() + label.slice(1);

  if (label === 'compile') {
    const expected = baseline.compileTime;
    if (expected == null) {
      throw new Error(
        `No baseline compileTime for "${programName}". Run --dump (compile) to set it.`
      );
    }

    const tol = override ?? (expected < 5e-5 ? compileTiny : compileTol);
    const allowedPct = (tol - 1) * 100;
    const regressionPct = expected === 0 ? 0 : ((actualTime - expected) / expected) * 100;
    const failed = actualTime > expected * tol;

    // colorized perf log
    logPerf(programName, label, expected, actualTime, regressionPct, allowedPct, failed);

    if (failed) {
      throw new Error(
        `Compile regression for ${programName}\n` +
          `  Actual:     ${actualTime.toFixed(6)}s\n` +
          `  Baseline:   ${expected.toFixed(6)}s\n` +
          `  Regression: +${Number.isFinite(regressionPct) ? regressionPct.toFixed(2) : '∞'}% (allowed +${allowedPct.toFixed(0)}%)`
      );
    }
    return;
  }

  // prove/verify checks
  const baseMethod = baseline.methods?.[methodName];
  if (!baseMethod) {
    throw new Error(
      `No baseline method entry for ${programName}.${methodName}. Run --dump (${label}) to add it.`
    );
  }

  if (baseMethod.digest !== digest) {
    throw new Error(
      `Digest mismatch for ${programName}.${methodName}\n` +
        `  Actual:   ${digest}\n` +
        `  Expected: ${baseMethod.digest}\n`
    );
  }

  const expected = label === 'prove' ? baseMethod.proveTime : baseMethod.verifyTime;
  if (expected == null) {
    throw new Error(
      `No baseline ${label}Time for ${programName}.${methodName}. Run --dump (${label}) to set it.`
    );
  }

  const tol = override ?? (expected < 0.2 ? timeTolSmall : timeTolDefault);
  const allowedPct = (tol - 1) * 100;
  const regressionPct = expected === 0 ? 0 : ((actualTime - expected) / expected) * 100;
  const failed = actualTime > expected * tol;

  logPerf(
    `${programName}.${methodName}`,
    label,
    expected,
    actualTime,
    regressionPct,
    allowedPct,
    failed
  );

  if (failed) {
    throw new Error(
      `${labelPretty} regression for ${programName}.${methodName}\n` +
        `  Actual:     ${actualTime.toFixed(3)}s\n` +
        `  Baseline:   ${expected.toFixed(3)}s\n` +
        `  Regression: +${Number.isFinite(regressionPct) ? regressionPct.toFixed(2) : '∞'}% (allowed +${allowedPct.toFixed(0)}%)`
    );
  }
}

function logPerf(
  scope: string,
  label: string,
  expected: number,
  actual: number,
  regressionPct: number,
  allowedPct: number,
  failed: boolean
) {
  const COLORS = {
    reset: '\x1b[0m',
    red: '\x1b[31m',
    green: '\x1b[32m',
    yellow: '\x1b[33m',
    cyan: '\x1b[36m',
  };

  let color: string;
  if (failed) color = COLORS.red;
  else if (regressionPct > 0) color = COLORS.yellow;
  else color = COLORS.green;

  console.log(
    `${COLORS.cyan}[Perf][${scope}]${COLORS.reset} ${label}: ` +
      `baseline=${expected.toFixed(6)}s, actual=${actual.toFixed(6)}s, ` +
      `${color}regression=${regressionPct >= 0 ? '+' : ''}${
        Number.isFinite(regressionPct) ? regressionPct.toFixed(2) : '∞'
      }%${COLORS.reset} ` +
      `(allowed +${allowedPct.toFixed(0)}%)`
  );
}
