//! Agentic CLI command implementations.
//!
//! Every command in this module returns JSON on stdout. Progress,
//! warnings, and informational messages go to stderr prefixed with
//! `droidsaw: ` so they're greppable. The two documented carve-outs:
//!
//! - `trufflehog` writes raw strings line-by-line because trufflehog
//!   filesystem mode needs a plain-text feed.
//! - `scan-corpus` writes NDJSON (one JSON object per line) for
//!   streaming large result sets.
//!
//! Shape of a typical list response:
//!
//! ```json
//! {
//!   "strings": [{"offset":0,"layer":"hbc","value":"foo","length":3}],
//!   "_meta": {
//!     "count": 1,
//!     "truncated": false,
//!     "hint": "Use --search to filter; large apks can emit 100k+ strings",
//!     "related": ["xrefs","frida","trufflehog"],
//!     "tool_version": "1.0.0"
//!   }
//! }
//! ```
//!
//! `_meta.count` is the returned length, `_meta.truncated` is true when a
//! `--limit` cut the result, `_meta.hint` suggests what to try next,
//! `_meta.related` names 2-4 sibling subcommands the agent can chain
//! into, and `_meta.tool_version` is the `CARGO_PKG_VERSION` of the
//! binary that produced the payload (build-constant, deterministic).

use std::path::PathBuf;

use clap::Subcommand;
use droidsaw_apk::{Manifest, ParseConfig, UnknownChunk};
use droidsaw_common::{entropy::shannon_entropy, Finding, Layer, Severity};
use serde::Serialize;
use serde_json::{json, Value};

use crate::{analysis, context::CrossLayerContext};

pub mod audit_envelope;
pub mod credentials_fp;
pub mod honeycomb_fp;
pub mod triage;

mod frida;
pub use frida::frida;
#[doc(hidden)]
pub use frida::{
    __fuzz_build_method_flags, proto_to_overload, smali_field_to_dotted, smali_split_args,
};

mod sbom;
pub use sbom::sbom;
pub mod sbom_openvex;

mod trufflehog;
pub use trufflehog::trufflehog;

mod semgrep;
pub use semgrep::{scan_semgrep, semgrep};

mod diff;
pub use diff::diff;

mod xrefs;
pub use xrefs::xrefs;

mod corpus;
pub use corpus::{corpus_ingest, scan_corpus};

mod deobf_strings;
pub use deobf_strings::{deobf_strings, DeobfStringsArgs};

mod strings;
pub use strings::{apply_methods_filter, apply_outline_filter, strings};

mod decompile;
mod bulk_emit;
pub use decompile::{decompile, decompile_hbc_all_js_stream, decompile_hbc_function};
pub use bulk_emit::bulk_emit_to_dir;

mod export;
pub use export::{
    ensure_findings_db_schema, export, finding_signature_hash, migrate_findings_schema_to_current,
    triage_finding, write_credentials_db, write_cross_layer_taint_flows_db,
    write_finding_xrefs_db, write_findings_db, write_findings_db_with_run, write_semgrep_db,
    write_taint_flows_db, write_xrefs_db, FINDINGS_SCHEMA_REV,
};
use export::hex_nibble;
pub use triage::{PromoteArgs, TriageCommands};

/// How a `droidsaw decompile <path> [target]` invocation should be
/// routed once the input file has been parsed. Returned by
/// [`classify_decompile_target`].
///
/// Dispatches on `(ctx.dex, ctx.hbc, target)`: the parsed input carries
/// the namespace unambiguously (a DEX-only APK has no HBC; a Hermes
/// bundle has no DEX). The target string's shape is consulted only for
/// the rare hybrid input (both DEX and HBC payloads), where a JVM
/// descriptor like `Lcom/foo/Bar;` selects DEX and a numeric function
/// id selects HBC.
pub enum DecompileRoute<'a> {
    /// Route to [`dex_decompile`] with the given search string. The
    /// resolver normalizes JVM descriptors, Java FQCNs, and bare
    /// class names — caller does not pre-shape the string.
    DexClass(&'a str),
    /// Route to [`decompile`] for a Hermes function id. The string is
    /// passed through; `decompile` parses it as a `u32`.
    HbcFunction(&'a str),
}

/// Pick the right decompile path for a `target` string given the parsed
/// input's bytecode namespace. Returns an error when the input has no
/// bytecode, or when the input is hybrid (both DEX and HBC present) AND
/// the target is ambiguous between the two namespaces.
pub fn classify_decompile_target<'a>(
    ctx: &CrossLayerContext,
    target: &'a str,
) -> anyhow::Result<DecompileRoute<'a>> {
    let has_dex = !ctx.dex.is_empty();
    let has_hbc = ctx.hbc.is_some();
    match (has_dex, has_hbc) {
        (false, false) => {
            // A present-but-unparseable bundle beats "no bytecode":
            // surface the recorded typed parse error rather than
            // claiming the target carries nothing.
            ctx.ensure_hbc_parsed()?;
            anyhow::bail!("no bytecode found in target")
        }
        (true, false) => {
            // DEX-only namespace — unless an unparseable bundle is
            // recorded AND the target is a bare numeric. Numeric
            // targets address HBC function indices (the hybrid arm
            // below routes them to `HbcFunction`), so a numeric here
            // names the broken hbc layer: hard-error with the typed
            // parse failure instead of running a doomed DEX class
            // search. Digit-leading DEX class names are valid
            // SimpleName and stay reachable via the `L<name>;`
            // descriptor form or `--all`.
            if target.parse::<u32>().is_ok() {
                ctx.ensure_hbc_parsed()?;
            }
            Ok(DecompileRoute::DexClass(target))
        }
        (false, true) => Ok(DecompileRoute::HbcFunction(target)),
        (true, true) => {
            // Hybrid input. Disambiguate by shape: numeric → HBC fid;
            // JVM-descriptor / dotted-FQCN / slashed-internal → DEX class;
            // else an explicit error asking the user to pick a layer.
            let looks_dex = (target.starts_with('L') && target.ends_with(';'))
                || target.contains('.')
                || target.contains('/');
            if target.parse::<u32>().is_ok() {
                Ok(DecompileRoute::HbcFunction(target))
            } else if looks_dex {
                Ok(DecompileRoute::DexClass(target))
            } else {
                anyhow::bail!(
                    "ambiguous target {target:?} for hybrid DEX+HBC input; \
                     pass a JVM descriptor (Lcom/foo/Bar;), Java FQCN \
                     (com.foo.Bar), or a numeric Hermes function id"
                )
            }
        }
    }
}

/// Normalize a Java-source-form class identifier into a regex on the
/// DEX type descriptor. Three input shapes are accepted:
///
/// 1. **JVM descriptor literal** (`Lcom/foo/Bar;`) — passed through as
///    a regex (user-supplied; may contain intentional metacharacters).
/// 2. **Java FQCN** (`com.foo.Bar`, possibly with `$` for inner classes)
///    — converted to literal descriptor `Lcom/foo/Bar;` then
///    `regex::escape`d so dots and `$` match literally rather than
///    coincidentally matching `/` or anchoring at end-of-string.
/// 3. **Bare class name** (`Bar`, possibly `Outer$Inner`) — escaped and
///    tail-anchored as `<name>;` so it matches `L<...>/<name>;` at
///    descriptor end.
///
/// **Why `$` is treated as a class-name character, not a regex metachar:**
/// inner-class FQCNs contain `$` legitimately (`com.foo.Outer$Inner`).
/// An earlier draft treated `$` as a regex metachar and short-circuited
/// inner-class targets to the pass-through branch, where `$` then
/// anchored at end-of-string and the regex never matched the descriptor.
/// `regex::escape` neutralizes `$` literally on both the FQCN and bare-
/// name paths, so accepting `$` into normalization is sound.
#[doc(hidden)]
pub fn normalize_dex_class_search(s: &str) -> String {
    // Targets that are already in JVM-descriptor or internal form, or
    // that contain real regex metacharacters the user likely intends,
    // pass through verbatim.
    let starts_with_l = s.starts_with('L');
    let has_semicolon = s.contains(';');
    let has_slash = s.contains('/');
    let has_real_regex_chars = s.contains('(')
        || s.contains('[')
        || s.contains('*')
        || s.contains('?')
        || s.contains('^');
    if starts_with_l || has_semicolon || has_slash || has_real_regex_chars {
        return s.to_string();
    }
    if s.contains('.') {
        // Java FQCN: literal descriptor, escaped.
        let descriptor = format!("L{};", s.replace('.', "/"));
        regex::escape(&descriptor)
    } else {
        // Bare class name (possibly with `$` for inner classes):
        // escape the name, append literal `;` as a tail anchor on
        // the descriptor.
        format!("{};", regex::escape(s))
    }
}

/// Hermes-only subcommand tree. Consumed by the clap CLI in
/// `src/main.rs` and dispatched via [`hbc`].
#[derive(Subcommand)]
pub enum HbcCommands {
    /// Hermes bytecode info. Returns `{hbc:{version,function_count,string_count}, _meta:{...}}`.
    Info {
        /// Path to APK or HBC file.
        path: PathBuf,
    },
    /// List Hermes functions. Returns `{functions:[...], _meta:{...}}`.
    Functions {
        /// Path to APK or HBC file.
        path: PathBuf,
        /// Regex filter over function name.
        #[arg(short, long)]
        search: Option<String>,
    },
    /// Decompile a Hermes function. Returns `{functions:[...], _meta:{...}}`.
    Decompile {
        /// Path to APK or HBC file.
        path: PathBuf,
        /// Function ID.
        func_id: Option<u32>,
        /// Emit valid JS.
        #[arg(long)]
        js: bool,
        /// Decompile every function.
        #[arg(long)]
        all: bool,
    },
    /// Search Hermes strings. Returns `{strings:[...], _meta:{...}}`.
    Strings {
        /// Path to APK or HBC file.
        path: PathBuf,
        /// Regex filter.
        #[arg(short, long)]
        search: Option<String>,
    },
    /// Disassemble Hermes bytecode to a deterministic plain-text instruction
    /// stream on stdout. One instruction per line, with a per-function header.
    /// Format target: external-oracle differential parsing (hbcdump and similar);
    /// not a user-facing tool.
    Disassemble {
        /// Path to APK or HBC file.
        path: PathBuf,
    },
}

/// DEX-only subcommand tree. Consumed by the clap CLI in
/// `src/main.rs` and dispatched via [`dex`].
#[derive(Subcommand)]
pub enum DexCommands {
    /// List DEX classes. Returns `{classes:[...], _meta:{...}}`.
    Classes {
        /// Path to APK or DEX file.
        path: PathBuf,
        /// Regex filter over class descriptor.
        #[arg(short, long)]
        search: Option<String>,
    },
    /// List DEX methods. Returns `{methods:[...], _meta:{...}}`.
    Methods {
        /// Path to APK or DEX file.
        path: PathBuf,
        /// Regex filter over class->method.
        #[arg(short, long)]
        search: Option<String>,
        /// Enumerate the defined-method set (all entries in each class's
        /// `class_data_item`) instead of the per-DEX `method_ids` reference
        /// table. Includes abstract and native declarations (which have no
        /// `code_item` but are declared on the class). Use for apples-to-apples
        /// comparison against tools that report the declaration surface (e.g. jadx).
        #[arg(long)]
        implementations: bool,
    },
    /// Search DEX strings. Returns `{strings:[...], _meta:{...}}`.
    Strings {
        /// Path to APK or DEX file.
        path: PathBuf,
        /// Regex filter.
        #[arg(short, long)]
        search: Option<String>,
    },
}

/// APK container inspection subcommands.
#[derive(Subcommand)]
pub enum InspectCommands {
    /// List APK ZIP entries and structural anomalies.
    Entries {
        path: PathBuf,
        #[arg(short, long)]
        search: Option<String>,
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Dump parsed ELF metadata for every native library.
    Elf {
        path: PathBuf,
        #[arg(short, long)]
        search: Option<String>,
    },
    /// Dump string-typed resources.arsc entries.
    Resources {
        path: PathBuf,
        #[arg(short, long)]
        search: Option<String>,
        #[arg(long)]
        limit: Option<usize>,
    },
    /// List WebView assets under assets/www/.
    Webview {
        path: PathBuf,
        #[arg(short, long)]
        search: Option<String>,
        #[arg(long)]
        extract: Option<String>,
    },
}

/// Scanning / extraction subcommands.
#[derive(Subcommand)]
pub enum ScanCommands {
    /// Scan APK artifacts with YARA-X rules.
    Yara {
        path: PathBuf,
        #[arg(short, long)]
        rules: Option<PathBuf>,
        #[arg(short, long, default_value = "all")]
        target: String,
        #[arg(long)]
        limit: Option<usize>,
    },
    /// Software Bill of Materials (CycloneDX-style).
    Sbom { path: PathBuf },
    /// Dump strings for trufflehog filesystem mode.
    Trufflehog {
        path: PathBuf,
        #[arg(short = 'n', long, default_value = "8")]
        min_length: usize,
    },
    /// Extract decompiled source for semgrep.
    ///
    /// Droidsaw does not ship semgrep rules. Provide your own via
    /// `--rules <path>` (repeatable) or `DROIDSAW_SEMGREP_RULES` env var
    /// (path-separator-merged). `--no-auto` suppresses the registry default.
    ///
    /// Default behaviour: extract only, return a `command` hint string
    /// for the operator to run semgrep manually. Pass `--persist` to
    /// invoke semgrep here and write hits to a SQLite findings DB.
    Semgrep {
        path: PathBuf,
        #[arg(short, long)]
        output: Option<PathBuf>,
        /// Invoke semgrep on the extracted source and persist hits to
        /// the findings DB (`--db <path>` selects where; default is
        /// `./droidsaw-<basename>.db` next to the input). Off by default
        /// for backward compat with consumers that parse the returned
        /// `command` hint and run semgrep themselves.
        #[arg(long)]
        persist: bool,
        /// Findings-DB path used by `--persist`. Defaults to
        /// `./droidsaw-<basename>.db` derived from the input path's
        /// file stem. Ignored without `--persist`.
        #[arg(long, value_name = "PATH")]
        db: Option<PathBuf>,
        #[command(flatten)]
        semgrep_args: crate::semgrep::SemgrepArgs,
    },
    /// Export parsed layers to a SQLite database.
    Export {
        path: PathBuf,
        #[arg(short, long)]
        output: String,
    },
}

/// Corpus (multi-APK batch) subcommands.
#[derive(Subcommand)]
pub enum CorpusCommands {
    /// Ingest APKs into a SQLite corpus database.
    Ingest {
        paths: Vec<PathBuf>,
        #[arg(short, long)]
        output: String,
        #[arg(long)]
        tag: Option<String>,
        #[arg(long)]
        no_skip_existing: bool,
    },
    /// Batch security scan (NDJSON output).
    Scan {
        paths: Vec<PathBuf>,
        #[arg(long, default_value = "info")]
        min_severity: String,
    },
}

// ── progress helpers ──────────────────────────────────────────────
//
// All stderr output passes through `progress!` so it's uniformly
// prefixed. Agents that run with `2>/dev/null` get clean JSON; agents
// that capture stderr can grep for `droidsaw: `.
//
// # Spoof-resistance discipline (build-enforced)
//
// `AuditFormat::HbcJs`'s terminator sentinel rides stderr as the line
// `droidsaw: DROIDSAW_HBC_JS_END\n`. The `droidsaw: ` prefix is what
// this macro auto-prepends — so a dedicated final emit call is the
// ONLY code path that can produce a bare `droidsaw: DROIDSAW_HBC_JS_END\n`
// line (attacker interpolations produce `droidsaw: <literal>: <attacker>`
// which does NOT match `ends_with(sentinel)`).
//
// **BUT** — if an interpolated arg contains a raw `\n` byte and is
// Display-formatted (`{}` / `{<ident>}`), the attacker can inject a
// fresh newline and then fake a `droidsaw: …_HBC_JS_END\n` suffix.
// Concrete vector: `progress!("could not parse {}", p.display())` with
// a filename containing `\ndroidsaw: DROIDSAW_HBC_JS_END`.
//
// **Discipline**: every interpolated argument that might carry
// attacker-controlled bytes MUST use `{:?}` Debug formatter (escapes
// `\n` → `\\n`). Integer counts + compile-time literals are
// indifferent (Debug ≡ Display for integers) — rewrite to `{:?}` for
// defense-in-depth + lint compliance.
//
// **Build-time gate**: `droidsaw/build.rs` scans `src/**/*.rs` for
// `progress!` callsites and rejects Display-formatter uses. Annotate
// genuinely-safe edge cases with a `// SAFE: <reason>` comment on the
// prior non-blank line.

/// Returns `true` if progress chatter should be emitted. Default is off.
/// Set `DROIDSAW_PROGRESS=1` (or any non-empty / non-`0` / non-`false`
/// value) to enable. Result is cached on first call.
///
/// The `progress!` macro is opt-in via env var; when unconditional it
/// dumps tens-to-hundreds of MB of debug-formatted strings per audit run
/// on large APKs — most of it per-string entropy output that already gets
/// emitted as a structured `Info`-severity finding (two outputs, one signal).
pub fn progress_enabled() -> bool {
    use std::sync::OnceLock;
    static ENABLED: OnceLock<bool> = OnceLock::new();
    *ENABLED.get_or_init(|| match std::env::var("DROIDSAW_PROGRESS") {
        Ok(v) => {
            let trimmed = v.trim();
            !trimmed.is_empty()
                && !trimmed.eq_ignore_ascii_case("0")
                && !trimmed.eq_ignore_ascii_case("false")
                && !trimmed.eq_ignore_ascii_case("no")
                && !trimmed.eq_ignore_ascii_case("off")
        }
        Err(_) => false,
    })
}

macro_rules! progress {
    ($($arg:tt)*) => {{
        if $crate::commands::progress_enabled() {
            eprintln!("droidsaw: {}", format_args!($($arg)*));
        }
    }};
}
pub(super) use progress;

pub fn print_json<T: Serialize>(value: &T) -> anyhow::Result<()> {
    println!("{}", serde_json::to_string_pretty(value)?);
    Ok(())
}

pub(super) fn meta(count: usize, truncated: bool, hint: &str, related: &[&str]) -> Value {
    json!({
        "count": count,
        "truncated": truncated,
        "hint": hint,
        "related": related,
        "tool_version": env!("CARGO_PKG_VERSION"),
    })
}

// ── info / manifest / signing / apk-info ──────────────────────────

#[derive(Serialize)]
struct InfoJson {
    path: String,
    package: Option<String>,
    version_name: Option<String>,
    layers: Layers,
    /// Co-loaded split APK names. Non-empty when the input was a base APK
    /// and co-located `split_config.*.apk` (or other sibling split APKs)
    /// were auto-discovered and merged into the analysis bundle. Absent
    /// (empty) when `--no-auto-splits` was set or no splits were found.
    loaded_splits: Vec<String>,
}

#[derive(Serialize)]
struct Layers {
    has_manifest: bool,
    hbc_present: bool,
    /// Typed parse failure for a Hermes bundle the container carried
    /// but droidsaw could not parse. `null` when the bundle parsed or
    /// no bundle exists; when set, `hbc_present` is `false` (no parsed
    /// HBC data is available) and this field is the honest record that
    /// a bundle is nevertheless present in the APK.
    hbc_parse_error: Option<String>,
    dex_count: usize,
    native_lib_abis: Vec<String>,
    hbc_version: Option<u32>,
    hbc_function_count: Option<u32>,
    hbc_string_count: Option<u32>,
    dex_versions: Vec<String>,
}

pub fn info(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let (package, version_name) = manifest_id(ctx);
    let native_lib_abis: Vec<String> = ctx
        .apk
        .as_ref()
        .map(|a| a.native_libs.keys().cloned().collect())
        .unwrap_or_default();
    let (hbc_version, hbc_function_count, hbc_string_count) = ctx
        .hbc
        .as_ref()
        .map(|h| h.hbc())
        .map(|h| (Some(h.version), Some(h.function_count), Some(h.string_count)))
        .unwrap_or((None, None, None));
    let dex_versions: Vec<String> = ctx
        .dex
        .iter()
        .map(|d| d.header.version().to_string())
        .collect();

    let info = InfoJson {
        path: ctx.path.clone(),
        package,
        version_name,
        layers: Layers {
            has_manifest: ctx.apk.as_ref().is_some_and(|a| a.manifest_raw.is_some()),
            hbc_present: ctx.hbc.is_some(),
            hbc_parse_error: ctx.hbc_parse_error.as_ref().map(|f| f.message()),
            dex_count: ctx.dex.len(),
            native_lib_abis,
            hbc_version,
            hbc_function_count,
            hbc_string_count,
            dex_versions,
        },
        loaded_splits: ctx.loaded_split_names.clone(),
    };

    let out = json!({
        "info": info,
        "_meta": meta(
            1,
            false,
            "use `droidsaw manifest` for permissions/components, `droidsaw apk-info` for a full one-shot report",
            &["manifest", "signing", "apk-info", "audit"],
        ),
    });
    Ok(out)
}

/// Return the APK ZIP entry list and any structural anomalies
/// detected during parse. Entries can be filtered with a regex and
/// capped with a limit. Anomalies are always returned in full
/// because they're small and load-bearing for tamper detection.
pub fn entries(
    ctx: &CrossLayerContext,
    search: Option<&str>,
    limit: Option<usize>,
) -> anyhow::Result<Value> {
    let filter = match search {
        Some(pat) => Some(regex::Regex::new(pat)?),
        None => None,
    };
    let apk = ctx.require_apk()?;
    let all: Vec<&String> = apk
        .entries
        .iter()
        .filter(|e| filter.as_ref().is_none_or(|r| r.is_match(e)))
        .collect();
    let total = all.len();
    let (truncated, entries): (bool, Vec<&String>) = match limit {
        Some(n) if all.len() > n => (true, all.into_iter().take(n).collect()),
        _ => (false, all),
    };
    let count = entries.len();

    let anomalies: Vec<Value> = apk
        .zip_anomalies
        .iter()
        .map(|a| {
            json!({
                "kind": format!("{:?}", a.kind),
                "detail": a.detail,
            })
        })
        .collect();

    let out = json!({
        "entries": entries,
        "entry_total": total,
        "zip_anomalies": anomalies,
        "_meta": meta(
            count,
            truncated,
            "use --search regex to target entries (e.g. 'META-INF|assets/'); anomalies detect repackaging, master-key duplicates, and out-of-order writes",
            &["strings", "apk_info", "audit"],
        ),
    });
    Ok(out)
}

/// Dump parsed ELF metadata for every native library in the APK.
/// `search` filters by lib path (regex). Returns sections, symbols,
/// needed libraries, RELRO/NX/PIE hardening state, debug-info
/// presence, JNI exports — everything the pre-parsed `ElfInfo`
/// (from `droidsaw_apk::elf`) exposes.
pub fn elf(ctx: &CrossLayerContext, search: Option<&str>) -> anyhow::Result<Value> {
    let filter = match search {
        Some(pat) => Some(regex::Regex::new(pat)?),
        None => None,
    };

    let mut libs: Vec<Value> = Vec::new();
    for (path, info) in &ctx.require_apk()?.elf_info {
        if filter.as_ref().is_some_and(|r| !r.is_match(path)) {
            continue;
        }
        let mut jni: Vec<&String> = info.jni_exports.iter().collect();
        jni.sort();
        let mut imports: Vec<&String> = info.imported_symbols.iter().collect();
        imports.sort();
        libs.push(json!({
            "path": path,
            "soname": info.soname,
            "sections_stripped": info.sections_stripped,
            "section_count": info.section_count,
            "has_text_section": info.has_text_section,
            "has_rwx_segment": info.has_rwx_segment,
            "is_pie": info.is_pie,
            "nx_stack": info.nx_stack,
            "has_relro": info.has_relro,
            "has_bind_now": info.has_bind_now,
            "has_stack_canary": info.has_stack_canary,
            "has_fortify_source": info.has_fortify_source,
            "has_symtab": info.has_symtab,
            "has_debug_sections": info.has_debug_sections,
            "debug_section_names": info.debug_section_names,
            "needed_libraries": info.needed_libraries,
            "runpath": info.runpath,
            "android_ndk_version": info.android_ndk_version,
            "android_target_sdk": info.android_target_sdk,
            "init_array_count": info.init_array_count,
            "init_array_addrs": info.init_array_addrs,
            "jni_exports": jni,
            "imported_symbols": imports,
        }));
    }

    let count = libs.len();
    let out = json!({
        "libs": libs,
        "_meta": meta(
            count,
            false,
            "filter with --search (e.g. 'arm64' or 'libcrypto'); has_debug_sections and sections_stripped=false are the biggest RE leaks",
            &["audit", "entries", "strings"],
        ),
    });
    Ok(out)
}

/// List packaged WebView assets (Cordova / Capacitor / Ionic apps
/// ship their JS/HTML under `assets/www/`). Returns path, size,
/// and a SHA-256 for each asset. Optionally emit the raw bytes of
/// a single asset (`extract` = exact path).
pub fn webview_assets(
    ctx: &CrossLayerContext,
    search: Option<&str>,
    extract: Option<&str>,
) -> anyhow::Result<Value> {
    let apk = ctx.require_apk()?;
    if let Some(target) = extract {
        let bytes = apk.webview_assets.get(target).ok_or_else(|| {
            anyhow::anyhow!(
                "no webview asset at path {target:?} — call without `extract` first to list"
            )
        })?;
        let out = json!({
            "asset": {
                "path": target,
                "size": bytes.len(),
                "content_utf8": String::from_utf8_lossy(bytes),
            },
            "_meta": meta(
                1,
                false,
                "content_utf8 is lossy — for binary payloads prefer a path on disk",
                &["webview_assets", "strings"],
            ),
        });
        return Ok(out);
    }

    let filter = match search {
        Some(pat) => Some(regex::Regex::new(pat)?),
        None => None,
    };

    let mut assets: Vec<Value> = Vec::new();
    for (path, bytes) in &apk.webview_assets {
        if filter.as_ref().is_some_and(|r| !r.is_match(path)) {
            continue;
        }
        assets.push(json!({
            "path": path,
            "size": bytes.len(),
        }));
    }

    let count = assets.len();
    let out = json!({
        "assets": assets,
        "_meta": meta(
            count,
            false,
            "empty for non-hybrid apps; call again with `extract=<path>` to read one asset's content",
            &["entries", "strings", "audit"],
        ),
    });
    Ok(out)
}

/// Dump the merged resources.arsc table. Returns one row per
/// resource id with its symbolic key and best (default-config)
/// string value. Filter with `search` (regex over key or string
/// value) and cap with `limit`. Non-string values (drawable,
/// dimens, etc.) are skipped — this tool is for credential /
/// endpoint hunting in compiled resources.
pub fn resources(
    ctx: &CrossLayerContext,
    search: Option<&str>,
    limit: Option<usize>,
) -> anyhow::Result<Value> {
    use droidsaw_apk::resources::ResourceValue;

    let apk = ctx.require_apk()?;
    let table = match apk.resources.as_ref() {
        Some(t) => t,
        None => {
            return Ok(json!({
                "entries": [],
                "_meta": meta(
                    0,
                    false,
                    "no resources.arsc parsed — not an APK or arsc was malformed",
                    &["entries", "strings", "audit"],
                ),
            }));
        }
    };

    let filter = match search {
        Some(pat) => Some(regex::Regex::new(pat)?),
        None => None,
    };

    let mut rows: Vec<Value> = Vec::new();
    let mut total = 0usize;
    for (id, entry) in table.iter() {
        let Some(val) = entry.best_value() else {
            continue;
        };
        let value_str = match val {
            ResourceValue::String(s) => s.as_str(),
            _ => continue,
        };
        // DISPLAY-ONLY: `_meta.entry_total` field; saturate to keep the JSON count monotonic.
        total = total.saturating_add(1);
        if let Some(ref r) = filter
            && !r.is_match(&entry.key)
            && !r.is_match(value_str)
        {
            continue;
        }
        rows.push(json!({
            "id": format!("0x{id:08x}"),
            "key": entry.key,
            "value": value_str,
        }));
    }

    let (truncated, rows) = match limit {
        Some(n) if rows.len() > n => (true, rows.into_iter().take(n).collect::<Vec<_>>()),
        _ => (false, rows),
    };
    let count = rows.len();

    let out = json!({
        "entries": rows,
        "entry_total": total,
        "_meta": meta(
            count,
            truncated,
            "only string-typed resources are returned; use a regex like '(api|secret|token|key)' to find credential leaks in values.xml",
            &["strings", "audit", "entries"],
        ),
    });
    Ok(out)
}

/// One entry in `manifest.exported_components` — the full exported-component
/// surface that an analyst needs to assess deep-link / IPC attack surface.
///
/// `exported_reason` semantics:
/// - `"explicit"` — `android:exported="true"` set directly on the element.
/// - `"implicit"` — no explicit `exported` attribute; the component is
///   implicitly exported because (a) it has at least one `<intent-filter>`
///   and `targetSdkVersion < 31`, or (b) it is a LAUNCHER activity (always
///   exported regardless of SDK level).
#[derive(Serialize)]
struct ExportedComponentJson {
    name: String,
    kind: String,
    permission: Option<String>,
    intent_filters: Vec<IntentFilterJson>,
    exported_reason: String,
}

/// Serialisable shape for a single `<intent-filter>`.
///
/// `data_paths` entries are `{"kind": "path"|"pathPrefix"|…, "value": "…"}`.
#[derive(Serialize)]
struct IntentFilterJson {
    actions: Vec<String>,
    categories: Vec<String>,
    data_schemes: Vec<String>,
    data_hosts: Vec<String>,
    data_paths: Vec<DataPathJson>,
}

#[derive(Serialize)]
struct DataPathJson {
    kind: String,
    value: String,
}

#[derive(Serialize)]
struct ManifestJson {
    package: String,
    version_name: String,
    version_code: String,
    min_sdk: String,
    target_sdk: String,
    debuggable: bool,
    uses_cleartext_traffic: Option<bool>,
    allow_backup: Option<bool>,
    permissions: Vec<String>,
    component_count: usize,
    exported_count: usize,
    exported_components: Vec<ExportedComponentJson>,
    finding_count: usize,
    findings: Vec<Finding>,
}

/// Parse the AndroidManifest.xml from `ctx` and emit a JSON envelope
/// suitable for both the CLI `manifest` subcommand and the MCP
/// `manifest` tool. Defaults to **lenient** parsing — unknown AXML
/// chunks (e.g. the `0x0104` commercial-obfuscator marker observed on
/// DexGuard-protected builds) are skipped and reported as
/// `_meta.warnings` rather than crashing the parse.
///
/// Callers that need the historical strict behaviour (a hard `Err` on
/// any unknown chunk) should call [`manifest_with_config`] with
/// [`ParseConfig::strict`].
///
/// Default-lenient with `_meta.warnings` is the lowest-friction,
/// semantics-preserving option.
pub fn manifest(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let mut cfg = ParseConfig::lenient();
    cfg.permissive_recovery = ctx.permissive_recovery;
    manifest_with_config(ctx, &cfg)
}

/// Variant of [`manifest`] that takes an explicit
/// [`droidsaw_apk::ParseConfig`]. Used by the MCP tool's `strict: bool`
/// parameter to opt back into hard-fail semantics; CLI consumers
/// inherit lenient via [`manifest`].
pub fn manifest_with_config(
    ctx: &CrossLayerContext,
    cfg: &ParseConfig,
) -> anyhow::Result<Value> {
    let raw = ctx
        .require_apk()?
        .manifest_raw
        .as_ref()
        .ok_or_else(|| anyhow::anyhow!("no AndroidManifest.xml found in APK"))?;
    let (manifest, unknown_chunks, _findings) = Manifest::from_binary_xml_with_config(raw, cfg)?;
    let findings = manifest.security_findings();
    let exported_components_list = manifest.exported_components();
    let exported_count = exported_components_list.len();
    let exported_components: Vec<ExportedComponentJson> = exported_components_list
        .iter()
        .map(|c| {
            let kind = c.kind.to_string();
            let exported_reason = if c.exported == Some(true) {
                "explicit".to_string()
            } else {
                // exported.is_none() + intent-filter (SDK < 31) or LAUNCHER.
                "implicit".to_string()
            };
            let intent_filters: Vec<IntentFilterJson> = c
                .intent_filters
                .iter()
                .map(|f| IntentFilterJson {
                    actions: f.actions.clone(),
                    categories: f.categories.clone(),
                    data_schemes: f.data_schemes.clone(),
                    data_hosts: f.data_hosts.clone(),
                    data_paths: f
                        .data_paths
                        .iter()
                        .map(|(k, v)| DataPathJson {
                            kind: k.clone(),
                            value: v.clone(),
                        })
                        .collect(),
                })
                .collect();
            ExportedComponentJson {
                name: c.name.clone(),
                kind,
                permission: c.permission.clone(),
                intent_filters,
                exported_reason,
            }
        })
        .collect();
    let m = ManifestJson {
        package: manifest.package.clone(),
        version_name: manifest.version_name.clone(),
        version_code: manifest.version_code,
        min_sdk: manifest.min_sdk,
        target_sdk: manifest.target_sdk,
        debuggable: manifest.debuggable,
        uses_cleartext_traffic: manifest.uses_cleartext_traffic,
        allow_backup: manifest.allow_backup,
        permissions: manifest.permissions.clone(),
        component_count: manifest.components.len(),
        exported_count,
        exported_components,
        finding_count: findings.len(),
        findings,
    };
    // Build _meta. Strict mode never populates `unknown_chunks` (it
    // returns Err on the first unknown chunk); lenient mode appends
    // one entry per skip. Surface skipped chunks as `_meta.warnings`
    // so MCP callers can decide whether to trust the parse and so
    // tooling that consumes this envelope can route to the audit-side
    // `COMMERCIAL_OBFUSCATOR_DETECTED` enrichment.
    let warnings = unknown_chunks_to_meta(&unknown_chunks);
    let mut meta_obj = meta(
        1,
        false,
        "use `droidsaw signing` for certs, `droidsaw audit` for full cross-layer findings",
        &["signing", "audit", "apk-info"],
    );
    if let Some(map) = meta_obj.as_object_mut()
        && !warnings.is_empty()
    {
        map.insert("warnings".to_string(), Value::Array(warnings));
        map.insert(
            "lenient_recovery".to_string(),
            Value::Bool(cfg.lenient_unknown_chunks),
        );
    }
    let out = json!({
        "manifest": m,
        "_meta": meta_obj,
    });
    Ok(out)
}

/// Render a slice of [`UnknownChunk`] values into the JSON shape used
/// by `_meta.warnings`: one object per skipped chunk with
/// `chunk_type` (16-bit hex) and `offset` (decimal byte offset). When
/// chunk_type matches a known commercial-obfuscator marker (`0x0104`),
/// the warning includes a `note` field naming the marker so the LLM
/// caller can route to the audit-side finding without consulting the
/// catalogue.
fn unknown_chunks_to_meta(chunks: &[UnknownChunk]) -> Vec<Value> {
    chunks
        .iter()
        .map(|uc| {
            let mut obj = serde_json::Map::new();
            obj.insert(
                "chunk_type".to_string(),
                Value::String(format!("0x{:04x}", uc.chunk_type)),
            );
            obj.insert(
                "offset".to_string(),
                Value::Number(serde_json::Number::from(uc.offset)),
            );
            if uc.chunk_type == 0x0104 {
                obj.insert(
                    "note".to_string(),
                    Value::String(
                        "commercial-obfuscator marker (DexGuard-class); \
                         see audit `COMMERCIAL_OBFUSCATOR_DETECTED` finding"
                            .to_string(),
                    ),
                );
            }
            Value::Object(obj)
        })
        .collect()
}

#[derive(Serialize)]
struct SigningJson {
    v1_present: bool,
    v2_present: bool,
    v3_present: bool,
    v4_present: bool,
    v1_subject: Option<String>,
    v1_algorithm: Option<String>,
    v1_key_size_bits: Option<u32>,
    v1_sha256_fingerprint: Option<String>,
    v1_not_before: Option<String>,
    v1_not_after: Option<String>,
    finding_count: usize,
    findings: Vec<Finding>,
    signers: Vec<SignerJson>,
}

#[derive(Serialize)]
struct SignerJson {
    scheme: &'static str,
    cert_sha256: Option<String>,
    public_key_algorithm: String,
    public_key_size_bits: u32,
    public_key_rsa_exponent: Option<String>,
    public_key_ec_curve: Option<String>,
    public_key_ec_point: Option<String>,
    signature_results: Vec<SignatureResult>,
    signed_data_outcome: SignatureOutcome,
}

#[derive(Serialize)]
struct SignatureResult {
    algorithm: u32,
    algorithm_name: String,
    outcome: SignatureOutcome,
}

/// Serializable view of `droidsaw_apk::signing::SignatureValidity`. Kept
/// distinct from boolean so a tampered signature (`Invalid{reason:…}`) is
/// never conflated with an unsupported curve (`Unsupported{scheme:…}`) at
/// the JSON boundary — apk-hardening made this distinction load-bearing.
#[derive(Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
enum SignatureOutcome {
    Valid,
    Invalid { reason: String },
    Unsupported { scheme: String },
}

impl From<&droidsaw_apk::signing::SignatureValidity> for SignatureOutcome {
    fn from(v: &droidsaw_apk::signing::SignatureValidity) -> Self {
        use droidsaw_apk::signing::SignatureValidity;
        match v {
            SignatureValidity::Valid => SignatureOutcome::Valid,
            SignatureValidity::Invalid { reason } => {
                SignatureOutcome::Invalid { reason: reason.to_string() }
            }
            SignatureValidity::Unsupported { scheme } => {
                SignatureOutcome::Unsupported { scheme: scheme.clone() }
            }
        }
    }
}

pub(super) fn to_hex(bytes: &[u8]) -> String {
    // DISPLAY-ONLY: `String::with_capacity` is an allocation hint; String regrows
    // on demand, so saturating is correctness-preserving.
    let mut s = String::with_capacity(bytes.len().saturating_mul(2));
    for b in bytes {
        use std::fmt::Write;
        #[allow(
            clippy::let_underscore_must_use,
            reason = "write! to String is infallible; the unit Result must be discarded"
        )]
        let _ = write!(s, "{b:02x}");
    }
    s
}

