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

import { SuggestProvider } from "moz-src:///browser/components/urlbar/private/SuggestFeature.sys.mjs";

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  GeolocationUtils:
    "moz-src:///browser/components/urlbar/private/GeolocationUtils.sys.mjs",
  MerinoClient: "moz-src:///browser/components/urlbar/MerinoClient.sys.mjs",
  QuickSuggest: "moz-src:///browser/components/urlbar/QuickSuggest.sys.mjs",
  Region: "resource://gre/modules/Region.sys.mjs",
  UrlbarPrefs: "moz-src:///browser/components/urlbar/UrlbarPrefs.sys.mjs",
  UrlbarResult: "moz-src:///browser/components/urlbar/UrlbarResult.sys.mjs",
  UrlbarUtils: "moz-src:///browser/components/urlbar/UrlbarUtils.sys.mjs",
  UrlbarView: "moz-src:///browser/components/urlbar/UrlbarView.sys.mjs",
});

const MERINO_PROVIDER = "accuweather";
const MERINO_TIMEOUT_MS = 5000; // 5s

// Cache period for Merino's weather response. This is intentionally a small
// amount of time. See the `cachePeriodMs` discussion in `MerinoClient`. In
// addition, caching also helps prevent the weather suggestion from flickering
// out of and into the view as the user matches the same suggestion with each
// keystroke, especially when Merino has high latency.
const MERINO_WEATHER_CACHE_PERIOD_MS = 60000; // 1 minute

const RESULT_MENU_COMMAND = {
  DISMISS: "dismiss",
  HELP: "help",
  INACCURATE_LOCATION: "inaccurate_location",
  MANAGE: "manage",
  SHOW_LESS_FREQUENTLY: "show_less_frequently",
};

const WEATHER_PROVIDER_DISPLAY_NAME = "AccuWeather®";

const NORTH_AMERICA_COUNTRY_CODES = new Set(["CA", "US"]);

const WEATHER_DYNAMIC_TYPE = "weather";
const WEATHER_VIEW_TEMPLATE = {
  attributes: {
    selectable: true,
  },
  children: [
    {
      name: "currentConditions",
      tag: "span",
      children: [
        {
          name: "currently",
          tag: "div",
        },
        {
          name: "currentTemperature",
          tag: "div",
          children: [
            {
              name: "temperature",
              tag: "span",
            },
            {
              name: "weatherIcon",
              tag: "img",
            },
          ],
        },
      ],
    },
    {
      name: "summary",
      tag: "span",
      overflowable: true,
      children: [
        {
          name: "top",
          tag: "div",
          children: [
            {
              name: "topNoWrap",
              tag: "span",
              children: [
                { name: "title", tag: "span", classList: ["urlbarView-title"] },
                {
                  name: "titleSeparator",
                  tag: "span",
                  classList: ["urlbarView-title-separator"],
                },
              ],
            },
            {
              name: "url",
              tag: "span",
              classList: ["urlbarView-url"],
            },
          ],
        },
        {
          name: "middle",
          tag: "div",
          children: [
            {
              name: "middleNoWrap",
              tag: "span",
              overflowable: true,
              children: [
                {
                  name: "summaryText",
                  tag: "span",
                },
                {
                  name: "summaryTextSeparator",
                  tag: "span",
                },
                {
                  name: "highLow",
                  tag: "span",
                },
              ],
            },
            {
              name: "highLowWrap",
              tag: "span",
            },
          ],
        },
        {
          name: "bottom",
          tag: "div",
        },
      ],
    },
  ],
};

/**
 * A feature that periodically fetches weather suggestions from Merino.
 */
export class WeatherSuggestions extends SuggestProvider {
  constructor() {
    super();
    lazy.UrlbarResult.addDynamicResultType(WEATHER_DYNAMIC_TYPE);
    lazy.UrlbarView.addDynamicViewTemplate(
      WEATHER_DYNAMIC_TYPE,
      WEATHER_VIEW_TEMPLATE
    );
  }

  get enablingPreferences() {
    return [
      "weatherFeatureGate",
      "suggest.weather",
      "suggest.quicksuggest.all",
      "suggest.quicksuggest.sponsored",
    ];
  }

  get primaryUserControlledPreferences() {
    return ["suggest.weather"];
  }

  get rustSuggestionType() {
    return "Weather";
  }

  get showLessFrequentlyCount() {
    const count = lazy.UrlbarPrefs.get("weather.showLessFrequentlyCount") || 0;
    return Math.max(count, 0);
  }

