///|
/// Input normalization is explicit so audit records can explain every
/// transformation before detection.
pub(all) enum NormalizationMode {
  NoNormalization
  MatchingInput
  ClinicalInput
  StrictInput
} derive(Debug, Eq)

///|
pub(all) struct NormalizationProfile {
  name : String
  mode : NormalizationMode
  trim_input : Bool
  collapse_space : Bool
  normalize_punctuation : Bool
  normalize_line_endings : Bool
  remove_control : Bool
  preserve_newlines : Bool
} derive(Debug, Eq)

///|
pub(all) struct NormalizationChange {
  code : String
  start : Int
  end : Int
  before : String
  after : String
} derive(Debug, Eq)

///|
pub(all) struct NormalizedDocument {
  original : String
  normalized : String
  profile : NormalizationProfile
  changes : Array[NormalizationChange]
  checksum : String
} derive(Debug, Eq)

///|
pub fn normalization_mode_name(mode : NormalizationMode) -> String {
  match mode {
    NoNormalization => "none"
    MatchingInput => "matching"
    ClinicalInput => "clinical"
    StrictInput => "strict"
  }
}

///|
pub fn NormalizationProfile::none() -> NormalizationProfile {
  {
    name: "none",
    mode: NoNormalization,
    trim_input: false,
    collapse_space: false,
    normalize_punctuation: false,
    normalize_line_endings: false,
    remove_control: false,
    preserve_newlines: true,
  }
}

///|
pub fn NormalizationProfile::matching() -> NormalizationProfile {
  {
    name: "matching",
    mode: MatchingInput,
    trim_input: true,
    collapse_space: true,
    normalize_punctuation: true,
    normalize_line_endings: true,
    remove_control: true,
    preserve_newlines: true,
  }
}

///|
pub fn NormalizationProfile::clinical() -> NormalizationProfile {
  {
    ..NormalizationProfile::matching(),
    name: "clinical",
    mode: ClinicalInput,
    collapse_space: false,
    preserve_newlines: true,
  }
}

///|
pub fn NormalizationProfile::strict() -> NormalizationProfile {
  {
    ..NormalizationProfile::matching(),
    name: "strict",
    mode: StrictInput,
    preserve_newlines: false,
  }
}

///|
pub fn normalization_profile_for_mode(
  mode : NormalizationMode,
) -> NormalizationProfile {
  match mode {
    NoNormalization => NormalizationProfile::none()
    MatchingInput => NormalizationProfile::matching()
    ClinicalInput => NormalizationProfile::clinical()
    StrictInput => NormalizationProfile::strict()
  }
}

///|
pub fn is_control_character(c : Char) -> Bool {
  let value = c.to_int()
  value < 32 && c != '\n' && c != '\r' && c != '\t'
}

///|
pub fn remove_control_characters(text : String) -> String {
  let builder = StringBuilder()
  for c in text {
    if !is_control_character(c) {
      builder.write_char(c)
    }
  }
  builder.to_string()
}

///|
pub fn normalize_line_endings(
  text : String,
  preserve_newlines : Bool,
) -> String {
  let builder = StringBuilder()
  let mut previous_cr = false
  for c in text {
    if c == '\r' {
      if preserve_newlines {
        builder.write_char('\n')
      }
      previous_cr = true
    } else if c == '\n' {
      if !previous_cr && preserve_newlines {
        builder.write_char('\n')
      }
      previous_cr = false
    } else {
      builder.write_char(c)
      previous_cr = false
    }
  }
  builder.to_string()
}

///|
pub fn normalize_fullwidth_digits(text : String) -> String {
  let builder = StringBuilder()
  for c in text {
    let value = c.to_int()
    if value >= '0'.to_int() && value <= '9'.to_int() {
      builder.write_char(
        (value - '0'.to_int() + '0'.to_int()).to_char().unwrap(),
      )
    } else {
      builder.write_char(c)
    }
  }
  builder.to_string()
}

///|
pub fn normalize_fullwidth_letters(text : String) -> String {
  let builder = StringBuilder()
  for c in text {
    let value = c.to_int()
    if value >= 'A'.to_int() && value <= 'Z'.to_int() {
      builder.write_char(
        (value - 'A'.to_int() + 'A'.to_int()).to_char().unwrap(),
      )
    } else if value >= 'a'.to_int() && value <= 'z'.to_int() {
      builder.write_char(
        (value - 'a'.to_int() + 'a'.to_int()).to_char().unwrap(),
      )
    } else {
      builder.write_char(c)
    }
  }
  builder.to_string()
}

///|
pub fn normalize_width(text : String) -> String {
  normalize_fullwidth_letters(normalize_fullwidth_digits(text))
}

