/* 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 mozIntlHelper = Cc["@mozilla.org/mozintlhelper;1"].getService(
  Ci.mozIMozIntlHelper
);
const osPrefs = Cc["@mozilla.org/intl/ospreferences;1"].getService(
  Ci.mozIOSPreferences
);

/**
 * RegExp used to parse variant subtags from a BCP47 language tag.
 * For example: ca-valencia
 */
const variantSubtagsMatch = /(?:-(?:[a-z0-9]{5,8}|[0-9][a-z0-9]{3}))+$/;

function getDateTimePatternStyle(option) {
  switch (option) {
    case "full":
      return osPrefs.dateTimeFormatStyleFull;
    case "long":
      return osPrefs.dateTimeFormatStyleLong;
    case "medium":
      return osPrefs.dateTimeFormatStyleMedium;
    case "short":
      return osPrefs.dateTimeFormatStyleShort;
    default:
      return osPrefs.dateTimeFormatStyleNone;
  }
}

/**
 * Number of milliseconds in other time units.
 *
 * This is used by relative time format best unit
 * calculations.
 */
const second = 1e3;
const minute = 6e4;
const hour = 36e5;
const day = 864e5;

/**
 * Use by RelativeTimeFormat.
 *
 * Allows for defining a cached getter to perform
 * calculations only once.
 *
 * @param {object} obj - Object to place the getter on.
 * @param {string} prop - Name of the property.
 * @param {Function} get - Function that will be used as a getter.
 */
function defineCachedGetter(obj, prop, get) {
  defineGetter(obj, prop, function () {
    if (!this._[prop]) {
      this._[prop] = get.call(this);
    }
    return this._[prop];
  });
}

/**
 * Used by RelativeTimeFormat.
 *
 * Defines a getter on an object
 *
 * @param {object} obj - Object to place the getter on.
 * @param {string} prop - Name of the property.
 * @param {Function} get - Function that will be used as a getter.
 */
function defineGetter(obj, prop, get) {
  Object.defineProperty(obj, prop, { get, enumerable: true });
}

/**
 * Used by RelativeTimeFormat.
 *
 * Allows for calculation of the beginning of
 * a period for discrete distances.
 *
 * @param {Date} date - Date of which we're looking to find a start of.
 * @param {string} unit - Period to calculate the start of.
 *
 * @returns {Date}
 */
function startOf(date, unit) {
  date = new Date(date.getTime());
  switch (unit) {
    case "year":
      date.setMonth(0);
    // falls through
    case "month":
      date.setDate(1);
    // falls through
    case "day":
      date.setHours(0);
    // falls through
    case "hour":
      date.setMinutes(0);
    // falls through
    case "minute":
      date.setSeconds(0);
    // falls through
    case "second":
      date.setMilliseconds(0);
  }
  return date;
}

/**
 * Used by RelativeTimeFormat.
 *
 * Calculates the best fit unit to use for an absolute diff distance based
 * on thresholds.
 *
 * @param {object} absDiff - Object with absolute diff per unit calculated.
 *
 * @returns {string}
 */
function bestFit(absDiff) {
  switch (true) {
    case absDiff.years > 0 && absDiff.months > threshold.month:
      return "year";
    case absDiff.months > 0 && absDiff.weeks > threshold.week:
      return "month";
    case absDiff.weeks > 0 && absDiff.days > threshold.day:
      return "week";
    case absDiff.days > 0 && absDiff.hours > threshold.hour:
      return "day";
    case absDiff.hours > 0 && absDiff.minutes > threshold.minute:
      return "hour";
    case absDiff.minutes > 0 && absDiff.seconds > threshold.second:
      return "minute";
    default:
      return "second";
  }
}

/**
 * Used by RelativeTimeFormat.
 *
 * Thresholds to use for calculating the best unit for relative time fromatting.
 */
const threshold = {
  month: 11, // at least 11 months before using year.
  week: 3, // at least 3 weeks before using month.
  day: 6, // at least 6 days before using week.
  hour: 6, // at least 6 hours before using day.
  minute: 59, // at least 59 minutes before using hour.
  second: 59, // at least 59 seconds before using minute.
};

/**
 * Notice: If you're updating this list, you should also
 *         update the list in
 *         languageNames.ftl and regionNames.ftl.
 */
