//! `droidsaw triage` — crash-bundle triage helpers.
//!
//! Today ships one subcommand:
//!
//! - `promote <bundle-dir> --input <path>` — converts a panic-hook crash
//!   bundle (emitted by `droidsaw_common::diag`) into an adversarial
//!   fixture under `<crate>/tests/fixtures/adversarial/<target>/<input_hash>.bin`
//!   and prints a `#[test]` scaffold mirroring the shape in the dex /
//!   hermes / apk `tests/adversarial.rs` files.
//!
//! `--dry-run` (default) only prints the plan. `--write` executes it.
//!
//! The CLI is deliberately a *scaffolding* tool: crate / target / Err
//! variant inference is a best-effort heuristic. The output banner
//! always reminds the human to review before committing.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, anyhow, bail};
use clap::{Args, Subcommand};
use serde::Deserialize;
use serde_json::{Value, json};

/// `droidsaw triage <subcommand>` dispatch table.
#[derive(Subcommand)]
pub enum TriageCommands {
    /// Promote a panic-hook diag bundle into an adversarial fixture + lock-test stub.
    Promote(PromoteArgs),
}

#[derive(Args)]
pub struct PromoteArgs {
    /// Path to the bundle directory (contains `metadata.json` + `backtrace.txt` + `stage-NN-<name>.ron`).
    pub bundle: PathBuf,

    /// Path to the original input bytes. Required unless the bundle has a sibling `input.bin`.
    #[arg(long, value_name = "PATH")]
    pub input: Option<PathBuf>,

    /// Override crate inference (e.g. `droidsaw-dex`, `droidsaw-hermes`, `droidsaw-apk`).
    #[arg(long = "crate", value_name = "NAME")]
    pub crate_override: Option<String>,

    /// Override fuzz-target inference (e.g. `fuzz_parser`, `fuzz_cfg`, `fuzz_ssa`).
    #[arg(long, value_name = "TARGET")]
    pub target: Option<String>,

    /// Workspace root (parent of `<crate>/` directories). Defaults to the bundle's working directory.
    #[arg(long, value_name = "PATH")]
    pub workspace: Option<PathBuf>,

    /// Execute the plan: copy input + append test stub to `<crate>/tests/adversarial.rs`.
    #[arg(long)]
    pub write: bool,
}

// ── metadata.json shape ──────────────────────────────────────────────
//
// Mirrors `droidsaw_common::diag::BundleMetadata` with `#[derive(Deserialize)]`.
// We only consume fields we need — unknown fields are allowed so a bundle
// from a newer diag version doesn't break triage.

#[derive(Debug, Deserialize)]
struct BundleMetadata {
    #[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
    version: u32,
    panic: PanicMeta,
    input_hash: Option<String>,
    #[serde(default)]
    stages: Vec<StageDesc>,
}

#[derive(Debug, Deserialize)]
struct PanicMeta {
    file: Option<String>,
    line: Option<u32>,
    col: Option<u32>,
    message: String,
    #[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
    thread: String,
}

#[derive(Debug, Deserialize)]
struct StageDesc {
    #[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
    seq: usize,
    name: String,
    #[allow(dead_code, reason = "Deserialize-only struct field — included for protocol completeness (matches droidsaw_common::diag::BundleMetadata) but unread by the triage consumer.")]
    file: String,
}

// ── inference ────────────────────────────────────────────────────────

/// Workspace crates we know how to promote into.
///
/// INFERENCE: `Common` is listed so the classifier can report it as an
/// ambiguity (a bundle whose deepest real frame lives inside
/// `droidsaw_common::*` has no obvious consumer crate). Crates cut
/// from v1 are not modelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KnownCrate {
    Dex,
    Hermes,
    Apk,
    Common,
}

