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

const lazy = {};

ChromeUtils.defineESModuleGetters(lazy, {
  AddonManager: "resource://gre/modules/AddonManager.sys.mjs",
  AddonRepository: "resource://gre/modules/addons/AddonRepository.sys.mjs",
});

if (Services.appinfo.processType !== Services.appinfo.PROCESS_TYPE_DEFAULT) {
  // This check ensures that the `mockable` API calls can be consisently mocked in tests.
  // If this requirement needs to be eased, please ensure the test logic remains valid.
  throw new Error("This code is assumed to run in the parent process.");
}

/**
 * Attempts to find an appropriate langpack for a given language. The async function
 * is infallible, but may not return a langpack.
 *
 * @returns {{
 *   langPack: LangPack | null,
 *   langPackDisplayName: string | null
 * }}
 */
async function negotiateLangPackForLanguageMismatch() {
  const localeInfo = getAppAndSystemLocaleInfo();
  const nullResult = {
    langPack: null,
    langPackDisplayName: null,
  };
  if (!localeInfo.systemLocale) {
    // The system locale info was not valid.
    return nullResult;
  }

  /**
   * Fetch the available langpacks from AMO.
   *
   * @type {Array<LangPack>}
   */
  const availableLangpacks = await mockable.getAvailableLangpacks();
  if (!availableLangpacks) {
    return nullResult;
  }

  /**
   * Figure out a langpack to recommend.
   * @type {LangPack | null}
   */
  const langPack =
    // First look for a langpack that matches the baseName, which may include a script.
    // e.g. system "fr-FR" matches langpack "fr-FR"
    //      system "en-GB" matches langpack "en-GB".
    //      system "zh-Hant-CN" matches langpack "zh-Hant-CN".
    availableLangpacks.find(
      ({ target_locale }) => target_locale === localeInfo.systemLocale.baseName
    ) ||
    // Next try matching language and region while excluding script
    // e.g. system "zh-Hant-TW" matches langpack "zh-TW" but not "zh-CN".
    availableLangpacks.find(
      ({ target_locale }) =>
        target_locale ===
        `${localeInfo.systemLocale.language}-${localeInfo.systemLocale.region}`
    ) ||
    // Next look for langpacks that just match the language.
    // e.g. system "fr-FR" matches langpack "fr".
    //      system "en-AU" matches langpack "en".
    availableLangpacks.find(
      ({ target_locale }) => target_locale === localeInfo.systemLocale.language
    ) ||
    // Next look for a langpack that matches the language, but not the region.
    // e.g. "es-CL" (Chilean Spanish) as a system language matching
    //      "es-ES" (European Spanish)
    availableLangpacks.find(({ target_locale }) =>
      target_locale.startsWith(`${localeInfo.systemLocale.language}-`)
    ) ||
    null;

  if (!langPack) {
    return nullResult;
  }

  return {
    langPack,
    langPackDisplayName: Services.intl.getLocaleDisplayNames(
      undefined,
      [langPack.target_locale],
      { preferNative: true }
    )[0],
  };
}

// If a langpack is being installed, allow blocking on that.
let installingLangpack = new Map();

/**
 * @typedef {LangPack}
 * @type {object}
 * @property {string} target_locale
 * @property {string} url
 * @property {string} hash
 */

/**
 * Ensure that a given lanpack is installed.
 *
 * @param {LangPack} langPack
 * @returns {Promise<boolean>} Success or failure.
 */
function ensureLangPackInstalled(langPack) {
  if (!langPack) {
    throw new Error("Expected a LangPack to install.");
  }
  // Make sure any outstanding calls get resolved before attempting another call.
  // This guards against any quick page refreshes attempting to install the langpack
  // twice.
  const inProgress = installingLangpack.get(langPack.hash);
  if (inProgress) {
    return inProgress;
  }
  const promise = _ensureLangPackInstalledImpl(langPack);
  installingLangpack.set(langPack.hash, promise);
  promise.finally(() => {
    installingLangpack.delete(langPack.hash);
  });
  return promise;
}

/**
 * @param {LangPack} langPack
 * @returns {boolean} Success or failure.
 */
async function _ensureLangPackInstalledImpl(langPack) {
  const availablelocales = await getAvailableLocales();
  if (availablelocales.includes(langPack.target_locale)) {
    // The langpack is already installed.
    return true;
  }

  return mockable.installLangPack(langPack);
}

/**
 * These are all functions with side effects or configuration options that should be
 * mockable for tests.
 */
const mockable = {
  /**
   * @returns {LangPack[] | null}
   */
  async getAvailableLangpacks() {
    try {
      return lazy.AddonRepository.getAvailableLangpacks();
    } catch (error) {
      console.error(
        `Failed to get the list of available language packs: ${error?.message}`
      );
      return null;
    }
  },

  /**
   * Use the AddonManager to install an addon from the URL.
   * @param {LangPack} langPack
   */
  async installLangPack(langPack) {
    let install;
    try {
      install = await lazy.AddonManager.getInstallForURL(langPack.url, {
        hash: langPack.hash,
        telemetryInfo: {
          source: "about:welcome",
        },
      });
    } catch (error) {
      console.error(error);
      return false;
    }

    try {
      await install.install();
    } catch (error) {
      console.error(error);
      return false;
    }
    return true;
  },

  /**
   * Returns the available locales, including the fallback locale, which may not include
   * all of the resources, in cases where the defaultLocale is not "en-US".
   *
   * @returns {string[]}
   */
  getAvailableLocalesIncludingFallback() {
    return Services.locale.availableLocales;
  },

  /**
   * @returns {string}
   */
  getDefaultLocale() {
    return Services.locale.defaultLocale;
  },

  /**
   * @returns {string}
   */
  getLastFallbackLocale() {
    return Services.locale.lastFallbackLocale;
  },

  /**
   * @returns {string}
   */
  getAppLocaleAsBCP47() {
    return Services.locale.appLocaleAsBCP47;
  },

  /**
   * @returns {string}
   */
  getSystemLocale() {
    // Allow the system locale to be overridden for manual testing.
    const systemLocaleOverride = Services.prefs.getCharPref(
      "intl.multilingual.aboutWelcome.systemLocaleOverride",
      null
    );
    if (systemLocaleOverride) {
      try {
        // If the locale can't be parsed, ignore the pref.
        new Services.intl.Locale(systemLocaleOverride);
        return systemLocaleOverride;
      } catch (_error) {}
    }

    const osPrefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(
      Ci.mozIOSPreferences
    );
    return osPrefs.systemLocale;
  },

  /**
   * @param {string[]} locales The BCP 47 locale identifiers.
   */
  setRequestedAppLocales(locales) {
    Services.locale.requestedLocales = locales;
  },
};

