// SPDX-License-Identifier: BSD-3-Clause

//! Canonical `AuditEnvelope` — the ONE response shape for all `audit` paths.
//!
//! Both the CLI `audit` command (via [`crate::commands::audit_light_with_mode`])
//! and the MCP `audit` tool (via `run_core_audit_blocking`) produce an
//! `AuditEnvelope`.  The intentional divergence between the two adapters is
//! in **which optional fields are populated**, not in the shape itself:
//!
//! | Field             | CLI (`audit_light`) | MCP (`audit`)      |
//! |-------------------|---------------------|--------------------|
//! | `findings`        | full inline list    | empty (in DB)      |
//! | `top_findings`    | ≤5 Semantic-first Critical/High | ≤5 Semantic-first Critical/High |
//! | `severity_by_gauge` | gauge-stratified  | gauge-stratified   |
//! | `truncated`       | false               | true when `top_findings` < total High+ |
//! | `db_path`         | `None`              | `Some("…")`        |
//! | `db_queries`      | `None`              | `Some({…})`        |
//! | `detectors`       | `Some({…})`         | `None`             |
//! | `timings_ms`      | `None`              | `Some({…})`        |
//!
//! Both adapters build `top_findings` via [`AuditEnvelope::rank_top_findings`]
//! and `severity_by_gauge` via [`AuditEnvelope::stratify_by_gauge`] over the
//! same finding set that feeds the flat `severity_summary`; the CLI
//! additionally carries the full list inline in `findings`.
//!
//! `schema_version` is carried on every response so downstream consumers can
//! gate on breaking changes (see `threat_model::envelope::SCHEMA_VERSION` for
//! the bump policy; this is the *response* version, independent of the
//! evidence-envelope wire format).

use std::collections::BTreeMap;

use droidsaw_common::{Finding, GaugeClass};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Current `AuditEnvelope` schema version.
///
/// Bump only for **breaking** field changes (removed field, renamed field,
/// changed type). New optional fields are additive and do not require a bump.
///
/// Bumped to `2` when `severity_by_gauge` was added: the field is additive
/// (`#[serde(default)]`), so old envelopes still deserialize, but the bump
/// signals to downstream consumers that the gauge-stratified summary is
/// available on responses at this version and above.
pub const AUDIT_ENVELOPE_VERSION: u32 = 2;

/// Cap on the `top_findings` capped projection. Shared by both audit
/// adapters via [`AuditEnvelope::rank_top_findings`] so the two transports
/// project the same number of findings. The MCP `truncated` flag is set when
/// the Critical/High population exceeds this cap.
pub const TOP_FINDINGS_CAP: usize = 5;

/// Per-APK shape statistics derived from the `CrossLayerContext` the audit
/// pipeline already builds.  Surfaced in the envelope so corpus-sweep scripts
/// can read these gauges directly from the audit JSON instead of attempting
/// to scrape them out of the `meta` block (which only holds navigation
/// metadata).
///
/// Three field names (`hbc_function_count`, `dex_methods_total`,
/// `dex_classes_total`) align with comparable Java/Android decompiler bench
/// schemas (`functions_decompiled`, `methods_emitted`, `classes_emitted`)
/// to enable cross-tool join queries.  Semantic note: the droidsaw counts
/// are "definitions present in parsed DEX/HBC", not "what decompilation
/// actually rendered" — droidsaw audit does not decompile.
///
/// `None` is emitted when the context did not originate from a container
/// input.  Additive field — old consumers that do not know about
/// `apk_summary` simply ignore it.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ApkSummary {
    /// `true` when a Hermes bytecode bundle was found in the APK container.
    pub has_hbc: bool,
    /// Byte length of the embedded HBC bundle, or `0` when `has_hbc` is
    /// `false`.
    pub hbc_bytes: u64,
    /// Number of functions declared in the parsed HBC bundle (jadx-aligned:
    /// mirrors `functions_decompiled`).  `0` when `has_hbc` is `false`.
    pub hbc_function_count: u32,
    /// Number of DEX entries in the APK container (classes.dex,
    /// classes2.dex, …).  For standalone `.dex` input this is always `1`.
    pub dex_count: u32,
    /// Sum of the raw byte lengths of all DEX entries.
    pub dex_total_bytes: u64,
    /// Total method-definition count across all DEX entries (sum of
    /// `direct_methods + virtual_methods` in every parsed class_data).
    /// Jadx-aligned: mirrors `methods_emitted`.  Distinct from the
    /// `method_ids` pool size, which also counts external references.
    pub dex_methods_total: u64,
    /// Total class-definition count across all DEX entries (sum of
    /// `class_defs.len()`).  Jadx-aligned: mirrors `classes_emitted`.
    pub dex_classes_total: u64,
}

