/* 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/. */

// Tests the quick suggest weather feature.

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  MockRegistrar: "resource://testing-common/MockRegistrar.sys.mjs",
  UrlbarProviderWeather: "resource:///modules/UrlbarProviderWeather.sys.mjs",
});

const HISTOGRAM_LATENCY = "FX_URLBAR_MERINO_LATENCY_WEATHER_MS";
const HISTOGRAM_RESPONSE = "FX_URLBAR_MERINO_RESPONSE_WEATHER";

const { WEATHER_RS_DATA, WEATHER_SUGGESTION } = MerinoTestUtils;

add_task(async function init() {
  await QuickSuggestTestUtils.ensureQuickSuggestInit({
    remoteSettingsResults: [
      {
        type: "weather",
        weather: WEATHER_RS_DATA,
      },
    ],
  });
  UrlbarPrefs.set("quicksuggest.enabled", true);

  await MerinoTestUtils.initWeather();

  // Give this a small value so it doesn't delay the test too long. Choose a
  // value that's unlikely to be used anywhere else in the test so that when
  // `lastFetchTimeMs` is expected to be `fetchDelayAfterComingOnlineMs`, we can
  // be sure the value actually came from `fetchDelayAfterComingOnlineMs`.
  QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs = 53;
});

// The feature should be properly uninitialized when it's disabled and then
// re-initialized when it's re-enabled. This task disables the feature using the
// feature gate pref.
add_task(async function disableAndEnable_featureGate() {
  await doBasicDisableAndEnableTest("weather.featureGate");
});

// The feature should be properly uninitialized when it's disabled and then
// re-initialized when it's re-enabled. This task disables the feature using the
// suggest pref.
add_task(async function disableAndEnable_suggestPref() {
  await doBasicDisableAndEnableTest("suggest.weather");
});

async function doBasicDisableAndEnableTest(pref) {
  // Sanity check initial state.
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  // Disable the feature. It should be immediately uninitialized.
  UrlbarPrefs.set(pref, false);
  assertDisabled({
    message: "After disabling",
    pendingFetchCount: 0,
  });

  // No suggestion should be returned for a search.
  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  let histograms = MerinoTestUtils.getAndClearHistograms({
    extraLatency: HISTOGRAM_LATENCY,
    extraResponse: HISTOGRAM_RESPONSE,
  });

  // Re-enable the feature. It should be immediately initialized and a fetch
  // should start.
  info("Re-enable the feature");
  let fetchPromise = QuickSuggest.weather.waitForFetches();
  UrlbarPrefs.set(pref, true);
  assertEnabled({
    message: "Immediately after re-enabling",
    hasSuggestion: false,
    pendingFetchCount: 1,
  });

  await fetchPromise;
  assertEnabled({
    message: "After awaiting fetch",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  Assert.equal(
    QuickSuggest.weather._test_merino.lastFetchStatus,
    "success",
    "The request successfully finished"
  );
  MerinoTestUtils.checkAndClearHistograms({
    histograms,
    response: "success",
    latencyRecorded: true,
    client: QuickSuggest.weather._test_merino,
  });

  // The suggestion should be returned for a search.
  context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [makeExpectedResult()],
  });
}

add_task(async function keywordsNotDefined() {
  // Sanity check initial state.
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  // Set RS data without any keywords. Fetching should immediately stop.
  await QuickSuggestTestUtils.setRemoteSettingsResults([
    {
      type: "weather",
      weather: {},
    },
  ]);
  assertDisabled({
    message: "After setting RS data without keywords",
    pendingFetchCount: 0,
  });

  // No suggestion should be returned for a search.
  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  // Set keywords. Fetching should immediately start.
  info("Setting keywords");
  let fetchPromise = QuickSuggest.weather.waitForFetches();
  await QuickSuggestTestUtils.setRemoteSettingsResults([
    {
      type: "weather",
      weather: MerinoTestUtils.WEATHER_RS_DATA,
    },
  ]);
  assertEnabled({
    message: "Immediately after setting keywords",
    hasSuggestion: false,
    pendingFetchCount: 1,
  });

  await fetchPromise;
  assertEnabled({
    message: "After awaiting fetch",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  Assert.equal(
    QuickSuggest.weather._test_merino.lastFetchStatus,
    "success",
    "The request successfully finished"
  );

  // The suggestion should be returned for a search.
  context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [makeExpectedResult()],
  });
});

