///|
// Detector family module, split out of scan.mbt for navigability.

///|
/// Recognized service token prefixes with per-family minimum total lengths.
/// All samples in tests are synthetic; real credentials never appear here.
fn builtin_token_specs() -> Array[TokenPrefixRule] {
  [
    { prefix: "github_pat_", min_length: 22, },
    { prefix: "sk-ant-", min_length: 15, },
    { prefix: "sk_live_", min_length: 16, },
    { prefix: "rk_live_", min_length: 16, },
    { prefix: "pk_live_", min_length: 16, },
    { prefix: "dop_v1_", min_length: 15, },
    { prefix: "dop_v2_", min_length: 15, },
    { prefix: "shpat_", min_length: 14, },
    { prefix: "shpca_", min_length: 14, },
    { prefix: "shppa_", min_length: 14, },
    { prefix: "ghp_", min_length: 12, },
    { prefix: "gho_", min_length: 12, },
    { prefix: "ghu_", min_length: 12, },
    { prefix: "ghs_", min_length: 12, },
    { prefix: "ghr_", min_length: 12, },
    { prefix: "gha_", min_length: 12, },
    { prefix: "xoxb-", min_length: 13, },
    { prefix: "xoxp-", min_length: 13, },
    { prefix: "xoxc-", min_length: 13, },
    { prefix: "xoxa-", min_length: 13, },
    { prefix: "xoxr-", min_length: 13, },
    { prefix: "glpat-", min_length: 14, },
    { prefix: "npm_", min_length: 12, },
    { prefix: "lin_api_", min_length: 24, },
    { prefix: "glc_", min_length: 20, },
    { prefix: "cko_", min_length: 20, },
    { prefix: "key-", min_length: 32, },
    { prefix: "nfp_", min_length: 40, },
    { prefix: "pypi-AgEIcHlwaS5vcmc", min_length: 40, },
    { prefix: "glrt-", min_length: 25, },
    { prefix: "xoxe.x-xcs-", min_length: 30, },
    { prefix: "ATATTx", min_length: 40, },
    { prefix: "1//", min_length: 40, },
    { prefix: "LTAI", min_length: 20, },
    { prefix: "sntrys_", min_length: 40, },
    { prefix: "AIza", min_length: 20, },
    { prefix: "AKIA", min_length: 20, },
    { prefix: "ASIA", min_length: 20, },
    { prefix: "SG.", min_length: 20, },
    { prefix: "sk-", min_length: 11, },
  ]
}

///|
fn rule_first(rule : TokenPrefixRule) -> Int {
  rule.prefix[0].to_int()
}

///|
/// Bucket the rules by first character so the scan loop only compares a
/// position against candidates that can match at all.
fn bucket_specs(
  specs : Array[TokenPrefixRule],
) -> Array[Array[TokenPrefixRule]] {
  let buckets : Array[Array[TokenPrefixRule]] = []
  for _ in 0..<256 {
    buckets.push([])
  }
  for spec in specs {
    if spec.prefix.length() >= 2 && spec.min_length >= spec.prefix.length() {
      buckets[rule_first(spec)].push(spec)
    }
  }
  for bucket in buckets {
    sort_specs(bucket)
  }
  buckets
}

///|
/// Longest prefix first so `sk-ant-` wins over `sk-` at the same position.
fn sort_specs(items : Array[TokenPrefixRule]) -> Unit {
  for i = 1; i < items.length(); i = i + 1 {
    let value = items[i]
    let mut j = i
    while j > 0 && items[j - 1].prefix.length() < value.prefix.length() {
      items[j] = items[j - 1]
      j -= 1
    }
    items[j] = value
  }
}

///|
fn char_class_match(chars : CharClass, c : Int) -> Bool {
  match chars {
    AlnumUndash => is_ascii_alnum(c) || c == 45 || c == 95
    HexLower => (c >= 48 && c <= 57) || (c >= 97 && c <= 102)
    HexAny => is_ascii_hexdigit(c)
    Base64Url => is_base64url(c)
    Digits => is_ascii_digit(c)
  }
}

///|
/// Scan caller-supplied shape rules: prefix, then a run in the rule's
/// character class whose total span lands inside the length window.
fn scan_pattern_rules(
  text : String,
  rules : Array[PatternRule],
  out : Array[Finding],
) -> Unit {
  for rule in rules {
    if rule.prefix.length() < 2 ||
      rule.min_length < rule.prefix.length() ||
      rule.max_length < rule.min_length {
      continue
    }
    let mut i = 0
    while i + rule.prefix.length() <= text.length() {
      if starts_at(text, i, rule.prefix) {
        let mut end = i + rule.prefix.length()
        while end < text.length() &&
              end - i < rule.max_length &&
              char_class_match(rule.chars, text[end].to_int()) {
          end += 1
        }
        // Do not cut a match short in the middle of a class run: a longer
        // neighbour would mean this shape does not own the token.
        let tail_ok = end >= text.length() ||
          !char_class_match(rule.chars, text[end].to_int())
        if tail_ok && end - i >= rule.min_length {
          push_finding(out, AccessToken, i, end, High)
          i = end
        } else {
          i += 1
        }
      } else {
        i += 1
      }
    }
  }
}

///|
fn scan_prefixed_tokens(
  text : String,
  extra : Array[TokenPrefixRule],
  out : Array[Finding],
) -> Unit {
  let specs = builtin_token_specs()
  for rule in extra {
    specs.push(rule)
  }
  let buckets = bucket_specs(specs)
  for i = 0; i < text.length(); i = i + 1 {
    let code = text[i].to_int()
    // Every built-in prefix starts with an ASCII character; skip non-ASCII
    // code units instead of indexing past the bucket table.
    if code >= 256 {
      continue
    }
    let bucket = buckets[code]
    for spec in bucket {
      if starts_at(text, i, spec.prefix) {
        let mut end = i + spec.prefix.length()
        while end < text.length() && is_token_char(text[end].to_int()) {
          end += 1
        }
        if end - i >= spec.min_length {
          push_finding(out, AccessToken, i, end, High)
        }
        break
      }
    }
  }
}