///|
pub fn normalize_clinical_text(text : String) -> String {
  let width = normalize_width(text)
  let punctuation = normalize_punctuation(width)
  let lines = normalize_line_endings(punctuation, true)
  remove_control_characters(lines)
}

///|
pub fn normalize_for_profile(
  text : String,
  profile : NormalizationProfile,
) -> String {
  let mut result = text
  if profile.normalize_line_endings {
    result = normalize_line_endings(result, profile.preserve_newlines)
  }
  if profile.remove_control {
    result = remove_control_characters(result)
  }
  result = normalize_width(result)
  if profile.normalize_punctuation {
    result = normalize_punctuation(result)
  }
  if profile.collapse_space {
    result = collapse_spaces(result)
  }
  if profile.trim_input {
    result = result.trim().to_owned()
  }
  result
}

///|
pub fn normalization_changes(
  original : String,
  normalized : String,
) -> Array[NormalizationChange] {
  if original == normalized {
    []
  } else {
    [
      {
        code: "NORMALIZED",
        start: 0,
        end: original.length(),
        before: original,
        after: normalized,
      },
    ]
  }
}

///|
pub fn normalize_document(
  text : String,
  profile : NormalizationProfile,
) -> NormalizedDocument {
  let normalized = normalize_for_profile(text, profile)
  {
    original: text,
    normalized,
    profile,
    changes: normalization_changes(text, normalized),
    checksum: stable_hash(normalized),
  }
}

///|
pub fn NormalizedDocument::changed(self : NormalizedDocument) -> Bool {
  self.original != self.normalized
}

///|
pub fn NormalizedDocument::change_count(self : NormalizedDocument) -> Int {
  self.changes.length()
}

///|
pub fn NormalizedDocument::summary(self : NormalizedDocument) -> String {
  [
    "profile=\{self.profile.name}",
    "mode=\{normalization_mode_name(self.profile.mode)}",
    "original_length=\{self.original.length()}",
    "normalized_length=\{self.normalized.length()}",
    "changed=\{self.changed()}",
    "checksum=\{self.checksum}",
  ].join("\n")
}

///|
pub fn normalization_change_json(change : NormalizationChange) -> String {
  "{" +
  "\"code\":\{json_escape(change.code)}," +
  "\"start\":\{change.start}," +
  "\"end\":\{change.end}," +
  "\"before\":\{json_escape(change.before)}," +
  "\"after\":\{json_escape(change.after)}" +
  "}"
}

///|
pub fn NormalizedDocument::to_json(self : NormalizedDocument) -> String {
  "{" +
  "\"profile\":\{json_escape(self.profile.name)}," +
  "\"mode\":\{json_escape(normalization_mode_name(self.profile.mode))}," +
  "\"original_length\":\{self.original.length()}," +
  "\"normalized_length\":\{self.normalized.length()}," +
  "\"checksum\":\{json_escape(self.checksum)}," +
  "\"changes\":[" +
  self.changes.map(normalization_change_json).join(",") +
  "]}"
}

///|
pub fn normalization_is_idempotent(
  text : String,
  profile : NormalizationProfile,
) -> Bool {
  let once = normalize_for_profile(text, profile)
  normalize_for_profile(once, profile) == once
}

///|
pub fn normalization_preserves_char_count(
  original : String,
  normalized : String,
) -> Bool {
  original.char_length() == normalized.char_length()
}

///|
pub fn normalization_preserves_newline_count(
  original : String,
  normalized : String,
) -> Bool {
  count_char(original, '\n') + count_char(original, '\r') ==
  count_char(normalized, '\n')
}

///|
pub fn normalization_safe_for_offsets(profile : NormalizationProfile) -> Bool {
  !profile.normalize_punctuation &&
  !profile.collapse_space &&
  !profile.remove_control &&
  profile.preserve_newlines
}

///|
pub fn normalization_profile_summary(profile : NormalizationProfile) -> String {
  [
    "name=\{profile.name}",
    "mode=\{normalization_mode_name(profile.mode)}",
    "trim=\{profile.trim_input}",
    "collapse_space=\{profile.collapse_space}",
    "punctuation=\{profile.normalize_punctuation}",
    "line_endings=\{profile.normalize_line_endings}",
    "remove_control=\{profile.remove_control}",
    "preserve_newlines=\{profile.preserve_newlines}",
  ].join("\n")
}

///|
pub fn text_has_mixed_width(text : String) -> Bool {
  let mut ascii = false
  let mut full = false
  for c in text {
    let value = c.to_int()
    if c.is_ascii_digit() || c.is_ascii_alphabetic() {
      ascii = true
    }
    if (value >= '0'.to_int() && value <= '9'.to_int()) ||
      (value >= 'A'.to_int() && value <= 'Z'.to_int()) ||
      (value >= 'a'.to_int() && value <= 'z'.to_int()) {
      full = true
    }
  }
  ascii && full
}

