///|
fn token_kind_for_char(c : Char) -> String {
  if is_blank_char(c) {
    "space"
  } else if c.is_ascii_digit() {
    "number"
  } else if c.is_ascii_alphabetic() {
    "word"
  } else if is_cjk_char(c) {
    "cjk"
  } else if c.is_ascii_punctuation() || c == ',' || c == '。' || c == ':' {
    "punctuation"
  } else {
    "symbol"
  }
}

///|
pub fn tokenize(text : String) -> Array[TextToken] {
  let chars = text.to_array()
  let tokens = []
  if chars.is_empty() {
    tokens
  } else {
    let mut start = 0
    let mut kind = token_kind_for_char(chars[0])
    let mut position = chars[0].to_string().length()
    for i in 1.. Array[TextToken] {
  tokens.filter(fn(item) { item.kind == kind })
}

///|
pub fn non_space_tokens(tokens : Array[TextToken]) -> Array[TextToken] {
  tokens.filter(fn(item) { item.kind != "space" })
}

///|
pub fn token_spans(tokens : Array[TextToken]) -> Array[Span] {
  tokens.map(fn(item) { { start: item.start, end: item.end } })
}

///|
pub fn token_texts(tokens : Array[TextToken]) -> Array[String] {
  tokens.map(fn(item) { item.text })
}

///|
pub fn token_kind_counts(tokens : Array[TextToken]) -> Map[String, Int] {
  let result : Map[String, Int] = Map([])
  for token in tokens {
    result[token.kind] = result.get_or_default(token.kind, 0) + 1
  }
  result
}

///|
pub fn token_char_counts(tokens : Array[TextToken]) -> Map[String, Int] {
  let result : Map[String, Int] = Map([])
  for token in tokens {
    result[token.kind] = result.get_or_default(token.kind, 0) +
      token.text.char_length()
  }
  result
}

///|
pub fn token_count(text : String) -> Int {
  tokenize(text).length()
}

///|
pub fn word_tokens(text : String) -> Array[TextToken] {
  tokens_of_kind(tokenize(text), "word")
}

///|
pub fn number_tokens(text : String) -> Array[TextToken] {
  tokens_of_kind(tokenize(text), "number")
}

///|
pub fn cjk_tokens(text : String) -> Array[TextToken] {
  tokens_of_kind(tokenize(text), "cjk")
}

///|
pub fn token_before(tokens : Array[TextToken], position : Int) -> TextToken? {
  let mut result = None
  for token in tokens {
    if token.end <= position {
      result = Some(token)
    }
  }
  result
}

///|
pub fn token_after(tokens : Array[TextToken], position : Int) -> TextToken? {
  for item in tokens {
    if item.start >= position {
      return Some(item)
    }
  }
  None
}

///|
pub fn tokens_in_span(
  tokens : Array[TextToken],
  span : Span,
) -> Array[TextToken] {
  tokens.filter(fn(item) {
    span.contains_span({ start: item.start, end: item.end })
  })
}

///|
pub fn neighboring_tokens(
  tokens : Array[TextToken],
  index : Int,
  radius : Int,
) -> Array[TextToken] {
  let safe_radius = if radius < 0 { 0 } else { radius }
  let start = if index - safe_radius < 0 { 0 } else { index - safe_radius }
  let end = if index + safe_radius + 1 > tokens.length() {
    tokens.length()
  } else {
    index + safe_radius + 1
  }
  tokens[start:end].to_owned()
}

///|
pub fn token_context(
  text : String,
  token : TextToken,
  radius : Int,
) -> TextWindow {
  text_window(text, (token.start + token.end) / 2, radius)
}

///|
pub fn merge_adjacent_tokens(tokens : Array[TextToken]) -> Array[TextToken] {
  let result : Array[TextToken] = []
  for token in tokens {
    match result.get(result.length() - 1) {
      Some(previous) =>
        if previous.kind == token.kind && previous.end == token.start {
          result[result.length() - 1] = {
            ..previous,
            text: previous.text + token.text,
            end: token.end,
          }
        } else {
          result.push(token)
        }
      None => result.push(token)
    }
  }
  result
}

///|
pub fn token_reconstruct(tokens : Array[TextToken]) -> String {
  tokens.map(fn(item) { item.text }).join("")
}

///|
pub fn tokenization_is_lossless(text : String) -> Bool {
  token_reconstruct(tokenize(text)) == text
}

///|
pub fn tokenization_report(text : String) -> String {
  let tokens = tokenize(text)
  let counts = token_kind_counts(tokens)
  [
    "input_length=\{text.length()}",
    "characters=\{text.char_length()}",
    "tokens=\{tokens.length()}",
    "kinds=\{counts.length()}",
    "lossless=\{tokenization_is_lossless(text)}",
  ].join("\n")
}

///|
pub fn redaction_candidates_from_tokens(
  text : String,
  tokens : Array[TextToken],
  rule_id : String,
  kind : PhiKind,
  confidence : Int,
) -> Array[Finding] {
  tokens
  .filter(fn(item) { item.kind == "word" || item.kind == "cjk" })
  .map(fn(item) {
    let finding : Finding = {
      id: "token-\{item.start}",
      kind,
      label: "token candidate",
      start: item.start,
      end: item.end,
      text: item.text,
      replacement: "[\{phi_kind_name(kind)}]",
      rule_id,
      confidence,
    }
    finding
  })
  .filter(fn(item) { validate_finding_text(text, item) })
}

///|
pub fn redact_token_candidates(
  text : String,
  kind : PhiKind,
  config : RedactionConfig,
) -> String {
  let tokens = non_space_tokens(tokenize(text))
  let candidates = redaction_candidates_from_tokens(
    text, tokens, "token-fallback", kind, 50,
  )
  let filtered = policy_filter_findings(candidates, config.policy)
  let replacement = filtered.map(fn(item) {
    { ..item, replacement: replacement_preview(item, config.policy.mode) }
  })
  let (output, _) = apply_findings(text, replacement)
  output
}

///|
pub fn token_lengths(tokens : Array[TextToken]) -> Array[Int] {
  tokens.map(fn(item) { item.text.length() })
}

///|
pub fn longest_token(tokens : Array[TextToken]) -> TextToken? {
  let mut result = None
  for token in tokens {
    match result {
      None => result = Some(token)
      Some(previous) =>
        if token.text.length() > previous.text.length() {
          result = Some(token)
        }
    }
  }
  result
}

///|
pub fn punctuation_tokens(text : String) -> Array[TextToken] {
  tokens_of_kind(tokenize(text), "punctuation")
}

///|
pub fn whitespace_spans(text : String) -> Array[Span] {
  tokenize(text)
  .filter(fn(item) { item.kind == "space" })
  .map(fn(item) { { start: item.start, end: item.end } })
}

///|
pub fn token_positions(tokens : Array[TextToken]) -> Array[LineColumn] {
  tokens.map(fn(item) { line_column(token_reconstruct(tokens), item.start) })
}

///|
pub fn token_kind_at(tokens : Array[TextToken], position : Int) -> String? {
  for item in tokens {
    if item.start <= position && position < item.end {
      return Some(item.kind)
    }
  }
  None
}

///|
pub fn replace_token(
  tokens : Array[TextToken],
  index : Int,
  value : String,
) -> Array[TextToken] {
  let result = tokens.copy()
  match result.get(index) {
    Some(token) =>
      result[index] = {
        ..token,
        text: value,
        end: token.start + value.length(),
      }
    None => ()
  }
  result
}