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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  PromiseUtils: "resource://gre/modules/PromiseUtils.sys.mjs",
  QuickSuggest: "resource:///modules/QuickSuggest.sys.mjs",
  TelemetryTestUtils: "resource://testing-common/TelemetryTestUtils.sys.mjs",
  UrlbarPrefs: "resource:///modules/UrlbarPrefs.sys.mjs",
});

const { HttpServer } = ChromeUtils.import("resource://testing-common/httpd.js");

// The following properties and methods are copied from the test scope to the
// test utils object so they can be easily accessed. Be careful about assuming a
// particular property will be defined because depending on the scope -- browser
// test or xpcshell test -- some may not be.
const TEST_SCOPE_PROPERTIES = [
  "Assert",
  "EventUtils",
  "info",
  "registerCleanupFunction",
];

const SEARCH_PARAMS = {
  CLIENT_VARIANTS: "client_variants",
  PROVIDERS: "providers",
  QUERY: "q",
  SEQUENCE_NUMBER: "seq",
  SESSION_ID: "sid",
};

const REQUIRED_SEARCH_PARAMS = [
  SEARCH_PARAMS.QUERY,
  SEARCH_PARAMS.SEQUENCE_NUMBER,
  SEARCH_PARAMS.SESSION_ID,
];

// We set the client timeout to a large value to avoid intermittent failures in
// CI, especially TV tests, where the Merino fetch unexpectedly doesn't finish
// before the default timeout.
const CLIENT_TIMEOUT_MS = 2000;

const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE";

// Maps from string labels of the `FX_URLBAR_MERINO_RESPONSE` histogram to their
// numeric values.
const RESPONSE_HISTOGRAM_VALUES = {
  success: 0,
  timeout: 1,
  network_error: 2,
  http_error: 3,
  no_suggestion: 4,
};

const WEATHER_KEYWORD = "weather";

const WEATHER_RS_DATA = {
  keywords: [WEATHER_KEYWORD],
};

const WEATHER_SUGGESTION = {
  title: "Weather for San Francisco",
  url: "https://example.com/weather",
  provider: "accuweather",
  is_sponsored: false,
  score: 0.2,
  icon: null,
  city_name: "San Francisco",
  current_conditions: {
    url: "https://example.com/weather-current-conditions",
    summary: "Mostly cloudy",
    icon_id: 6,
    temperature: { c: 15.5, f: 60.0 },
  },
  forecast: {
    url: "https://example.com/weather-forecast",
    summary: "Pleasant Saturday",
    high: { c: 21.1, f: 70.0 },
    low: { c: 13.9, f: 57.0 },
  },
};

// We set the weather suggestion fetch interval to an absurdly large value so it
// absolutely will not fire during tests.
const WEATHER_FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours

/**
 * Test utils for Merino.
 */