const availableLocaleDisplayNames = {
  region: new Set([
    "ad",
    "ae",
    "af",
    "ag",
    "ai",
    "al",
    "am",
    "ao",
    "aq",
    "ar",
    "as",
    "at",
    "au",
    "aw",
    "az",
    "ba",
    "bb",
    "bd",
    "be",
    "bf",
    "bg",
    "bh",
    "bi",
    "bj",
    "bl",
    "bm",
    "bn",
    "bo",
    "bq",
    "br",
    "bs",
    "bt",
    "bv",
    "bw",
    "by",
    "bz",
    "ca",
    "cc",
    "cd",
    "cf",
    "cg",
    "ch",
    "ci",
    "ck",
    "cl",
    "cm",
    "cn",
    "co",
    "cp",
    "cr",
    "cu",
    "cv",
    "cw",
    "cx",
    "cy",
    "cz",
    "de",
    "dg",
    "dj",
    "dk",
    "dm",
    "do",
    "dz",
    "ec",
    "ee",
    "eg",
    "eh",
    "er",
    "es",
    "et",
    "fi",
    "fj",
    "fk",
    "fm",
    "fo",
    "fr",
    "ga",
    "gb",
    "gd",
    "ge",
    "gf",
    "gg",
    "gh",
    "gi",
    "gl",
    "gm",
    "gn",
    "gp",
    "gq",
    "gr",
    "gs",
    "gt",
    "gu",
    "gw",
    "gy",
    "hk",
    "hm",
    "hn",
    "hr",
    "ht",
    "hu",
    "id",
    "ie",
    "il",
    "im",
    "in",
    "io",
    "iq",
    "ir",
    "is",
    "it",
    "je",
    "jm",
    "jo",
    "jp",
    "ke",
    "kg",
    "kh",
    "ki",
    "km",
    "kn",
    "kp",
    "kr",
    "kw",
    "ky",
    "kz",
    "la",
    "lb",
    "lc",
    "li",
    "lk",
    "lr",
    "ls",
    "lt",
    "lu",
    "lv",
    "ly",
    "ma",
    "mc",
    "md",
    "me",
    "mf",
    "mg",
    "mh",
    "mk",
    "ml",
    "mm",
    "mn",
    "mo",
    "mp",
    "mq",
    "mr",
    "ms",
    "mt",
    "mu",
    "mv",
    "mw",
    "mx",
    "my",
    "mz",
    "na",
    "nc",
    "ne",
    "nf",
    "ng",
    "ni",
    "nl",
    "no",
    "np",
    "nr",
    "nu",
    "nz",
    "om",
    "pa",
    "pe",
    "pf",
    "pg",
    "ph",
    "pk",
    "pl",
    "pm",
    "pn",
    "pr",
    "pt",
    "pw",
    "py",
    "qa",
    "qm",
    "qs",
    "qu",
    "qw",
    "qx",
    "qz",
    "re",
    "ro",
    "rs",
    "ru",
    "rw",
    "sa",
    "sb",
    "sc",
    "sd",
    "se",
    "sg",
    "sh",
    "si",
    "sk",
    "sl",
    "sm",
    "sn",
    "so",
    "sr",
    "ss",
    "st",
    "sv",
    "sx",
    "sy",
    "sz",
    "tc",
    "td",
    "tf",
    "tg",
    "th",
    "tj",
    "tk",
    "tl",
    "tm",
    "tn",
    "to",
    "tr",
    "tt",
    "tv",
    "tw",
    "tz",
    "ua",
    "ug",
    "us",
    "uy",
    "uz",
    "va",
    "vc",
    "ve",
    "vg",
    "vi",
    "vn",
    "vu",
    "wf",
    "ws",
    "xa",
    "xb",
    "xc",
    "xd",
    "xe",
    "xg",
    "xh",
    "xj",
    "xk",
    "xl",
    "xm",
    "xp",
    "xq",
    "xr",
    "xs",
    "xt",
    "xu",
    "xv",
    "xw",
    "ye",
    "yt",
    "za",
    "zm",
    "zw",
  ]),
  language: new Set([
    "aa",
    "ab",
    "ach",
    "ae",
    "af",
    "ak",
    "am",
    "an",
    "ar",
    "as",
    "ast",
    "av",
    "ay",
    "az",
    "ba",
    "be",
    "bg",
    "bh",
    "bi",
    "bm",
    "bn",
    "bo",
    "br",
    "bs",
    "ca",
    "cak",
    "ce",
    "ch",
    "co",
    "cr",
    "crh",
    "cs",
    "csb",
    "cu",
    "cv",
    "cy",
    "da",
    "de",
    "dsb",
    "dv",
    "dz",
    "ee",
    "el",
    "en",
    "eo",
    "es",
    "et",
    "eu",
    "fa",
    "ff",
    "fi",
    "fj",
    "fo",
    "fr",
    "fur",
    "fy",
    "ga",
    "gd",
    "gl",
    "gn",
    "gu",
    "gv",
    "ha",
    "haw",
    "he",
    "hi",
    "hil",
    "ho",
    "hr",
    "hsb",
    "ht",
    "hu",
    "hy",
    "hz",
    "ia",
    "id",
    "ie",
    "ig",
    "ii",
    "ik",
    "io",
    "is",
    "it",
    "iu",
    "ja",
    "jv",
    "ka",
    "kab",
    "kg",
    "ki",
    "kj",
    "kk",
    "kl",
    "km",
    "kn",
    "ko",
    "kok",
    "kr",
    "ks",
    "ku",
    "kv",
    "kw",
    "ky",
    "la",
    "lb",
    "lg",
    "li",
    "lij",
    "ln",
    "lo",
    "lt",
    "ltg",
    "lu",
    "lv",
    "mai",
    "meh",
    "mg",
    "mh",
    "mi",
    "mix",
    "mk",
    "ml",
    "mn",
    "mr",
    "ms",
    "mt",
    "my",
    "na",
    "nb",
    "nd",
    "ne",
    "ng",
    "nl",
    "nn",
    "no",
    "nr",
    "nso",
    "nv",
    "ny",
    "oc",
    "oj",
    "om",
    "or",
    "os",
    "pa",
    "pi",
    "pl",
    "ps",
    "pt",
    "qu",
    "rm",
    "rn",
    "ro",
    "ru",
    "rw",
    "sa",
    "sat",
    "sc",
    "sco",
    "sd",
    "se",
    "sg",
    "si",
    "sk",
    "skr",
    "sl",
    "sm",
    "sn",
    "so",
    "son",
    "sq",
    "sr",
    "ss",
    "st",
    "su",
    "sv",
    "sw",
    "szl",
    "ta",
    "te",
    "tg",
    "th",
    "ti",
    "tig",
    "tk",
    "tl",
    "tlh",
    "tn",
    "to",
    "tr",
    "trs",
    "ts",
    "tt",
    "tw",
    "ty",
    "ug",
    "uk",
    "ur",
    "uz",
    "ve",
    "vi",
    "vo",
    "wa",
    "wen",
    "wo",
    "xh",
    "yi",
    "yo",
    "za",
    "zam",
    "zh",
    "zu",
  ]),
};