  get canShowLessFrequently() {
    const cap =
      lazy.UrlbarPrefs.get("weatherShowLessFrequentlyCap") ||
      lazy.QuickSuggest.config.showLessFrequentlyCap ||
      0;
    return !cap || this.showLessFrequentlyCount < cap;
  }

  isSuggestionSponsored(_suggestion) {
    return true;
  }

  getSuggestionTelemetryType() {
    return "weather";
  }

  enable(enabled) {
    if (!enabled) {
      this.#merino = null;
    }
  }

  async filterSuggestions(suggestions) {
    // If the query didn't include a city, Rust will return at most one
    // suggestion. If the query matched multiple cities, Rust will return one
    // suggestion per city. All suggestions will have the same score, and
    // they'll be ordered by population size from largest to smallest.
    if (suggestions.length <= 1) {
      return suggestions;
    }

    let suggestion = await lazy.GeolocationUtils.best(suggestions, s => ({
      latitude: s.city?.latitude,
      longitude: s.city?.longitude,
      country: s.city?.countryCode,
      region: s.city?.adminDivisionCodes.get(1),
      population: s.city?.population,
    }));

    return [suggestion];
  }

  async makeResult(queryContext, suggestion, searchString) {
    if (searchString.length < this.#minKeywordLength) {
      return null;
    }

    // `suggestion` is a Rust suggestion that tells us weather intent was
    // matched and possibly a city. Fetch the final suggestion from Merino.
    let merinoSuggestion = await this.#fetchMerinoSuggestion(suggestion.city);
    if (!merinoSuggestion) {
      return null;
    }

    let unit = Services.locale.regionalPrefsLocales[0] == "en-US" ? "f" : "c";

    let treatment = lazy.UrlbarPrefs.get("weatherUiTreatment");
    if (treatment == 1 || treatment == 2) {
      return this.#makeDynamicResult(merinoSuggestion, unit);
    }

    let titleL10n = await this.#getTitleL10n(suggestion.city, merinoSuggestion);

    return new lazy.UrlbarResult({
      type: lazy.UrlbarUtils.RESULT_TYPE.URL,
      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
      suggestedIndex: 1,
      isRichSuggestion: true,
      richSuggestionIconVariation: String(
        merinoSuggestion.current_conditions.icon_id
      ),
      payload: {
        url: merinoSuggestion.url,
        titleL10n: {
          id: titleL10n.id,
          args: {
            temperature: merinoSuggestion.current_conditions.temperature[unit],
            unit: unit.toUpperCase(),
            ...titleL10n.args,
          },
          parseMarkup: true,
        },
        bottomTextL10n: {
          id: "urlbar-result-weather-provider-sponsored",
          args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
        },
        helpUrl: lazy.QuickSuggest.HELP_URL,
      },
    });
  }

