// num2words-moonbit.mbt — public API and dispatch.
//
// Port of the Python `num2words` library (LGPL-2.1). See README / base.mbt header
// for attribution. Acknowledged upstream: https://github.com/savoirfairelinux/num2words
//
// API: `num2words(value, lang, to)` where `value` is a BigInt (or use the
// `num2words_int` / `num2words_float` convenience wrappers), `lang` is
// "en" | "fr" | "es" | "de" | "it" | "pt" | "pt_PT" | "ru", and `to` is
// "cardinal" (default), "ordinal", "ordinal_num", "year" or "currency".
//
// Currency takes a decimal amount string and an ISO-4217 code through
// `num2words_currency`; the `to = "currency"` dispatch path uses the language
// default code (EUR, or BRL for `pt`).

// --- input validation -------------------------------------------------------
//
// `BigInt::from_string` aborts on malformed input, so every string entry point
// validates first. Accepted grammar: `-?\d+` or `-?\d+\.\d+`.

///|
/// True when `s` is non-empty and made only of ASCII digits.
fn all_digits(s : String) -> Bool {
  if s.length() == 0 {
    return false
  }
  for i = 0; i < s.length(); i = i + 1 {
    if !"0123456789".contains(s[i:i + 1].to_owned()) {
      return false
    }
  }
  true
}

///|
/// True for an integer literal: an optional leading `-` followed by digits.
fn is_int_part(s : String) -> Bool {
  let body = if s.length() > 0 && s[0:1].to_owned() == "-" {
    s[1:s.length()].to_owned()
  } else {
    s
  }
  all_digits(body)
}

///|
/// Validate a decimal/integer string before it reaches `BigInt`.
///
/// Accepted: `1234`, `-42`, `007`, `1234.56`
/// Rejected: `""`, `"-"`, `"5."`, `".5"`, `"1.2.3"`, `"12a"`, `"abc"`, `"--5"`
pub fn is_valid_number(s : String) -> Bool {
  let parts = s.split(".").map(fn(w) { w.to_owned() }).collect()
  if parts.length() > 2 {
    return false
  }
  if !is_int_part(parts[0]) {
    return false
  }
  if parts.length() == 2 {
    if !all_digits(parts[1]) {
      return false
    }
  }
  true
}

///|
/// Position of the single-character string `target` inside `s`, or -1.
fn index_of_char(s : String, target : String) -> Int {
  for i = 0; i < s.length(); i = i + 1 {
    if s[i:i + 1].to_owned() == target {
      return i
    }
  }
  -1
}

///|
/// Parse the (signed) exponent digits of a scientific-notation string.
fn parse_exp_int(s : String) -> Int {
  let mut neg = false
  let mut i = 0
  if s.length() > 0 && s[0:1].to_owned() == "+" {
    i = 1
  } else if s.length() > 0 && s[0:1].to_owned() == "-" {
    neg = true
    i = 1
  }
  let mut v = 0
  while i < s.length() {
    v = v * 10 + BigInt::from_string(s[i:i + 1].to_owned()).to_int()
    i = i + 1
  }
  if neg {
    -v
  } else {
    v
  }
}

