/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* 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/. */

#include "LocaleService.h"

#include "mozilla/ClearOnShutdown.h"
#include "mozilla/DebugOnly.h"
#include "mozilla/Omnijar.h"
#include "mozilla/Preferences.h"
#include "mozilla/Services.h"
#include "mozilla/StaticPrefs_privacy.h"
#include "mozilla/intl/AppDateTimeFormat.h"
#include "mozilla/intl/Locale.h"
#include "mozilla/intl/OSPreferences.h"
#include "nsDirectoryService.h"
#include "nsDirectoryServiceDefs.h"
#include "nsIObserverService.h"
#include "nsStringEnumerator.h"
#include "nsXULAppAPI.h"
#include "nsZipArchive.h"
#ifdef XP_WIN
#  include "WinUtils.h"
#endif
#ifdef MOZ_WIDGET_GTK
#  include "mozilla/WidgetUtilsGtk.h"
#endif

#define INTL_SYSTEM_LOCALES_CHANGED "intl:system-locales-changed"

#define PSEUDO_LOCALE_PREF "intl.l10n.pseudo"
#define REQUESTED_LOCALES_PREF "intl.locale.requested"
#define WEB_EXPOSED_LOCALES_PREF "intl.locale.privacy.web_exposed"

static const char* kObservedPrefs[] = {REQUESTED_LOCALES_PREF,
                                       WEB_EXPOSED_LOCALES_PREF,
                                       PSEUDO_LOCALE_PREF, nullptr};

using namespace mozilla::intl::ffi;
using namespace mozilla::intl;
using namespace mozilla;

NS_IMPL_ISUPPORTS(LocaleService, mozILocaleService, nsIObserver,
                  nsISupportsWeakReference)

mozilla::StaticRefPtr<LocaleService> LocaleService::sInstance;

/**
 * This function splits an input string by `,` delimiter, sanitizes the result
 * language tags and returns them to the caller.
 */
static void SplitLocaleListStringIntoArray(nsACString& str,
                                           nsTArray<nsCString>& aRetVal) {
  if (str.Length() > 0) {
    for (const nsACString& part : str.Split(',')) {
      nsAutoCString locale(part);
      if (LocaleService::CanonicalizeLanguageId(locale)) {
        if (!aRetVal.Contains(locale)) {
          aRetVal.AppendElement(locale);
        }
      }
    }
  }
}

static void ReadRequestedLocales(nsTArray<nsCString>& aRetVal) {
  nsAutoCString str;
  nsresult rv = Preferences::GetCString(REQUESTED_LOCALES_PREF, str);
  // isRepack means this is a version of Firefox specifically
  // built for one language.
  const bool isRepack =
#ifdef XP_WIN
      !mozilla::widget::WinUtils::HasPackageIdentity();
#elif defined(MOZ_WIDGET_GTK)
      !widget::IsRunningUnderSnap();
#else
      true;
#endif

  // We handle four scenarios here:
  //
  // 1) The pref is not set - use default locale
  // 2) The pref is not set and we're a packaged app - use OS locales
  // 3) The pref is set to "" - use OS locales
  // 4) The pref is set to a value - parse the locale list and use it
  if (NS_SUCCEEDED(rv)) {
    if (str.Length() == 0) {
      // Case 3
      OSPreferences::GetInstance()->GetSystemLocales(aRetVal);
    } else {
      // Case 4
      SplitLocaleListStringIntoArray(str, aRetVal);
    }
  }

  // This will happen when either the pref is not set,
  // or parsing of the pref didn't produce any usable
  // result.
  if (aRetVal.IsEmpty()) {
    if (isRepack) {
      // Case 1
      nsAutoCString defaultLocale;
      LocaleService::GetInstance()->GetDefaultLocale(defaultLocale);
      aRetVal.AppendElement(defaultLocale);
    } else {
      // Case 2
      OSPreferences::GetInstance()->GetSystemLocales(aRetVal);
    }
  }
}

static void ReadWebExposedLocales(nsTArray<nsCString>& aRetVal) {
  nsAutoCString str;
  nsresult rv = Preferences::GetCString(WEB_EXPOSED_LOCALES_PREF, str);
  if (NS_WARN_IF(NS_FAILED(rv)) || str.Length() == 0) {
    return;
  }

  SplitLocaleListStringIntoArray(str, aRetVal);
}

LocaleService::LocaleService(bool aIsServer) : mIsServer(aIsServer) {}

/**
 * This function performs the actual language negotiation for the API.
 *
 * Currently it collects the locale ID used by nsChromeRegistry and
 * adds hardcoded default locale as a fallback.
 */
