use super::*;

/// Contract: `CALIBRATED_RULE_IDS` is the single source of truth used by
/// both the calibration pipeline AND the compound verdict detector in
/// `verdict.rs` to guard against checking a calibrated rule's raw action.
/// Adding a new branch to `calibrate_verdict_inputs` without registering
/// the rule here lets the verdict detector treat the post-calibration
/// finding as if calibration never ran. This test pins the round-trip:
/// every calibration target appears in the constant, and every entry in
/// the constant is a real calibration target.
#[test]
fn calibrated_rule_ids_covers_all_calibration_targets() {
    // Rules that have explicit calibration branches in calibrate_verdict_inputs
    let expected: &[&str] = &[
        "DECLARED_PERMISSION_NETWORK_ACCESS",
        "CAPABILITY_PERMISSION_MISMATCH",
        "INTERNAL_NETWORK_ACCESS",
        "MCP_NO_AUTH_MODEL",
        "OFFICIAL_MCP_NO_AUTH_REMOTE_ENDPOINT",
    ];

    for rule_id in expected {
        assert!(
            CALIBRATED_RULE_IDS.contains(rule_id),
            "Calibration target rule '{rule_id}' is missing from CALIBRATED_RULE_IDS. \
             Add it to the constant in verdict_calibration.rs."
        );
    }

    for rule_id in CALIBRATED_RULE_IDS {
        assert!(
            expected.contains(rule_id),
            "CALIBRATED_RULE_IDS contains '{rule_id}' which is not a known calibration target. \
             Either add a calibration branch or remove it from the constant."
        );
    }
}

/// Contract: when a co-resident finding qualifies as "stronger behavior"
/// (RequireApproval-or-stronger action, SuspiciousPackageBehavior /
/// MaliciousBehavior class, NOT a permission-model rule), the Tier 1
/// `DECLARED_PERMISSION_NETWORK_ACCESS` calibration gate STAYS CLOSED.
/// The group must remain at its original action with no calibration note
/// emitted — otherwise a real exfiltration co-resident with a benign
/// network declaration would be silently downgraded.
#[test]
fn stronger_behavior_prevents_network_downgrade() {
    use crate::findings::{
        ArtifactScope, Finding, MatchTarget, RootCauseGroup, Severity, ThreatCategory,
    };

    // The "stronger behavior" finding prevents the gate from opening for
    // DECLARED_PERMISSION_NETWORK_ACCESS calibration.
    let findings = vec![
        Finding::builder(
            "DECLARED_PERMISSION_NETWORK_ACCESS",
            ThreatCategory::DataExfiltration,
        )
        .severity(Severity::Medium)
        .action(RecommendedAction::RequireApproval)
        .signal_class(SignalClass::SuspiciousPackageBehavior)
        .matched_on(MatchTarget::Document)
        .match_value("network access")
        .reason("declared network")
        .build(),
        // This finding qualifies as "stronger behavior": RequireApproval,
        // SuspiciousPackageBehavior, not a permission-model or calibration rule.
        Finding::builder("SKILL_REMOTE_EXEC_CURL_BASH", ThreatCategory::RemoteExec)
            .severity(Severity::Critical)
            .action(RecommendedAction::RequireApproval)
            .signal_class(SignalClass::SuspiciousPackageBehavior)
            .matched_on(MatchTarget::Document)
            .match_value("curl | bash")
            .reason("remote exec")
            .build(),
    ];

    let root_cause_groups = vec![RootCauseGroup {
        scope: ArtifactScope::AgentEntrypoint,
        category: ThreatCategory::DataExfiltration,
        signal_class: SignalClass::SuspiciousPackageBehavior,
        finding_count: 1,
        strongest_action: RecommendedAction::RequireApproval,
        representative_rules: vec!["DECLARED_PERMISSION_NETWORK_ACCESS".to_string()],
    }];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    assert_eq!(
        result.root_cause_groups.len(),
        1,
        "group must not be pruned when stronger behavior prevents downgrade"
    );
    assert_eq!(
        result.root_cause_groups[0].strongest_action,
        RecommendedAction::RequireApproval,
        "action must remain RequireApproval when gate is blocked by stronger behavior"
    );
    assert!(
        result.notes.is_empty(),
        "no calibration note should be emitted when the gate does not open"
    );
}