/**
 * Notice: If you're updating these names, you should also update the data
 *         in python/mozbuild/mozbuild/action/langpack_localeNames.json.
 */
const nativeLocaleNames = new Map(
  Object.entries({
    ach: "Acholi",
    af: "Afrikaans",
    an: "Aragonés",
    ar: "العربية",
    ast: "Asturianu",
    az: "Azərbaycanca",
    be: "Беларуская",
    bg: "Български",
    bn: "বাংলা",
    bo: "བོད་སྐད",
    br: "Brezhoneg",
    brx: "बड़ो",
    bs: "Bosanski",
    ca: "Català",
    "ca-valencia": "Català (Valencià)",
    cak: "Kaqchikel",
    cs: "Čeština",
    cy: "Cymraeg",
    da: "Dansk",
    de: "Deutsch",
    dsb: "Dolnoserbšćina",
    el: "Ελληνικά",
    "en-CA": "English (CA)",
    "en-GB": "English (GB)",
    "en-US": "English (US)",
    eo: "Esperanto",
    "es-AR": "Español (AR)",
    "es-CL": "Español (CL)",
    "es-ES": "Español (ES)",
    "es-MX": "Español (MX)",
    et: "Eesti",
    eu: "Euskara",
    fa: "فارسی",
    ff: "Pulaar",
    fi: "Suomi",
    fr: "Français",
    fur: "Furlan",
    "fy-NL": "Frysk",
    "ga-IE": "Gaeilge",
    gd: "Gàidhlig",
    gl: "Galego",
    gn: "Guarani",
    "gu-IN": "ગુજરાતી",
    he: "עברית",
    "hi-IN": "हिन्दी",
    hr: "Hrvatski",
    hsb: "Hornjoserbšćina",
    hu: "Magyar",
    "hy-AM": "հայերեն",
    ia: "Interlingua",
    id: "Indonesia",
    is: "Islenska",
    it: "Italiano",
    ja: "日本語",
    "ja-JP-mac": "日本語",
    ka: "ქართული",
    kab: "Taqbaylit",
    kk: "қазақ тілі",
    km: "ខ្មែរ",
    kn: "ಕನ್ನಡ",
    ko: "한국어",
    lij: "Ligure",
    lo: "ລາວ",
    lt: "Lietuvių",
    ltg: "Latgalīšu",
    lv: "Latviešu",
    mk: "македонски",
    ml: "മലയാളം",
    mr: "मराठी",
    ms: "Melayu",
    my: "မြန်မာ",
    "nb-NO": "Norsk Bokmål",
    "ne-NP": "नेपाली",
    nl: "Nederlands",
    "nn-NO": "Nynorsk",
    oc: "Occitan",
    or: "ଓଡ଼ିଆ",
    "pa-IN": "ਪੰਜਾਬੀ",
    pl: "Polski",
    "pt-BR": "Português (BR)",
    "pt-PT": "Português (PT)",
    rm: "Rumantsch",
    ro: "Română",
    ru: "Русский",
    sat: "ᱥᱟᱱᱛᱟᱲᱤ",
    sc: "Sardu",
    sco: "Scots",
    si: "සිංහල",
    sk: "Slovenčina",
    skr: "سرائیکی",
    sl: "Slovenščina",
    son: "Soŋay",
    sq: "Shqip",
    sr: "Српски",
    "sv-SE": "Svenska",
    szl: "Ślōnsko",
    ta: "தமிழ்",
    te: "తెలుగు",
    tg: "Тоҷикӣ",
    th: "ไทย",
    tl: "Tagalog",
    tr: "Türkçe",
    trs: "Triqui",
    uk: "Українська",
    ur: "اردو",
    uz: "O‘zbek",
    vi: "Tiếng Việt",
    wo: "Wolof",
    xh: "IsiXhosa",
    "zh-CN": "简体中文",
    "zh-TW": "正體中文",
  })
);