void LocaleService::NegotiateAppLocales(nsTArray<nsCString>& aRetVal) {
  if (mIsServer) {
    nsAutoCString defaultLocale;
    AutoTArray<nsCString, 100> availableLocales;
    AutoTArray<nsCString, 10> requestedLocales;
    GetDefaultLocale(defaultLocale);
    GetAvailableLocales(availableLocales);
    GetRequestedLocales(requestedLocales);

    NegotiateLanguages(requestedLocales, availableLocales, defaultLocale,
                       kLangNegStrategyFiltering, aRetVal);
  }

  nsAutoCString lastFallbackLocale;
  GetLastFallbackLocale(lastFallbackLocale);

  if (!aRetVal.Contains(lastFallbackLocale)) {
    // This part is used in one of the two scenarios:
    //
    // a) We're in a client mode, and no locale has been set yet,
    //    so we need to return last fallback locale temporarily.
    // b) We're in a server mode, and the last fallback locale was excluded
    //    when negotiating against the requested locales.
    //    Since we currently package it as a last fallback at build
    //    time, we should also add it at the end of the list at
    //    runtime.
    aRetVal.AppendElement(lastFallbackLocale);
  }
}

LocaleService* LocaleService::GetInstance() {
  if (!sInstance) {
    sInstance = new LocaleService(XRE_IsParentProcess());

    if (sInstance->IsServer()) {
      // We're going to observe for requested languages changes which come
      // from prefs.
      DebugOnly<nsresult> rv =
          Preferences::AddWeakObservers(sInstance, kObservedPrefs);
      MOZ_ASSERT(NS_SUCCEEDED(rv), "Adding observers failed.");

      nsCOMPtr<nsIObserverService> obs =
          mozilla::services::GetObserverService();
      if (obs) {
        obs->AddObserver(sInstance, INTL_SYSTEM_LOCALES_CHANGED, true);
        obs->AddObserver(sInstance, NS_XPCOM_SHUTDOWN_OBSERVER_ID, true);
      }
    }
    // DOM might use ICUUtils and LocaleService during UnbindFromTree by
    // final cycle collection.
    ClearOnShutdown(&sInstance, ShutdownPhase::CCPostLastCycleCollection);
  }
  return sInstance;
}

static void NotifyAppLocaleChanged() {
  nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
  if (obs) {
    obs->NotifyObservers(nullptr, "intl:app-locales-changed", nullptr);
  }
  // The locale in AppDateTimeFormat is cached statically.
  AppDateTimeFormat::ClearLocaleCache();
}

void LocaleService::RemoveObservers() {
  if (mIsServer) {
    Preferences::RemoveObservers(this, kObservedPrefs);

    nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
    if (obs) {
      obs->RemoveObserver(this, INTL_SYSTEM_LOCALES_CHANGED);
      obs->RemoveObserver(this, NS_XPCOM_SHUTDOWN_OBSERVER_ID);
    }
  }
}

void LocaleService::AssignAppLocales(const nsTArray<nsCString>& aAppLocales) {
  MOZ_ASSERT(!mIsServer,
             "This should only be called for LocaleService in client mode.");

  mAppLocales = aAppLocales.Clone();
  NotifyAppLocaleChanged();
}

void LocaleService::AssignRequestedLocales(
    const nsTArray<nsCString>& aRequestedLocales) {
  MOZ_ASSERT(!mIsServer,
             "This should only be called for LocaleService in client mode.");

  mRequestedLocales = aRequestedLocales.Clone();
  nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
  if (obs) {
    obs->NotifyObservers(nullptr, "intl:requested-locales-changed", nullptr);
  }
}

void LocaleService::RequestedLocalesChanged() {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");

  nsTArray<nsCString> newLocales;
  ReadRequestedLocales(newLocales);

  if (mRequestedLocales != newLocales) {
    mRequestedLocales = std::move(newLocales);
    nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService();
    if (obs) {
      obs->NotifyObservers(nullptr, "intl:requested-locales-changed", nullptr);
    }
    LocalesChanged();
  }
}

void LocaleService::WebExposedLocalesChanged() {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");

  nsTArray<nsCString> newLocales;
  ReadWebExposedLocales(newLocales);
  if (mWebExposedLocales != newLocales) {
    mWebExposedLocales = std::move(newLocales);
  }
}

void LocaleService::LocalesChanged() {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");

  // if mAppLocales has not been initialized yet, just return
  if (mAppLocales.IsEmpty()) {
    return;
  }

  nsTArray<nsCString> newLocales;
  NegotiateAppLocales(newLocales);

  if (mAppLocales != newLocales) {
    mAppLocales = std::move(newLocales);
    NotifyAppLocaleChanged();
  }
}

