/* 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.
//
// w/r/t weather queries with cities, note that the Suggest Rust component
// handles city/region parsing and has extensive tests for that. Here we need to
// test our geolocation logic, make sure Merino is called with the correct
// city/region, and make sure the urlbar result has the correct city.

"use strict";

ChromeUtils.defineESModuleGetters(this, {
  MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs",
  Region: "resource://gre/modules/Region.sys.mjs",
  UrlbarProviderPlaces:
    "moz-src:///browser/components/urlbar/UrlbarProviderPlaces.sys.mjs",
});

const { WEATHER_SUGGESTION } = MerinoTestUtils;

const EXPECTED_MERINO_PARAMS_WATERLOO_IA = {
  city: "Waterloo",
  region: "IA,013,94597",
  country: "US",
};

const EXPECTED_MERINO_PARAMS_WATERLOO_AL = {
  city: "Waterloo",
  region: "AL,077",
  country: "US",
};

let gWeather;

add_setup(async () => {
  // Weather suggestion titles depend on the current home region, and this test
  // assumes it's the US.
  Region._setHomeRegion("US", true);

  await QuickSuggestTestUtils.ensureQuickSuggestInit({
    prefs: [
      ["suggest.quicksuggest.sponsored", true],
      ["weather.featureGate", true],
    ],
    remoteSettingsRecords: [
      QuickSuggestTestUtils.weatherRecord(),
      ...QuickSuggestTestUtils.geonamesRecords(),
      ...QuickSuggestTestUtils.geonamesAlternatesRecords(),
    ],
  });

  await MerinoTestUtils.initWeather();

  gWeather = QuickSuggest.getFeature("WeatherSuggestions");
});

// The feature should be properly enabled according to relavant prefs.
add_task(async function disableAndEnable() {
  let prefs = [
    "weather.featureGate",
    "suggest.weather",
    "suggest.quicksuggest.all",
    "suggest.quicksuggest.sponsored",
  ];
  for (let pref of prefs) {
    info("Testing pref: " + pref);
    await doBasicDisableAndEnableTest(pref);
  }
});

async function doBasicDisableAndEnableTest(pref) {
  let cleanup = GeolocationTestUtils.stubGeolocation(
    GeolocationTestUtils.SAN_FRANCISCO
  );

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

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

  // Re-enable the feature.
  info("Re-enable the feature");
  UrlbarPrefs.set(pref, true);

  // The suggestion should be returned for a search.
  context = createContext("weather", {
    providers: [UrlbarProviderQuickSuggest.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [QuickSuggestTestUtils.weatherResult()],
  });

  await cleanup();
}

// Tests a Merino fetch that doesn't return a suggestion.
add_task(async function noSuggestion() {
  let { suggestions } = MerinoTestUtils.server.response.body;
  MerinoTestUtils.server.response.body.suggestions = [];

  let context = createContext("weather", {
    providers: [UrlbarProviderQuickSuggest.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [],
  });

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

// When the Merino response doesn't include a `region_code` for the geolocated
// version of the suggestion, the suggestion title should only contain a city.
add_task(async function geolocationSuggestionNoRegion() {
  let cleanup = GeolocationTestUtils.stubGeolocation(
    GeolocationTestUtils.SAN_FRANCISCO
  );

  let { suggestions } = MerinoTestUtils.server.response.body;
  let s = { ...MerinoTestUtils.WEATHER_SUGGESTION };
  delete s.region_code;
  MerinoTestUtils.server.response.body.suggestions = [s];

  let context = createContext("weather", {
    providers: [UrlbarProviderQuickSuggest.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [
      QuickSuggestTestUtils.weatherResult({
        titleL10n: {
          id: "urlbar-result-weather-title-city-only",
          args: {
            city: s.city_name,
          },
        },
      }),
    ],
  });

  MerinoTestUtils.server.response.body.suggestions = suggestions;
  await cleanup();
});

// When the query matches both the weather suggestion and a previous visit to
// the suggestion's URL, the suggestion should be shown and the history visit
// should not be shown.
add_task(async function urlAlreadyInHistory() {
  let cleanup = GeolocationTestUtils.stubGeolocation(
    GeolocationTestUtils.SAN_FRANCISCO
  );

  // A visit to the weather suggestion's exact URL.
  let suggestionVisit = {
    uri: MerinoTestUtils.WEATHER_SUGGESTION.url,
    title: MerinoTestUtils.WEATHER_SUGGESTION.title,
  };

  // A visit to a totally unrelated URL that also matches "weather" just to make
  // sure the Places provider is enabled and returning matches as expected.
  let otherVisit = {
    uri: "https://example.com/some-other-weather-page",
    title: "Some other weather page",
  };

  await PlacesTestUtils.addVisits([suggestionVisit, otherVisit]);

  // First make sure both visit results are matched by doing a search with only
  // the Places provider.
  info("Doing first search");
  let context = createContext("weather", {
    providers: [UrlbarProviderPlaces.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [
      makeVisitResult(context, otherVisit),
      makeVisitResult(context, suggestionVisit),
    ],
  });

  // Now do a search with both the Suggest and Places providers.
  info("Doing second search");
  context = createContext("weather", {
    providers: [UrlbarProviderQuickSuggest.name, UrlbarProviderPlaces.name],
    isPrivate: false,
  });
  await check_results({
    context,
    matches: [
      // The visit result to the unrelated URL will be first since the weather
      // suggestion has a `suggestedIndex` of 1.
      makeVisitResult(context, otherVisit),
      QuickSuggestTestUtils.weatherResult(),
    ],
  });

  await PlacesUtils.history.clear();
  await cleanup();
});

// 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;
  }

  // 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.withRegionAndLocale({
      locale,
      // Weather suggestions are not enabled by default for all regions/locale
      // combinations in this test, so don't reset Suggest so that they remain
      // enabled rather than being set according to region/locale.
      skipSuggestReset: true,
      callback: async () => {
        let cleanup = GeolocationTestUtils.stubGeolocation(
          GeolocationTestUtils.SAN_FRANCISCO
        );

        info("Checking locale: " + locale);
        await check_results({
          context: createContext("weather", {
            providers: [UrlbarProviderQuickSuggest.name],
            isPrivate: false,
          }),
          matches: [QuickSuggestTestUtils.weatherResult({ 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("weather", {
            providers: [UrlbarProviderQuickSuggest.name],
            isPrivate: false,
          }),
          matches: [
            QuickSuggestTestUtils.weatherResult({ temperatureUnit: osUnit }),
          ],
        });
        Services.prefs.clearUserPref("intl.regional_prefs.use_os_locales");

        await cleanup();
      },
    });
  }
}

// Query for country in North America (US), client in same country
//
// Suggestion title should be: "{city}, {region}"
add_task(async function queryForNorthAmerica_clientInSameCountry() {
  await doRegionTest({
    homeRegion: "US",
    locale: "en-US",
    query: "waterloo ia",
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Waterloo",
        region: "IA",
      },
    },
  });
});

// Query for country in North America (US), client in different North American
// country (CA)
//
// Suggestion title should be: "{city}, {region}, {country}"
add_task(async function queryForNorthAmerica_clientInNorthAmerica() {
  await doRegionTest({
    homeRegion: "CA",
    locale: "en-CA",
    query: "waterloo ia",
    expectedTitleL10n: {
      id: "urlbar-result-weather-title-with-country",
      args: {
        city: "Waterloo",
        region: "IA",
        country: "United States",
      },
    },
  });
});

// Query for country in North America (US), client in different country outside
// North America (GB)
//
// Suggestion title should be: "{city}, {region}, {country}"
add_task(async function queryForNorthAmerica_clientOutsideNorthAmerica() {
  await doRegionTest({
    homeRegion: "GB",
    locale: "en-GB",
    query: "waterloo ia",
    expectedTitleL10n: {
      id: "urlbar-result-weather-title-with-country",
      args: {
        city: "Waterloo",
        region: "IA",
        country: "United States",
      },
    },
  });
});

// Query for country outside North America (GB), client in same country
//
// Suggestion title should be: "{city}"
add_task(async function queryOutsideNorthAmerica_clientInSameCountry() {
  await doRegionTest({
    homeRegion: "GB",
    locale: "en-GB",
    query: "liverpool uk",
    expectedTitleL10n: {
      id: "urlbar-result-weather-title-city-only",
      args: {
        city: "Liverpool",
      },
    },
  });
});

// Query for country outside North America (GB), client in North American
// country (US)
//
// Suggestion title should be: "{city}, {region}"
// * `region` should be the country name (GB)
add_task(async function queryOutsideNorthAmerica_clientInNorthAmerica() {
  await doRegionTest({
    homeRegion: "US",
    locale: "en-US",
    query: "liverpool uk",
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Liverpool",
        region: "United Kingdom",
      },
    },
  });
});

// Query for country outside North America (GB), client different country
// outside North America (DE)
//
// Suggestion title should be: "{city}, {region}"
// * `region` should be the country name (GB)
add_task(async function queryOutsideNorthAmerica_clientOutsideNorthAmerica() {
  await doRegionTest({
    homeRegion: "DE",
    locale: "de",
    query: "liverpool uk",
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Liverpool",
        region: "United Kingdom",
      },
    },
  });
});

async function doRegionTest({ homeRegion, locale, query, expectedTitleL10n }) {
  await QuickSuggestTestUtils.withRegionAndLocale({
    locale,
    region: homeRegion,
    // Weather suggestions are not enabled by default for all regions/locale
    // combinations in this test, so don't reset Suggest so that they remain
    // enabled rather than being set according to region/locale.
    skipSuggestReset: true,
    callback: async () => {
      info(
        "Doing region test: " + JSON.stringify({ homeRegion, locale, query })
      );
      await check_results({
        context: createContext(query, {
          providers: [UrlbarProviderQuickSuggest.name],
          isPrivate: false,
        }),
        matches: [
          QuickSuggestTestUtils.weatherResult({
            titleL10n: expectedTitleL10n,
          }),
        ],
      });
    },
  });
}

// Tests dismissal.
add_task(async function dismissal() {
  let cleanup = GeolocationTestUtils.stubGeolocation(
    GeolocationTestUtils.SAN_FRANCISCO
  );

  await doDismissAllTest({
    result: QuickSuggestTestUtils.weatherResult(),
    command: "dismiss",
    feature: QuickSuggest.getFeature("WeatherSuggestions"),
    pref: "suggest.weather",
    queries: [
      {
        query: "weather",
      },
    ],
  });

  await cleanup();
});

// When a Nimbus experiment is installed, it should override the remote settings
// weather record.
add_task(async function nimbusOverride() {
  let cleanup = GeolocationTestUtils.stubGeolocation(
    GeolocationTestUtils.SAN_FRANCISCO
  );
  let defaultResult = QuickSuggestTestUtils.weatherResult();

  // Verify a search works as expected with the default remote settings weather
  // record (which was added in the init task).
  await check_results({
    context: createContext("weather", {
      providers: [UrlbarProviderQuickSuggest.name],
      isPrivate: false,
    }),
    matches: [defaultResult],
  });

  // Install an experiment with a different min keyword length.
  let nimbusCleanup = await UrlbarTestUtils.initNimbusFeature({
    weatherKeywordsMinimumLength: 999,
  });

  // The suggestion shouldn't be returned anymore.
  await check_results({
    context: createContext("weather", {
      providers: [UrlbarProviderQuickSuggest.name],
      isPrivate: false,
    }),
    matches: [],
  });

  // Uninstall the experiment.
  await nimbusCleanup();

  // The suggestion should be returned again.
  await check_results({
    context: createContext("weather", {
      providers: [UrlbarProviderQuickSuggest.name],
      isPrivate: false,
    }),
    matches: [defaultResult],
  });

  await cleanup();
});

// Tests queries that include a city without a region and where Merino does not
// return a geolocation.
add_task(async function cityQueries_noGeo() {
  await doCityTest({
    desc: "Should match most populous Waterloo (Waterloo IA)",
    query: "waterloo",
    geolocation: null,
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_IA,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "IA",
        },
      },
    },
  });
});

// Tests queries that include a city without a region and where Merino returns a
// geolocation with geographic coordinates.
add_task(async function cityQueries_geoCoords() {
  await doCityTest({
    desc: "Coordinates closer to Waterloo IA, so should match it",
    query: "waterloo",
    geolocation: {
      location: {
        latitude: 41.0,
        longitude: -93.0,
      },
    },
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_IA,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "IA",
        },
      },
    },
  });

  await doCityTest({
    desc: "Coordinates closer to Waterloo AL, so should match it",
    query: "waterloo",
    geolocation: {
      location: {
        latitude: 33.0,
        longitude: -87.0,
      },
    },
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_AL,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "AL",
        },
      },
    },
  });

  // This assumes the mock GeoNames data includes "Twin City A" and
  // "Twin City B" and they're <= 5 km apart.
  await doCityTest({
    desc: "When multiple cities are tied for nearest (within the accuracy radius), the most populous one should match",
    query: "weather twin city",
    geolocation: {
      location: {
        latitude: 0.0,
        longitude: 0.0,
        // 5 km radius
        accuracy: 5,
      },
    },
    expected: {
      geolocationCalled: true,
      weatherParams: {
        city: "Twin City B",
        region: "GA",
        country: "US",
      },
      titleL10n: {
        id: "urlbar-result-weather-title-city-only",
        args: {
          city: "Twin City B",
        },
      },
    },
  });
});

// Tests queries that include a city without a region and where Merino returns a
// geolocation with only region and country codes, no geographic coordinates.
add_task(async function cityQueries_geoRegion() {
  await doCityTest({
    desc: "Should match Waterloo IA",
    query: "waterloo",
    geolocation: {
      region_code: "IA",
      country_code: "US",
    },
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_IA,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "IA",
        },
      },
    },
  });

  await doCityTest({
    desc: "Should match Waterloo AL",
    query: "waterloo",
    geolocation: {
      region_code: "AL",
      country_code: "US",
    },
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_AL,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "AL",
        },
      },
    },
  });

  await doCityTest({
    desc: "Rust did not return Waterloo NY, so should match most populous Waterloo (Waterloo IA)",
    query: "waterloo",
    geolocation: {
      region_code: "NY",
      country_code: "US",
    },
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_IA,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "IA",
        },
      },
    },
  });

  await doCityTest({
    desc: "Rust did not return Waterloo ON CA, so should match most populous Waterloo (Waterloo IA)",
    query: "waterloo",
    geolocation: {
      region_code: "08",
      country_code: "CA",
    },
    expected: {
      geolocationCalled: true,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_IA,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "IA",
        },
      },
    },
  });

  await doCityTest({
    desc: "Query matches a US and CA city, geolocation is US, so should match US city",
    query: "us ca city",
    geolocation: {
      region_code: "HI",
      country_code: "US",
    },
    expected: {
      geolocationCalled: true,
      weatherParams: {
        city: "US CA City",
        region: "IA",
        country: "US",
      },
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "US CA City",
          region: "IA",
        },
      },
    },
  });

  await doCityTest({
    desc: "Query matches a US and CA city, geolocation is CA, so should match CA city",
    query: "us ca city",
    geolocation: {
      region_code: "01",
      country_code: "CA",
    },
    expected: {
      geolocationCalled: true,
      weatherParams: {
        city: "US CA City",
        region: "08",
        country: "CA",
      },
      // There isn't a geoname in the data for the region of the CA version of
      // this city, so the city-only title should be used.
      titleL10n: {
        id: "urlbar-result-weather-title-city-only",
        args: {
          city: "US CA City",
        },
      },
    },
  });
});

// Tests queries that include both a city and a region.
add_task(async function cityRegionQueries() {
  await doCityTest({
    desc: "Waterloo IA directly queried",
    query: "waterloo ia",
    geolocation: null,
    expected: {
      geolocationCalled: false,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_IA,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "IA",
        },
      },
    },
  });

  await doCityTest({
    desc: "Waterloo AL directly queried",
    query: "waterloo al",
    geolocation: null,
    expected: {
      geolocationCalled: false,
      weatherParams: EXPECTED_MERINO_PARAMS_WATERLOO_AL,
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: "Waterloo",
          region: "AL",
        },
      },
    },
  });

  await doCityTest({
    desc: "Waterloo NY directly queried, but Rust didn't return Waterloo NY, so no match",
    query: "waterloo ny",
    geolocation: null,
    expected: null,
  });
});

// Tests weather queries that don't include a city.
add_task(async function noCityQuery() {
  let cleanup = GeolocationTestUtils.stubGeolocation(
    GeolocationTestUtils.SAN_FRANCISCO
  );

  await doCityTest({
    desc: "No city in query, so only one call to Merino should be made and Merino does the geolocation internally",
    query: "weather",
    geolocation: null,
    expected: {
      geolocationCalled: false,
      weatherParams: {},
      titleL10n: {
        id: "urlbar-result-weather-title",
        args: {
          city: MerinoTestUtils.WEATHER_SUGGESTION.city_name,
          region: MerinoTestUtils.WEATHER_SUGGESTION.region_code,
        },
      },
    },
  });

  await cleanup();
});

async function doCityTest({
  desc,
  query,
  geolocation,
  expected,
  merinoSuggestion = null,
}) {
  info("Doing city test: " + JSON.stringify({ desc, query }));

  if (expected) {
    expected.weatherParams.q ??= "";
  }

  let callsByProvider = await doSearch({
    query,
    geolocation,
    merinoSuggestion,
    expectedTitleL10n: expected?.titleL10n,
  });

  // Check the Merino calls.
  Assert.equal(
    callsByProvider.geolocation?.length || 0,
    expected?.geolocationCalled ? 1 : 0,
    "geolocation provider should have been called the correct number of times"
  );
  Assert.equal(
    callsByProvider.accuweather?.length || 0,
    expected ? 1 : 0,
    "accuweather provider should have been called the correct number of times"
  );
  if (expected) {
    expected.weatherParams.source = "urlbar";

    for (let [key, value] of Object.entries(expected.weatherParams)) {
      Assert.strictEqual(
        callsByProvider.accuweather[0].get(key),
        value,
        "Weather param should be correct: " + key
      );
    }
  }
}

// `MerinoClient` should cache Merino responses for geolocation and weather.
add_task(async function merinoCache() {
  let query = "waterloo";
  let geolocation = {
    location: {
      latitude: 41.0,
      longitude: -93.0,
    },
  };

  MerinoTestUtils.enableClientCache(true);

  let startDateMs = Date.now();
  let sandbox = sinon.createSandbox();
  let dateNowStub = sandbox.stub(
    Cu.getGlobalForObject(MerinoClient).Date,
    "now"
  );
  dateNowStub.returns(startDateMs);

  // Search 1: Firefox should call Merino for both geolocation and weather and
  // cache the responses.
  info("Doing search 1");
  let callsByProvider = await doSearch({
    query,
    geolocation,
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Waterloo",
        region: "IA",
      },
    },
  });
  info("search 1 callsByProvider: " + JSON.stringify(callsByProvider));
  Assert.equal(
    callsByProvider.geolocation.length,
    1,
    "geolocation provider should have been called on search 1"
  );
  Assert.equal(
    callsByProvider.accuweather.length,
    1,
    "accuweather provider should have been called on search 1"
  );

  // Set the date forward 0.5 minutes, which is shorter than the geolocation
  // cache period of 2 hours and the weather cache period of 1 minute.
  dateNowStub.returns(startDateMs + 0.5 * 60 * 1000);

  // Search 2: Firefox should use the cached responses, so it should not call
  // Merino.
  info("Doing search 2");
  callsByProvider = await doSearch({
    query,
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Waterloo",
        region: "IA",
      },
    },
  });
  info("search 2 callsByProvider: " + JSON.stringify(callsByProvider));
  Assert.ok(
    !callsByProvider.geolocation,
    "geolocation provider should not have been called on search 2"
  );
  Assert.ok(
    !callsByProvider.accuweather,
    "accuweather provider should not have been called on search 2"
  );

  // Set the date forward 1.5 minutes, which is shorter than the geolocation
  // cache period but longer than the weather cache period.
  dateNowStub.returns(startDateMs + 1.5 * 60 * 1000);

  // Search 3: Firefox should call Merino for the weather suggestion but not for
  // geolocation.
  info("Doing search 3");
  callsByProvider = await doSearch({
    query,
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Waterloo",
        region: "IA",
      },
    },
  });
  info("search 3 callsByProvider: " + JSON.stringify(callsByProvider));
  Assert.ok(
    !callsByProvider.geolocation,
    "geolocation provider should not have been called on search 3"
  );
  Assert.equal(
    callsByProvider.accuweather.length,
    1,
    "accuweather provider should have been called on search 3"
  );

  // Set the date forward 1.5 hours that is still shorter than the geolocation
  // period.
  dateNowStub.returns(startDateMs + 1.5 * 60 * 60 * 1000);

  // Search 4: Firefox should still call Merino for the weather suggestion but
  // not for geolocation.
  info("Doing search 4");
  callsByProvider = await doSearch({
    query,
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Waterloo",
        region: "IA",
      },
    },
  });
  info("search 4 callsByProvider: " + JSON.stringify(callsByProvider));
  Assert.ok(
    !callsByProvider.geolocation,
    "geolocation provider should not have been called on search 4"
  );
  Assert.equal(
    callsByProvider.accuweather.length,
    1,
    "accuweather provider should have been called on search 4"
  );

  // Set the date forward 3 hours.
  dateNowStub.returns(startDateMs + 3 * 60 * 60 * 1000);

  // Search 5: Firefox should call Merino for both weather and geolocation.
  info("Doing search 5");
  callsByProvider = await doSearch({
    query,
    expectedTitleL10n: {
      id: "urlbar-result-weather-title",
      args: {
        city: "Waterloo",
        region: "IA",
      },
    },
  });
  info("search 5 callsByProvider: " + JSON.stringify(callsByProvider));
  Assert.equal(
    callsByProvider.geolocation.length,
    1,
    "geolocation provider should have been called on search 5"
  );
  Assert.equal(
    callsByProvider.accuweather.length,
    1,
    "accuweather provider should have been called on search 5"
  );

  sandbox.restore();
  MerinoTestUtils.enableClientCache(false);
});

async function doSearch({
  query,
  geolocation,
  merinoSuggestion,
  expectedTitleL10n,
}) {
  let callsByProvider = {};

  // Set up the Merino request handler.
  MerinoTestUtils.server.requestHandler = req => {
    let params = new URLSearchParams(req.queryString);
    let provider = params.get("providers");
    callsByProvider[provider] ||= [];
    callsByProvider[provider].push(params);

    // Handle geolocation requests.
    if (provider == "geolocation") {
      return {
        body: {
          request_id: "request_id",
          suggestions: !geolocation
            ? []
            : [
                {
                  custom_details: { geolocation },
                },
              ],
        },
      };
    }

    // Handle accuweather requests.
    Assert.equal(
      provider,
      "accuweather",
      "Sanity check: If the request isn't geolocation, it should be accuweather"
    );
    let suggestion = {
      ...WEATHER_SUGGESTION,
      ...(merinoSuggestion ?? {}),
    };
    return {
      body: {
        request_id: "request_id",
        suggestions: [suggestion],
      },
    };
  };

  // Do a search.
  await check_results({
    context: createContext(query, {
      providers: [UrlbarProviderQuickSuggest.name],
      isPrivate: false,
    }),
    matches: !expectedTitleL10n
      ? []
      : [
          QuickSuggestTestUtils.weatherResult({
            titleL10n: expectedTitleL10n,
          }),
        ],
  });

  MerinoTestUtils.server.requestHandler = null;
  return callsByProvider;
}

function assertDisabled({ message }) {
  info("Asserting feature is disabled");
  if (message) {
    info(message);
  }
  Assert.strictEqual(gWeather._test_merino, null, "Merino client is null");
}