class MozRelativeTimeFormat extends Intl.RelativeTimeFormat {
  constructor(locales, options = {}, ...args) {
    // If someone is asking for MozRelativeTimeFormat, it's likely they'll want
    // to use `formatBestUnit` which works better with `auto`
    if (options.numeric === undefined) {
      options.numeric = "auto";
    }
    super(locales, options, ...args);
  }

  formatBestUnit(date, { now = new Date() } = {}) {
    const diff = {
      _: {},
      ms: date.getTime() - now.getTime(),
      years: date.getFullYear() - now.getFullYear(),
    };

    defineCachedGetter(diff, "months", function () {
      return this.years * 12 + date.getMonth() - now.getMonth();
    });
    defineCachedGetter(diff, "weeks", function () {
      return Math.trunc(this.days / 7);
    });
    defineCachedGetter(diff, "days", function () {
      return Math.trunc((startOf(date, "day") - startOf(now, "day")) / day);
    });
    defineCachedGetter(diff, "hours", function () {
      return Math.trunc((startOf(date, "hour") - startOf(now, "hour")) / hour);
    });
    defineCachedGetter(diff, "minutes", function () {
      return Math.trunc(
        (startOf(date, "minute") - startOf(now, "minute")) / minute
      );
    });
    defineCachedGetter(diff, "seconds", function () {
      return Math.trunc(
        (startOf(date, "second") - startOf(now, "second")) / second
      );
    });

    const absDiff = {
      _: {},
    };

    for (let prop of Object.keys(diff)) {
      defineGetter(absDiff, prop, function () {
        return Math.abs(diff[prop]);
      });
    }
    const unit = bestFit(absDiff);

    switch (unit) {
      case "year":
        return this.format(diff.years, unit);
      case "month":
        return this.format(diff.months, unit);
      case "week":
        return this.format(diff.weeks, unit);
      case "day":
        return this.format(diff.days, unit);
      case "hour":
        return this.format(diff.hours, unit);
      case "minute":
        return this.format(diff.minutes, unit);
      default:
        if (unit !== "second") {
          throw new TypeError(`Unsupported unit "${unit}"`);
        }
        return this.format(diff.seconds, unit);
    }
  }
}

