/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";
/* exported Cr, CC, Match, Census, Task, DevToolsUtils, HeapAnalysesClient,
  assertThrows, getFilePath, saveHeapSnapshotAndTakeCensus,
  saveHeapSnapshotAndComputeDominatorTree, compareCensusViewData, assertDiff,
  assertLabelAndShallowSize, makeTestDominatorTreeNode,
  assertDominatorTreeNodeInsertion, assertDeduplicatedPaths,
  assertCountToBucketBreakdown, pathEntry */

var CC = Components.Constructor;

const { require } = ChromeUtils.importESModule(
  "resource://devtools/shared/loader/Loader.sys.mjs"
);
const { Match } = ChromeUtils.importESModule("resource://test/Match.sys.mjs");
const { Census } = ChromeUtils.importESModule("resource://test/Census.sys.mjs");
const { addDebuggerToGlobal } = ChromeUtils.importESModule(
  "resource://gre/modules/jsdebugger.sys.mjs"
);

const DevToolsUtils = require("resource://devtools/shared/DevToolsUtils.js");
const HeapAnalysesClient = require("resource://devtools/shared/heapsnapshot/HeapAnalysesClient.js");
const {
  censusReportToCensusTreeNode,
} = require("resource://devtools/shared/heapsnapshot/census-tree-node.js");
const CensusUtils = require("resource://devtools/shared/heapsnapshot/CensusUtils.js");
const DominatorTreeNode = require("resource://devtools/shared/heapsnapshot/DominatorTreeNode.js");
const {
  deduplicatePaths,
} = require("resource://devtools/shared/heapsnapshot/shortest-paths.js");
const { LabelAndShallowSizeVisitor } = DominatorTreeNode;

// Always log packets when running tests. runxpcshelltests.py will throw
// the output away anyway, unless you give it the --verbose flag.
if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_DEFAULT) {
  Services.prefs.setBoolPref("devtools.debugger.log", true);
  Services.prefs.setBoolPref("security.allow_eval_with_system_principal", true);
  registerCleanupFunction(() => {
    Services.prefs.clearUserPref("devtools.debugger.log");
    Services.prefs.clearUserPref("security.allow_eval_with_system_principal");
  });
}

const SYSTEM_PRINCIPAL = Cc["@mozilla.org/systemprincipal;1"].createInstance(
  Ci.nsIPrincipal
);

function dumpn(msg) {
  dump("HEAPSNAPSHOT-TEST: " + msg + "\n");
}

function addTestingFunctionsToGlobal(global) {
  global.eval(
    `
    const testingFunctions = Components.utils.getJSTestingFunctions();
    for (let k in testingFunctions) {
      this[k] = testingFunctions[k];
    }
    `
  );
  if (!global.print) {
    global.print = info;
  }
  if (!global.newGlobal) {
    global.newGlobal = newGlobal;
  }
  if (!global.Debugger) {
    addDebuggerToGlobal(global);
  }
}

addTestingFunctionsToGlobal(this);

/**
 * Create a new global, with all the JS shell testing functions. Similar to the
 * newGlobal function exposed to JS shells, and useful for porting JS shell
 * tests to xpcshell tests.
 */
function newGlobal() {
  const global = new Cu.Sandbox(SYSTEM_PRINCIPAL, { freshZone: true });
  addTestingFunctionsToGlobal(global);
  return global;
}

function assertThrows(f, val, msg) {
  let fullmsg;
  try {
    f();
  } catch (exc) {
    if (exc === val && (val !== 0 || 1 / exc === 1 / val)) {
      return;
    } else if (exc instanceof Error && exc.message === val) {
      return;
    }
    fullmsg = "Assertion failed: expected exception " + val + ", got " + exc;
  }
  if (fullmsg === undefined) {
    fullmsg =
      "Assertion failed: expected exception " + val + ", no exception thrown";
  }
  if (msg !== undefined) {
    fullmsg += " - " + msg;
  }
  throw new Error(fullmsg);
}

/**
 * Returns the full path of the file with the specified name in a
 * platform-independent and URL-like form.
 */
