//! Detector calibration for the trufflehog ingestion path.
//!
//! Owns the noise-calibration pre-emit gates that operate on the
//! `(detector, raw)` shape of a trufflehog NDJSON line. Four families:
//!
//! 1. **Severity downgrade** — high-noise detectors (`Box`,
//!    `GoogleGeminiAPIKey`, `Honeycomb`, `Myfreshworks`, `Shortcut`,
//!    `Splunk Observability Token`) emit at Low rather than Critical.
//!    Every audited Critical/High hit on these detectors was an FP after
//!    manual verification.
//! 2. **OpenAI sk-anchor** — `OpenAI` hits without the documented
//!    `sk-proj-` project-key prefix or 51-char total-length anchor get
//!    downgraded to Low. The `sk-Latn-…` BCP-47 locale path is
//!    effectively covered here (locale prefixes fail both anchors and
//!    downgrade).
//! 3. **Discord 3-segment-with-entropy anchor** — `Discord` hits whose
//!    `Raw` doesn't have three base64url segments AND ≥3 character
//!    classes AND ≥0.7 per-segment normalized Shannon entropy are
//!    downgraded to Low. Catches the RN/Hermes minified-bundle FP shape
//!    where random concatenated fragments hit the
//!    `[A-Za-z0-9_-]{24}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27,}` regex.
//! 4. **Class-name skip** — `Box` and `Splunk Observability Token` hits
//!    whose `Raw` substring matches inside a Java/Kotlin class identifier
//!    (`HoneycombBitmapFactory`, `BatteryMonitorFactoryPeerCleaner`,
//!    `SpoofingApprovalScreen`) are silently skipped. The `Honeycomb`
//!    detector's class-name FP is owned by sibling
//!    `detector-trufflehog-honeycomb-android-class-fp` which re-promotes
//!    Honeycomb post regex-anchor; the blanket downgrade in family 1
//!    keeps the FP volume bounded until that sibling lands.
//!
//! Coordination: umbrella `provenance-aware-suppression-layer` rows
//! 2 (Netty PEM PrivateKey allowlist) and 4 (CSP domain gate)
//! are V1.X — detector-local handling for those is NOT implemented at
//! v1.0; row 6 (Expo UUID precision for Myfreshworks/Shortcut) is
//! coarsely handled here by family 1's blanket Low.
//!
//! Surface contract: ingest-side gates are pure functions. They take a
//! borrowed `(detector, raw)` and return a typed `Calibration` enum.
//! No I/O, no allocation, no panics.

/// Severity for trufflehog finding emit. Mirror of
/// `droidsaw_common::Severity` ordered by escalation.
///
/// Local enum (rather than a re-export) keeps the calibration module
/// self-contained; the ingestion path translates this into the textual
/// severity string at the SQL boundary.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CalibratedSeverity {
    /// Critical — preserved as-is. Default for non-list detectors.
    Critical,
    /// Low — high-noise detector with no shipping-bug shape; surfaced
    /// for completeness but not promoted in `top_findings`.
    Low,
}

impl CalibratedSeverity {
    /// Textual severity string used by the trufflehog ingestion SQL
    /// (mirrors the `severity` TEXT column values produced by the
    /// `apk.audit_security()` finding emitters: `"Critical"`, `"High"`,
    /// `"Medium"`, `"Low"`, `"Info"`).
    pub fn as_str(self) -> &'static str {
        match self {
            CalibratedSeverity::Critical => "Critical",
            CalibratedSeverity::Low => "Low",
        }
    }
}

/// Outcome of the calibration gate for a single trufflehog ingestion line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Calibration {
    /// Emit at the calibrated severity. The default for non-matching
    /// detectors is `Emit(Critical)` — preserves shipping behavior.
    Emit(CalibratedSeverity),
    /// Skip the line entirely. Used for class-name FP shapes where the
    /// match is provably noise (`HoneycombBitmapFactory`,
    /// `BatteryMonitorFactoryPeerCleaner`).
    Skip,
}

