///|
/// A normalized system locale tag.
///
/// The `tag` field keeps the canonicalized locale identifier, while the other
/// fields expose the most common structured pieces for MoonBit code.
pub struct Locale {
  tag : String
  language : String
  script : String?
  region : String?
} derive(Eq, Debug)

///|
/// Writes a derived debug representation to a `Show` logger.
fn[T : Debug] write_debug_show(value : T, logger : &Logger) -> Unit {
  logger.write_string(value.to_repr().to_string())
}

///|
/// Renders a locale using the derived debug representation.
pub impl Show for Locale with fn output(self, logger : &Logger) -> Unit {
  write_debug_show(self, logger)
}

///|
/// Returns the first locale from a non-empty list.
fn first_locale(locales : Array[Locale]) -> Locale? {
  locales.get(0)
}

///|
/// Extracts the normalized tag from an optional locale.
fn locale_tag(locale : Locale?) -> String? {
  match locale {
    Some(locale) => Some(locale.tag)
    None => None
  }
}

///|
/// Returns the highest-priority locale exposed by the current runtime.
///
/// The result is the first valid locale from `preferred()`, so it follows the
/// package's full discovery order: environment overrides such as `LC_ALL`,
/// `LC_MESSAGES`, `LANGUAGE`, and `LANG`, followed by the native system locale
/// when available.
///
/// This function is useful when callers need a single structured locale for
/// choosing translations, formatting defaults, or other locale-sensitive
/// behavior. Raw candidates that are empty, malformed, or unsupported are
/// skipped automatically instead of failing the whole lookup.
///
/// The returned `Locale` is already normalized:
/// - separators use `-`
/// - language subtags are lowercase
/// - script subtags are title-cased
/// - region subtags are uppercase when alphabetic
///
/// # Returns
///
/// - `Some(locale)` when at least one preferred locale candidate can be parsed
/// - `None` when no candidate is available or every candidate is invalid
///
/// # Notes
///
/// - This is equivalent to the first element of `preferred()`, when present.
/// - Use `current_tag()` when callers only need the canonical locale tag.
///
/// Example:
/// ```mbt check
/// test "current matches the first preferred locale" {
///   let current = @sys_locale.current()
///   let preferred = @sys_locale.preferred()
///   match current {
///     Some(locale) => {
///       assert_true(preferred.length() > 0)
///       @test.assert_eq(preferred[0], locale)
///     }
///     None => @test.assert_eq(preferred.length(), 0)
///   }
/// }
/// ```
pub fn current() -> Locale? {
  first_locale(preferred())
}

///|
/// Returns the normalized tag for the current locale.
///
/// This is the tag-only form of `current()`. Use it when callers only need the
/// canonical locale identifier and do not need the parsed `language`, `script`,
/// or `region` fields.
///
/// The returned tag uses the same normalization rules as `parse()`, such as
/// converting `en_US.UTF-8` into `en-US`.
///
/// This helper is a good fit for logging, serialization, comparisons against a
/// list of supported locales, or passing a locale tag into another API.
///
/// # Returns
///
/// - `Some(tag)` when `current()` resolves to a valid locale
/// - `None` when no valid current locale can be discovered
///
/// # Notes
///
/// - Discovery order and fallback behavior are identical to `current()`.
/// - The returned tag is always the same as `locale.tag` from `current()`.
///
/// Example:
/// ```mbt check
/// test "current_tag mirrors current" {
///   @test.assert_eq(
///     @sys_locale.current_tag(),
///     match @sys_locale.current() {
///       Some(locale) => Some(locale.tag)
///       None => None
///     },
///   )
/// }
/// ```
pub fn current_tag() -> String? {
  locale_tag(current())
}

///|
/// Returns all preferred locales discovered from the current runtime.
///
/// Locale candidates are collected in this order:
/// - `LC_ALL`
/// - `LC_MESSAGES`
/// - `LANGUAGE`
/// - `LANG`
/// - the native system locale reported by the current platform
///
/// Each raw candidate is normalized before it is returned:
/// - whitespace is trimmed
/// - POSIX separators such as `_` are converted to `-`
/// - encoding, modifier, and quality suffixes after `.`, `@`, and `;` are removed
/// - duplicate locale tags are dropped while preserving first-seen order
///
/// This function keeps as much signal as possible from the current runtime.
/// For example, `LANGUAGE` entries separated by `:` or `,` are expanded into
/// multiple locale candidates, while malformed entries are ignored without
/// discarding the rest of the list.
///
/// # Returns
///
/// An array of normalized `Locale` values in preference order. The array may
/// be empty when no valid locale can be detected.
///
/// # Notes
///
/// - The first returned locale, if any, is always the value from `current()`.
/// - Tags are deduplicated after normalization, so `en_US` and `en-US` collapse
///   to the same `Locale`.
///
/// Example:
/// ```mbt check
/// test "preferred and preferred_tags stay aligned" {
///   let locales = @sys_locale.preferred()
///   let tags = @sys_locale.preferred_tags()
///   @test.assert_eq(locales.length(), tags.length())
///   for i in 0.. Array[Locale] {
  locales_from_raw_candidates(locale_raw_candidates_internal())
}