// Disables and re-enables the feature without waiting for any intermediate
// fetches to complete, using the following steps:
//
// 1. Disable
// 2. Enable
// 3. Disable again
//
// At this point, the fetch from step 2 will remain ongoing but once it finishes
// it should be discarded since the feature is disabled.
add_task(async function disableAndEnable_immediate1() {
  // Sanity check initial state.
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  // Disable the feature. It should be immediately uninitialized.
  UrlbarPrefs.set("weather.featureGate", false);
  assertDisabled({
    message: "After disabling",
    pendingFetchCount: 0,
  });

  // Re-enable the feature. It should be immediately initialized and a fetch
  // should start.
  let fetchPromise = QuickSuggest.weather.waitForFetches();
  UrlbarPrefs.set("weather.featureGate", true);
  assertEnabled({
    message: "Immediately after re-enabling",
    hasSuggestion: false,
    pendingFetchCount: 1,
  });

  // Disable it again. The fetch will remain ongoing since pending fetches
  // aren't stopped when the feature is disabled.
  UrlbarPrefs.set("weather.featureGate", false);
  assertDisabled({
    message: "After disabling again",
    pendingFetchCount: 1,
  });

  // Wait for the fetch to finish.
  await fetchPromise;

  // The fetched suggestion should be discarded and the feature should remain
  // uninitialized.
  assertDisabled({
    message: "After awaiting fetch",
    pendingFetchCount: 0,
  });

  // Clean up by re-enabling the feature for the remaining tasks.
  fetchPromise = QuickSuggest.weather.waitForFetches();
  UrlbarPrefs.set("weather.featureGate", true);
  await fetchPromise;
  assertEnabled({
    message: "On cleanup",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// Disables and re-enables the feature without waiting for any intermediate
// fetches to complete, using the following steps:
//
// 1. Disable
// 2. Enable
// 3. Disable again
// 4. Enable again
//
// At this point, the fetches from steps 2 and 4 will remain ongoing. The fetch
// from step 2 should be discarded.
add_task(async function disableAndEnable_immediate2() {
  // Sanity check initial state.
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  // Disable the feature. It should be immediately uninitialized.
  UrlbarPrefs.set("weather.featureGate", false);
  assertDisabled({
    message: "After disabling",
    pendingFetchCount: 0,
  });

  // Re-enable the feature. It should be immediately initialized and a fetch
  // should start.
  UrlbarPrefs.set("weather.featureGate", true);
  assertEnabled({
    message: "Immediately after re-enabling",
    hasSuggestion: false,
    pendingFetchCount: 1,
  });

  // Disable it again. The fetch will remain ongoing since pending fetches
  // aren't stopped when the feature is disabled.
  UrlbarPrefs.set("weather.featureGate", false);
  assertDisabled({
    message: "After disabling again",
    pendingFetchCount: 1,
  });

  // Re-enable it. A new fetch should start, so now there will be two pending
  // fetches.
  let fetchPromise = QuickSuggest.weather.waitForFetches();
  UrlbarPrefs.set("weather.featureGate", true);
  assertEnabled({
    message: "Immediately after re-enabling again",
    hasSuggestion: false,
    pendingFetchCount: 2,
  });

  // Wait for both fetches to finish.
  await fetchPromise;
  assertEnabled({
    message: "Immediately after re-enabling again",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// A fetch that doesn't return a suggestion should cause the last-fetched
// suggestion to be discarded.
add_task(async function noSuggestion() {
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  let histograms = MerinoTestUtils.getAndClearHistograms({
    extraLatency: HISTOGRAM_LATENCY,
    extraResponse: HISTOGRAM_RESPONSE,
  });

  let { suggestions } = MerinoTestUtils.server.response.body;
  MerinoTestUtils.server.response.body.suggestions = [];

  await QuickSuggest.weather._test_fetch();

  assertEnabled({
    message: "After fetch",
    hasSuggestion: false,
    pendingFetchCount: 0,
  });
  Assert.equal(
    QuickSuggest.weather._test_merino.lastFetchStatus,
    "no_suggestion",
    "The request successfully finished without suggestions"
  );
  MerinoTestUtils.checkAndClearHistograms({
    histograms,
    response: "no_suggestion",
    latencyRecorded: true,
    client: QuickSuggest.weather._test_merino,
  });

  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  MerinoTestUtils.server.response.body.suggestions = suggestions;

  // Clean up by forcing another fetch so the suggestion is non-null for the
  // remaining tasks.
  await QuickSuggest.weather._test_fetch();
  assertEnabled({
    message: "On cleanup",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// A network error should cause the last-fetched suggestion to be discarded.
add_task(async function networkError() {
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  let histograms = MerinoTestUtils.getAndClearHistograms({
    extraLatency: HISTOGRAM_LATENCY,
    extraResponse: HISTOGRAM_RESPONSE,
  });

  // Set the weather fetch timeout high enough that the network error exception
  // will happen first. See `MerinoTestUtils.withNetworkError()`.
  QuickSuggest.weather._test_setTimeoutMs(10000);

  await MerinoTestUtils.server.withNetworkError(async () => {
    await QuickSuggest.weather._test_fetch();
  });

  QuickSuggest.weather._test_setTimeoutMs(-1);

  assertEnabled({
    message: "After fetch",
    hasSuggestion: false,
    pendingFetchCount: 0,
  });
  Assert.equal(
    QuickSuggest.weather._test_merino.lastFetchStatus,
    "network_error",
    "The request failed with a network error"
  );
  MerinoTestUtils.checkAndClearHistograms({
    histograms,
    response: "network_error",
    latencyRecorded: false,
    client: QuickSuggest.weather._test_merino,
  });

  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  // Clean up by forcing another fetch so the suggestion is non-null for the
  // remaining tasks.
  await QuickSuggest.weather._test_fetch();
  assertEnabled({
    message: "On cleanup",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// An HTTP error should cause the last-fetched suggestion to be discarded.
add_task(async function httpError() {
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  let histograms = MerinoTestUtils.getAndClearHistograms({
    extraLatency: HISTOGRAM_LATENCY,
    extraResponse: HISTOGRAM_RESPONSE,
  });

  MerinoTestUtils.server.response = { status: 500 };
  await QuickSuggest.weather._test_fetch();

  assertEnabled({
    message: "After fetch",
    hasSuggestion: false,
    pendingFetchCount: 0,
  });
  Assert.equal(
    QuickSuggest.weather._test_merino.lastFetchStatus,
    "http_error",
    "The request failed with an HTTP error"
  );
  MerinoTestUtils.checkAndClearHistograms({
    histograms,
    response: "http_error",
    latencyRecorded: true,
    client: QuickSuggest.weather._test_merino,
  });

  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  // Clean up by forcing another fetch so the suggestion is non-null for the
  // remaining tasks.
  MerinoTestUtils.server.reset();
  MerinoTestUtils.server.response.body.suggestions = [WEATHER_SUGGESTION];
  await QuickSuggest.weather._test_fetch();
  assertEnabled({
    message: "On cleanup",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// A fetch that doesn't return a suggestion due to a client timeout should cause
// the last-fetched suggestion to be discarded.
add_task(async function clientTimeout() {
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  let histograms = MerinoTestUtils.getAndClearHistograms({
    extraLatency: HISTOGRAM_LATENCY,
    extraResponse: HISTOGRAM_RESPONSE,
  });

  // Make the server return a delayed response so the Merino client times out
  // waiting for it.
  MerinoTestUtils.server.response.delay = 400;

  // Make the client time out immediately.
  QuickSuggest.weather._test_setTimeoutMs(1);

  // Set up a promise that will be resolved when the client finally receives the
  // response.
  let responsePromise = QuickSuggest.weather._test_merino.waitForNextResponse();

  await QuickSuggest.weather._test_fetch();

  assertEnabled({
    message: "After fetch",
    hasSuggestion: false,
    pendingFetchCount: 0,
  });
  Assert.equal(
    QuickSuggest.weather._test_merino.lastFetchStatus,
    "timeout",
    "The request timed out"
  );
  MerinoTestUtils.checkAndClearHistograms({
    histograms,
    response: "timeout",
    latencyRecorded: false,
    latencyStopwatchRunning: true,
    client: QuickSuggest.weather._test_merino,
  });

  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  // Await the response.
  await responsePromise;

  // The `checkAndClearHistograms()` call above cleared the histograms. After
  // that, nothing else should have been recorded for the response.
  MerinoTestUtils.checkAndClearHistograms({
    histograms,
    response: null,
    latencyRecorded: true,
    client: QuickSuggest.weather._test_merino,
  });

  QuickSuggest.weather._test_setTimeoutMs(-1);
  delete MerinoTestUtils.server.response.delay;

  // Clean up by forcing another fetch so the suggestion is non-null for the
  // remaining tasks.
  await QuickSuggest.weather._test_fetch();
  assertEnabled({
    message: "On cleanup",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// Locale task for when this test runs on an en-US OS.
add_task(async function locale_enUS() {
  await doLocaleTest({
    shouldRunTask: osLocale => osLocale == "en-US",
    osUnit: "f",
    unitsByLocale: {
      "en-US": "f",
      // When the app's locale is set to any en-* locale, F will be used because
      // `regionalPrefsLocales` will prefer the en-US OS locale.
      "en-CA": "f",
      "en-GB": "f",
      de: "c",
    },
  });
});

// Locale task for when this test runs on a non-US English OS.
add_task(async function locale_nonUSEnglish() {
  await doLocaleTest({
    shouldRunTask: osLocale => osLocale.startsWith("en") && osLocale != "en-US",
    osUnit: "c",
    unitsByLocale: {
      // When the app's locale is set to en-US, C will be used because
      // `regionalPrefsLocales` will prefer the non-US English OS locale.
      "en-US": "c",
      "en-CA": "c",
      "en-GB": "c",
      de: "c",
    },
  });
});

// Locale task for when this test runs on a non-English OS.
add_task(async function locale_nonEnglish() {
  await doLocaleTest({
    shouldRunTask: osLocale => !osLocale.startsWith("en"),
    osUnit: "c",
    unitsByLocale: {
      "en-US": "f",
      "en-CA": "c",
      "en-GB": "c",
      de: "c",
    },
  });
});

/**
 * Testing locales is tricky due to the weather feature's use of
 * `Services.locale.regionalPrefsLocales`. By default `regionalPrefsLocales`
 * prefers the OS locale if its language is the same as the app locale's
 * language; otherwise it prefers the app locale. For example, assuming the OS
 * locale is en-CA, then if the app locale is en-US it will prefer en-CA since
 * both are English, but if the app locale is de it will prefer de. If the pref
 * `intl.regional_prefs.use_os_locales` is set, then the OS locale is always
 * preferred.
 *
 * This function tests a given set of locales with and without
 * `intl.regional_prefs.use_os_locales` set.
 *
 * @param {object} options
 *   Options
 * @param {Function} options.shouldRunTask
 *   Called with the OS locale. Should return true if the function should run.
 *   Use this to skip tasks that don't target a desired OS locale.
 * @param {string} options.osUnit
 *   The expected "c" or "f" unit for the OS locale.
 * @param {object} options.unitsByLocale
 *   The expected "c" or "f" unit when the app's locale is set to particular
 *   locales. This should be an object that maps locales to expected units. For
 *   each locale in the object, the app's locale is set to that locale and the
 *   actual unit is expected to be the unit in the object.
 */
async function doLocaleTest({ shouldRunTask, osUnit, unitsByLocale }) {
  Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true);
  let osLocale = Services.locale.regionalPrefsLocales[0];
  Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales");

  if (!shouldRunTask(osLocale)) {
    info("Skipping task, should not run for this OS locale");
    return;
  }

  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  // Sanity check initial locale info.
  Assert.equal(
    Services.locale.appLocaleAsBCP47,
    "en-US",
    "Initial app locale should be en-US"
  );
  Assert.ok(
    !Services.prefs.getBoolPref("intl.regional_prefs.use_os_locales"),
    "intl.regional_prefs.use_os_locales should be false initially"
  );

  // Check locales.
  for (let [locale, temperatureUnit] of Object.entries(unitsByLocale)) {
    await QuickSuggestTestUtils.withLocales([locale], async () => {
      info("Checking locale: " + locale);
      await check_results({
        context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
          providers: [UrlbarProviderWeather.name],
          isPrivate: false,
        }),
        matches: [makeExpectedResult({ temperatureUnit })],
      });

      info(
        "Checking locale with intl.regional_prefs.use_os_locales: " + locale
      );
      Services.prefs.setBoolPref("intl.regional_prefs.use_os_locales", true);
      await check_results({
        context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
          providers: [UrlbarProviderWeather.name],
          isPrivate: false,
        }),
        matches: [makeExpectedResult({ temperatureUnit: osUnit })],
      });
      Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales");
    });
  }
}

// Blocks a result and makes sure the weather pref is disabled.
add_task(async function block() {
  // Sanity check initial state.
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
  Assert.ok(
    UrlbarPrefs.get("suggest.weather"),
    "Sanity check: suggest.weather is true initially"
  );

  // Do a search so we can get an actual result.
  let context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [makeExpectedResult()],
  });

  // Block the result.
  UrlbarProviderWeather.onEngagement(false, "engagement", context, {
    result: context.results[0],
    selType: "dismiss",
    selIndex: context.results[0].rowIndex,
  });
  Assert.ok(
    !UrlbarPrefs.get("suggest.weather"),
    "suggest.weather is false after blocking the result"
  );

  // Do a second search. Nothing should be returned.
  context = createContext(MerinoTestUtils.WEATHER_KEYWORD, {
    providers: [UrlbarProviderWeather.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

  // Re-enable the pref and clean up.
  let fetchPromise = QuickSuggest.weather.waitForFetches();
  UrlbarPrefs.set("suggest.weather", true);
  await fetchPromise;
  assertEnabled({
    message: "On cleanup",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });
});

// Simulates wake 100ms before the start of the next fetch period. A new fetch
// should not start.
add_task(async function wakeBeforeNextFetchPeriod() {
  await doWakeTest({
    sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs - 100,
    shouldFetchOnWake: false,
    fetchTimerMsOnWake: 100,
  });
});

// Simulates wake 100ms after the start of the next fetch period. A new fetch
// should start.
add_task(async function wakeAfterNextFetchPeriod() {
  await doWakeTest({
    sleepIntervalMs: QuickSuggest.weather._test_fetchIntervalMs + 100,
    shouldFetchOnWake: true,
  });
});

// Simulates wake after many fetch periods + 100ms. A new fetch should start.
add_task(async function wakeAfterManyFetchPeriods() {
  await doWakeTest({
    sleepIntervalMs: 100 * QuickSuggest.weather._test_fetchIntervalMs + 100,
    shouldFetchOnWake: true,
  });
});

async function doWakeTest({
  sleepIntervalMs,
  shouldFetchOnWake,
  fetchTimerMsOnWake,
}) {
  // Make `Date.now()` return a value under our control, doesn't matter what it
  // is. This is the time the first fetch period will start.
  let nowOnStart = 100;
  let sandbox = sinon.createSandbox();
  let dateNowStub = sandbox.stub(
    Cu.getGlobalForObject(QuickSuggest.weather).Date,
    "now"
  );
  dateNowStub.returns(nowOnStart);

  // Start the first fetch period.
  info("Starting first fetch period");
  await QuickSuggest.weather._test_fetch();
  Assert.equal(
    QuickSuggest.weather._test_lastFetchTimeMs,
    nowOnStart,
    "Last fetch time should be updated after fetch"
  );
  Assert.equal(
    QuickSuggest.weather._test_fetchTimerMs,
    QuickSuggest.weather._test_fetchIntervalMs,
    "Timer period should be full fetch interval"
  );

  let timer = QuickSuggest.weather._test_fetchTimer;

  // Advance the clock and simulate wake.
  info("Sending wake notification");
  let nowOnWake = nowOnStart + sleepIntervalMs;
  dateNowStub.returns(nowOnWake);
  QuickSuggest.weather.observe(null, "wake_notification", "");

  Assert.equal(
    QuickSuggest.weather._test_pendingFetchCount,
    0,
    "After wake, next fetch should not have immediately started"
  );
  Assert.equal(
    QuickSuggest.weather._test_lastFetchTimeMs,
    nowOnStart,
    "After wake, last fetch time should be unchanged"
  );
  Assert.notEqual(
    QuickSuggest.weather._test_fetchTimer,
    0,
    "After wake, the timer should exist (be non-zero)"
  );
  Assert.notEqual(
    QuickSuggest.weather._test_fetchTimer,
    timer,
    "After wake, a new timer should have been created"
  );

  if (shouldFetchOnWake) {
    Assert.equal(
      QuickSuggest.weather._test_fetchTimerMs,
      QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs,
      "After wake, timer period should be fetchDelayAfterComingOnlineMs"
    );
  } else {
    Assert.equal(
      QuickSuggest.weather._test_fetchTimerMs,
      fetchTimerMsOnWake,
      "After wake, timer period should be the remaining interval"
    );
  }

  // Wait for the fetch. If the wake didn't trigger it, then the caller should
  // have passed in a `sleepIntervalMs` that will make it start soon.
  info("Waiting for fetch after wake");
  await QuickSuggest.weather.waitForFetches();

  Assert.equal(
    QuickSuggest.weather._test_fetchTimerMs,
    QuickSuggest.weather._test_fetchIntervalMs,
    "After post-wake fetch, timer period should remain full fetch interval"
  );
  Assert.equal(
    QuickSuggest.weather._test_pendingFetchCount,
    0,
    "After post-wake fetch, no more fetches should be pending"
  );

  dateNowStub.restore();
}

// When network:link-status-changed is observed and the suggestion is non-null,
// a fetch should not start.
add_task(async function networkLinkStatusChanged_nonNull() {
  // See nsINetworkLinkService for possible data values.
  await doOnlineTestWithSuggestion({
    topic: "network:link-status-changed",
    dataValues: [
      "down",
      "up",
      "changed",
      "unknown",
      "this is not a valid data value",
    ],
  });
});

// When network:offline-status-changed is observed and the suggestion is
// non-null, a fetch should not start.
add_task(async function networkOfflineStatusChanged_nonNull() {
  // See nsIIOService for possible data values.
  await doOnlineTestWithSuggestion({
    topic: "network:offline-status-changed",
    dataValues: ["offline", "online", "this is not a valid data value"],
  });
});

// When captive-portal-login-success is observed and the suggestion is non-null,
// a fetch should not start.
add_task(async function captivePortalLoginSuccess_nonNull() {
  // See nsIIOService for possible data values.
  await doOnlineTestWithSuggestion({
    topic: "captive-portal-login-success",
    dataValues: [""],
  });
});

async function doOnlineTestWithSuggestion({ topic, dataValues }) {
  info("Starting fetch period");
  await QuickSuggest.weather._test_fetch();
  Assert.ok(
    QuickSuggest.weather.suggestion,
    "Suggestion should have been fetched"
  );

  let timer = QuickSuggest.weather._test_fetchTimer;

  for (let data of dataValues) {
    info("Sending notification: " + JSON.stringify({ topic, data }));
    QuickSuggest.weather.observe(null, topic, data);

    Assert.equal(
      QuickSuggest.weather._test_pendingFetchCount,
      0,
      "Fetch should not have started"
    );
    Assert.equal(
      QuickSuggest.weather._test_fetchTimer,
      timer,
      "Timer should not have been recreated"
    );
    Assert.equal(
      QuickSuggest.weather._test_fetchTimerMs,
      QuickSuggest.weather._test_fetchIntervalMs,
      "Timer period should be the full fetch interval"
    );
  }
}

// When network:link-status-changed is observed and the suggestion is null, a
// fetch should start unless the data indicates the status is offline.
add_task(async function networkLinkStatusChanged_null() {
  // See nsINetworkLinkService for possible data values.
  await doOnlineTestWithNullSuggestion({
    topic: "network:link-status-changed",
    offlineData: "down",
    otherDataValues: [
      "up",
      "changed",
      "unknown",
      "this is not a valid data value",
    ],
  });
});

// When network:offline-status-changed is observed and the suggestion is null, a
// fetch should start unless the data indicates the status is offline.
add_task(async function networkOfflineStatusChanged_null() {
  // See nsIIOService for possible data values.
  await doOnlineTestWithNullSuggestion({
    topic: "network:offline-status-changed",
    offlineData: "offline",
    otherDataValues: ["online", "this is not a valid data value"],
  });
});

// When captive-portal-login-success is observed and the suggestion is null, a
// fetch should start.
add_task(async function captivePortalLoginSuccess_null() {
  // See nsIIOService for possible data values.
  await doOnlineTestWithNullSuggestion({
    topic: "captive-portal-login-success",
    otherDataValues: [""],
  });
});

async function doOnlineTestWithNullSuggestion({
  topic,
  otherDataValues,
  offlineData = "",
}) {
  QuickSuggest.weather._test_setSuggestionToNull();
  Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");

  let timer = QuickSuggest.weather._test_fetchTimer;

  // First, send the notification with the offline data value. Nothing should
  // happen.
  if (offlineData) {
    info("Sending notification: " + JSON.stringify({ topic, offlineData }));
    QuickSuggest.weather.observe(null, topic, offlineData);

    Assert.ok(
      !QuickSuggest.weather.suggestion,
      "Suggestion should remain null"
    );
    Assert.equal(
      QuickSuggest.weather._test_pendingFetchCount,
      0,
      "Fetch should not have started"
    );
    Assert.equal(
      QuickSuggest.weather._test_fetchTimer,
      timer,
      "Timer should not have been recreated"
    );
    Assert.equal(
      QuickSuggest.weather._test_fetchTimerMs,
      QuickSuggest.weather._test_fetchIntervalMs,
      "Timer period should be the full fetch interval"
    );
  }

  // Now send it with all other data values. Fetches should be triggered.
  for (let data of otherDataValues) {
    QuickSuggest.weather._test_setSuggestionToNull();
    Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");

    info("Sending notification: " + JSON.stringify({ topic, data }));
    QuickSuggest.weather.observe(null, topic, data);

    Assert.equal(
      QuickSuggest.weather._test_pendingFetchCount,
      0,
      "Fetch should not have started yet"
    );
    Assert.notEqual(
      QuickSuggest.weather._test_fetchTimer,
      0,
      "Timer should exist"
    );
    Assert.notEqual(
      QuickSuggest.weather._test_fetchTimer,
      timer,
      "A new timer should have been created"
    );
    Assert.equal(
      QuickSuggest.weather._test_fetchTimerMs,
      QuickSuggest.weather._test_fetchDelayAfterComingOnlineMs,
      "Timer ms should be fetchDelayAfterComingOnlineMs"
    );

    timer = QuickSuggest.weather._test_fetchTimer;

    info("Waiting for fetch after notification");
    await QuickSuggest.weather.waitForFetches();

    Assert.equal(
      QuickSuggest.weather._test_pendingFetchCount,
      0,
      "Fetch should not be pending"
    );
    Assert.notEqual(
      QuickSuggest.weather._test_fetchTimer,
      0,
      "Timer should exist"
    );
    Assert.notEqual(
      QuickSuggest.weather._test_fetchTimer,
      timer,
      "A new timer should have been created"
    );
    Assert.equal(
      QuickSuggest.weather._test_fetchTimerMs,
      QuickSuggest.weather._test_fetchIntervalMs,
      "Timer period should be full fetch interval"
    );

    timer = QuickSuggest.weather._test_fetchTimer;
  }
}

// When many online notifications are received at once, only one fetch should
// start.
add_task(async function manyOnlineNotifications() {
  await doManyNotificationsTest([
    ["network:link-status-changed", "changed"],
    ["network:link-status-changed", "up"],
    ["network:offline-status-changed", "online"],
  ]);
});

// When wake and online notifications are received at once, only one fetch
// should start.
add_task(async function wakeAndOnlineNotifications() {
  await doManyNotificationsTest([
    ["wake_notification", ""],
    ["network:link-status-changed", "changed"],
    ["network:link-status-changed", "up"],
    ["network:offline-status-changed", "online"],
  ]);
});

async function doManyNotificationsTest(notifications) {
  // Make `Date.now()` return a value under our control, doesn't matter what it
  // is. This is the time the first fetch period will start.
  let nowOnStart = 100;
  let sandbox = sinon.createSandbox();
  let dateNowStub = sandbox.stub(
    Cu.getGlobalForObject(QuickSuggest.weather).Date,
    "now"
  );
  dateNowStub.returns(nowOnStart);

  // Start a first fetch period so that after we send the notifications below
  // the last fetch time will be in the past.
  info("Starting first fetch period");
  await QuickSuggest.weather._test_fetch();
  Assert.equal(
    QuickSuggest.weather._test_lastFetchTimeMs,
    nowOnStart,
    "Last fetch time should be updated after fetch"
  );

  // Now advance the clock by many fetch intervals.
  let nowOnWake = nowOnStart + 100 * QuickSuggest.weather._test_fetchIntervalMs;
  dateNowStub.returns(nowOnWake);

  // Set the suggestion to null so online notifications will trigger a fetch.
  QuickSuggest.weather._test_setSuggestionToNull();
  Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");

  // Clear the server's list of received requests.
  MerinoTestUtils.server.reset();
  MerinoTestUtils.server.response.body.suggestions = [
    MerinoTestUtils.WEATHER_SUGGESTION,
  ];

  // Send the notifications.
  for (let [topic, data] of notifications) {
    info("Sending notification: " + JSON.stringify({ topic, data }));
    QuickSuggest.weather.observe(null, topic, data);
  }

  info("Waiting for fetch after notifications");
  await QuickSuggest.weather.waitForFetches();

  Assert.equal(
    QuickSuggest.weather._test_pendingFetchCount,
    0,
    "Fetch should not be pending"
  );
  Assert.notEqual(
    QuickSuggest.weather._test_fetchTimer,
    0,
    "Timer should exist"
  );
  Assert.equal(
    QuickSuggest.weather._test_fetchTimerMs,
    QuickSuggest.weather._test_fetchIntervalMs,
    "Timer period should be full fetch interval"
  );

  Assert.equal(
    MerinoTestUtils.server.requests.length,
    1,
    "Merino should have received only one request"
  );

  dateNowStub.restore();
}

// Fetching when a VPN is detected should set the suggestion to null, and
// turning off the VPN should trigger a re-fetch.
add_task(async function vpn() {
  // Register a mock object that implements nsINetworkLinkService.
  let mockLinkService = {
    isLinkUp: true,
    linkStatusKnown: true,
    linkType: Ci.nsINetworkLinkService.LINK_TYPE_WIFI,
    networkID: "abcd",
    dnsSuffixList: [],
    platformDNSIndications: Ci.nsINetworkLinkService.NONE_DETECTED,
    QueryInterface: ChromeUtils.generateQI(["nsINetworkLinkService"]),
  };
  let networkLinkServiceCID = MockRegistrar.register(
    "@mozilla.org/network/network-link-service;1",
    mockLinkService
  );
  QuickSuggest.weather._test_linkService = mockLinkService;

  // At this point no VPN is detected, so a fetch should complete successfully.
  await QuickSuggest.weather._test_fetch();
  Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should exist");

  // Modify the mock link service to indicate a VPN is detected.
  mockLinkService.platformDNSIndications =
    Ci.nsINetworkLinkService.VPN_DETECTED;

  // Now a fetch should set the suggestion to null.
  await QuickSuggest.weather._test_fetch();
  Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");

  // Set `weather.ignoreVPN` and fetch again. It should complete successfully.
  UrlbarPrefs.set("weather.ignoreVPN", true);
  await QuickSuggest.weather._test_fetch();
  Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched");

  // Clear the pref and fetch again. It should set the suggestion back to null.
  UrlbarPrefs.clear("weather.ignoreVPN");
  await QuickSuggest.weather._test_fetch();
  Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should be null");

  // Simulate the link status changing. Since the mock link service still
  // indicates a VPN is detected, the suggestion should remain null.
  let fetchPromise = QuickSuggest.weather.waitForFetches();
  QuickSuggest.weather.observe(null, "network:link-status-changed", "changed");
  await fetchPromise;
  Assert.ok(!QuickSuggest.weather.suggestion, "Suggestion should remain null");

  // Modify the mock link service to indicate a VPN is no longer detected.
  mockLinkService.platformDNSIndications =
    Ci.nsINetworkLinkService.NONE_DETECTED;

  // Simulate the link status changing again. The suggestion should be fetched.
  fetchPromise = QuickSuggest.weather.waitForFetches();
  QuickSuggest.weather.observe(null, "network:link-status-changed", "changed");
  await fetchPromise;
  Assert.ok(QuickSuggest.weather.suggestion, "Suggestion should be fetched");

  MockRegistrar.unregister(networkLinkServiceCID);
  delete QuickSuggest.weather._test_linkService;
});

// When a Nimbus experiment is installed, it should override the remote settings
// config.
add_task(async function nimbusOverride() {
  // Sanity check initial state.
  assertEnabled({
    message: "Sanity check initial state",
    hasSuggestion: true,
    pendingFetchCount: 0,
  });

  // Verify a search works as expected with the default remote settings config.
  await check_results({
    context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [makeExpectedResult()],
  });

  // Install an experiment with a different keyword and min length.
  let nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({
    weatherKeywords: ["nimbusoverride"],
    weatherKeywordsMinimumLength: "nimbus".length,
  });

  // The usual default keyword shouldn't match.
  await check_results({
    context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [],
  });

  // The new keyword from Nimbus should match.
  await check_results({
    context: createContext("nimbusoverride", {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [makeExpectedResult()],
  });
  await check_results({
    context: createContext("nimbus", {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [makeExpectedResult()],
  });

  // Uninstall the experiment.
  await nimbusCleanup();

  // The usual default keyword should match again.
  await check_results({
    context: createContext(MerinoTestUtils.WEATHER_KEYWORD, {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [makeExpectedResult()],
  });

  // The keywords from Nimbus shouldn't match anymore.
  await check_results({
    context: createContext("nimbusoverride", {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [],
  });
  await check_results({
    context: createContext("nimbus", {
      providers: [UrlbarProviderWeather.name],
      isPrivate: false,
    }),
    matches: [],
  });
});

function assertEnabled({ message, hasSuggestion, pendingFetchCount }) {
  info("Asserting feature is enabled");
  if (message) {
    info(message);
  }

  Assert.equal(
    !!QuickSuggest.weather.suggestion,
    hasSuggestion,
    "Suggestion is null or non-null as expected"
  );
  Assert.notEqual(
    QuickSuggest.weather._test_fetchTimer,
    0,
    "Fetch timer is non-zero"
  );
  Assert.ok(QuickSuggest.weather._test_merino, "Merino client is non-null");
  Assert.equal(
    QuickSuggest.weather._test_pendingFetchCount,
    pendingFetchCount,
    "Expected pending fetch count"
  );
}

function assertDisabled({ message, pendingFetchCount }) {
  info("Asserting feature is disabled");
  if (message) {
    info(message);
  }

  Assert.strictEqual(
    QuickSuggest.weather.suggestion,
    null,
    "Suggestion is null"
  );
  Assert.strictEqual(
    QuickSuggest.weather._test_fetchTimer,
    0,
    "Fetch timer is zero"
  );
  Assert.strictEqual(
    QuickSuggest.weather._test_merino,
    null,
    "Merino client is null"
  );
  Assert.equal(
    QuickSuggest.weather._test_pendingFetchCount,
    pendingFetchCount,
    "Expected pending fetch count"
  );
}

function makeExpectedResult({
  suggestedIndex = 1,
  temperatureUnit = undefined,
} = {}) {
  if (!temperatureUnit) {
    temperatureUnit =
      Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";
  }

  return {
    suggestedIndex,
    type: UrlbarUtils.RESULT_TYPE.DYNAMIC,
    source: UrlbarUtils.RESULT_SOURCE.SEARCH,
    heuristic: false,
    payload: {
      temperatureUnit,
      url: WEATHER_SUGGESTION.url,
      iconId: "6",
      helpUrl: QuickSuggest.HELP_URL,
      helpL10n: {
        id: UrlbarPrefs.get("resultMenu")
          ? "urlbar-result-menu-learn-more-about-firefox-suggest"
          : "firefox-suggest-urlbar-learn-more",
      },
      isBlockable: true,
      blockL10n: {
        id: UrlbarPrefs.get("resultMenu")
          ? "urlbar-result-menu-dismiss-firefox-suggest"
          : "firefox-suggest-urlbar-block",
      },
      requestId: MerinoTestUtils.server.response.body.request_id,
      source: "merino",
      provider: "accuweather",
      dynamicType: "weather",
      city: WEATHER_SUGGESTION.city_name,
      temperature:
        WEATHER_SUGGESTION.current_conditions.temperature[temperatureUnit],
      currentConditions: WEATHER_SUGGESTION.current_conditions.summary,
      forecast: WEATHER_SUGGESTION.forecast.summary,
      high: WEATHER_SUGGESTION.forecast.high[temperatureUnit],
      low: WEATHER_SUGGESTION.forecast.low[temperatureUnit],
      shouldNavigate: true,
    },
  };
}