/// Severity histogram split by [`GaugeClass`] noise tier.
///
/// The flat `severity_summary` tallies every finding regardless of tier,
/// which lets repackaging artefacts (signing facts, ZIP/encoding noise)
/// dominate the Critical/High counts and bury the attacker-relevant
/// surface. This struct stratifies the same set of findings into three
/// disjoint buckets so a consumer can read the *actionable* severity
/// histogram first:
///
/// - `actionable` — [`GaugeClass::Semantic`]: taint flows, manifest
///   intent, deeplink exposure, obfuscation — everything attacker-relevant.
///   This is the default tier for any finding ID not explicitly listed in
///   the other two.
/// - `signing_facts` — [`GaugeClass::Cryptographic`]: certificate / signing
///   block facts (`SPLIT_KEY_MISMATCH`, `WEAK_SIGNATURE`, `DEBUG_CERT`, …).
///   Invariant under lossless repackaging.
/// - `encoding_noise` — [`GaugeClass::Representational`]: ZIP / resource /
///   asset encoding artefacts (`ENCRYPTED_ASSET`, `ZIP_REPACKAGED`, …).
///
/// Each bucket is keyed on the `Debug` name of
/// [`droidsaw_common::Severity`] (e.g. `"Critical"`, `"High"`), exactly
/// like `severity_summary`. By construction the per-severity sum across all
/// three buckets equals the flat `severity_summary` count for that severity
/// (see [`AuditEnvelope::stratify_by_gauge`]); nothing is dropped, the same
/// finding set is merely re-bucketed.
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct SeverityStrata {
    /// [`GaugeClass::Semantic`] severity histogram — the attacker-relevant
    /// surface a reviewer should triage first.
    pub actionable: BTreeMap<String, u64>,
    /// [`GaugeClass::Cryptographic`] severity histogram — signing / cert
    /// facts, invariant under repackaging.
    pub signing_facts: BTreeMap<String, u64>,
    /// [`GaugeClass::Representational`] severity histogram — ZIP / resource
    /// / asset encoding artefacts.
    pub encoding_noise: BTreeMap<String, u64>,
}