/// Contract: an isolated `INTERNAL_NETWORK_ACCESS` finding (no network
/// chain co-resident) MUST reclassify the surviving group's
/// `signal_class` to `ReviewSignal`. Tier 3 sets `reclassify_signal =
/// true` precisely so `verdict::predicates::is_isolated_weak_package_root_signal`
/// recognises the finding as review-only and emits Benign.
/// Without the reclassification, an isolated loopback hit would still
/// present as `MaliciousBehavior` to verdict.rs, bypassing the Benign
/// downgrade path Tier 3 was designed to enable.
#[test]
fn internal_network_reclassifies_to_review_signal() {
    use crate::findings::{
        ArtifactScope, Finding, MatchTarget, RootCauseGroup, Severity, ThreatCategory,
    };

    // ToolAbuse is used intentionally: DataExfiltration + RequireApproval would
    // satisfy `has_network_chain` (the gate condition), preventing calibration
    // from firing. ToolAbuse is outside the chain-category check.
    let findings = vec![
        Finding::builder("INTERNAL_NETWORK_ACCESS", ThreatCategory::ToolAbuse)
            .severity(Severity::Medium)
            .action(RecommendedAction::RequireApproval)
            .signal_class(SignalClass::SuspiciousPackageBehavior)
            .matched_on(MatchTarget::Document)
            .match_value("localhost")
            .reason("internal network")
            .build(),
        // A low-severity co-resident finding that will survive exclusion and
        // keep the group alive so we can inspect its signal_class.
        Finding::builder("SOME_REVIEW_SIGNAL", ThreatCategory::ToolAbuse)
            .severity(Severity::Low)
            .action(RecommendedAction::Log)
            .signal_class(SignalClass::SuspiciousPackageBehavior)
            .matched_on(MatchTarget::Document)
            .match_value("benign")
            .reason("low risk")
            .build(),
    ];

    let root_cause_groups = vec![RootCauseGroup {
        scope: ArtifactScope::AgentEntrypoint,
        category: ThreatCategory::ToolAbuse,
        signal_class: SignalClass::SuspiciousPackageBehavior,
        finding_count: 2,
        strongest_action: RecommendedAction::RequireApproval,
        representative_rules: vec!["INTERNAL_NETWORK_ACCESS".to_string()],
    }];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    assert_eq!(
        result.root_cause_groups.len(),
        1,
        "group should survive because one non-calibrated finding remains"
    );
    assert_eq!(
        result.root_cause_groups[0].signal_class,
        SignalClass::ReviewSignal,
        "INTERNAL_NETWORK_ACCESS calibration must reclassify group to ReviewSignal"
    );
}

/// Contract: dedup_notes collapses notes that match on the FULL identity
/// tuple `(scope, category, rule_id, effect, rationale)`. Two groups that
/// differ only in `category` produce DIFFERENT notes and MUST survive
/// independently — the verdict predicate filters by `(scope, category)`
/// to decide isolated-weak Benign downgrades, so collapsing them would
/// silently widen the downgrade.
#[test]
fn note_deduplication_keeps_per_group_notes() {
    use crate::findings::{
        ArtifactScope, Finding, MatchTarget, RootCauseGroup, Severity, ThreatCategory,
    };

    let findings = vec![
        Finding::builder(
            "DECLARED_PERMISSION_NETWORK_ACCESS",
            ThreatCategory::DataExfiltration,
        )
        .severity(Severity::Medium)
        .action(RecommendedAction::RequireApproval)
        .signal_class(SignalClass::SuspiciousPackageBehavior)
        .matched_on(MatchTarget::Document)
        .match_value("network exfil")
        .reason("group a")
        .build(),
        Finding::builder(
            "DECLARED_PERMISSION_NETWORK_ACCESS",
            ThreatCategory::SupplyChain,
        )
        .severity(Severity::Medium)
        .action(RecommendedAction::RequireApproval)
        .signal_class(SignalClass::SuspiciousPackageBehavior)
        .matched_on(MatchTarget::Document)
        .match_value("network supply")
        .reason("group b")
        .build(),
    ];

    let root_cause_groups = vec![
        RootCauseGroup {
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::DataExfiltration,
            signal_class: SignalClass::SuspiciousPackageBehavior,
            finding_count: 1,
            strongest_action: RecommendedAction::RequireApproval,
            representative_rules: vec!["DECLARED_PERMISSION_NETWORK_ACCESS".to_string()],
        },
        RootCauseGroup {
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::SupplyChain,
            signal_class: SignalClass::SuspiciousPackageBehavior,
            finding_count: 1,
            strongest_action: RecommendedAction::RequireApproval,
            representative_rules: vec!["DECLARED_PERMISSION_NETWORK_ACCESS".to_string()],
        },
    ];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    // Two distinct (scope, category) pairs MUST surface two notes so
    // verdict::predicates can filter per-group.
    assert_eq!(
        result.notes.len(),
        2,
        "Notes for groups with different (scope, category) MUST NOT collapse; \
         the verdict predicate keys on those fields to decide Benign downgrades"
    );
    let categories: std::collections::HashSet<_> =
        result.notes.iter().map(|n| n.category).collect();
    assert!(categories.contains(&ThreatCategory::DataExfiltration));
    assert!(categories.contains(&ThreatCategory::SupplyChain));
}

