// Copyright 2018-2026 the Deno authors. MIT license.

import { core, primordials } from "ext:core/mod.js";

// TODO(mmastrac): We cannot import these from "ext:core/ops" yet
const {
  op_test_event_snapshot_summary,
  op_test_snapshot_in_update_mode,
  op_test_snapshot_read,
  op_test_snapshot_write,
} = core.ops;
const {
  ArrayPrototypeIncludes,
  ArrayPrototypeJoin,
  ArrayPrototypePush,
  Error,
  Map,
  MapPrototypeGet,
  MapPrototypeHas,
  MapPrototypeSet,
  SafeArrayIterator,
  StringPrototypeIncludes,
  StringPrototypeIndexOf,
  StringPrototypeReplaceAll,
  StringPrototypeSlice,
  StringPrototypeSplit,
  TypeError,
} = primordials;

// Capture `Deno.inspect` and `Deno.noColor` early so that user code mangling
// the `Deno` global doesn't affect snapshot serialization.
const DenoNs = globalThis.Deno;
const inspect = DenoNs.inspect;
const noColor = DenoNs.noColor;

class AssertionError extends Error {
  name = "AssertionError";
}

/**
 * Run state shared with `40_test.js`. Stale snapshots are only removed from
 * snapshot files when every test in the module had a chance to run all of
 * its snapshot assertions; an ignored test, an `only` run or a failed test
 * means some assertions may not have executed, so removal is skipped.
 */
export const snapshotRunState = {
  sawIgnored: false,
  sawOnly: false,
  sawFailure: false,
};

/**
 * Default snapshot serializer, mirroring `@std/testing/snapshot` so that
 * snapshot files generated by the std library keep working unchanged.
 */
function serialize(actual) {
  return StringPrototypeReplaceAll(
    inspect(actual, {
      depth: Infinity,
      sorted: true,
      trailingComma: true,
      compact: false,
      iterableLimit: Infinity,
      strAbbreviateSize: Infinity,
      breakLength: Infinity,
      escapeSequences: false,
    }),
    "\r",
    "\\r",
  );
}

/**
 * Escapes a string so it can be embedded in a template literal in the
 * generated snapshot file. Mirrors `@std/testing/snapshot`.
 */
function escapeStringForJs(str) {
  str = StringPrototypeReplaceAll(str, "\\", "\\\\");
  str = StringPrototypeReplaceAll(str, "`", "\\`");
  str = StringPrototypeReplaceAll(str, "$", "\\$");
  return str;
}

/**
 * Reads a backtick-delimited string starting at `pos` (which must point right
 * after the opening backtick). Only the escape sequences produced by
 * `escapeStringForJs` are understood. Returns `{ value, end }` or `null`.
 */
function readBacktickString(content, pos) {
  let value = "";
  while (pos < content.length) {
    const c = content[pos];
    if (c === "\\") {
      if (pos + 1 >= content.length) {
        return null;
      }
      value += content[pos + 1];
      pos += 2;
      continue;
    }
    if (c === "`") {
      return { value, end: pos + 1 };
    }
    value += c;
    pos++;
  }
  return null;
}

/**
 * Parses a snapshot file. Only the exact format produced by the snapshot
 * writer (and by `@std/testing/snapshot`) is supported:
 *
 * ```js
 * export const snapshot = {};
 *
 * snapshot[`name`] = `value`;
 * ```
 *
 * Returns `{ names, values }` where `names` preserves file order.
 */
function parseSnapshotFileContent(content, filePath) {
  const names = [];
  const values = new Map();
  const corrupt = () => {
    return new AssertionError(
      `Corrupt snapshot file (only snapshot files generated by the test runner or @std/testing/snapshot are supported):\n\t${filePath}`,
    );
  };
  let pos = 0;
  while (true) {
    const start = StringPrototypeIndexOf(content, "snapshot[`", pos);
    if (start === -1) {
      break;
    }
    const name = readBacktickString(content, start + 10);
    if (name === null) {
      throw corrupt();
    }
    pos = name.end;
    if (StringPrototypeSlice(content, pos, pos + 4) !== "] = ") {
      throw corrupt();
    }
    if (content[pos + 4] !== "`") {
      throw corrupt();
    }
    const value = readBacktickString(content, pos + 5);
    if (value === null) {
      throw corrupt();
    }
    pos = value.end;
    // Multi-line snapshot values are written with a leading and trailing
    // newline for readability; strip them back off when reading.
    const text = StringPrototypeIncludes(value.value, "\n")
      ? StringPrototypeSlice(value.value, 1, -1)
      : value.value;
    if (!MapPrototypeHas(values, name.value)) {
      ArrayPrototypePush(names, name.value);
    }
    MapPrototypeSet(values, name.value, text);
  }
  return { names, values };
}