export class MozIntl {
  Collator = Intl.Collator;
  ListFormat = Intl.ListFormat;
  Locale = Intl.Locale;
  NumberFormat = Intl.NumberFormat;
  PluralRules = Intl.PluralRules;
  RelativeTimeFormat = MozRelativeTimeFormat;

  constructor() {
    this._cache = {};
    Services.obs.addObserver(this, "intl:app-locales-changed", true);
  }

  observe() {
    // Clear cache when things change.
    this._cache = {};
  }

  getCalendarInfo(locales, ...args) {
    if (!this._cache.hasOwnProperty("getCalendarInfo")) {
      mozIntlHelper.addGetCalendarInfo(this._cache);
    }

    return this._cache.getCalendarInfo(locales, ...args);
  }

  getDisplayNamesDeprecated(locales, options = {}) {
    // Helper for IntlUtils.webidl, will be removed once Intl.DisplayNames is
    // available in non-privileged code.

    let { type, style, calendar, keys = [] } = options;

    let dn = new this.DisplayNames(locales, { type, style, calendar });
    let {
      locale: resolvedLocale,
      type: resolvedType,
      style: resolvedStyle,
      calendar: resolvedCalendar,
    } = dn.resolvedOptions();
    let values = keys.map(key => dn.of(key));

    return {
      locale: resolvedLocale,
      type: resolvedType,
      style: resolvedStyle,
      calendar: resolvedCalendar,
      values,
    };
  }

  getAvailableLocaleDisplayNames(type) {
    if (availableLocaleDisplayNames.hasOwnProperty(type)) {
      return Array.from(availableLocaleDisplayNames[type]);
    }

    return new Error("Unimplemented!");
  }

  getLanguageDisplayNames(locales, langCodes) {
    if (locales !== undefined) {
      throw new Error("First argument support not implemented yet");
    }

    if (!this._cache.hasOwnProperty("languageLocalization")) {
      const loc = new Localization(["toolkit/intl/languageNames.ftl"], true);
      this._cache.languageLocalization = loc;
    }

    const loc = this._cache.languageLocalization;

    return langCodes.map(langCode => {
      if (typeof langCode !== "string") {
        throw new TypeError("All language codes must be strings.");
      }
      let lcLangCode = langCode.toLowerCase();
      if (availableLocaleDisplayNames.language.has(lcLangCode)) {
        const value = loc.formatValueSync(`language-name-${lcLangCode}`);
        if (value !== null) {
          return value;
        }
      }
      return lcLangCode;
    });
  }

  getRegionDisplayNames(locales, regionCodes) {
    if (locales !== undefined) {
      throw new Error("First argument support not implemented yet");
    }

    if (!this._cache.hasOwnProperty("regionLocalization")) {
      const loc = new Localization(["toolkit/intl/regionNames.ftl"], true);
      this._cache.regionLocalization = loc;
    }

    const loc = this._cache.regionLocalization;

    return regionCodes.map(regionCode => {
      if (typeof regionCode !== "string") {
        throw new TypeError("All region codes must be strings.");
      }
      let lcRegionCode = regionCode.toLowerCase();
      if (availableLocaleDisplayNames.region.has(lcRegionCode)) {
        let regionID;
        // Allow changing names over time for specific regions
        switch (lcRegionCode) {
          case "bq":
            regionID = "region-name-bq-2018";
            break;
          case "cv":
            regionID = "region-name-cv-2020";
            break;
          case "cz":
            regionID = "region-name-cz-2019";
            break;
          case "mk":
            regionID = "region-name-mk-2019";
            break;
          case "sz":
            regionID = "region-name-sz-2019";
            break;
          default:
            regionID = `region-name-${lcRegionCode}`;
        }

        const value = loc.formatValueSync(regionID);
        if (value !== null) {
          return value;
        }
      }
      return regionCode.toUpperCase();
    });
  }