/// One canonical response shape for every `audit` invocation, whether issued
/// via the CLI or MCP transport.
///
/// See module-level doc for the intentional-divergence table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditEnvelope {
    /// Envelope schema version (always [`AUDIT_ENVELOPE_VERSION`]).
    pub schema_version: u32,

    /// Full finding list. Populated by CLI; empty for MCP (findings live in
    /// the sqlite DB at `db_path`).
    pub findings: Vec<Finding>,

    /// Post-dedup finding count (matches `SELECT COUNT(*) FROM findings`).
    pub finding_count: u64,

    /// Pre-dedup raw count from the detector pipeline.  The gap
    /// `findings_emitted - finding_count` is the number of duplicates the
    /// signature-hash UNIQUE index collapsed.
    pub findings_emitted: u64,

    /// Count of confirmed taint-flow findings present in `.findings`,
    /// i.e. those with `id ∈ {HBC_TAINT_FLOW, DEX_TAINT_FLOW,
    /// BRIDGE_TAINT_FLOW}`. These are the cross-layer source→sink flows
    /// the taint pipeline asserts.
    ///
    /// Deliberately excludes `JNI_TAINTED_NATIVE_CALL` (different class
    /// — tainted data reached native code, but the sink behavior is
    /// opaque without symbol resolution) and
    /// `BRIDGE_RESOLUTION_AMBIGUOUS` (resolution ambiguity, not a
    /// confirmed flow). Operators wanting either category should filter
    /// `.findings` directly.
    ///
    /// Both CLI and MCP audit paths populate this from
    /// [`AuditEnvelope::count_taint_flow_findings`] over the same
    /// finding set, so the value is identical regardless of transport.
    pub taint_flow_count: u64,

    /// Severity histogram keyed on the `Debug` name of
    /// [`droidsaw_common::Severity`] (e.g. `"Critical"`, `"High"`).
    ///
    /// This is the **flat** tally over *all* findings — it does not
    /// distinguish actionable findings from signing/encoding noise. Kept
    /// unchanged for back-compat; prefer `severity_by_gauge.actionable`
    /// when you want the attacker-relevant histogram.
    pub severity_summary: BTreeMap<String, u64>,

    /// Severity histogram **stratified by [`GaugeClass`]** — the same
    /// finding set as `severity_summary`, split into `actionable` /
    /// `signing_facts` / `encoding_noise` so a consumer can read the
    /// attacker-relevant severity counts without signing/encoding facts
    /// inflating them. Built by [`AuditEnvelope::stratify_by_gauge`] on
    /// both the CLI and MCP paths from the same finding set that feeds
    /// `severity_summary`.
    ///
    /// `#[serde(default)]`: an envelope serialized before this field
    /// existed deserializes with an empty [`SeverityStrata`].
    #[serde(default)]
    pub severity_by_gauge: SeverityStrata,

    /// Capped projection (≤ [`TOP_FINDINGS_CAP`]) of Critical/High findings,
    /// ranked Semantic-first via [`AuditEnvelope::rank_top_findings`]. Both
    /// the CLI and MCP paths populate it; the CLI path additionally carries
    /// the full finding list in `findings`.
    pub top_findings: Vec<Value>,

    /// `true` when `top_findings` is a truncated view of the full High+
    /// Critical set (i.e., there are more than 5 such findings).
    pub truncated: bool,

    /// Path to the sqlite DB written by this audit run.
    /// `None` for the CLI path (no DB is written).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub db_path: Option<String>,

    /// Pre-built SQL queries for common follow-up patterns.
    /// Populated by the MCP path; `None` for CLI.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub db_queries: Option<Value>,

    /// Number of finding-xref rows written.  `None` for CLI.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub finding_xrefs_written: Option<u64>,

    /// Per-detector status summary.  Populated by CLI; `None` for MCP
    /// (MCP reports detector results inline under `trufflehog` / `semgrep`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub detectors: Option<Value>,

    /// Trufflehog subprocess result (both paths).  `None` when mode doesn't
    /// run trufflehog (`basic`, `semgrep`).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub trufflehog: Option<Value>,

    /// Semgrep result (both paths).  `None` when mode doesn't run semgrep.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub semgrep: Option<Value>,

    /// Per-phase wall-clock timing (milliseconds).  Populated by MCP;
    /// `None` for CLI (CLI callers read progress from stderr).
    #[serde(skip_serializing_if = "Option::is_none")]
    pub timings_ms: Option<Value>,

    /// Per-APK shape statistics (HBC presence + byte size, DEX count + total
    /// bytes).  Populated by both the CLI and MCP audit paths when the input
    /// was an APK / AAB / XAPK container.  `None` for raw HBC / DEX inputs
    /// and for old envelopes deserialized from before this field existed.
    #[serde(skip_serializing_if = "Option::is_none", default)]
    pub apk_summary: Option<ApkSummary>,

    /// Navigation metadata for agent consumers: count, truncation flag,
    /// usage hint, and related subcommand names.
    pub meta: AuditMeta,
}