/// Build the per-signer `signer_summary` projection surfaced in
/// `apk_info` output. Reuses already-parsed [`droidsaw_apk::signing::SigningInfo`]
/// fields — no extra I/O beyond the one `signing_info()` call.
///
/// One entry per (v1, v2, v3) block actually present:
///   * v1 — full fidelity: `subject_cn` + `subject_o` (parsed from
///     `cert.subject`), `sha256_fingerprint`, `not_before`, `not_after`.
///   * v2 / v3 — only `sha256_fingerprint` is exposed on the per-signer
///     `SignerInfo` shape; the subject DN and validity require deeper
///     cert-chain re-parsing the apk crate does not currently surface
///     at this level. Those fields are emitted as `null` so the JSON
///     shape stays uniform across schemes.
///
/// Resolve the `(signer_summary, signer_summary_status)` pair for an
/// APK by calling `Apk::signing_info()` and projecting the result.
///
/// Ok(SigningInfo)  ⇒ (`build_signer_summary(...)`, `"ok"`)
/// Err(_)           ⇒ (`Vec::new()`,                `"parse_failed"`)
///
/// Separated out so the projection is unit-testable without going
/// through the full `apk_info()` audit pipeline.
pub fn resolve_signer_summary(apk: &droidsaw_apk::Apk) -> (Vec<Value>, &'static str) {
    match apk.signing_info() {
        Ok(sig) => (build_signer_summary(&sig), "ok"),
        Err(_) => (Vec::new(), "parse_failed"),
    }
}

/// Returns an empty `Vec` when no signing blocks are present — the
/// "unsigned APK" branch (an empty signer summary is valid).
pub fn build_signer_summary(sig: &droidsaw_apk::signing::SigningInfo) -> Vec<Value> {
    use droidsaw_apk::signing::{extract_subject_cn, extract_subject_organization, SigningScheme};
    let mut out: Vec<Value> = Vec::new();
    if let Some(cert) = sig.v1_cert.as_ref() {
        out.push(json!({
            "signing_scheme": "v1",
            "subject_cn": extract_subject_cn(&cert.subject),
            "subject_o": extract_subject_organization(&cert.subject),
            "sha256_fingerprint": cert.sha256_fingerprint,
            "not_before": cert.not_before,
            "not_after": cert.not_after,
        }));
    }
    for signer in &sig.signers {
        let scheme = match signer.scheme {
            SigningScheme::V2 => "v2",
            SigningScheme::V3 => "v3",
        };
        out.push(json!({
            "signing_scheme": scheme,
            "subject_cn": Value::Null,
            "subject_o": Value::Null,
            "sha256_fingerprint": signer.cert_sha256,
            "not_before": Value::Null,
            "not_after": Value::Null,
        }));
    }
    out
}

fn build_signer_json(signer: &droidsaw_apk::signing::SignerInfo) -> SignerJson {
    use droidsaw_apk::signing::SigningScheme;
    let scheme = match signer.scheme {
        SigningScheme::V2 => "v2",
        SigningScheme::V3 => "v3",
    };
    let is_rsa = signer.public_key_algorithm == "RSA";
    let is_ec = signer.public_key_algorithm == "EC";
    let signature_results: Vec<SignatureResult> = signer
        .signatures
        .iter()
        .zip(signer.signature_algorithms.iter())
        .zip(signer.signature_results.iter())
        .map(|(((algo_id, _), name), verified)| SignatureResult {
            algorithm: *algo_id,
            algorithm_name: name.clone(),
            outcome: verified.into(),
        })
        .collect();
    SignerJson {
        scheme,
        cert_sha256: signer.cert_sha256.clone(),
        public_key_algorithm: signer.public_key_algorithm.clone(),
        public_key_size_bits: signer.public_key_size_bits,
        public_key_rsa_exponent: is_rsa.then(|| to_hex(&signer.public_key_rsa_exponent)),
        public_key_ec_curve: is_ec.then(|| signer.public_key_ec_curve.clone()),
        public_key_ec_point: is_ec.then(|| to_hex(&signer.public_key_ec_point)),
        signed_data_outcome: (&signer.signature_verified).into(),
        signature_results,
    }
}

/// Build a [`droidsaw_apk::signing::SigningFindingsContext`] from the
/// cross-layer context, threading the co-loaded split-APK count into the
/// APKMirror split-bundle re-sign downgrade gate. `loaded_split_names` is
/// populated by `CrossLayerContext::parse_with_splits` when the input was
/// a base APK with co-located `split_config.*.apk` siblings; the count
/// saturates at `u32::MAX` because the context-struct field is `u32`.
fn signing_findings_context_from(
    ctx: &CrossLayerContext,
) -> droidsaw_apk::signing::SigningFindingsContext {
    let loaded_split_count =
        u32::try_from(ctx.loaded_split_names.len()).unwrap_or(u32::MAX);
    droidsaw_apk::signing::SigningFindingsContext { loaded_split_count }
}

pub fn signing(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let sig = ctx.require_apk()?.signing_info()?;
    let findings = sig.security_findings_at_with_context(
        std::time::SystemTime::now(),
        signing_findings_context_from(ctx),
    );
    let v1_subject = sig.v1_cert.as_ref().map(|c| c.subject.clone());
    let v1_algorithm = sig.v1_cert.as_ref().map(|c| c.key_algorithm.clone());
    let v1_key_size_bits = sig.v1_cert.as_ref().map(|c| c.key_size_bits);
    let v1_sha256_fingerprint = sig.v1_cert.as_ref().map(|c| c.sha256_fingerprint.clone());
    let v1_not_before = sig.v1_cert.as_ref().map(|c| c.not_before.clone());
    let v1_not_after = sig.v1_cert.as_ref().map(|c| c.not_after.clone());
    let finding_count = findings.len();
    let signers: Vec<SignerJson> = sig.signers.iter().map(build_signer_json).collect();
    let s = SigningJson {
        v1_present: sig.v1_cert.is_some(),
        v2_present: sig.v2_present,
        v3_present: sig.v3_present,
        v4_present: sig.v4_present,
        v1_subject,
        v1_algorithm,
        v1_key_size_bits,
        v1_sha256_fingerprint,
        v1_not_before,
        v1_not_after,
        finding_count,
        findings,
        signers,
    };
    let out = json!({
        "signing": s,
        "_meta": meta(
            1,
            false,
            "cert details plus v2/v3/v4 presence; pair with `droidsaw manifest` for permission context",
            &["manifest", "audit", "apk-info"],
        ),
    });
    Ok(out)
}

pub fn apk_info(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let apk = ctx.require_apk()?;
    let findings = collect_apk_findings(ctx, DEFAULT_ENTROPY_THRESHOLD_BITS);
    let (package, version_name) = manifest_id(ctx);
    // signing_info() is best-effort here — `info` is a recon tool and
    // must not fail when the v1 PKCS#7 envelope is corrupted or absent.
    // Unsigned APK ⇒ empty `signer_summary` with status `"ok"`; PKCS#7
    // parse failure ⇒ empty `signer_summary` with status `"parse_failed"`.
    // Carrying the status as a sibling field lets analysts distinguish
    // "no signature at all" from "signature exists but is corrupted"
    // without having to call the `signing` tool a second time.
    let (signer_summary, signer_summary_status) = resolve_signer_summary(apk);
    let apk_info = build_apk_info_response(
        ApkInfoContext {
            path: &apk.path,
            package,
            version_name,
            hbc_present: ctx.hbc.is_some(),
            dex_count: ctx.dex.len(),
            native_lib_abis: apk.native_libs.keys().cloned().collect(),
            dex_entries: &apk.dex,
            signer_summary,
            signer_summary_status,
        },
        &findings,
    );
    let out = json!({
        "apk_info": apk_info,
        "_meta": meta(
            1,
            false,
            "summary report with severity tally + top critical/high findings; full findings + queryable DB via `audit` + `query`",
            &["info", "manifest", "signing", "audit"],
        ),
    });
    Ok(out)
}

/// Cap on the `top_findings` array in `apk_info` responses. Mirrors
/// `run_core_audit`'s 5-entry cap so callers see a consistent surface
/// across `info` and `audit`. The audit DB carries the full set; this
/// is the recon-tool projection.
pub const APK_INFO_TOP_FINDINGS_CAP: usize = 5;

/// APK context bundle passed to [`build_apk_info_response`].
///
/// Groups the APK-identity and binary-layer fields that were previously
/// passed as 7 separate positional arguments, reducing `build_apk_info_response`
/// from 8 arguments to 2 (context + findings).
pub struct ApkInfoContext<'a> {
    /// Filesystem path to the APK file (or the equivalent for raw DEX/HBC).
    pub path: &'a str,
    /// Application package name from the manifest, if present.
    pub package: Option<String>,
    /// Application version name from the manifest, if present.
    pub version_name: Option<String>,
    /// Whether a Hermes bytecode bundle is present in this APK.
    pub hbc_present: bool,
    /// Number of DEX files found in the APK.
    pub dex_count: usize,
    /// ABI directory names for bundled native libraries (e.g. `arm64-v8a`).
    pub native_lib_abis: Vec<String>,
    /// DEX zip-entry metadata used to populate `layer_map`. Pass `&[]` for
    /// non-APK inputs (raw DEX / HBC) — the field will be an empty array.
    pub dex_entries: &'a [droidsaw_apk::DexEntry],
    /// Per-signer summary surfaced under `signer_summary`. Empty for
    /// unsigned APKs or non-APK inputs. Already-projected JSON values
    /// so [`build_apk_info_response`] stays purely-shaping.
    pub signer_summary: Vec<Value>,
    /// Status sibling for [`Self::signer_summary`]. Carries `"ok"` when
    /// `Apk::signing_info()` returned `Ok` (including the unsigned-APK
    /// branch where every block is absent); carries `"parse_failed"`
    /// when the PKCS#7 / signing-block parse failed. Lets analysts tell
    /// "no signature exists" from "signature exists but is corrupted"
    /// without calling the `signing` tool a second time.
    pub signer_summary_status: &'static str,
}

/// Build the `apk_info` response object — the JSON value nested under
/// the top-level `apk_info` key. Extracted from `apk_info()` so the
/// response shape (severity tally + top critical/high projection) is
/// testable with synthetic finding sets.
///
/// Drops the inline `findings` array that previously drove `info`
/// payloads to 95 KB on adversarial inputs (237 findings × ~400
/// chars/finding); replaces it with `severity_summary` (BTreeMap
/// counts) + `top_findings` (capped projection mirroring `audit`'s
/// shape). Callers wanting the queryable form run `audit`.
///
/// `ctx.dex_entries` populates the `layer_map` field: each DEX layer ID
/// (`dex1`, `dex2`, …) is mapped to its APK zip entry name, uncompressed
/// size, and SHA-256 digest. Entry names carry the split prefix (e.g.,
/// `base:classes.dex`, `feature_module:classes2.dex`) because that is
/// how [`droidsaw_apk::DexEntry`] stores them — the split name comes
/// from the APK prefix applied at parse time.
///
/// SHA-256 is computed from the stored entry bytes rather than re-read
/// from disk; for APK inputs the bytes are already in memory from the
/// initial ZIP extraction, so this adds only a single-pass hash per
/// DEX entry.
pub fn build_apk_info_response(ctx: ApkInfoContext<'_>, findings: &[Finding]) -> Value {
    let ApkInfoContext {
        path,
        package,
        version_name,
        hbc_present,
        dex_count,
        native_lib_abis,
        dex_entries,
        signer_summary,
        signer_summary_status,
    } = ctx;
    use droidsaw_common::Severity;
    use sha2::{Digest, Sha256};

    // Build layer_map: dex1 → first entry, dex2 → second, … (1-based IDs
    // match every other tool that uses `format!("dex{}", i + 1)`).
    let layer_map: Vec<Value> = dex_entries
        .iter()
        .enumerate()
        .map(|(i, entry)| {
            // i + 1: layer IDs are 1-based ("dex1", "dex2", …) consistent
            // with xrefs / decompile / taint output. Bounded by dex_entries.len()
            // which is bounded by Apk::MAX_DEX_FILES (enforced at parse time).
            // PROOF: i < dex_entries.len() ≤ Apk::MAX_DEX_FILES ≤ usize::MAX - 1;
            //        i.saturating_add(1) is safe. Layer ID string is display-only.
            let layer_id = format!("dex{}", i.saturating_add(1));
            let size_bytes = entry.data.len();
            let sha256 = {
                let digest = Sha256::digest(&entry.data);
                let mut out = String::with_capacity(64);
                for byte in digest.iter() {
                    let hi = byte >> 4;
                    let lo = byte & 0x0f;
                    out.push(hex_nibble(hi));
                    out.push(hex_nibble(lo));
                }
                out
            };
            json!({
                "layer_id": layer_id,
                "entry": entry.name,
                "size_bytes": size_bytes,
                "sha256": sha256,
            })
        })
        .collect();

    let mut severity_summary = std::collections::BTreeMap::<String, usize>::new();
    for f in findings {
        let c = severity_summary.entry(format!("{:?}", f.severity)).or_insert(0);
        *c = c.saturating_add(1);
    }

    let top_findings: Vec<Value> = findings
        .iter()
        .filter(|f| matches!(f.severity, Severity::Critical | Severity::High))
        .take(APK_INFO_TOP_FINDINGS_CAP)
        .map(|f| json!({
            "severity": format!("{:?}", f.severity),
            "id": f.id,
            "detail": f.detail,
            "cwe": f.cwe,
        }))
        .collect();

    json!({
        "path": path,
        "package": package,
        "version_name": version_name,
        "layers": {
            "hbc_present": hbc_present,
            "dex_count": dex_count,
            "native_lib_abis": native_lib_abis,
        },
        "layer_map": layer_map,
        "finding_count": findings.len(),
        "severity_summary": severity_summary,
        "top_findings": top_findings,
        "signer_summary": signer_summary,
        "signer_summary_status": signer_summary_status,
    })
}

// ── hbc / dex subcommand dispatch ─────────────────────────────────

#[derive(Serialize)]
struct HbcInfo {
    version: u32,
    function_count: u32,
    string_count: u32,
}

#[derive(Serialize)]
struct HbcFunctionEntry {
    id: u32,
    name: String,
    param_count: u32,
    offset: u32,
    size: u32,
}

pub fn hbc_info(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    Ok(json!({
        "hbc": HbcInfo {
            version: hbc.version,
            function_count: hbc.function_count,
            string_count: hbc.string_count,
        },
        "_meta": meta(
            1,
            false,
            "use `droidsaw hbc functions` to list functions, `droidsaw hbc decompile` to get source",
            &["hbc functions", "hbc decompile", "hbc strings"],
        ),
    }))
}

pub fn hbc_functions(
    ctx: &CrossLayerContext,
    search: Option<&str>,
) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let re = search.map(regex::Regex::new).transpose()?;
    let mut functions: Vec<HbcFunctionEntry> = Vec::new();
    for fid in 0..hbc.function_count {
        let f = hbc.function_get(fid);
        let name = if f.name_id < hbc.string_count {
            // Lenient policy: function-name lookup; corrupted entry
            // renders as `""` (the search regex won't match an empty
            // string except `""` itself, which is the right behavior).
            hbc.string_as_str_or_empty(f.name_id).into_owned()
        } else {
            String::new()
        };
        if let Some(ref re) = re
            && !re.is_match(&name)
        {
            continue;
        }
        functions.push(HbcFunctionEntry {
            id: fid,
            name,
            param_count: f.param_count,
            offset: f.offset,
            size: f.size,
        });
    }
    let count = functions.len();
    Ok(json!({
        "functions": functions,
        "_meta": meta(
            count,
            false,
            "use `droidsaw hbc decompile --func-id <id>` to see source",
            &["hbc decompile", "hbc strings", "xrefs"],
        ),
    }))
}

/// List CommonJS modules from the loaded Hermes bundle. Returns
/// `{modules: [{index, path, function_id}], _meta}`. Empty for
/// bundles built with Metro static resolution (most modern React
/// Native apps).
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`cjs_module_count as usize` is u32→usize widening; always safe.")]
pub fn module_list(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let mut modules = Vec::with_capacity(hbc.cjs_module_count as usize);
    for i in 0..hbc.cjs_module_count {
        // `cjs_module_get` returns `Option<ModuleData>`. `None` here
        // is the typed face of a missing entry / corrupt module-table —
        // skip the row rather than emitting `("", 0)`.
        let Some(m) = hbc.cjs_module_get(i) else {
            continue;
        };
        let path = if m.symbol_id < hbc.string_count {
            // Lenient policy: corrupted symbol-id renders as the
            // numeric id, mirroring the existing `<{id}>` fallback.
            hbc.string_as_str_or_empty(m.symbol_id).into_owned()
        } else {
            format!("<{}>", m.symbol_id)
        };
        modules.push(json!({
            "index": i,
            "path": path,
            "function_id": m.func_offset,
        }));
    }
    let count = modules.len();
    Ok(json!({
        "modules": modules,
        "_meta": meta(
            count,
            false,
            "empty for Metro static-resolution bundles; use `strings --search node_modules` to find dependencies the other way",
            &["hbc functions", "npm-packages", "strings"],
        ),
    }))
}

/// Find React Native `NativeModules` bridge calls in the loaded
/// Hermes bundle. Maps `caller_function → [bridged module names]`.
/// Returns `{bridges: [{function_id, function_name, modules: [...]}],
/// _meta}`.
// PROOF: DEX struct-field indices (m_idx.0, class_idx.0) widen u32→usize for
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "DEX struct-field indices (m_idx.0, class_idx.0) widen u32→usize for bounds-checked `.get()`; `dex_idx + 1` is usize+1 bounded by Vec::len.")]
pub fn native_modules(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let hbc_data = hbc_owned.bytes();
    let scan = droidsaw_hermes::scanner::scan_parsed_with_mode(
        hbc,
        hbc_data,
        &droidsaw_hermes::scanner::ScanMode {
            xrefs: true,
            callgraph: false,
        },
    );

    // Collect every string index that is literally "NativeModules".
    // Lenient policy: corrupted entries cannot equal "NativeModules"
    // as `""` or as raw bytes — skip them silently. The typed Finding
    // signal is preserved via the predecessor stream's side-channel.
    let mut nm_ids: Vec<u32> = Vec::new();
    for i in 0..hbc.string_count {
        if hbc.string_as_str_or_empty(i) == "NativeModules" {
            nm_ids.push(i);
        }
    }

    // For every function that references "NativeModules", collect the
    // identifier strings referenced by the same function — those are
    // the module names accessed via the bridge.
    use std::collections::BTreeMap;
    let mut results: BTreeMap<u32, (String, Vec<String>)> = BTreeMap::new();
    for &nm_id in &nm_ids {
        let Some(func_ids) = scan.string_refs.get(&nm_id) else {
            continue;
        };
        for &fid in func_ids {
            if fid >= hbc.function_count {
                continue;
            }
            let f = hbc.function_get(fid);
            let fname = if f.name_id < hbc.string_count {
                hbc.string_as_str_or_empty(f.name_id).into_owned()
            } else {
                String::new()
            };
            let entry = results.entry(fid).or_insert_with(|| (fname.clone(), Vec::new()));
            for (&sid, refs) in &scan.string_refs {
                if sid >= hbc.string_count || !refs.contains(&fid) {
                    continue;
                }
                // `string_get` typed-Result migration: skip corrupt
                // entries silently (the side-channel Finding fires on
                // OOR). `Ok(None)` here means the index landed past
                // `string_count` despite the explicit guard above —
                // not reachable today but typed for safety.
                let Ok(Some(sd)) = hbc.string_get(sid) else {
                    continue;
                };
                if sd.kind == 1 {
                    let name = hbc.string_as_str_or_empty(sid).into_owned();
                    if name != "NativeModules" && !name.is_empty() {
                        entry.1.push(name);
                    }
                }
            }
        }
    }

    // Resolve JS module names → Java @ReactMethod implementations.
    let bridge = analysis::bridge::BridgeResolver::resolve(ctx);

    let bridges: Vec<Value> = results
        .into_iter()
        .map(|(fid, (fname, mut modules))| {
            modules.sort();
            modules.dedup();

            // For each module name referenced by this JS function, look up
            // the Java @ReactMethod implementations via BridgeResolver.
            let java_targets: Vec<Value> = modules.iter().flat_map(|module_name| {
                // Use the legacy by_method index here — this site has only
                // a heuristic JS-side method-name string (no (module, method)
                // pair from a property-chain back-walk), so the per-(module,
                // method) `bridge.mappings` index can't be keyed against it.
                let targets = bridge.by_method.get(module_name.as_str());
                targets.into_iter().flat_map(move |ts| {
                    ts.iter().filter_map(move |(dex_idx, m_idx)| {
                        let dex = ctx.dex.get(*dex_idx)?;
                        let m_id_item = dex.methods.get(m_idx.0 as usize)?;
                        let class = dex.type_descriptors
                            .get(m_id_item.class_idx.0 as usize)
                            .cloned()
                            .unwrap_or_default();
                        let method = dex.get_string(m_id_item.name_idx)
                            .unwrap_or_default()
                            .to_string();
                        Some(json!({
                            "js_module": module_name,
                            "java_class": class,
                            "java_method": method,
                            "dex": format!("dex{}", dex_idx + 1),
                        }))
                    })
                })
            }).collect();

            json!({
                "function_id": fid,
                "function_name": fname,
                "modules": modules,
                "java_targets": java_targets,
            })
        })
        .collect();
    let count = bridges.len();
    Ok(json!({
        "bridges": bridges,
        "_meta": meta(
            count,
            false,
            "java_targets maps each JS module name to its @ReactMethod Java class+method. \
             Empty if the bundle doesn't use NativeModules or no @ReactMethod annotations found.",
            &["xrefs", "dex_classes", "dex_decompile", "hbc_functions"],
        ),
    }))
}