/**
 * This function is really only setting `Services.locale.requestedLocales`, but it's
 * using the `mockable` object to allow this behavior to be mocked in tests.
 *
 * @param {string[]} locales The BCP 47 locale identifiers.
 */
function setRequestedAppLocales(locales) {
  mockable.setRequestedAppLocales(locales);
}

/**
 * A serializable Intl.Locale.
 *
 * @typedef StructuredLocale
 * @type {object}
 * @property {string} baseName
 * @property {string} language
 * @property {string} region
 */

/**
 * In telemetry data, some of the system locales show up as blank. Guard against this
 * and any other malformed locale information provided by the system by wrapping the call
 * into a catch/try.
 *
 * @param {string} locale
 * @returns {StructuredLocale | null}
 */
function getStructuredLocaleOrNull(localeString) {
  try {
    const locale = new Services.intl.Locale(localeString);
    return {
      baseName: locale.baseName,
      language: locale.language,
      region: locale.region,
    };
  } catch (_err) {
    return null;
  }
}

/**
 * Determine the system and app locales, and how much the locales match.
 *
 * @returns {{
 *  systemLocale: StructuredLocale,
 *  appLocale: StructuredLocale,
 *  matchType: "unknown" | "language-mismatch" | "region-mismatch" | "match",
 * }}
 */
function getAppAndSystemLocaleInfo() {
  // Convert locale strings into structured locale objects.
  const systemLocaleRaw = mockable.getSystemLocale();
  const appLocaleRaw = mockable.getAppLocaleAsBCP47();

  const systemLocale = getStructuredLocaleOrNull(systemLocaleRaw);
  const appLocale = getStructuredLocaleOrNull(appLocaleRaw);

  let matchType = "unknown";
  if (systemLocale && appLocale) {
    if (systemLocale.language !== appLocale.language) {
      matchType = "language-mismatch";
    } else if (systemLocale.region !== appLocale.region) {
      matchType = "region-mismatch";
    } else {
      matchType = "match";
    }
  }

  // Live reloading with bidi switching may not be supported.
  let canLiveReload = null;
  if (systemLocale && appLocale) {
    const systemDirection = Services.intl.getScriptDirection(
      systemLocale.language
    );
    const appDirection = Services.intl.getScriptDirection(appLocale.language);
    const supportsBidiSwitching = Services.prefs.getBoolPref(
      "intl.multilingual.liveReloadBidirectional",
      false
    );
    canLiveReload = systemDirection === appDirection || supportsBidiSwitching;
  }
  return {
    // Return the Intl.Locale in a serializable form.
    systemLocaleRaw,
    systemLocale,
    appLocaleRaw,
    appLocale,
    matchType,
    canLiveReload,

    // These can be used as Fluent message args.
    displayNames: {
      systemLanguage: systemLocale
        ? Services.intl.getLocaleDisplayNames(
            undefined,
            [systemLocale.baseName],
            { preferNative: true }
          )[0]
        : null,
      appLanguage: appLocale
        ? Services.intl.getLocaleDisplayNames(undefined, [appLocale.baseName], {
            preferNative: true,
          })[0]
        : null,
    },
  };
}

/**
 * Filter the lastFallbackLocale from availableLocales if it doesn't have all
 * of the needed strings.
 *
 * When the lastFallbackLocale isn't the defaultLocale, then by default only
 * fluent strings are included. To fully use that locale you need the langpack
 * to be installed, so if it isn't installed remove it from availableLocales.
 */
async function getAvailableLocales() {
  const availableLocales = mockable.getAvailableLocalesIncludingFallback();
  const defaultLocale = mockable.getDefaultLocale();
  const lastFallbackLocale = mockable.getLastFallbackLocale();
  // If defaultLocale isn't lastFallbackLocale, then we still need the langpack
  // for lastFallbackLocale for it to be useful.
  if (defaultLocale != lastFallbackLocale) {
    let lastFallbackId = `langpack-${lastFallbackLocale}@firefox.mozilla.org`;
    let lastFallbackInstalled =
      await lazy.AddonManager.getAddonByID(lastFallbackId);
    if (!lastFallbackInstalled) {
      return availableLocales.filter(locale => locale != lastFallbackLocale);
    }
  }
  return availableLocales;
}

export var LangPackMatcher = {
  negotiateLangPackForLanguageMismatch,
  ensureLangPackInstalled,
  getAppAndSystemLocaleInfo,
  setRequestedAppLocales,
  getAvailableLocales,
  mockable,
};