impl AuditEnvelope {
    /// Count taint-flow findings in a slice — the canonical accounting
    /// for [`Self::taint_flow_count`]. Both CLI and MCP audit paths
    /// MUST go through this helper rather than maintaining separate
    /// filter lists; the prior duplication left the CLI path counting
    /// `0` while MCP counted correctly.
    ///
    /// Matches exactly three IDs: `HBC_TAINT_FLOW`, `DEX_TAINT_FLOW`,
    /// `BRIDGE_TAINT_FLOW`. See the field-level doc on
    /// `taint_flow_count` for the rationale of which IDs are in scope.
    #[allow(
        clippy::as_conversions,
        reason = "PROOF: filter().count() returns usize bounded by findings.len() ≤ usize::MAX; usize→u64 widening is lossless on every supported 64-bit target."
    )]
    pub fn count_taint_flow_findings(findings: &[Finding]) -> u64 {
        findings
            .iter()
            .filter(|f| {
                matches!(
                    f.id.as_str(),
                    "HBC_TAINT_FLOW" | "DEX_TAINT_FLOW" | "BRIDGE_TAINT_FLOW"
                )
            })
            .count() as u64
    }

    /// Bucket a finding slice into a [`SeverityStrata`] — the canonical
    /// gauge-stratified severity summary for [`Self::severity_by_gauge`].
    ///
    /// Each finding is routed by [`GaugeClass::of`] on its `id`
    /// ([`GaugeClass::Semantic`] → `actionable`, `Cryptographic` →
    /// `signing_facts`, `Representational` → `encoding_noise`) and tallied
    /// under the `Debug` name of its severity — exactly the key shape the
    /// flat `severity_summary` uses.
    ///
    /// Both the CLI and MCP audit paths MUST build `severity_by_gauge`
    /// through this one helper, so the stratification cannot drift between
    /// transports. Signal-preserving invariant: for every severity, the
    /// sum of the three buckets' counts equals the flat `severity_summary`
    /// count for that severity — nothing is dropped, the same findings are
    /// re-bucketed.
    pub fn stratify_by_gauge(findings: &[Finding]) -> SeverityStrata {
        let mut strata = SeverityStrata::default();
        for f in findings {
            let bucket = match GaugeClass::of(&f.id) {
                GaugeClass::Semantic => &mut strata.actionable,
                GaugeClass::Cryptographic => &mut strata.signing_facts,
                GaugeClass::Representational => &mut strata.encoding_noise,
            };
            let key = format!("{:?}", f.severity);
            let c = bucket.entry(key).or_insert(0);
            *c = c.saturating_add(1);
        }
        strata
    }

    /// Build the capped `top_findings` projection, ordered **actionable
    /// (Semantic) first, then by severity** — so the reader sees the
    /// attacker-relevant surface before signing/encoding facts of the same
    /// severity.
    ///
    /// Only Critical/High findings are projected (the same filter the
    /// adapters used inline before this helper consolidated them), capped
    /// at `cap`. Within the actionable / non-actionable split, findings are
    /// ordered by ascending [`droidsaw_common::Severity`] (`Critical` <
    /// `High` per its derived `Ord`), then by `id` for a stable,
    /// deterministic projection. The sort is **stable**, so for inputs
    /// already sorted by `(severity, id)` it only lifts Semantic findings
    /// ahead of equal-severity noise without otherwise reshuffling.
    ///
    /// Both audit paths route their `top_findings` construction through this
    /// helper so the ordering and JSON shape cannot drift between
    /// transports. The projected JSON object shape (`severity`/`id`/
    /// `detail`/`cwe`) is the long-standing `top_findings` contract.
    pub fn rank_top_findings(findings: &[Finding], cap: usize) -> Vec<Value> {
        use droidsaw_common::Severity;
        let mut ranked: Vec<&Finding> = findings
            .iter()
            .filter(|f| matches!(f.severity, Severity::Critical | Severity::High))
            .collect();
        // Actionable (Semantic) first, then by severity (Critical before
        // High via derived Ord), then id for determinism.
        ranked.sort_by(|a, b| {
            let a_actionable = GaugeClass::of(&a.id) == GaugeClass::Semantic;
            let b_actionable = GaugeClass::of(&b.id) == GaugeClass::Semantic;
            // `true` should sort first → reverse the bool comparison.
            b_actionable
                .cmp(&a_actionable)
                .then_with(|| a.severity.cmp(&b.severity))
                .then_with(|| a.id.cmp(&b.id))
        });
        ranked
            .into_iter()
            .take(cap)
            .map(|f| {
                serde_json::json!({
                    "severity": format!("{:?}", f.severity),
                    "id": f.id,
                    "detail": f.detail,
                    "cwe": f.cwe,
                })
            })
            .collect()
    }
}

