///|
/// Controls how application text is transformed for localization testing.
pub struct PseudoOptions {
  accents : Bool
  expand : Bool
  wrap : Bool
} derive(Eq, Debug)

///|
pub fn PseudoOptions::new(
  accents? : Bool = true,
  expand? : Bool = true,
  wrap? : Bool = true,
) -> PseudoOptions {
  { accents, expand, wrap }
}

///|
pub fn PseudoOptions::accents(self : PseudoOptions) -> Bool {
  self.accents
}

///|
pub fn PseudoOptions::expands(self : PseudoOptions) -> Bool {
  self.expand
}

///|
pub fn PseudoOptions::wraps(self : PseudoOptions) -> Bool {
  self.wrap
}

///|
fn pseudo_accent(character : Char) -> Char {
  match character {
    'A' => 'Å'
    'B' => 'Ɓ'
    'C' => 'Ç'
    'D' => 'Ð'
    'E' => 'Ë'
    'F' => 'Ƒ'
    'G' => 'Ĝ'
    'H' => 'Ĥ'
    'I' => 'Ï'
    'J' => 'Ĵ'
    'K' => 'Ķ'
    'L' => 'Ŀ'
    'M' => 'M'
    'N' => 'Ñ'
    'O' => 'Ø'
    'P' => 'Þ'
    'Q' => 'Q'
    'R' => 'Ŕ'
    'S' => 'Š'
    'T' => 'Ţ'
    'U' => 'Û'
    'V' => 'V'
    'W' => 'Ŵ'
    'X' => 'X'
    'Y' => 'Ÿ'
    'Z' => 'Ž'
    'a' => 'å'
    'b' => 'ƀ'
    'c' => 'ç'
    'd' => 'ð'
    'e' => 'ë'
    'f' => 'ƒ'
    'g' => 'ĝ'
    'h' => 'ĥ'
    'i' => 'ï'
    'j' => 'ĵ'
    'k' => 'ķ'
    'l' => 'ŀ'
    'm' => 'm'
    'n' => 'ñ'
    'o' => 'ø'
    'p' => 'þ'
    'q' => 'q'
    'r' => 'ŕ'
    's' => 'š'
    't' => 'ţ'
    'u' => 'û'
    'v' => 'v'
    'w' => 'ŵ'
    'x' => 'x'
    'y' => 'ÿ'
    'z' => 'ž'
    other => other
  }
}

///|
fn pseudo_is_vowel(character : Char) -> Bool {
  match character {
    'A' | 'E' | 'I' | 'O' | 'U' | 'a' | 'e' | 'i' | 'o' | 'u' => true
    _ => false
  }
}

///|
fn transform_pseudo_text(text : String, options : PseudoOptions) -> String {
  let output = StringBuilder::new()
  for character in text {
    let transformed = if options.accents {
      pseudo_accent(character)
    } else {
      character
    }
    output.write_char(transformed)
    if options.expand && pseudo_is_vowel(character) {
      output.write_char(transformed)
    }
  }
  output.to_string()
}

///|
/// Transforms plain UI text so untranslated strings and cramped layouts stand
/// out during testing.
pub fn pseudo_localize(
  text : String,
  options? : PseudoOptions = PseudoOptions::new(),
) -> String {
  let transformed = transform_pseudo_text(text, options)
  if options.wrap {
    "[!! \{transformed} !!]"
  } else {
    transformed
  }
}

///|
fn pseudo_message_nodes(
  nodes : Array[MessageNode],
  options : PseudoOptions,
) -> Array[MessageNode] {
  nodes.map(fn(node) {
    match node {
      Text(text) => Text(transform_pseudo_text(text, options))
      Argument(name) => Argument(name)
      Select(name, cases) =>
        Select(
          name,
          cases.map(fn(branch) {
            MessageCase::new(
              branch.selector,
              pseudo_message_nodes(branch.nodes, options),
            )
          }),
        )
      Plural(name, offset, kind, cases) =>
        Plural(
          name,
          offset,
          kind,
          cases.map(fn(branch) {
            MessageCase::new(
              branch.selector,
              pseudo_message_nodes(branch.nodes, options),
            )
          }),
        )
    }
  })
}

///|
/// Pseudo-localizes literal text while preserving arguments, selectors, plural
/// rules, and the template's original source for diagnostics.
pub fn MessageTemplate::pseudo_localized(
  self : MessageTemplate,
  options? : PseudoOptions = PseudoOptions::new(),
) -> MessageTemplate {
  let transformed = pseudo_message_nodes(self.nodes, options)
  let nodes = if options.wrap {
    let wrapped : Array[MessageNode] = [Text("[!! ")]
    for node in transformed {
      wrapped.push(node)
    }
    wrapped.push(Text(" !!]"))
    wrapped
  } else {
    transformed
  }
  { source: self.source, nodes }
}