impl KnownCrate {
    fn module_prefix(self) -> &'static str {
        match self {
            KnownCrate::Dex => "droidsaw_dex",
            KnownCrate::Hermes => "droidsaw_hermes",
            KnownCrate::Apk => "droidsaw_apk",
            KnownCrate::Common => "droidsaw_common",
        }
    }

    fn crate_dir(self) -> &'static str {
        match self {
            KnownCrate::Dex => "droidsaw-dex",
            KnownCrate::Hermes => "droidsaw-hermes",
            KnownCrate::Apk => "droidsaw-apk",
            KnownCrate::Common => "droidsaw-common",
        }
    }

    fn from_crate_override(name: &str) -> Option<Self> {
        match name {
            "droidsaw-dex" | "droidsaw_dex" | "dex" => Some(KnownCrate::Dex),
            "droidsaw-hermes" | "droidsaw_hermes" | "hermes" => Some(KnownCrate::Hermes),
            "droidsaw-apk" | "droidsaw_apk" | "apk" => Some(KnownCrate::Apk),
            "droidsaw-common" | "droidsaw_common" | "common" => Some(KnownCrate::Common),
            _ => None,
        }
    }
}

/// Scan the backtrace for the deepest (= closest to panic) frame whose
/// module path lives inside a known crate. Returns:
///
/// - `Ok(c)` — exactly one non-`Common` crate seen; `c` is that crate.
/// - `Err(Ambiguous { crates })` — multiple non-`Common` crates seen.
/// - `Err(Unknown)` — no frames match; require `--crate`.
///
/// INFERENCE: Every bundle contains `droidsaw_common::diag::*` frames
/// (that's the panic hook itself). We treat `Common` as a signal only
/// when *no* other crate's frames appear — otherwise a real downstream
/// panic wins. A bundle from inside common's own parser would come back
/// as `Common`, and the human picks the right crate via `--crate`.
fn infer_crate(backtrace: &str) -> Result<KnownCrate, InferError> {
    let mut seen: Vec<KnownCrate> = Vec::new();

    for line in backtrace.lines() {
        for candidate in [
            KnownCrate::Dex,
            KnownCrate::Hermes,
            KnownCrate::Apk,
            KnownCrate::Common,
        ] {
            if line.contains(candidate.module_prefix())
                && !seen.contains(&candidate)
            {
                seen.push(candidate);
            }
        }
    }

    let non_common: Vec<KnownCrate> =
        seen.iter().copied().filter(|c| *c != KnownCrate::Common).collect();

    match non_common.as_slice() {
        [] => {
            if seen.contains(&KnownCrate::Common) {
                Err(InferError::Ambiguous {
                    reason: "backtrace only contains `droidsaw_common::*` frames \
                             (panic-hook infra); panic site is in common itself or \
                             outside the workspace — pass --crate to disambiguate"
                        .to_string(),
                })
            } else {
                Err(InferError::Unknown {
                    reason: "no workspace crate frames (dex / hermes / apk / common) \
                             found in backtrace — pass --crate to override"
                        .to_string(),
                })
            }
        }
        [only] => Ok(*only),
        _ => Err(InferError::Ambiguous {
            reason: format!(
                "multiple workspace crates in backtrace: {:?} — pass --crate to pick one",
                non_common.iter().map(|c| c.crate_dir()).collect::<Vec<_>>(),
            ),
        }),
    }
}

/// Fuzz target name. String-typed because different crates expose
/// different target sets (apk has `fuzz_binary_xml`, dex doesn't).
#[derive(Debug, Clone, PartialEq, Eq)]
struct FuzzTarget(String);

impl FuzzTarget {
    fn as_str(&self) -> &str {
        &self.0
    }
}

