/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

/* globals Assert */
/* globals info */

/**
 * This file contains utilities that can be shared between xpcshell tests and mochitests.
 */

// The marker phases.
const INSTANT = 0;
const INTERVAL = 1;
const INTERVAL_START = 2;
const INTERVAL_END = 3;

// This Services declaration may shadow another from head.js, so define it as
// a var rather than a const.

const defaultSettings = {
  entries: 8 * 1024 * 1024, // 8M entries = 64MB
  interval: 1, // ms
  features: [],
  threads: ["GeckoMain"],
};

// Effectively `async`: Start the profiler and return the `startProfiler`
// promise that will get resolved when all child process have started their own
// profiler.
async function startProfiler(callersSettings) {
  if (Services.profiler.IsActive()) {
    Assert.ok(
      Services.env.exists("MOZ_PROFILER_STARTUP"),
      "The profiler is active at the begining of the test, " +
        "the MOZ_PROFILER_STARTUP environment variable should be set."
    );
    if (Services.env.exists("MOZ_PROFILER_STARTUP")) {
      // If the startup profiling environment variable exists, it is likely
      // that tests are being profiled.
      // Stop the profiler before starting profiler tests.
      info(
        "This test starts and stops the profiler and is not compatible " +
          "with the use of MOZ_PROFILER_STARTUP. " +
          "Stopping the profiler before starting the test."
      );
      await Services.profiler.StopProfiler();
    } else {
      throw new Error(
        "The profiler must not be active before starting it in a test."
      );
    }
  }
  const settings = Object.assign({}, defaultSettings, callersSettings);
  return Services.profiler.StartProfiler(
    settings.entries,
    settings.interval,
    settings.features,
    settings.threads,
    0,
    settings.duration
  );
}

function startProfilerForMarkerTests() {
  return startProfiler({
    features: ["nostacksampling", "js"],
    threads: ["GeckoMain", "DOM Worker"],
  });
}

/**
 * This is a helper function be able to run `await wait(500)`. Unfortunately
 * this is needed as the act of collecting functions relies on the periodic
 * sampling of the threads. See:
 * https://bugzilla.mozilla.org/show_bug.cgi?id=1529053
 *
 * @param {number} time
 * @returns {Promise}
 */
function wait(time) {
  return new Promise(resolve => {
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    setTimeout(resolve, time);
  });
}

/**
 * Get the payloads of a type recursively, including from all subprocesses.
 *
 * @param {Object} profile The gecko profile.
 * @param {string} type The marker payload type, e.g. "DiskIO".
 * @param {Array} payloadTarget The recursive list of payloads.
 * @return {Array} The final payloads.
 */
function getPayloadsOfTypeFromAllThreads(profile, type, payloadTarget = []) {
  for (const { markers } of profile.threads) {
    for (const markerTuple of markers.data) {
      const payload = markerTuple[markers.schema.data];
      if (payload && payload.type === type) {
        payloadTarget.push(payload);
      }
    }
  }

  for (const subProcess of profile.processes) {
    getPayloadsOfTypeFromAllThreads(subProcess, type, payloadTarget);
  }

  return payloadTarget;
}

/**
 * Get the payloads of a type from a single thread.
 *
 * @param {Object} thread The thread from a profile.
 * @param {string} type The marker payload type, e.g. "DiskIO".
 * @return {Array} The payloads.
 */
function getPayloadsOfType(thread, type) {
  const { markers } = thread;
  const results = [];
  for (const markerTuple of markers.data) {
    const payload = markerTuple[markers.schema.data];
    if (payload && payload.type === type) {
      results.push(payload);
    }
  }
  return results;
}

/**
 * Applies the marker schema to create individual objects for each marker
 *
 * @param {Object} thread The thread from a profile.
 * @return {InflatedMarker[]} The markers.
 */
function getInflatedMarkerData(thread) {
  const { markers, stringTable } = thread;
  return markers.data.map(markerTuple => {
    const marker = {};
    for (const [key, tupleIndex] of Object.entries(markers.schema)) {
      marker[key] = markerTuple[tupleIndex];
      if (key === "name") {
        // Use the string from the string table.
        marker[key] = stringTable[marker[key]];
      }
    }
    return marker;
  });
}