///|
/// Creates a pseudo-locale catalog without reparsing or modifying arguments.
pub fn MessageCatalog::pseudo_localized(
  self : MessageCatalog,
  locale : Locale,
  options? : PseudoOptions = PseudoOptions::new(),
) -> MessageCatalog {
  {
    locale,
    messages: self.messages.map(fn(message) {
      {
        key: message.key,
        template: message.template.pseudo_localized(options~),
      }
    }),
  }
}

///|
/// A concrete difference between a reference catalog and a translation.
pub(all) enum CatalogAuditIssue {
  MissingKey(String, String)
  UnexpectedKey(String, String)
  ArgumentSetMismatch(String, String, Array[String], Array[String])
} derive(Eq, Debug)

///|
pub fn CatalogAuditIssue::locale_tag(self : CatalogAuditIssue) -> String {
  match self {
    MissingKey(tag, _)
    | UnexpectedKey(tag, _)
    | ArgumentSetMismatch(tag, _, _, _) => tag
  }
}

///|
pub fn CatalogAuditIssue::key(self : CatalogAuditIssue) -> String {
  match self {
    MissingKey(_, key)
    | UnexpectedKey(_, key)
    | ArgumentSetMismatch(_, key, _, _) => key
  }
}

///|
pub fn CatalogAuditIssue::message(self : CatalogAuditIssue) -> String {
  match self {
    MissingKey(tag, key) => "\{tag} is missing message '\{key}'"
    UnexpectedKey(tag, key) => "\{tag} has unexpected message '\{key}'"
    ArgumentSetMismatch(tag, key, expected, actual) =>
      "\{tag} message '\{key}' uses arguments [\{actual.join(", ")}], expected [\{expected.join(", ")}]"
  }
}

///|
/// Summary of a reference-to-translations catalog comparison.
pub struct CatalogAudit {
  reference_locale : Locale
  checked_catalogs : Int
  issues : Array[CatalogAuditIssue]
} derive(Eq, Debug)

///|
pub fn CatalogAudit::reference_locale(self : CatalogAudit) -> Locale {
  self.reference_locale
}

///|
pub fn CatalogAudit::checked_catalogs(self : CatalogAudit) -> Int {
  self.checked_catalogs
}

///|
pub fn CatalogAudit::issues(self : CatalogAudit) -> Array[CatalogAuditIssue] {
  self.issues.copy()
}

///|
pub fn CatalogAudit::is_complete(self : CatalogAudit) -> Bool {
  self.issues.is_empty()
}

///|
pub fn CatalogAudit::missing_count(self : CatalogAudit) -> Int {
  let mut count = 0
  for issue in self.issues {
    if issue is MissingKey(_, _) {
      count = count + 1
    }
  }
  count
}

///|
pub fn CatalogAudit::unexpected_count(self : CatalogAudit) -> Int {
  let mut count = 0
  for issue in self.issues {
    if issue is UnexpectedKey(_, _) {
      count = count + 1
    }
  }
  count
}

///|
pub fn CatalogAudit::argument_mismatch_count(self : CatalogAudit) -> Int {
  let mut count = 0
  for issue in self.issues {
    if issue is ArgumentSetMismatch(_, _, _, _) {
      count = count + 1
    }
  }
  count
}

///|
fn string_array_contains(values : Array[String], expected : String) -> Bool {
  for value in values {
    if value == expected {
      return true
    }
  }
  false
}

///|
fn same_string_set(left : Array[String], right : Array[String]) -> Bool {
  if left.length() != right.length() {
    return false
  }
  for value in left {
    if !string_array_contains(right, value) {
      return false
    }
  }
  true
}

///|
/// Compares every translation with a source-of-truth catalog.
///
/// Argument order may differ between languages, but names must form the same
/// set so messages cannot fail only after reaching production.
pub fn audit_catalogs(
  reference : MessageCatalog,
  translations : Array[MessageCatalog],
) -> CatalogAudit {
  let issues : Array[CatalogAuditIssue] = []
  for catalog in translations {
    let tag = catalog.locale.tag()
    for message in reference.messages {
      match catalog.get(message.key) {
        None => issues.push(MissingKey(tag, message.key))
        Some(translated) => {
          let expected = message.template.arguments()
          let actual = translated.arguments()
          if !same_string_set(expected, actual) {
            issues.push(ArgumentSetMismatch(tag, message.key, expected, actual))
          }
        }
      }
    }
    for message in catalog.messages {
      if !reference.contains(message.key) {
        issues.push(UnexpectedKey(tag, message.key))
      }
    }
  }
  {
    reference_locale: reference.locale,
    checked_catalogs: translations.length(),
    issues,
  }
}