// INFERENCE: (crate, frame-substring) → fuzz target. The classifier walks
// the backtrace top-to-bottom (closest-to-panic first) and returns the
// first matching mapping. Order within one crate is specificity-first —
// `Cfg::build` beats a generic `parse` hit in the same frame.
//
// Each mapping is a function-name or module-path substring that is
// distinctive enough to separate the target. Keep mappings narrow; a
// shared pipeline step like `parse_code_item` is worth disambiguating
// only when the existing fuzz targets actually distinguish on it.
const TARGET_MAP: &[(KnownCrate, &str, &str)] = &[
    // dex
    (KnownCrate::Dex, "build_ssa", "fuzz_ssa"),
    (KnownCrate::Dex, "SsaBody::build", "fuzz_ssa"),
    (KnownCrate::Dex, "ssa::Builder", "fuzz_ssa"),
    (KnownCrate::Dex, "::braun::", "fuzz_ssa"),
    (KnownCrate::Dex, "Cfg::build", "fuzz_cfg"),
    (KnownCrate::Dex, "cfg::Cfg::", "fuzz_cfg"),
    (KnownCrate::Dex, "decode_insns", "fuzz_opcode_decode"),
    (KnownCrate::Dex, "decode::decode", "fuzz_opcode_decode"),
    (KnownCrate::Dex, "parse_code_item", "fuzz_opcode_decode"),
    (KnownCrate::Dex, "DexFile::parse", "fuzz_parser"),
    (KnownCrate::Dex, "parse_class_defs", "fuzz_parser"),
    (KnownCrate::Dex, "parse_class_data", "fuzz_parser"),
    (KnownCrate::Dex, "parse_strings", "fuzz_parser"),
    (KnownCrate::Dex, "parse_type_lists", "fuzz_parser"),
    (KnownCrate::Dex, "parser::", "fuzz_parser"),
    // hermes
    (KnownCrate::Hermes, "build_ssa", "fuzz_ssa"),
    (KnownCrate::Hermes, "ssa::build", "fuzz_ssa"),
    (KnownCrate::Hermes, "Cfg::build", "fuzz_cfg"),
    (KnownCrate::Hermes, "cfg::Cfg::", "fuzz_cfg"),
    (KnownCrate::Hermes, "decode_function", "fuzz_opcode_decode"),
    (KnownCrate::Hermes, "decode::decode", "fuzz_opcode_decode"),
    (KnownCrate::Hermes, "HbcFile::parse", "fuzz_parser"),
    (KnownCrate::Hermes, "parser::", "fuzz_parser"),
    // apk
    (KnownCrate::Apk, "BinaryXml::decode", "fuzz_binary_xml"),
    (KnownCrate::Apk, "binary_xml::", "fuzz_binary_xml"),
    (KnownCrate::Apk, "ResourceTable", "fuzz_arsc"),
    (KnownCrate::Apk, "resources::", "fuzz_arsc"),
    (KnownCrate::Apk, "parse_signing_block", "fuzz_signing_block"),
    (KnownCrate::Apk, "signing::", "fuzz_signing_block"),
    (KnownCrate::Apk, "Apk::parse", "fuzz_apk_parse"),
    (KnownCrate::Apk, "apk::", "fuzz_apk_parse"),
];

fn infer_target(backtrace: &str, krate: KnownCrate) -> Result<FuzzTarget, InferError> {
    for line in backtrace.lines() {
        for (c, needle, target) in TARGET_MAP {
            if *c == krate && line.contains(needle) {
                return Ok(FuzzTarget((*target).to_string()));
            }
        }
    }
    Err(InferError::Unknown {
        reason: format!(
            "no frame in backtrace matched a known fuzz-target pattern for {} — \
             pass --target to override",
            krate.crate_dir()
        ),
    })
}

#[derive(Debug)]
enum InferError {
    Unknown { reason: String },
    Ambiguous { reason: String },
}

impl std::fmt::Display for InferError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            InferError::Unknown { reason } | InferError::Ambiguous { reason } => {
                f.write_str(reason)
            }
        }
    }
}

impl std::error::Error for InferError {}

/// Guess the typed `Err` variant the lock-test should assert against.
///
/// Heuristic, based on panic message + final recorded stage. The human
/// must review — we always leave a TODO banner in the skeleton.
fn guess_err_variant(krate: KnownCrate, meta: &BundleMetadata) -> VariantGuess {
    let msg = meta.panic.message.to_ascii_lowercase();
    let last_stage = meta.stages.last().map(|s| s.name.as_str()).unwrap_or("");

    let guesses: Vec<&'static str> = match krate {
        KnownCrate::Dex => {
            let mut v: Vec<&'static str> = Vec::new();
            if msg.contains("overflow") {
                v.push("DexError::InvalidInstruction { .. }");
            }
            if msg.contains("slice index") || msg.contains("out of bounds") {
                v.push("DexError::Truncated { .. }");
                v.push("DexError::ScrollRead { .. }");
            }
            if msg.contains("allocation") || msg.contains("capacity") {
                v.push("DexError::CountExceedsInput { .. }");
            }
            if v.is_empty() {
                v.push("DexError::Truncated { .. }");
                v.push("DexError::InvalidInstruction { .. }");
                v.push("DexError::CountExceedsInput { .. }");
                v.push("DexError::ScrollRead { .. }");
            }
            v
        }
        KnownCrate::Hermes => {
            let mut v: Vec<&'static str> = Vec::new();
            if msg.contains("unsupported bytecode version") {
                v.push("HermesError::UnsupportedVersion { .. }");
            }
            if msg.contains("catch handler") || msg.contains("exception") {
                v.push("HermesError::InvalidExceptionLayout { .. }");
            }
            if v.is_empty() {
                v.push("HermesError::UnsupportedVersion { .. }");
                v.push("HermesError::InvalidExceptionLayout { .. }");
            }
            v
        }
        KnownCrate::Apk => {
            // apk errors are largely anyhow-flavoured; keep the guess loose.
            vec!["/* apk errors are message-typed — inspect err.to_string() */"]
        }
        KnownCrate::Common => vec!["/* common: pick the variant from the source location */"],
    };

    VariantGuess { candidates: guesses, last_stage: last_stage.to_string() }
}