/**
 * Applies the marker schema to create individual objects for each marker, then
 * keeps only the network markers that match the profiler tests.
 *
 * @param {Object} thread The thread from a profile.
 * @return {InflatedMarker[]} The filtered network markers.
 */
function getInflatedNetworkMarkers(thread) {
  const markers = getInflatedMarkerData(thread);
  return markers.filter(
    m =>
      m.data &&
      m.data.type === "Network" &&
      // We filter out network markers that aren't related to the test, to
      // avoid intermittents.
      m.data.URI.includes("/tools/profiler/")
  );
}

/**
 * From a list of network markers, this returns pairs of start/stop markers.
 * If a stop marker can't be found for a start marker, this will return an array
 * of only 1 element.
 *
 * @param {InflatedMarker[]} networkMarkers Network markers
 * @return {InflatedMarker[][]} Pairs of network markers
 */
function getPairsOfNetworkMarkers(allNetworkMarkers) {
  // For each 'start' marker we want to find the next 'stop' or 'redirect'
  // marker with the same id.
  const result = [];
  const mapOfStartMarkers = new Map(); // marker id -> id in result array
  for (const marker of allNetworkMarkers) {
    const { data } = marker;
    if (data.status === "STATUS_START") {
      if (mapOfStartMarkers.has(data.id)) {
        const previousMarker = result[mapOfStartMarkers.get(data.id)][0];
        Assert.ok(
          false,
          `We found 2 start markers with the same id ${data.id}, without end marker in-between.` +
            `The first marker has URI ${previousMarker.data.URI}, the second marker has URI ${data.URI}.` +
            ` This should not happen.`
        );
        continue;
      }

      mapOfStartMarkers.set(data.id, result.length);
      result.push([marker]);
    } else {
      // STOP or REDIRECT
      if (!mapOfStartMarkers.has(data.id)) {
        Assert.ok(
          false,
          `We found an end marker without a start marker (id: ${data.id}, URI: ${data.URI}). This should not happen.`
        );
        continue;
      }
      result[mapOfStartMarkers.get(data.id)].push(marker);
      mapOfStartMarkers.delete(data.id);
    }
  }

  return result;
}

/**
 * It can be helpful to force the profiler to collect a JavaScript sample. This
 * function spins on a while loop until at least one more sample is collected.
 *
 * @return {number} The index of the collected sample.
 */
function captureAtLeastOneJsSample() {
  function getProfileSampleCount() {
    const profile = Services.profiler.getProfileData();
    return profile.threads[0].samples.data.length;
  }

  const sampleCount = getProfileSampleCount();
  // Create an infinite loop until a sample has been collected.
  while (true) {
    if (sampleCount < getProfileSampleCount()) {
      return sampleCount;
    }
  }
}

function isJSONWhitespace(c) {
  return ["\n", "\r", " ", "\t"].includes(c);
}

function verifyJSONStringIsCompact(s) {
  const stateData = 0;
  const stateString = 1;
  const stateEscapedChar = 2;
  let state = stateData;
  for (let i = 0; i < s.length; ++i) {
    let c = s[i];
    switch (state) {
      case stateData:
        if (isJSONWhitespace(c)) {
          Assert.ok(
            false,
            `"Unexpected JSON whitespace at index ${i} in profile: <<<${s}>>>"`
          );
          return;
        }
        if (c == '"') {
          state = stateString;
        }
        break;
      case stateString:
        if (c == '"') {
          state = stateData;
        } else if (c == "\\") {
          state = stateEscapedChar;
        }
        break;
      case stateEscapedChar:
        state = stateString;
        break;
    }
  }
}

/**
 * This function pauses the profiler before getting the profile. Then after
 * getting the data, the profiler is stopped, and all profiler data is removed.
 * @returns {Promise<Profile>}
 */
async function stopNowAndGetProfile() {
  // Don't await the pause, because each process will handle it before it
  // receives the following `getProfileDataAsArrayBuffer()`.
  Services.profiler.Pause();

  const profileArrayBuffer =
    await Services.profiler.getProfileDataAsArrayBuffer();
  await Services.profiler.StopProfiler();

  const profileUint8Array = new Uint8Array(profileArrayBuffer);
  const textDecoder = new TextDecoder("utf-8", { fatal: true });
  const profileString = textDecoder.decode(profileUint8Array);
  verifyJSONStringIsCompact(profileString);

  return JSON.parse(profileString);
}