///|
/// Returns the normalized tags for all preferred locales.
///
/// This is the tag-only form of `preferred()`. It keeps the same preference
/// order and deduplication rules, but returns only canonical locale tags
/// instead of full `Locale` records.
///
/// Use this helper when downstream code only needs normalized BCP 47 style
/// tags and does not care about the parsed `language`, `script`, or `region`
/// fields.
///
/// # Returns
///
/// An array of normalized locale tags, possibly empty when no valid locale can
/// be found.
///
/// # Notes
///
/// - Each returned tag corresponds to the `tag` field of the same-index locale
///   in `preferred()`.
/// - The first returned tag, if any, is always the value from `current_tag()`.
///
/// Example:
/// ```mbt check
/// test "preferred_tags starts with current_tag when present" {
///   let tags = @sys_locale.preferred_tags()
///   match @sys_locale.current_tag() {
///     Some(tag) => {
///       assert_true(tags.length() > 0)
///       @test.assert_eq(tags[0], tag)
///     }
///     None => ()
///   }
/// }
/// ```
pub fn preferred_tags() -> Array[String] {
  let tags : Array[String] = []
  for locale in preferred() {
    tags.push(locale.tag)
  }
  tags
}

///|
/// Parses a raw locale string into a normalized `Locale`.
///
/// This parser accepts common POSIX-style locale strings such as
/// `en_US.UTF-8`, `zh_Hans_CN`, and `fr_FR@euro`, then normalizes them into a
/// BCP 47 style representation.
///
/// Normalization includes:
/// - trimming surrounding whitespace
/// - removing suffixes after `.`, `@`, and `;`
/// - converting `_` separators into `-`
/// - canonicalizing subtag casing
///
/// The parser rejects empty input, malformed subtags, and fallback locales
/// such as `C` and `POSIX`.
///
/// Accepted inputs include plain language tags such as `en`, mixed
/// language-region forms such as `fr_FR`, and longer spellings such as
/// `zh_Hans_CN.UTF-8`. Additional variant subtags are preserved when they are
/// valid and are lowercased in the final tag.
///
/// # Parameters
///
/// - `raw`: A locale string from user input, environment variables, or a native
///   platform API.
///
/// # Returns
///
/// - `Some(locale)` when the input can be normalized into a valid locale
/// - `None` when the input is empty, unsupported, or malformed
///
/// # Notes
///
/// - The resulting `Locale.tag` is stable and canonicalized for comparisons.
/// - The parsed `script` and `region` fields are optional because many locale
///   strings only contain a language subtag.
///
/// Example:
/// ```mbt check
/// test "parse normalizes locale strings" {
///   debug_inspect(
///     @sys_locale.parse("zh_Hans_CN.UTF-8"),
///     content=(
///       #|Some(
///       #|  {
///       #|    tag: "zh-Hans-CN",
///       #|    language: "zh",
///       #|    script: Some("Hans"),
///       #|    region: Some("CN"),
///       #|  },
///       #|)
///     ),
///   )
///   debug_inspect(@sys_locale.parse("C.UTF-8"), content="None")
/// }
/// ```
pub fn parse(raw : String) -> Locale? {
  parse_locale(raw)
}

///|
/// Returns the canonical locale tag for a raw locale string.
///
/// This convenience wrapper around `parse()` keeps only the normalized tag,
/// which is useful when callers do not need the structured `Locale` record.
///
/// It follows the same acceptance and rejection rules as `parse()`, so invalid
/// or unsupported locale strings still return `None`.
///
/// # Parameters
///
/// - `raw`: A raw locale string to normalize into a canonical locale tag.
///
/// # Returns
///
/// - `Some(tag)` when `raw` can be normalized into a valid locale tag
/// - `None` when `raw` is empty, unsupported, or malformed
///
/// # Notes
///
/// - This is equivalent to `parse(raw)` followed by extracting `locale.tag`.
/// - Use `parse()` instead when callers also need structured locale fields.
///
/// Example:
/// ```mbt check
/// test "canonicalize returns normalized tags" {
///   debug_inspect(
///     @sys_locale.canonicalize("en_US.UTF-8"),
///     content="Some(\"en-US\")",
///   )
///   debug_inspect(
///     @sys_locale.canonicalize("fr_FR@euro"),
///     content="Some(\"fr-FR\")",
///   )
/// }
/// ```
pub fn canonicalize(raw : String) -> String? {
  locale_tag(parse(raw))
}