///|
/// A normalized, practical subset of a BCP 47 language tag.
///
/// MoonLocale keeps the language, optional script, optional region, and any
/// remaining variants separately. This is sufficient for application locale
/// negotiation without coupling callers to a large standards database.
pub struct Locale {
  language : String
  script : String?
  region : String?
  variants : Array[String]
} derive(Eq, Debug)

///|
/// Reports why a locale tag could not be parsed.
pub enum LocaleError {
  EmptyTag
  EmptySubtag(Int)
  InvalidLanguage(String)
  InvalidVariant(String)
  DuplicateScript(String)
  DuplicateRegion(String)
} derive(Eq, Debug)

///|
pub fn LocaleError::message(self : LocaleError) -> String {
  match self {
    EmptyTag => "locale tag must not be empty"
    EmptySubtag(index) => "locale subtag \{index} must not be empty"
    InvalidLanguage(value) => "invalid language subtag: \{value}"
    InvalidVariant(value) => "invalid variant subtag: \{value}"
    DuplicateScript(value) => "duplicate script subtag: \{value}"
    DuplicateRegion(value) => "duplicate region subtag: \{value}"
  }
}

///|
fn ascii_is_alpha(value : Char) -> Bool {
  value.is_ascii_alphabetic()
}

///|
fn ascii_is_digit(value : Char) -> Bool {
  value.is_ascii_digit()
}

///|
fn ascii_is_alnum(value : Char) -> Bool {
  ascii_is_alpha(value) || ascii_is_digit(value)
}

///|
fn ascii_all(value : String, predicate : (Char) -> Bool) -> Bool {
  if value.length() == 0 {
    return false
  }
  for character in value {
    if !predicate(character) {
      return false
    }
  }
  true
}

///|
fn ascii_lower(value : String) -> String {
  let output = StringBuilder::new()
  for character in value {
    output.write_char(character.to_ascii_lowercase())
  }
  output.to_string()
}

///|
fn ascii_upper(value : String) -> String {
  let output = StringBuilder::new()
  for character in value {
    output.write_char(character.to_ascii_uppercase())
  }
  output.to_string()
}

///|
fn ascii_title(value : String) -> String {
  let output = StringBuilder::new()
  for index, character in ascii_lower(value) {
    if index == 0 {
      output.write_char(character.to_ascii_uppercase())
    } else {
      output.write_char(character)
    }
  }
  output.to_string()
}

///|
fn is_language(value : String) -> Bool {
  let length = value.length()
  length >= 2 && length <= 8 && ascii_all(value, ascii_is_alpha)
}

///|
fn is_script(value : String) -> Bool {
  value.length() == 4 && ascii_all(value, ascii_is_alpha)
}

///|
fn is_region(value : String) -> Bool {
  (value.length() == 2 && ascii_all(value, ascii_is_alpha)) ||
  (value.length() == 3 && ascii_all(value, ascii_is_digit))
}

///|
fn is_variant(value : String) -> Bool {
  let length = value.length()
  (length >= 5 && length <= 8 && ascii_all(value, ascii_is_alnum)) ||
  (
    length == 4 &&
    value[0] >= 48 &&
    value[0] <= 57 &&
    ascii_all(value, ascii_is_alnum)
  )
}

///|
/// Parses a locale tag such as `en`, `pt-BR`, or `zh-Hans-CN`.
///
/// Underscores are accepted as a convenience for operating-system locale
/// values. The returned locale always renders with hyphens and conventional
/// casing.
pub fn Locale::parse(tag : String) -> Result[Locale, LocaleError] {
  if tag.length() == 0 {
    return Err(EmptyTag)
  }
  let normalized = tag.replace_all(old="_", new="-")
  let parts = normalized.split("-").map(fn(part) { "\{part}" }).to_array()
  for index, part in parts {
    if part.length() == 0 {
      return Err(EmptySubtag(index))
    }
  }
  let language = parts[0]
  if !is_language(language) {
    return Err(InvalidLanguage(language))
  }
  let mut script : String? = None
  let mut region : String? = None
  let variants : Array[String] = []
  for index = 1; index < parts.length(); index = index + 1 {
    let part = parts[index]
    if is_script(part) {
      match script {
        None => script = Some(ascii_title(part))
        Some(_) => return Err(DuplicateScript(part))
      }
    } else if is_region(part) {
      match region {
        None => region = Some(ascii_upper(part))
        Some(_) => return Err(DuplicateRegion(part))
      }
    } else if is_variant(part) {
      variants.push(ascii_lower(part))
    } else {
      return Err(InvalidVariant(part))
    }
  }
  Ok({ language: ascii_lower(language), script, region, variants })
}