/// Disassemble a Hermes function by id. Returns
/// `{function_id, name, param_count, instructions: [...], _meta}`.
/// String operands are resolved inline.
// PROOF: HBC bytecode region validated in u64 — `end = f.offset as u64 +
// f.size as u64` + `if end > hbc_data.len() as u64 { bail }` guard —
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "HBC bytecode region validated in u64 — `end = f.offset as u64 + f.size as u64` + `if end > hbc_data.len() as u64 { bail }` guard — before narrowing `as usize`; u32→u64/i64 widening cannot overflow.")]
pub fn disasm(ctx: &CrossLayerContext, func_id: u32) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let hbc_data = hbc_owned.bytes();

    if func_id >= hbc.function_count {
        anyhow::bail!(
            "function index {func_id} out of range (valid: 0..{}); use `hbc_functions` to list available ids",
            // DISPLAY-ONLY: error-message format width; `function_count == 0`
            // would underflow but the bail arm itself only runs when the user
            // asked for an id ≥ function_count, so the "valid: 0..N" suffix is
            // already approximate.
            hbc.function_count.saturating_sub(1)
        );
    }

    let f = hbc.function_get(func_id);
    let fname = if f.name_id < hbc.string_count {
        hbc.string_as_str_or_empty(f.name_id).into_owned()
    } else {
        String::new()
    };
    let end = u64::from(f.offset) + u64::from(f.size);
    #[allow(
        clippy::cast_possible_truncation,
        reason = "PROOF: `.get()` returns None on out-of-range, propagated as the typed error below. On 32-bit targets `end as usize` may truncate, but the truncated slice is still bounds-checked by `.get()` — a truncated end past hbc_data.len() yields None, never UB."
    )]
    let code = hbc_data
        .get(f.offset as usize..end as usize)
        .ok_or_else(|| {
            anyhow::anyhow!("function bytecode region extends past buffer end — corrupt bundle?")
        })?;
    let instructions =
        droidsaw_hermes::decompile::decode::decode_function(code, hbc.opcode_version())
            .map_err(|e| anyhow::anyhow!("failed to decode Hermes function {func_id}: {e}"))?;

    use droidsaw_hermes::decompile::decode::Operand;
    let insts: Vec<Value> = instructions
        .iter()
        .map(|inst| {
            let operands: Vec<String> = inst
                .operands
                .iter()
                .map(|op| match op {
                    Operand::Reg(r) => format!("r{r}"),
                    Operand::Reg32(r) => format!("r{r}"),
                    Operand::UInt(v) => {
                        if *v < hbc.string_count {
                            format!("\"{}\"", hbc.string_as_str_or_empty(*v))
                        } else {
                            format!("{v}")
                        }
                    }
                    Operand::Int(v) => format!("{v}"),
                    Operand::Double(v) => format!("{v}"),
                    Operand::Addr(rel) => format!("→0x{:04x}", i64::from(inst.offset) + i64::from(*rel)),
                })
                .collect();
            json!({
                "offset": inst.offset,
                "opcode": inst.name,
                "operands": operands,
            })
        })
        .collect();
    let count = insts.len();
    Ok(json!({
        "function_id": func_id,
        "name": fname,
        "param_count": f.param_count,
        "size": f.size,
        "instructions": insts,
        "_meta": meta(
            count,
            false,
            "raw instruction stream — use `decompile --func-id <id>` for higher-level view, `hbc_functions --search <re>` to find targets",
            &["decompile", "hbc_functions", "xrefs"],
        ),
    }))
}

/// Extract an npm package inventory (SBOM) from the Hermes string
/// table. Scans for `node_modules/…` substrings and aggregates
/// package references. Returns
/// `{packages: [{name, ref_count}], _meta}`.
pub fn npm_packages(ctx: &CrossLayerContext) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let re = regex::Regex::new(r"(?:^|/)node_modules/(@[^/]+/[^/]+|[^/]+)")?;

    use std::collections::BTreeMap;
    let mut packages: BTreeMap<String, usize> = BTreeMap::new();
    for i in 0..hbc.string_count {
        let value = hbc.string_as_str_or_empty(i);
        if let Some(caps) = re.captures(&value)
            && let Some(name) = caps.get(1)
        {
            // DISPLAY-ONLY: `ref_count` feeds a JSON response field only.
            let refcount = packages.entry(name.as_str().to_string()).or_insert(0);
            *refcount = refcount.saturating_add(1);
        }
    }

    let mut rows: Vec<Value> = packages
        .into_iter()
        .map(|(name, count)| json!({ "name": name, "ref_count": count }))
        .collect();
    rows.sort_by_key(|v| {
        v.get("name")
            .and_then(|n| n.as_str())
            .map(|s| s.to_lowercase())
            .unwrap_or_default()
    });
    let count = rows.len();
    Ok(json!({
        "packages": rows,
        "_meta": meta(
            count,
            false,
            "Hermes-only SBOM extracted from the string pool; for the full APK-level SBOM use `droidsaw sbom`",
            &["sbom", "strings", "hbc functions"],
        ),
    }))
}

/// Hermes function call graph. Returns `{edges: [{caller: {id,
/// name}, callees: [{id, name}]}], _meta}`. Filter callers with
/// a regex on the caller name; cap with `limit` (default 50).
// PROOF: `fi`/`ci` widen u32→usize for `func_names` index, each bounded by
// the `ci < hbc.function_count` filter + func_names.len() == function_count.
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`fi`/`ci` widen u32→usize for `func_names` index, each bounded by the `ci < hbc.function_count` filter + func_names.len() == function_count.")]
pub fn call_graph(
    ctx: &CrossLayerContext,
    search: Option<&str>,
    limit: Option<usize>,
) -> anyhow::Result<Value> {
    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let hbc_data = hbc_owned.bytes();
    let scan = droidsaw_hermes::scanner::scan_parsed_with_mode(
        hbc,
        hbc_data,
        &droidsaw_hermes::scanner::ScanMode {
            xrefs: false,
            callgraph: true,
        },
    );
    let re = search.map(regex::Regex::new).transpose()?;
    let cap = limit.unwrap_or(50);

    let func_names: Vec<String> = (0..hbc.function_count)
        .map(|i| {
            let f = hbc.function_get(i);
            if f.name_id < hbc.string_count {
                hbc.string_as_str_or_empty(f.name_id).into_owned()
            } else {
                String::new()
            }
        })
        .collect();

    let mut edges: Vec<Value> = Vec::new();
    let mut truncated = false;
    for fi in 0..hbc.function_count {
        let Some(callees) = scan.call_graph.get(&fi) else {
            continue;
        };
        if callees.is_empty() {
            continue;
        }
        let Some(caller_name) = func_names.get(fi as usize) else {
            continue;
        };
        if let Some(ref re) = re
            && !re.is_match(caller_name)
        {
            continue;
        }
        let callees_json: Vec<Value> = callees
            .iter()
            .filter(|&&ci| ci < hbc.function_count)
            .filter_map(|&ci| {
                func_names.get(ci as usize).map(|name| {
                    json!({
                        "id": ci,
                        "name": name,
                    })
                })
            })
            .collect();
        edges.push(json!({
            "caller": {
                "id": fi,
                "name": caller_name,
            },
            "callees": callees_json,
        }));
        if edges.len() >= cap {
            truncated = true;
            break;
        }
    }
    let count = edges.len();
    Ok(json!({
        "edges": edges,
        "_meta": meta(
            count,
            truncated,
            "Hermes call graph via scanner::scan_parsed_with_mode(callgraph=true); anonymous callers have empty `name`",
            &["hbc_functions", "decompile", "xrefs"],
        ),
    }))
}

/// Cap on the number of body lines retained per method when
/// `decompile` is invoked with `mode: "outline"`. The remaining lines
/// (if any) are replaced by a `// ... N more lines elided` marker so
/// the caller knows truncation happened. Picked to keep the per-class
/// payload < 8 KB on adversarial deeplink-router-shaped classes
/// (~5 methods × 20 lines × ~80 chars/line ≈ 8 KB ceiling, well below
/// the 71 KB observed on a pathological real-world deeplink activity).
pub const OUTLINE_LINES_PER_METHOD: usize = 20;

/// `decompile` MCP-tool output mode.
#[derive(Debug, Clone, Copy, Default)]
pub enum DecompileMode {
    /// Full decompiled source for every emitted class. Backward-compatible default.
    #[default]
    Full,
    /// Class header + field decls + method signatures + first
    /// `OUTLINE_LINES_PER_METHOD` body lines per method. Remaining
    /// body lines replaced by `// ... N more lines elided`.
    Outline,
}

/// Decompile DEX classes to Java source. Either `class_index`
/// (0-based global index across all loaded DEX files) or a `search`
/// regex on the class descriptor must be provided. Returns
/// `{classes: [{layer, class_index, descriptor, source}], _meta}`.
///
/// Backward-compatible thin wrapper preserving the legacy 3-arg
/// signature for CLI dispatch + integration tests at
/// `tests/decompile_dispatch.rs`. Output-size controls live behind
/// `dex_decompile_filtered`.
pub fn dex_decompile(
    ctx: &CrossLayerContext,
    class_index: Option<usize>,
    search: Option<&str>,
) -> anyhow::Result<Value> {
    dex_decompile_filtered(ctx, class_index, search, DecompileMode::Full, None)
}

/// Like `dex_decompile`, but with two output-size controls applied at
/// the MCP/CLI boundary:
///
/// - `mode: DecompileMode::Outline` — compresses each emitted class to
///   class header + method signatures + first `OUTLINE_LINES_PER_METHOD`
///   body lines per method (see `apply_outline_filter`).
/// - `methods_filter: Some(&[name; ...])` — keeps only methods whose
///   name (last whitespace-separated identifier before `(`) appears in
///   the list. Field decls + class header always survive (see
///   `apply_methods_filter`). Overloads with the same name all fire.
///
/// Both filters are pure string-rewriters over the decompiled source
/// produced by the bundle crate. No format-specific knowledge leaks
/// into common; algorithms live in the shared library, instruction-set
/// knowledge stays in the bundle.
// PROOF: `i + 1` (dex-layer 1-based label) is usize+1 bounded by ctx.dex.len()
// ≤ isize::MAX; `class_idx.0 as usize` widens u32→usize for `.get()` lookup.
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`i + 1` (dex-layer 1-based label) is usize+1 bounded by ctx.dex.len() ≤ isize::MAX; `class_idx.0 as usize` widens u32→usize for `.get()` lookup.")]
pub fn dex_decompile_filtered(
    ctx: &CrossLayerContext,
    class_index: Option<usize>,
    search: Option<&str>,
    mode: DecompileMode,
    methods_filter: Option<&[String]>,
) -> anyhow::Result<Value> {
    if class_index.is_none() && search.is_none() {
        anyhow::bail!(
            "specify `class_index` (from `dex classes`) or `search` (regex on class descriptor); class descriptors use JVM format: Lcom/example/Foo;"
        );
    }
    let normalized_search: Option<String> = search.map(normalize_dex_class_search);
    let re = normalized_search.as_deref().map(regex::Regex::new).transpose()?;

    let mut decompiled: Vec<Value> = Vec::new();
    let mut close_matches: Vec<String> = Vec::new();
    let mut global_idx: usize = 0;
    for (i, dex) in ctx.dex.iter().enumerate() {
        let layer = format!("dex{}", i + 1);
        let Some(dex_data) = ctx.dex_bytes(i) else {
            continue;
        };
        // Amortize r8_inversion::build_trampoline_census across all
        // class_defs in this DEX. Per-class decompile_class was rebuilding
        // the census every call, consuming 77.66% self-time on a large
        // social app's full decompile.
        let census = droidsaw_dex::r8_inversion::build_trampoline_census(dex);
        for (class_defs_idx, class_def) in dex.class_defs.iter().enumerate() {
            // Shadow gate: duplicate-class_idx rows would surface as
            // distinct entries in the operator's `--index N` selector
            // while the parser-level resolution (first-wins) pins to
            // the canonical row. Skip shadowed rows so `global_idx`
            // and the operator's selector address the same set the
            // dex_classes listing surfaces.
            if dex.class_def_is_shadowed(class_defs_idx) {
                continue;
            }
            let descriptor = dex
                .type_descriptors
                .get(class_def.class_idx.0 as usize)
                .cloned()
                .unwrap_or_default();
            let matched = match (class_index, re.as_ref()) {
                (Some(target), _) => global_idx == target,
                (None, Some(re)) => re.is_match(&descriptor),
                _ => false,
            };
            if matched {
                let raw_source =
                    droidsaw_dex::classes::decompile_class_with_census(dex, dex_data, class_def, &census);
                let after_methods = match methods_filter {
                    Some(list) => apply_methods_filter(&raw_source, list),
                    None => raw_source,
                };
                let source = match mode {
                    DecompileMode::Full => after_methods,
                    DecompileMode::Outline => {
                        apply_outline_filter(&after_methods, OUTLINE_LINES_PER_METHOD)
                    }
                };
                decompiled.push(json!({
                    "layer": layer,
                    "class_index": global_idx,
                    "descriptor": descriptor,
                    "source": source,
                }));
            } else if let Some(raw) = search {
                // Collect close matches for a helpful error message.
                if close_matches.len() < 5
                    && descriptor.to_lowercase().contains(&raw.to_lowercase())
                {
                    close_matches.push(descriptor.clone());
                }
            }
            // DISPLAY-ONLY: cross-dex class index for JSON response.
            global_idx = global_idx.saturating_add(1);
        }
    }

    if decompiled.is_empty() {
        let hint = if close_matches.is_empty() {
            format!(
                "no matching class for {search:?}; \
                 use JVM descriptor format (Lcom/example/Foo;) or a bare class name. \
                 Call `dex_classes` with a broad search to browse available descriptors."
            )
        } else {
            format!(
                "no matching class for {search:?}; \
                 use JVM descriptor format (Lcom/example/Foo;) or a bare class name. \
                 Close matches:\n{}",
                close_matches
                    .iter()
                    .map(|s| format!("  {s}"))
                    .collect::<Vec<_>>()
                    .join("\n")
            )
        };
        anyhow::bail!("{hint}");
    }
    let count = decompiled.len();
    Ok(json!({
        "classes": decompiled,
        "_meta": meta(
            count,
            false,
            "search decompiles every match — narrow with a tighter regex to avoid large output",
            &["dex classes", "dex strings", "decompile"],
        ),
    }))
}

/// Dry-run variant of [`dex_decompile_filtered`]: resolves the same
/// class-matching logic (by `class_index` or `search` regex) but does
/// **not** invoke the decompiler pipeline.
///
/// Returns a JSON array of `{class_descriptor, class_index,
/// estimated_method_count, estimated_output_tokens}` for every
/// matching class.  The caller uses this to decide whether to narrow
/// the search regex before paying the full decompile cost.
///
/// `estimated_output_tokens` is a heuristic: `method_count * 80`.
/// It is a rough guide, not a guaranteed budget cap.
///
/// Returns an empty list (not an error) when zero classes match.
// PROOF: `i + 1` label is usize+1 bounded by ctx.dex.len() ≤ isize::MAX;
// `class_idx.0 as usize` widens u32→usize for bounds-checked `.get()`.
// method_count arithmetic: saturating_add + saturating_mul throughout;
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`i + 1` label is usize+1 bounded by ctx.dex.len() ≤ isize::MAX; `class_idx.0 as usize` widens u32→usize for bounds-checked `.get()`. method_count arithmetic: saturating_add + saturating_mul throughout; no wrapping or overflow possible on any platform.")]
pub fn dex_decompile_dry_run(
    ctx: &CrossLayerContext,
    class_index: Option<usize>,
    search: Option<&str>,
) -> anyhow::Result<Value> {
    if class_index.is_none() && search.is_none() {
        anyhow::bail!(
            "specify `class_index` (from `dex classes`) or `search` (regex on class descriptor); class descriptors use JVM format: Lcom/example/Foo;"
        );
    }
    let normalized_search: Option<String> = search.map(normalize_dex_class_search);
    let re = normalized_search.as_deref().map(regex::Regex::new).transpose()?;

    let mut matches: Vec<Value> = Vec::new();
    let mut global_idx: usize = 0;
    for (i, dex) in ctx.dex.iter().enumerate() {
        let layer = format!("dex{}", i + 1);
        for (class_defs_idx, class_def) in dex.class_defs.iter().enumerate() {
            // Shadow gate (see dex_decompile_class_index for rationale):
            // skip duplicate-class_idx rows so the operator-visible class
            // listing matches the canonical first-wins resolution.
            if dex.class_def_is_shadowed(class_defs_idx) {
                continue;
            }
            let descriptor = dex
                .type_descriptors
                .get(class_def.class_idx.0 as usize)
                .cloned()
                .unwrap_or_default();
            let matched = match (class_index, re.as_ref()) {
                (Some(target), _) => global_idx == target,
                (None, Some(re)) => re.is_match(&descriptor),
                _ => false,
            };
            if matched {
                // Count methods from the pre-parsed ClassData if present.
                let method_count: usize = dex
                    .class_datas
                    .get(&class_def.class_data_off)
                    .map(|cd| {
                        cd.direct_methods
                            .len()
                            .saturating_add(cd.virtual_methods.len())
                    })
                    .unwrap_or(0);
                // Heuristic: 80 tokens per method.  saturating_mul avoids
                // overflow on pathological method counts (saturates to
                // usize::MAX rather than wrapping or panicking).
                let estimated_output_tokens: usize = method_count.saturating_mul(80);
                matches.push(json!({
                    "layer": layer,
                    "class_index": global_idx,
                    "class_descriptor": descriptor,
                    "estimated_method_count": method_count,
                    "estimated_output_tokens": estimated_output_tokens,
                }));
            }
            global_idx = global_idx.saturating_add(1);
        }
    }

    let count = matches.len();
    Ok(json!({
        "classes": matches,
        "_meta": meta(
            count,
            false,
            "dry_run=true — no source emitted; tighten regex then call decompile without dry_run",
            &["decompile", "dex classes", "xrefs"],
        ),
    }))
}

/// Bulk-decompile every class across every loaded DEX. Drives
/// `droidsaw decompile --all <apk>` (and raw `.dex`). Output modes:
///
/// - **Streaming (default)**: `out_dir = None`. Writes concatenated
///   Java source to `out`, each class prefixed with a
///   `// ==class <FQN>==\n` delimiter so downstream tooling (bench
///   pitch / cross-tool diff) can split the stream by delimiter.
/// - **Directory (with `--out <dir>`)**: `out_dir = Some(p)`. Writes
///   one `.java` per class at `<p>/<slashed/package/path>/<Class>.java`;
///   creates directories as needed. Prints a summary count line to
///   `out`.
///
/// Per-class decompile errors propagate via `?` — partial emit is
/// worse than a clean Err per brief (user re-runs without `--all` to
/// isolate).
// PROOF: `class_idx.0 as usize` widens u32→usize for `.get()` bounds-checked
// lookup on type_descriptors.
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`class_idx.0 as usize` widens u32→usize for `.get()` bounds-checked lookup on type_descriptors.")]
pub fn dex_decompile_all(
    ctx: &CrossLayerContext,
    out: &mut dyn std::io::Write,
    out_dir: Option<&std::path::Path>,
) -> anyhow::Result<()> {
    if ctx.dex.is_empty() {
        anyhow::bail!("no DEX layers to decompile; this command requires an APK or raw .dex input");
    }
    let mut count = 0usize;
    for (i, dex) in ctx.dex.iter().enumerate() {
        let Some(dex_data) = ctx.dex_bytes(i) else {
            continue;
        };
        // Amortize census per-DEX for performance.
        let census = droidsaw_dex::r8_inversion::build_trampoline_census(dex);
        for (class_defs_idx, class_def) in dex.class_defs.iter().enumerate() {
            // Shadow gate: `decompile --all` against attacker
            // duplicate-class_idx DEX would emit output for both the
            // primary and shadowed row. Operator's `--all` set must
            // match the canonical first-wins resolution.
            if dex.class_def_is_shadowed(class_defs_idx) {
                continue;
            }
            let descriptor = dex
                .type_descriptors
                .get(class_def.class_idx.0 as usize)
                .cloned()
                .ok_or_else(|| {
                    anyhow::anyhow!(
                        "class_idx {} OOB in type_descriptors",
                        class_def.class_idx.0
                    )
                })?;
            let source = droidsaw_dex::classes::decompile_class_with_census(dex, dex_data, class_def, &census);
            if let Some(dir) = out_dir {
                let rel = class_file_rel_path(&descriptor);
                let full = dir.join(&rel);
                if let Some(parent) = full.parent() {
                    std::fs::create_dir_all(parent)?;
                }
                std::fs::write(&full, &source)?;
            } else {
                writeln!(out, "// ==class {descriptor}==")?;
                out.write_all(source.as_bytes())?;
                if !source.ends_with('\n') {
                    writeln!(out)?;
                }
            }
            // DISPLAY-ONLY: feeds the `{"decompiled":N,...}` summary line.
            count = count.saturating_add(1);
        }
    }
    if let Some(dir) = out_dir {
        writeln!(out, "{{\"decompiled\":{count},\"out_dir\":{:?}}}", dir.display().to_string())?;
    }
    Ok(())
}

/// Translate a JVM descriptor like `Lcom/example/Foo$Bar;` into a
/// relative file path `com/example/Foo$Bar.java`. Missing `L…;`
/// wrapping falls back to the raw descriptor (with `/` path separators
/// preserved). `$` in inner-class names stays verbatim — matches
/// jadx/javap conventions.
fn class_file_rel_path(descriptor: &str) -> std::path::PathBuf {
    let inner = descriptor
        .strip_prefix('L')
        .and_then(|s| s.strip_suffix(';'))
        .unwrap_or(descriptor);
    let mut p = std::path::PathBuf::from(inner);
    p.set_extension("java");
    p
}

pub fn hbc(cmd: HbcCommands) -> anyhow::Result<Value> {
    match cmd {
        HbcCommands::Info { path } => parse_with_diag_scope(&path, hbc_info),
        HbcCommands::Functions { path, search } => {
            parse_with_diag_scope(&path, |ctx| hbc_functions(ctx, search.as_deref()))
        }
        HbcCommands::Decompile { path, func_id, js, all } => parse_with_diag_scope(&path, |ctx| {
            // `hbc decompile` names the hbc layer explicitly: a
            // present-but-unparseable bundle is a hard typed error,
            // never a silent fall-through to the DEX layer.
            ctx.ensure_hbc_parsed()?;
            decompile(ctx, func_id.map(|id| id.to_string()).as_deref(), js, all)
        }),
        HbcCommands::Strings { path, search } => {
            parse_with_diag_scope(&path, |ctx| strings(ctx, search.as_deref(), None, None, Some("hbc")))
        }
        // Disassemble emits plain text on stdout, not a JSON envelope.
        // Routed by main.rs's Hbc dispatch arm before this match runs;
        // reaching here would be a dispatcher bug.
        HbcCommands::Disassemble { .. } => Err(anyhow::anyhow!(
            "hbc disassemble must be dispatched via hbc_disassemble_to (plain-text output, not JSON)"
        )),
    }
}

/// Write a deterministic per-function instruction stream for the loaded
/// Hermes bundle to `sink`. Plain-text output, one instruction per line.
///
/// Output shape (stable across runs on the same input + tool version):
///
/// ```text
/// function <fn-idx> <name> arity=<N> registers=<N> @ pc=<offset>
/// <pc> <opname> [<operand>...]
/// ...
/// function <fn-idx> ...
/// ...
/// ```
///
/// Operand encoding:
/// - `Reg` / `Reg32` → `r<N>`
/// - `UInt`          → `<N>` (unsigned decimal)
/// - `Int`           → `<N>` (signed decimal)
/// - `Double`        → Rust `{:?}` formatting (always emits a decimal point;
///   stable for `NaN` / `inf` / `-0.0`)
/// - `Addr`          → `<offset>` (signed decimal; relative branch displacement;
///   bench's normalizer is responsible for any absolute-target rewrite)
///
/// The `<name>` field is the raw string-table entry for the function name
/// (or `<unnamed>` if the name slot is out of range or empty). String / function
/// indices are emitted as plain decimal — bench's normalization layer tags them
/// post-hoc against the same hbc; tagging here would couple this surface to
/// schema-table changes.
///
/// Empty function bodies emit only the function header. Decode failures emit
/// the function header followed by `; decode failed: <error>` and continue
/// with the next function — partial disassembly beats a hard fail when the
/// goal is differential coverage against an external oracle.
pub fn hbc_disassemble_to<W: std::io::Write>(
    ctx: &CrossLayerContext,
    sink: &mut W,
) -> anyhow::Result<()> {
    use droidsaw_hermes::parser::UnrecognizedReason;

    let hbc_owned = ctx.require_hbc()?;
    let hbc = hbc_owned.hbc();
    let data = hbc_owned.bytes();
    let version = hbc.opcode_version();

    for fid in 0..hbc.function_count {
        let f = hbc.function_get(fid);
        let name = if f.name_id < hbc.string_count {
            let s = hbc.string_as_str_or_empty(f.name_id).into_owned();
            if s.is_empty() { String::from("<unnamed>") } else { s }
        } else {
            String::from("<unnamed>")
        };
        // Header: registers field reports `frame_size` (0 = unavailable, e.g.
        // pre-v97 small headers). Worth emitting the literal 0 so the header
        // shape is byte-stable across versions.
        writeln!(
            sink,
            "function {} {:?} arity={} registers={} @ pc={}",
            fid, name, f.param_count, f.frame_size, f.offset,
        )?;

        // A function the parser marked Unrecognized has no trustworthy
        // (offset, size): its small-header fallback offset is
        // attacker-controllable and would route a decode into header /
        // string-table bytes. Render an explicit comment marker naming the
        // reason and never slice or decode this function's body.
        if hbc.is_function_unrecognized(fid) {
            // Sorted-ascending side-set (hermes invariant) → O(log N) lookup,
            // not a linear scan inside the per-function loop (avoids O(N^2) on
            // an all-OOB-overflow file). Mirrors the hermes gate.
            let unrec = hbc.unrecognized_functions();
            match unrec
                .binary_search_by_key(&fid, |u| u.func_idx)
                .ok()
                .and_then(|pos| unrec.get(pos))
                .map(|u| u.reason)
            {
                Some(UnrecognizedReason::OverflowedHeaderOutOfBounds { large_off, buf_len }) => {
                    writeln!(
                        sink,
                        "; unrecognized — function region not decoded \
                         (overflow large-header OOB: large_off={large_off}, buf_len={buf_len})"
                    )?;
                }
                // `UnrecognizedReason` is `#[non_exhaustive]`, and the side-set
                // (not the reason lookup) is the source of truth: even when the
                // reason is absent or a future variant, the body is still
                // refused. Emit a reason-agnostic marker rather than decode.
                Some(_) | None => {
                    writeln!(sink, "; unrecognized — function region not decoded")?;
                }
            }
            continue;
        }

        // Compute the function's bytecode slice. Adversarial offsets / sizes
        // can wrap; treat overflow / out-of-range as "no body to decode" and
        // continue — the function header is already emitted as a marker.
        let Some(end) = f.offset.checked_add(f.size) else {
            writeln!(sink, "; function-body bounds overflow (offset+size)")?;
            continue;
        };
        let (Ok(start_us), Ok(end_us)) =
            (usize::try_from(f.offset), usize::try_from(end))
        else {
            writeln!(sink, "; function-body bounds out of usize range")?;
            continue;
        };
        if end_us > data.len() || start_us > end_us {
            writeln!(sink, "; function-body bounds exceed file size")?;
            continue;
        }
        let code = match data.get(start_us..end_us) {
            Some(c) => c,
            None => {
                writeln!(sink, "; function-body slice unavailable")?;
                continue;
            }
        };
        match droidsaw_hermes::decompile::decode::decode_function(code, version) {
            Ok(insts) => {
                for inst in &insts {
                    write!(sink, "{} {}", inst.offset, inst.name)?;
                    for operand in &inst.operands {
                        sink.write_all(b" ")?;
                        write_operand(sink, operand)?;
                    }
                    writeln!(sink)?;
                }
            }
            Err(e) => {
                writeln!(sink, "; decode failed: {e}")?;
            }
        }
    }
    sink.flush()?;
    Ok(())
}

fn write_operand<W: std::io::Write>(
    sink: &mut W,
    operand: &droidsaw_hermes::decompile::decode::Operand,
) -> std::io::Result<()> {
    use droidsaw_hermes::decompile::decode::Operand;
    match operand {
        Operand::Reg(r) => write!(sink, "r{r}"),
        Operand::Reg32(r) => write!(sink, "r{r}"),
        Operand::UInt(v) => write!(sink, "{v}"),
        Operand::Int(v) => write!(sink, "{v}"),
        Operand::Addr(v) => write!(sink, "{v}"),
        // {:?} on f64 always emits a decimal point and is stable for
        // NaN / inf / -0.0 — required for byte-deterministic output.
        Operand::Double(v) => write!(sink, "{v:?}"),
    }
}

/// Top-level entry point: parse `path`, install the diag scope, and write
/// the disassembly to `sink`. Pairs with `parse_with_diag_scope` so panics
/// on the parse / decode path land in a hash-named bundle just like every
/// other CLI command.
pub fn hbc_disassemble<W: std::io::Write>(
    path: &std::path::Path,
    sink: &mut W,
) -> anyhow::Result<()> {
    parse_with_diag_scope(path, |ctx| hbc_disassemble_to(ctx, sink))
}

/// Hash the input file, install the hash in `droidsaw_common::diag`'s
/// thread-local, then parse + run `f` inside that scope. Panics anywhere
/// in parse / cfg / ssa / structure / emit land in the hash-named bundle.
/// Pairs with main.rs's dispatch-level wraps — any command that parses a
/// path outside `dispatch()`'s `run()` helper goes through this.
fn parse_with_diag_scope<F, R>(path: &std::path::Path, f: F) -> anyhow::Result<R>
where
    F: FnOnce(&CrossLayerContext) -> anyhow::Result<R>,
{
    let hash = CrossLayerContext::input_hash(path)?;
    droidsaw_common::diag::with_input_hash(&hash, || {
        let ctx = CrossLayerContext::parse(path, None)?;
        f(&ctx)
    })
}