  getLocaleDisplayNames(locales, localeCodes, options = {}) {
    const { preferNative = false } = options;

    if (locales !== undefined) {
      throw new Error("First argument support not implemented yet");
    }
    // Patterns hardcoded from CLDR 33 english.
    // We can later look into fetching them from CLDR directly.
    const localePattern = "{0} ({1})";
    const localeSeparator = ", ";

    return localeCodes.map(localeCode => {
      if (typeof localeCode !== "string") {
        throw new TypeError("All locale codes must be strings.");
      }

      if (preferNative && nativeLocaleNames.has(localeCode)) {
        return nativeLocaleNames.get(localeCode);
      }

      let locale;
      try {
        locale = new Intl.Locale(localeCode.replaceAll("_", "-"));
      } catch {
        return localeCode;
      }

      const {
        language: languageSubtag,
        script: scriptSubtag,
        region: regionSubtag,
      } = locale;

      const variantSubtags = locale.baseName.match(variantSubtagsMatch);

      const displayName = [
        this.getLanguageDisplayNames(locales, [languageSubtag])[0],
      ];

      if (scriptSubtag) {
        displayName.push(scriptSubtag);
      }

      if (regionSubtag) {
        displayName.push(
          this.getRegionDisplayNames(locales, [regionSubtag])[0]
        );
      }

      if (variantSubtags) {
        displayName.push(...variantSubtags[0].substr(1).split("-")); // Collapse multiple variants.
      }

      let modifiers;
      if (displayName.length === 1) {
        return displayName[0];
      } else if (displayName.length > 2) {
        modifiers = displayName.slice(1).join(localeSeparator);
      } else {
        modifiers = displayName[1];
      }
      return localePattern
        .replace("{0}", displayName[0])
        .replace("{1}", modifiers);
    });
  }

  getScriptDirection(locale) {
    // This is a crude implementation until Bug 1693576 lands.
    // See justification in toolkit/components/mozintl/mozIMozIntl.idl
    const { language } = new Intl.Locale(locale);
    if (
      language == "ar" ||
      language == "ckb" ||
      language == "fa" ||
      language == "he" ||
      language == "ur"
    ) {
      return "rtl";
    }
    return "ltr";
  }

  stringHasRTLChars(str) {
    return mozIntlHelper.stringHasRTLChars(str);
  }

  get DateTimeFormat() {
    if (!this._cache.hasOwnProperty("DateTimeFormat")) {
      mozIntlHelper.addDateTimeFormatConstructor(this._cache);

      const DateTimeFormat = this._cache.DateTimeFormat;

      class MozDateTimeFormat extends DateTimeFormat {
        constructor(locales, options, ...args) {
          let resolvedLocales = DateTimeFormat.supportedLocalesOf(locales);
          if (options) {
            if (options.dateStyle || options.timeStyle) {
              options.pattern = osPrefs.getDateTimePattern(
                getDateTimePatternStyle(options.dateStyle),
                getDateTimePatternStyle(options.timeStyle),
                resolvedLocales[0]
              );
            } else {
              // make sure that user doesn't pass a pattern explicitly
              options.pattern = undefined;
            }
          }
          super(resolvedLocales, options, ...args);
        }
      }
      this._cache.MozDateTimeFormat = MozDateTimeFormat;
    }

    return this._cache.MozDateTimeFormat;
  }

  get DisplayNames() {
    if (!this._cache.hasOwnProperty("DisplayNames")) {
      mozIntlHelper.addDisplayNamesConstructor(this._cache);
    }

    return this._cache.DisplayNames;
  }
}

MozIntl.prototype.classID = Components.ID(
  "{35ec195a-e8d0-4300-83af-c8a2cc84b4a3}"
);
MozIntl.prototype.QueryInterface = ChromeUtils.generateQI([
  "mozIMozIntl",
  "nsIObserver",
  "nsISupportsWeakReference",
]);