/// Detectors whose Critical/High hits are high-noise and lack a tighter
/// regex anchor today. Listed detectors are downgraded to Low at ingestion
/// time. Once a sibling detector-* stream lands a regex anchor (e.g.
/// the Honeycomb android-class anchor), that detector's name leaves this list.
///
/// Match semantics: case-INSENSITIVE prefix match against trufflehog's
/// `DetectorName` field. Trufflehog reports detector names like `"Box"`,
/// `"Honeycomb"`, `"OpenAI"`; matching is on canonical-name basis.
const LOW_SEVERITY_DETECTORS: &[&str] = &[
    "Box",
    "GoogleGeminiAPIKey",
    "Honeycomb",
    "Myfreshworks",
    "Shortcut",
    "Splunk Observability Token",
];

/// Java/Kotlin class-name shapes that the `Box` and `Splunk Observability
/// Token` detectors substring-match against decompiled class metadata.
/// These are AOSP framework + 3rd-party SDK class identifiers, not
/// credentials.
///
/// Match semantics: a `Raw` value is a class-name FP if it contains any
/// of the listed substrings AND has the `PascalCase[A-Za-z0-9]*$` shape
/// (no `=`, `:`, `/`, no spaces, ends with an identifier-class char).
/// The substring list is the empirical FP shapes from audited APKs; future
/// audits may extend it.
const CLASS_NAME_SUBSTRINGS: &[&str] = &[
    "BitmapFactory",
    "Compat",
    "Factory",
    "ApprovalScreen",
    "PeerCleaner",
];

/// Apply calibration gates to a `(detector, raw)` pair.
///
/// Order of evaluation:
/// 1. Class-name skip (family 4) — most aggressive; silently drops the
///    line.
/// 2. OpenAI sk-anchor (family 2) — downgrade to Low if the `Raw`
///    doesn't have the `sk-proj-` project-key prefix or the documented
///    51-char total length.
/// 3. Discord 3-segment-with-entropy anchor (family 3) — downgrade to
///    Low if the `Raw` doesn't satisfy the composed predicate (three
///    base64url segments + ≥3 character classes + ≥0.7 per-segment
///    normalized Shannon entropy).
/// 4. Low-severity detector list (family 1) — downgrade to Low for the
///    enumerated detectors.
/// 5. Default — `Emit(Critical)`.
///
/// **Fallthrough policy (step 5).** Any trufflehog detector name that is
/// not matched by families 1–4 above defaults to `Emit(Critical)`.
/// Contributors adding support for a new detector kind **must** add an
/// entry to the appropriate family list (or a new family gate) before the
/// `Calibration::Emit(CalibratedSeverity::Critical)` return; otherwise
/// every match from that detector will be emitted at Critical severity
/// regardless of FP rate.
///
/// **Coverage limits.** This function calibrates based on the detector
/// *name* and the raw matched string only. It does not:
/// - inspect surrounding file context or call-site semantics;
/// - apply entropy thresholds for detector families other than OpenAI and
///   Discord;
/// - suppress findings for detectors whose FP rate is unknown (they
///   fall through to Critical, which is intentionally conservative).
///
/// Future calibration families should be added above the default return.
///
/// All match operations are bounds-checked via slice / `str::starts_with`
/// / `str::contains`; no direct indexing.
pub fn calibrate(detector: &str, raw: &str) -> Calibration {
    // Family 4: class-name skip for `Box` + `Splunk Observability Token`.
    // Honeycomb's class-name FP (`HoneycombBitmapFactory`) is covered by
    // family 1's blanket downgrade; the `honeycomb_fp` module handles
    // precision re-promotion.
    if detector_in_class_name_scope(detector) && looks_like_class_name(raw) {
        return Calibration::Skip;
    }

    // Family 2: OpenAI sk-anchor.
    if detector.eq_ignore_ascii_case("OpenAI") && !openai_sk_anchored(raw) {
        return Calibration::Emit(CalibratedSeverity::Low);
    }

    // Family 3: Discord 3-segment-with-entropy anchor.
    if detector.eq_ignore_ascii_case("Discord") && !discord_bot_token_anchored(raw) {
        return Calibration::Emit(CalibratedSeverity::Low);
    }

    // Family 1: low-severity detector list.
    if detector_in_low_severity_list(detector) {
        return Calibration::Emit(CalibratedSeverity::Low);
    }

    Calibration::Emit(CalibratedSeverity::Critical)
}