bool LocaleService::IsLocaleRTL(const nsACString& aLocale) {
  return unic_langid_is_rtl(&aLocale);
}

bool LocaleService::IsAppLocaleRTL() {
  // Next, check if there is a pseudo locale `bidi` set.
  nsAutoCString pseudoLocale;
  if (NS_SUCCEEDED(Preferences::GetCString("intl.l10n.pseudo", pseudoLocale))) {
    if (pseudoLocale.EqualsLiteral("bidi")) {
      return true;
    }
    if (pseudoLocale.EqualsLiteral("accented")) {
      return false;
    }
  }

  nsAutoCString locale;
  GetAppLocaleAsBCP47(locale);
  return IsLocaleRTL(locale);
}

NS_IMETHODIMP
LocaleService::Observe(nsISupports* aSubject, const char* aTopic,
                       const char16_t* aData) {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");

  if (!strcmp(aTopic, INTL_SYSTEM_LOCALES_CHANGED)) {
    RequestedLocalesChanged();
    WebExposedLocalesChanged();
  } else if (!strcmp(aTopic, NS_XPCOM_SHUTDOWN_OBSERVER_ID)) {
    RemoveObservers();
  } else {
    NS_ConvertUTF16toUTF8 pref(aData);
    // At the moment the only thing we're observing are settings indicating
    // user requested locales.
    if (pref.EqualsLiteral(REQUESTED_LOCALES_PREF)) {
      RequestedLocalesChanged();
    } else if (pref.EqualsLiteral(WEB_EXPOSED_LOCALES_PREF)) {
      WebExposedLocalesChanged();
    } else if (pref.EqualsLiteral(PSEUDO_LOCALE_PREF)) {
      NotifyAppLocaleChanged();
    }
  }

  return NS_OK;
}

bool LocaleService::LanguagesMatch(const nsACString& aRequested,
                                   const nsACString& aAvailable) {
  Locale requested;
  auto requestedResult = LocaleParser::TryParse(aRequested, requested);
  Locale available;
  auto availableResult = LocaleParser::TryParse(aAvailable, available);

  if (requestedResult.isErr() || availableResult.isErr()) {
    return false;
  }

  if (requested.Canonicalize().isErr() || available.Canonicalize().isErr()) {
    return false;
  }

  return requested.Language().Span() == available.Language().Span();
}

bool LocaleService::IsServer() { return mIsServer; }

static bool GetGREFileContents(const char* aFilePath, nsCString* aOutString) {
  // Look for the requested file in omnijar.
  RefPtr<nsZipArchive> zip = Omnijar::GetReader(Omnijar::GRE);
  if (zip) {
    nsZipItemPtr<char> item(zip, aFilePath);
    if (!item) {
      return false;
    }
    aOutString->Assign(item.Buffer(), item.Length());
    return true;
  }

  // If we didn't have an omnijar (i.e. we're running a non-packaged
  // build), then look in the GRE directory.
  nsCOMPtr<nsIFile> path;
  if (NS_FAILED(nsDirectoryService::gService->Get(
          NS_GRE_DIR, NS_GET_IID(nsIFile), getter_AddRefs(path)))) {
    return false;
  }

  path->AppendRelativeNativePath(nsDependentCString(aFilePath));
  bool result;
  if (NS_FAILED(path->IsFile(&result)) || !result ||
      NS_FAILED(path->IsReadable(&result)) || !result) {
    return false;
  }

  // This is a small file, only used once, so it's not worth doing some fancy
  // off-main-thread file I/O or whatever. Just read it.
  FILE* fp;
  if (NS_FAILED(path->OpenANSIFileDesc("r", &fp)) || !fp) {
    return false;
  }

  fseek(fp, 0, SEEK_END);
  long len = ftell(fp);
  rewind(fp);
  aOutString->SetLength(len);
  size_t cc = fread(aOutString->BeginWriting(), 1, len, fp);

  fclose(fp);

  return cc == size_t(len);
}

void LocaleService::InitPackagedLocales() {
  MOZ_ASSERT(mPackagedLocales.IsEmpty());

  nsAutoCString localesString;
  if (GetGREFileContents("res/multilocale.txt", &localesString)) {
    localesString.Trim(" \t\n\r");
    // This should never be empty in a correctly-built product.
    MOZ_ASSERT(!localesString.IsEmpty());
    SplitLocaleListStringIntoArray(localesString, mPackagedLocales);
  }

  // Last resort in case of broken build
  if (mPackagedLocales.IsEmpty()) {
    nsAutoCString defaultLocale;
    GetDefaultLocale(defaultLocale);
    mPackagedLocales.AppendElement(defaultLocale);
  }
}