/// Contract: when a calibration rule fires on a group whose
/// `strongest_action` is already `Log`, the emitted note must record
/// `effect_unchanged` (`remains_context_only`), NOT `effect_downgraded`.
/// Conflating the two would make the SHIELD audit trail claim a
/// downgrade that never happened, which masks calibration regressions
/// during a corpus rebaseline.
#[test]
fn effect_unchanged_when_action_already_at_minimum() {
    use crate::findings::{
        ArtifactScope, Finding, MatchTarget, RootCauseGroup, Severity, ThreatCategory,
    };

    // Finding with Log action: after excluding DECLARED_PERMISSION the group
    // ends up at Log regardless — no downgrade occurred, so effect_unchanged fires.
    let findings = vec![Finding::builder(
        "DECLARED_PERMISSION_NETWORK_ACCESS",
        ThreatCategory::DataExfiltration,
    )
    .severity(Severity::Low)
    .action(RecommendedAction::Log)
    .signal_class(SignalClass::SuspiciousPackageBehavior)
    .matched_on(MatchTarget::Document)
    .match_value("network")
    .reason("declared network log-level")
    .build()];

    let root_cause_groups = vec![RootCauseGroup {
        scope: ArtifactScope::AgentEntrypoint,
        category: ThreatCategory::DataExfiltration,
        signal_class: SignalClass::SuspiciousPackageBehavior,
        finding_count: 1,
        strongest_action: RecommendedAction::Log,
        representative_rules: vec!["DECLARED_PERMISSION_NETWORK_ACCESS".to_string()],
    }];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    assert_eq!(
        result.notes.len(),
        1,
        "one calibration note must be emitted"
    );
    assert_eq!(
        result.notes[0].effect, "remains_context_only",
        "effect must be 'remains_context_only' when action was already at Log"
    );
}

/// Contract: when calibration excludes every finding in a group, the
/// post-calibration `finding_count` is 0 and the group MUST be pruned
/// from `root_cause_groups`. Phantom groups with zero remaining findings
/// inflate verdict-reason counts and surface confusing "0 finding(s)"
/// entries in SHIELD output. The retain step in
/// `calibrate_verdict_inputs` is what guards against that regression.
#[test]
fn calibration_updates_finding_count_when_excluding_rules() {
    use crate::findings::{
        ArtifactScope, Finding, MatchTarget, RootCauseGroup, Severity, ThreatCategory,
    };

    // Two findings in the same group, both DECLARED_PERMISSION_NETWORK_ACCESS
    let findings = vec![
        Finding::builder(
            "DECLARED_PERMISSION_NETWORK_ACCESS",
            ThreatCategory::DataExfiltration,
        )
        .severity(Severity::Medium)
        .action(RecommendedAction::RequireApproval)
        .signal_class(SignalClass::SuspiciousPackageBehavior)
        .matched_on(MatchTarget::Document)
        .match_value("network access")
        .reason("declared network")
        .build(),
        Finding::builder(
            "DECLARED_PERMISSION_NETWORK_ACCESS",
            ThreatCategory::DataExfiltration,
        )
        .severity(Severity::Low)
        .action(RecommendedAction::Log)
        .signal_class(SignalClass::SuspiciousPackageBehavior)
        .matched_on(MatchTarget::Document)
        .match_value("another network ref")
        .reason("declared network 2")
        .build(),
    ];

    let root_cause_groups = vec![RootCauseGroup {
        scope: ArtifactScope::AgentEntrypoint,
        category: ThreatCategory::DataExfiltration,
        signal_class: SignalClass::SuspiciousPackageBehavior,
        finding_count: 2,
        strongest_action: RecommendedAction::RequireApproval,
        representative_rules: vec!["DECLARED_PERMISSION_NETWORK_ACCESS".to_string()],
    }];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    // Both findings were excluded by the DECLARED_PERMISSION_NETWORK_ACCESS rule,
    // so the group is removed entirely (phantom groups with 0 findings are pruned).
    assert!(
        result.root_cause_groups.is_empty(),
        "Groups with 0 remaining findings should be pruned"
    );
}

