///|
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 scan_prefixed_tokens(text : String, out : Array[Finding]) -> Unit {
  let prefixes = [
    "ghp_", "gho_", "ghs_", "github_pat_", "sk-", "xoxb-", "xoxp-", "AKIA",
  ]
  for i = 0; i < text.length(); i = i + 1 {
    for prefix in prefixes {
      if starts_at(text, i, prefix) {
        let mut end = i + prefix.length()
        while end < text.length() && is_token_char(text[end].to_int()) {
          end += 1
        }
        let minimum = if prefix == "AKIA" { 20 } else { prefix.length() + 8 }
        if end - i >= minimum {
          push_finding(out, AccessToken, i, end, High)
        }
      }
    }
  }
}

///|
fn scan_bearer(text : String, out : Array[Finding]) -> Unit {
  let lower = ascii_lower(text)
  for i = 0; i + 7 < text.length(); i = i + 1 {
    if starts_at(lower, i, "bearer ") {
      let start = i + 7
      let end = trim_value_end(text, start)
      if end - start >= 8 {
        push_finding(out, BearerToken, start, end, High)
      }
    }
  }
}

///|
fn scan_jwt(text : String, out : Array[Finding]) -> Unit {
  for i = 0; i + 12 < text.length(); i = i + 1 {
    if starts_at(text, i, "eyJ") {
      let mut end = i
      let mut dots = 0
      while end < text.length() {
        let c = text[end].to_int()
        if c == 46 {
          dots += 1
          end += 1
        } else if is_base64url(c) {
          end += 1
        } else {
          break
        }
      }
      if dots == 2 && end - i >= 16 {
        push_finding(out, Jwt, i, end, High)
      }
    }
  }
}

///|
fn scan_private_keys(text : String, out : Array[Finding]) -> Unit {
  let begins = [
    "-----BEGIN PRIVATE KEY-----", "-----BEGIN RSA PRIVATE KEY-----", "-----BEGIN EC PRIVATE KEY-----",
    "-----BEGIN OPENSSH PRIVATE KEY-----",
  ]
  for i = 0; i < text.length(); i = i + 1 {
    for begin in begins {
      if starts_at(text, i, begin) {
        let mut end = i + begin.length()
        let marker = "-----END"
        while end < text.length() && !starts_at(text, end, marker) {
          end += 1
        }
        if end < text.length() {
          while end < text.length() &&
                text[end].to_int() != 10 &&
                text[end].to_int() != 13 {
            end += 1
          }
        } else {
          end = text.length()
        }
        push_finding(out, PrivateKey, i, end, High)
      }
    }
  }
}

///|
fn scan_url_credentials(text : String, out : Array[Finding]) -> Unit {
  for i = 0; i + 3 < text.length(); i = i + 1 {
    if starts_at(text, i, "://") {
      let authority_start = i + 3
      let mut at = authority_start
      let mut colon = -1
      while at < text.length() &&
            text[at].to_int() != 47 &&
            text[at].to_int() != 32 &&
            text[at].to_int() != 9 {
        if text[at].to_int() == 58 && colon < 0 {
          colon = at
        }
        if text[at].to_int() == 64 {
          break
        }
        at += 1
      }
      if at < text.length() &&
        text[at].to_int() == 64 &&
        colon > authority_start &&
        colon + 1 < at {
        push_finding(out, UrlCredential, authority_start, at, High)
      }
    }
  }
}

///|
fn sensitive_key(key : String) -> Bool {
  let k = ascii_lower(key)
  k == "password" ||
  k == "passwd" ||
  k == "pwd" ||
  k == "secret" ||
  k == "token" ||
  k == "api_key" ||
  k == "apikey" ||
  k == "private_key" ||
  k == "authorization" ||
  k == "client_secret"
}

///|
fn scan_assignments(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i < text.length() {
    if is_ascii_letter(text[i].to_int()) || text[i].to_int() == 95 {
      let key_start = i
      while i < text.length() &&
            (
              is_ascii_alnum(text[i].to_int()) ||
              text[i].to_int() == 95 ||
              text[i].to_int() == 45
            ) {
        i += 1
      }
      let key = text[key_start:i].to_owned()
      let mut sep = i
      while sep < text.length() &&
            (text[sep].to_int() == 32 || text[sep].to_int() == 9) {
        sep += 1
      }
      if sensitive_key(key) &&
        sep < text.length() &&
        (text[sep].to_int() == 61 || text[sep].to_int() == 58) {
        let mut start = sep + 1
        while start < text.length() &&
              (text[start].to_int() == 32 || text[start].to_int() == 9) {
          start += 1
        }
        if ascii_lower(key) == "authorization" &&
          starts_at(ascii_lower(text), start, "bearer ") {
          i = start + 7
        } else if starts_at(text, start, "[REDACTED") {
          i = start + 9
        } else if start < text.length() &&
          (text[start].to_int() == 34 || text[start].to_int() == 39) {
          let quote = text[start].to_int()
          start += 1
          let mut end = start
          while end < text.length() && text[end].to_int() != quote {
            end += 1
          }
          push_finding(out, CredentialAssignment, start, end, High)
        } else {
          push_finding(
            out,
            CredentialAssignment,
            start,
            trim_value_end(text, start),
            High,
          )
        }
      }
    } else {
      i += 1
    }
  }
}

