///|
fn push_finding(
  out : Array[Finding],
  kind : SensitiveKind,
  start : Int,
  end : Int,
  confidence : Confidence,
) -> Unit {
  if start >= 0 && end > start {
    out.push({ kind, start, end, confidence, })
  }
}

///|
fn sort_findings(items : Array[Finding]) -> Unit {
  for i = 1; i < items.length(); i = i + 1 {
    let value = items[i]
    let mut j = i
    while j > 0 &&
          (
            items[j - 1].start > value.start ||
            (items[j - 1].start == value.start && items[j - 1].end < value.end)
          ) {
      items[j] = items[j - 1]
      j -= 1
    }
    items[j] = value
  }
}

///|
fn non_overlapping(items : Array[Finding]) -> Array[Finding] {
  sort_findings(items)
  let out : Array[Finding] = []
  for item in items {
    if out.length() == 0 || item.start >= out[out.length() - 1].end {
      out.push(item)
    }
  }
  out
}

///|
/// Remove email findings whose domain is an IANA-reserved documentation
/// domain: example.com/net/org plus the test/example/invalid TLDs.
fn filter_example_domains(text : String, out : Array[Finding]) -> Unit {
  let keep : Array[Finding] = []
  for finding in out {
    if finding.kind == Email && is_example_domain(text, finding) {
      continue
    }
    keep.push(finding)
  }
  out.clear()
  for finding in keep {
    out.push(finding)
  }
}

///|
fn is_example_domain(text : String, finding : Finding) -> Bool {
  // Walk back from the '@' recorded inside the finding range.
  let mut at = finding.end - 1
  while at > finding.start && text[at].to_int() != 64 {
    at -= 1
  }
  if text[at].to_int() != 64 {
    return false
  }
  let domain = ascii_lower(text[at + 1:finding.end].to_owned())
  domain == "example.com" ||
  domain == "example.net" ||
  domain == "example.org" ||
  domain == "test" ||
  domain == "example" ||
  domain == "invalid"
}

///|
fn run_detectors(
  text : String,
  config : ScanConfig,
  out : Array[Finding],
) -> Unit {
  let needs_lower = config.detect_bearer ||
    config.detect_assignment ||
    config.detect_webhook
  let lower = if needs_lower { ascii_lower(text) } else { "" }
  if config.detect_access_token {
    scan_prefixed_tokens(text, config.extra_prefixes, out)
  }
  if config.detect_bearer {
    scan_bearer(text, lower, out)
  }
  if config.detect_jwt {
    scan_jwt(text, out)
  }
  if config.detect_private_key {
    scan_private_keys(text, out)
  }
  if config.detect_url_credential {
    scan_url_credentials(text, out)
  }
  if config.detect_assignment {
    scan_assignments(text, lower, out)
  }
  if config.detect_email {
    scan_emails(text, out)
    if config.suppress_example_domains {
      filter_example_domains(text, out)
    }
  }
  if config.detect_ipv4 {
    scan_ipv4(text, config.suppress_version_ipv4, out)
  }
  if config.detect_ipv6 {
    scan_ipv6(text, out)
  }
  if config.detect_phone {
    scan_phones(text, out)
  }
  if config.detect_public_key {
    scan_public_keys(text, out)
  }
  if config.detect_wallet {
    scan_wallet_addresses(text, out)
  }
  if config.detect_webhook {
    scan_webhook_urls(text, lower, out)
  }
  if config.detect_assignment {
    scan_basic_auth(text, lower, out)
  }
  // Resident IDs outrank payment cards: an eighteen-digit checksummed
  // identity number is stronger evidence than a Luhn pass, and Mastercard
  // BINs (51-55) overlap common area codes.
  if config.detect_resident_id {
    scan_resident_ids(text, out)
  }
  if config.detect_mac {
    scan_mac_addresses(text, out)
  }
  if config.detect_uuid {
    scan_uuids(text, out)
  }
  if config.detect_payment_card {
    scan_cards(text, config.require_known_card_prefix, out)
  }
}