///|
/// Expand a `Double` rendered in scientific notation (e.g. `1e+21`, `1e-7`,
/// `1.5e+21`) into a plain decimal string. Values already in plain form are
/// returned unchanged. `Double::to_string` switches to scientific notation for
/// magnitudes at or above 10^21 and below 10^-6; passing that form straight to
/// `BigInt::from_string` would abort, so it is expanded first.
fn expand_scientific(s : String) -> String {
  let e_pos = index_of_char(s, "e")
  let e_pos_upper = index_of_char(s, "E")
  let pos = if e_pos >= 0 { e_pos } else { e_pos_upper }
  if pos < 0 {
    return s
  }
  let mantissa = s[0:pos].to_owned()
  let exp = parse_exp_int(s[pos + 1:s.length()].to_owned())
  let neg = mantissa.length() > 0 && mantissa[0:1].to_owned() == "-"
  let body = if neg {
    mantissa[1:mantissa.length()].to_owned()
  } else {
    mantissa
  }
  let dot = index_of_char(body, ".")
  let int_len = if dot >= 0 { dot } else { body.length() }
  let digits = if dot >= 0 {
    body[0:dot].to_owned() + body[dot + 1:body.length()].to_owned()
  } else {
    body
  }
  let point = int_len + exp
  let mut out = ""
  if point <= 0 {
    out = "0."
    for i = point; i < 0; i = i + 1 {
      out = out + "0"
    }
    out = out + digits
  } else if point >= digits.length() {
    out = digits
    for i = digits.length(); i < point; i = i + 1 {
      out = out + "0"
    }
  } else {
    out = digits[0:point].to_owned() +
      "." +
      digits[point:digits.length()].to_owned()
  }
  if neg {
    "-" + out
  } else {
    out
  }
}

///|
/// Spell the fractional digits of a decimal: `"5"` -> `" point five"`.
fn decimal_tail(l : Lang, frac : String) -> String {
  let mut out = " " + l.decimal
  for i = 0; i < frac.length(); i = i + 1 {
    let d = BigInt::from_string(frac[i:i + 1].to_owned()).to_int()
    out = out + " " + l.ones[d]
  }
  out
}

///|
/// Resolve a language code to its `Lang` descriptor.
pub fn get_lang(code : String) -> Lang {
  match code {
    "en" => lang_en()
    "fr" => lang_fr()
    "es" => lang_es()
    "de" => lang_de()
    "it" => lang_it()
    "pt" => lang_pt()
    "pt_PT" => lang_pt_pt()
    "ru" => lang_ru()
    _ => lang_en()
  }
}

///|
/// Upstream raises TypeError for negative ordinals in every language except
/// Italian ("meno ..."); our string API returns "" for those instead.
fn ordinal_for(l : Lang, value : BigInt) -> String {
  match l.variant {
    En => ordinal_en(value)
    Fr => ordinal_fr(value)
    Es => ordinal_es(value)
    De => ordinal_de(value)
    It => ordinal_it(value)
    Pt => ordinal_pt(value)
    PtPt => ordinal_pt_pt(value)
    Ru => ordinal_ru(value)
  }
}

///|
/// ordinal_num formats: en "21st", fr "21me"/"1er", es/pt "21º", de "21.",
/// it/ru plain "21" (upstream falls through to str(value)).
fn ordinal_num_for(l : Lang, value : BigInt) -> String {
  match l.variant {
    En => ordinal_num_en(value)
    Fr => ordinal_num_fr(value)
    Es => ordinal_num_es(value)
    De => ordinal_num_de(value)
    It => ordinal_num_it(value)
    Pt => ordinal_num_pt(value)
    PtPt => ordinal_num_pt(value)
    Ru => ordinal_num_ru(value)
  }
}

///|
fn year_for(l : Lang, value : BigInt) -> String {
  match l.variant {
    En => year_en(value)
    Fr => year_fr(value)
    Es => year_es(value)
    De => year_de(value)
    It => year_it(value)
    Pt => year_pt(value)
    PtPt => year_pt_pt(value)
    Ru => year_ru(value)
  }
}

///|
/// Currency spelling with the default code (EUR); `""` when the language or
/// the amount is not supported.
fn currency_string(amount : String, lang : String) -> String {
  match num2words_currency(amount, lang, "EUR") {
    Ok(words) => words
    Err(_) => ""
  }
}

///|
/// Spell a big integer.
pub fn num2words(value : BigInt, lang : String, to : String) -> String {
  let l = get_lang(lang)
  if !l.supports(value) {
    // Beyond the language's scale-word table: report as unsupported rather
    // than indexing past the end of the table.
    return ""
  }
  match to {
    "cardinal" => l.cardinal(value)
    "ordinal" => ordinal_for(l, value)
    "ordinal_num" => ordinal_num_for(l, value)
    "year" => year_for(l, value)
    "currency" => currency_string(value.to_string(), lang)
    _ => l.cardinal(value)
  }
}