class _MerinoTestUtils {
  /**
   * Initializes the utils.
   *
   * @param {object} scope
   *   The global JS scope where tests are being run. This allows the instance
   *   to access test helpers like `Assert` that are available in the scope.
   */
  init(scope) {
    if (!scope) {
      throw new Error("MerinoTestUtils.init() must be called with a scope");
    }

    this.#initDepth++;
    scope.info?.("MerinoTestUtils init: Depth is now " + this.#initDepth);

    for (let p of TEST_SCOPE_PROPERTIES) {
      this[p] = scope[p];
    }
    // If you add other properties to `this`, null them in `uninit()`.

    if (!this.#server) {
      this.#server = new MockMerinoServer(scope);
    }
    lazy.UrlbarPrefs.set("merino.timeoutMs", CLIENT_TIMEOUT_MS);
    scope.registerCleanupFunction?.(() => {
      scope.info?.("MerinoTestUtils cleanup function");
      this.uninit();
    });
  }

  /**
   * Uninitializes the utils. If they were created with a test scope that
   * defines `registerCleanupFunction()`, you don't need to call this yourself
   * because it will automatically be called as a cleanup function. Otherwise
   * you'll need to call this.
   */
  uninit() {
    this.#initDepth--;
    this.info?.("MerinoTestUtils uninit: Depth is now " + this.#initDepth);

    if (this.#initDepth) {
      this.info?.("MerinoTestUtils uninit: Bailing because depth > 0");
      return;
    }
    this.info?.("MerinoTestUtils uninit: Now uninitializing");

    for (let p of TEST_SCOPE_PROPERTIES) {
      this[p] = null;
    }
    this.#server.uninit();
    this.#server = null;
    lazy.UrlbarPrefs.clear("merino.timeoutMs");
  }

  /**
   * @returns {object}
   *   The names of URL search params.
   */
  get SEARCH_PARAMS() {
    return SEARCH_PARAMS;
  }

  /**
   * @returns {string}
   *   The weather keyword in `WEATHER_RS_DATA`. Can be used as a search string
   *   to match the weather suggestion.
   */
  get WEATHER_KEYWORD() {
    return WEATHER_KEYWORD;
  }

  /**
   * @returns {object}
   *   Default remote settings data that sets up `WEATHER_KEYWORD` as the
   *   keyword for the weather suggestion.
   */
  get WEATHER_RS_DATA() {
    return { ...WEATHER_RS_DATA };
  }

  /**
   * @returns {object}
   *   A mock weather suggestion.
   */
  get WEATHER_SUGGESTION() {
    return WEATHER_SUGGESTION;
  }

  /**
   * @returns {MockMerinoServer}
   *   The mock Merino server. The server isn't started until its `start()`
   *   method is called.
   */
  get server() {
    return this.#server;
  }

  /**
   * Clears the Merino-related histograms and returns them.
   *
   * @param {object} options
   *   Options
   * @param {string} options.extraLatency
   *   The name of another latency histogram you expect to be updated.
   * @param {string} options.extraResponse
   *   The name of another response histogram you expect to be updated.
   * @returns {object}
   *   An object of histograms: `{ latency, response }`
   *   `latency` and `response` are both arrays of Histogram objects.
   */
  getAndClearHistograms({
    extraLatency = undefined,
    extraResponse = undefined,
  } = {}) {
    let histograms = {
      latency: [
        lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_LATENCY),
      ],
      response: [
        lazy.TelemetryTestUtils.getAndClearHistogram(HISTOGRAM_RESPONSE),
      ],
    };
    if (extraLatency) {
      histograms.latency.push(
        lazy.TelemetryTestUtils.getAndClearHistogram(extraLatency)
      );
    }
    if (extraResponse) {
      histograms.response.push(
        lazy.TelemetryTestUtils.getAndClearHistogram(extraResponse)
      );
    }
    return histograms;
  }

  /**
   * Asserts the Merino-related histograms are updated as expected. Clears the
   * histograms before returning.
   *
   * @param {object} options
   *   Options object
   * @param {MerinoClient} options.client
   *   The relevant `MerinoClient` instance. This is used to check the latency
   *   stopwatch.
   * @param {object} options.histograms
   *   The histograms object returned from `getAndClearHistograms()`.
   * @param {string} options.response
   *   The expected string label for the `response` histogram. If the histogram
   *   should not be recorded, pass null.
   * @param {boolean} options.latencyRecorded
   *   Whether the latency histogram is expected to contain a value.
   * @param {boolean} options.latencyStopwatchRunning
   *   Whether the latency stopwatch is expected to be running.
   */
  checkAndClearHistograms({
    client,
    histograms,
    response,
    latencyRecorded,
    latencyStopwatchRunning = false,
  }) {
    // Check the response histograms.
    if (response) {
      this.Assert.ok(
        RESPONSE_HISTOGRAM_VALUES.hasOwnProperty(response),
        "Sanity check: Expected response is valid: " + response
      );
      for (let histogram of histograms.response) {
        lazy.TelemetryTestUtils.assertHistogram(
          histogram,
          RESPONSE_HISTOGRAM_VALUES[response],
          1
        );
      }
    } else {
      for (let histogram of histograms.response) {
        this.Assert.strictEqual(
          histogram.snapshot().sum,
          0,
          "Response histogram not updated: " + histogram.name()
        );
      }
    }

    // Check the latency histograms.
    if (latencyRecorded) {
      // There should be a single value across all buckets.
      for (let histogram of histograms.latency) {
        this.Assert.deepEqual(
          Object.values(histogram.snapshot().values).filter(v => v > 0),
          [1],
          "Latency histogram updated: " + histogram.name()
        );
      }
    } else {
      for (let histogram of histograms.latency) {
        this.Assert.strictEqual(
          histogram.snapshot().sum,
          0,
          "Latency histogram not updated: " + histogram.name()
        );
      }
    }

    // Check the latency stopwatch.
    this.Assert.equal(
      TelemetryStopwatch.running(
        HISTOGRAM_LATENCY,
        client._test_latencyStopwatchInstance
      ),
      latencyStopwatchRunning,
      "Latency stopwatch running as expected"
    );

    // Clear histograms.
    for (let histogramArray of Object.values(histograms)) {
      for (let histogram of histogramArray) {
        histogram.clear();
      }
    }
  }

  /**
   * Initializes the quick suggest weather feature and mock Merino server.
   */
  async initWeather() {
    await this.server.start();
    this.server.response.body.suggestions = [WEATHER_SUGGESTION];

    lazy.QuickSuggest.weather._test_fetchIntervalMs = WEATHER_FETCH_INTERVAL_MS;

    // Enabling weather will trigger a fetch. Wait for it to finish so the
    // suggestion is ready when this function returns.
    let fetchPromise = lazy.QuickSuggest.weather.waitForFetches();
    lazy.UrlbarPrefs.set("weather.featureGate", true);
    lazy.UrlbarPrefs.set("suggest.weather", true);
    await fetchPromise;

    this.Assert.equal(
      lazy.QuickSuggest.weather._test_pendingFetchCount,
      0,
      "No pending fetches after awaiting initial fetch"
    );

    this.registerCleanupFunction?.(async () => {
      lazy.UrlbarPrefs.clear("weather.featureGate");
      lazy.UrlbarPrefs.clear("suggest.weather");
      lazy.QuickSuggest.weather._test_fetchIntervalMs = -1;
    });
  }

  #initDepth = 0;
  #server = null;
}

/**
 * A mock Merino server with useful helper methods.
 */
class MockMerinoServer {
  /**
   * Until `start()` is called the server isn't started and `this.url` is null.
   *
   * @param {object} scope
   *   The global JS scope where tests are being run. This allows the instance
   *   to access test helpers like `Assert` that are available in the scope.
   */
  constructor(scope) {
    scope.info?.("MockMerinoServer constructor");

    for (let p of TEST_SCOPE_PROPERTIES) {
      this[p] = scope[p];
    }

    let path = "/merino";
    this.#httpServer = new HttpServer();
    this.#httpServer.registerPathHandler(path, (req, resp) =>
      this.#handleRequest(req, resp)
    );
    this.#baseURL = new URL("http://localhost/");
    this.#baseURL.pathname = path;