function getFilePath(
  name,
  allowMissing = false,
  usePlatformPathSeparator = false
) {
  const file = do_get_file(name, allowMissing);
  let path = Services.io.newFileURI(file).spec;
  let filePrePath = "file://";
  if ("nsILocalFileWin" in Ci && file instanceof Ci.nsILocalFileWin) {
    filePrePath += "/";
  }

  path = path.slice(filePrePath.length);

  if (usePlatformPathSeparator && path.match(/^\w:/)) {
    path = path.replace(/\//g, "\\");
  }

  return path;
}

function saveNewHeapSnapshot(opts = { runtime: true }) {
  const filePath = ChromeUtils.saveHeapSnapshot(opts);
  ok(filePath, "Should get a file path to save the core dump to.");
  ok(true, "Saved a heap snapshot to " + filePath);
  return filePath;
}

function readHeapSnapshot(filePath) {
  const snapshot = ChromeUtils.readHeapSnapshot(filePath);
  ok(snapshot, "Should have read a heap snapshot back from " + filePath);
  ok(
    HeapSnapshot.isInstance(snapshot),
    "snapshot should be an instance of HeapSnapshot"
  );
  return snapshot;
}

/**
 * Save a heap snapshot to the file with the given name in the current
 * directory, read it back as a HeapSnapshot instance, and then take a census of
 * the heap snapshot's serialized heap graph with the provided census options.
 *
 * @param {object | undefined} censusOptions
 *        Options that should be passed through to the takeCensus method. See
 *        js/src/doc/Debugger/Debugger.Memory.md for details.
 *
 * @param {Debugger|null} dbg
 *        If a Debugger object is given, only serialize the subgraph covered by
 *        the Debugger's debuggees. If null, serialize the whole heap graph.
 *
 * @param {string} fileName
 *        The file name to save the heap snapshot's core dump file to, within
 *        the current directory.
 *
 * @returns Census
 */
function saveHeapSnapshotAndTakeCensus(dbg = null, censusOptions = undefined) {
  const snapshotOptions = dbg ? { debugger: dbg } : { runtime: true };
  const filePath = saveNewHeapSnapshot(snapshotOptions);
  const snapshot = readHeapSnapshot(filePath);

  equal(
    typeof snapshot.takeCensus,
    "function",
    "snapshot should have a takeCensus method"
  );

  return snapshot.takeCensus(censusOptions);
}

/**
 * Save a heap snapshot to disk, read it back as a HeapSnapshot instance, and
 * then compute its dominator tree.
 *
 * @param {Debugger|null} dbg
 *        If a Debugger object is given, only serialize the subgraph covered by
 *        the Debugger's debuggees. If null, serialize the whole heap graph.
 *
 * @returns {DominatorTree}
 */
function saveHeapSnapshotAndComputeDominatorTree(dbg = null) {
  const snapshotOptions = dbg ? { debugger: dbg } : { runtime: true };
  const filePath = saveNewHeapSnapshot(snapshotOptions);
  const snapshot = readHeapSnapshot(filePath);

  equal(
    typeof snapshot.computeDominatorTree,
    "function",
    "snapshot should have a `computeDominatorTree` method"
  );

  const dominatorTree = snapshot.computeDominatorTree();

  ok(dominatorTree, "Should be able to compute a dominator tree");
  ok(
    DominatorTree.isInstance(dominatorTree),
    "Should be an instance of DominatorTree"
  );

  return dominatorTree;
}

function isSavedFrame(obj) {
  return Object.prototype.toString.call(obj) === "[object SavedFrame]";
}

function savedFrameReplacer(key, val) {
  if (isSavedFrame(val)) {
    return `<SavedFrame '${val.toString().split(/\n/g).shift()}'>`;
  }
  return val;
}

/**
 * Assert that creating a CensusTreeNode from the given `report` with the
 * specified `breakdown` creates the given `expected` CensusTreeNode.
 *
 * @param {object} breakdown
 *        The census breakdown.
 *
 * @param {object} report
 *        The census report.
 *
 * @param {object} expected
 *        The expected CensusTreeNode result.
 *
 * @param {object} options
 *        The options to pass through to `censusReportToCensusTreeNode`.
 */
function compareCensusViewData(breakdown, report, expected, options) {
  dumpn("Generating CensusTreeNode from report:");
  dumpn("breakdown: " + JSON.stringify(breakdown, null, 4));
  dumpn("report: " + JSON.stringify(report, null, 4));
  dumpn("expected: " + JSON.stringify(expected, savedFrameReplacer, 4));

  const actual = censusReportToCensusTreeNode(breakdown, report, options);
  dumpn("actual: " + JSON.stringify(actual, savedFrameReplacer, 4));

  assertStructurallyEquivalent(actual, expected);
}

// Deep structural equivalence that can handle Map objects in addition to plain
// objects.
function assertStructurallyEquivalent(actual, expected, path = "root") {
  if (actual === expected) {
    equal(actual, expected, "actual and expected are the same");
    return;
  }

  equal(typeof actual, typeof expected, `${path}: typeof should be the same`);

  if (actual && typeof actual === "object") {
    const actualProtoString = Object.prototype.toString.call(actual);
    const expectedProtoString = Object.prototype.toString.call(expected);
    equal(
      actualProtoString,
      expectedProtoString,
      `${path}: Object.prototype.toString.call() should be the same`
    );

    if (actualProtoString === "[object Map]") {
      const expectedKeys = new Set([...expected.keys()]);

      for (const key of actual.keys()) {
        ok(
          expectedKeys.has(key),
          `${path}: every key in actual is expected: ${String(key).slice(
            0,
            10
          )}`
        );
        expectedKeys.delete(key);

        assertStructurallyEquivalent(
          actual.get(key),
          expected.get(key),
          path + ".get(" + String(key).slice(0, 20) + ")"
        );
      }

      equal(
        expectedKeys.size,
        0,
        `${path}: every key in expected should also exist in actual,\
        did not see ${[...expectedKeys]}`
      );
    } else if (actualProtoString === "[object Set]") {
      const expectedItems = new Set([...expected]);

      for (const item of actual) {
        ok(
          expectedItems.has(item),
          `${path}: every set item in actual should exist in expected: ${item}`
        );
        expectedItems.delete(item);
      }

      equal(
        expectedItems.size,
        0,
        `${path}: every set item in expected should also exist in actual,\
        did not see ${[...expectedItems]}`
      );
    } else {
      const expectedKeys = new Set(Object.keys(expected));

      for (const key of Object.keys(actual)) {
        ok(
          expectedKeys.has(key),
          `${path}: every key in actual should exist in expected: ${key}`
        );
        expectedKeys.delete(key);

        assertStructurallyEquivalent(
          actual[key],
          expected[key],
          path + "." + key
        );
      }

      equal(
        expectedKeys.size,
        0,
        `${path}: every key in expected should also exist in actual,\
        did not see ${[...expectedKeys]}`
      );
    }
  } else {
    equal(actual, expected, `${path}: primitives should be equal`);
  }
}

/**
 * Assert that creating a diff of the `first` and `second` census reports
 * creates the `expected` delta-report.
 *
 * @param {object} breakdown
 *        The census breakdown.
 *
 * @param {object} first
 *        The first census report.
 *
 * @param {object} second
 *        The second census report.
 *
 * @param {object} expected
 *        The expected delta-report.
 */
function assertDiff(breakdown, first, second, expected) {
  dumpn("Diffing census reports:");
  dumpn("Breakdown: " + JSON.stringify(breakdown, null, 4));
  dumpn("First census report: " + JSON.stringify(first, null, 4));
  dumpn("Second census report: " + JSON.stringify(second, null, 4));
  dumpn("Expected delta-report: " + JSON.stringify(expected, null, 4));

  const actual = CensusUtils.diff(breakdown, first, second);
  dumpn("Actual delta-report: " + JSON.stringify(actual, null, 4));

  assertStructurallyEquivalent(actual, expected);
}

/**
 * Assert that creating a label and getting a shallow size from the given node
 * description with the specified breakdown is as expected.
 *
 * @param {object} breakdown
 * @param {object} givenDescription
 * @param {number} expectedShallowSize
 * @param {object} expectedLabel
 */
function assertLabelAndShallowSize(
  breakdown,
  givenDescription,
  expectedShallowSize,
  expectedLabel
) {
  dumpn("Computing label and shallow size from node description:");
  dumpn("Breakdown: " + JSON.stringify(breakdown, null, 4));
  dumpn("Given description: " + JSON.stringify(givenDescription, null, 4));

  const visitor = new LabelAndShallowSizeVisitor();
  CensusUtils.walk(breakdown, givenDescription, visitor);

  dumpn("Expected shallow size: " + expectedShallowSize);
  dumpn("Actual shallow size: " + visitor.shallowSize());
  equal(
    visitor.shallowSize(),
    expectedShallowSize,
    "Shallow size should be correct"
  );

  dumpn("Expected label: " + JSON.stringify(expectedLabel, null, 4));
  dumpn("Actual label: " + JSON.stringify(visitor.label(), null, 4));
  assertStructurallyEquivalent(visitor.label(), expectedLabel);
}

// Counter for mock DominatorTreeNode ids.
let TEST_NODE_ID_COUNTER = 0;

/**
 * Create a mock DominatorTreeNode for testing, with sane defaults. Override any
 * property by providing it on `opts`. Optionally pass child nodes as well.
 *
 * @param {object} opts
 * @param {Array<DominatorTreeNode>?} children
 *
 * @returns {DominatorTreeNode}
 */
function makeTestDominatorTreeNode(opts, children) {
  const nodeId = TEST_NODE_ID_COUNTER++;

  const node = Object.assign(
    {
      nodeId,
      label: undefined,
      shallowSize: 1,
      retainedSize: (children || []).reduce(
        (size, c) => size + c.retainedSize,
        1
      ),
      parentId: undefined,
      children,
      moreChildrenAvailable: true,
    },
    opts
  );

  if (children && children.length) {
    children.map(c => (c.parentId = node.nodeId));
  }

  return node;
}

/**
 * Insert `newChildren` into the given dominator `tree` as specified by the
 * `path` from the root to the node the `newChildren` should be inserted
 * beneath. Assert that the resulting tree matches `expected`.
 */
function assertDominatorTreeNodeInsertion(
  tree,
  path,
  newChildren,
  moreChildrenAvailable,
  expected
) {
  dumpn("Inserting new children into a dominator tree:");
  dumpn("Dominator tree: " + JSON.stringify(tree, null, 2));
  dumpn("Path: " + JSON.stringify(path, null, 2));
  dumpn("New children: " + JSON.stringify(newChildren, null, 2));
  dumpn("Expected resulting tree: " + JSON.stringify(expected, null, 2));

  const actual = DominatorTreeNode.insert(
    tree,
    path,
    newChildren,
    moreChildrenAvailable
  );
  dumpn("Actual resulting tree: " + JSON.stringify(actual, null, 2));

  assertStructurallyEquivalent(actual, expected);
}

function assertDeduplicatedPaths({
  target,
  paths,
  expectedNodes,
  expectedEdges,
}) {
  dumpn("Deduplicating paths:");
  dumpn("target = " + target);
  dumpn("paths = " + JSON.stringify(paths, null, 2));
  dumpn("expectedNodes = " + expectedNodes);
  dumpn("expectedEdges = " + JSON.stringify(expectedEdges, null, 2));

  const { nodes, edges } = deduplicatePaths(target, paths);

  dumpn("Actual nodes = " + nodes);
  dumpn("Actual edges = " + JSON.stringify(edges, null, 2));

  equal(
    nodes.length,
    expectedNodes.length,
    "actual number of nodes is equal to the expected number of nodes"
  );

  equal(
    edges.length,
    expectedEdges.length,
    "actual number of edges is equal to the expected number of edges"
  );

  const expectedNodeSet = new Set(expectedNodes);
  const nodeSet = new Set(nodes);
  Assert.strictEqual(
    nodeSet.size,
    nodes.length,
    "each returned node should be unique"
  );

  for (const node of nodes) {
    ok(expectedNodeSet.has(node), `the ${node} node was expected`);
  }

  for (const expectedEdge of expectedEdges) {
    let count = 0;
    for (const edge of edges) {
      if (
        edge.from === expectedEdge.from &&
        edge.to === expectedEdge.to &&
        edge.name === expectedEdge.name
      ) {
        count++;
      }
    }
    equal(
      count,
      1,
      "should have exactly one matching edge for the expected edge = " +
        JSON.stringify(expectedEdge)
    );
  }
}

function assertCountToBucketBreakdown(breakdown, expected) {
  dumpn("count => bucket breakdown");
  dumpn("Initial breakdown = ", JSON.stringify(breakdown, null, 2));
  dumpn("Expected results = ", JSON.stringify(expected, null, 2));

  const actual = CensusUtils.countToBucketBreakdown(breakdown);
  dumpn("Actual results = ", JSON.stringify(actual, null, 2));

  assertStructurallyEquivalent(actual, expected);
}

/**
 * Create a mock path entry for the given predecessor and edge.
 */
function pathEntry(predecessor, edge) {
  return { predecessor, edge };
}