/// Case-insensitive prefix-or-exact match against the low-severity list.
///
/// Trufflehog's `DetectorName` is canonical-cased (e.g. `"Box"`,
/// `"Honeycomb"`); we use `eq_ignore_ascii_case` for robustness against
/// future trufflehog version changes.
fn detector_in_low_severity_list(detector: &str) -> bool {
    LOW_SEVERITY_DETECTORS
        .iter()
        .any(|name| detector.eq_ignore_ascii_case(name))
}

/// Detectors that participate in the class-name skip gate. The Honeycomb
/// detector is intentionally excluded — the `honeycomb_fp` module owns its
/// precision re-promotion; family 1's blanket downgrade keeps FP volume
/// bounded until that fires.
fn detector_in_class_name_scope(detector: &str) -> bool {
    detector.eq_ignore_ascii_case("Box")
        || detector.eq_ignore_ascii_case("Splunk Observability Token")
}

/// Returns true if `raw` has the shape of a Java/Kotlin Pascal-case
/// class identifier and contains one of the framework/SDK substrings
/// from `CLASS_NAME_SUBSTRINGS`.
///
/// Conservative: the predicate must hold ALL of:
/// - first byte is an ASCII uppercase letter (A-Z) → PascalCase start
/// - all bytes are ASCII identifier chars (`A-Za-z0-9_$`)
/// - contains at least one of the framework substrings
///
/// The first-byte + all-identifier-chars filter excludes credential
/// shapes (which usually include `=`, `:`, `/`, base64 padding, or
/// non-letter prefixes). The `bytes().all(|b| ...)` walk avoids any
/// indexing.
fn looks_like_class_name(raw: &str) -> bool {
    let bytes = raw.as_bytes();
    let Some(first) = bytes.first() else {
        return false;
    };
    if !first.is_ascii_uppercase() {
        return false;
    }
    if !bytes.iter().all(|b| {
        b.is_ascii_alphanumeric() || *b == b'_' || *b == b'$'
    }) {
        return false;
    }
    CLASS_NAME_SUBSTRINGS
        .iter()
        .any(|needle| raw.contains(needle))
}

/// Returns true if `raw` looks like a real OpenAI key with one of the
/// documented anchors:
///
/// - `sk-proj-…` — project-scoped keys (introduced 2024).
/// - 51-char total length with `sk-` prefix — the legacy classic-key
///   anchor: `sk-` (3) + 48 base58/base64-ish chars = 51.
///
/// Anything else (locale prefixes like `sk-Latn-…`, short prefixes,
/// arbitrary 16-byte hex strings, etc.) is treated as un-anchored and
/// downgraded.
fn openai_sk_anchored(raw: &str) -> bool {
    if raw.starts_with("sk-proj-") {
        return true;
    }
    if raw.len() == 51 && raw.starts_with("sk-") {
        return true;
    }
    false
}