    this.reset();
  }

  /**
   * Uninitializes the server.
   */
  uninit() {
    this.info?.("MockMerinoServer uninit");
    for (let p of TEST_SCOPE_PROPERTIES) {
      this[p] = null;
    }
  }

  /**
   * @returns {nsIHttpServer}
   *   The underlying HTTP server.
   */
  get httpServer() {
    return this.#httpServer;
  }

  /**
   * @returns {URL}
   *   The server's endpoint URL or null if the server isn't running.
   */
  get url() {
    return this.#url;
  }

  /**
   * @returns {Array}
   *   Array of received nsIHttpRequest objects. Requests are continually
   *   collected, and the list can be cleared with `reset()`.
   */
  get requests() {
    return this.#requests;
  }

  /**
   * @returns {object}
   *   An object that describes the response that the server will return. Can be
   *   modified or set to a different object to change the response. Can be
   *   reset to the default reponse by calling `reset()`. For details see
   *   `makeDefaultResponse()` and `#handleRequest()`. In summary:
   *
   *   {
   *     status,
   *     contentType,
   *     delay,
   *     body: {
   *       request_id,
   *       suggestions,
   *     },
   *   }
   */
  get response() {
    return this.#response;
  }
  set response(value) {
    this.#response = value;
  }

  /**
   * Starts the server and sets `this.url`. If the server was created with a
   * test scope that defines `registerCleanupFunction()`, you don't need to call
   * `stop()` yourself because it will automatically be called as a cleanup
   * function. Otherwise you'll need to call `stop()`.
   */
  async start() {
    if (this.#url) {
      return;
    }

    this.info("MockMerinoServer starting");

    this.#httpServer.start(-1);
    this.#url = new URL(this.#baseURL);
    this.#url.port = this.#httpServer.identity.primaryPort;

    this._originalEndpointURL = lazy.UrlbarPrefs.get("merino.endpointURL");
    lazy.UrlbarPrefs.set("merino.endpointURL", this.#url.toString());

    this.registerCleanupFunction?.(() => this.stop());

    // Wait for the server to actually start serving. In TV tests, where the
    // server is created over and over again, sometimes it doesn't seem to be
    // ready after being recreated even after `#httpServer.start()` is called.
    this.info("MockMerinoServer waiting to start serving...");
    this.reset();
    let suggestion;
    while (!suggestion) {
      let response = await fetch(this.#url);
      let body = await response?.json();
      suggestion = body?.suggestions?.[0];
    }
    this.reset();
    this.info("MockMerinoServer is now serving");
  }

  /**
   * Stops the server and cleans up other state.
   */
  async stop() {
    if (!this.#url) {
      return;
    }

    // `uninit()` may have already been called by this point and removed
    // `this.info()`, so don't assume it's defined.
    this.info?.("MockMerinoServer stopping");

    // Cancel delayed-response timers and resolve their promises. Otherwise, if
    // a test awaits this method before finishing, it will hang until the timers
    // fire and allow the server to send the responses.
    this.#cancelDelayedResponses();

    await this.#httpServer.stop();
    this.#url = null;
    lazy.UrlbarPrefs.set("merino.endpointURL", this._originalEndpointURL);

    this.info?.("MockMerinoServer is now stopped");
  }

  /**
   * Returns a new object that describes the default response the server will
   * return.
   *
   * @returns {object}
   */
  makeDefaultResponse() {
    return {
      status: 200,
      contentType: "application/json",
      body: {
        request_id: "request_id",
        suggestions: [
          {
            provider: "adm",
            full_keyword: "full_keyword",
            title: "title",
            url: "url",
            icon: null,
            impression_url: "impression_url",
            click_url: "click_url",
            block_id: 1,
            advertiser: "advertiser",
            is_sponsored: true,
            score: 1,
          },
        ],
      },
    };
  }

  /**
   * Clears the received requests and sets the response to the default.
   */
  reset() {
    this.#requests = [];
    this.response = this.makeDefaultResponse();
    this.#cancelDelayedResponses();
  }

  /**
   * Asserts a given list of requests has been received. Clears the list of
   * received requests before returning.
   *
   * @param {Array} expected
   *   The expected requests. Each item should be an object: `{ params }`
   */
  checkAndClearRequests(expected) {
    let actual = this.requests.map(req => {
      let params = new URLSearchParams(req.queryString);
      return { params: Object.fromEntries(params) };
    });

    this.info("Checking requests");
    this.info("actual: " + JSON.stringify(actual));
    this.info("expect: " + JSON.stringify(expected));

    // Check the request count.
    this.Assert.equal(actual.length, expected.length, "Expected request count");
    if (actual.length != expected.length) {
      return;
    }

    // Check each request.
    for (let i = 0; i < actual.length; i++) {
      let a = actual[i];
      let e = expected[i];
      this.info("Checking requests at index " + i);
      this.info("actual: " + JSON.stringify(a));
      this.info("expect: " + JSON.stringify(e));

      // Check required search params.
      for (let p of REQUIRED_SEARCH_PARAMS) {
        this.Assert.ok(
          a.params.hasOwnProperty(p),
          "Required param is present in actual request: " + p
        );
        if (p != SEARCH_PARAMS.SESSION_ID) {
          this.Assert.ok(
            e.params.hasOwnProperty(p),
            "Required param is present in expected request: " + p
          );
        }
      }

      // If the expected request doesn't include a session ID, then:
      if (!e.params.hasOwnProperty(SEARCH_PARAMS.SESSION_ID)) {
        if (e.params[SEARCH_PARAMS.SEQUENCE_NUMBER] == 0 || i == 0) {
          // If its sequence number is zero, then copy the actual request's
          // sequence number to the expected request. As a convenience, do the
          // same if this is the first request.
          e.params[SEARCH_PARAMS.SESSION_ID] =
            a.params[SEARCH_PARAMS.SESSION_ID];
        } else {
          // Otherwise this is not the first request in the session and
          // therefore the session ID should be the same as the ID in the
          // previous expected request.
          e.params[SEARCH_PARAMS.SESSION_ID] =
            expected[i - 1].params[SEARCH_PARAMS.SESSION_ID];
        }
      }

      this.Assert.deepEqual(a, e, "Expected request at index " + i);

      let actualSessionID = a.params[SEARCH_PARAMS.SESSION_ID];
      this.Assert.ok(actualSessionID, "Session ID exists");
      this.Assert.ok(
        /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(actualSessionID),
        "Session ID is a UUID"
      );
    }

    this.#requests = [];
  }

  /**
   * Temporarily creates the conditions for a network error. Any Merino fetches
   * that occur during the callback will fail with a network error.
   *
   * @param {Function} callback
   *   Callback function.
   */
  async withNetworkError(callback) {
    // Set the endpoint to a valid, unreachable URL.
    let originalURL = lazy.UrlbarPrefs.get("merino.endpointURL");
    lazy.UrlbarPrefs.set(
      "merino.endpointURL",
      "http://localhost/valid-but-unreachable-url"
    );

    // Set the timeout high enough that the network error exception will happen
    // first. On Mac and Linux the fetch naturally times out fairly quickly but
    // on Windows it seems to take 5s, so set our artificial timeout to 10s.
    let originalTimeout = lazy.UrlbarPrefs.get("merino.timeoutMs");
    lazy.UrlbarPrefs.set("merino.timeoutMs", 10000);

    await callback();

    lazy.UrlbarPrefs.set("merino.endpointURL", originalURL);
    lazy.UrlbarPrefs.set("merino.timeoutMs", originalTimeout);
  }

  /**
   * Returns a promise that will resolve when the next request is received.
   *
   * @returns {Promise}
   */
  waitForNextRequest() {
    if (!this.#nextRequestDeferred) {
      this.#nextRequestDeferred = lazy.PromiseUtils.defer();
    }
    return this.#nextRequestDeferred.promise;
  }

  /**
   * nsIHttpServer request handler.
   *
   * @param {nsIHttpRequest} httpRequest
   *   Request.
   * @param {nsIHttpResponse} httpResponse
   *   Response.
   */
  #handleRequest(httpRequest, httpResponse) {
    this.info(
      "MockMerinoServer received request with query string: " +
        JSON.stringify(httpRequest.queryString)
    );
    this.info(
      "MockMerinoServer replying with response: " +
        JSON.stringify(this.response)
    );

    // Add the request to the list of received requests.
    this.#requests.push(httpRequest);

    // Resolve promises waiting on the next request.
    this.#nextRequestDeferred?.resolve();
    this.#nextRequestDeferred = null;

    // Now set up and finish the response.
    httpResponse.processAsync();

    let { response } = this;

    let finishResponse = () => {
      let status = response.status || 200;
      httpResponse.setStatusLine("", status, status);

      let contentType = response.contentType || "application/json";
      httpResponse.setHeader("Content-Type", contentType, false);

      if (typeof response.body == "string") {
        httpResponse.write(response.body);
      } else if (response.body) {
        httpResponse.write(JSON.stringify(response.body));
      }

      httpResponse.finish();
    };

    if (typeof response.delay != "number") {
      finishResponse();
      return;
    }

    // Set up a timer to wait until the delay elapses. Since we called
    // `httpResponse.processAsync()`, we need to be careful to always finish the
    // response, even if the timer is canceled. Otherwise the server will hang
    // when we try to stop it at the end of the test. When an `nsITimer` is
    // canceled, its callback is *not* called. Therefore we set up a race
    // between the timer's callback and a deferred promise. If the timer is
    // canceled, resolving the deferred promise will resolve the race, and the
    // response can then be finished.

    let delayedResponseID = this.#nextDelayedResponseID++;
    this.info(
      "MockMerinoServer delaying response: " +
        JSON.stringify({ delayedResponseID, delay: response.delay })
    );

    let deferred = lazy.PromiseUtils.defer();
    let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
    let record = { timer, resolve: deferred.resolve };
    this.#delayedResponseRecords.add(record);

    // Don't await this promise.
    Promise.race([
      deferred.promise,
      new Promise(resolve => {
        timer.initWithCallback(
          resolve,
          response.delay,
          Ci.nsITimer.TYPE_ONE_SHOT
        );
      }),
    ]).then(() => {
      this.info(
        "MockMerinoServer done delaying response: " +
          JSON.stringify({ delayedResponseID })
      );
      deferred.resolve();
      this.#delayedResponseRecords.delete(record);
      finishResponse();
    });
  }

  /**
   * Cancels the timers for delayed responses and resolves their promises.
   */
  #cancelDelayedResponses() {
    for (let { timer, resolve } of this.#delayedResponseRecords) {
      timer.cancel();
      resolve();
    }
    this.#delayedResponseRecords.clear();
  }

  #httpServer = null;
  #url = null;
  #baseURL = null;
  #response = null;
  #requests = [];
  #nextRequestDeferred = null;
  #nextDelayedResponseID = 0;
  #delayedResponseRecords = new Set();
}

export var MerinoTestUtils = new _MerinoTestUtils();