#[derive(Serialize)]
struct DexClassEntry {
    layer: String,
    name: String,
    superclass: Option<String>,
}

#[derive(Serialize)]
struct DexMethodEntry {
    layer: String,
    class: String,
    name: String,
}

// PROOF: `i + 1` is usize+1 bounded by ctx.dex.len(); struct-field indices
// widen u32→usize for `.get()` bounds-checked lookup.
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`i + 1` is usize+1 bounded by ctx.dex.len(); struct-field indices widen u32→usize for `.get()` bounds-checked lookup.")]
pub fn dex_classes(
    ctx: &CrossLayerContext,
    search: Option<&str>,
) -> anyhow::Result<Value> {
    let re = search.map(regex::Regex::new).transpose()?;
    let mut classes: Vec<DexClassEntry> = Vec::new();
    for (i, dex) in ctx.dex.iter().enumerate() {
        let layer = format!("dex{}", i + 1);
        for (class_defs_idx, cd) in dex.class_defs.iter().enumerate() {
            // Shadow gate: duplicate-class_idx rows would surface as
            // distinct DexClassEntry rows in the JSON envelope; the
            // operator's mental model is "one class_def per class_idx"
            // (matches the canonical first-wins index resolution).
            if dex.class_def_is_shadowed(class_defs_idx) {
                continue;
            }
            let name = dex
                .type_descriptors
                .get(cd.class_idx.0 as usize)
                .cloned()
                .unwrap_or_default();
            if let Some(ref re) = re
                && !re.is_match(&name)
            {
                continue;
            }
            let superclass = cd
                .superclass_idx
                .and_then(|s| dex.type_descriptors.get(s.0 as usize))
                .cloned();
            classes.push(DexClassEntry {
                layer: layer.clone(),
                name,
                superclass,
            });
        }
    }
    let count = classes.len();
    Ok(json!({
        "classes": classes,
        "_meta": meta(
            count,
            false,
            "use `droidsaw dex methods` to list methods, `droidsaw xrefs -s <name>` to find string refs",
            &["dex methods", "dex strings", "xrefs"],
        ),
    }))
}

// PROOF: `i + 1` is usize+1 bounded by ctx.dex.len(); struct-field indices
// (class_idx.0, name_idx.0) widen u32→usize for `.get()` lookup.
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`i + 1` is usize+1 bounded by ctx.dex.len(); struct-field indices (class_idx.0, name_idx.0) widen u32→usize for `.get()` lookup.")]
pub fn dex_methods(
    ctx: &CrossLayerContext,
    search: Option<&str>,
    implementations: bool,
) -> anyhow::Result<Value> {
    let re = search.map(regex::Regex::new).transpose()?;
    let mut methods: Vec<DexMethodEntry> = Vec::new();
    for (i, dex) in ctx.dex.iter().enumerate() {
        let layer = format!("dex{}", i + 1);
        if implementations {
            // Defined-method view: iterate each class's direct_methods +
            // virtual_methods (class_data_item membership). This is the
            // dual surface to `dex.methods` (the `method_ids` reference
            // table) and is comparable to jadx's declaration count.
            for (class_defs_idx, cd_def) in dex.class_defs.iter().enumerate() {
                // Shadow gate: duplicate-class_idx rows share their
                // class_data_off in the canonical case; without this
                // gate the same class_data's direct + virtual methods
                // would be emitted twice in the JSON envelope.
                if dex.class_def_is_shadowed(class_defs_idx) {
                    continue;
                }
                let Some(cd) = dex.class_datas.get(&cd_def.class_data_off) else {
                    continue;
                };
                for em in cd.direct_methods.iter().chain(cd.virtual_methods.iter()) {
                    let Some(m) = dex.methods.get(em.method_idx.0 as usize) else {
                        continue;
                    };
                    let class = dex
                        .type_descriptors
                        .get(m.class_idx.0 as usize)
                        .cloned()
                        .unwrap_or_default();
                    let name = dex
                        .strings
                        .get(m.name_idx.0 as usize)
                        .map(|e| e.as_str_lossy().to_string())
                        .unwrap_or_default();
                    let full = format!("{class}->{name}");
                    if let Some(ref re) = re
                        && !re.is_match(&full)
                    {
                        continue;
                    }
                    methods.push(DexMethodEntry {
                        layer: layer.clone(),
                        class,
                        name,
                    });
                }
            }
        } else {
            for m in &dex.methods {
                let class = dex
                    .type_descriptors
                    .get(m.class_idx.0 as usize)
                    .cloned()
                    .unwrap_or_default();
                let name = dex
                    .strings
                    .get(m.name_idx.0 as usize)
                    .map(|e| e.as_str_lossy().to_string())
                    .unwrap_or_default();
                let full = format!("{class}->{name}");
                if let Some(ref re) = re
                    && !re.is_match(&full)
                {
                    continue;
                }
                methods.push(DexMethodEntry {
                    layer: layer.clone(),
                    class,
                    name,
                });
            }
        }
    }
    let count = methods.len();
    let hint = if implementations {
        "implementations view: declared methods via class_data_item (includes abstract/native; compare against jadx)"
    } else {
        "use `droidsaw xrefs -s <name>` to find string references to a method"
    };
    Ok(json!({
        "methods": methods,
        "_meta": meta(
            count,
            false,
            hint,
            &["dex classes", "dex strings", "xrefs"],
        ),
    }))
}

pub fn dex(cmd: DexCommands) -> anyhow::Result<Value> {
    match cmd {
        DexCommands::Classes { path, search } => {
            parse_with_diag_scope(&path, |ctx| dex_classes(ctx, search.as_deref()))
        }
        DexCommands::Methods {
            path,
            search,
            implementations,
        } => parse_with_diag_scope(&path, |ctx| {
            dex_methods(ctx, search.as_deref(), implementations)
        }),
        DexCommands::Strings { path, search } => {
            parse_with_diag_scope(&path, |ctx| strings(ctx, search.as_deref(), None, None, None))
        }
    }
}

// ── yara ───────────────────────────────────────────────────────────

pub fn yara(
    ctx: &CrossLayerContext,
    rules_src: Option<&str>,
    rules_path: Option<&std::path::Path>,
    target: &str,
    limit: Option<usize>,
) -> anyhow::Result<Value> {
    use droidsaw_apk::yara_scan::{
        bundled_rules, compile_rules_str, compile_rules_str_restricted, load_rules_from_dir,
    };

    // Priority: inline source > file/dir path > bundled ruleset (credential + packer).
    // We need either an owned Rules or a &'static Rules; use a Deref wrapper
    // so the two cases can be handled uniformly below. The Owned variant is
    // boxed to keep the enum pointer-sized (Rules is ~488 bytes); indirection
    // cost is immaterial since the holder is consulted once per scan.
    enum RulesHolder {
        Owned(Box<droidsaw_apk::yara_scan::Rules>),
        Borrowed(&'static droidsaw_apk::yara_scan::Rules),
    }
    impl std::ops::Deref for RulesHolder {
        type Target = droidsaw_apk::yara_scan::Rules;
        fn deref(&self) -> &Self::Target {
            match self {
                RulesHolder::Owned(r) => r,
                RulesHolder::Borrowed(r) => r,
            }
        }
    }

    // The per-string Aho-Corasick prefilter is keyed to the bundled
    // ruleset's literal anchors; it must not gate caller-supplied rules.
    // Set at the selection site (not inferred from the holder's variant)
    // so a future borrowed-but-user-supplied ruleset cannot silently
    // re-enable the prefilter and drop caller-rule matches.
    let mut rules_are_bundled = false;
    let holder = if let Some(src) = rules_src {
        // Security: caller-supplied inline source goes through the restricted
        // compiler that disables `include` and limits `import` to the
        // MCP_ALLOWED_YARA_MODULES allowlist. See
        // `droidsaw_apk::yara_scan::compile_rules_str_restricted` for details.
        progress!("compiling inline yara rules ({:?} bytes) [restricted]", src.len());
        RulesHolder::Owned(Box::new(compile_rules_str_restricted(&[("inline", src)])?))
    } else {
        match rules_path {
            None => {
                progress!("no --rules supplied, using bundled ruleset (credential + packer)");
                let r = bundled_rules()
                    .ok_or_else(|| anyhow::anyhow!("bundled rules failed to compile"))?;
                rules_are_bundled = true;
                RulesHolder::Borrowed(r)
            }
            Some(p) => {
                if !p.exists() {
                    anyhow::bail!(
                        "rules path does not exist: {} — pass --rules <file-or-dir>",
                        p.display()
                    );
                }
                if p.is_dir() {
                    progress!("loading yara rules from directory {:?}", p);
                    RulesHolder::Owned(Box::new(load_rules_from_dir(p)?))
                } else {
                    progress!("loading yara rule file {:?}", p);
                    let src = std::fs::read_to_string(p)?;
                    let ns = p.file_stem().and_then(|s| s.to_str()).unwrap_or("user");
                    RulesHolder::Owned(Box::new(compile_rules_str(&[(ns, &src)])?))
                }
            }
        }
    };
    let rules: &droidsaw_apk::yara_scan::Rules = &holder;

    let apk = ctx.require_apk()?;
    progress!("scanning {:?} with yara-x", apk.path);

    // Bundle-aware extraction for Hermes-bytecode (HBC) assets.
    //
    // Background: Hermes bundles concatenate the string table inline at a
    // fixed offset, so a raw-byte YARA scan of the asset sees adjacent
    // strings as one byte stream. A regex anchored on `sk-` (OpenAI),
    // `key-` (Mailgun), `bot` (Telegram), etc. trips whenever string
    // entry N ends with the prefix and entry N+1 starts with the
    // continuation — a cross-string-table-boundary substring concat that
    // is not a real credential. A 43 MB Hermes bundle can produce multiple
    // cross-boundary credential FPs; every Hermes bundle in the corpus
    // inherits the same FP class.
    //
    // Fix: re-scan each HBC asset per-string-table-entry instead of as a
    // raw-byte stream. `HbcFile::parse → string_count × string_as_str(i)`
    // returns one entry's resolved bytes at a time; cross-boundary concat
    // becomes structurally impossible.
    //
    // The hermes parser is bounds-checked and graceful-empty on adversarial
    // input (per `droidsaw-hermes/src/parser.rs` audit; 780.8M fuzz inputs
    // / 0 crashes confirmed in the `fuzz-1hr-gate` close), so a malformed
    // HBC bundle parse failure falls back to the existing raw-byte scan
    // path with no behavior regression.
    //
    // Order: collect HBC paths BEFORE the apk-side scan so we can pass
    // them in as a skip list. The apk-side raw-byte pass previously
    // scanned the full HBC blob anyway and the matches were filtered
    // out below — skipping the raw-byte pass on HBC assets avoids
    // redundant PikeVM work while preserving findings parity.
    let hbc_asset_paths = collect_hbc_asset_paths(&apk.path);
    progress!(
        "yara: {:?} HBC asset paths detected for per-string-table scanning",
        hbc_asset_paths.len()
    );

    // `_yara_overflow_finding` is the cumulative-match-data cap
    // truncation marker from `apk.yara_scan_with_skip`. The MCP yara
    // command returns matches as JSON rows (not Findings), so the
    // overflow Finding is intentionally not surfaced here — the
    // `collect_findings` aggregator path is the right home for it.
    let (all, _yara_overflow_finding) = apk.yara_scan_with_skip(rules, &hbc_asset_paths);
    progress!(
        "yara produced {:?} raw matches (HBC assets skipped here, scanned per-string below: {:?})",
        all.len(),
        hbc_asset_paths.len()
    );

    // Defensive filter: with `yara_scan_with_skip(rules, &hbc_asset_paths)`
    // the apk-side raw pass never produces HBC-asset matches in the first
    // place, so this is a no-op on the well-formed path. Kept as a guard
    // against future asset-targeting changes that might re-introduce
    // raw-byte HBC matches (e.g. a new caller that bypasses the skip
    // list, or a scan_bytes_with_scanner_budgeted path that synthesises
    // an `asset:` target out of band).
    let mut bundle_aware: Vec<_> = all
        .into_iter()
        .filter(|m| !is_hbc_asset_match(&m.target, &hbc_asset_paths))
        .collect();

    for hbc_path in &hbc_asset_paths {
        let per_string = scan_hbc_per_string(rules, &apk.path, hbc_path, rules_are_bundled);
        progress!(
            "yara: hermes-bundle {:?} produced {:?} per-string matches",
            hbc_path,
            per_string.len()
        );
        bundle_aware.extend(per_string);
    }

    let filtered: Vec<_> = match target {
        "all" => bundle_aware,
        "manifest" | "dex" | "resources" | "native" | "assets" => bundle_aware
            .into_iter()
            .filter(|m| yara_target_matches(target, &m.target))
            .collect(),
        other => anyhow::bail!(
            "--target must be one of manifest|dex|resources|native|assets|all (got {other:?})"
        ),
    };

    let (truncated, matches) = match limit {
        Some(n) if filtered.len() > n => (true, filtered.into_iter().take(n).collect::<Vec<_>>()),
        _ => (false, filtered),
    };
    let count = matches.len();

    let rows: Vec<Value> = matches
        .iter()
        .map(|m| {
            let metadata: serde_json::Map<String, Value> = m
                .metadata
                .iter()
                .map(|(k, v)| (k.clone(), Value::String(v.clone())))
                .collect();
            json!({
                "rule": m.rule,
                "namespace": m.namespace,
                "target": m.target,
                "tags": m.tags,
                "metadata": metadata,
            })
        })
        .collect();

    let out = json!({
        "matches": rows,
        "_meta": meta(
            count,
            truncated,
            "pass --rules <.yar|.yara|dir>; scope with --target manifest|dex|resources|native|assets|all",
            &["strings", "audit", "sbom", "trufflehog"],
        ),
    });
    Ok(out)
}

/// 4-byte Hermes bytecode magic prefix.
///
/// Reproduced here (canonical value also at `droidsaw-apk/src/apk.rs::HERMES_MAGIC`
/// and the 8-byte form at `droidsaw/src/context.rs::CrossLayerContext::HBC_MAGIC`)
/// so this module can sniff HBC bundles without opening up a private
/// constant in `droidsaw-apk`. Unchanged across every documented HBC
/// version (v40–v100); swapping it would be a breaking change to the
/// Hermes bytecode file format itself.
const HERMES_MAGIC_4: [u8; 4] = [0xc6, 0x1f, 0xbc, 0x03];

/// Walk the APK ZIP and return paths of asset entries whose first four
/// bytes are the Hermes magic. Used by [`yara`] to drive bundle-aware
/// per-string-table scanning.
///
/// Errors are swallowed silently because this is a best-effort
/// classification step: if the APK can't be re-opened (concurrent
/// deletion, permissions race), we return an empty vec and the YARA
/// result simply degrades to the existing raw-byte scan output. The
/// asset loop in [`droidsaw_apk::Apk::yara_scan`] already iterates over
/// the same entries, so any persistent I/O failure here would have
/// already degraded the upstream raw-scan path identically.
///
/// Bounds-checked: each entry's read is capped at 4 bytes for the magic
/// sniff. The full asset is only re-read inside [`scan_hbc_per_string`]
/// after magic confirms the entry is a Hermes bundle.
fn collect_hbc_asset_paths(apk_path: &str) -> Vec<String> {
    use std::io::Read;

    let Ok(file) = std::fs::File::open(apk_path) else {
        return Vec::new();
    };
    let Ok(mut archive) = zip::ZipArchive::new(file) else {
        return Vec::new();
    };

    let mut out = Vec::new();
    for i in 0..archive.len() {
        let Ok(mut entry) = archive.by_index(i) else {
            continue;
        };
        let name = entry.name().to_string();
        // Mirror the asset/non-asset classifier from
        // `droidsaw-apk/src/yara_scan.rs::Apk::yara_scan` so we only
        // shadow entries the upstream scanner actually scanned. Native
        // libs are excluded — they can't be Hermes bundles.
        let is_so = name.starts_with("lib/") && name.ends_with(".so");
        let is_asset = name.starts_with("assets/")
            || (!is_so
                && !name.ends_with(".dex")
                && !name.ends_with(".arsc")
                && !name.ends_with(".xml")
                && !name.starts_with("META-INF/"));
        if !is_asset {
            continue;
        }
        let mut head = [0u8; 4];
        // `read_exact` returns `UnexpectedEof` on short reads; we treat
        // anything < 4 bytes as "definitely not HBC" without surfacing
        // the error.
        if entry.read_exact(&mut head).is_err() {
            continue;
        }
        if head == HERMES_MAGIC_4 {
            out.push(name);
        }
    }
    out
}

/// Returns true if `match_target` is the raw-byte `asset:<path>` shape
/// for any asset path in `hbc_paths`. Used to filter raw-scan matches
/// that came from a Hermes bundle's concatenated string table — those
/// are replaced with per-string matches.
///
/// Note: the `hermes-fallback-raw:<path>` target shape is NOT filtered
/// here. Those matches come from the parse-failure fallback inside
/// `scan_hbc_per_string` — the bundle-aware per-string scan didn't run
/// (parse failed), so the fallback's raw-byte matches are the only
/// signal we have for that asset and must survive the filter step.
/// Downstream consumers (provenance gates, write_credentials_db) can
/// identify and downgrade them by their distinguishable target prefix.
fn is_hbc_asset_match(match_target: &str, hbc_paths: &[String]) -> bool {
    let Some(stripped) = match_target.strip_prefix("asset:") else {
        return false;
    };
    hbc_paths.iter().any(|p| p == stripped)
}

/// Re-read an asset by name and run YARA per-string-table-entry against
/// its Hermes bytecode contents.
///
/// Each match's `target` is `hermes-string:<asset-path>:<index>` so
/// downstream consumers can attribute the hit to (a) the bundle source
/// and (b) the specific string-table entry it came from.
///
/// Falls back to a raw-byte scan against `hermes-fallback-raw:<path>`
/// when the bundle fails to parse — preserves the without the guard detection
/// contract on adversarial / corrupt HBC inputs while tagging the
/// matches distinguishably so downstream consumers (provenance gates,
/// write_credentials_db) can identify them and downgrade. The fallback
/// re-introduces the cross-boundary FP class for that one asset; the
/// distinguishable tag is what lets a future suppression pass surface
/// the leak without re-introducing the FP across all bundles.
/// `HbcFile::parse` is the typed-Err parser entry; `string_as_str(i)`
/// is graceful-empty on out-of-bounds / malformed entries (verified
/// non-panicking by the hermes-side fuzz corpus).
///
/// Constructs the `yara_x::Scanner` ONCE at function entry and reuses
/// it across every per-string iteration via `scan_bytes_with_scanner`.
/// yara-x's `Scanner` is designed to be reused; per-call construction
/// was the dominant cost on adversarial bundles producing N small
/// strings. One Scanner construction per asset, not N.
fn scan_hbc_per_string(
    rules: &droidsaw_apk::yara_scan::Rules,
    apk_path: &str,
    hbc_asset_path: &str,
    bundled_anchor_prefilter: bool,
) -> Vec<droidsaw_apk::yara_scan::YaraMatch> {
    // RAII drain guard: replaces the pre-existing entry-only discard
    // that this function used to do. The prior pattern (discard at
    // entry only) closed parse-time emit but missed per-string-loop
    // emits (`hbc.string_as_str_or_empty` calls `string_get` which
    // can fire `OverflowIndexOutOfRange`). RAII Drop fires after the
    // loop on every exit path — guarantees a clean channel for the
    // next tenant on the same blocking-pool worker.
    let _drain_guard = crate::context::HermesFindingDrainGuard::install_discard();

    use std::io::Read;
    use droidsaw_apk::yara_scan::scan_bytes_with_scanner;

    let Ok(file) = std::fs::File::open(apk_path) else {
        return Vec::new();
    };
    let Ok(mut archive) = zip::ZipArchive::new(file) else {
        return Vec::new();
    };
    let Ok(mut entry) = archive.by_name(hbc_asset_path) else {
        return Vec::new();
    };
    // Cap parity with `droidsaw-apk/src/yara_scan.rs::YARA_ENTRY_MAX`
    // (128 MiB). HBC bundles >128 MiB are out-of-scope for credential
    // YARA rules; the cap prevents a 1 GiB-deflated asset from
    // unrolling into a 1 GiB Vec on the audit-command path. Without
    // this cap, the call was only reachable from `commands::yara`
    // (operator-initiated); the F1 lift makes it reachable from every
    // `droidsaw audit <apk>` invocation so the same per-entry cap that
    // the apk-side asset loop enforces applies here too.
    const HBC_ENTRY_MAX: u64 = 128 * 1024 * 1024;
    let mut buf = Vec::new();
    if entry.by_ref().take(HBC_ENTRY_MAX).read_to_end(&mut buf).is_err() {
        return Vec::new();
    }
    if buf.is_empty() {
        return Vec::new();
    }

    // One Scanner per asset, reused across the parse-failure fallback
    // OR the per-string loop. Both branches scan the same rule set; the
    // construction cost amortizes over either N raw-byte scans (just 1)
    // or N per-string scans.
    let mut scanner = droidsaw_apk::yara_scan::Scanner::new(rules);

    // Parse the bundle. On parse failure we conservatively fall back to
    // the raw-byte scan path so we don't regress detection on a
    // malformed-but-credential-bearing bundle. The fallback target uses
    // a distinguishable `hermes-fallback-raw:` prefix so downstream
    // gates can identify and downgrade these matches (the cross-string
    // boundary FP is structurally unavoidable when parse fails).
    // Secondary HbcFile::parse outside `CrossLayerContext::parse`.
    // Parse-time emit AND per-string-loop emits below are both swept
    // by the function-entry RAII guard's Drop. No explicit drain
    // needed mid-function.
    let hbc = match droidsaw_hermes::parser::HbcFile::parse(&buf, None) {
        Ok(h) => h,
        Err(_) => {
            return scan_bytes_with_scanner(
                &mut scanner,
                &format!("hermes-fallback-raw:{}", hbc_asset_path),
                &buf,
            );
        }
    };

    let mut matches = Vec::new();
    for i in 0..hbc.string_count {
        let s = hbc.string_as_str_or_empty(i);
        if s.is_empty() {
            // Empty entry can't carry a credential; skip the YARA
            // round-trip cost. (Matches what the raw-byte scan would
            // yield for a zero-length window.) The
            // `string_as_str_or_empty` helper folds typed `Err`
            // (corrupt entry) and `Ok(None)` (out-of-range) and
            // `Ok(Some(empty))` into a single `""` here — the typed
            // signal stays observable via the `HermesFinding`
            // side-channel that `string_get` still emits.
            continue;
        }
        // Aho-Corasick prefilter over the literal anchors of every
        // bundled YARA rule with an extractable prefix — see
        // `droidsaw_apk::yara_scan::string_contains_credential_prefix`
        // for the parity contract. Strings the prefilter rejects cannot
        // match any anchored rule; pikevm would report zero hits, so
        // skipping the dispatch preserves findings parity. Documented
        // exceptions (`discord_bot_token`, `telegram_bot_token`,
        // `twilio_account_sid`) begin with non-literal character classes
        // and are bypassed; corpus baselines observed zero
        // `hermes-string:` matches for any of them.
        //
        // The parity contract holds ONLY for the bundled ruleset:
        // caller-supplied rules (`yara --rules` / inline source) have
        // arbitrary anchors the credential-prefix automaton knows
        // nothing about, so for those every non-empty string must reach
        // the scanner — the prefilter would silently drop their matches.
        if bundled_anchor_prefilter
            && !droidsaw_apk::yara_scan::string_contains_credential_prefix(&s)
        {
            continue;
        }
        let target = format!("hermes-string:{}:{}", hbc_asset_path, i);
        matches.extend(scan_bytes_with_scanner(&mut scanner, &target, s.as_bytes()));
    }
    matches
}

fn yara_target_matches(filter: &str, match_target: &str) -> bool {
    match filter {
        "manifest" => match_target == "manifest",
        "dex" => match_target.starts_with("dex:"),
        "resources" => match_target.starts_with("resources.arsc"),
        "native" => match_target.starts_with("so:"),
        // Three asset-family targets:
        // - `asset:<path>` — non-HBC asset bytes (raw scan from apk).
        // - `hermes-string:<path>:<i>` — bundle-aware per-string match.
        // - `hermes-fallback-raw:<path>` — HBC parse failed; raw bytes
        //   scanned. Tagged distinguishably so downstream provenance gates
        //   can downgrade (the cross-string-boundary FP class is
        //   structurally re-introduced for that one asset).
        "assets" => {
            match_target.starts_with("asset:")
                || match_target.starts_with("hermes-string:")
                || match_target.starts_with("hermes-fallback-raw:")
        }
        _ => false,
    }
}

// ── audit ──────────────────────────────────────────────────────────

// PROOF: `i + 1` dex-layer label in progress! format; bounded by ctx.dex.len().
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`i + 1` dex-layer label in progress! format; bounded by ctx.dex.len().")]
/// Build the `detectors` map surfaced in the audit JSON output.
///
/// Goal: honesty about what actually ran. Each detector entry carries a
/// `status` string the operator can grep; `binary_on_path` (where
/// relevant) is the PATH-detection result; `note` is a one-liner
/// explaining the status.
///
/// `mode = AuditMode::Basic` — only the always-on detectors (parser-
/// side findings + bundled YARA) ran. Both subprocess detectors are
/// tagged `"skipped_by_mode"`.
///
/// `mode = AuditMode::Full` (CLI semantics today) — basic plus
/// semgrep source EXTRACTION. The semgrep subprocess itself runs only
/// on the MCP path or via the operator's manual invocation of the
/// `command` hint; the CLI surfaces the hint and reports `status =
/// "extracted"`. Trufflehog subprocess invocation is MCP-only today
/// Trufflehog status is derived from `trufflehog_result`, the envelope
/// the shared helper returned (or `None` when the mode skipped the
/// trufflehog run entirely):
///
/// - `Some({"ran": true, ...})` → `"ran"`, with `hit_count` /
///   `verified_count` / `credentials_written` lifted to the status
///   payload so a downstream agent can grep the field without
///   re-parsing the full audit body.
/// - `Some({"ran": false, "error": ...})` → `"error"` plus the
///   subprocess error string.
/// - `Some({"ran": false, "strings_file": ..., "command": ...})` →
///   `"binary_missing"` when trufflehog is absent from `$PATH`, else
///   `"no_strings_extracted"` (input layer had nothing ≥ min_length).
/// - `None` → `"skipped_by_mode"`.
///
/// `binary_on_path` consults `$PATH` once per call. Cheap; the answer
/// can drift over a long-running process but that's not a concern for
/// the CLI's one-shot audit lifecycle.
fn build_detectors_status(
    mode: droidsaw_cli_contract::AuditMode,
    trufflehog_result: Option<&Value>,
) -> Value {
    let semgrep_on_path = which_binary_in_path("semgrep");
    let trufflehog_on_path = which_binary_in_path("trufflehog");

    let yara = json!({
        "status": "ran",
        "note": "bundled credential / packer / crypto / antianalysis rules; \
                 Aho-Corasick prefilter on per-string HBC scan; \
                 results coalesced by (rule, asset)",
    });

    let semgrep = if mode.runs_semgrep() {
        json!({
            "status": "extracted",
            "binary_on_path": semgrep_on_path,
            "note": if semgrep_on_path {
                "source extracted; run the returned `command` hint to scan, \
                 or use the MCP `audit` tool for end-to-end subprocess invocation"
            } else {
                "source extracted; `semgrep` binary NOT on PATH — \
                 install semgrep, then run the returned `command` hint"
            },
        })
    } else {
        json!({
            "status": "skipped_by_mode",
            "mode": mode.as_cli_str(),
            "binary_on_path": semgrep_on_path,
            "note": "pass `--mode=full` or `--mode=semgrep` to extract source for semgrep",
        })
    };

    let trufflehog = match trufflehog_result {
        Some(r) if r.get("ran").and_then(|b| b.as_bool()).unwrap_or(false) => {
            json!({
                "status": "ran",
                "binary_on_path": trufflehog_on_path,
                "hit_count": r.get("hit_count").cloned().unwrap_or(json!(0)),
                "verified_count": r.get("verified_count").cloned().unwrap_or(json!(0)),
                "credentials_written": r.get("credentials_written").cloned().unwrap_or(json!(0)),
                "note": "credentials persisted to per-input ./droidsaw-<stem>.db; \
                         query with `droidsaw query credentials`",
            })
        }
        Some(r) if r.get("error").is_some() => {
            json!({
                "status": "error",
                "binary_on_path": trufflehog_on_path,
                "error": r.get("error").cloned().unwrap_or(Value::Null),
                "note": "trufflehog subprocess invocation failed; see error field",
            })
        }
        Some(_) if !trufflehog_on_path => {
            json!({
                "status": "binary_missing",
                "binary_on_path": false,
                "note": "trufflehog binary NOT on PATH — install trufflehog, \
                         then re-run with `--mode=full` or `--mode=trufflehog`",
            })
        }
        Some(_) => {
            json!({
                "status": "no_strings_extracted",
                "binary_on_path": trufflehog_on_path,
                "note": "no strings ≥ min_length extracted from input layers; \
                         nothing for trufflehog to consume",
            })
        }
        None => {
            json!({
                "status": "skipped_by_mode",
                "mode": mode.as_cli_str(),
                "binary_on_path": trufflehog_on_path,
                "note": "pass `--mode=full` or `--mode=trufflehog` to invoke \
                         the trufflehog subprocess",
            })
        }
    };

    json!({
        "yara": yara,
        "semgrep": semgrep,
        "trufflehog": trufflehog,
    })
}

/// Cheap `$PATH` walk. Mirrors `crate::semgrep::run::which_in_path`,
/// reimplemented here so the basic-mode `audit_light` path doesn't
/// pull in the semgrep::run module.
fn which_binary_in_path(name: &str) -> bool {
    std::env::var_os("PATH")
        .map(|path| std::env::split_paths(&path).any(|dir| dir.join(name).is_file()))
        .unwrap_or(false)
}

/// Back-compat wrapper: callers that don't carry an explicit
/// `AuditMode` get the `Basic` detector-status surface (no semgrep
/// extraction, no trufflehog). Equivalent to the prior signature; the
/// MCP audit path constructs its own envelope and doesn't consume this.
pub fn audit_light(ctx: &CrossLayerContext, entropy_threshold: f32) -> anyhow::Result<Value> {
    audit_light_with_mode(ctx, entropy_threshold, droidsaw_cli_contract::AuditMode::Basic)
}

/// Mode-aware audit-light. When `mode.runs_trufflehog()` is true
/// (`--mode=trufflehog` or `--mode=full`), this fn invokes the shared
/// [`crate::trufflehog::run::run_and_persist`] helper and merges its
/// envelope under a top-level `"trufflehog"` key. Credentials persist
/// to `./droidsaw-<stem>.db` next to the working directory, mirroring
/// `scan_semgrep --persist` precedent. Other modes leave the
/// `"trufflehog"` key absent and the `detectors.trufflehog.status`
/// field reports `"skipped_by_mode"`.
// PROOF: `i + 1` dex-layer label in progress! format; bounded by ctx.dex.len().
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "`i + 1` dex-layer label in progress! format; bounded by ctx.dex.len().")]
pub fn audit_light_with_mode(
    ctx: &CrossLayerContext,
    entropy_threshold: f32,
    mode: droidsaw_cli_contract::AuditMode,
) -> anyhow::Result<Value> {
    let mut findings = collect_apk_findings(ctx, entropy_threshold);

    // Augment with high-entropy HBC/DEX strings as Info-severity findings
    // so they show up in the unified envelope. We don't promote these to
    // full severity — they're leads, not confirmed secrets — so any agent
    // that wants signal chains into `trufflehog` or `strings -s <regex>`.
    if let Some(hbc_owned) = ctx.hbc.as_ref() {
        let hbc = hbc_owned.hbc();
        let hbc_data = hbc_owned.bytes();
        let _ = droidsaw_hermes::scanner::scan_parsed(hbc, hbc_data);
        for i in 0..hbc.string_count {
            let s = hbc.string_as_str_or_empty(i);
            let ent = shannon_entropy(s.as_bytes());
            if ent >= entropy_threshold {
                progress!(
                    "hbc high-entropy string #{i:?}: entropy={ent:.3} value={s:?}"
                );
            }
        }
    }

    for (i, dex) in ctx.dex.iter().enumerate() {
        for (j, entry) in dex.strings.iter().enumerate() {
            // Entropy is computed on raw bytes — the scan-safe view.
            // The display rendering uses `as_str_lossy()` for human
            // readability.
            let ent = shannon_entropy(entry.raw_bytes());
            if ent >= entropy_threshold {
                let s = entry.as_str_lossy();
                progress!(
                    "dex{:?} high-entropy string #{j:?}: entropy={ent:.3} value={s:?}",
                    i + 1
                );
            }
        }
    }

    // Sort: derived Ord on Severity has Critical < High < ... < Info, so
    // ascending sort puts the most severe first.
    findings.sort_by(|a, b| a.severity.cmp(&b.severity).then_with(|| a.id.cmp(&b.id)));

    let trufflehog_result: Option<Value> = if mode.runs_trufflehog() {
        let input = std::path::PathBuf::from(&ctx.path);
        let stem = input
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or("unknown");
        let db_path = std::path::PathBuf::from(format!("./droidsaw-{stem}.db"));
        Some(crate::trufflehog::run::run_and_persist(
            ctx,
            crate::trufflehog::run::DEFAULT_MIN_LENGTH,
            &db_path,
            None,
            None,
        )?)
    } else {
        None
    };

    let count = findings.len();

    // Build severity_summary from the inline finding list.
    let mut severity_summary = std::collections::BTreeMap::<String, u64>::new();
    for f in &findings {
        let c = severity_summary.entry(format!("{:?}", f.severity)).or_insert(0);
        *c = c.saturating_add(1);
    }

    // Derive per-APK shape stats from the CrossLayerContext.
    // APK path: dex bytes live on apk.dex[i].data; HBC bytes on hbc.bytes().
    // Standalone .dex path: dex bytes live on dex_direct_bytes (always one entry).
    // Standalone .hbc path: has_hbc = true, dex_count = 0.
    // None is emitted when the input had neither APK container nor standalone DEX/HBC.
    //
    // dex_methods_total / dex_classes_total walk ctx.dex (the parsed DexFiles, populated
    // for both APK and standalone .dex paths). dex_methods_total sums direct + virtual
    // method definitions across every parsed class_data — matches the jadx
    // `methods_emitted` semantic, distinct from the broader `method_ids` pool count.
    // hbc_function_count reads HbcFile.function_count (a parsed-header cached projection).
    let apk_summary: Option<audit_envelope::ApkSummary> = {
        let has_hbc = ctx.hbc.is_some();
        let hbc_bytes: u64 = ctx
            .hbc
            .as_ref()
            .map(|h| h.bytes().len())
            .unwrap_or(0)
            .try_into()
            .unwrap_or(u64::MAX);
        let hbc_function_count: u32 = ctx
            .hbc
            .as_ref()
            .map(|h| h.hbc().function_count)
            .unwrap_or(0);
        let dex_methods_total: u64 = ctx.dex.iter().fold(0u64, |acc, df| {
            let per_dex: u64 = df.class_datas.values().fold(0u64, |a, cd| {
                a.saturating_add(cd.direct_methods.len() as u64)
                    .saturating_add(cd.virtual_methods.len() as u64)
            });
            acc.saturating_add(per_dex)
        });
        let dex_classes_total: u64 = ctx
            .dex
            .iter()
            .fold(0u64, |acc, df| acc.saturating_add(df.class_defs.len() as u64));

        if let Some(apk) = ctx.apk.as_ref() {
            // APK / AAB / XAPK container input.
            let dex_count = apk.dex.len().try_into().unwrap_or(u32::MAX);
            let dex_total_bytes: u64 = apk
                .dex
                .iter()
                .fold(0u64, |acc, entry| acc.saturating_add(entry.data.len() as u64));
            Some(audit_envelope::ApkSummary {
                has_hbc,
                hbc_bytes,
                hbc_function_count,
                dex_count,
                dex_total_bytes,
                dex_methods_total,
                dex_classes_total,
            })
        } else if !ctx.dex.is_empty() {
            // Standalone .dex input — bytes in dex_direct_bytes (single entry).
            let dex_count = ctx.dex.len().try_into().unwrap_or(u32::MAX);
            let dex_total_bytes: u64 = ctx
                .dex_direct_bytes
                .as_ref()
                .map(|b| b.len() as u64)
                .unwrap_or(0);
            Some(audit_envelope::ApkSummary {
                has_hbc,
                hbc_bytes,
                hbc_function_count,
                dex_count,
                dex_total_bytes,
                dex_methods_total,
                dex_classes_total,
            })
        } else if has_hbc {
            // Standalone .hbc input — no DEX entries.
            Some(audit_envelope::ApkSummary {
                has_hbc,
                hbc_bytes,
                hbc_function_count,
                dex_count: 0,
                dex_total_bytes: 0,
                dex_methods_total: 0,
                dex_classes_total: 0,
            })
        } else {
            None
        }
    };

    // Build canonical AuditEnvelope — the ONE shared shape for all audit paths.
    // Doc on run_core_audit_blocking (MCP path): both adapters must produce this
    // shape; intentional divergence (inline findings vs DB path) is in which
    // optional fields are populated, not in the struct shape itself.
    let taint_flow_count =
        audit_envelope::AuditEnvelope::count_taint_flow_findings(&findings);
    // Gauge-stratified severity histogram + Semantic-first capped projection,
    // both built from the same `findings` slice that fed `severity_summary`
    // above, through the canonical helpers so the shape cannot drift from the
    // MCP path. Computed before `findings` is moved into the envelope.
    let severity_by_gauge = audit_envelope::AuditEnvelope::stratify_by_gauge(&findings);
    let top_findings = audit_envelope::AuditEnvelope::rank_top_findings(
        &findings,
        audit_envelope::TOP_FINDINGS_CAP,
    );
    let envelope = audit_envelope::AuditEnvelope {
        schema_version: audit_envelope::AUDIT_ENVELOPE_VERSION,
        findings,
        finding_count: count as u64,
        findings_emitted: count as u64,
        taint_flow_count,
        severity_summary,
        severity_by_gauge,
        top_findings,
        truncated: false,
        db_path: None,
        db_queries: None,
        finding_xrefs_written: None,
        detectors: Some(build_detectors_status(mode, trufflehog_result.as_ref())),
        trufflehog: trufflehog_result,
        semgrep: None,
        timings_ms: None,
        apk_summary,
        meta: audit_envelope::AuditMeta {
            count: count as u64,
            truncated: false,
            hint: "filter by severity via `jq '.findings[] | select(.severity==\"High\")'`; \
                   pair with `export` to query in sqlite"
                .to_string(),
            related: vec![
                "scan-corpus".to_string(),
                "export".to_string(),
                "manifest".to_string(),
                "signing".to_string(),
            ],
            thread_pool_size: rayon::current_num_threads(),
        },
    };
    Ok(serde_json::to_value(&envelope)?)
}