/// Contract: `dedup_notes` must keep notes with the same `(rule_id, effect, rationale)`
/// when their `(scope, category)` differ. Downstream verdict predicates filter notes
/// by `(scope, category)` to decide isolated-weak Benign downgrades; collapsing them
/// would let one group's `downgraded_*` note vacuously satisfy another group's
/// predicate, silently turning `Suspicious` into `Benign`.
///
/// This test guards against re-introducing the round-1 `dedup_notes` regression.
#[test]
fn dedup_notes_preserves_per_group_distinctions() {
    use crate::findings::{ArtifactScope, SignalClass, ThreatCategory, VerdictCalibrationNote};

    let mut notes = vec![
        VerdictCalibrationNote {
            rule_id: "DECLARED_PERMISSION_NETWORK_ACCESS".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "shared rationale".to_string(),
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::Hygiene,
        },
        VerdictCalibrationNote {
            rule_id: "DECLARED_PERMISSION_NETWORK_ACCESS".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "shared rationale".to_string(),
            scope: ArtifactScope::PackageRootArtifact,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::Hygiene,
        },
        // Exact duplicate of the first — must collapse.
        VerdictCalibrationNote {
            rule_id: "DECLARED_PERMISSION_NETWORK_ACCESS".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "shared rationale".to_string(),
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::Hygiene,
        },
    ];

    dedup_notes(&mut notes);

    assert_eq!(
        notes.len(),
        2,
        "Notes differing only in scope/category MUST survive dedup; \
         exact duplicates MUST collapse"
    );
    let scopes: Vec<_> = notes.iter().map(|n| n.scope).collect();
    assert!(scopes.contains(&ArtifactScope::AgentEntrypoint));
    assert!(scopes.contains(&ArtifactScope::PackageRootArtifact));
}

/// Contract: two notes that match on the FULL identity tuple
/// `(scope, category, rule_id, effect, rationale)` MUST collapse to a
/// single entry. This is the legitimate dedup path — without it, a rule
/// that fires twice within the same group would emit duplicate audit
/// entries in SHIELD output. The companion test
/// `dedup_notes_preserves_per_group_distinctions` guards the inverse.
#[test]
fn dedup_notes_collapses_per_group_duplicates_within_same_group() {
    use crate::findings::{ArtifactScope, SignalClass, ThreatCategory, VerdictCalibrationNote};

    let mut notes = vec![
        VerdictCalibrationNote {
            rule_id: "RULE_A".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "x".to_string(),
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::Hygiene,
        },
        VerdictCalibrationNote {
            rule_id: "RULE_A".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "x".to_string(),
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::Hygiene,
        },
    ];
    dedup_notes(&mut notes);
    assert_eq!(notes.len(), 1);
}

/// Contract: `dedup_notes` MUST NOT collapse notes that differ only in
/// `signal_class`. Two root cause groups at the same `(scope, category)`
/// but different `signal_class` (e.g. `MaliciousBehavior` vs `ReviewSignal`)
/// produce calibration notes with identical `(rule_id, effect, rationale)`.
/// Collapsing them would let an unrelated `downgraded_*` note in one group
/// vacuously satisfy the Benign-path filter for another group, silently
/// downgrading `Suspicious` to `Benign`. Pre-fix the dedup key omitted
/// `signal_class`, causing exactly this collapse.
#[test]
fn dedup_notes_preserves_signal_class_distinctions() {
    use crate::findings::{ArtifactScope, SignalClass, ThreatCategory, VerdictCalibrationNote};

    let mut notes = vec![
        VerdictCalibrationNote {
            rule_id: "DECLARED_PERMISSION_NETWORK_ACCESS".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "shared rationale".to_string(),
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::MaliciousBehavior,
        },
        VerdictCalibrationNote {
            rule_id: "DECLARED_PERMISSION_NETWORK_ACCESS".to_string(),
            effect: "remains_context_only".to_string(),
            rationale: "shared rationale".to_string(),
            scope: ArtifactScope::AgentEntrypoint,
            category: ThreatCategory::ScopeCreep,
            signal_class: SignalClass::ReviewSignal,
        },
    ];

    dedup_notes(&mut notes);

    assert_eq!(
        notes.len(),
        2,
        "Notes differing in signal_class MUST survive dedup; got {:?}",
        notes
    );
    let classes: Vec<_> = notes.iter().map(|n| n.signal_class).collect();
    assert!(
        classes.contains(&SignalClass::MaliciousBehavior),
        "MaliciousBehavior note must survive"
    );
    assert!(
        classes.contains(&SignalClass::ReviewSignal),
        "ReviewSignal note must survive"
    );
}