struct VariantGuess {
    candidates: Vec<&'static str>,
    last_stage: String,
}

// ── plan + skeleton emission ─────────────────────────────────────────

/// The output of `plan_promotion`. Everything derived from the bundle +
/// overrides, without touching disk. `execute_plan` then either prints
/// or writes the plan's side-effects.
struct Plan {
    bundle_dir: PathBuf,
    input_hash: String,
    input_source: PathBuf,
    krate: KnownCrate,
    target: FuzzTarget,
    crate_root: PathBuf,
    fixture_path: PathBuf,
    test_fn_name: String,
    test_skeleton: String,
    review_banner: Vec<String>,
}

/// Replace any char that isn't valid in a Rust identifier with `_`.
/// The caller is responsible for prefixing if the result might begin
/// with a digit; triage.rs always wraps the output as `fuzz_<target>_<hash>_triage`,
/// so the composite identifier is always legal.
fn sanitize_identifier(s: &str) -> String {
    s.chars()
        .map(|c| if c.is_ascii_alphanumeric() || c == '_' { c } else { '_' })
        .collect()
}

fn build_skeleton(plan_krate: KnownCrate, plan_target: &FuzzTarget, plan: &SkeletonArgs<'_>) -> String {
    let include_path = format!(
        "fixtures/adversarial/{}/{}.bin",
        plan_target.as_str(),
        plan.input_hash
    );

    let driver_body = driver_body_for(plan_krate, plan_target);

    let panic_site = match (&plan.panic_file, plan.panic_line, plan.panic_col) {
        (Some(f), Some(l), Some(c)) => format!("{f}:{l}:{c}"),
        (Some(f), Some(l), None) => format!("{f}:{l}"),
        (Some(f), None, None) => f.clone(),
        _ => "<unknown>".to_string(),
    };

    let stages_line = if plan.stage_names.is_empty() {
        "<none>".to_string()
    } else {
        plan.stage_names.join(", ")
    };

    let candidates_block = plan
        .variant_candidates
        .iter()
        .map(|c| format!("//     {c}"))
        .collect::<Vec<_>>()
        .join("\n");

    format!(
        "\
// Origin: diag bundle {bundle_rel}
// input_hash: {hash}
// panic_site: {site}
// panic_message: {msg}
// stages recorded (oldest→newest): {stages}
// last stage before panic: {last_stage}
//
// TODO(triage-promote): human review required. Tighten the match arm
// below to the specific typed `Err` variant. Suggested candidates:
{candidates}
#[test]
fn {test_fn}() {{
    let data = include_bytes!(\"{include_path}\");
{driver}
}}
",
        bundle_rel = plan.bundle_rel,
        hash = plan.input_hash,
        site = panic_site,
        msg = plan.panic_message,
        stages = stages_line,
        last_stage = if plan.last_stage.is_empty() {
            "<none>"
        } else {
            &plan.last_stage
        },
        candidates = candidates_block,
        test_fn = plan.test_fn_name,
        include_path = include_path,
        driver = driver_body,
    )
}

struct SkeletonArgs<'a> {
    bundle_rel: String,
    input_hash: String,
    panic_file: Option<String>,
    panic_line: Option<u32>,
    panic_col: Option<u32>,
    panic_message: String,
    stage_names: Vec<String>,
    last_stage: String,
    variant_candidates: &'a [&'static str],
    test_fn_name: String,
}