/// Audit including semgrep source extraction. Runs `audit_light` then appends
/// the semgrep extraction result under a `semgrep` key. Extracts to a temp dir
/// named after the APK; callers can pass `output` to pin the location.
///
/// The CLI surface extracts source for semgrep and emits a `command` hint —
/// it does NOT invoke the semgrep subprocess itself; that lives on the
/// MCP path. The `detectors.semgrep.status` field reports `"extracted"`
/// (or `"extracted"` with `binary_on_path: false` when semgrep isn't
/// installed). Trufflehog runs end-to-end via
/// [`crate::trufflehog::run::run_and_persist`] (invoked from
/// `audit_light_with_mode` when `mode.runs_trufflehog()`); the
/// `detectors.trufflehog.status` field reports the helper's actual
/// outcome (`ran` / `binary_missing` / `no_strings_extracted` / `error`).
pub fn audit_full(
    ctx: &CrossLayerContext,
    entropy_threshold: f32,
    output: Option<&std::path::Path>,
    semgrep_args: &crate::semgrep::SemgrepArgs,
) -> anyhow::Result<Value> {
    audit_full_with_mode(
        ctx,
        entropy_threshold,
        output,
        semgrep_args,
        droidsaw_cli_contract::AuditMode::Full,
    )
}

/// Mode-aware audit-full. The CLI `--mode=semgrep` invocation flows
/// through this fn (since the audit-full extraction step is the
/// only semgrep-relevant work the CLI does today) — the `mode`
/// parameter ensures the `detectors` field reports
/// `--mode=semgrep` accurately (semgrep extracted, trufflehog
/// skipped_by_mode) instead of always reporting Full.
pub fn audit_full_with_mode(
    ctx: &CrossLayerContext,
    entropy_threshold: f32,
    output: Option<&std::path::Path>,
    semgrep_args: &crate::semgrep::SemgrepArgs,
    mode: droidsaw_cli_contract::AuditMode,
) -> anyhow::Result<Value> {
    let mut out = audit_light_with_mode(ctx, entropy_threshold, mode)?;
    let sg = semgrep(ctx, output, semgrep_args)?;
    if let Some(obj) = out.as_object_mut() {
        obj.insert("semgrep".into(), sg);
    }
    Ok(out)
}

/// Public entry point: collect all findings across every layer and sort
/// by severity. Used by both the CLI `audit-light` command and the MCP handler
/// (which writes results to SQLite rather than serialising into context).
pub fn collect_findings(ctx: &CrossLayerContext, entropy_threshold: f32) -> anyhow::Result<Vec<Finding>> {
    let mut findings = collect_apk_findings(ctx, entropy_threshold);
    findings.sort_by(|a, b| a.severity.cmp(&b.severity).then_with(|| a.id.cmp(&b.id)));
    Ok(findings)
}

/// Per-(call, kind) cap on Finding-bearing walker outputs. Defends
/// the audit envelope against adversarial-input DoS: a DEX with N
/// malformed `class_data` items produces N `*_INDETERMINATE` findings;
/// without a cap an attacker can balloon the JSON envelope to multi-GB
/// and OOM the consumer pipeline. 256 entries surface enough signal
/// for triage; the truncation Finding tells the operator how many
/// were dropped so they can re-run with a higher cap (or examine the
/// raw `parse_errors` if they need full enumeration).
const FINDING_CAP_PER_KIND: usize = 256;

/// Apply [`FINDING_CAP_PER_KIND`] to a Finding-bearing Vec. If `v.len()`
/// exceeds the cap, truncate and append a synthetic `*_TRUNCATED`
/// Finding describing how many entries were dropped + which kind +
/// the originating layer. `id` should be the truncation-Finding id
/// (e.g., `"DEX_DETECTOR_INDETERMINATE_TRUNCATED"`); `layer` the layer
/// the original Findings belong to.
/// Strip control bytes that would otherwise reach a terminal via the
/// stderr surfacing path or a log pipeline. APK ZIP entry names are
/// attacker-controlled; an entry named `evil.apk\x1b[2J` propagated
/// verbatim into a Finding.detail would clear the operator's terminal
/// when the audit summary printed. Sanitize at the boundary between
/// untrusted bytes and the operator-facing channel.
///
/// Allows printable ASCII (0x20-0x7E) + tab/newline; replaces anything
/// else with `?`. This is conservative — it does not preserve UTF-8
/// content — but the audit-envelope JSON path uses serde_json which
/// escapes high bytes anyway; the stderr human-readable path is the
/// vulnerable one, and ZIP entry names are ASCII in compliant APKs.
fn sanitize_detail(s: &str) -> String {
    s.chars()
        .map(|c| {
            if c == '\t' || c == '\n' || (u32::from(c) >= 0x20 && u32::from(c) <= 0x7E) {
                c
            } else {
                '?'
            }
        })
        .collect()
}

fn cap_findings(mut v: Vec<Finding>, id: &'static str, layer: Layer) -> Vec<Finding> {
    if v.len() > FINDING_CAP_PER_KIND {
        let dropped = v.len().saturating_sub(FINDING_CAP_PER_KIND);
        v.truncate(FINDING_CAP_PER_KIND);
        let detail = format!(
            "truncated to {FINDING_CAP_PER_KIND} entries; {dropped} additional Findings of the same kind dropped to defend the audit envelope against unbounded-input DoS"
        );
        let mut trunc = Finding::new(id, layer, Severity::Info, detail);
        trunc.confidence = droidsaw_common::finding::Confidence::Verified;
        v.push(trunc);
    }
    v
}

/// Default HBC high-entropy string threshold (bits/char). Matches the
/// CLI `audit --entropy` default and the prior hardcoded constant that
/// was silently overriding `collect_findings`'s ignored parameter.
pub const DEFAULT_ENTROPY_THRESHOLD_BITS: f32 = 4.5;

/// Convert a Java-style class form string into a (descriptor, dot_form)
/// pair for dex-xref lookup. Inputs handled:
///
/// - Bare class names (`DexClassLoader`) — mapped to canonical
///   `Ldalvik/system/DexClassLoader;` / `dalvik.system.DexClassLoader`
///   via the small static table below. The detector at
///   `droidsaw-apk/src/dex.rs::scan_strings_full` only matches a closed
///   set of `dalvik.system.*` classes plus literal `loadLibrary`, so
///   the table covers every gateable input without speculative
///   guessing.
/// - Dot-form FQCNs (`dalvik.system.DexClassLoader`) — descriptor
///   derived by wrapping `L` … `;` and replacing `.` with `/`.
/// - Descriptor form (`Ldalvik/system/DexClassLoader;`) — dot-form
///   derived by stripping the wrapper and replacing `/` with `.`.
///
/// Returns `None` when the string is not a class-load token (e.g. the
/// `loadLibrary`/`System` substring match — that pattern is method
/// invocation and the xref-gate does not constrain it).
fn classify_dynamic_loading_token(s: &str) -> Option<(String, String)> {
    // (descriptor, dot_form) pairs the apk-layer detector can match.
    // Keep in sync with `droidsaw-apk/src/dex.rs::scan_strings_full`'s
    // dynamic-loading branch.
    const KNOWN: &[(&str, &str)] = &[
        ("Ldalvik/system/DexClassLoader;", "dalvik.system.DexClassLoader"),
        ("Ldalvik/system/PathClassLoader;", "dalvik.system.PathClassLoader"),
        (
            "Ldalvik/system/InMemoryDexClassLoader;",
            "dalvik.system.InMemoryDexClassLoader",
        ),
    ];

    // Exact match against either form in the known table. Covers
    // `dalvik.system.DexClassLoader` (string literally appears in the
    // pool — the typical R8/Kotlin metadata case) and the rare
    // descriptor-form pool entry.
    for (desc, dot) in KNOWN {
        if s == *desc || s == *dot {
            return Some(((*desc).to_string(), (*dot).to_string()));
        }
    }

    // Bare class name (`DexClassLoader`, `PathClassLoader`,
    // `InMemoryDexClassLoader`). The detector matches via
    // `s.contains(...)`, but a bare-substring hit on a longer string
    // would have been caught above by the exact-match branch on the
    // dot-form (since the longer string IS the dot-form). What
    // remains here is the case where the pool entry is just the bare
    // class name with no package — uncommon but legal.
    for (desc, dot) in KNOWN {
        // Strip "dalvik.system." prefix to recover the bare class
        // name; if `s` matches that, the canonical descriptor pair is
        // the (desc, dot) row.
        let bare = dot.rsplit('.').next().unwrap_or(dot);
        if s == bare {
            return Some(((*desc).to_string(), (*dot).to_string()));
        }
    }

    // Generic descriptor form `Lfoo/bar/Baz;` — derive dot-form.
    if s.starts_with('L') && s.ends_with(';') && s.len() >= 3 {
        let inner = s.get(1..s.len().saturating_sub(1))?;
        if !inner.is_empty() {
            let dot_form = inner.replace('/', ".");
            return Some((s.to_string(), dot_form));
        }
    }

    // Generic dot-form `foo.bar.Baz` (must contain a `.` to avoid
    // matching bare identifiers we don't recognize).
    if s.contains('.') && !s.contains('/') && !s.contains(' ') {
        let descriptor = format!("L{};", s.replace('.', "/"));
        return Some((descriptor, s.to_string()));
    }

    None
}

/// Verify whether a flagged DYNAMIC_LOADING string corresponds to an
/// actual function reference, via a three-way disjunction. Inputs
/// the merged `Xrefs` and the matched string `s`. Returns `true`
/// when ANY of:
///
/// 1. A type-ref opcode (`new-instance`, `check-cast`, `const-class`,
///    …) cites the descriptor — captured in `xrefs.type_xrefs`.
/// 2. Some method `const-string`-loaded the dot-form name (reflective
///    `Class.forName("dalvik.system.DexClassLoader")`).
/// 3. Some method `const-string`-loaded the descriptor form directly.
/// 4. The matched string itself appears as a `const-string` operand
///    (catches non-classlike tokens that nonetheless show real
///    references — the raw lookup).
///
/// When `classify_dynamic_loading_token` returns `None` (the matched
/// string is not a class-load token, e.g. `loadLibrary`+`System`), the
/// gate falls back to `string_to_methods.contains_key(s)` only — the
/// const-string-of-method-name pattern. Direct `Runtime.loadLibrary`
/// invocations don't const-string the method name, so a `false` here
/// doesn't prove no real load; the caller treats class-load and
/// non-class-load tokens separately.
fn dynamic_loading_token_referenced(
    xrefs: &droidsaw_dex::xrefs::Xrefs,
    s: &str,
) -> bool {
    if xrefs.string_to_methods.contains_key(s) {
        return true;
    }
    if let Some((descriptor, dot_form)) = classify_dynamic_loading_token(s) {
        if xrefs.type_xrefs.contains_key(&descriptor) {
            return true;
        }
        if xrefs.string_to_methods.contains_key(&dot_form) {
            return true;
        }
        if xrefs.string_to_methods.contains_key(&descriptor) {
            return true;
        }
    }
    false
}

/// Partition a DYNAMIC_LOADING string set against a built dex-xref
/// index. Pure helper extracted from `gate_dynamic_loading` so it can
/// be unit-tested without an `Apk` / `CrossLayerContext`.
///
/// Returns `(referenced_class_load, unreferenced_class_load,
/// non_class_load)` where:
/// - **referenced_class_load** — class-load tokens with a confirmed
///   function reference (descriptor in `type_xrefs`, dot-form or
///   descriptor in `string_to_methods`, or raw `s` in
///   `string_to_methods`).
/// - **unreferenced_class_load** — class-load tokens with NO function
///   reference (the false-positive set being suppressed).
/// - **non_class_load** — tokens the classifier rejects (e.g.
///   `loadLibrary`/`System` substrings). Method invocation patterns;
///   the gate does not constrain them.
fn partition_dynamic_loading_strings(
    xrefs: &droidsaw_dex::xrefs::Xrefs,
    strings: &[String],
) -> (Vec<String>, Vec<String>, Vec<String>) {
    let mut deduped: Vec<String> = strings.to_vec();
    deduped.sort();
    deduped.dedup();

    let mut referenced_class_load: Vec<String> = Vec::new();
    let mut unreferenced_class_load: Vec<String> = Vec::new();
    let mut non_class_load: Vec<String> = Vec::new();

    for s in deduped {
        if classify_dynamic_loading_token(&s).is_some() {
            if dynamic_loading_token_referenced(xrefs, &s) {
                referenced_class_load.push(s);
            } else {
                unreferenced_class_load.push(s);
            }
        } else {
            // Non-class-load tokens (e.g. `loadLibrary`/`System`):
            // direct calls don't const-string the method name, so the
            // gate doesn't apply. Keep verbatim.
            non_class_load.push(s);
        }
    }

    (referenced_class_load, unreferenced_class_load, non_class_load)
}

/// Mutate a `DYNAMIC_LOADING` finding given the partition result.
/// Pure helper extracted from `gate_dynamic_loading` for the same
/// reason — unit testable in isolation.
///
/// - When every class-load token is unreferenced AND no non-class-load
///   tokens were matched, downgrades severity Medium → Info and
///   rewrites the detail to "type descriptor present in DEX string
///   pool but no function references; likely a metadata / type-pool
///   artefact, not a real dynamic-load call site."
/// - When at least one token survives the gate (referenced class-load
///   or any non-class-load), keeps severity Medium and rewrites the
///   detail to list only the surviving strings.
fn apply_dynamic_loading_partition_to_finding(
    finding: &mut Finding,
    referenced_class_load: &[String],
    unreferenced_class_load: &[String],
    non_class_load: &[String],
) {
    let surviving_count = referenced_class_load
        .len()
        .saturating_add(non_class_load.len());

    if surviving_count == 0 {
        // Every flagged token was a class-load with no function
        // reference. Downgrade and rewrite.
        finding.severity = Severity::Info;
        let unreferenced_list = unreferenced_class_load.join(", ");
        finding.detail = format!(
            "Type descriptor(s) present in DEX string pool but no function references \
             (no allocation, cast, const-class, or reflective load) — likely metadata / \
             type-pool artefact, not a dynamic-load call site: {unreferenced_list}"
        );
        return;
    }

    // At least one survivor. Keep Medium; rewrite detail to list
    // only the surviving strings and (if any) note the gated set.
    let mut surviving: Vec<&String> = Vec::with_capacity(surviving_count);
    surviving.extend(referenced_class_load.iter());
    surviving.extend(non_class_load.iter());
    let surviving_joined = surviving
        .iter()
        .map(|s| s.as_str())
        .collect::<Vec<_>>()
        .join(", ");

    if unreferenced_class_load.is_empty() {
        finding.detail = format!(
            "Dynamic code loading in DEX strings (function references confirmed via \
             dex xref index): {surviving_joined}"
        );
    } else {
        let unreferenced_joined = unreferenced_class_load.join(", ");
        finding.detail = format!(
            "Dynamic code loading in DEX strings (function references confirmed via \
             dex xref index): {surviving_joined}; additionally, the following type \
             descriptors appeared in the string pool but had no function references \
             and were not counted toward this finding: {unreferenced_joined}"
        );
    }
}

/// Apply the DYNAMIC_LOADING xref gate to an apk-layer findings list.
///
/// - Builds a merged dex-xref index across every DEX in `ctx`. Per-dex
///   build failures are logged via `progress!` and skipped — the gate
///   then operates on whichever indexes succeeded. If no dex builds
///   succeed (no DEX bytes available, or every build errored), the
///   gate becomes a no-op: we cannot prove the descriptor is
///   unreferenced without an index, so we leave the finding as-is.
/// - Partitions `apk.dynamic_loading_strings` via
///   `partition_dynamic_loading_strings`.
/// - Mutates the located `DYNAMIC_LOADING` finding via
///   `apply_dynamic_loading_partition_to_finding`.
fn gate_dynamic_loading(
    ctx: &CrossLayerContext,
    apk: &droidsaw_apk::Apk,
    findings: &mut [Finding],
) {
    // Locate the DYNAMIC_LOADING finding (apk-layer emits at most one).
    // If absent, the apk-layer detector found nothing to flag — gate
    // is trivially a no-op.
    let Some(idx) = findings.iter().position(|f| f.id == "DYNAMIC_LOADING") else {
        return;
    };

    // Build merged xref index across every DEX. We accept partial
    // builds: a single broken dex shouldn't blind the gate. Failures
    // surface via stderr `progress!` so operators can see them.
    let mut xrefs = droidsaw_dex::xrefs::Xrefs::default();
    let mut any_index_built = false;
    for (i, dex) in ctx.dex.iter().enumerate() {
        let Some(raw) = ctx.dex_bytes(i) else {
            continue;
        };
        match droidsaw_dex::xrefs::Xrefs::build(dex, raw) {
            Ok(other) => {
                xrefs.merge(other);
                any_index_built = true;
            }
            Err(e) => {
                // SAFE: index `i` is a usize from `ctx.dex.iter().enumerate()`,
                // bounded by `ctx.dex.len()` ≤ isize::MAX; integer Display is
                // not attacker-controllable.
                progress!(
                    "dex{:?}: xrefs build failed (DYNAMIC_LOADING gate skipped for this dex): {:?}",
                    i.saturating_add(1),
                    e
                );
            }
        }
    }

    // No xref index ⇒ cannot prove unreferenced ⇒ leave finding as-is.
    if !any_index_built {
        return;
    }

    let (referenced_class_load, unreferenced_class_load, non_class_load) =
        partition_dynamic_loading_strings(&xrefs, &apk.dynamic_loading_strings);

    let Some(finding) = findings.get_mut(idx) else {
        return;
    };
    apply_dynamic_loading_partition_to_finding(
        finding,
        &referenced_class_load,
        &unreferenced_class_load,
        &non_class_load,
    );
}