/// Returns true if `raw` looks like a real Discord bot token with high
/// confidence. Un-anchored `Discord` detector hits are downgraded to Low.
///
/// Real Discord bot tokens have the shape:
/// - Three `.`-separated base64url segments.
/// - Segment 1: snowflake-encoded user ID (~24-26 chars).
/// - Segment 2: 4-byte unix-timestamp-encoded (6 chars exactly — base64
///   of 4 bytes is `ceil(4 * 4 / 3) = 6` chars).
/// - Segment 3: HMAC-SHA256-derived signature (27+ chars).
///
/// Composed anchor predicate (sound — all three sub-predicates
/// independently reject FPs):
///
/// 1. Three dot-separated segments, each conforming to base64url charset
///    (`[A-Za-z0-9_-]`). Defensive — trufflehog's regex already
///    constrains the match shape.
/// 2. **Character-class variety** ≥3 of {uppercase, lowercase, digit,
///    base64url-special (`-`/`_`)} across the full raw string. Real
///    cryptographic-content tokens span all 4 classes with probability
///    ≈ 1 (P(missing any one class in 57 random base64url chars) ≈
///    10^-24); minified-bundle FPs typically span 2 (lowercase + digit,
///    or PascalCase identifiers).
/// 3. **Per-segment normalized Shannon entropy** ≥ 0.7. Normalized by
///    `log2(min(segment_len, 64))` so the 6-char middle segment
///    (theoretical max ~2.58 bits/char) is correctly scaled. Real-token
///    segments land at 0.92-0.93 normalized; minified-bundle fragments
///    typically land 0.4-0.7.
///
/// Filters 2 and 3 compose AND: variety catches the all-lowercase
/// identifier FP shape (e.g., minified `myhookcomponentstate1234.dolist.
/// somerandomidentifier1234567`); per-segment entropy catches 3-class-
/// but-patterned FPs that variety alone would miss (e.g., `AAAaaa111.
/// BBBbbb.CCCccc222...` with low within-segment randomness).
fn discord_bot_token_anchored(raw: &str) -> bool {
    let Some((seg1, rest)) = raw.split_once('.') else {
        return false;
    };
    let Some((seg2, seg3)) = rest.split_once('.') else {
        return false;
    };
    if !looks_like_base64url(seg1)
        || !looks_like_base64url(seg2)
        || !looks_like_base64url(seg3)
    {
        return false;
    }
    if char_class_count(raw) < 3 {
        return false;
    }
    if normalized_entropy(seg1) < 0.7
        || normalized_entropy(seg2) < 0.7
        || normalized_entropy(seg3) < 0.7
    {
        return false;
    }
    true
}

/// Returns true if every byte in `s` is base64url-valid: ASCII alpha-
/// numeric or `-` or `_`. Empty strings return false.
fn looks_like_base64url(s: &str) -> bool {
    if s.is_empty() {
        return false;
    }
    s.bytes()
        .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}

/// Counts character classes present in `s`. Returns 0-4.
///
/// Classes: uppercase, lowercase, digit, base64url-special (`-`/`_`).
/// Bitwise-OR accumulation avoids integer arithmetic side effects;
/// `count_ones` reads the final flag set.
fn char_class_count(s: &str) -> u32 {
    let mut flags = 0u8;
    for b in s.bytes() {
        let bit = if b.is_ascii_uppercase() {
            1u8
        } else if b.is_ascii_lowercase() {
            2u8
        } else if b.is_ascii_digit() {
            4u8
        } else if b == b'-' || b == b'_' {
            8u8
        } else {
            0u8
        };
        flags |= bit;
    }
    flags.count_ones()
}