/// Pick a test-body driver template. For the common (crate, target)
/// combinations we know, emit a one-line call; otherwise emit a
/// `TODO(...)` stub that still compiles-guards via `let _ = data;`.
fn driver_body_for(krate: KnownCrate, target: &FuzzTarget) -> String {
    let indent = "    ";
    match (krate, target.as_str()) {
        (KnownCrate::Dex, "fuzz_parser") => format!(
            "{indent}let err = droidsaw_dex::DexFile::parse(data, None)\n\
             {indent}    .expect_err(\"must surface typed Err, no panic\");\n\
             {indent}let _ = err; // TODO: match specific DexError variant"
        ),
        (KnownCrate::Dex, "fuzz_cfg") => format!(
            "{indent}// Reuse `drive_cfg` helper already defined in tests/adversarial.rs.\n\
             {indent}let err = drive_cfg(data).expect_err(\"must surface typed Err, no panic\");\n\
             {indent}let _ = err; // TODO: match specific DexError variant"
        ),
        (KnownCrate::Dex, "fuzz_ssa") => format!(
            "{indent}// Reuse `drive_ssa` helper already defined in tests/adversarial.rs.\n\
             {indent}let err = drive_ssa(data).expect_err(\"must surface typed Err, no panic\");\n\
             {indent}let _ = err; // TODO: match specific DexError variant"
        ),
        (KnownCrate::Dex, "fuzz_opcode_decode") => format!(
            "{indent}// decode_insns takes a raw instruction stream (u16 units).\n\
             {indent}let insns_size = (data.len() / 2) as u32;\n\
             {indent}let result = droidsaw_dex::decode::decode_insns(data, 0, insns_size);\n\
             {indent}// TODO: tighten to the specific DexError variant and drop _result\n\
             {indent}let _ = result;"
        ),
        (KnownCrate::Hermes, "fuzz_parser") => format!(
            "{indent}let err = droidsaw_hermes::parser::HbcFile::parse(data, None)\n\
             {indent}    .expect_err(\"must surface typed Err, no panic\");\n\
             {indent}let _ = err; // TODO: match specific HermesError variant"
        ),
        (KnownCrate::Hermes, "fuzz_cfg") => format!(
            "{indent}// Reuse `first_cfg_build_err` helper already defined in tests/adversarial.rs.\n\
             {indent}let err = first_cfg_build_err(data)\n\
             {indent}    .expect(\"must surface typed Err, no panic\");\n\
             {indent}let _ = err; // TODO: match specific HermesError variant"
        ),
        (KnownCrate::Hermes, "fuzz_ssa") => format!(
            "{indent}// TODO: add / reuse a `first_build_ssa_err` helper — mirrors\n\
             {indent}// `first_cfg_build_err` but calls `build_ssa` after Cfg::build.\n\
             {indent}let _ = data;"
        ),
        (KnownCrate::Apk, "fuzz_binary_xml") => format!(
            "{indent}let err = droidsaw_apk::binary_xml::BinaryXml::decode(data)\n\
             {indent}    .expect_err(\"must surface typed Err, no panic\");\n\
             {indent}// TODO: tighten via err.to_string() assertions\n\
             {indent}let _ = err;"
        ),
        (KnownCrate::Apk, "fuzz_arsc") => format!(
            "{indent}// TODO: drive the ARSC parser. The canonical entry point is\n\
             {indent}// droidsaw_apk::resources::ResourceTable — confirm the exact\n\
             {indent}// function name in the current crate.\n\
             {indent}let _ = data;"
        ),
        (KnownCrate::Apk, "fuzz_apk_parse") => format!(
            "{indent}// TODO: drive Apk::parse — may require a ZIP-containered fixture.\n\
             {indent}let _ = data;"
        ),
        (KnownCrate::Apk, "fuzz_signing_block") => format!(
            "{indent}// TODO: drive the signing block parser.\n\
             {indent}let _ = data;"
        ),
        _ => format!(
            "{indent}// TODO(triage-promote): no driver template for ({k:?}, \"{t}\").\n\
             {indent}// Add one in commands/triage.rs driver_body_for().\n\
             {indent}let _ = data;",
            k = krate,
            t = target.as_str(),
        ),
    }
}

