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

///|
fn days_in_month(year : Int, month : Int) -> Int {
  match month {
    1 | 3 | 5 | 7 | 8 | 10 | 12 => 31
    4 | 6 | 9 | 11 => 30
    _ =>
      if year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) {
        29
      } else {
        28
      }
  }
}

///|
fn two_digits(text : String, at : Int) -> Int {
  (text[at].to_int() - 48) * 10 + (text[at + 1].to_int() - 48)
}

///|
fn four_digits(text : String, at : Int) -> Int {
  let mut value = 0
  for k = 0; k < 4; k = k + 1 {
    value = value * 10 + text[at + k].to_int() - 48
  }
  value
}

///|
/// The birth-date segment must name a calendar date between 1900 and 2100;
/// checksum-valid strings like a fabricated 2001-02-29 are rejected here.
fn resident_id_birth_ok(text : String, start : Int) -> Bool {
  let year = four_digits(text, start + 6)
  let month = two_digits(text, start + 10)
  let day = two_digits(text, start + 12)
  year >= 1900 &&
  year <= 2100 &&
  month >= 1 &&
  month <= 12 &&
  day >= 1 &&
  day <= days_in_month(year, month)
}

///|
/// GB 11643-1999 check digit: weighted sum of the first 17 digits under
/// weights 7 9 10 5 8 4 2 1 6 3 7 9 10 5 8 4 2, mapped through `10X98765432`.
fn resident_id_checksum_ok(text : String, start : Int) -> Bool {
  let weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2]
  let order = "10X98765432"
  let mut sum = 0
  for k = 0; k < 17; k = k + 1 {
    sum += (text[start + k].to_int() - 48) * weights[k]
  }
  text[start + 17] == order[sum % 11]
}

///|
/// Chinese resident identity numbers: eighteen characters, seventeen digits
/// plus a check digit that may be X. Both the checksum and the embedded
/// birth date must validate, which keeps ordinary 18-digit order numbers
/// from being reported.
fn scan_resident_ids(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i + 18 <= text.length() {
    if is_ascii_digit(text[i].to_int()) &&
      (i == 0 || !is_ascii_alnum(text[i - 1].to_int())) {
      let end = i + 18
      let last = text[end - 1].to_int()
      let shaped = if is_ascii_digit(last) || last == 88 || last == 120 {
        let mut k = i
        while k < end - 1 && is_ascii_digit(text[k].to_int()) {
          k += 1
        }
        k == end - 1
      } else {
        false
      }
      let tail_free = end >= text.length() ||
        (
          !is_ascii_alnum(text[end].to_int()) &&
          text[end].to_int() != 45 &&
          text[end].to_int() != 95
        )
      if shaped &&
        tail_free &&
        resident_id_checksum_ok(text, i) &&
        resident_id_birth_ok(text, i) {
        push_finding(out, ResidentId, i, end, High)
        i = end
      } else {
        i += 1
      }
    } else {
      i += 1
    }
  }
}

///|
/// EUI-48 hardware addresses: six hyphen-or-colon separated hex pairs with a
/// consistent separator. Reported at medium confidence because most log
/// occurrences are ordinary device identifiers rather than personal data.
fn scan_mac_addresses(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i + 17 <= text.length() {
    if is_ascii_hexdigit(text[i].to_int()) &&
      (i == 0 || !is_ascii_hexdigit(text[i - 1].to_int())) {
      let sep = text[i + 2].to_int()
      if sep == 58 || sep == 45 {
        let mut k = i + 2
        let mut groups = 1
        while groups < 6 {
          if text[k].to_int() != sep {
            break
          }
          if !(is_ascii_hexdigit(text[k + 1].to_int()) &&
            is_ascii_hexdigit(text[k + 2].to_int())) {
            break
          }
          k += 3
          groups += 1
        }
        let end = i + 17
        let tail = if end < text.length() { text[end].to_int() } else { -1 }
        if groups == 6 &&
          (tail == -1 || (!is_ascii_hexdigit(tail) && tail != 58 && tail != 45)) {
          push_finding(out, MacAddress, i, end, Medium)
          i = end
        } else {
          i += 1
        }
      } else {
        i += 1
      }
    } else {
      i += 1
    }
  }
}

///|
/// Canonical 8-4-4-4-12 hexadecimal UUIDs. Reported at medium confidence:
/// most occurrences are trace or entity identifiers, which are personal data
/// only in specific contexts.
fn scan_uuids(text : String, out : Array[Finding]) -> Unit {
  let lengths = [8, 4, 4, 4, 12]
  let mut i = 0
  while i + 36 <= text.length() {
    if is_ascii_hexdigit(text[i].to_int()) &&
      (
        i == 0 ||
        (
          !is_ascii_hexdigit(text[i - 1].to_int()) &&
          !is_ascii_letter(text[i - 1].to_int())
        )
      ) {
      let mut k = i
      let mut ok = true
      for group = 0; group < 5; group = group + 1 {
        for _ in 0..= text.length() || !is_ascii_hexdigit(text[k].to_int()) {
            ok = false
            break
          }
          k += 1
        }
        if !ok {
          break
        }
        if group < 4 {
          if k >= text.length() || text[k].to_int() != 45 {
            ok = false
            break
          }
          k += 1
        }
      }
      let tail = if k < text.length() { text[k].to_int() } else { -1 }
      if ok &&
        (
          tail == -1 ||
          (!is_ascii_hexdigit(tail) && !is_ascii_letter(tail) && tail != 45)
        ) {
        push_finding(out, Uuid, i, k, Medium)
        i = k
      } else {
        i += 1
      }
    } else {
      i += 1
    }
  }
}