/// Per-segment normalized Shannon entropy: `H(s) / log2(min(len(s), 64))`.
///
/// For `len < 2`, returns 0.0 (entropy undefined / degenerate). The
/// histogram uses bounds-checked `.get_mut()` + `saturating_add` to
/// satisfy the crate's `indexing_slicing` + `arithmetic_side_effects`
/// deny lints. `f64` arithmetic is exempt from those lints
/// (no integer overflow on floats).
fn normalized_entropy(s: &str) -> f64 {
    let len = s.len();
    if len < 2 {
        return 0.0;
    }
    let mut counts = [0u32; 256];
    for b in s.bytes() {
        // PROOF: `b: u8` → `usize` widening, lossless on all targets.
        #[allow(clippy::as_conversions, reason = "PROOF: u8 → usize widening, lossless on all targets.")]
        let idx = b as usize;
        if let Some(c) = counts.get_mut(idx) {
            *c = c.saturating_add(1);
        }
    }
    #[allow(
        clippy::cast_precision_loss,
        clippy::as_conversions,
        reason = "PROOF: entropy thresholds tolerate f64 mantissa loss; len/cap are bounded by string length (well within 2^52 for any realistic input). Output range is [0, 1] for normalized entropy."
    )]
    let len_f = len as f64;
    let mut h = 0.0_f64;
    for c in counts.iter() {
        if *c > 0 {
            let p = f64::from(*c) / len_f;
            h -= p * p.log2();
        }
    }
    #[allow(
        clippy::cast_precision_loss,
        clippy::as_conversions,
        reason = "PROOF: cap is .min(64), trivially exact in f64."
    )]
    let cap = len.min(64) as f64;
    h / cap.log2()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn box_class_name_skip() {
        assert_eq!(
            calibrate("Box", "BatteryMonitorFactoryPeerCleaner"),
            Calibration::Skip,
        );
    }

    #[test]
    fn splunk_class_name_skip() {
        assert_eq!(
            calibrate("Splunk Observability Token", "SpoofingApprovalScreen"),
            Calibration::Skip,
        );
    }

    #[test]
    fn honeycomb_bitmap_factory_downgraded_not_skipped() {
        // Honeycomb skip is owned by the `honeycomb_fp` module; family 1's
        // blanket downgrade applies here.
        assert_eq!(
            calibrate("Honeycomb", "HoneycombBitmapFactory"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn box_real_token_downgraded_not_skipped() {
        // A real Box API token is base64-shaped (contains `=`/`/`),
        // therefore looks_like_class_name returns false; family 1 applies.
        assert_eq!(
            calibrate("Box", "abc123XYZ/=tokenshape="),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn openai_sk_proj_preserves_critical() {
        assert_eq!(
            calibrate("OpenAI", "sk-proj-abcd1234deadbeefcafe5678"),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn openai_classic_51char_preserves_critical() {
        let key = format!("sk-{}", "a".repeat(48));
        assert_eq!(key.len(), 51);
        assert_eq!(
            calibrate("OpenAI", &key),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn openai_locale_prefix_downgraded() {
        assert_eq!(
            calibrate("OpenAI", "sk-Latn-SK"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn openai_unanchored_downgraded() {
        assert_eq!(
            calibrate("OpenAI", "sk-something"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn known_real_critical_preserved() {
        // Pendo APP_KEY JWT shape: a `Pendo` detector match on a
        // JWT-shaped string. Pendo is NOT in the low-severity list;
        // preserves Critical.
        assert_eq!(
            calibrate("Pendo", "eyJhbGciOiJIUzI1NiJ9.payload.signature"),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn aws_real_critical_preserved() {
        // Alexa SECURE_REDIRECT_API_KEY shape — AWS detector emits an
        // AKIA-prefixed access key + secret-pair. Aws is not in the
        // low-severity list; preserves Critical.
        assert_eq!(
            calibrate("AWS", "AKIAIOSFODNN7EXAMPLE"),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn empty_inputs_default_critical() {
        // Defensive: empty detector + empty raw default to Critical
        // emit. Never panics.
        assert_eq!(
            calibrate("", ""),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn detector_case_insensitive() {
        assert_eq!(
            calibrate("BOX", "abc123"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
        assert_eq!(
            calibrate("box", "abc123"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn class_name_lowercase_first_char_not_skipped() {
        // `boxapprovalscreen` (lowercase first) is not a Pascal-case
        // class name; not skipped. Row 1a applies.
        assert_eq!(
            calibrate("Box", "boxapprovalscreen"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn class_name_with_punctuation_not_skipped() {
        // `Factory=foo` contains `=`; not a pure identifier. Not skipped.
        assert_eq!(
            calibrate("Box", "Factory=foo"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn calibrated_severity_as_str_round_trips() {
        assert_eq!(CalibratedSeverity::Critical.as_str(), "Critical");
        assert_eq!(CalibratedSeverity::Low.as_str(), "Low");
    }

    #[test]
    fn unicode_raw_does_not_panic() {
        // Multi-byte UTF-8 input should not panic the byte-walk in
        // looks_like_class_name. CLASS_NAME_SUBSTRINGS contain ASCII
        // only; multi-byte content fails the all-ASCII-identifier check
        // and falls through.
        let raw = "Bo\u{1F600}xCompat";
        assert_eq!(
            calibrate("Box", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    // ---------------------------------------------------------------
    // Discord 3-segment-with-entropy anchor (family 3).
    //
    // Real-token format: BASE64(snowflake) . BASE64(timestamp 4B) .
    // BASE64URL(HMAC). Seg-2 is exactly 6 chars; seg-1 is ~24-26;
    // seg-3 is 27+. Composed gate: ≥3 character classes across full
    // raw AND ≥0.7 normalized Shannon entropy per segment.
    // ---------------------------------------------------------------

    #[test]
    fn discord_real_token_shape_preserves_critical() {
        // Synthesized cryptographic-quality bot token. Three base64url
        // segments; 4 character classes (upper, lower, digit, `-`/`_`);
        // high per-segment entropy.
        let raw = "MTAxOTQ4MzgxMjU3Njc0NDgyOA.Gxq3-T.ZX1J2k7N0pQ-vY3Cm8sR5tU9wW";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn discord_minified_bundle_fp_two_class_downgraded() {
        // RN/Hermes minified-bundle FP shape: random concatenated
        // identifier fragments. Only 2 character classes (lowercase +
        // digit). Family 3 downgrades; class-variety gate fires first.
        let raw = "myhookcomponentstate1234.dolist.somerandomidentifier1234567";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn discord_three_class_low_entropy_downgraded() {
        // 3 character classes (upper, lower, digit) but very low
        // within-segment entropy (each segment is mostly a repeating
        // pattern). Class-variety gate passes; per-segment entropy
        // gate fires.
        let raw = "AAAaaa1AAAaaa1AAAaaa1AAA.BBBbb1.CCCccc222CCCccc222CCCccc22";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn discord_two_segment_only_downgraded() {
        // No third segment. Composed anchor's structural gate (3
        // dot-separated segments) fails immediately.
        let raw = "MTAxOTQ4MzgxMjU3Njc0NDgyOA.Gxq3vT";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn discord_empty_segments_downgraded() {
        // Three segments but one is empty (e.g., `aaa..bbb`). Empty
        // segments fail the base64url charset check (looks_like_base64url
        // returns false on empty). No panic on the entropy walk.
        let raw = "MTAxOTQ4MzgxMjU3Njc0NDgyOA..ZX1J2k7N0pQ-vY3Cm8sR5tU9wW";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn discord_invalid_base64url_chars_downgraded() {
        // Contains `=` (not base64url) — fails the charset check.
        let raw = "MTAxOTQ4MzgxMjU3Njc0NDgyOA=.Gxq3vT.ZX1J2k7N0pQ-vY3Cm8sR5tU9wW";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn discord_detector_case_insensitive() {
        let real = "MTAxOTQ4MzgxMjU3Njc0NDgyOA.Gxq3-T.ZX1J2k7N0pQ-vY3Cm8sR5tU9wW";
        assert_eq!(
            calibrate("DISCORD", real),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
        assert_eq!(
            calibrate("discord", real),
            Calibration::Emit(CalibratedSeverity::Critical),
        );
    }

    #[test]
    fn discord_unicode_raw_does_not_panic() {
        // Multi-byte UTF-8 in the raw must not panic the bytes() walk
        // in char_class_count / normalized_entropy / looks_like_base64url.
        // Non-ASCII bytes fail the base64url charset check.
        let raw = "MTAxOTQ4M\u{1F600}gxMjU3Njc0NDgyOA.Gxq3vT.ZX1J2k7N0pQ-vY3Cm8sR5tU9wW";
        assert_eq!(
            calibrate("Discord", raw),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    // ---------------------------------------------------------------
    // Helper unit tests — exercise the composed-gate primitives
    // independently so regressions surface at the right layer.
    // ---------------------------------------------------------------

    #[test]
    fn char_class_count_counts_all_four_classes() {
        // String spanning upper + lower + digit + base64url-special.
        assert_eq!(char_class_count("Aa1-"), 4);
        assert_eq!(char_class_count("Aa1_"), 4);
    }

    #[test]
    fn char_class_count_counts_three_classes() {
        assert_eq!(char_class_count("Aa1"), 3);
        assert_eq!(char_class_count("Aa-"), 3);
    }

    #[test]
    fn char_class_count_counts_two_classes() {
        assert_eq!(char_class_count("aa1"), 2);
        assert_eq!(char_class_count("AA1"), 2);
    }

    #[test]
    fn char_class_count_empty_string_zero() {
        assert_eq!(char_class_count(""), 0);
    }

    #[test]
    fn normalized_entropy_uniform_string_near_one() {
        // 8-char string with 8 distinct chars from base64url. Shannon
        // = log2(8) = 3.0; normalize divisor = log2(min(8, 64)) = 3.0.
        // Result: exactly 1.0.
        let h = normalized_entropy("Aa1-Bb2_");
        assert!(
            (h - 1.0).abs() < 1e-9,
            "expected ~1.0, got {h}"
        );
    }

    #[test]
    fn normalized_entropy_all_same_char_zero() {
        // Repeated single char: Shannon = 0, normalized = 0.
        let h = normalized_entropy("aaaaaaaa");
        assert!(h.abs() < 1e-9, "expected 0.0, got {h}");
    }

    #[test]
    fn normalized_entropy_short_string_zero() {
        // len < 2 returns 0.0 by design (entropy degenerate).
        assert!(normalized_entropy("").abs() < 1e-9);
        assert!(normalized_entropy("a").abs() < 1e-9);
    }

    #[test]
    fn looks_like_base64url_accepts_alphabet() {
        assert!(looks_like_base64url("AaZz09-_"));
    }

    #[test]
    fn looks_like_base64url_rejects_padding() {
        // `=` is base64 padding but NOT in the base64url URL-safe
        // alphabet that trufflehog matches.
        assert!(!looks_like_base64url("Aa1="));
    }

    #[test]
    fn looks_like_base64url_rejects_empty() {
        assert!(!looks_like_base64url(""));
    }

    // ---------------------------------------------------------------
    // GoogleGeminiAPIKey added to LOW_SEVERITY_DETECTORS — covers the
    // "common shapes" list omission for Gemini API keys.
    // ---------------------------------------------------------------

    #[test]
    fn gemini_api_key_downgraded() {
        // GoogleGeminiAPIKey is in LOW_SEVERITY_DETECTORS; coarse
        // downgrade applies regardless of raw shape. (Precision anchor
        // could be added when FP corpus warrants.)
        assert_eq!(
            calibrate("GoogleGeminiAPIKey", "AIzaSyABCDEFGHIJKLMNOPQRSTUVWXYZ123456"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    #[test]
    fn gemini_case_insensitive() {
        assert_eq!(
            calibrate("googlegeminiapikey", "abc123"),
            Calibration::Emit(CalibratedSeverity::Low),
        );
    }

    // ---------------------------------------------------------------
    // Exhaustiveness fixture — canonical routing table for calibrate().
    //
    // Every detector name the codebase has seen in production or has an
    // explicit calibration family for MUST appear here with its expected
    // routing outcome. Adding a new detector name without adding a row
    // (or a comment explaining why it is absent) is a test failure.
    //
    // Detectors handled BEFORE calibrate() is called — absent by design:
    //   - `Square`    — dropped by `trufflehog_is_known_fp` pre-filter in
    //                   `write_credentials_db`; never reaches calibrate().
    //   - `Circle` /
    //     `CircleCI`  — downgraded to Info by `credentials_fp::is_circle_detector`
    //                   pre-filter; never reaches calibrate().
    //   - `Honeycomb` — class-name Info / anchor re-promotion owned by the
    //                   `honeycomb_fp` pre-filter. Honeycomb IS also in
    //                   family 1 as a backstop, so it appears below for
    //                   its calibrate()-reachable shape.
    //
    // For anchored detectors (OpenAI, Discord), two rows are needed:
    // one for the anchored (Critical) shape and one for the un-anchored
    // (Low) shape, because routing depends on `raw`, not just `detector`.
    //
    // HOW TO UPDATE: when a new production-observed detector name is
    // confirmed (e.g. from a new findings batch), add a row to `CASES`
    // with the expected `Calibration`. If the detector is correctly
    // Critical by default, add a `// intentional Critical — <reason>`
    // comment so the intent is recorded.
    // ---------------------------------------------------------------
    #[test]
    fn calibrate_exhaustiveness_known_detectors() {
        // `sk-` + 48 chars = 51 total — classic OpenAI key anchor.
        let openai_classic_51 = format!("sk-{}", "a".repeat(48));

        #[allow(clippy::type_complexity)]
        let cases: &[(&str, &str, Calibration)] = &[
            // Family 1 — Low-severity detector list (coarse downgrade).
            ("Box",                        "abc123",        Calibration::Emit(CalibratedSeverity::Low)),
            ("GoogleGeminiAPIKey",         "AIzaSyABC",     Calibration::Emit(CalibratedSeverity::Low)),
            // Honeycomb backstop: non-class-name, non-anchor raw → Low via family 1.
            ("Honeycomb",                  "abc123",        Calibration::Emit(CalibratedSeverity::Low)),
            ("Myfreshworks",               "abc123",        Calibration::Emit(CalibratedSeverity::Low)),
            ("Shortcut",                   "abc123",        Calibration::Emit(CalibratedSeverity::Low)),
            ("Splunk Observability Token", "abc123",        Calibration::Emit(CalibratedSeverity::Low)),
            // Family 4 — class-name skip for PascalCase raw.
            ("Box",                        "BatteryMonitorFactoryPeerCleaner", Calibration::Skip),
            ("Splunk Observability Token", "SpoofingApprovalScreen",           Calibration::Skip),
            // Family 2 — OpenAI sk-anchor: anchored shapes → Critical; un-anchored → Low.
            ("OpenAI",  "sk-proj-abcd1234deadbeefcafe5678",    Calibration::Emit(CalibratedSeverity::Critical)),
            ("OpenAI",  &openai_classic_51,                    Calibration::Emit(CalibratedSeverity::Critical)),
            ("OpenAI",  "sk-Latn-SK",                          Calibration::Emit(CalibratedSeverity::Low)),
            // Family 3 — Discord 3-segment-with-entropy anchor.
            ("Discord", "MTAxOTQ4MzgxMjU3Njc0NDgyOA.Gxq3-T.ZX1J2k7N0pQ-vY3Cm8sR5tU9wW",
                        Calibration::Emit(CalibratedSeverity::Critical)),
            ("Discord", "myhookcomponentstate1234.dolist.somerandomidentifier1234567",
                        Calibration::Emit(CalibratedSeverity::Low)),
            // Default Critical — production-observed; no explicit family.
            // These fall through to Critical intentionally: the FP rate for
            // their real-credential shapes is too low to warrant a downgrade.
            ("Pendo",   "eyJhbGciOiJIUzI1NiJ9.payload.sig",   Calibration::Emit(CalibratedSeverity::Critical)), // intentional Critical — JWT-shaped app key
            ("AWS",     "AKIAIOSFODNN7EXAMPLE",                Calibration::Emit(CalibratedSeverity::Critical)), // intentional Critical — AKIA-prefixed access key
        ];

        for (detector, raw, expected) in cases {
            assert_eq!(
                calibrate(detector, raw),
                *expected,
                "calibrate({detector:?}, {raw:?}) routing mismatch — \
                 update CASES in calibrate_exhaustiveness_known_detectors \
                 or add a new calibration family",
            );
        }
    }
}