// ── plan construction ───────────────────────────────────────────────

/// Load and validate a bundle, then plan the promotion.
fn plan_promotion(args: &PromoteArgs) -> anyhow::Result<Plan> {
    let bundle_dir = args
        .bundle
        .canonicalize()
        .with_context(|| format!("bundle dir does not exist: {}", args.bundle.display()))?;

    if !bundle_dir.is_dir() {
        bail!("bundle path is not a directory: {}", bundle_dir.display());
    }

    let metadata_path = bundle_dir.join("metadata.json");
    let metadata_bytes = fs::read(&metadata_path)
        .with_context(|| format!("missing metadata.json at {}", metadata_path.display()))?;
    let metadata: BundleMetadata = serde_json::from_slice(&metadata_bytes)
        .with_context(|| format!("metadata.json is not a valid BundleMetadata: {}", metadata_path.display()))?;

    let backtrace_path = bundle_dir.join("backtrace.txt");
    let backtrace = fs::read_to_string(&backtrace_path)
        .with_context(|| format!("missing backtrace.txt at {}", backtrace_path.display()))?;

    // Crate inference + override.
    let krate = match args.crate_override.as_deref() {
        Some(name) => KnownCrate::from_crate_override(name)
            .ok_or_else(|| anyhow!("--crate must be one of: droidsaw-dex / droidsaw-hermes / droidsaw-apk / droidsaw-common; got `{name}`"))?,
        None => infer_crate(&backtrace).map_err(|e| {
            anyhow!(
                "crate inference failed: {e}\n(pass --crate to override; see `--help`)"
            )
        })?,
    };

    // Target inference + override.
    let target = match args.target.as_deref() {
        Some(t) => FuzzTarget(t.to_string()),
        None => infer_target(&backtrace, krate).map_err(|e| {
            anyhow!(
                "fuzz-target inference failed: {e}\n(pass --target to override)"
            )
        })?,
    };

    // Workspace root — default to two-parents-up from the bundle if
    // unspecified (the bundle usually lives in .droidsaw-crash/<hash>/
    // under the working dir); fall back to the current dir.
    let workspace = match args.workspace.clone() {
        Some(w) => w.canonicalize().with_context(|| format!("workspace: {}", w.display()))?,
        None => std::env::current_dir().context("cannot resolve current working directory")?,
    };

    let crate_root = workspace.join(krate.crate_dir());
    if !crate_root.is_dir() {
        bail!(
            "crate directory not found: {} (pass --workspace <path> if running outside the workspace)",
            crate_root.display()
        );
    }

    let input_hash = metadata
        .input_hash
        .clone()
        .ok_or_else(|| anyhow!("metadata.json has no input_hash — bundle pre-dates diag-wire or panic lost it; pass --crate and promote manually"))?;

    // Input source — resolve in priority order: explicit --input, bundle sibling.
    let input_source = match args.input.clone() {
        Some(p) => p,
        None => {
            let sibling = bundle_dir.join("input.bin");
            if sibling.is_file() {
                sibling
            } else {
                bail!(
                    "no --input path given and bundle has no `input.bin` — pass \
                     --input <path> to the original bytes that triggered the panic"
                );
            }
        }
    };
    if !input_source.is_file() {
        bail!("--input path is not a readable file: {}", input_source.display());
    }

    let fixture_dir = crate_root
        .join("tests")
        .join("fixtures")
        .join("adversarial")
        .join(target.as_str());
    let fixture_path = fixture_dir.join(format!("{input_hash}.bin"));

    // Stage names for the skeleton banner.
    let stage_names: Vec<String> = metadata.stages.iter().map(|s| s.name.clone()).collect();

    let variant_guess = guess_err_variant(krate, &metadata);
    let test_fn_name = format!(
        "{target}_{hash}_triage",
        target = target.as_str(),
        hash = sanitize_identifier(&input_hash),
    );

    // Render the bundle directory relative to the workspace if possible,
    // so the origin comment is reproducible between workstations.
    let bundle_rel = bundle_dir
        .strip_prefix(&workspace)
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| bundle_dir.to_string_lossy().into_owned());

    let skeleton_args = SkeletonArgs {
        bundle_rel,
        input_hash: input_hash.clone(),
        panic_file: metadata.panic.file.clone(),
        panic_line: metadata.panic.line,
        panic_col: metadata.panic.col,
        panic_message: metadata.panic.message.clone(),
        stage_names,
        last_stage: variant_guess.last_stage.clone(),
        variant_candidates: &variant_guess.candidates,
        test_fn_name: test_fn_name.clone(),
    };

    let test_skeleton = build_skeleton(krate, &target, &skeleton_args);

    let review_banner = vec![
        "═══════════════════════════════════════════════════════════════".to_string(),
        "  REVIEW BEFORE COMMITTING".to_string(),
        "  Triage-promote is a scaffolding tool; crate / target / Err".to_string(),
        "  variant guesses are heuristics. Tighten the match arm in the".to_string(),
        "  emitted stub before merging.".to_string(),
        "═══════════════════════════════════════════════════════════════".to_string(),
    ];

    Ok(Plan {
        bundle_dir,
        input_hash,
        input_source,
        krate,
        target,
        crate_root,
        fixture_path,
        test_fn_name,
        test_skeleton,
        review_banner,
    })
}