///|
/// Validate a hex-and-colon run as an RFC 4291 address: groups of one to
/// four hex digits, at most one `::` compression, and either eight full
/// groups or fewer when compressed. IPv4-mapped tails are not accepted.
fn ipv6_run_ok(run : String) -> Bool {
  if run.length() < 2 || run.length() > 45 {
    return false
  }
  let mut groups = 0
  let mut compressed = false
  let mut part_start = 0
  let mut i = 0
  let mut expect_group = false
  while i <= run.length() {
    if i == run.length() || run[i].to_int() == 58 {
      if i > part_start {
        if i - part_start > 4 || !all_hex(run, part_start, i) {
          return false
        }
        groups += 1
      } else if i == 0 || i == run.length() {
        // leading or trailing single colon adjacent to compression
      } else if run[i - 1].to_int() == 58 {
        // second colon of a compression marker
      } else {
        return false
      }
      if i < run.length() && run[i].to_int() == 58 {
        if i + 1 < run.length() && run[i + 1].to_int() == 58 {
          if compressed {
            return false
          }
          compressed = true
          expect_group = false
          i += 2
          part_start = i
          continue
        }
      }
      part_start = i + 1
      expect_group = true
    }
    i += 1
  }
  ignore(expect_group)
  if compressed {
    groups < 8
  } else {
    groups == 8
  }
}

///|
fn all_hex(text : String, start : Int, end : Int) -> Bool {
  for k = start; k < end; k = k + 1 {
    if !is_ascii_hexdigit(text[k].to_int()) {
      return false
    }
  }
  true
}

///|
/// Scan for IPv6 literals inside hex-and-colon runs bounded by non-hex,
/// non-colon characters. The run is re-parsed strictly, so MAC-shaped and
/// time-shaped colon runs never qualify.
fn scan_ipv6(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i < text.length() {
    let c = text[i].to_int()
    if is_ascii_hexdigit(c) || c == 58 {
      let start = i
      while i < text.length() {
        let r = text[i].to_int()
        if is_ascii_hexdigit(r) || r == 58 {
          i += 1
        } else {
          break
        }
      }
      let run = text[start:i].to_owned()
      if ipv6_run_ok(run) {
        push_finding(out, Ipv6, start, i, Medium)
      }
    } else {
      i += 1
    }
  }
}

///|
/// E.164 phone numbers: a plus sign followed by eight to fifteen digits with
/// a non-zero country code. Opt-in because bare international numbers are
/// frequently order ids or reference codes; grouped formats like
/// `+86 138 0013 8000` are not matched.
fn scan_phones(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i < text.length() {
    if text[i].to_int() == 43 &&
      i + 1 < text.length() &&
      text[i + 1].to_int() >= 49 &&
      text[i + 1].to_int() <= 57 {
      let start = i
      let mut end = i + 1
      while end < text.length() && is_ascii_digit(text[end].to_int()) {
        end += 1
      }
      let digits = end - start - 1
      if digits >= 8 && digits <= 15 {
        push_finding(out, Phone, start, end, Medium)
        i = end
      } else {
        i += 1
      }
    } else {
      i += 1
    }
  }
}

///|
/// Cryptocurrency wallet addresses as pure shape matches: `0x` plus forty
/// hex characters for Ethereum-style, and bech32 `bc1`/`tb1`/`ltc1` bodies
/// for SegWit-style. No checksum validation, hence medium confidence.
fn scan_wallet_addresses(text : String, out : Array[Finding]) -> Unit {
  let mut i = 0
  while i < text.length() {
    let c = text[i].to_int()
    if c == 48 &&
      i + 1 < text.length() &&
      (text[i + 1].to_int() == 120 || text[i + 1].to_int() == 88) {
      let start = i
      let mut end = i + 2
      while end < text.length() && is_ascii_hexdigit(text[end].to_int()) {
        end += 1
      }
      if end - start == 42 {
        push_finding(out, WalletAddress, start, end, Medium)
        i = end
      } else {
        i += 1
      }
    } else if (c == 98 || c == 66) && i + 3 < text.length() {
      let probe = ascii_lower(text[i:i + 4].to_owned())
      if probe == "bc1q" ||
        probe == "bc1p" ||
        probe == "tb1q" ||
        probe == "tb1p" ||
        probe == "ltc1" {
        // Collect the full lowercase-alnum body (HRP plus separator plus
        // data); the prefix probe above already pins the family.
        let start = i
        let mut end = i
        while end < text.length() {
          let r = text[end].to_int()
          if is_ascii_digit(r) || (r >= 97 && r <= 122) {
            end += 1
          } else {
            break
          }
        }
        if end - start >= 14 && end - start <= 90 {
          push_finding(out, WalletAddress, start, end, Medium)
          i = end
        } else {
          i += 1
        }
      } else {
        i += 1
      }
    } 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
      }
      // Inspect the domain in place: an allocation per '@' candidate would
      // dominate the scan on address-heavy text.
      if start < at && end - at >= 4 && domain_shape_ok(text, at + 1, end) {
        push_finding(out, Email, start, end, High)
      }
    }
  }
}