function red(str) {
  return noColor ? str : `\x1b[31m${str}\x1b[39m`;
}

function green(str) {
  return noColor ? str : `\x1b[32m${str}\x1b[39m`;
}

/**
 * Builds a simple line diff (longest common subsequence based) between the
 * actual and expected snapshot texts. Lines only present in `actual` are
 * prefixed with `+`, lines only present in `expected` with `-`.
 */
function buildDiffLines(actual, expected) {
  const a = StringPrototypeSplit(actual, "\n");
  const b = StringPrototypeSplit(expected, "\n");
  const out = [];
  if (a.length * b.length > 4_000_000) {
    // Too large for an LCS table; fall back to showing both versions.
    for (const line of new SafeArrayIterator(a)) {
      ArrayPrototypePush(out, green(`+   ${line}`));
    }
    for (const line of new SafeArrayIterator(b)) {
      ArrayPrototypePush(out, red(`-   ${line}`));
    }
    return out;
  }
  // dp[i][j] = length of the LCS of a[i..] and b[j..]
  const width = b.length + 1;
  const dp = new Uint32Array((a.length + 1) * width);
  for (let i = a.length - 1; i >= 0; i--) {
    for (let j = b.length - 1; j >= 0; j--) {
      dp[i * width + j] = a[i] === b[j]
        ? dp[(i + 1) * width + j + 1] + 1
        : (dp[(i + 1) * width + j] >= dp[i * width + j + 1]
          ? dp[(i + 1) * width + j]
          : dp[i * width + j + 1]);
    }
  }
  let i = 0;
  let j = 0;
  while (i < a.length && j < b.length) {
    if (a[i] === b[j]) {
      ArrayPrototypePush(out, `    ${a[i]}`);
      i++;
      j++;
    } else if (dp[(i + 1) * width + j] >= dp[i * width + j + 1]) {
      ArrayPrototypePush(out, green(`+   ${a[i]}`));
      i++;
    } else {
      ArrayPrototypePush(out, red(`-   ${b[j]}`));
      j++;
    }
  }
  for (; i < a.length; i++) {
    ArrayPrototypePush(out, green(`+   ${a[i]}`));
  }
  for (; j < b.length; j++) {
    ArrayPrototypePush(out, red(`-   ${b[j]}`));
  }
  return out;
}

function getSnapshotNotMatchMessage(actual, expected) {
  const diff = ArrayPrototypeJoin(buildDiffLines(actual, expected), "\n");
  return `Snapshot does not match:\n\n    ${green("[Diff]")} ${
    green("Actual")
  } / ${
    red("Expected")
  }\n\n${diff}\n\nTo update snapshots, run\n    deno test --update-snapshots [files]...\n`;
}

let isUpdateMode = undefined;

function getIsUpdateMode() {
  if (isUpdateMode === undefined) {
    isUpdateMode = op_test_snapshot_in_update_mode();
  }
  return isUpdateMode;
}

/**
 * Per snapshot file state. Contexts are keyed by the resolved snapshot file
 * path so that different option combinations pointing at the same file share
 * one context (mirroring `@std/testing/snapshot`).
 *
 * @typedef {{
 *   locationArgs: { dir: string | undefined, path: string | undefined },
 *   filePath: string,
 *   fileExists: boolean,
 *   currentNames: string[],
 *   currentValues: Map<string, string>,
 *   counts: Map<string, number>,
 *   updateQueue: string[],
 *   updatedValues: Map<string, string>,
 *   updatedNames: string[],
 * }} SnapshotContext
 */

/** @type {Map<string, SnapshotContext>} */
const snapshotContexts = new Map();
/** @type {SnapshotContext[]} */
const snapshotContextsList = [];

function getSnapshotContext(options) {
  const locationArgs = { dir: options.dir, path: options.path };
  const { path: filePath, content } = op_test_snapshot_read(locationArgs);
  let context = MapPrototypeGet(snapshotContexts, filePath);
  if (context !== undefined) {
    return context;
  }
  let parsed = { names: [], values: new Map() };
  if (content !== null) {
    parsed = parseSnapshotFileContent(content, filePath);
  }
  context = {
    locationArgs,
    filePath,
    fileExists: content !== null,
    currentNames: parsed.names,
    currentValues: parsed.values,
    counts: new Map(),
    updateQueue: [],
    updatedValues: new Map(),
    updatedNames: [],
  };
  MapPrototypeSet(snapshotContexts, filePath, context);
  ArrayPrototypePush(snapshotContextsList, context);
  return context;
}

function getFullTestName(tContext) {
  if (tContext.parent !== undefined) {
    return `${getFullTestName(tContext.parent)} > ${tContext.name}`;
  }
  return tContext.name;
}

function getErrorMessage(message, options) {
  return typeof options.msg === "string" ? options.msg : message;
}