// ── execution ───────────────────────────────────────────────────────

pub fn promote(args: PromoteArgs) -> anyhow::Result<Value> {
    let plan = plan_promotion(&args)?;
    execute_plan(&plan, args.write)
}

fn execute_plan(plan: &Plan, write: bool) -> anyhow::Result<Value> {
    let input_bytes = fs::read(&plan.input_source)
        .with_context(|| format!("reading input file: {}", plan.input_source.display()))?;

    let mut written: Vec<String> = Vec::new();

    if write {
        if let Some(parent) = plan.fixture_path.parent() {
            fs::create_dir_all(parent)
                .with_context(|| format!("creating fixture dir: {}", parent.display()))?;
        }
        fs::write(&plan.fixture_path, &input_bytes).with_context(|| {
            format!("writing fixture: {}", plan.fixture_path.display())
        })?;
        written.push(plan.fixture_path.to_string_lossy().into_owned());

        let adversarial_rs = plan.crate_root.join("tests").join("adversarial.rs");
        append_skeleton_to_file(&adversarial_rs, &plan.test_skeleton)?;
        written.push(adversarial_rs.to_string_lossy().into_owned());
    } else {
        for line in &plan.review_banner {
            eprintln!("droidsaw: {line}");
        }
        eprintln!(
            "droidsaw: dry-run (pass --write to execute)\n\
             droidsaw: fixture target: {}\n\
             droidsaw: would append #[test] {}() to: {}/tests/adversarial.rs",
            plan.fixture_path.display(),
            plan.test_fn_name,
            plan.crate_root.display(),
        );
    }

    Ok(json!({
        "bundle": plan.bundle_dir.display().to_string(),
        "input_hash": plan.input_hash,
        "input_source": plan.input_source.display().to_string(),
        "input_bytes": input_bytes.len(),
        "crate": plan.krate.crate_dir(),
        "target": plan.target.as_str(),
        "fixture_path": plan.fixture_path.display().to_string(),
        "test_fn_name": plan.test_fn_name,
        "test_skeleton": plan.test_skeleton,
        "written_files": written,
        "wrote": write,
        "_meta": {
            "hint": if write {
                "test stub appended — review the match arm and `cargo test -p <crate>` before committing"
            } else {
                "dry-run — re-run with --write to copy the fixture and append the test stub"
            },
            "review_banner": plan.review_banner,
            "related": ["audit", "info", "decompile"],
            "tool_version": env!("CARGO_PKG_VERSION"),
        }
    }))
}