///|
/// Domain shape: at least three code units, no leading or trailing dot,
/// and at least one interior dot.
fn domain_shape_ok(text : String, from : Int, until : Int) -> Bool {
  if until - from < 3 {
    return false
  }
  if text[from].to_int() == 46 || text[until - 1].to_int() == 46 {
    return false
  }
  for k = from; k < until; k = k + 1 {
    if text[k].to_int() == 46 && k > from {
      return true
    }
  }
  false
}

///|
fn ipv4_octet(text : String, start : Int, end : Int) -> Bool {
  if end <= start || end - start > 3 {
    return false
  }
  // Leading zeros are ambiguous dotted-decimal (octal in some parsers) and
  // appear far more often in version strings than in addresses.
  if end - start > 1 && text[start].to_int() == 48 {
    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
}

///|
/// A version-looking IPv4 sits inside a longer token such as `v1.2.3.4` or
/// `1.2.3.4rc1`. Reject matches glued to word characters on either side; a
/// trailing dot stays acceptable because sentences end IPs with periods.
fn ipv4_glued_to_word(text : String, start : Int, end : Int) -> Bool {
  if start > 0 {
    let before = text[start - 1].to_int()
    if is_ascii_alnum(before) || before == 46 || before == 45 || before == 95 {
      return true
    }
  }
  if end < text.length() {
    let after = text[end].to_int()
    if is_ascii_alnum(after) || after == 45 || after == 95 {
      return true
    }
  }
  false
}

///|
/// Parse a digit-and-dot run as an IPv4 literal. Returns the length of the
/// four valid octets, or -1 when the run is not a plain address. A single
/// trailing dot after the fourth octet is tolerated so sentence-ending
/// periods do not hide real addresses, but it is not part of the match.
fn ipv4_run_end(run : String) -> Int {
  let mut octets = 0
  let mut part_start = 0
  for i = 0; i <= run.length(); i = i + 1 {
    if i == run.length() || run[i].to_int() == 46 {
      if !ipv4_octet(run, part_start, i) {
        return -1
      }
      octets += 1
      if octets == 4 {
        if i == run.length() {
          return i
        }
        if i + 1 == run.length() {
          return i
        }
        return -1
      }
      part_start = i + 1
    }
  }
  -1
}

///|
fn scan_ipv4(
  text : String,
  suppress_versions : Bool,
  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
      while i < text.length() &&
            (is_ascii_digit(text[i].to_int()) || text[i].to_int() == 46) {
        i += 1
      }
      let run = text[start:i].to_owned()
      let valid_end = ipv4_run_end(run)
      if valid_end >= 0 &&
        !(suppress_versions &&
        ipv4_glued_to_word(text, start, start + valid_end)) {
        push_finding(out, Ipv4, start, start + valid_end, Medium)
      }
    } else {
      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
}

///|
/// Major card-network prefixes: Visa 4, Mastercard 5, Discover/UnionPay 6,
/// Amex 34/37. Long digit runs outside these networks are overwhelmingly
/// order or account identifiers that happen to satisfy Luhn.
fn known_card_prefix(digits : Array[Int]) -> Bool {
  if digits.length() < 1 {
    return false
  }
  let first = digits[0]
  if first == 4 || first == 5 || first == 6 {
    return true
  }
  if digits.length() >= 2 {
    let two = first * 10 + digits[1]
    if two == 34 || two == 37 {
      return true
    }
  }
  false
}

///|
fn scan_cards(text : String, require_bin : Bool, 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 end > start &&
          is_ascii_digit(text[end - 1].to_int()) &&
          (
            c == 45 ||
            (
              c == 32 &&
              (
                digits.length() == 4 ||
                digits.length() == 8 ||
                digits.length() == 10 ||
                digits.length() == 12
              )
            )
          ) {
          // Hyphenated groups anywhere; spaces only at the usual 4/8/12
          // Visa-style boundaries and 4/10 for Amex 4-6-5, so an unrelated
          // neighbouring number is never glued onto a card candidate.
          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) && !(require_bin && !known_card_prefix(digits)) {
        push_finding(out, PaymentCard, start, end, High)
      }
      i = if end > i { end } else { i + 1 }
    } else {
      i += 1
    }
  }
}