/**
 * Implementation of `Deno.TestContext.assertSnapshot()`. `tContext` is the
 * test context object the method was called on.
 */
export function assertSnapshot(
  tContext,
  actual,
  options = { __proto__: null },
) {
  if (typeof options === "string") {
    options = { __proto__: null, msg: options };
  } else if (typeof options !== "object" || options === null) {
    throw new TypeError(
      "Expected the second argument to assertSnapshot() to be an options object or a message string",
    );
  }

  const context = getSnapshotContext(options);
  const testName = options.name ?? getFullTestName(tContext);
  const count = (MapPrototypeGet(context.counts, testName) ?? 0) + 1;
  MapPrototypeSet(context.counts, testName, count);
  const name = `${testName} ${count}`;

  if (!ArrayPrototypeIncludes(context.updateQueue, name)) {
    ArrayPrototypePush(context.updateQueue, name);
  }

  const serializer = options.serializer ?? serialize;
  const actualSnapshot = serializer(actual);
  if (typeof actualSnapshot !== "string") {
    throw new TypeError("Snapshot serializer must return a string");
  }

  const expectedSnapshot = MapPrototypeGet(context.currentValues, name);

  if (getIsUpdateMode()) {
    if (actualSnapshot !== expectedSnapshot) {
      MapPrototypeSet(context.updatedValues, name, actualSnapshot);
      if (!ArrayPrototypeIncludes(context.updatedNames, name)) {
        ArrayPrototypePush(context.updatedNames, name);
      }
    }
    return;
  }

  if (!context.fileExists) {
    throw new AssertionError(
      getErrorMessage("Missing snapshot file.", options),
    );
  }
  if (expectedSnapshot === undefined) {
    throw new AssertionError(
      getErrorMessage(`Missing snapshot: ${name}`, options),
    );
  }
  if (actualSnapshot === expectedSnapshot) {
    return;
  }
  throw new AssertionError(
    getErrorMessage(
      getSnapshotNotMatchMessage(actualSnapshot, expectedSnapshot),
      options,
    ),
  );
}

function buildSnapshotFileContent(names, getValue) {
  const buf = ["export const snapshot = {};"];
  for (const name of new SafeArrayIterator(names)) {
    const value = getValue(name);
    if (value === undefined) {
      continue;
    }
    let formatted = escapeStringForJs(value);
    formatted = StringPrototypeIncludes(formatted, "\n")
      ? `\n${formatted}\n`
      : formatted;
    ArrayPrototypePush(
      buf,
      `\nsnapshot[\`${escapeStringForJs(name)}\`] = \`${formatted}\`;`,
    );
  }
  return ArrayPrototypeJoin(buf, "\n") + "\n";
}

/**
 * Called by the test runner (from Rust) after all tests in the module have
 * finished. In update mode, writes pending snapshot updates to disk, removes
 * stale snapshots when safe to do so, and reports a summary to the test
 * reporter. No-op when not running with `--update-snapshots`.
 */
function flushTestSnapshots(allowStaleRemoval) {
  if (!getIsUpdateMode()) {
    return;
  }
  allowStaleRemoval = allowStaleRemoval &&
    !snapshotRunState.sawIgnored &&
    !snapshotRunState.sawOnly &&
    !snapshotRunState.sawFailure;

  let updated = 0;
  const removed = [];
  for (const context of new SafeArrayIterator(snapshotContextsList)) {
    let names;
    const removedNames = [];
    if (allowStaleRemoval) {
      // Snapshots are written in assertion order; everything in the file
      // that was not asserted this run is stale and gets dropped.
      names = context.updateQueue;
      for (const name of new SafeArrayIterator(context.currentNames)) {
        if (!ArrayPrototypeIncludes(context.updateQueue, name)) {
          ArrayPrototypePush(removedNames, name);
        }
      }
    } else {
      // Keep existing entries (in file order) and append new ones.
      names = [...new SafeArrayIterator(context.currentNames)];
      for (const name of new SafeArrayIterator(context.updateQueue)) {
        if (!ArrayPrototypeIncludes(names, name)) {
          ArrayPrototypePush(names, name);
        }
      }
    }
    if (context.updatedNames.length === 0 && removedNames.length === 0) {
      continue;
    }
    const content = buildSnapshotFileContent(
      names,
      (name) =>
        MapPrototypeGet(context.updatedValues, name) ??
          MapPrototypeGet(context.currentValues, name),
    );
    op_test_snapshot_write(context.locationArgs, content);
    updated += context.updatedNames.length;
    for (const name of new SafeArrayIterator(removedNames)) {
      ArrayPrototypePush(removed, name);
    }
  }

  if (updated > 0 || removed.length > 0) {
    op_test_event_snapshot_summary(updated, removed);
  }
}

globalThis.Deno[globalThis.Deno.internal].flushTestSnapshots =
  flushTestSnapshots;