/**
 * This function ensures there's at least one sample, then pauses the profiler
 * before getting the profile. Then after getting the data, the profiler is
 * stopped, and all profiler data is removed.
 * @returns {Promise<Profile>}
 */
async function waitSamplingAndStopAndGetProfile() {
  await Services.profiler.waitOnePeriodicSampling();
  return stopNowAndGetProfile();
}

/**
 * Verifies that a marker is an interval marker.
 *
 * @param {InflatedMarker} marker
 * @returns {boolean}
 */
function isIntervalMarker(inflatedMarker) {
  return (
    inflatedMarker.phase === 1 &&
    typeof inflatedMarker.startTime === "number" &&
    typeof inflatedMarker.endTime === "number"
  );
}

/**
 * @param {Profile} profile
 * @returns {Thread[]}
 */
function getThreads(profile) {
  const threads = [];

  function getThreadsRecursive(process) {
    for (const thread of process.threads) {
      threads.push(thread);
    }
    for (const subprocess of process.processes) {
      getThreadsRecursive(subprocess);
    }
  }

  getThreadsRecursive(profile);
  return threads;
}

/**
 * Find a specific marker schema from any process of a profile.
 *
 * @param {Profile} profile
 * @param {string} name
 * @returns {MarkerSchema}
 */
function getSchema(profile, name) {
  {
    const schema = profile.meta.markerSchema.find(s => s.name === name);
    if (schema) {
      return schema;
    }
  }
  for (const subprocess of profile.processes) {
    const schema = subprocess.meta.markerSchema.find(s => s.name === name);
    if (schema) {
      return schema;
    }
  }
  console.error("Parent process schema", profile.meta.markerSchema);
  for (const subprocess of profile.processes) {
    console.error("Child process schema", subprocess.meta.markerSchema);
  }
  throw new Error(`Could not find a schema for "${name}".`);
}

/**
 * This escapes all characters that have a special meaning in RegExps.
 * This was stolen from https://github.com/sindresorhus/escape-string-regexp and
 * so it is licence MIT and:
 * Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com).
 * See the full license in https://raw.githubusercontent.com/sindresorhus/escape-string-regexp/main/license.
 * @param {string} string The string to be escaped
 * @returns {string} The result
 */
function escapeStringRegexp(string) {
  if (typeof string !== "string") {
    throw new TypeError("Expected a string");
  }

  // Escape characters with special meaning either inside or outside character
  // sets.  Use a simple backslash escape when it’s always valid, and a `\xnn`
  // escape when the simpler form would be disallowed by Unicode patterns’
  // stricter grammar.
  return string.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&").replace(/-/g, "\\x2d");
}

/** ------ Assertions helper ------ */
/**
 * This assert helper function makes it easy to check a lot of properties in an
 * object. We augment Assert.sys.mjs to make it easier to use.
 */