///|
/// Scan text without retaining sensitive values in the returned findings.
pub fn scan(
  text : String,
  policy? : ScanPolicy = ScanPolicy::standard(),
) -> Array[Finding] {
  scan_with_config(text, policy.to_config())
}

///|
/// Scan with full control over detector families, suppressors and extra
/// prefixed-token rules.
pub fn scan_with_config(text : String, config : ScanConfig) -> Array[Finding] {
  let out : Array[Finding] = []
  run_detectors(text, config, out)
  non_overlapping(out)
}

///|
/// Add caller-known exact secrets to the built-in detector set. The returned
/// findings do not carry rule labels or values. Empty and very short values are
/// deliberately ignored.
pub fn scan_with_rules(
  text : String,
  rules : Array[CustomRule],
  policy? : ScanPolicy = ScanPolicy::standard(),
) -> Array[Finding] {
  scan_full(text, rules, policy.to_config())
}

///|
/// Unified entry point combining exact-value rules with a full configuration.

///|
/// Count findings by kind without producing redacted text. Ordered by kind
/// name for deterministic output; counts only, never values.
pub fn findings_summary(
  text : String,
  config? : ScanConfig = ScanConfig::standard(),
) -> Array[KindCount] {
  let findings = scan_with_config(text, config)
  let rows : Array[KindCount] = []
  for finding in findings {
    let mut placed = false
    for row in rows {
      if row.kind == finding.kind {
        row.count += 1
        placed = true
        break
      }
    }
    if !placed {
      rows.push({ kind: finding.kind, count: 1, })
    }
  }
  for i = 1; i < rows.length(); i = i + 1 {
    let value = rows[i]
    let mut j = i
    while j > 0 &&
          lexicographic_before(
            value.kind.to_string(),
            rows[j - 1].kind.to_string(),
          ) {
      rows[j] = rows[j - 1]
      j -= 1
    }
    rows[j] = value
  }
  rows
}

///|

///|
/// Scan while keeping caller-certified literals in place: any finding that
/// overlaps a kept literal is dropped. Keep values shorter than four code
/// units are ignored so a tiny string cannot neuter whole families.
pub fn scan_except(
  text : String,
  keep : Array[String],
  config? : ScanConfig = ScanConfig::standard(),
) -> Array[Finding] {
  let findings = scan_with_config(text, config)
  let kept : Array[(Int, Int)] = []
  for literal in keep {
    if literal.length() >= 4 {
      let mut i = 0
      while i + literal.length() <= text.length() {
        if starts_at(text, i, literal) {
          kept.push((i, i + literal.length()))
          i += literal.length()
        } else {
          i += 1
        }
      }
    }
  }
  let out : Array[Finding] = []
  for finding in findings {
    let mut overlap = false
    for span in kept {
      if finding.start < span.1 && finding.end > span.0 {
        overlap = true
        break
      }
    }
    if !overlap {
      out.push(finding)
    }
  }
  out
}

///|

///|
/// Human-readable, value-free finding report: one line per finding in the
/// form `KIND start-end CONFIDENCE`. Safe to print because it never
/// contains matched text.
pub fn explain(
  text : String,
  config? : ScanConfig = ScanConfig::standard(),
) -> Array[String] {
  let lines : Array[String] = []
  for finding in scan_with_config(text, config) {
    lines.push(
      finding.kind.to_string() +
      " " +
      finding.start.to_string() +
      "-" +
      finding.end.to_string() +
      " " +
      finding.confidence.to_string(),
    )
  }
  lines
}

///|
pub fn scan_full(
  text : String,
  rules : Array[CustomRule],
  config : ScanConfig,
) -> Array[Finding] {
  let out = scan_with_config(text, config)
  for rule in rules {
    if rule.value.length() >= 4 {
      let mut i = 0
      while i + rule.value.length() <= text.length() {
        if starts_at(text, i, rule.value) {
          push_finding(out, CustomSecret, i, i + rule.value.length(), High)
          i += rule.value.length()
        } else {
          i += 1
        }
      }
    }
  }
  non_overlapping(out)
}