///|
fn anchor_is_ascii_word(c : Char) -> Bool {
  c.is_ascii_alphabetic() || c.is_ascii_digit() || c == '_' || c == '-'
}

///|
fn anchor_is_number(text : String) -> Bool {
  if text.is_empty() {
    return false
  }
  let mut seen_digit = false
  let mut valid = true
  for c in text {
    if c.is_ascii_digit() {
      seen_digit = true
    } else if c != '.' && c != ',' && c != '%' && c != '-' {
      valid = false
    }
  }
  seen_digit && valid
}

///|
fn anchor_is_url(text : String) -> Bool {
  text.has_prefix("http://") ||
  text.has_prefix("https://") ||
  text.has_prefix("www.")
}

///|
fn anchor_is_identifier(text : String) -> Bool {
  if text.is_empty() {
    return false
  }
  let mut has_letter = false
  let mut valid = true
  for c in text {
    if c.is_ascii_alphabetic() {
      has_letter = true
    } else if !anchor_is_ascii_word(c) {
      valid = false
    }
  }
  has_letter &&
  valid &&
  (text.contains("_") || text.contains("-") || text.contains("/"))
}

///|
fn anchor_normalize(text : String, case_sensitive : Bool) -> String {
  let value = text.trim().to_owned()
  if case_sensitive {
    value
  } else {
    value.to_lower()
  }
}

///|
fn anchor_tokenize(text : String) -> Array[String] {
  let out = []
  let mut current = ""
  for c in text {
    if anchor_is_ascii_word(c) || !c.is_ascii() {
      current = current + "{c}"
    } else if !current.is_empty() {
      out.push(current)
      current = ""
    }
  }
  if !current.is_empty() {
    out.push(current)
  }
  out
}

///|
fn anchor_kind(token : String, options : AnchorOptions) -> String? {
  if options.include_urls && anchor_is_url(token) {
    return Some("url")
  }
  if options.include_numbers && anchor_is_number(token) {
    return Some("number")
  }
  if options.include_identifiers && anchor_is_identifier(token) {
    return Some("identifier")
  }
  if token.char_length() >= options.min_token_length &&
    token.to_array().iter().any(fn(c) { c.is_ascii_alphabetic() }) {
    Some("word")
  } else {
    None
  }
}

///|
fn anchor_weight(kind : String, token : String) -> Double {
  let base = match kind {
    "url" => 1.0
    "number" => 0.95
    "identifier" => 0.9
    _ => 0.55
  }
  base + token.char_length().min(24).to_double() / 100.0
}

///|
/// Extract deterministic anchors from segmented units.
pub fn extract_anchors(
  units : Array[TextUnit],
  options? : AnchorOptions = default_anchor_options(),
) -> Array[LexicalAnchor] {
  let anchors = []
  for unit in units {
    let seen : Map[String, Bool] = Map([])
    for token in anchor_tokenize(unit.normalized) {
      let normalized = anchor_normalize(token, options.case_sensitive)
      if seen.contains(normalized) {
        continue
      }
      match anchor_kind(token, options) {
        Some(kind) => {
          seen[normalized] = true
          anchors.push({
            source_unit: unit.id,
            target_unit: unit.id,
            source_text: token,
            target_text: token,
            normalized,
            kind,
            score: anchor_weight(kind, token),
          })
        }
        None => ()
      }
      if anchors.length() >= options.max_anchors_per_unit * (unit.id + 1) {
        break
      }
    }
  }
  anchors
}

///|
/// Return normalized token overlap between two units.
pub fn token_overlap(left : TextUnit, right : TextUnit) -> Double {
  let left_tokens = anchor_tokenize(left.normalized)
  let right_tokens = anchor_tokenize(right.normalized)
  if left_tokens.is_empty() || right_tokens.is_empty() {
    return 0.0
  }
  let right_set : Map[String, Bool] = Map([])
  for token in right_tokens {
    right_set[anchor_normalize(token, false)] = true
  }
  let mut common = 0
  let seen : Map[String, Bool] = Map([])
  for token in left_tokens {
    let key = anchor_normalize(token, false)
    if right_set.contains(key) && !seen.contains(key) {
      common += 1
      seen[key] = true
    }
  }
  2.0 *
  common.to_double() /
  (left_tokens.length() + right_tokens.length()).to_double()
}

///|
/// Create cross-language anchor candidates using shared normalized tokens.
pub fn match_anchors(
  source_units : Array[TextUnit],
  target_units : Array[TextUnit],
  options? : AnchorOptions = default_anchor_options(),
) -> Array[LexicalAnchor] {
  let result = []
  for source in source_units {
    let source_tokens = anchor_tokenize(source.normalized)
    let mut added = 0
    for target in target_units {
      if added >= options.max_anchors_per_unit {
        break
      }
      let target_tokens = anchor_tokenize(target.normalized)
      for source_token in source_tokens {
        let source_key = anchor_normalize(source_token, options.case_sensitive)
        match anchor_kind(source_token, options) {
          Some(kind) => {
            let mut found = false
            for target_token in target_tokens {
              if source_key ==
                anchor_normalize(target_token, options.case_sensitive) {
                result.push({
                  source_unit: source.id,
                  target_unit: target.id,
                  source_text: source_token,
                  target_text: target_token,
                  normalized: source_key,
                  kind,
                  score: anchor_weight(kind, source_token),
                })
                added += 1
                found = true
                break
              }
            }
            if found {
              break
            }
          }
          None => ()
        }
      }
    }
  }
  result
}

///|
/// Count anchors grouped by their category.
pub fn anchor_kind_counts(anchors : Array[LexicalAnchor]) -> Map[String, Int] {
  let counts : Map[String, Int] = Map([])
  for anchor in anchors {
    counts[anchor.kind] = counts.get_or_default(anchor.kind, 0) + 1
  }
  counts
}

///|
/// Compute an anchor bonus for a proposed alignment pair.
pub fn anchor_bonus(
  pair : AlignmentPair,
  anchors : Array[LexicalAnchor],
) -> Double {
  let mut score = 0.0
  for anchor in anchors {
    if anchor.source_unit >= pair.source_start &&
      anchor.source_unit < pair.source_end &&
      anchor.target_unit >= pair.target_start &&
      anchor.target_unit < pair.target_end {
      score += anchor.score
    }
  }
  score.min(3.0)
}