/// Collect all apk-layer security findings from all sub-audits into a
/// single Vec<common::Finding>, sorted by severity. This is the unit of
/// analysis for audit and scan-corpus output.
// PROOF: HBC bytecode region validated by `end_u64 = f.offset as u64 +
// f.size as u64` + `if end_u64 > hbc_data.len() as u64 { continue }` guard
// before narrowing `as usize`.
#[allow(clippy::arithmetic_side_effects, clippy::as_conversions, reason = "HBC bytecode region validated by `end_u64 = f.offset as u64 + f.size as u64` + `if end_u64 > hbc_data.len() as u64 { continue }` guard before narrowing `as usize`.")]
pub(super) fn collect_apk_findings(ctx: &CrossLayerContext, entropy_threshold: f32) -> Vec<Finding> {
    let mut findings = Vec::new();

    // Wave-1-re-review remediation: surface the per-bundle Findings
    // that droidsaw-hermes emits onto its thread-local channel via
    // `emit_finding`. Drained at parse time, translated to common
    // Finding, stashed on the Context; pulled into the audit envelope
    // here so the v98-form-ambiguous (and every other HermesFinding
    // variant) reaches operator-facing output instead of dying in
    // a thread-local. Capped to defend against adversarial decompile-
    // time amplification (per-function ObjectShapeNumPropsExceeded).
    findings.extend(cap_findings(
        ctx.hermes_findings.clone(),
        "HBC_FINDINGS_TRUNCATED",
        Layer::Hbc,
    ));

    // A Hermes bundle the container carried but droidsaw could not
    // parse is signal, never silence: the layer is a live code path
    // the analysis is blind on (unknown future HBC layout, corruption,
    // or deliberate anti-analysis bytes). The dex/apk layers still
    // serve; this finding is the audit-envelope record of the gap.
    if let Some(failure) = ctx.hbc_parse_error.as_ref() {
        findings.push(Finding::new(
            "HBC_BUNDLE_UNPARSEABLE",
            Layer::Hbc,
            Severity::Medium,
            sanitize_detail(&format!(
                "Hermes bundle present but unparseable; HBC-layer analysis is blind on a live code path: {}",
                failure.message()
            )),
        ));
    }

    // APK-specific audits: no-op for raw HBC inputs (apk is None).
    if let Some(apk) = ctx.apk.as_ref() {
        // Wave-1-re-review remediation: translate apk.zip_anomalies
        // (already populated by parse_multiple_inner) into Findings so
        // the Master Key (CVE-2013-4787) DuplicateEntry signal and
        // sibling repackaging anomalies (ManifestNotFirst,
        // OutOfOrderEntries, SuspiciousEocdComment, DoubleEocd,
        // NonZeroDiskNumber, FeatureModuleOverflow) reach the audit
        // envelope. Without the guard: anomalies were serialized only by
        // a single command's per-entry JSON output; the audit envelope
        // surface was wallpaper.
        use droidsaw_apk::apk::ZipAnomalyKind;
        use droidsaw_common::finding::{Confidence, Layer, Severity};
        let mut zip_findings: Vec<Finding> = Vec::new();
        for anomaly in &apk.zip_anomalies {
            let (id, severity) = match &anomaly.kind {
                ZipAnomalyKind::DuplicateEntry => ("APK_ZIP_DUPLICATE_ENTRY", Severity::High),
                ZipAnomalyKind::ManifestNotFirst => ("APK_ZIP_MANIFEST_NOT_FIRST", Severity::Low),
                ZipAnomalyKind::OutOfOrderEntries => ("APK_ZIP_OUT_OF_ORDER_ENTRIES", Severity::Low),
                ZipAnomalyKind::SuspiciousEocdComment { .. } => {
                    ("APK_ZIP_SUSPICIOUS_EOCD_COMMENT", Severity::Medium)
                }
                ZipAnomalyKind::DoubleEocd { .. } => ("APK_ZIP_DOUBLE_EOCD", Severity::High),
                ZipAnomalyKind::NonZeroDiskNumber { .. } => {
                    ("APK_ZIP_NON_ZERO_DISK_NUMBER", Severity::Medium)
                }
                ZipAnomalyKind::FeatureModuleOverflow { .. } => {
                    ("APK_ZIP_FEATURE_MODULE_OVERFLOW", Severity::Low)
                }
                // Cross-split coherence anomalies: trust-binding inversion is
                // the apex shape. Cert from base + manifest/SF/NSC from
                // attacker split = signed-badge on attacker policy. High
                // severity reflects the operator's need to demote
                // `signing_summary.has_v1` against the
                // affected bundle.
                ZipAnomalyKind::CrossSplitTrustMismatch { .. } => {
                    ("APK_CROSS_SPLIT_TRUST_MISMATCH", Severity::High)
                }
                // Duplicate split prefix lets later splits' cert
                // contributions slip past the per-split fingerprint gate
                // silently. Medium because the inversion still needs a
                // cert-vs-policy decoupling to weaponize, and that's
                // surfaced separately by CrossSplitTrustMismatch when
                // certs are present.
                ZipAnomalyKind::DuplicateSplitPrefix { .. } => {
                    ("APK_DUPLICATE_SPLIT_PREFIX", Severity::Medium)
                }
                // Malformed cert envelope at a CERT.RSA / .DSA / .EC slot —
                // bytes don't parse as PKCS#7. Medium: signing_info() also
                // refuses these bytes downstream, so the impact is
                // denial-of-attribution (no signer claim surfaces), not
                // false-attribution.
                ZipAnomalyKind::MalformedCertEnvelope { .. } => {
                    ("APK_MALFORMED_CERT_ENVELOPE", Severity::Medium)
                }
                // First cert-bearing split sits at a path position other
                // than the heuristic-identified base. Trust-binding
                // inversion class: attacker prepends their own split to
                // weaponize first-wins-across-splits content load while
                // a public cert replayed into their split sticks. High
                // severity matches CrossSplitTrustMismatch — same
                // operator response (demote `signing_summary.has_v1` /
                // refuse to display "signed by X" against the bundle).
                ZipAnomalyKind::BaseSplitNotFirst { .. } => {
                    ("APK_BASE_SPLIT_NOT_FIRST", Severity::High)
                }
                // LFH-vs-central-directory differential (CWE-436). BadPack
                // mangles the local-file-header size/method fields so
                // forward-validating tools (apktool / jadx / python
                // zipfile / unzip) desync while Android's libziparchive
                // reads the central directory and runs the entry. High:
                // the APK installs and executes on-device but
                // mis-reports or crashes in every standard analyst tool.
                ZipAnomalyKind::LfhCentralSizeMismatch { .. } => {
                    ("APK_ZIP_LFH_SIZE_MISMATCH", Severity::High)
                }
                ZipAnomalyKind::LfhCentralMethodMismatch { .. } => {
                    ("APK_ZIP_LFH_METHOD_MISMATCH", Severity::High)
                }
                // General-purpose encryption flag (bit 0) set on an entry
                // (Oblivion RAT). Critical: Android ignores the flag and
                // runs the DEX while jadx / python zipfile / unzip refuse
                // to extract it — the analyst's tooling goes blind on a
                // live code path.
                ZipAnomalyKind::EncryptionFlagSet { .. } => {
                    ("APK_ZIP_ENCRYPTION_FLAG_SET", Severity::Critical)
                }
                // File does not begin with a ZIP signature yet parses via
                // its trailing EOCD (Janus generalization / Mitra
                // "polymock" content-type evasion). High: file/libmagic/AV
                // report an attacker-chosen type while the archive still
                // installs as an APK. (`JANUS_DEX_PREFIX` owns the
                // specific `dex\n` shape; deduped at the producer.)
                ZipAnomalyKind::LeadingNonZipBytes { .. } => {
                    ("APK_ZIP_LEADING_NON_ZIP_BYTES", Severity::High)
                }
            };
            // ZIP entry names are attacker-controlled and may appear in
            // anomaly.detail. Sanitize control bytes (terminal-escape
            // injection class) before the Finding reaches stderr.
            let mut f = Finding::new(id, Layer::Apk, severity, sanitize_detail(&anomaly.detail));
            f.confidence = Confidence::Verified;
            zip_findings.push(f);
        }
        findings.extend(cap_findings(
            zip_findings,
            "APK_ZIP_ANOMALIES_TRUNCATED",
            Layer::Apk,
        ));
        // Bundle-aware Hermes credential scanning (drives apk.audit_security
        // via the yara-overrides path).
        //
        // Background: the apk-side raw-byte YARA pass over a large HBC
        // bundle is the hot path in audit CPU AND produces
        // cross-string-table-boundary FPs (entry N's suffix concatenating
        // with entry N+1's prefix into a fake `sk-`/`AIza`/`AKIA`
        // credential).
        //
        // Fix: skip raw-byte YARA scanning on HBC asset paths via the
        // `yara_skip_assets` override AND inject precise matches from
        // `scan_hbc_per_string`'s per-entry pass as `yara_extra_matches`.
        // The apk-side verifier + dedup + finding-emit pipeline treats
        // them indistinguishably from raw-byte matches.
        let hbc_asset_paths = collect_hbc_asset_paths(&apk.path);
        let mut hbc_extra_yara_matches: Vec<droidsaw_apk::yara_scan::YaraMatch> = Vec::new();
        if !hbc_asset_paths.is_empty()
            && let Some(rules) = droidsaw_apk::yara_scan::bundled_credential_rules()
        {
            for hbc_path in &hbc_asset_paths {
                hbc_extra_yara_matches.extend(scan_hbc_per_string(
                    rules, &apk.path, hbc_path, /* bundled rules */ true,
                ));
            }
        }

        // audit_security_with_yara_overrides() internally calls
        // manifest.security_findings(), nsc.security_findings(),
        // audit_dex_ratios(), audit_elf_anomalies(), and
        // audit_asset_entropy() — do NOT call those separately or every
        // finding will appear twice in the DB. The yara-overrides arg
        // pair routes HBC scanning through the precision-preserving
        // per-string path; empty slices reproduce `audit_security()`.
        let mut apk_findings =
            apk.audit_security_with_yara_overrides(&hbc_asset_paths, &hbc_extra_yara_matches);

        // DYNAMIC_LOADING xref gate. The apk-layer detector at
        // `droidsaw-apk/src/dex.rs::scan_strings_full` matches type
        // descriptors / class names in the DEX string pool (e.g.
        // `dalvik.system.DexClassLoader`). Pool presence alone is not
        // evidence of dynamic loading: the descriptor can land in the
        // pool via type-signatures, class-hierarchy supertypes, or
        // metadata tables that the runtime uses to *describe* types it
        // never *loads*. The gate verifies at least one method actually
        // references the named class via the dex xref index. When the
        // disjunction (type-ref opcodes ∪ const-string of the
        // dot-form ∪ const-string of the descriptor) is empty across
        // every matched string AND no non-class-load tokens were
        // matched (i.e. `loadLibrary`/`System`, which the gate does
        // not constrain), downgrade the finding to Info and rewrite
        // the detail.
        gate_dynamic_loading(ctx, apk, &mut apk_findings);

        findings.extend(apk_findings);

        // signing_info() is NOT covered by audit_security() — keep this call.
        if let Ok(signing) = apk.signing_info() {
            findings.extend(signing.security_findings_at_with_context(
                std::time::SystemTime::now(),
                signing_findings_context_from(ctx),
            ));
        }

        // Commercial-obfuscator detection from AXML `unknown_chunks`.
        // Re-parses the manifest with the lenient config so the
        // skipped-chunk catalogue is observable; strict mode (the
        // historical default of `apk.decode_manifest()`) returns `None`
        // on the same input and erases the signal we want to surface.
        // One emission per commercial-obfuscator chunk type so the
        // detail string can name the offset; in practice 0x0104
        // appears at most once per AndroidManifest in the field.
        if let Some(raw) = apk.manifest_raw.as_ref()
            && let Ok((_, unknown_chunks, _)) =
                Manifest::from_binary_xml_with_config(raw, &ParseConfig::lenient())
        {
            for uc in &unknown_chunks {
                if uc.chunk_type == 0x0104 {
                    findings.push(
                        Finding::new(
                            "COMMERCIAL_OBFUSCATOR_DETECTED",
                            Layer::Apk,
                            Severity::Info,
                            format!(
                                "AXML chunk type 0x0104 at offset {} — \
                                 commercial-obfuscator marker (DexGuard-class). \
                                 This is an enrichment signal, not a vulnerability; \
                                 companion `UNKNOWN_SIGNING_BLOCK 0xfa1cfabe` \
                                 promotes confidence when present.",
                                uc.offset,
                            ),
                        )
                        .with_extra(format!(
                            "{{\"chunk_type\":\"0x0104\",\"offset\":{}}}",
                            uc.offset,
                        )),
                    );
                }
            }

            // AXML parse-time findings (e.g. AXML_NON_CANONICAL_HEADER_SIZE,
            // AXML_MULTIPLE_ROOTS) are deliberately NOT extended here:
            // `audit_security_with_yara_overrides` above already surfaces
            // them via `Apk::axml_structural_findings` (the same lenient
            // decode), so extending the reparse's copy would double-count
            // every AXML structural finding in the envelope. This reparse
            // exists only for the unknown-chunk catalogue.
        }
    }

    // Dex layer: UNRECOGNIZED_REGION findings — one per `Stmt::Unrecognized`
    // region produced by the inversion-track signature engine when no
    // recognizer matches a region's bytecode signature. Joins
    // COMPILE_FAIL / SEMANTIC_FAIL as a third per-APK ratchet metric.
    // Tagged-region sentinels (recognized shapes the IR has no first-
    // class variant for, e.g. coroutine_suspend) are filtered out by
    // `collect_unrecognized_findings` and do not produce findings.
    //
    // Header-vs-map disagreement, string-length disagreement, code-item
    // invariant violations, and the silent-skip-tolerant-subsection
    // detector-Indeterminate signal join the audit channel here so the
    // mitigations surface to operators via the standard Finding flow rather
    // than dying in a thread-local thrown by the parser.
    // Two-tier cap defends against adversarial-input DoS on BOTH
    // axes:
    // - INTRA-LOOP per-dex cap: bounds RSS during accumulation. A
    //   single attacker DEX with 25M malformed strings would otherwise
    //   produce ~5 GiB of per-string Findings BEFORE the post-loop cap
    //   fires. cap_findings inside the loop bounds each dex's
    //   contribution to ~58 KiB.
    // - POST-LOOP per-kind-global cap: bounds the audit envelope. An
    //   N-dex APK that hits the per-dex cap on every dex would
    //   otherwise contribute N × 256 entries per kind. The post-loop
    //   cap pins the envelope at 256 + 1 (truncation marker) per kind.
    // Two truncation Findings can collide when both ceilings fire on
    // the same kind; that's informational (operator sees both
    // ceilings hit). The synthetic-marker IDs already disambiguate.
    let mut unrecognized: Vec<Finding> = Vec::new();
    let mut header_map: Vec<Finding> = Vec::new();
    let mut string_length: Vec<Finding> = Vec::new();
    let mut code_item: Vec<Finding> = Vec::new();
    let mut detector_indeterminate: Vec<Finding> = Vec::new();
    let mut duplicate_class_def: Vec<Finding> = Vec::new();
    let mut class_data_off_collision: Vec<Finding> = Vec::new();
    let mut spec_invariant: Vec<Finding> = Vec::new();
    for (i, dex) in ctx.dex.iter().enumerate() {
        let Some(dex_data) = ctx.dex_bytes(i) else {
            continue;
        };
        unrecognized.extend(cap_findings(
            droidsaw_dex::diag::collect_unrecognized_findings(dex, dex_data),
            "DEX_UNRECOGNIZED_TRUNCATED",
            Layer::Dex,
        ));
        header_map.extend(cap_findings(
            droidsaw_dex::diag::collect_header_map_findings(dex),
            "DEX_HEADER_MAP_TRUNCATED",
            Layer::Dex,
        ));
        string_length.extend(cap_findings(
            droidsaw_dex::diag::collect_string_length_findings(dex),
            "DEX_STRING_LENGTH_TRUNCATED",
            Layer::Dex,
        ));
        code_item.extend(cap_findings(
            droidsaw_dex::diag::collect_code_item_findings(dex),
            "DEX_CODE_ITEM_TRUNCATED",
            Layer::Dex,
        ));
        detector_indeterminate.extend(cap_findings(
            droidsaw_dex::diag::collect_detector_indeterminate_findings(dex),
            "DEX_DETECTOR_INDETERMINATE_TRUNCATED",
            Layer::Dex,
        ));
        duplicate_class_def.extend(cap_findings(
            droidsaw_dex::diag::collect_duplicate_class_def_findings(dex),
            "DEX_DUPLICATE_CLASS_DEF_TRUNCATED",
            Layer::Dex,
        ));
        class_data_off_collision.extend(cap_findings(
            droidsaw_dex::diag::collect_class_data_off_collision_findings(dex),
            "DEX_CLASS_DATA_OFF_COLLISION_TRUNCATED",
            Layer::Dex,
        ));
        spec_invariant.extend(cap_findings(
            droidsaw_dex::diag::collect_spec_invariant_findings(dex),
            "DEX_SPEC_INVARIANT_TRUNCATED",
            Layer::Dex,
        ));
    }
    findings.extend(cap_findings(unrecognized, "DEX_UNRECOGNIZED_TRUNCATED", Layer::Dex));
    findings.extend(cap_findings(header_map, "DEX_HEADER_MAP_TRUNCATED", Layer::Dex));
    findings.extend(cap_findings(string_length, "DEX_STRING_LENGTH_TRUNCATED", Layer::Dex));
    findings.extend(cap_findings(code_item, "DEX_CODE_ITEM_TRUNCATED", Layer::Dex));
    findings.extend(cap_findings(
        detector_indeterminate,
        "DEX_DETECTOR_INDETERMINATE_TRUNCATED",
        Layer::Dex,
    ));
    findings.extend(cap_findings(
        duplicate_class_def,
        "DEX_DUPLICATE_CLASS_DEF_TRUNCATED",
        Layer::Dex,
    ));
    findings.extend(cap_findings(
        class_data_off_collision,
        "DEX_CLASS_DATA_OFF_COLLISION_TRUNCATED",
        Layer::Dex,
    ));
    findings.extend(cap_findings(
        spec_invariant,
        "DEX_SPEC_INVARIANT_TRUNCATED",
        Layer::Dex,
    ));

    // HBC (Hermes / React Native) string entropy scan — surfaces high-entropy
    // strings as a single batch finding so they appear in the audit DB and FTS5
    // index. Individual strings are not listed (too noisy); use apk_trufflehog
    // or apk_yara for pattern-level credential matching.
    if let Some(hbc_owned) = ctx.hbc.as_ref() {
        let hbc = hbc_owned.hbc();
        const HBC_MIN_STR_LEN: usize = 8;
        let mut high_entropy: Vec<String> = Vec::new();
        for i in 0..hbc.string_count {
            let s = hbc.string_as_str_or_empty(i);
            if s.len() >= HBC_MIN_STR_LEN
                && shannon_entropy(s.as_bytes()) >= entropy_threshold
                // Skip obvious code paths and URLs — they're high entropy but not secrets
                && !s.contains("://")
                && !s.starts_with("com.")
                && !s.starts_with("org.")
                && !s.starts_with("net.")
                && !s.starts_with("io.")
            {
                high_entropy.push(s.into_owned());
            }
        }
        if !high_entropy.is_empty() {
            let sample = high_entropy.iter().take(3)
                .map(|s| format!("{:?}", s))
                .collect::<Vec<_>>()
                .join(", ");
            findings.push(Finding::new(
                "HBC_HIGH_ENTROPY_STRINGS",
                Layer::Hbc,
                Severity::Info,
                format!(
                    "{} high-entropy strings in Hermes bundle \
                     (entropy ≥ {entropy_threshold} bits/char, len ≥ {HBC_MIN_STR_LEN}); \
                     samples: {sample}. Use apk_trufflehog or apk_yara for credential patterns.",
                    high_entropy.len(),
                ),
            ));
        }
    }

    // ── HBC taint (JS-layer source→sink + bridge crossing detection) ──
    //
    // Per-Call `arg_positions` live on each `TaintSink::NativeModuleArg`
    // finding. HBC findings with this sink and DEX bridge findings are
    // routed through `stitch_cross_layer_taint` after both passes
    // complete (rather than emitted as disjoint HBC_TAINT_FLOW +
    // BRIDGE_TAINT_FLOW pairs). The stitcher emits
    // CROSS_LAYER_TAINT_FLOW composites for paired flows, falls back to
    // HBC_TAINT_FLOW / BRIDGE_TAINT_FLOW for orphans, and emits
    // BRIDGE_RESOLUTION_AMBIGUOUS findings carrying a typed
    // `AmbiguousCause` for back-walk failures + resolver multi/zero
    // matches + legacy `getName()`-only modules.
    let mut hbc_stitch_payloads: Vec<analysis::cross_layer_stitch::HbcStitchPayload> = Vec::new();
    let mut hbc_backwalk_failures: Vec<analysis::cross_layer_stitch::HbcBackwalkFailurePayload> =
        Vec::new();
    if let Some(hbc_owned) = ctx.hbc.as_ref() {
        use analysis::hbc_taint::HbcTaintAnalysis;
        use analysis::dex_taint::TaintSource;

        let hbc = hbc_owned.hbc();
        let hbc_data = hbc_owned.bytes();

        // Identify which function IDs access NativeModules via string refs.
        let scan = droidsaw_hermes::scanner::scan_parsed(hbc, hbc_data);
        let nm_id =
            (0..hbc.string_count).find(|&i| hbc.string_as_str_or_empty(i) == "NativeModules");
        let bridge_func_ids: std::collections::BTreeSet<u32> = nm_id
            .and_then(|id| scan.string_refs.get(&id))
            .map(|fids| fids.iter().copied().collect())
            .unwrap_or_default();

        for func_id in 0..hbc.function_count {
            let f = hbc.function_get(func_id);
            if f.size == 0 { continue; }
            let end_u64 = u64::from(f.offset) + u64::from(f.size);
            if end_u64 > hbc_data.len() as u64 { continue; }
            #[allow(
                clippy::cast_possible_truncation,
                reason = "PROOF: just verified `end_u64 <= hbc_data.len() as u64`; downcast is bounded by the addressable buffer length on every supported target."
            )]
            let end = end_u64 as usize;
            let Some(decode_slice) = hbc_data.get(f.offset as usize..end) else {
                continue;
            };
            let Ok(instructions) = droidsaw_hermes::decompile::decode::decode_function(
                decode_slice,
                hbc.opcode_version(),
            ) else {
                continue;
            };
            let exc_count = hbc.function_exception_count(func_id);
            let exc_handlers: Vec<droidsaw_hermes::decompile::cfg::ExcHandler> = (0..exc_count)
                .map(|i| {
                    let eh = hbc.function_exception_get(func_id, i);
                    droidsaw_hermes::decompile::cfg::ExcHandler {
                        start: eh.start, end: eh.end, target: eh.target,
                    }
                })
                .collect();
            let code_end = (end + 256).min(hbc_data.len());
            let Some(code) = hbc_data.get(f.offset as usize..code_end) else {
                continue;
            };
            let Ok(cfg) = droidsaw_hermes::decompile::cfg::Cfg::build(&instructions, &exc_handlers, code) else {
                continue;
            };
            let Ok(ssa) = droidsaw_hermes::decompile::ssa::build_ssa(&cfg, f.frame_size) else {
                continue;
            };

            // Seed all parameters as UserInput taint sources.
            let seeds: std::collections::BTreeMap<droidsaw_hermes::decompile::ssa::VarId, TaintSource> =
                ssa.param_vars
                    .iter()
                    .map(|&v| (v, TaintSource::UserInput))
                    .collect();

            if seeds.is_empty() {
                continue;
            }

            // Bridge functions need <Resolved> SSA so the two-hop
            // property-chain back-walk can read ResolvedString operands
            // on GetById* ops. Non-bridge functions only need DirectEval
            // sink detection, which is phase-agnostic — skip the
            // optimize pipeline (~9 passes including 4 DCE rounds per
            // function) for them. On a typical shipped RN bundle with
            // thousands of functions and a small bridge-function set,
            // this is the dominant per-function wall-time cost.
            let result = if bridge_func_ids.contains(&func_id) {
                let get_str = |id: u32| -> String {
                    if id < hbc.string_count {
                        hbc.string_as_str_or_empty(id).into_owned()
                    } else {
                        String::new()
                    }
                };
                let get_literal = |a: u8, b: u32, c: u32, d: u32| -> (u8, u32, i32, f64) {
                    let v = hbc.literal_get(a, b, c, d);
                    (v.tag, v.str_id, v.ival, v.dval)
                };
                let get_shape = |i: u32| -> (u32, u32) {
                    match hbc.object_shape_get(i) {
                        Some(s) => (s.key_buffer_offset, s.num_props),
                        None => (0, 0),
                    }
                };
                let get_func_name = |fid2: u32| -> String {
                    if fid2 < hbc.function_count {
                        let fi = hbc.function_get(fid2);
                        if fi.name_id < hbc.string_count {
                            return hbc.string_as_str_or_empty(fi.name_id).into_owned();
                        }
                    }
                    String::new()
                };
                let get_bigint = |idx: u32| -> Option<String> { hbc.bigint_as_str(idx) };
                let ssa = droidsaw_hermes::decompile::optimize::optimize(
                    ssa,
                    &get_str,
                    &get_literal,
                    &get_shape,
                    &get_func_name,
                    &get_bigint,
                );
                HbcTaintAnalysis::run_full(
                    &ssa,
                    func_id,
                    droidsaw_common::finding::Layer::Hbc,
                    seeds,
                    &bridge_func_ids,
                )
            } else {
                HbcTaintAnalysis::run_eval_only(
                    &ssa,
                    func_id,
                    droidsaw_common::finding::Layer::Hbc,
                    seeds,
                )
            };
            for tf in result.findings {
                // Route NativeModuleArg-sink findings to the stitcher;
                // non-bridge HBC sinks (Eval, ...) still emit
                // HBC_TAINT_FLOW directly.
                match tf.sink {
                    droidsaw_common::analysis::TaintSink::NativeModuleArg { .. } => {
                        hbc_stitch_payloads.push(
                            analysis::cross_layer_stitch::HbcStitchPayload { tf },
                        );
                    }
                    _ => {
                        findings.push(hbc_taint_to_finding(&tf));
                    }
                }
            }
            // Back-walk failures land in the stitcher's structured
            // `BridgeResolutionAmbiguous { cause: ChainExtractionFailed }`
            // emit. `hop_count` per BackwalkFailureReason variant:
            //   CalleeNotVar         -> 0 (couldn't even start the chain)
            //   ChainExtractionFailed -> 1 (some hop failed)
            //   TerminalHopIsGetById  -> 2 (both hops succeeded but a
            //                              third hop indicates a >2-hop
            //                              property chain)
            for fs in result.backwalk_failures {
                let hop_count = match fs.reason {
                    analysis::hbc_taint::BackwalkFailureReason::CalleeNotVar => 0u8,
                    analysis::hbc_taint::BackwalkFailureReason::ChainExtractionFailed => 1,
                    analysis::hbc_taint::BackwalkFailureReason::TerminalHopIsGetById => 2,
                };
                hbc_backwalk_failures.push(
                    analysis::cross_layer_stitch::HbcBackwalkFailurePayload {
                        func_id: fs.func_id,
                        hop_count,
                    },
                );
            }
        }
    }

    // ── DEX taint (intra + cross-DEX interprocedural + bridge) ──────────
    //
    // Build the unified cross-DEX code index once — two passes over all DEX
    // class data, producing (caller_dex, MethodIdx) → (owner_dex, code_off).
    // Callee follow in interproc_inner uses this for O(log n) cross-DEX lookup.
    let dex_data_vecs: Vec<&[u8]> = (0..ctx.dex.len())
        .filter_map(|i| ctx.dex_bytes(i))
        .collect();
    let class_analysis = analysis::dex_taint::DexTaintAnalysis::collect_unified_code_index(
        &ctx.dex, &dex_data_vecs,
    );

    let bridge = analysis::bridge::BridgeResolver::resolve(ctx);
    // bridge_targets is keyed for the seed-all DEX bridge-seeding path:
    // it uses the legacy by-method-name index (`by_method`) so that every
    // @ReactMethod-annotated Java method participates, including legacy
    // RN modules that lack class-level @ReactModule(name=X). Downstream
    // analysis additionally consumes `bridge.mappings` (per-(module, method)
    // keyed) to revive per-position DEX seeding via the per-finding
    // arg_positions lookup against TaintSink::NativeModuleArg.
    let bridge_targets: std::collections::BTreeMap<(usize, droidsaw_dex::ids::MethodIdx), String> =
        bridge.by_method.iter()
            .flat_map(|(js_name, targets)| {
                targets.iter().map(move |(dex_idx, m_idx)| ((*dex_idx, *m_idx), js_name.clone()))
            })
            .collect();

    // Collect per-method work units serially (the parse_class_data step
    // isn't parallelizable cheaply) then run the CFG/SSA/interproc
    // pipeline per method in parallel. Each work unit is
    // (dex_idx, method_idx, code_off). Borrowed state (ctx.dex,
    // dex_data_vecs, class_analysis, bridge_targets) is read-only
    // inside the closure, so rayon's Send+Sync requirements are
    // satisfied without wrapping in Arc/Mutex.
    let mut method_tasks: Vec<(usize, droidsaw_dex::ids::MethodIdx, u32)> = Vec::new();
    for ((i, dex), &data) in ctx.dex.iter().enumerate().zip(dex_data_vecs.iter()) {
        for (class_defs_idx, cd) in dex.class_defs.iter().enumerate() {
            // Shadow gate: duplicate-class_idx rows share their
            // class_data_off; without this gate the full-audit method
            // task builder would queue the same (dex_idx, method_idx,
            // code_off) tuples twice, inflating ratchet-tracked
            // detector finding counts by 2× per duplicate.
            if dex.class_def_is_shadowed(class_defs_idx) {
                continue;
            }
            if cd.class_data_off == 0 {
                continue;
            }
            let Ok(class_data) = droidsaw_dex::decode::parse_class_data(data, cd.class_data_off)
            else {
                continue;
            };
            for m in class_data
                .direct_methods
                .iter()
                .chain(class_data.virtual_methods.iter())
            {
                if m.code_off == 0 {
                    continue;
                }
                method_tasks.push((i, m.method_idx, m.code_off));
            }
        }
    }

    use rayon::prelude::*;
    // Per-task output: regular DEX taint Findings + bridge stitcher
    // payloads. After collect, the two halves get unzipped and routed:
    // regular findings into the main vec, bridge payloads into the
    // stitcher.
    type DexTaskOut = (
        Vec<Finding>,
        Vec<analysis::cross_layer_stitch::DexBridgeStitchPayload>,
    );
    // Native-method set per DEX, computed ONCE here. collect_native_methods_per_dex
    // re-parses every class_data in the APK; it is loop-invariant across the
    // method tasks, so it is hoisted above the par_iter and threaded by shared
    // reference into each run_interprocedural / _bridge call. Computing it inside
    // the per-task closure is O(tasks × class_data) and hangs large multidex apps.
    let native_methods_per_dex =
        analysis::dex_taint::collect_native_methods_per_dex(&ctx.dex, &dex_data_vecs);
    // App's own manifest package, loop-invariant, for the FilePathTraversal
    // first-party severity tier (threaded into taint_finding_to_finding). `Copy`
    // Option<&str> captures cleanly into the rayon closure.
    let app_package: Option<String> = manifest_id(ctx).0;
    let app_pkg: Option<&str> = app_package.as_deref();
    let task_outputs: Vec<DexTaskOut> = method_tasks
        .par_iter()
        .map(|&(i, m_idx, code_off)| {
            let empty: DexTaskOut = (Vec::new(), Vec::new());
            let Some(&data) = dex_data_vecs.get(i) else {
                return empty;
            };
            let Ok(code) = droidsaw_dex::decode::parse_code_item(data, code_off) else {
                return empty;
            };
            let Ok(cfg) = droidsaw_dex::cfg::Cfg::build(&code) else {
                return empty;
            };
            let Ok(ssa) = droidsaw_dex::ssa::SsaBody::build(&code, &cfg) else {
                return empty;
            };

            let mut out: Vec<Finding> = Vec::new();
            let mut bridge_out:
                Vec<analysis::cross_layer_stitch::DexBridgeStitchPayload> = Vec::new();

            // Resolve the containing method's class + signature from the DEX method pool.
            // These are injected into each TaintFinding so that write_finding_xrefs_db
            // can link xrefs to the correct application class rather than requiring
            // class-name regex extraction from the free-text detail string.
            let containing_key = ctx.dex.get(i)
                .and_then(|dex| droidsaw_dex::method_key_for_idx(dex, m_idx));

            // Regular interprocedural taint — cross-DEX callee following included.
            let result = analysis::dex_taint::DexTaintAnalysis::run_interprocedural(
                &ctx.dex,
                &dex_data_vecs,
                &class_analysis,
                i,
                &ssa,
                droidsaw_common::finding::Layer::Dex,
                std::collections::BTreeMap::new(),
                &native_methods_per_dex,
                4,
            );
            for mut tf in result.findings {
                // Stamp the containing method's class/signature onto every finding
                // produced for this method body.  The inner taint walker only
                // sees the sink callee's method_idx; the caller (here) has the
                // outer m_idx that corresponds to the application method under
                // analysis.  write_finding_xrefs_db uses class_descriptor to
                // locate string constants loaded by this method.
                if let Some(ref key) = containing_key {
                    tf.class_descriptor = Some(key.class.to_string());
                    tf.method_signature = Some(format!(
                        "{}{}", key.name, key.proto
                    ));
                }
                out.push(taint_finding_to_finding(&tf, app_pkg));
            }

            // Bridge taint: seed every @ReactMethod param on every bridge
            // target. Conservative default — the prior "precise seeding"
            // via the process-global `bridge_nm_arg_positions` accumulator
            // was due to overly broad positions being unioned across
            // unrelated bridge functions. Downstream analysis brings back
            // precision: BridgeResolver widens to
            // `JsBridgeKey { module, method }`, then per-finding lookup
            // against the new `TaintSink::NativeModuleArg.arg_positions`
            // populates exact seeds per (module, method) target.
            if let Some(js_method) = bridge_targets.get(&(i, m_idx)) {
                use analysis::dex_taint::TaintSource;
                // Bridge-seeded params have no invoke-site source address — wrap with None.
                let seeds =
                    analysis::dex_taint::DexTaintAnalysis::seeds_from_sources(
                        ssa.param_vars
                            .iter()
                            .map(|v| (v.clone(), TaintSource::ReactBridgeParam { method: js_method.clone() }))
                            .collect(),
                    );
                // seed_positions maps each real-arg param_vars[idx]
                // (idx >= 1; idx==0 is the implicit `this`) to its
                // JS-side arg position (idx - 1). The `this` slot is
                // skipped because it does not correspond to any JS-side
                // argument index. Capped at u8 because DEX method param
                // counts never exceed 255 in practice (the Dalvik
                // insns_size operand is 16-bit but no real bridge method
                // has hundreds of args; truncation here would still be
                // safe since downstream analysis performs the overlap test
                // simply gets a position never produced on the HBC side).
                #[allow(
                    clippy::cast_possible_truncation,
                    reason = "DEX method param count never exceeds 255 in practice; truncation is safe — downstream analysis intersects against HBC arg_positions which are also u8-range."
                )]
                let seed_positions: std::collections::BTreeMap<droidsaw_dex::ssa::VarId, u8> = ssa
                    .param_vars
                    .iter()
                    .enumerate()
                    .filter_map(|(idx, v)| {
                        idx.checked_sub(1).map(|pos| (v.clone(), pos as u8))
                    })
                    .collect();
                if !seeds.is_empty() {
                    let bridge_result =
                        analysis::dex_taint::DexTaintAnalysis::run_interprocedural_bridge(
                            &ctx.dex,
                            &dex_data_vecs,
                            &class_analysis,
                            i,
                            &ssa,
                            droidsaw_common::finding::Layer::Dex,
                            seeds,
                            seed_positions,
                            &native_methods_per_dex,
                            4,
                        );
                    // Collect bridge findings as typed payloads for
                    // the stitcher. `sink_reachable_seed_positions`
                    // feeds the stitcher's overlap test against HBC
                    // arg_positions; both sides are in the same
                    // JS-side-arg-position space (DEX positions are
                    // already shifted by -1 for `this`).
                    let positions = bridge_result.bridge_sink_reachable_positions;
                    for (idx, mut tf) in bridge_result.findings.into_iter().enumerate() {
                        if let Some(ref key) = containing_key {
                            tf.class_descriptor = Some(key.class.to_string());
                            tf.method_signature = Some(format!(
                                "{}{}", key.name, key.proto
                            ));
                        }
                        let pos_set = positions.get(idx).cloned().unwrap_or_default();
                        bridge_out.push(
                            analysis::cross_layer_stitch::DexBridgeStitchPayload {
                                tf,
                                js_method: js_method.clone(),
                                dex_idx: i,
                                method_idx: m_idx,
                                sink_reachable_seed_positions: pos_set,
                            },
                        );
                    }
                }
            }

            (out, bridge_out)
        })
        .collect();
    let mut dex_bridge_payloads:
        Vec<analysis::cross_layer_stitch::DexBridgeStitchPayload> = Vec::new();
    for (regular, bridge_payloads) in task_outputs {
        findings.extend(regular);
        dex_bridge_payloads.extend(bridge_payloads);
    }

    // ── Cross-layer taint stitching ──────────────────────────────────
    //
    // Consume the HBC + DEX bridge typed payloads (and HBC back-walk
    // failures) into a StitchOutcome. Composites become a single
    // CROSS_LAYER_TAINT_FLOW finding per cross-layer flow; orphans on
    // either side fall back to the legacy HBC_TAINT_FLOW /
    // BRIDGE_TAINT_FLOW shapes; ambiguity diagnostics emit
    // BRIDGE_RESOLUTION_AMBIGUOUS findings with a typed `AmbiguousCause`
    // serialized into `Finding.extra`.
    // Record input cardinalities BEFORE consumed-by-move so the
    // post-stitch partition gauge can verify input-sum = output-sum.
    let n_hbc_in = hbc_stitch_payloads.len();
    let n_dex_in = dex_bridge_payloads.len();
    let n_bw_in = hbc_backwalk_failures.len();
    let stitch_outcome = analysis::cross_layer_stitch::stitch_cross_layer_taint(
        hbc_stitch_payloads,
        dex_bridge_payloads,
        hbc_backwalk_failures,
        &bridge,
    );
    // Partition gauge: every input payload lands in exactly one
    // output bin (redundancy as a structural check). A composite
    // consumes one HBC payload + one DEX payload (counts twice on the
    // input side). A backwalk failure consumes one slot and produces
    // one ambiguous finding. An HBC payload that did not compose lands
    // in unjoined_hbc OR ambiguous (resolver zero/multi/legacy).
    // A DEX payload not claimed by a composite lands in unjoined_dex.
    // The conservation rule that holds by construction:
    //   composites*2 + unjoined_hbc + unjoined_dex + ambiguous
    //     == n_hbc_in + n_dex_in + n_bw_in
    debug_assert_eq!(
        stitch_outcome.composites.len() * 2
            + stitch_outcome.unjoined_hbc.len()
            + stitch_outcome.unjoined_dex.len()
            + stitch_outcome.ambiguous.len(),
        n_hbc_in + n_dex_in + n_bw_in,
        "cross-layer stitcher partition invariant violated: input-sum != output-sum",
    );
    for composite in stitch_outcome.composites {
        findings.push(composite_to_finding(composite));
    }
    for tf in stitch_outcome.unjoined_hbc {
        findings.push(hbc_taint_to_finding(&tf));
    }
    for tf in stitch_outcome.unjoined_dex {
        // The js_method lives inside the TaintFinding's source as
        // `ReactBridgeParam { method }` (set at bridge-seed time). Pull
        // it back out so the legacy BRIDGE_TAINT_FLOW detail string
        // stays consistent with the pre-Phase-4 shape.
        let js_method = match &tf.source {
            droidsaw_common::analysis::TaintSource::ReactBridgeParam { method } => {
                method.as_str()
            }
            _ => "",
        };
        findings.push(bridge_taint_to_finding(&tf, js_method));
    }
    findings.extend(stitch_outcome.ambiguous);

    // Cache-truncation Findings. After the par_iter completes, surface
    // any cap-skipped inserts via Info-severity Findings so the operator
    // (or downstream agent) can tell that CHA / build-cache memoization
    // was bounded by `MAX_CHA_CACHE_ENTRIES` / `MAX_BUILD_CACHE_ENTRIES`.
    // No semantic regression — cap-miss lookups re-do the work — but the
    // signal is load-bearing for adversarial-input forensics. Mirrors
    // `AXML_FINDINGS_TRUNCATED` in `droidsaw-apk/src/binary_xml.rs:153`.
    let cha_truncations = class_analysis
        .cha_cache_truncations
        .load(std::sync::atomic::Ordering::Relaxed);
    if cha_truncations > 0 {
        findings.push(Finding::new(
            analysis::dex_taint::CHA_CACHE_ENTRY_CAP_HIT,
            Layer::Dex,
            Severity::Info,
            format!(
                "CHA cache exceeded the {}-entry cap; {cha_truncations} memoization \
                 inserts were skipped. Lookups past the cap re-iterate the candidate \
                 set, so virtual-call resolution remains correct but slower.",
                65536_usize,
            ),
        ));
    }
    let build_truncations = class_analysis
        .build_cache_truncations
        .load(std::sync::atomic::Ordering::Relaxed);
    if build_truncations > 0 {
        findings.push(Finding::new(
            analysis::dex_taint::BUILD_CACHE_ENTRY_CAP_HIT,
            Layer::Dex,
            Severity::Info,
            format!(
                "Per-call BuildCache exceeded the {}-entry cap; {build_truncations} \
                 (CodeItem, Cfg, SsaBody) memoization inserts were skipped across the \
                 audit's interproc fixpoint. Re-build cost is paid on subsequent \
                 lookups; no semantic regression.",
                4096_usize,
            ),
        ));
    }

    findings.sort_by(|a, b| a.severity.cmp(&b.severity).then_with(|| a.id.cmp(&b.id)));
    findings
}