///|
/// Creates a locale and returns `None` for an invalid tag.
pub fn Locale::from_string(tag : String) -> Locale? {
  match Locale::parse(tag) {
    Ok(locale) => Some(locale)
    Err(_) => None
  }
}

///|
pub fn Locale::language(self : Locale) -> String {
  self.language
}

///|
pub fn Locale::script(self : Locale) -> String? {
  self.script
}

///|
pub fn Locale::region(self : Locale) -> String? {
  self.region
}

///|
pub fn Locale::variants(self : Locale) -> Array[String] {
  self.variants.copy()
}

///|
pub fn Locale::tag(self : Locale) -> String {
  let output = StringBuilder::new()
  output.write_string(self.language)
  match self.script {
    Some(value) => {
      output.write_char('-')
      output.write_string(value)
    }
    None => ()
  }
  match self.region {
    Some(value) => {
      output.write_char('-')
      output.write_string(value)
    }
    None => ()
  }
  for variant in self.variants {
    output.write_char('-')
    output.write_string(variant)
  }
  output.to_string()
}

///|
/// Returns the locale with all variants removed.
pub fn Locale::without_variants(self : Locale) -> Locale {
  { ..self, variants: [] }
}

///|
/// Returns the locale with its region removed.
pub fn Locale::without_region(self : Locale) -> Locale {
  { ..self, region: None, variants: [] }
}

///|
/// Returns only the language component.
pub fn Locale::language_only(self : Locale) -> Locale {
  { language: self.language, script: None, region: None, variants: [] }
}

///|
/// Produces a most-specific-to-least-specific fallback chain.
///
/// For `zh-Hans-CN-posix`, this returns `zh-Hans-CN-posix`,
/// `zh-Hans-CN`, `zh-Hans`, and `zh`.
pub fn Locale::fallback_chain(self : Locale) -> Array[Locale] {
  let result : Array[Locale] = [self]
  if !self.variants.is_empty() {
    result.push(self.without_variants())
  }
  match self.region {
    Some(_) => result.push(self.without_region())
    None => ()
  }
  match self.script {
    Some(_) => result.push(self.language_only())
    None =>
      if result[result.length() - 1].tag() != self.language {
        result.push(self.language_only())
      }
  }
  result
}

///|
/// Measures how closely two locales match.
///
/// A negative score means the languages differ. Higher scores prefer exact
/// tags, then matching scripts, regions, and variants.
pub fn Locale::match_score(self : Locale, candidate : Locale) -> Int {
  if self.language != candidate.language {
    return -1
  }
  let mut score = 100
  match (self.script, candidate.script) {
    (Some(left), Some(right)) =>
      if left == right {
        score += 30
      } else {
        return -1
      }
    (None, None) => score += 10
    _ => ()
  }
  match (self.region, candidate.region) {
    (Some(left), Some(right)) => if left == right { score += 20 }
    (None, None) => score += 5
    _ => ()
  }
  if self.variants == candidate.variants {
    score += 10
  }
  if self.tag() == candidate.tag() {
    score += 100
  }
  score
}

///|
/// Selects the best supported locale for a requested locale.
pub fn best_locale(
  requested : Locale,
  supported : Array[Locale],
  default? : Locale? = None,
) -> Locale? {
  let mut best : Locale? = None
  let mut best_score = -1
  for candidate in supported {
    let score = requested.match_score(candidate)
    if score > best_score {
      best = Some(candidate)
      best_score = score
    }
  }
  match best {
    Some(_) => best
    None => default
  }
}

///|
/// Parses a list of supported tags and ignores invalid entries.
pub fn valid_locales(tags : Array[String]) -> Array[Locale] {
  let result : Array[Locale] = []
  for tag in tags {
    match Locale::from_string(tag) {
      Some(locale) => result.push(locale)
      None => ()
    }
  }
  result
}