///|
pub fn text_has_control_characters(text : String) -> Bool {
  text.to_array().any(is_control_character)
}

///|
pub fn text_has_noncanonical_punctuation(text : String) -> Bool {
  text
  .to_array()
  .any(fn(c) {
    c == ':' ||
    c == ',' ||
    c == ';' ||
    c == '(' ||
    c == ')' ||
    c == '[' ||
    c == ']' ||
    c == '—' ||
    c == '–' ||
    c == '/'
  })
}

///|
pub fn normalization_risk_flags(text : String) -> Array[String] {
  let flags = []
  if text_has_mixed_width(text) {
    flags.push("mixed_width")
  }
  if text_has_control_characters(text) {
    flags.push("control_characters")
  }
  if text_has_noncanonical_punctuation(text) {
    flags.push("noncanonical_punctuation")
  }
  if text.contains("\r\n") {
    flags.push("crlf")
  }
  flags
}

///|
pub fn normalization_risk_score(text : String) -> Int {
  normalization_risk_flags(text).length() * 25
}

///|
pub fn normalization_requires_review(text : String) -> Bool {
  normalization_risk_score(text) >= 50
}

///|
pub fn normalization_replace_char(
  text : String,
  from : Char,
  to : Char,
) -> String {
  let builder = StringBuilder()
  for c in text {
    if c == from {
      builder.write_char(to)
    } else {
      builder.write_char(c)
    }
  }
  builder.to_string()
}

///|
pub fn normalization_remove_chars(text : String, chars : Array[Char]) -> String {
  let builder = StringBuilder()
  for c in text {
    if !chars.contains(c) {
      builder.write_char(c)
    }
  }
  builder.to_string()
}

///|
pub fn normalization_keep_chars(text : String, chars : Array[Char]) -> String {
  let builder = StringBuilder()
  for c in text {
    if chars.contains(c) {
      builder.write_char(c)
    }
  }
  builder.to_string()
}

///|
pub fn normalization_line_lengths(text : String) -> Array[Int] {
  split_lines_with_offsets(text).map(fn(line) { line.text.length() })
}

///|
pub fn normalization_longest_line(text : String) -> Int {
  normalization_line_lengths(text).fold(init=0, (best, item) => {
    if item > best {
      item
    } else {
      best
    }
  })
}

///|
pub fn normalization_blank_line_count(text : String) -> Int {
  split_lines_with_offsets(text)
  .filter(fn(line) { line.text.trim().is_empty() })
  .length()
}

///|
pub fn normalization_word_count(text : String) -> Int {
  text
  .split(" ")
  .to_array()
  .filter(fn(item) { !item.trim().is_empty() })
  .length()
}

///|
pub fn normalization_checksum(
  text : String,
  profile : NormalizationProfile,
) -> String {
  stable_hash(profile.name + "\u{1f}" + normalize_for_profile(text, profile))
}

///|
pub fn normalization_compare(
  left : NormalizedDocument,
  right : NormalizedDocument,
) -> String {
  [
    "same_original=\{left.original == right.original}",
    "same_normalized=\{left.normalized == right.normalized}",
    "left_checksum=\{left.checksum}",
    "right_checksum=\{right.checksum}",
    "left_changes=\{left.change_count()}",
    "right_changes=\{right.change_count()}",
  ].join("\n")
}

///|
pub fn normalization_pipeline(
  text : String,
  modes : Array[NormalizationMode],
) -> Array[NormalizedDocument] {
  let result = []
  let mut current = text
  for mode in modes {
    let item = normalize_document(current, normalization_profile_for_mode(mode))
    result.push(item)
    current = item.normalized
  }
  result
}

///|
pub fn normalization_pipeline_text(
  text : String,
  modes : Array[NormalizationMode],
) -> String {
  match normalization_pipeline(text, modes).last() {
    Some(item) => item.normalized
    None => text
  }
}

///|
pub fn normalization_report(text : String) -> String {
  let flags = normalization_risk_flags(text)
  let clinical = normalize_document(text, NormalizationProfile::clinical())
  let flag_text = flags.join(",")
  [
    "input_length=\{text.length()}",
    "risk_score=\{normalization_risk_score(text)}",
    "flags=\{flag_text}",
    "clinical_length=\{clinical.normalized.length()}",
    "idempotent=\{normalization_is_idempotent(text, NormalizationProfile::clinical())}",
    "requires_review=\{normalization_requires_review(text)}",
  ].join("\n")
}