/**
 * mozILocaleService methods
 */

NS_IMETHODIMP
LocaleService::GetDefaultLocale(nsACString& aRetVal) {
  // We don't allow this to change during a session (it's set at build/package
  // time), so we cache the result the first time we're called.
  if (mDefaultLocale.IsEmpty()) {
    nsAutoCString locale;
    // Try to get the package locale from update.locale in omnijar. If the
    // update.locale file is not found, item.len will remain 0 and we'll
    // just use our hard-coded default below.
    GetGREFileContents("update.locale", &locale);
    locale.Trim(" \t\n\r");
#ifdef MOZ_UPDATER
    // This should never be empty.
    MOZ_ASSERT(!locale.IsEmpty());
#endif
    if (CanonicalizeLanguageId(locale)) {
      mDefaultLocale.Assign(locale);
    }

    // Hard-coded fallback to allow us to survive even if update.locale was
    // missing/broken in some way.
    if (mDefaultLocale.IsEmpty()) {
      GetLastFallbackLocale(mDefaultLocale);
    }
  }

  aRetVal = mDefaultLocale;
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetLastFallbackLocale(nsACString& aRetVal) {
  aRetVal.AssignLiteral("en-US");
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetAppLocalesAsLangTags(nsTArray<nsCString>& aRetVal) {
  if (mAppLocales.IsEmpty()) {
    NegotiateAppLocales(mAppLocales);
  }
  for (uint32_t i = 0; i < mAppLocales.Length(); i++) {
    nsAutoCString locale(mAppLocales[i]);
    if (locale.LowerCaseEqualsASCII("ja-jp-macos")) {
      aRetVal.AppendElement("ja-JP-mac");
    } else {
      aRetVal.AppendElement(locale);
    }
  }
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetAppLocalesAsBCP47(nsTArray<nsCString>& aRetVal) {
  if (mAppLocales.IsEmpty()) {
    NegotiateAppLocales(mAppLocales);
  }
  aRetVal = mAppLocales.Clone();

  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetAppLocaleAsLangTag(nsACString& aRetVal) {
  AutoTArray<nsCString, 32> locales;
  GetAppLocalesAsLangTags(locales);

  aRetVal = locales[0];
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetAppLocaleAsBCP47(nsACString& aRetVal) {
  if (mAppLocales.IsEmpty()) {
    NegotiateAppLocales(mAppLocales);
  }
  aRetVal = mAppLocales[0];
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetRegionalPrefsLocales(nsTArray<nsCString>& aRetVal) {
  bool useOSLocales =
      Preferences::GetBool("intl.regional_prefs.use_os_locales", false);

  // If the user specified that they want to use OS Regional Preferences
  // locales, try to retrieve them and use.
  if (useOSLocales) {
    if (NS_SUCCEEDED(
            OSPreferences::GetInstance()->GetRegionalPrefsLocales(aRetVal))) {
      return NS_OK;
    }

    // If we fail to retrieve them, return the app locales.
    GetAppLocalesAsBCP47(aRetVal);
    return NS_OK;
  }

  // Otherwise, fetch OS Regional Preferences locales and compare the first one
  // to the app locale. If the language subtag matches, we can safely use
  // the OS Regional Preferences locale.
  //
  // This facilitates scenarios such as Firefox in "en-US" and User sets
  // regional prefs to "en-GB".
  nsAutoCString appLocale;
  AutoTArray<nsCString, 10> regionalPrefsLocales;
  LocaleService::GetInstance()->GetAppLocaleAsBCP47(appLocale);

  if (NS_FAILED(OSPreferences::GetInstance()->GetRegionalPrefsLocales(
          regionalPrefsLocales))) {
    GetAppLocalesAsBCP47(aRetVal);
    return NS_OK;
  }

  if (LocaleService::LanguagesMatch(appLocale, regionalPrefsLocales[0])) {
    aRetVal = regionalPrefsLocales.Clone();
    return NS_OK;
  }

  // Otherwise use the app locales.
  GetAppLocalesAsBCP47(aRetVal);
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetWebExposedLocales(nsTArray<nsCString>& aRetVal) {
  if (StaticPrefs::privacy_spoof_english() == 2) {
    aRetVal = nsTArray<nsCString>({"en-US"_ns});
    return NS_OK;
  }

  if (!mWebExposedLocales.IsEmpty()) {
    aRetVal = mWebExposedLocales.Clone();
    return NS_OK;
  }

  return GetRegionalPrefsLocales(aRetVal);
}

NS_IMETHODIMP
LocaleService::NegotiateLanguages(const nsTArray<nsCString>& aRequested,
                                  const nsTArray<nsCString>& aAvailable,
                                  const nsACString& aDefaultLocale,
                                  int32_t aStrategy,
                                  nsTArray<nsCString>& aRetVal) {
  if (aStrategy < 0 || aStrategy > 2) {
    return NS_ERROR_INVALID_ARG;
  }

#ifdef DEBUG
  Locale parsedLocale;
  auto result = LocaleParser::TryParse(aDefaultLocale, parsedLocale);

  MOZ_ASSERT(
      aDefaultLocale.IsEmpty() || result.isOk(),
      "If specified, default locale must be a well-formed BCP47 language tag.");
#endif

  if (aStrategy == kLangNegStrategyLookup && aDefaultLocale.IsEmpty()) {
    NS_WARNING(
        "Default locale should be specified when using lookup strategy.");
  }

  NegotiationStrategy strategy;
  switch (aStrategy) {
    case kLangNegStrategyFiltering:
      strategy = NegotiationStrategy::Filtering;
      break;
    case kLangNegStrategyMatching:
      strategy = NegotiationStrategy::Matching;
      break;
    case kLangNegStrategyLookup:
      strategy = NegotiationStrategy::Lookup;
      break;
  }

  fluent_langneg_negotiate_languages(&aRequested, &aAvailable, &aDefaultLocale,
                                     strategy, &aRetVal);

  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetRequestedLocales(nsTArray<nsCString>& aRetVal) {
  if (mRequestedLocales.IsEmpty()) {
    ReadRequestedLocales(mRequestedLocales);
  }

  aRetVal = mRequestedLocales.Clone();
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetRequestedLocale(nsACString& aRetVal) {
  if (mRequestedLocales.IsEmpty()) {
    ReadRequestedLocales(mRequestedLocales);
  }

  if (mRequestedLocales.Length() > 0) {
    aRetVal = mRequestedLocales[0];
  }

  return NS_OK;
}

NS_IMETHODIMP
LocaleService::SetRequestedLocales(const nsTArray<nsCString>& aRequested) {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");
  if (!mIsServer) {
    return NS_ERROR_UNEXPECTED;
  }

  nsAutoCString str;

  for (auto& req : aRequested) {
    nsAutoCString locale(req);
    if (!CanonicalizeLanguageId(locale)) {
      NS_ERROR("Invalid language tag provided to SetRequestedLocales!");
      return NS_ERROR_INVALID_ARG;
    }

    if (!str.IsEmpty()) {
      str.AppendLiteral(",");
    }
    str.Append(locale);
  }
  Preferences::SetCString(REQUESTED_LOCALES_PREF, str);

  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetAvailableLocales(nsTArray<nsCString>& aRetVal) {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");
  if (!mIsServer) {
    return NS_ERROR_UNEXPECTED;
  }

  if (mAvailableLocales.IsEmpty()) {
    // If there are no available locales set, it means that L10nRegistry
    // did not register its locale pool yet. The best course of action
    // is to use packaged locales until that happens.
    GetPackagedLocales(mAvailableLocales);
  }

  aRetVal = mAvailableLocales.Clone();
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetIsAppLocaleRTL(bool* aRetVal) {
  (*aRetVal) = IsAppLocaleRTL();
  return NS_OK;
}

NS_IMETHODIMP
LocaleService::SetAvailableLocales(const nsTArray<nsCString>& aAvailable) {
  MOZ_ASSERT(mIsServer, "This should only be called in the server mode.");
  if (!mIsServer) {
    return NS_ERROR_UNEXPECTED;
  }

  nsTArray<nsCString> newLocales;

  for (auto& avail : aAvailable) {
    nsAutoCString locale(avail);
    if (!CanonicalizeLanguageId(locale)) {
      NS_ERROR("Invalid language tag provided to SetAvailableLocales!");
      return NS_ERROR_INVALID_ARG;
    }
    newLocales.AppendElement(locale);
  }

  if (newLocales != mAvailableLocales) {
    mAvailableLocales = std::move(newLocales);
    LocalesChanged();
  }

  return NS_OK;
}

NS_IMETHODIMP
LocaleService::GetPackagedLocales(nsTArray<nsCString>& aRetVal) {
  if (mPackagedLocales.IsEmpty()) {
    InitPackagedLocales();
  }
  aRetVal = mPackagedLocales.Clone();
  return NS_OK;
}