/// Navigation + pagination metadata embedded in every `AuditEnvelope`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuditMeta {
    /// Number of items in the primary result array (`findings` for CLI,
    /// `top_findings` for MCP).
    pub count: u64,
    /// True when the primary result array was cut by a limit.
    pub truncated: bool,
    /// Plain-text agent hint for the next useful action.
    pub hint: String,
    /// 2-4 sibling subcommand names the agent can chain into.
    pub related: Vec<String>,
    /// Rayon thread pool size used during this audit. Lets an agent
    /// consuming the JSON verify the audit ran with the intended
    /// concurrency (e.g. that `--single-thread` was honored). Always
    /// equal to `rayon::current_num_threads()` at audit-emit time. A
    /// value of 0 deserialized from an older envelope indicates the
    /// field was absent — treat as unknown.
    #[serde(default)]
    pub thread_pool_size: usize,
}

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

    fn minimal_envelope() -> AuditEnvelope {
        AuditEnvelope {
            schema_version: AUDIT_ENVELOPE_VERSION,
            findings: vec![],
            finding_count: 0,
            findings_emitted: 0,
            taint_flow_count: 0,
            severity_summary: BTreeMap::new(),
            severity_by_gauge: SeverityStrata::default(),
            top_findings: vec![],
            truncated: false,
            db_path: None,
            db_queries: None,
            finding_xrefs_written: None,
            detectors: None,
            trufflehog: None,
            semgrep: None,
            timings_ms: None,
            apk_summary: None,
            meta: AuditMeta {
                count: 0,
                truncated: false,
                hint: "test".to_string(),
                related: vec![],
                thread_pool_size: 1,
            },
        }
    }

    // ── round-trip: CLI shape (findings inline, no db_path) ───────────────

    #[test]
    fn cli_shape_roundtrip() {
        let env = minimal_envelope();
        let json = serde_json::to_string(&env).expect("serialize");
        let back: AuditEnvelope = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.schema_version, AUDIT_ENVELOPE_VERSION);
        assert!(back.findings.is_empty());
        assert!(back.db_path.is_none());
        assert!(back.db_queries.is_none());
        assert!(!back.truncated);
    }

    // ── round-trip: MCP shape (db_path + top_findings, no inline findings) ─

    #[test]
    fn mcp_shape_roundtrip() {
        let mut env = minimal_envelope();
        env.finding_count = 12;
        env.findings_emitted = 15;
        env.taint_flow_count = 3;
        env.severity_summary.insert("High".to_string(), 8);
        env.severity_summary.insert("Critical".to_string(), 4);
        env.top_findings = vec![
            serde_json::json!({"severity": "Critical", "id": "HARDCODED_KEY", "detail": "...", "cwe": 321}),
        ];
        env.truncated = true;
        env.db_path = Some("/tmp/droidsaw-audit-abc.db".to_string());
        env.db_queries = Some(serde_json::json!({"all_high": "SELECT ..."}));
        env.finding_xrefs_written = Some(42);
        env.timings_ms = Some(serde_json::json!({"core_total": 1200}));
        env.meta = AuditMeta {
            count: 1,
            truncated: true,
            hint: "pair with query for full list".to_string(),
            related: vec!["query".to_string(), "investigate".to_string()],
            thread_pool_size: 1,
        };

        let json = serde_json::to_string(&env).expect("serialize");

        // Verify key MCP-specific fields survive round-trip.
        let back: AuditEnvelope = serde_json::from_str(&json).expect("deserialize");
        assert_eq!(back.schema_version, AUDIT_ENVELOPE_VERSION);
        assert_eq!(back.finding_count, 12);
        assert_eq!(back.findings_emitted, 15);
        assert_eq!(back.taint_flow_count, 3);
        assert_eq!(back.severity_summary.get("Critical").copied(), Some(4));
        assert_eq!(back.top_findings.len(), 1);
        assert!(back.truncated);
        assert_eq!(back.db_path.as_deref(), Some("/tmp/droidsaw-audit-abc.db"));
        assert!(back.db_queries.is_some());
        assert_eq!(back.finding_xrefs_written, Some(42));
        assert!(back.timings_ms.is_some());
        assert_eq!(back.meta.count, 1);
        assert!(back.meta.truncated);
    }

    // ── count_taint_flow_findings: canonical taint accounting ─────────────

    #[test]
    fn count_taint_flow_findings_matches_three_ids_only() {
        use droidsaw_common::{Finding, Layer, Severity};
        let findings = vec![
            Finding::new("HBC_TAINT_FLOW", Layer::Hbc, Severity::Critical, "eval"),
            Finding::new("DEX_TAINT_FLOW", Layer::Dex, Severity::High, "ipc"),
            Finding::new("DEX_TAINT_FLOW", Layer::Dex, Severity::High, "fs"),
            Finding::new("BRIDGE_TAINT_FLOW", Layer::Dex, Severity::High, "bridge"),
            // Out-of-scope IDs that must NOT be counted:
            Finding::new("JNI_TAINTED_NATIVE_CALL", Layer::Dex, Severity::Medium, "jni"),
            Finding::new("BRIDGE_RESOLUTION_AMBIGUOUS", Layer::Dex, Severity::Info, "amb"),
            Finding::new("HARDCODED_KEY", Layer::Apk, Severity::Critical, "key"),
        ];
        let n = AuditEnvelope::count_taint_flow_findings(&findings);
        assert_eq!(
            n, 4,
            "must count exactly the three TAINT_FLOW ids (HBC, DEX, BRIDGE); \
             JNI_TAINTED_NATIVE_CALL and BRIDGE_RESOLUTION_AMBIGUOUS excluded by design"
        );
    }

    #[test]
    fn count_taint_flow_findings_zero_on_empty_input() {
        assert_eq!(AuditEnvelope::count_taint_flow_findings(&[]), 0);
    }

    #[test]
    fn count_taint_flow_findings_zero_on_non_taint_findings_only() {
        use droidsaw_common::{Finding, Layer, Severity};
        let findings = vec![
            Finding::new("HARDCODED_KEY", Layer::Apk, Severity::Critical, "x"),
            Finding::new("V1_MANIFEST_MISMATCH", Layer::Apk, Severity::High, "y"),
        ];
        assert_eq!(AuditEnvelope::count_taint_flow_findings(&findings), 0);
    }

    // ── schema_version field appears in JSON ──────────────────────────────

    #[test]
    fn schema_version_present_in_json() {
        let env = minimal_envelope();
        let json = serde_json::to_string(&env).expect("serialize");
        assert!(
            json.contains(&format!("\"schema_version\":{AUDIT_ENVELOPE_VERSION}")),
            "schema_version must appear in JSON; got: {json}",
        );
    }

    // ── optional fields skip_serializing_if ──────────────────────────────

    #[test]
    fn none_optional_fields_omitted_from_json() {
        let env = minimal_envelope();
        let json = serde_json::to_string(&env).expect("serialize");
        // Fields with skip_serializing_if = Option::is_none should be absent.
        assert!(!json.contains("\"db_path\""), "db_path must be absent when None");
        assert!(!json.contains("\"db_queries\""), "db_queries must be absent when None");
        assert!(!json.contains("\"detectors\""), "detectors must be absent when None");
        assert!(!json.contains("\"timings_ms\""), "timings_ms must be absent when None");
        assert!(!json.contains("\"trufflehog\""), "trufflehog must be absent when None");
        assert!(!json.contains("\"semgrep\""), "semgrep must be absent when None");
        assert!(!json.contains("\"apk_summary\""), "apk_summary must be absent when None");
    }

    // ── symmetric: CLI emits, MCP can parse ──────────────────────────────

    #[test]
    fn cli_emits_mcp_parses() {
        // Simulate a CLI emission (findings inline, no db_path).
        let mut env = minimal_envelope();
        env.finding_count = 1;
        env.findings_emitted = 1;
        env.severity_summary.insert("High".to_string(), 1);
        env.detectors = Some(serde_json::json!({"semgrep": {"status": "skipped_by_mode"}}));
        env.meta = AuditMeta {
            count: 1,
            truncated: false,
            hint: "filter by severity via jq".to_string(),
            related: vec!["export".to_string(), "audit".to_string()],
            thread_pool_size: 1,
        };

        let cli_json = serde_json::to_string(&env).expect("serialize");

        // MCP side deserializes the same JSON (no db_path, has detectors).
        let mcp_view: AuditEnvelope = serde_json::from_str(&cli_json).expect("deserialize");
        assert_eq!(mcp_view.schema_version, AUDIT_ENVELOPE_VERSION);
        assert_eq!(mcp_view.finding_count, 1);
        assert!(mcp_view.db_path.is_none());
        assert!(mcp_view.detectors.is_some());
    }

    // ── apk_summary: round-trip with populated values ─────────────────

    #[test]
    fn apk_summary_roundtrip() {
        let mut env = minimal_envelope();
        env.apk_summary = Some(ApkSummary {
            has_hbc: true,
            hbc_bytes: 1234,
            hbc_function_count: 42,
            dex_count: 2,
            dex_total_bytes: 5678,
            dex_methods_total: 80_000,
            dex_classes_total: 5_500,
        });

        let json = serde_json::to_string(&env).expect("serialize");

        // The field must be present in JSON when Some.
        assert!(json.contains("\"apk_summary\""), "apk_summary must appear in JSON when Some");

        let back: AuditEnvelope = serde_json::from_str(&json).expect("deserialize");
        let summary = back.apk_summary.expect("apk_summary must survive round-trip");
        assert_eq!(summary.has_hbc, true, "has_hbc must survive round-trip");
        assert_eq!(summary.hbc_bytes, 1234, "hbc_bytes must survive round-trip");
        assert_eq!(summary.hbc_function_count, 42, "hbc_function_count must survive round-trip");
        assert_eq!(summary.dex_count, 2, "dex_count must survive round-trip");
        assert_eq!(summary.dex_total_bytes, 5678, "dex_total_bytes must survive round-trip");
        assert_eq!(summary.dex_methods_total, 80_000, "dex_methods_total must survive round-trip");
        assert_eq!(summary.dex_classes_total, 5_500, "dex_classes_total must survive round-trip");
    }

    // ── symmetric: MCP emits, CLI can parse ──────────────────────────────

    #[test]
    fn mcp_emits_cli_parses() {
        let mut env = minimal_envelope();
        env.finding_count = 5;
        env.findings_emitted = 7;
        env.db_path = Some("/tmp/test.db".to_string());
        env.top_findings = vec![serde_json::json!({"severity": "High", "id": "FOO"})];
        env.truncated = false;
        env.timings_ms = Some(serde_json::json!({"core_total": 800}));
        env.meta = AuditMeta {
            count: 1,
            truncated: false,
            hint: "pair with query".to_string(),
            related: vec!["query".to_string()],
            thread_pool_size: 1,
        };

        let mcp_json = serde_json::to_string(&env).expect("serialize");

        // CLI side deserializes the MCP JSON (has db_path, no inline findings).
        let cli_view: AuditEnvelope = serde_json::from_str(&mcp_json).expect("deserialize");
        assert_eq!(cli_view.schema_version, AUDIT_ENVELOPE_VERSION);
        assert_eq!(cli_view.finding_count, 5);
        assert_eq!(cli_view.db_path.as_deref(), Some("/tmp/test.db"));
        assert!(cli_view.findings.is_empty());
        assert_eq!(cli_view.top_findings.len(), 1);
    }

    // ── gauge stratification + Semantic-first ranking ─────────────────────

    /// A mixed finding set with one finding per gauge class, used by the
    /// stratify / rank tests below. IDs only — no app names.
    ///
    /// - `SPLIT_KEY_MISMATCH` → `GaugeClass::Cryptographic` (signing_facts),
    ///   Critical.
    /// - `ENCRYPTED_ASSET`    → `GaugeClass::Representational` (encoding_noise),
    ///   High.
    /// - `DEX_TAINT_FLOW`     → `GaugeClass::Semantic` (actionable), High.
    fn mixed_gauge_findings() -> Vec<Finding> {
        use droidsaw_common::{Finding, Layer, Severity};
        vec![
            Finding::new("SPLIT_KEY_MISMATCH", Layer::Apk, Severity::Critical, "cert mismatch"),
            Finding::new("ENCRYPTED_ASSET", Layer::Apk, Severity::High, "encrypted blob"),
            Finding::new("DEX_TAINT_FLOW", Layer::Dex, Severity::High, "source to sink"),
        ]
    }

    /// The three fixture IDs must classify as intended — this anchors the rest
    /// of the gauge tests to the canonical `GaugeClass::of` table.
    #[test]
    fn fixture_ids_classify_as_intended() {
        use droidsaw_common::GaugeClass;
        assert_eq!(GaugeClass::of("SPLIT_KEY_MISMATCH"), GaugeClass::Cryptographic);
        assert_eq!(GaugeClass::of("ENCRYPTED_ASSET"), GaugeClass::Representational);
        assert_eq!(GaugeClass::of("DEX_TAINT_FLOW"), GaugeClass::Semantic);
    }

    #[test]
    fn stratify_buckets_by_gauge_class() {
        let strata = AuditEnvelope::stratify_by_gauge(&mixed_gauge_findings());

        // Critical Cryptographic lands in signing_facts, NOT actionable.
        assert_eq!(
            strata.signing_facts.get("Critical").copied(),
            Some(1),
            "Critical SPLIT_KEY_MISMATCH must bucket into signing_facts",
        );
        // The collapse that motivates the whole stratification: the flat
        // severity_summary would show Critical=1, but zero of it is
        // actionable — it is a signing fact, invariant under repackaging.
        assert_eq!(
            strata.actionable.get("Critical").copied(),
            None,
            "actionable Critical count must be 0 (signing fact does not inflate the actionable histogram)",
        );

        // High Representational lands in encoding_noise.
        assert_eq!(
            strata.encoding_noise.get("High").copied(),
            Some(1),
            "High ENCRYPTED_ASSET must bucket into encoding_noise",
        );

        // High Semantic lands in actionable.
        assert_eq!(
            strata.actionable.get("High").copied(),
            Some(1),
            "High DEX_TAINT_FLOW must bucket into actionable",
        );
        // …and nothing else leaked into actionable.
        assert_eq!(strata.actionable.len(), 1, "actionable holds only the Semantic High");
    }

    #[test]
    fn rank_top_findings_puts_semantic_first() {
        let ranked = AuditEnvelope::rank_top_findings(&mixed_gauge_findings(), TOP_FINDINGS_CAP);
        // All three are Critical/High, so all three project; the Semantic
        // (actionable) finding must lead even though a Critical
        // Cryptographic finding is present — Semantic-first beats severity.
        assert_eq!(ranked.len(), 3, "all three Critical/High findings project");
        assert_eq!(
            ranked[0].get("id").and_then(|v| v.as_str()),
            Some("DEX_TAINT_FLOW"),
            "the Semantic finding must rank first, ahead of the Critical signing fact",
        );
    }

    /// Sum-invariant: for every severity, the three strata buckets sum to the
    /// flat `severity_summary` count — nothing is dropped by re-bucketing.
    #[test]
    fn strata_per_severity_sum_equals_flat_summary() {
        let findings = mixed_gauge_findings();
        let strata = AuditEnvelope::stratify_by_gauge(&findings);

        // Build the flat severity_summary the same way both adapters do.
        let mut flat = BTreeMap::<String, u64>::new();
        for f in &findings {
            *flat.entry(format!("{:?}", f.severity)).or_insert(0) += 1;
        }

        // Union of every severity key seen across flat + all three buckets.
        let mut keys: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
        keys.extend(flat.keys().cloned());
        keys.extend(strata.actionable.keys().cloned());
        keys.extend(strata.signing_facts.keys().cloned());
        keys.extend(strata.encoding_noise.keys().cloned());

        for sev in &keys {
            let strata_sum = strata.actionable.get(sev).copied().unwrap_or(0)
                + strata.signing_facts.get(sev).copied().unwrap_or(0)
                + strata.encoding_noise.get(sev).copied().unwrap_or(0);
            assert_eq!(
                strata_sum,
                flat.get(sev).copied().unwrap_or(0),
                "per-severity strata total must equal flat severity_summary for {sev}",
            );
        }
    }

    /// Back-compat: an envelope serialized before `severity_by_gauge` existed
    /// (no such key in the JSON) still deserializes, with the field defaulting
    /// to an empty [`SeverityStrata`] via `#[serde(default)]`.
    #[test]
    fn old_schema_without_severity_by_gauge_deserializes() {
        let old_json = serde_json::json!({
            "schema_version": 1,
            "findings": [],
            "finding_count": 0,
            "findings_emitted": 0,
            "taint_flow_count": 0,
            "severity_summary": {"High": 2},
            "top_findings": [],
            "truncated": false,
            "meta": {
                "count": 0,
                "truncated": false,
                "hint": "x",
                "related": []
            }
        })
        .to_string();

        let env: AuditEnvelope =
            serde_json::from_str(&old_json).expect("old-schema JSON must still deserialize");
        assert_eq!(env.severity_by_gauge, SeverityStrata::default());
        assert!(env.severity_by_gauge.actionable.is_empty());
        assert!(env.severity_by_gauge.signing_facts.is_empty());
        assert!(env.severity_by_gauge.encoding_noise.is_empty());
        // The pre-existing fields still parse.
        assert_eq!(env.severity_summary.get("High").copied(), Some(2));
    }
}