  #makeDynamicResult(suggestion, unit) {
    return new lazy.UrlbarResult({
      type: lazy.UrlbarUtils.RESULT_TYPE.DYNAMIC,
      source: lazy.UrlbarUtils.RESULT_SOURCE.SEARCH,
      showFeedbackMenu: true,
      suggestedIndex: 1,
      payload: {
        url: suggestion.url,
        input: suggestion.url,
        iconId: suggestion.current_conditions.icon_id,
        dynamicType: WEATHER_DYNAMIC_TYPE,
        city: suggestion.city_name,
        region: suggestion.region_code,
        temperatureUnit: unit,
        temperature: suggestion.current_conditions.temperature[unit],
        currentConditions: suggestion.current_conditions.summary,
        forecast: suggestion.forecast.summary,
        high: suggestion.forecast.high[unit],
        low: suggestion.forecast.low[unit],
        showRowLabel: true,
        helpUrl: lazy.QuickSuggest.HELP_URL,
      },
    });
  }

  getViewUpdate(result) {
    let useSimplerUi = lazy.UrlbarPrefs.get("weatherUiTreatment") == 1;
    let uppercaseUnit = result.payload.temperatureUnit.toUpperCase();
    return {
      currently: {
        l10n: {
          id: "firefox-suggest-weather-currently",
        },
      },
      temperature: {
        l10n: {
          id: "firefox-suggest-weather-temperature",
          args: {
            value: result.payload.temperature,
            unit: uppercaseUnit,
          },
        },
      },
      weatherIcon: {
        attributes: { "icon-variation": result.payload.iconId },
      },
      title: {
        l10n: {
          id: "firefox-suggest-weather-title",
          args: { city: result.payload.city, region: result.payload.region },
        },
      },
      url: {
        textContent: result.payload.url,
      },
      summaryText: useSimplerUi
        ? { textContent: result.payload.currentConditions }
        : {
            l10n: {
              id: "firefox-suggest-weather-summary-text",
              args: {
                currentConditions: result.payload.currentConditions,
                forecast: result.payload.forecast,
              },
            },
          },
      highLow: {
        l10n: {
          id: "firefox-suggest-weather-high-low",
          args: {
            high: result.payload.high,
            low: result.payload.low,
            unit: uppercaseUnit,
          },
        },
      },
      highLowWrap: {
        l10n: {
          id: "firefox-suggest-weather-high-low",
          args: {
            high: result.payload.high,
            low: result.payload.low,
            unit: uppercaseUnit,
          },
        },
      },
      bottom: {
        l10n: {
          id: "urlbar-result-weather-provider-sponsored",
          args: { provider: WEATHER_PROVIDER_DISPLAY_NAME },
        },
      },
    };
  }

  /**
   * Gets the list of commands that should be shown in the result menu for a
   * given result from the provider. All commands returned by this method should
   * be handled by implementing `onEngagement()` with the possible exception of
   * commands automatically handled by the urlbar, like "help".
   */
  getResultCommands() {
    /** @type {UrlbarResultCommand[]} */
    let commands = [
      {
        name: RESULT_MENU_COMMAND.INACCURATE_LOCATION,
        l10n: {
          id: "urlbar-result-menu-report-inaccurate-location",
        },
      },
    ];

    if (this.canShowLessFrequently) {
      commands.push({
        name: RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY,
        l10n: {
          id: "urlbar-result-menu-show-less-frequently",
        },
      });
    }

    commands.push(
      {
        name: RESULT_MENU_COMMAND.DISMISS,
        l10n: {
          id: "urlbar-result-menu-dont-show-weather-suggestions",
        },
      },
      { name: "separator" },
      {
        name: RESULT_MENU_COMMAND.MANAGE,
        l10n: {
          id: "urlbar-result-menu-manage-firefox-suggest",
        },
      },
      {
        name: RESULT_MENU_COMMAND.HELP,
        l10n: {
          id: "urlbar-result-menu-learn-more-about-firefox-suggest",
        },
      }
    );

    return commands;
  }

  onEngagement(queryContext, controller, details, searchString) {
    let { result } = details;
    switch (details.selType) {
      case RESULT_MENU_COMMAND.HELP:
      case RESULT_MENU_COMMAND.MANAGE:
        // "help" and "manage" are handled by UrlbarInput, no need to do
        // anything here.
        break;
      // Note that selType == "dismiss" when the user presses the dismiss key
      // shortcut, in addition to the result menu command.
      case RESULT_MENU_COMMAND.DISMISS:
        this.logger.info("Dismissing weather result");
        lazy.UrlbarPrefs.set("suggest.weather", false);
        result.acknowledgeDismissalL10n = {
          id: "urlbar-dismissal-acknowledgment-weather",
        };
        controller.removeResult(result);
        break;
      case RESULT_MENU_COMMAND.INACCURATE_LOCATION:
        // Currently the only way we record this feedback is in the Glean
        // engagement event. As with all commands, it will be recorded with an
        // `engagement_type` value that is the command's name, in this case
        // `inaccurate_location`.
        controller.view.acknowledgeFeedback(result);
        break;
      case RESULT_MENU_COMMAND.SHOW_LESS_FREQUENTLY:
        controller.view.acknowledgeFeedback(result);
        this.incrementShowLessFrequentlyCount();
        if (!this.canShowLessFrequently) {
          controller.view.invalidateResultMenuCommands();
        }
        lazy.UrlbarPrefs.set(
          "weather.minKeywordLength",
          searchString.length + 1
        );
        break;
    }
  }

  incrementShowLessFrequentlyCount() {
    if (this.canShowLessFrequently) {
      lazy.UrlbarPrefs.set(
        "weather.showLessFrequentlyCount",
        this.showLessFrequentlyCount + 1
      );
    }
  }

  get #config() {
    let { rustBackend } = lazy.QuickSuggest;
    let config = rustBackend.isEnabled
      ? rustBackend.getConfigForSuggestionType(this.rustSuggestionType)
      : null;
    return config || {};
  }

  get #minKeywordLength() {
    // Use the pref value if it has a user value, which means the user clicked
    // "Show less frequently" at least once. Otherwise, fall back to the Nimbus
    // value and then the config value. That lets us override the pref's default
    // value using Nimbus or the config, if necessary.
    let minLength = lazy.UrlbarPrefs.get("weather.minKeywordLength");
    if (
      !Services.prefs.prefHasUserValue(
        "browser.urlbar.weather.minKeywordLength"
      )
    ) {
      let nimbusValue = lazy.UrlbarPrefs.get("weatherKeywordsMinimumLength");
      if (nimbusValue !== null) {
        minLength = nimbusValue;
      } else if (!isNaN(this.#config.minKeywordLength)) {
        minLength = this.#config.minKeywordLength;
      }
    }
    return Math.max(minLength, 0);
  }

  async #fetchMerinoSuggestion(cityGeoname) {
    if (!this.#merino) {
      this.#merino = new lazy.MerinoClient(this.constructor.name, {
        allowOhttp: true,
        cachePeriodMs: MERINO_WEATHER_CACHE_PERIOD_MS,
      });
    }

    let merino = this.#merino;
    let fetchInstance = (this.#fetchInstance = {});

    // Set up location params to pass to Merino. We need to null-check each
    // suggestion property because `MerinoClient` will stringify null values.
    let otherParams = { source: "urlbar" };
    if (cityGeoname) {
      if (cityGeoname.name) {
        otherParams.city = cityGeoname.name;
      }
      if (cityGeoname.countryCode) {
        otherParams.country = cityGeoname.countryCode;
      }
      // The admin codes are a `Map` from integer levels to codes. Convert it to
      // a comma-separated string of codes sorted by level ascending.
      let adminCodes = [...cityGeoname.adminDivisionCodes.entries()]
        .sort(([level1, _admin1], [level2, _admin2]) => level1 - level2)
        .map(([_, admin]) => admin)
        .join(",");
      if (adminCodes) {
        otherParams.region = adminCodes;
      }
    } else {
      let geolocation = await lazy.GeolocationUtils.geolocation();

      if (
        !geolocation ||
        fetchInstance != this.#fetchInstance ||
        merino != this.#merino
      ) {
        return null;
      }

      if (geolocation.country_code) {
        otherParams.country = geolocation.country_code;
      }
      let region = geolocation.region_code || geolocation.region;
      if (region) {
        otherParams.region = region;
      }
      let city = geolocation.city || geolocation.region;
      if (city) {
        otherParams.city = city;
      }
    }

    let merinoSuggestions = await merino.fetch({
      query: "",
      otherParams,
      providers: [MERINO_PROVIDER],
      timeoutMs: this.#timeoutMs,
    });
    if (fetchInstance != this.#fetchInstance || merino != this.#merino) {
      return null;
    }

    return merinoSuggestions[0] ?? null;
  }

  async #getTitleL10n(cityGeoname, merinoSuggestion) {
    let displayCity = "";
    let displayRegion = "";
    let displayCountry = "";

    if (!cityGeoname) {
      displayCity = merinoSuggestion.city_name;
      displayRegion = merinoSuggestion.region_code;
    } else {
      // Fetch localized names for the city.
      let alts =
        await lazy.QuickSuggest.rustBackend.fetchGeonameAlternates(cityGeoname);

      displayCity = alts.geoname.localized || alts.geoname.primary;

      // For cities in Canada and the US, always show the province/state using
      // its usual two-char abbreviation. For other countries we won't show any
      // admin divisions at all; there's maybe room for improvement here.
      if (NORTH_AMERICA_COUNTRY_CODES.has(cityGeoname.countryCode)) {
        displayRegion =
          alts.adminDivisions.get(1)?.abbreviation ||
          alts.adminDivisions.get(1)?.localized ||
          alts.adminDivisions.get(1)?.primary;
      }

      // If the city's country is different from the user's, show it.
      if (cityGeoname.countryCode != lazy.Region.home) {
        displayCountry = alts.country?.localized || alts.country?.primary;
      }
    }

    if (displayRegion && displayCountry) {
      return {
        id: "urlbar-result-weather-title-with-country",
        args: {
          city: displayCity,
          region: displayRegion,
          country: displayCountry,
        },
      };
    }

    // This is a little confusing but if we only have a country, show it as the
    // "region". Don't get hung up on the name of this l10n string variable. It
    // just means the final string will be "{city}, {country}".
    let region = displayRegion || displayCountry;
    if (region) {
      return {
        id: "urlbar-result-weather-title",
        args: {
          region,
          city: displayCity,
        },
      };
    }

    return {
      id: "urlbar-result-weather-title-city-only",
      args: {
        city: displayCity,
      },
    };
  }

  get _test_merino() {
    return this.#merino;
  }

  _test_setTimeoutMs(ms) {
    this.#timeoutMs = ms < 0 ? MERINO_TIMEOUT_MS : ms;
  }

  #fetchInstance = null;
  #merino = null;
  #timeoutMs = MERINO_TIMEOUT_MS;
}