/// Contract: after a calibration rule reclassifies a group's `signal_class`
/// from `SuspiciousPackageBehavior` to `ReviewSignal`, a second calibration
/// rule firing on the same group MUST still populate `representative_rules`
/// with the original signal class's findings. The pre-fix code filtered
/// `f.signal_class == group.signal_class` (post-reclassification), which
/// produced an empty `representative_rules` vector because all findings
/// retained their original signal class.
#[test]
fn representative_rules_uses_original_signal_class_after_reclassification() {
    use crate::findings::{
        ArtifactScope, Finding, MatchTarget, RootCauseGroup, Severity, ThreatCategory,
    };

    // INTERNAL_NETWORK_ACCESS triggers Tier 3 (reclassify_signal = true).
    // After Tier 3 fires, group.signal_class becomes ReviewSignal.
    // A second rule (MCP_NO_AUTH_MODEL, Tier 4) would then rebuild
    // representative_rules. If it used group.signal_class instead of
    // state.original_signal_class, it would find zero matching findings.
    let findings = vec![
        Finding::builder("INTERNAL_NETWORK_ACCESS", ThreatCategory::ToolAbuse)
            .severity(Severity::Medium)
            .action(RecommendedAction::RequireApproval)
            .signal_class(SignalClass::SuspiciousPackageBehavior)
            .matched_on(MatchTarget::Document)
            .match_value("localhost")
            .reason("internal network")
            .build(),
        Finding::builder("SOME_OTHER_RULE", ThreatCategory::ToolAbuse)
            .severity(Severity::Low)
            .action(RecommendedAction::Log)
            .signal_class(SignalClass::SuspiciousPackageBehavior)
            .matched_on(MatchTarget::Document)
            .match_value("benign")
            .reason("low risk")
            .build(),
    ];

    let root_cause_groups = vec![RootCauseGroup {
        scope: ArtifactScope::AgentEntrypoint,
        category: ThreatCategory::ToolAbuse,
        signal_class: SignalClass::SuspiciousPackageBehavior,
        finding_count: 2,
        strongest_action: RecommendedAction::RequireApproval,
        representative_rules: vec!["INTERNAL_NETWORK_ACCESS".to_string()],
    }];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    // After reclassification, representative_rules must NOT be empty.
    let group = &result.root_cause_groups[0];
    assert!(
        !group.representative_rules.is_empty(),
        "representative_rules must contain rules from findings with the original signal_class; \
         got empty representative_rules, which means the rebuild used the post-reclassification \
         signal_class instead of original_signal_class"
    );
}

/// Contract: a group whose strongest action is *inherently* `Log` (i.e. it was
/// `Log` before any calibration rule fired) must NOT be reclassified to
/// `ReviewSignal`. Reclassification is reserved for groups that calibration
/// *actively* downgraded — groups already at their floor action have no
/// calibration artifact to justify a signal-class change.
#[test]
fn reclassify_signal_does_not_fire_on_inherently_log_groups() {
    use crate::findings::{ArtifactScope, Finding, MatchTarget, Severity, ThreatCategory};

    let findings = vec![
        Finding::builder("SOME_HYGIENE_RULE", ThreatCategory::DataExfiltration)
            .severity(Severity::Low)
            .action(RecommendedAction::Log)
            .signal_class(SignalClass::SuspiciousPackageBehavior)
            .matched_on(MatchTarget::Document)
            .match_value("localhost")
            .reason("inherent log signal")
            .build(),
    ];

    let root_cause_groups = vec![RootCauseGroup {
        scope: ArtifactScope::AgentEntrypoint,
        category: ThreatCategory::DataExfiltration,
        signal_class: SignalClass::SuspiciousPackageBehavior,
        finding_count: 1,
        strongest_action: RecommendedAction::Log,
        representative_rules: vec!["SOME_HYGIENE_RULE".to_string()],
    }];

    let result = calibrate_verdict_inputs(&findings, &root_cause_groups);

    // The group was at Log before calibration; reclassify_signal must NOT fire.
    let group = &result.root_cause_groups[0];
    assert_eq!(
        group.signal_class,
        SignalClass::SuspiciousPackageBehavior,
        "inherently-Log group must not be reclassified to ReviewSignal; \
         reclassify_signal should only fire when calibration actively downgraded the action"
    );
}