Object.assign(Assert, {
  /*
   * It checks if the properties on the right are all present in the object on
   * the left. Note that the object might still have other properties (see
   * objectContainsOnly below if you want the stricter form).
   *
   * The basic form does basic equality on each expected property:
   *
   * Assert.objectContains(fixture, {
   *   foo: "foo",
   *   bar: 1,
   *   baz: true,
   * });
   *
   * But it also has a more powerful form with expectations. The available
   * expectations are:
   * - any(): this only checks for the existence of the property, not its value
   * - number(), string(), boolean(), bigint(), function(), symbol(), object():
   *   this checks if the value is of this type
   * - objectContains(expected): this applies Assert.objectContains()
   *   recursively on this property.
   * - stringContains(needle): this checks if the expected value is included in
   *   the property value.
   * - stringMatches(regexp): this checks if the property value matches this
   *   regexp. The regexp can be passed as a string, to be dynamically built.
   *
   * example:
   *
   * Assert.objectContains(fixture, {
   *   name: Expect.stringMatches(`Load \\d+:.*${url}`),
   *   data: Expect.objectContains({
   *     status: "STATUS_STOP",
   *     URI: Expect.stringContains("https://"),
   *     requestMethod: "GET",
   *     contentType: Expect.string(),
   *     startTime: Expect.number(),
   *     cached: Expect.boolean(),
   *   }),
   * });
   *
   * Each expectation will translate into one or more Assert call. Therefore if
   * one expectation fails, this will be clearly visible in the test output.
   *
   * Expectations can also be normal functions, for example:
   *
   * Assert.objectContains(fixture, {
   *   number: value => Assert.greater(value, 5)
   * });
   *
   * Note that you'll need to use Assert inside this function.
   */
  objectContains(object, expectedProperties) {
    // Basic tests: we don't want to run other assertions if these tests fail.
    if (typeof object !== "object") {
      this.ok(
        false,
        `The first parameter should be an object, but found: ${object}.`
      );
      return;
    }

    if (typeof expectedProperties !== "object") {
      this.ok(
        false,
        `The second parameter should be an object, but found: ${expectedProperties}.`
      );
      return;
    }

    for (const key of Object.keys(expectedProperties)) {
      const expected = expectedProperties[key];
      if (!(key in object)) {
        this.report(
          true,
          object,
          expectedProperties,
          `The object should contain the property "${key}", but it's missing.`
        );
        continue;
      }

      if (typeof expected === "function") {
        // This is a function, so let's call it.
        expected(
          object[key],
          `The object should contain the property "${key}" with an expected value and type.`
        );
      } else {
        // Otherwise, we check for equality.
        this.equal(
          object[key],
          expectedProperties[key],
          `The object should contain the property "${key}" with an expected value.`
        );
      }
    }
  },

  /**
   * This is very similar to the previous `objectContains`, but this also looks
   * at the number of the objects' properties. Thus this will fail if the
   * objects don't have the same properties exactly.
   */
  objectContainsOnly(object, expectedProperties) {
    // Basic tests: we don't want to run other assertions if these tests fail.
    if (typeof object !== "object") {
      this.ok(
        false,
        `The first parameter should be an object but found: ${object}.`
      );
      return;
    }

    if (typeof expectedProperties !== "object") {
      this.ok(
        false,
        `The second parameter should be an object but found: ${expectedProperties}.`
      );
      return;
    }

    // In objectContainsOnly, we specifically want to check if all properties
    // from the fixture object are expected.
    // We'll be failing a test only for the specific properties that weren't
    // expected, and only fail with one message, so that the test outputs aren't
    // spammed.
    const extraProperties = [];
    for (const fixtureKey of Object.keys(object)) {
      if (!(fixtureKey in expectedProperties)) {
        extraProperties.push(fixtureKey);
      }
    }

    if (extraProperties.length) {
      // Some extra properties have been found.
      this.report(
        true,
        object,
        expectedProperties,
        `These properties are present, but shouldn't: "${extraProperties.join(
          '", "'
        )}".`
      );
    }

    // Now, let's carry on the rest of our work.
    this.objectContains(object, expectedProperties);
  },
});

const Expect = {
  any:
    () =>
    actual => {} /* We don't check anything more than the presence of this property. */,
};

/* These functions are part of the Assert object, and we want to reuse them. */
[
  "stringContains",
  "stringMatches",
  "objectContains",
  "objectContainsOnly",
].forEach(
  assertChecker =>
    (Expect[assertChecker] =
      expected =>
      (actual, ...moreArgs) =>
        Assert[assertChecker](actual, expected, ...moreArgs))
);

/* These functions will only check for the type. */
[
  "number",
  "string",
  "boolean",
  "bigint",
  "symbol",
  "object",
  "function",
].forEach(type => (Expect[type] = makeTypeChecker(type)));

function makeTypeChecker(type) {
  return (...unexpectedArgs) => {
    if (unexpectedArgs.length) {
      throw new Error(
        "Type checkers expectations aren't expecting any argument."
      );
    }
    return (actual, message) => {
      const isCorrect = typeof actual === type;
      Assert.report(!isCorrect, actual, type, message, "has type");
    };
  };
}
/* ------ End of assertion helper ------ */