///|
fn scan_emails(text : String, out : Array[Finding]) -> Unit {
  for at = 1; at + 3 < text.length(); at = at + 1 {
    if text[at].to_int() == 64 {
      let mut start = at
      while start > 0 && is_email_local(text[start - 1].to_int()) {
        start -= 1
      }
      let mut end = at + 1
      while end < text.length() &&
            (
              is_ascii_alnum(text[end].to_int()) ||
              text[end].to_int() == 45 ||
              text[end].to_int() == 46
            ) {
        end += 1
      }
      let domain = text[at + 1:end].to_owned()
      if start < at &&
        domain.length() >= 3 &&
        domain.contains(".") &&
        !domain.has_prefix(".") &&
        !domain.has_suffix(".") {
        push_finding(out, Email, start, end, High)
      }
    }
  }
}

///|
fn ipv4_octet(text : String, start : Int, end : Int) -> Bool {
  if end <= start || end - start > 3 {
    return false
  }
  let mut value = 0
  for i = start; i < end; i = i + 1 {
    if !is_ascii_digit(text[i].to_int()) {
      return false
    }
    value = value * 10 + text[i].to_int() - 48
  }
  value <= 255
}

///|
fn scan_ipv4(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i < text.length() {
    if is_ascii_digit(text[i].to_int()) &&
      (i == 0 || !is_ascii_digit(text[i - 1].to_int())) {
      let start = i
      let mut part_start = i
      let mut parts = 0
      while i < text.length() &&
            (is_ascii_digit(text[i].to_int()) || text[i].to_int() == 46) {
        if text[i].to_int() == 46 {
          if !ipv4_octet(text, part_start, i) {
            break
          }
          parts += 1
          i += 1
          part_start = i
        } else {
          i += 1
        }
      }
      if parts == 3 && ipv4_octet(text, part_start, i) {
        push_finding(out, Ipv4, start, i, Medium)
      }
    }
    i += 1
  }
}

///|
fn luhn_valid(digits : Array[Int]) -> Bool {
  if digits.length() < 13 || digits.length() > 19 {
    return false
  }
  let mut sum = 0
  let mut alternate = false
  for k = digits.length() - 1; k >= 0; k = k - 1 {
    let mut value = digits[k]
    if alternate {
      value *= 2
      if value > 9 {
        value -= 9
      }
    }
    sum += value
    alternate = !alternate
  }
  sum % 10 == 0
}

///|
fn scan_cards(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i < text.length() {
    if is_ascii_digit(text[i].to_int()) &&
      (i == 0 || !is_ascii_digit(text[i - 1].to_int())) {
      let start = i
      let digits : Array[Int] = []
      let mut end = i
      while end < text.length() {
        let c = text[end].to_int()
        if is_ascii_digit(c) {
          digits.push(c - 48)
          end += 1
        } else if (c == 32 || c == 45) && digits.length() > 0 {
          end += 1
        } else {
          break
        }
      }
      while end > start &&
            (text[end - 1].to_int() == 32 || text[end - 1].to_int() == 45) {
        end -= 1
      }
      if luhn_valid(digits) {
        push_finding(out, PaymentCard, start, end, High)
      }
      i = if end > i { end } else { i + 1 }
    } else {
      i += 1
    }
  }
}

///|
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
}

///|
/// Scan text without retaining sensitive values in the returned findings.
pub fn scan(
  text : String,
  policy? : ScanPolicy = ScanPolicy::standard(),
) -> Array[Finding] {
  let out : Array[Finding] = []
  scan_prefixed_tokens(text, out)
  scan_bearer(text, out)
  scan_jwt(text, out)
  scan_private_keys(text, out)
  scan_url_credentials(text, out)
  scan_assignments(text, out)
  if policy.detect_email {
    scan_emails(text, out)
  }
  if policy.detect_ipv4 {
    scan_ipv4(text, out)
  }
  if policy.detect_payment_card {
    scan_cards(text, 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] {
  let out = scan(text, policy~)
  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)
}