/// Append a test skeleton to an existing `tests/adversarial.rs`, or
/// create the file with a minimal preamble if it doesn't exist. We
/// always append, never overwrite — adversarial tests accumulate.
fn append_skeleton_to_file(path: &Path, skeleton: &str) -> anyhow::Result<()> {
    let existing = match fs::read_to_string(path) {
        Ok(s) => Some(s),
        Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
        Err(e) => {
            return Err(anyhow!(e).context(format!("reading {}", path.display())));
        }
    };

    let preamble = "\
//! Adversarial fixtures promoted from panic-hook crash bundles.
//!
//! Each test here was scaffolded by `droidsaw triage promote` from a
//! `droidsaw_common::diag` bundle. The match arms are heuristic on
//! emission — see the per-test TODO comment for what to tighten.

";

    let mut buf = existing.unwrap_or_else(|| preamble.to_string());
    if !buf.ends_with('\n') {
        buf.push('\n');
    }
    buf.push('\n');
    buf.push_str(skeleton);

    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("creating tests dir: {}", parent.display()))?;
    }
    fs::write(path, buf)
        .with_context(|| format!("writing {}", path.display()))?;
    Ok(())
}

// ── tests ───────────────────────────────────────────────────────────
//
// Unit tests for the inference tables. Integration tests with real
// bundles live in `tests/triage_promote.rs`.

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

    fn bt(frames: &[&str]) -> String {
        frames
            .iter()
            .enumerate()
            .map(|(i, f)| format!("   {i}: {f}"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    #[test]
    fn infer_crate_picks_dex_over_common_infra() {
        let backtrace = bt(&[
            "droidsaw_common::diag::init::{{closure}}",
            "std::panicking::panic_with_hook",
            "droidsaw_dex::ssa::Builder::build_ssa",
            "droidsaw_dex::DexFile::parse",
        ]);
        assert_eq!(infer_crate(&backtrace).unwrap(), KnownCrate::Dex);
    }

    #[test]
    fn infer_crate_reports_ambiguity_when_multiple_crates_appear() {
        let backtrace = bt(&[
            "droidsaw_dex::ssa::Builder::build_ssa",
            "droidsaw_hermes::parser::HbcFile::parse",
        ]);
        let err = infer_crate(&backtrace).unwrap_err();
        assert!(matches!(err, InferError::Ambiguous { .. }));
    }

    #[test]
    fn infer_crate_rejects_bundles_with_only_diag_frames() {
        let backtrace = bt(&[
            "droidsaw_common::diag::init::{{closure}}",
            "std::panicking::panic_with_hook",
            "core::panicking::panic_fmt",
            "some_user_binary::main",
        ]);
        let err = infer_crate(&backtrace).unwrap_err();
        assert!(matches!(err, InferError::Ambiguous { .. }));
    }

    #[test]
    fn infer_crate_unknown_when_no_workspace_frames() {
        let backtrace = bt(&[
            "std::panicking::panic_with_hook",
            "core::panicking::panic_fmt",
            "main",
        ]);
        let err = infer_crate(&backtrace).unwrap_err();
        assert!(matches!(err, InferError::Unknown { .. }));
    }

    #[test]
    fn infer_target_ssa_beats_parser() {
        let backtrace = bt(&[
            "droidsaw_dex::ssa::Builder::build_ssa",
            "droidsaw_dex::cfg::Cfg::build",
            "droidsaw_dex::parser::parse_class_defs",
        ]);
        assert_eq!(
            infer_target(&backtrace, KnownCrate::Dex).unwrap().as_str(),
            "fuzz_ssa"
        );
    }

    #[test]
    fn infer_target_maps_hermes_cfg() {
        let backtrace = bt(&[
            "droidsaw_hermes::decompile::cfg::Cfg::build",
            "droidsaw_hermes::parser::HbcFile::parse",
        ]);
        assert_eq!(
            infer_target(&backtrace, KnownCrate::Hermes).unwrap().as_str(),
            "fuzz_cfg"
        );
    }

    #[test]
    fn infer_target_apk_binary_xml() {
        let backtrace = bt(&["droidsaw_apk::binary_xml::BinaryXml::decode"]);
        assert_eq!(
            infer_target(&backtrace, KnownCrate::Apk).unwrap().as_str(),
            "fuzz_binary_xml"
        );
    }

    #[test]
    fn sanitize_identifier_replaces_non_ident_chars() {
        assert_eq!(sanitize_identifier("ab12"), "ab12");
        assert_eq!(sanitize_identifier("ab-12"), "ab_12");
        assert_eq!(sanitize_identifier("12ab"), "12ab");
    }
}