// ── shared helpers ─────────────────────────────────────────────────

/// Is `class_descriptor` under the app's own manifest package root? A
/// self-comparison against the application id (reverse-domain root, first 3
/// labels) — NOT a third-party SDK table, so it carries no encyclopedia data and
/// is admissible in the detector. Returns `None` when the class or package is
/// unknown (locality undeterminable); `Some(false)` means a bundled-library /
/// framework / obfuscated class that is not the app's own code.
fn is_first_party_class(class_descriptor: Option<&str>, app_package: Option<&str>) -> Option<bool> {
    let class = class_descriptor?;
    let pkg = app_package?;
    if pkg.is_empty() {
        return None;
    }
    // App root = first 3 reverse-domain labels (or all if fewer), Dalvik form:
    // "com.app.foo.bar" -> "Lcom/app/foo". A class is first-party iff its
    // descriptor begins with that root followed by '/'.
    let root = pkg.split('.').take(3).collect::<Vec<_>>().join("/");
    if root.is_empty() {
        return None;
    }
    let prefix = format!("L{root}/");
    Some(class.trim_start_matches('[').starts_with(&prefix))
}

/// Tier a `FilePathTraversal` (CWE-22) finding by source-trust + first-party
/// locality. Cross-boundary sources (Intent extra / network response) are the
/// genuine attacker-controlled-path cases and stay Medium regardless of
/// locality. An app-internal or ambiguous source (own SharedPreferences, a
/// Cursor/content-URI read, a file/stream read, on-device input) landing in
/// non-first-party code (a bundled library / obfuscated class) drops to Low —
/// that is the library content-URI path-resolution pattern (Glide/Fresco/...)
/// the app developer cannot act on. First-party code, or an undeterminable
/// locality, stays Medium (do not demote on missing data).
fn file_path_traversal_severity(
    source: &analysis::dex_taint::TaintSource,
    class_descriptor: Option<&str>,
    app_package: Option<&str>,
) -> Severity {
    use analysis::dex_taint::TaintSource;
    match source {
        TaintSource::IntentExtra { .. } | TaintSource::NetworkResponse { .. } => Severity::Medium,
        _ => match is_first_party_class(class_descriptor, app_package) {
            Some(false) => Severity::Low,
            _ => Severity::Medium,
        },
    }
}

/// Convert a `TaintFinding` to a `Finding` with appropriate severity and CWE.
fn taint_finding_to_finding(tf: &analysis::dex_taint::TaintFinding, app_package: Option<&str>) -> Finding {
    use analysis::dex_taint::{TaintSink};

    // NativeMethod is the JNI-bridge minimum-tier sink — a tainted
    // argument crossed the JNI boundary into native code the analyzer
    // can't follow. It gets its own id_tag (`JNI_TAINTED_NATIVE_CALL`)
    // so analyst dashboards and the MCP taint tool can surface JNI
    // handoffs distinctly from other DEX-side taint flows.
    let (id_tag, severity, cwe) = match &tf.sink {
        TaintSink::NativeMethod { .. }           => ("JNI_TAINTED_NATIVE_CALL", Severity::Medium, Some(111u16)),
        TaintSink::RuntimeExec | TaintSink::Eval => ("DEX_TAINT_FLOW", Severity::Critical, Some(78u16)),
        TaintSink::SqlExecute                    => ("DEX_TAINT_FLOW", Severity::High,     Some(89)),
        TaintSink::WebViewLoadUrl                => ("DEX_TAINT_FLOW", Severity::High,     Some(79)),
        TaintSink::LogOutput                     => ("DEX_TAINT_FLOW", Severity::Medium,   Some(532)),
        TaintSink::ReflectionInvoke { .. }       => ("DEX_TAINT_FLOW", Severity::Medium,   Some(470)),
        // CWE-22 path traversal — substantiated: the taint reached the PATH arg
        // of a file-open call (classify_path_sink). The retired FileWrite
        // (data-write) sink below is no longer emitted by the DEX walker — it was
        // the benign read-then-write file-copy idiom mislabeled as CWE-22 — and
        // its arm stays only for any non-DEX producer.
        TaintSink::FilePathTraversal { .. }      => ("DEX_TAINT_FLOW", Severity::Medium,   Some(22)),
        TaintSink::FileWrite { .. }              => ("DEX_TAINT_FLOW", Severity::Medium,   Some(22)),
        TaintSink::ContentProviderInsert { .. }  => ("DEX_TAINT_FLOW", Severity::Medium,   Some(862)),
        TaintSink::NativeModuleArg { .. }        => ("DEX_TAINT_FLOW", Severity::Medium,   Some(20)),
        TaintSink::CryptoInput { .. }            => ("DEX_TAINT_FLOW", Severity::Low,      Some(327)),
        TaintSink::NetworkFetch
        | TaintSink::HttpRequest { .. }          => ("DEX_TAINT_FLOW", Severity::Low,      Some(918)),
        _                                        => ("DEX_TAINT_FLOW", Severity::Low,      None),
    };

    // FilePathTraversal is tiered by source-trust + first-party locality (the
    // match above gives the cross-boundary baseline; this refines it). CWE-22 is
    // genuine path traversal, but a tainted PATH from an app-internal/ambiguous
    // source landing in non-first-party (bundled-library / obfuscated) code is the
    // content-URI path-resolution pattern (Glide/Fresco/etc.) the app developer
    // cannot act on — drop it to Low. Cross-boundary sources and first-party code
    // stay Medium. Other sinks are unaffected.
    let severity = match &tf.sink {
        TaintSink::FilePathTraversal { .. } => {
            file_path_traversal_severity(&tf.source, tf.class_descriptor.as_deref(), app_package)
        }
        _ => severity,
    };

    let source_str = format!("{:?}", tf.source)
        .split('{').next().unwrap_or("Unknown").trim().to_string();
    let sink_str = format!("{:?}", tf.sink)
        .split('{').next().unwrap_or("Unknown").trim().to_string();
    // Embed class_descriptor in the detail string so write_finding_xrefs_db
    // Strategy B (regex extraction) can pivot to the correct class in the
    // xrefs table.  The Dalvik descriptor `Lcom/foo/Bar;` is the canonical
    // form that `class_ref_re` matches.
    let class_tag = tf.class_descriptor.as_deref()
        .map(|c| format!(" in {c}"))
        .unwrap_or_default();
    // JNI sinks get a distinct detail prefix so analysts can grep them
    // out of the larger DEX_TAINT_FLOW stream without parsing id_tag.
    let detail = if let TaintSink::NativeMethod { class, method } = &tf.sink {
        format!(
            "jni boundary: {source_str} → native {class}->{method}{class_tag} (func #{:#x}; native side unanalyzed)",
            tf.func_id,
        )
    } else {
        format!(
            "intra-method taint: {source_str} → {sink_str}{class_tag} (func #{:#x}; interprocedural cross-DEX depth 4)",
            tf.func_id,
        )
    };

    // Store source_offset / sink_offset in extra JSON so write_taint_flows_db
    // can persist them into the taint_flows columns and the MCP taint tool
    // can surface them. Both are null for HBC and bridge-seeded findings.
    let extra = serde_json::json!({
        "source_offset": tf.source_offset,
        "sink_offset": tf.sink_offset,
    }).to_string();

    // class_descriptor + Debug-formatted source/sink can carry
    // attacker-controlled bytes; sanitize at the Finding boundary
    // before the detail reaches stderr / the audit envelope.
    let f = Finding::new(id_tag, tf.layer, severity, sanitize_detail(&detail))
        .with_func(tf.func_id)
        .with_extra(extra);
    match cwe {
        Some(c) => f.with_cwe(c),
        None    => f,
    }
}

/// Convert an HBC `TaintFinding` to a `Finding`.
fn hbc_taint_to_finding(tf: &analysis::dex_taint::TaintFinding) -> Finding {
    use analysis::dex_taint::TaintSink;
    let (severity, cwe) = match &tf.sink {
        TaintSink::Eval                    => (Severity::Critical, Some(95u16)),
        TaintSink::NativeModuleArg { .. }  => (Severity::High,     Some(20)),
        TaintSink::WebViewLoadUrl          => (Severity::High,     Some(79)),
        TaintSink::SqlExecute              => (Severity::High,     Some(89)),
        _                                  => (Severity::Medium,   None),
    };
    let sink_str = format!("{:?}", tf.sink)
        .split('{').next().unwrap_or("Unknown").trim().to_string();
    let detail = format!(
        "hbc taint: JsParam → {sink_str} (func #{:#x})",
        tf.func_id
    );
    let f = Finding::new("HBC_TAINT_FLOW", tf.layer, severity, sanitize_detail(&detail))
        .with_func(tf.func_id);
    match cwe {
        Some(c) => f.with_cwe(c),
        None    => f,
    }
}

/// Like `taint_finding_to_finding` but tags the finding as `BRIDGE_TAINT_FLOW`
/// and annotates the detail with the originating JS method name.
fn bridge_taint_to_finding(tf: &analysis::dex_taint::TaintFinding, js_method: &str) -> Finding {
    use analysis::dex_taint::TaintSink;

    let (severity, cwe) = match &tf.sink {
        TaintSink::RuntimeExec | TaintSink::Eval => (Severity::Critical, Some(78u16)),
        TaintSink::SqlExecute                    => (Severity::Critical, Some(89)),
        TaintSink::WebViewLoadUrl                => (Severity::High,     Some(79)),
        TaintSink::LogOutput                     => (Severity::Medium,   Some(532)),
        TaintSink::ReflectionInvoke { .. }       => (Severity::High,     Some(470)),
        // JS-controlled value steering a file PATH — substantiated CWE-22.
        TaintSink::FilePathTraversal { .. }      => (Severity::High,     Some(22)),
        TaintSink::FileWrite { .. }              => (Severity::High,     Some(22)),
        TaintSink::ContentProviderInsert { .. }  => (Severity::Medium,   Some(862)),
        TaintSink::NativeModuleArg { .. }        => (Severity::Medium,   Some(20)),
        TaintSink::CryptoInput { .. }            => (Severity::Medium,   Some(327)),
        TaintSink::NetworkFetch
        | TaintSink::HttpRequest { .. }          => (Severity::Low,      Some(918)),
        _                                        => (Severity::Medium,   None),
    };

    let sink_str = format!("{:?}", tf.sink)
        .split('{').next().unwrap_or("Unknown").trim().to_string();
    let class_tag = tf.class_descriptor.as_deref()
        .map(|c| format!(" in {c}"))
        .unwrap_or_default();
    let detail = format!(
        "bridge taint: ReactBridgeParam[{js_method}] → {sink_str}{class_tag} (func #{:#x})",
        tf.func_id
    );

    // js_method + class_descriptor + Debug-formatted sink carry
    // attacker-controlled bytes from React Bridge analysis;
    // sanitize at the Finding boundary.
    let f = Finding::new("BRIDGE_TAINT_FLOW", tf.layer, severity, sanitize_detail(&detail))
        .with_func(tf.func_id);
    match cwe {
        Some(c) => f.with_cwe(c),
        None    => f,
    }
}

/// Lower a stitched [`CrossLayerTaintFinding`] into a `CROSS_LAYER_TAINT_FLOW`
/// `Finding` carrying the typed composite inside `Finding.extra` (parallel
/// typed-vec + JSON-extra channels with a debug-time roundtrip cross-check).
/// Severity + CWE come from the native-side sink classification (mirrors
/// `bridge_taint_to_finding` so the composite replaces the legacy pair
/// cleanly).
fn composite_to_finding(
    c: droidsaw_common::cross_layer_taint::CrossLayerTaintFinding<droidsaw_dex::ids::MethodIdx>,
) -> Finding {
    use droidsaw_common::finding::{FindingProvenance, Layer};
    let class_tag = c.native_class_descriptor.as_deref()
        .map(|cd| format!(" in {cd}"))
        .unwrap_or_default();
    let sink_str = format!("{:?}", c.native_sink)
        .split('{').next().unwrap_or("Unknown").trim().to_string();
    let source_str = format!("{:?}", c.js_source)
        .split('{').next().unwrap_or("Unknown").trim().to_string();
    let detail = format!(
        "cross-layer taint: {source_str} → {}.{} → {sink_str}{class_tag} \
         (js func #{:#x} → dex func #{:#x})",
        c.bridge.js_module.as_str(),
        c.bridge.js_method.as_str(),
        c.js_func_id,
        c.native_func_id,
    );
    let severity = c.severity;
    let cwe = c.cwe;
    let js_func_id = c.js_func_id;
    let native_func_id = c.native_func_id;
    let bridge_label = format!(
        "ReactBridge:{}.{}",
        c.bridge.js_module.as_str(),
        c.bridge.js_method.as_str(),
    );
    // Serde-shape cross-check gauge: serialize, deserialize, assert
    // equality (debug builds only — costs one assert per composite).
    let extra = serde_json::to_string(&c).unwrap_or_else(|_| "{}".into());
    #[cfg(debug_assertions)]
    {
        // PROOF: `c` is an internally-constructed
        // CrossLayerTaintFinding<MethodIdx> assembled by the stitcher
        // from already-validated typed payloads; it is NOT parsed from
        // attacker-controlled bytes. `serde::Serialize` is deterministic
        // for this concrete type (the unit tests in droidsaw-common
        // exercise the roundtrip), so `serialize(c)` followed by
        // `deserialize` yields the original value. A `c != back`
        // mismatch therefore implies compile-time serde-shape drift
        // between the droidsaw-common type definition and a consumer's
        // expectation — that is the bug class this gauge exists to
        // catch, and the panic terminates the dev test loop early
        // where it can be triaged. The `if let Ok` guard also discards
        // the deserialization-failure path defensively.
        if let Ok(back) = serde_json::from_str::<
            droidsaw_common::cross_layer_taint::CrossLayerTaintFinding<
                droidsaw_dex::ids::MethodIdx,
            >,
        >(&extra)
        {
            debug_assert_eq!(c, back, "CrossLayerTaintFinding serde roundtrip drift");
        }
    }
    let provenance = FindingProvenance {
        source_layer: Layer::Hbc,
        bridge: bridge_label,
        sink_layer: Layer::Dex,
    };
    let mut f = Finding::new(
        "CROSS_LAYER_TAINT_FLOW",
        Layer::Dex,
        severity,
        sanitize_detail(&detail),
    )
    .with_extra(extra)
    .with_func(native_func_id)
    .with_provenance(provenance);
    if let Some(cwe) = cwe {
        f = f.with_cwe(cwe);
    }
    let _ = js_func_id;
    f
}

fn manifest_id(ctx: &CrossLayerContext) -> (Option<String>, Option<String>) {
    let Some(apk) = ctx.apk.as_ref() else {
        return (None, None);
    };
    match apk.manifest_raw.as_ref() {
        Some(raw) => match Manifest::from_binary_xml(raw) {
            Ok(m) => (Some(m.package.clone()), Some(m.version_name.clone())),
            Err(_) => (None, None),
        },
        None => (None, None),
    }
}

#[cfg(test)]
mod taint_factory_tests {
    //! Lock the typed `Finding.func_id` propagation invariant on each of
    //! the 3 taint-factory functions. The integration regression test at
    //! `tests/db_taint_flows_func_id.rs` covers the persistence path
    //! (Finding.func_id → sqlite column); these tests cover the analysis
    //! path (TaintFinding.func_id → Finding.func_id). Together they
    //! prevent the round-trip-via-string regression that was the v2 bug.

    use super::*;
    use droidsaw_common::analysis::{TaintFinding, TaintSink, TaintSource};
    use droidsaw_common::finding::Layer;

    fn synth_dex_tf(func_id: u32) -> TaintFinding {
        TaintFinding {
            source: TaintSource::IntentExtra { key: "url".into() },
            sink: TaintSink::WebViewLoadUrl,
            layer: Layer::Dex,
            func_id,
            class_descriptor: None,
            method_signature: None,
            source_offset: None,
            sink_offset: None,
        }
    }

    fn synth_hbc_tf(func_id: u32) -> TaintFinding {
        TaintFinding {
            source: TaintSource::NativeModuleCall { module: "M".into(), method: "m".into() },
            sink: TaintSink::Eval,
            layer: Layer::Hbc,
            func_id,
            class_descriptor: None,
            method_signature: None,
            source_offset: None,
            sink_offset: None,
        }
    }

    #[test]
    fn file_path_traversal_cross_boundary_stays_medium() {
        // Cross-boundary source = attacker can influence the path → Medium even in
        // non-first-party / unknown locality (must never be demoted by locality).
        assert_eq!(
            file_path_traversal_severity(
                &TaintSource::IntentExtra { key: String::new() },
                Some("Lcom/bumptech/glide/Foo;"), Some("org.example.app")),
            Severity::Medium
        );
        assert_eq!(
            file_path_traversal_severity(
                &TaintSource::NetworkResponse { endpoint: String::new() }, None, None),
            Severity::Medium
        );
    }

    #[test]
    fn file_path_traversal_internal_in_library_drops_to_low() {
        let pkg = Some("org.example.app");
        // app-internal (own SharedPreferences) → bundled-library class → Low.
        assert_eq!(
            file_path_traversal_severity(
                &TaintSource::SharedPreferencesRead { key: String::new() },
                Some("Lcom/bumptech/glide/Foo;"), pkg),
            Severity::Low
        );
        // ambiguous (content-URI read) → library class → Low (the dominant pattern).
        assert_eq!(
            file_path_traversal_severity(
                &TaintSource::ContentProviderQuery { uri: String::new() },
                Some("Lcom/facebook/imagepipeline/Bar;"), pkg),
            Severity::Low
        );
    }

    #[test]
    fn file_path_traversal_first_party_stays_medium() {
        // ambiguous source but first-party app code → Medium (the app's own path
        // handling, which it can act on).
        assert_eq!(
            file_path_traversal_severity(
                &TaintSource::ContentProviderQuery { uri: String::new() },
                Some("Lorg/example/app/FileResolver;"), Some("org.example.app")),
            Severity::Medium
        );
        // No package → locality undeterminable → keep Medium (no demote on missing data).
        assert_eq!(
            file_path_traversal_severity(
                &TaintSource::ContentProviderQuery { uri: String::new() },
                Some("Lcom/x/Y;"), None),
            Severity::Medium
        );
    }

    #[test]
    fn is_first_party_class_reverse_domain_match() {
        assert_eq!(is_first_party_class(Some("Lorg/example/app/Foo;"), Some("org.example.app")), Some(true));
        assert_eq!(is_first_party_class(Some("Lorg/example/app/sub/Foo;"), Some("org.example.app.feature")), Some(true));
        assert_eq!(is_first_party_class(Some("Lcom/bumptech/glide/Foo;"), Some("org.example.app")), Some(false));
        // near-miss prefix must not false-match (com.app.thing vs com.apple)
        assert_eq!(is_first_party_class(Some("Lcom/apple/Foo;"), Some("com.app.thing")), Some(false));
        assert_eq!(is_first_party_class(None, Some("org.example.app")), None);
        assert_eq!(is_first_party_class(Some("Lorg/example/app/Foo;"), None), None);
        assert_eq!(is_first_party_class(Some("Lorg/example/app/Foo;"), Some("")), None);
    }

    #[test]
    fn taint_finding_to_finding_propagates_func_id() {
        let f = taint_finding_to_finding(&synth_dex_tf(0x176d5), None);
        assert_eq!(f.func_id, Some(0x176d5));
    }

    #[test]
    fn hbc_taint_to_finding_propagates_func_id() {
        let f = hbc_taint_to_finding(&synth_hbc_tf(0xdeadbeef));
        assert_eq!(f.func_id, Some(0xdeadbeef));
    }

    #[test]
    fn bridge_taint_to_finding_propagates_func_id() {
        let f = bridge_taint_to_finding(&synth_dex_tf(0x42), "fetchSecret");
        assert_eq!(f.func_id, Some(0x42));
    }

    #[test]
    fn dex_taint_finding_offsets_stored_in_extra() {
        // When source_offset / sink_offset are populated on a DEX TaintFinding,
        // taint_finding_to_finding must encode them in Finding.extra JSON so
        // write_taint_flows_db can read them back into the DB columns.
        let tf = TaintFinding {
            source: TaintSource::IntentExtra { key: "url".into() },
            sink: TaintSink::WebViewLoadUrl,
            layer: Layer::Dex,
            func_id: 0x100,
            class_descriptor: None,
            method_signature: None,
            source_offset: Some(0x04),
            sink_offset: Some(0x1a),
        };
        let f = taint_finding_to_finding(&tf, None);
        let extra = f.extra.as_deref().unwrap_or("");
        let v: serde_json::Value = serde_json::from_str(extra).expect("extra must be valid JSON");
        assert_eq!(v["source_offset"], serde_json::json!(4));
        assert_eq!(v["sink_offset"],   serde_json::json!(26));
    }