///|
/// Convenience wrapper for `Int` values.
pub fn num2words_int(value : Int, lang : String, to : String) -> String {
  num2words(BigInt::from_int(value), lang, to)
}

///|
/// Spell a decimal number: integer part in words, fractional digits spelled
/// out one by one after the decimal word (e.g. 1234.56 -> "... point five six").
///
/// Scientific-notation values (very large or very small doubles) are expanded
/// to a plain decimal first. Values too large for the language's scale table,
/// and non-finite values (`NaN` / infinity), return `""` instead of aborting.
/// With `to = "currency"` the amount is rendered with the default code (EUR).
pub fn num2words_float(value : Double, lang : String, to : String) -> String {
  let s = expand_scientific(value.to_string())
  if to == "currency" {
    return currency_string(s, lang)
  }
  let l = get_lang(lang)
  if !is_valid_number(s) {
    return ""
  }
  let neg = s.length() > 0 && s[0:1].to_owned() == "-"
  let parts = s.split(".").map(fn(w) { w.to_owned() }).collect()
  let int_part = BigInt::from_string(parts[0])
  if !l.supports(int_part) {
    return ""
  }
  let mut out = l.cardinal(int_part)
  if parts.length() > 1 {
    out = out + decimal_tail(l, parts[1])
    // `-0` is normalised to zero by `BigInt`, so restore the minus sign for
    // negative values below one (e.g. -0.5 -> "minus zero point five").
    if neg && int_part == BigInt::from_int(0) {
      out = l.negative + " " + out
    }
  }
  out
}

///|
/// Spell a number given directly as a decimal/integer string, e.g.
/// `num2words_str("1234.56", "en", "cardinal")` ->
/// "one thousand, two hundred and thirty-four point five six".
/// Integer and arbitrarily large values are supported via `BigInt`.
///
/// Malformed input returns `""` instead of aborting; use
/// `num2words_str_checked` when the caller needs to tell the two apart.
pub fn num2words_str(value : String, lang : String, to : String) -> String {
  if to == "currency" {
    return currency_string(value, lang)
  }
  if !is_valid_number(value) {
    return ""
  }
  let l = get_lang(lang)
  let parts = value.split(".").map(fn(w) { w.to_owned() }).collect()
  if parts.length() > 1 {
    let int_part = BigInt::from_string(parts[0])
    if !l.supports(int_part) {
      // An out-of-range integer part must yield "" as a whole; spelling the
      // decimal tail on its own would leak " point five" and friends.
      return ""
    }
    let mut out = l.cardinal(int_part) + decimal_tail(l, parts[1])
    // Restore the sign for negative values below one (e.g. -0.5).
    let neg = value.length() > 0 && value[0:1].to_owned() == "-"
    if neg && int_part == BigInt::from_int(0) {
      out = l.negative + " " + out
    }
    out
  } else {
    num2words(BigInt::from_string(value), lang, to)
  }
}

///|
/// Same as `num2words_str`, but rejects bad input explicitly:
/// `Ok(words)` on success, `Err(message)` when `value` is either malformed
/// (`Err("invalid number: ...")`) or larger than the language's scale-word
/// table (`Err("magnitude out of supported range: ...")`). Currency mode also
/// reports unsupported languages through `Err`.
pub fn num2words_str_checked(
  value : String,
  lang : String,
  to : String,
) -> Result[String, String] {
  if to == "currency" {
    return num2words_currency(value, lang, "EUR")
  }
  if !is_valid_number(value) {
    return Err("invalid number: " + value)
  }
  let l = get_lang(lang)
  let int_part = value.split(".").map(fn(w) { w.to_owned() }).collect()[0]
  if !l.supports(BigInt::from_string(int_part)) {
    return Err("magnitude out of supported range: " + value)
  }
  Ok(num2words_str(value, lang, to))
}