    #[test]
    fn taint_finding_native_method_emits_jni_id_tag() {
        // JNI-bridges minimum-tier wiring: TaintSink::NativeMethod must
        // route through the JNI_TAINTED_NATIVE_CALL id_tag, not the
        // generic DEX_TAINT_FLOW. Severity Medium, CWE-111 (Direct Use
        // of Unsafe JNI). Detail string carries `jni boundary:` prefix
        // so analysts can grep cleanly.
        let tf = TaintFinding {
            source: TaintSource::IntentExtra { key: "key".into() },
            sink: TaintSink::NativeMethod {
                class: "Lcom/example/JniBridge;".into(),
                method: "nativeDecrypt".into(),
            },
            layer: Layer::Dex,
            func_id: 0xa00,
            class_descriptor: None,
            method_signature: None,
            source_offset: Some(0x10),
            sink_offset: Some(0x20),
        };
        let f = taint_finding_to_finding(&tf, None);
        assert_eq!(f.id, "JNI_TAINTED_NATIVE_CALL");
        assert_eq!(f.severity, Severity::Medium);
        assert_eq!(f.cwe, Some(111));
        assert!(
            f.detail.starts_with("jni boundary:"),
            "detail must carry the JNI prefix, got: {}",
            f.detail,
        );
        // The class + method descriptors must appear in the detail so
        // an analyst can identify the JNI handoff without parsing extra.
        assert!(f.detail.contains("Lcom/example/JniBridge;"));
        assert!(f.detail.contains("nativeDecrypt"));
    }

    #[test]
    fn hbc_taint_finding_offsets_remain_none_in_extra() {
        // HBC findings carry no instruction-level address; source_offset and
        // sink_offset stay None. taint_finding_to_finding must not introduce
        // extra JSON on HBC paths (hbc_taint_to_finding produces no extra).
        let tf_hbc = synth_hbc_tf(0xbeef);
        let f = hbc_taint_to_finding(&tf_hbc);
        // hbc factory does not set .extra at all.
        assert!(f.extra.is_none(), "HBC findings must not carry extra JSON");
    }

    #[test]
    fn dex_taint_offsets_persist_to_db() {
        // End-to-end: TaintFinding with offsets → taint_finding_to_finding →
        // write_findings_db + write_taint_flows_db → SQL query asserts
        // source_offset IS NOT NULL and sink_offset IS NOT NULL.
        let tf = TaintFinding {
            source: TaintSource::IntentExtra { key: String::new() },
            sink: TaintSink::WebViewLoadUrl,
            layer: Layer::Dex,
            func_id: 0x200,
            class_descriptor: None,
            method_signature: None,
            source_offset: Some(0x08),
            sink_offset: Some(0x2c),
        };
        let f = taint_finding_to_finding(&tf, None);
        let tmp = tempfile::NamedTempFile::new().expect("tempfile");
        write_findings_db(std::slice::from_ref(&f), tmp.path()).expect("write_findings_db");
        let n = write_taint_flows_db(std::slice::from_ref(&f), tmp.path())
            .expect("write_taint_flows_db");
        assert_eq!(n, 1);

        let conn = rusqlite::Connection::open(tmp.path()).expect("open");
        let (src_off, snk_off): (Option<i64>, Option<i64>) = conn
            .query_row(
                "SELECT source_offset, sink_offset FROM taint_flows LIMIT 1",
                [],
                |r| Ok((r.get(0)?, r.get(1)?)),
            )
            .expect("query");
        assert_eq!(src_off, Some(0x08), "source_offset must be non-None for DEX taint");
        assert_eq!(snk_off, Some(0x2c), "sink_offset must be non-None for DEX taint");
    }

    #[test]
    fn parse_taint_source_sink_handles_semicolon_suffix() {
        // The detail-string suffix that broke the old `parse_taint_detail`
        // — the source/sink half stays intact even with the `;`-tail.
        let detail = "intra-method taint: IntentExtra → WebViewLoadUrl (func #0x176d5; interprocedural cross-DEX depth 4)";
        let (source, sink) = super::export::parse_taint_source_sink(detail);
        assert_eq!(source, "IntentExtra");
        assert_eq!(sink, "WebViewLoadUrl");
    }
}

#[cfg(test)]
mod dynamic_loading_gate_tests {
    //! Lock the `DYNAMIC_LOADING` xref-gate's three-way disjunction +
    //! token classifier. The heuristic was firing on type-pool-only
    //! references with no actual function calls; the gate downgrades or
    //! re-details the finding when the dex xref index has nothing.
    //!
    //! These tests cover the pure helpers
    //! (`classify_dynamic_loading_token`,
    //! `dynamic_loading_token_referenced`) against synthetic `Xrefs`
    //! values. The full `gate_dynamic_loading` requires a built APK
    //! `Apk` plus DEX bytes (out of scope for an inline unit test —
    //! handled by the integration-level audit smoke when a
    //! DexClassLoader fixture is added in a follow-up).

    use super::*;
    use droidsaw_dex::xrefs::{MethodKey, Xrefs};

    fn mk_method_key(class: &str, name: &str) -> MethodKey {
        MethodKey {
            class: class.into(),
            name: name.into(),
            proto: "()V".into(),
        }
    }

    #[test]
    fn classify_descriptor_form_yields_descriptor_and_dot_form() {
        let pair = classify_dynamic_loading_token("Ldalvik/system/DexClassLoader;");
        assert_eq!(
            pair,
            Some((
                "Ldalvik/system/DexClassLoader;".to_string(),
                "dalvik.system.DexClassLoader".to_string(),
            ))
        );
    }

    #[test]
    fn classify_dot_form_yields_descriptor_and_dot_form() {
        let pair = classify_dynamic_loading_token("dalvik.system.DexClassLoader");
        assert_eq!(
            pair,
            Some((
                "Ldalvik/system/DexClassLoader;".to_string(),
                "dalvik.system.DexClassLoader".to_string(),
            ))
        );
    }

    #[test]
    fn classify_bare_class_name_resolves_via_known_table() {
        let pair = classify_dynamic_loading_token("DexClassLoader");
        assert_eq!(
            pair,
            Some((
                "Ldalvik/system/DexClassLoader;".to_string(),
                "dalvik.system.DexClassLoader".to_string(),
            ))
        );
    }

    #[test]
    fn classify_loadlibrary_token_returns_none() {
        // The `loadLibrary` + `System` substring match in the
        // apk-layer detector is a method-invocation pattern, not a
        // class-load token — the gate skips it.
        assert!(classify_dynamic_loading_token("loadLibrary").is_none());
        assert!(classify_dynamic_loading_token("System").is_none());
    }

    #[test]
    fn classify_generic_descriptor_form_works() {
        let pair = classify_dynamic_loading_token("Lcom/foo/Bar;");
        assert_eq!(
            pair,
            Some(("Lcom/foo/Bar;".to_string(), "com.foo.Bar".to_string()))
        );
    }

    #[test]
    fn classify_malformed_descriptor_returns_none() {
        // Empty inner.
        assert!(classify_dynamic_loading_token("L;").is_none());
        // Single-char `L`.
        assert!(classify_dynamic_loading_token("L").is_none());
        // Bare identifier (no `.`, not in the known table).
        assert!(classify_dynamic_loading_token("RandomClassName").is_none());
    }

    #[test]
    fn unreferenced_token_returns_false_against_empty_xrefs() {
        let xrefs = Xrefs::default();
        assert!(!dynamic_loading_token_referenced(
            &xrefs,
            "dalvik.system.DexClassLoader"
        ));
        assert!(!dynamic_loading_token_referenced(
            &xrefs,
            "Ldalvik/system/DexClassLoader;"
        ));
        assert!(!dynamic_loading_token_referenced(&xrefs, "DexClassLoader"));
    }

    #[test]
    fn type_xref_hit_resolves_descriptor_and_dot_form() {
        let mut xrefs = Xrefs::default();
        xrefs
            .type_xrefs
            .entry("Ldalvik/system/DexClassLoader;".to_string())
            .or_default()
            .push(mk_method_key("Lcom/app/Loader;", "load"));

        // Direct descriptor hit.
        assert!(dynamic_loading_token_referenced(
            &xrefs,
            "Ldalvik/system/DexClassLoader;"
        ));
        // Dot-form hit (resolves to the same descriptor via
        // classifier).
        assert!(dynamic_loading_token_referenced(
            &xrefs,
            "dalvik.system.DexClassLoader"
        ));
        // Bare class name (resolves via known table).
        assert!(dynamic_loading_token_referenced(&xrefs, "DexClassLoader"));
    }

    #[test]
    fn string_to_methods_dot_form_hit_resolves() {
        // Reflective `Class.forName("dalvik.system.DexClassLoader")`
        // — the const-string load lands in `string_to_methods`.
        let mut xrefs = Xrefs::default();
        xrefs
            .string_to_methods
            .entry("dalvik.system.DexClassLoader".to_string())
            .or_default()
            .push(mk_method_key("Lcom/app/Loader;", "load"));

        // The descriptor-form lookup must still resolve via the
        // classifier's dot-form derivation.
        assert!(dynamic_loading_token_referenced(
            &xrefs,
            "Ldalvik/system/DexClassLoader;"
        ));
        // Direct dot-form match.
        assert!(dynamic_loading_token_referenced(
            &xrefs,
            "dalvik.system.DexClassLoader"
        ));
    }

    #[test]
    fn string_to_methods_descriptor_form_hit_resolves() {
        // Rare reflective form: `Class.forName("Ldalvik/system/DexClassLoader;")`.
        let mut xrefs = Xrefs::default();
        xrefs
            .string_to_methods
            .entry("Ldalvik/system/DexClassLoader;".to_string())
            .or_default()
            .push(mk_method_key("Lcom/app/Loader;", "load"));

        // Direct descriptor-form lookup (raw match in
        // string_to_methods).
        assert!(dynamic_loading_token_referenced(
            &xrefs,
            "Ldalvik/system/DexClassLoader;"
        ));
    }

    #[test]
    fn raw_string_match_in_string_to_methods_resolves() {
        // The detector also matches arbitrary substrings; if `s`
        // itself appears as a const-string, that's a real reference
        // regardless of the classifier.
        let mut xrefs = Xrefs::default();
        xrefs
            .string_to_methods
            .entry("loadLibrary".to_string())
            .or_default()
            .push(mk_method_key("Lcom/app/Reflective;", "call"));

        assert!(dynamic_loading_token_referenced(&xrefs, "loadLibrary"));
    }

    #[test]
    fn partition_empty_xrefs_groups_all_class_load_as_unreferenced() {
        // Core case: type descriptor present in the string pool,
        // no function references — classified as unreferenced.
        let xrefs = Xrefs::default();
        let strings = vec![
            "Ldalvik/system/DexClassLoader;".to_string(),
            "dalvik.system.DexClassLoader".to_string(),
        ];

        let (referenced, unreferenced, non_class) =
            partition_dynamic_loading_strings(&xrefs, &strings);

        assert!(referenced.is_empty(), "no xref entries ⇒ no referenced");
        assert_eq!(unreferenced.len(), 2);
        assert!(non_class.is_empty());
    }

    #[test]
    fn partition_with_xref_hit_groups_class_load_as_referenced() {
        let mut xrefs = Xrefs::default();
        xrefs
            .type_xrefs
            .entry("Ldalvik/system/DexClassLoader;".to_string())
            .or_default()
            .push(mk_method_key("Lcom/app/Loader;", "load"));

        let strings = vec![
            "Ldalvik/system/DexClassLoader;".to_string(),
            "Ldalvik/system/PathClassLoader;".to_string(),
        ];

        let (referenced, unreferenced, non_class) =
            partition_dynamic_loading_strings(&xrefs, &strings);

        assert_eq!(referenced, vec!["Ldalvik/system/DexClassLoader;".to_string()]);
        assert_eq!(
            unreferenced,
            vec!["Ldalvik/system/PathClassLoader;".to_string()]
        );
        assert!(non_class.is_empty());
    }

    #[test]
    fn partition_loadlibrary_grouped_as_non_class_load() {
        let xrefs = Xrefs::default();
        let strings = vec![
            "loadLibrary in System".to_string(),
            "Ldalvik/system/DexClassLoader;".to_string(),
        ];

        let (_referenced, unreferenced, non_class) =
            partition_dynamic_loading_strings(&xrefs, &strings);

        // `loadLibrary in System` (with spaces, no `.`, not in the
        // known table) is rejected by classify ⇒ non-class-load.
        assert_eq!(non_class, vec!["loadLibrary in System".to_string()]);
        // The DexClassLoader descriptor is class-load and unreferenced.
        assert_eq!(
            unreferenced,
            vec!["Ldalvik/system/DexClassLoader;".to_string()]
        );
    }

    #[test]
    fn apply_partition_downgrades_when_all_unreferenced() {
        let mut finding = Finding::new(
            "DYNAMIC_LOADING",
            droidsaw_common::Layer::Apk,
            Severity::Medium,
            "Dynamic code loading in DEX strings: Ldalvik/system/DexClassLoader;",
        );
        let referenced: Vec<String> = Vec::new();
        let unreferenced = vec!["Ldalvik/system/DexClassLoader;".to_string()];
        let non_class: Vec<String> = Vec::new();

        apply_dynamic_loading_partition_to_finding(
            &mut finding,
            &referenced,
            &unreferenced,
            &non_class,
        );

        assert_eq!(finding.severity, Severity::Info);
        assert!(
            finding
                .detail
                .contains("present in DEX string pool but no function references"),
            "detail must explain the type-pool-only case; got {:?}",
            finding.detail
        );
        assert!(
            finding.detail.contains("Ldalvik/system/DexClassLoader;"),
            "detail must list the unreferenced descriptors"
        );
    }

    #[test]
    fn apply_partition_keeps_medium_when_any_referenced() {
        let mut finding = Finding::new(
            "DYNAMIC_LOADING",
            droidsaw_common::Layer::Apk,
            Severity::Medium,
            "Dynamic code loading in DEX strings: Ldalvik/system/DexClassLoader;",
        );
        let referenced = vec!["Ldalvik/system/DexClassLoader;".to_string()];
        let unreferenced: Vec<String> = Vec::new();
        let non_class: Vec<String> = Vec::new();

        apply_dynamic_loading_partition_to_finding(
            &mut finding,
            &referenced,
            &unreferenced,
            &non_class,
        );

        assert_eq!(finding.severity, Severity::Medium);
        assert!(
            finding
                .detail
                .contains("function references confirmed via dex xref index"),
            "detail must reflect xref confirmation; got {:?}",
            finding.detail
        );
    }

    #[test]
    fn apply_partition_keeps_medium_with_unreferenced_note_when_partial() {
        let mut finding = Finding::new(
            "DYNAMIC_LOADING",
            droidsaw_common::Layer::Apk,
            Severity::Medium,
            "Dynamic code loading in DEX strings: A, B",
        );
        let referenced = vec!["Ldalvik/system/DexClassLoader;".to_string()];
        let unreferenced = vec!["Ldalvik/system/PathClassLoader;".to_string()];
        let non_class: Vec<String> = Vec::new();

        apply_dynamic_loading_partition_to_finding(
            &mut finding,
            &referenced,
            &unreferenced,
            &non_class,
        );

        assert_eq!(finding.severity, Severity::Medium);
        assert!(finding.detail.contains("Ldalvik/system/DexClassLoader;"));
        assert!(finding.detail.contains("Ldalvik/system/PathClassLoader;"));
        assert!(
            finding.detail.contains("not counted toward this finding"),
            "detail must call out the gated descriptors; got {:?}",
            finding.detail
        );
    }

    #[test]
    fn apply_partition_keeps_medium_when_only_non_class_load() {
        // The `loadLibrary`/`System` pattern only — gate doesn't
        // constrain it, so the finding stays Medium.
        let mut finding = Finding::new(
            "DYNAMIC_LOADING",
            droidsaw_common::Layer::Apk,
            Severity::Medium,
            "Dynamic code loading in DEX strings: System loadLibrary call",
        );
        let referenced: Vec<String> = Vec::new();
        let unreferenced: Vec<String> = Vec::new();
        let non_class = vec!["System loadLibrary call".to_string()];

        apply_dynamic_loading_partition_to_finding(
            &mut finding,
            &referenced,
            &unreferenced,
            &non_class,
        );

        assert_eq!(finding.severity, Severity::Medium);
        assert!(finding.detail.contains("System loadLibrary call"));
    }
}

#[cfg(test)]
mod detector_status_tests {
    //! Lock the `detectors` field shape emitted by `audit_light_with_mode`
    //! / `audit_full_with_mode`. Surfaces honestly what ran vs what was
    //! skipped, so a CLI user (or downstream LLM agent) can grep the
    //! field and know the audit's coverage without re-deriving it from
    //! the mode flag + a knowledge of which subprocesses the CLI does
    //! or doesn't wire.
    use super::*;
    use droidsaw_cli_contract::AuditMode;

    fn status_for(mode: AuditMode, detector: &str) -> String {
        let v = build_detectors_status(mode, None);
        v.get(detector)
            .and_then(|d| d.get("status"))
            .and_then(|s| s.as_str())
            .expect("detector status field present")
            .to_string()
    }

    fn status_for_with_trufflehog(
        mode: AuditMode,
        trufflehog_result: &Value,
        detector: &str,
    ) -> String {
        let v = build_detectors_status(mode, Some(trufflehog_result));
        v.get(detector)
            .and_then(|d| d.get("status"))
            .and_then(|s| s.as_str())
            .expect("detector status field present")
            .to_string()
    }

    #[test]
    fn basic_mode_skips_both_subprocess_detectors() {
        assert_eq!(status_for(AuditMode::Basic, "yara"), "ran");
        assert_eq!(status_for(AuditMode::Basic, "semgrep"), "skipped_by_mode");
        assert_eq!(status_for(AuditMode::Basic, "trufflehog"), "skipped_by_mode");
    }

    #[test]
    fn full_mode_with_ran_trufflehog_envelope_reports_ran() {
        // Helper returned the full-success envelope; status surfaces it.
        let envelope = json!({
            "ran": true,
            "hit_count": 5,
            "verified_count": 2,
            "unverified_count": 3,
            "credentials_written": 5,
            "db_table": "credentials",
            "note": "…",
        });
        assert_eq!(
            status_for_with_trufflehog(AuditMode::Full, &envelope, "yara"),
            "ran",
        );
        assert_eq!(
            status_for_with_trufflehog(AuditMode::Full, &envelope, "semgrep"),
            "extracted",
        );
        assert_eq!(
            status_for_with_trufflehog(AuditMode::Full, &envelope, "trufflehog"),
            "ran",
        );
    }

    #[test]
    fn semgrep_mode_extracts_only_semgrep() {
        assert_eq!(status_for(AuditMode::Semgrep, "yara"), "ran");
        assert_eq!(status_for(AuditMode::Semgrep, "semgrep"), "extracted");
        assert_eq!(status_for(AuditMode::Semgrep, "trufflehog"), "skipped_by_mode");
    }

    #[test]
    fn trufflehog_mode_with_error_envelope_reports_error() {
        let envelope = json!({
            "ran": false,
            "error": "spawn failed: ENOENT",
        });
        assert_eq!(
            status_for_with_trufflehog(AuditMode::Trufflehog, &envelope, "yara"),
            "ran",
        );
        assert_eq!(
            status_for_with_trufflehog(AuditMode::Trufflehog, &envelope, "semgrep"),
            "skipped_by_mode",
        );
        assert_eq!(
            status_for_with_trufflehog(AuditMode::Trufflehog, &envelope, "trufflehog"),
            "error",
        );
    }

    #[test]
    fn trufflehog_short_circuit_envelope_disambiguates_via_path_check() {
        // Helper returned the "ran=false" short-circuit envelope (binary
        // missing OR no strings produced). Status disambiguates by
        // consulting $PATH at status-build time. We can't force the
        // outcome here (depends on host PATH); we just assert that the
        // status is one of the two expected disambiguated values.
        let envelope = json!({
            "ran": false,
            "strings_file": "/tmp/droidsaw-strings.txt",
            "command": "trufflehog filesystem /tmp/droidsaw-strings.txt --json --no-verification",
        });
        let status = status_for_with_trufflehog(
            AuditMode::Trufflehog,
            &envelope,
            "trufflehog",
        );
        assert!(
            status == "binary_missing" || status == "no_strings_extracted",
            "expected binary_missing or no_strings_extracted, got {status:?}",
        );
    }

    #[test]
    fn collect_apk_findings_surfaces_hermes_findings_from_context() {
        // Wave-1-re-review regression test: the per-thread HermesFinding
        // channel that `droidsaw-hermes::emit_finding` writes to is now
        // drained at Context::parse time, translated to common::Finding
        // via `findings_as_common`, and stashed on `ctx.hermes_findings`.
        // `collect_apk_findings` MUST consume from that field so the
        // audit envelope includes the V98FormAmbiguous + sibling
        // HermesFinding variants — without this wiring the Finding
        // stream was wallpaper.
        use crate::context::CrossLayerContext;
        use droidsaw_common::finding::{Confidence, Finding, Layer, Severity};
        let mut f = Finding::new(
            "HERMES_V98_FORM_AMBIGUOUS",
            Layer::Hbc,
            Severity::Low,
            "synthetic regression test".to_string(),
        );
        f.confidence = Confidence::Verified;
        let ctx = CrossLayerContext {
            path: "test".to_string(),
            apk: None,
            hbc: None,
            hbc_parse_error: None,
            dex: Vec::new(),
            dex_direct_bytes: None,
            loaded_split_names: Vec::new(),
            hermes_findings: vec![f.clone()],
            permissive_recovery: droidsaw_apk::PermissiveRecoveryOpts::default(),
        };
        let out = collect_apk_findings(&ctx, 4.5);
        assert!(
            out.iter().any(|x| x.id == "HERMES_V98_FORM_AMBIGUOUS"),
            "collect_apk_findings must surface ctx.hermes_findings into the audit envelope; got {:?}",
            out.iter().map(|x| &x.id).collect::<Vec<_>>()
        );
    }

    #[test]
    fn sanitize_detail_strips_terminal_escape_sequences() {
        // Regression: attacker-controlled ZIP entry names with control
        // bytes would propagate into Finding.detail and reach a terminal
        // via stderr surfacing. Sanitize at the boundary.
        let attacker_input = "evil.apk\x1b[2Jcleared\x07bell\x00null";
        let sanitized = super::sanitize_detail(attacker_input);
        assert_eq!(
            sanitized, "evil.apk?[2Jcleared?bell?null",
            "control bytes (0x1b, 0x07, 0x00) must be replaced with `?`"
        );
        // Allowed: printable ASCII + tab + newline.
        let ok_input = "META-INF/MANIFEST.MF\tat line\n123";
        assert_eq!(super::sanitize_detail(ok_input), ok_input);
    }

    #[test]
    fn cap_findings_truncates_above_threshold_with_synthetic_marker() {
        // Regression: unbounded collect_* walker output is a DoS primitive
        // (attacker DEX with N malformed sub-sections → N Findings per kind).
        // Cap defends the audit envelope.
        use droidsaw_common::finding::{Layer, Severity};
        let mut v: Vec<Finding> = Vec::new();
        for i in 0..300 {
            v.push(Finding::new(
                "DEX_DETECTOR_INDETERMINATE",
                Layer::Dex,
                Severity::Low,
                format!("detector-indeterminate at offset {i}"),
            ));
        }
        let capped = super::cap_findings(v, "DEX_DETECTOR_INDETERMINATE_TRUNCATED", Layer::Dex);
        assert_eq!(
            capped.len(),
            super::FINDING_CAP_PER_KIND + 1,
            "capped Vec must be cap + 1 (truncation marker)"
        );
        let last = capped.last().expect("non-empty after cap");
        assert_eq!(last.id, "DEX_DETECTOR_INDETERMINATE_TRUNCATED");
        assert!(
            last.detail.contains("44 additional"),
            "truncation Finding must report the dropped count (300 - 256 = 44); got {:?}",
            last.detail
        );
    }

    #[test]
    fn cap_findings_below_threshold_is_unchanged() {
        use droidsaw_common::finding::{Layer, Severity};
        let mut v: Vec<Finding> = Vec::new();
        for i in 0..10 {
            v.push(Finding::new(
                "DEX_HEADER_MAP",
                Layer::Dex,
                Severity::Low,
                format!("hdr-map at offset {i}"),
            ));
        }
        let capped = super::cap_findings(v.clone(), "DEX_HEADER_MAP_TRUNCATED", Layer::Dex);
        assert_eq!(capped.len(), 10);
        assert!(capped.iter().all(|f| f.id == "DEX_HEADER_MAP"));
    }

    #[test]
    fn context_parse_clears_residual_hermes_findings_on_entry() {
        // Regression: emit a finding directly into the hermes thread-local
        // channel (simulating a prior parse that emitted then errored before
        // its caller drained), then start a fresh `Context::parse` and
        // verify the entry-of-parse `discard_findings()` clears the channel.
        // Without this guard,
        // a tokio `spawn_blocking` worker reused across tasks would
        // attribute prior-tenant findings to the new bundle.
        use droidsaw_hermes::finding::{
            discard_findings, drain_findings_for_test, emit_finding, HermesFinding,
        };

        // Ensure clean baseline.
        discard_findings();

        // Plant a "leaked" finding from a prior tenant.
        emit_finding(HermesFinding::V98FormAmbiguous {
            early_options: 0x00,
            late_options: 0x00,
            function_count: 1,
            debug_with: 0,
            debug_without: 0,
            picked_late: false,
        });
        assert_eq!(
            drain_findings_for_test().len(),
            1,
            "preconditions: emit_finding must populate the channel"
        );

        // Re-plant and call Context::parse on a path that errors fast
        // (non-existent file). The entry-of-parse discard MUST clear the
        // prior plant; the parse itself does not need to succeed for the
        // discard to fire (the discard runs before any I/O).
        emit_finding(HermesFinding::V98FormAmbiguous {
            early_options: 0x00,
            late_options: 0x00,
            function_count: 1,
            debug_with: 0,
            debug_without: 0,
            picked_late: false,
        });

        let nonexistent = std::path::Path::new("/tmp/droidsaw-test-finding-drain-nonexistent.bin");
        let _ = crate::context::CrossLayerContext::parse(nonexistent, None);

        // After parse returns (Err on missing file), the residual
        // finding must be gone.
        let leftover = drain_findings_for_test();
        assert!(
            leftover.is_empty(),
            "Context::parse must call discard_findings() on entry to clear cross-tenant residual; got {leftover:?}"
        );
    }

    #[test]
    fn hermes_finding_drain_guard_drops_drains_channel_on_scope_exit() {
        // Regression: RAII drain guard. The prior pattern's explicit-drain-
        // at-end leaked findings on I/O-`?`-before-drain paths (SIGPIPE,
        // ENOSPC). The RAII guard's Drop fires on every exit path — even
        // when a `?` or
        // `bail!` short-circuits before reaching the explicit drain.
        //
        // This test simulates the leak shape: enter a scope that
        // plants a finding into the TLS, then exit the scope via a
        // short-circuit-equivalent (here, just letting `_guard` drop
        // at the closure boundary). The channel must be empty after.
        use crate::context::HermesFindingDrainGuard;
        use droidsaw_hermes::finding::{
            discard_findings, drain_findings_for_test, emit_finding, HermesFinding,
        };

        // Baseline: clean channel.
        discard_findings();

        // Enter scope, install guard, plant finding, exit scope.
        // Mimics the shape: function entry installs guard → emit fires
        // somewhere mid-function → early return / `?` → guard Drops →
        // channel cleaned. Even though there's no explicit drain
        // before the early return.
        {
            let _guard = HermesFindingDrainGuard::install_discard();
            emit_finding(HermesFinding::OverflowIndexOutOfRange {
                index: 7,
                count: 3,
            });
            // Drop fires at end of this scope. No explicit drain.
        }

        let leftover = drain_findings_for_test();
        assert!(
            leftover.is_empty(),
            "HermesFindingDrainGuard's Drop must discard findings even when caller does not drain explicitly; got {leftover:?}"
        );
    }

    #[test]
    fn detectors_field_carries_binary_on_path_for_subprocess_detectors() {
        // The actual bool depends on the test host's PATH, but the
        // field must be present + boolean-typed for both subprocess
        // detectors regardless of mode.
        for mode in [AuditMode::Basic, AuditMode::Full, AuditMode::Semgrep, AuditMode::Trufflehog] {
            let v = build_detectors_status(mode, None);
            assert!(
                v.get("semgrep")
                    .and_then(|d| d.get("binary_on_path"))
                    .and_then(|b| b.as_bool())
                    .is_some(),
                "semgrep.binary_on_path must be a bool under mode={:?}",
                mode,
            );
            assert!(
                v.get("trufflehog")
                    .and_then(|d| d.get("binary_on_path"))
                    .and_then(|b| b.as_bool())
                    .is_some(),
                "trufflehog.binary_on_path must be a bool under mode={:?}",
                mode,
            );
        }
    }
}

#[cfg(test)]
mod meta_tool_version_tests {
    //! Lock the `_meta.tool_version` provenance field on the shared
    //! `meta()` funnel: every command payload built through it
    //! self-identifies the producing binary version. The value is
    //! `CARGO_PKG_VERSION` — build-constant, so determinism gauges
    //! (byte-identical output across runs of the same build) are
    //! unaffected.
    use super::meta;

    #[test]
    fn meta_tool_version_matches_cargo_pkg_version() {
        let m = meta(1, false, "hint", &["strings"]);
        assert_eq!(
            m.get("tool_version").and_then(|v| v.as_str()),
            Some(env!("CARGO_PKG_VERSION")),
            "_meta.tool_version must equal CARGO_PKG_VERSION; got: {m}",
        );
    }
}
