//! CLI surface for the research-artifact documents: the `aura process` and
//! `aura campaign` verb families. Presentation layer only —
//! parsing/validation/introspection live in aura-research; stores and the
//! referential tier in aura-registry.

use std::path::PathBuf;

use aura_research::{
    binding_column_vocabulary_display, campaign_to_json, campaign_vocabulary, describe_block,
    open_slots_campaign, open_slots_process, parse_campaign, parse_process, process_to_json,
    process_vocabulary, slot_kind_label, tap_vocabulary, validate_campaign, validate_process,
    CampaignDoc, DocError, DocFault, DocKind, ProcessDoc, StageBlock,
};
use aura_registry::RefFault;

use crate::project::Env;

#[derive(clap::Args)]
pub struct ProcessCmd {
    #[command(subcommand)]
    pub sub: ProcessSub,
}

#[derive(clap::Subcommand)]
pub enum ProcessSub {
    /// Validate a process document (intrinsic tier).
    Validate { file: PathBuf },
    /// Introspect the process vocabulary or a document.
    Introspect(DocIntrospectCmd),
    /// Register a valid process document into the store under the runs root.
    Register { file: PathBuf },
}

#[derive(clap::Args)]
pub struct DocIntrospectCmd {
    /// List the block vocabulary
    #[arg(long)]
    pub vocabulary: bool,
    /// Describe one block's typed slots
    #[arg(long, value_name = "ID")]
    pub block: Option<String>,
    /// List the open slots of a (partial) document — start from a bare {} to see the whole envelope
    #[arg(long, value_name = "FILE")]
    pub unwired: Option<PathBuf>,
    /// Print the content id of a valid document
    #[arg(long, value_name = "FILE")]
    pub content_id: Option<PathBuf>,
    /// List the metric vocabulary with applicability tags
    #[arg(long)]
    pub metrics: bool,
}

impl DocIntrospectCmd {
    fn selected_modes(&self) -> usize {
        usize::from(self.vocabulary)
            + usize::from(self.block.is_some())
            + usize::from(self.unwired.is_some())
            + usize::from(self.content_id.is_some())
            + usize::from(self.metrics)
    }
}

/// Parse `--parallel-instruments` in domain terms: clap's built-in
/// `NonZeroUsize` parser would leak the Rust type name ("number would be
/// zero for non-zero type") at exactly the moment a user needs guidance.
fn parse_parallel_instruments(s: &str) -> Result<std::num::NonZeroUsize, String> {
    s.parse::<usize>().ok().and_then(std::num::NonZeroUsize::new).ok_or_else(|| {
        "must be a whole number of at least 1 — it bounds how many distinct \
         instruments are resident in parallel"
            .to_string()
    })
}

/// Exactly one introspect mode (the graph-introspect guard idiom: usage
/// error on stderr, exit 2).
fn guard_one_mode(cmd: &DocIntrospectCmd, family: &str) {
    if cmd.selected_modes() != 1 {
        eprintln!(
            "aura: Usage: aura {family} introspect --vocabulary | --block <ID> | --unwired <FILE> | --content-id <FILE> | --metrics"
        );
        std::process::exit(2);
    }
}

/// `--metrics`: the 17-name metric vocabulary, tagged by where a name may be
/// used — `rankable` (sweep/walk_forward `select` metrics), `gate`
/// (per-member gate predicates), `annotation` (selection annotations,
/// neither). The tags derive from aura-campaign's existing pub rosters, so
/// this mode adds no fourth roster site (#190); metrics are process-side
/// vocabulary, but the mode rides the shared introspect struct and answers
/// for both families.
fn print_metric_roster() {
    for name in aura_research::metric_vocabulary() {
        let rankable = aura_campaign::RANKABLE_METRICS.contains(name);
        let gate = aura_campaign::PER_MEMBER_METRICS.contains(name);
        // The generalize applicability is the registry's own R-expectancy
        // predicate — no fourth roster site (#190/#207).
        let generalize = aura_registry::check_r_metric(name).is_ok();
        let base = match (rankable, gate) {
            (true, true) => "rankable | gate",
            (false, true) => "gate",
            // Unreachable today (the rankable roster is a subset of the
            // per-member roster) — kept total so a roster shift stays honest.
            (true, false) => "rankable",
            (false, false) => "annotation",
        };
        if generalize {
            println!("{name:<24} {base} | generalize");
        } else {
            println!("{name:<24} {base}");
        }
    }
}

pub(crate) fn doc_error_prose(what: &str, e: &DocError) -> String {
    match e {
        DocError::NotJson(msg) => format!("{what} is not JSON: {msg}"),
        DocError::BadFormatVersion(v) => {
            format!("{what}: unsupported format_version {v} (expected 1)")
        }
        DocError::WrongKind { expected } => {
            let want = match expected {
                DocKind::Process => "process",
                DocKind::Campaign => "campaign",
            };
            format!("{what}: the \"kind\" key must be \"{want}\"")
        }
        DocError::Malformed(msg) => format!("{what}: {msg}"),
    }
}

pub(crate) fn doc_fault_prose(f: &DocFault) -> String {
    match f {
        DocFault::EmptyPipeline => "the pipeline is empty — a process needs at least one stage".into(),
        DocFault::UnknownMetric { stage, metric } => {
            format!(
                "stage {stage}: unknown metric \"{metric}\" (see: aura process introspect --metrics)"
            )
        }
        DocFault::EmptyConjunction { stage } => {
            format!("stage {stage}: a gate needs at least one predicate")
        }
        DocFault::GateFirst => "stage 0: a gate cannot open the pipeline (nothing to filter yet)".into(),
        DocFault::GateAfterTerminalStage { stage } => {
            format!("stage {stage}: a gate cannot follow a terminal stage (monte_carlo/generalize)")
        }
        DocFault::ZeroWalkForwardLength { stage, field } => {
            format!("pipeline[{stage}]: walk_forward {field} must be > 0")
        }
        DocFault::EmptyInstruments => "data.instruments is empty".into(),
        DocFault::DuplicateInstrument { index, instrument } => {
            format!("data.instruments[{index}]: \"{instrument}\" is listed more than once")
        }
        DocFault::NoWindow => "data.windows is empty".into(),
        DocFault::BadWindow { index } => {
            format!("data.windows[{index}]: from_ms must be earlier than to_ms")
        }
        DocFault::BadRegime { index } => {
            format!("risk[{index}]: stop length must be >= 1 and k must be > 0")
        }
        DocFault::BadCost { index } => {
            format!("cost[{index}]: the component's price-unit knob must be finite and >= 0")
        }
        DocFault::CostInstrumentKeys { index, missing, extra } => {
            let mut parts = Vec::new();
            if !missing.is_empty() {
                parts.push(format!("misses campaign instrument(s) {}", missing.join(", ")));
            }
            if !extra.is_empty() {
                parts.push(format!("names no campaign instrument(s): {}", extra.join(", ")));
            }
            format!("cost[{index}]: instrument map {}", parts.join("; "))
        }
        DocFault::NoStrategy => "strategies is empty".into(),
        DocFault::EmptyAxes { strategy } => format!("strategies[{strategy}]: axes is empty"),
        DocFault::EmptyAxis { strategy, axis } => {
            format!("strategies[{strategy}].axes.{axis}: an axis is a non-empty finite set")
        }
        DocFault::UnknownEmitKind(kind) => format!("presentation.emit: unknown kind \"{kind}\""),
        DocFault::UnknownTap { index, tap } => format!(
            "presentation.persist_taps[{index}]: unknown tap \"{tap}\" (taps: {})",
            tap_vocabulary().join(" | ")
        ),
        DocFault::UnknownBindingColumn { role, column } => format!(
            "data.bindings.{role}: \"{column}\" names no archive column (columns: {})",
            binding_column_vocabulary_display()
        ),
        DocFault::ProcessRefMustBeContentId => {
            "process.ref: a process is referenced by content id in this version".into()
        }
    }
}

fn read_doc(file: &PathBuf) -> Result<String, String> {
    std::fs::read_to_string(file).map_err(|e| format!("cannot read {}: {e}", file.display()))
}

pub(crate) fn fault_block(header: &str, lines: Vec<String>) -> String {
    format!("{header}\n  {}", lines.join("\n  "))
}

/// `aura process …` — the dispatch entry main.rs calls.
pub fn process_cmd(cmd: ProcessCmd, env: &Env) {
    let result = match &cmd.sub {
        ProcessSub::Validate { file } => validate_process_file(file),
        ProcessSub::Introspect(i) => {
            guard_one_mode(i, "process");
            introspect_process(i)
        }
        ProcessSub::Register { file } => register_process(file, env),
    };
    if let Err(m) = result {
        eprintln!("aura: {m}");
        std::process::exit(1);
    }
}

fn parse_valid_process(file: &PathBuf) -> Result<ProcessDoc, String> {
    let text = read_doc(file)?;
    let doc = parse_process(&text).map_err(|e| doc_error_prose("process document", &e))?;
    let faults = validate_process(&doc);
    if !faults.is_empty() {
        return Err(fault_block(
            "process document invalid:",
            faults.iter().map(doc_fault_prose).collect(),
        ));
    }
    Ok(doc)
}

fn validate_process_file(file: &PathBuf) -> Result<(), String> {
    let doc = parse_valid_process(file)?;
    let gates: usize = doc
        .pipeline
        .iter()
        .map(|s| match s {
            StageBlock::Gate { all } => all.len(),
            _ => 0,
        })
        .sum();
    println!(
        "process document valid (intrinsic): {} pipeline blocks, {} gate predicates",
        doc.pipeline.len(),
        gates
    );
    Ok(())
}

fn register_process(file: &PathBuf, env: &Env) -> Result<(), String> {
    let doc = parse_valid_process(file)
        .map_err(|m| format!("refusing to register: {m}"))?;
    let canonical = process_to_json(&doc);
    let registry = env.registry();
    let id = registry.put_process(&canonical).map_err(|e| e.to_string())?;
    println!(
        "registered process {id} ({})",
        registry.process_path(&id).display()
    );
    Ok(())
}

fn introspect_process(cmd: &DocIntrospectCmd) -> Result<(), String> {
    if cmd.metrics {
        print_metric_roster();
        return Ok(());
    }
    if cmd.vocabulary {
        for b in process_vocabulary() {
            println!("{:<18} {}", b.id, b.doc);
        }
        return Ok(());
    }
    if let Some(id) = &cmd.block {
        return describe_one_block(id);
    }
    if let Some(file) = &cmd.unwired {
        let text = read_doc(file)?;
        let slots =
            open_slots_process(&text).map_err(|e| doc_error_prose("process document", &e))?;
        print_open_slots(&slots);
        return Ok(());
    }
    if let Some(file) = &cmd.content_id {
        let doc = parse_valid_process(file)
            .map_err(|m| format!("an invalid document has no canonical form — {m}"))?;
        println!("{}", aura_research::content_id_of(&process_to_json(&doc)));
        return Ok(());
    }
    unreachable!("guard_one_mode enforces one mode")
}

fn describe_one_block(id: &str) -> Result<(), String> {
    let schema = describe_block(id).ok_or_else(|| format!("unknown block \"{id}\""))?;
    println!("{} — {}", schema.id, schema.doc);
    for s in schema.slots {
        let req = if s.required { "required" } else { "optional" };
        println!("  {:<20} {req}, {}", s.name, slot_kind_label(s.kind));
    }
    Ok(())
}

fn print_open_slots(slots: &[aura_research::OpenSlot]) {
    for s in slots {
        println!("open slot: {} ({})", s.path, s.hint);
        for note in &s.notes {
            println!("    {note}");
        }
    }
    if slots.is_empty() {
        println!("no open slots");
    }
}

#[derive(clap::Args)]
pub struct CampaignCmd {
    #[command(subcommand)]
    pub sub: CampaignSub,
}

#[derive(clap::Subcommand)]
pub enum CampaignSub {
    /// Validate a campaign document (intrinsic + referential inside a project).
    Validate { file: PathBuf },
    /// Introspect the campaign vocabulary or a document.
    Introspect(DocIntrospectCmd),
    /// Register a valid campaign document into the store under the runs root.
    Register { file: PathBuf },
    /// Execute a stored campaign into a realized run-set (a .json file is
    /// register-then-run sugar; the canonical address is the content id).
    Run {
        target: String,
        /// Bound on distinct instruments resident in parallel (the RAM
        /// lever; 1 reproduces the sequential loop's footprint).
        #[arg(
            long,
            default_value_t = aura_campaign::DEFAULT_PARALLEL_INSTRUMENTS,
            value_parser = parse_parallel_instruments,
        )]
        parallel_instruments: std::num::NonZeroUsize,
    },
    /// List stored campaign realizations, or dump one campaign's records
    /// (the bare store lines, not the run-emit wrapper).
    Runs { campaign: Option<String>, run: Option<usize> },
}

/// The one authoring trap the referential tier can name outright: a ref that
/// carries the `content:` DISPLAY prefix (doc ref fields are bare-only —
/// canonical-form byte stability, #194).
fn prefix_hint(id: &str) -> &'static str {
    if id.starts_with("content:") {
        " (refs use the bare 64-hex id — drop the 'content:' prefix)"
    } else {
        ""
    }
}

pub(crate) fn ref_fault_prose(f: &RefFault) -> String {
    match f {
        RefFault::ProcessNotFound(id) => {
            format!("process {id} not found in the project store{}", prefix_hint(id))
        }
        RefFault::StrategyNotFound(id) => {
            format!("strategy {id} not found in the blueprint store{}", prefix_hint(id))
        }
        RefFault::IdentityUnmatched(id) => format!("identity id {id} matches no stored blueprint"),
        RefFault::StrategyUnloadable { id, error } => {
            format!("strategy {id} cannot be loaded: {error}")
        }
        RefFault::AxisNotInParamSpace { strategy, axis } => {
            format!("strategy {strategy}: axis \"{axis}\" is not in the param space")
        }
        RefFault::AxisKindMismatch { strategy, axis } => {
            format!("strategy {strategy}: axis \"{axis}\" declares a kind that is not the param's kind")
        }
        RefFault::ParamNotCovered { strategy, param } => {
            format!("strategy {strategy}: open param \"{param}\" is bound by no campaign axis")
        }
        RefFault::BindingRoleUnknown { role, roles } => format!(
            "data.bindings: key \"{role}\" names no input role of any campaign strategy (roles: {})",
            roles.join(", ")
        ),
    }
}

/// `aura campaign …` — the dispatch entry main.rs calls.
/// `aura campaign runs [CAMPAIGN-ID] [RUN]` (#206): the read-back for stored
/// realizations. Without an id: one scannable line per record. With an id
/// (optionally narrowed to one run counter): the BARE stored record line(s)
/// — the store form, never the `{"campaign_run": …}` run-emit wrapper (the
/// 0108 fieldtest F10 parsing trap). A lookup, not an action: an empty store
/// or unmatched id notes it and exits 0 (the `runs family` convention).
fn campaign_runs(campaign: Option<&str>, run: Option<usize>, env: &Env) -> Result<(), String> {
    if env.provenance().is_none() {
        let cwd = std::env::current_dir()
            .map(|d| d.display().to_string())
            .unwrap_or_default();
        return Err(format!(
            "campaign runs needs a project: the realization store lives under the \
             project runs root (no Aura.toml found up from {cwd})"
        ));
    }
    let records = env.registry().load_campaign_runs().map_err(|e| e.to_string())?;
    match campaign {
        None => {
            if records.is_empty() {
                println!("no campaign runs recorded");
                return Ok(());
            }
            for r in &records {
                let signature: Vec<&str> = r
                    .cells
                    .first()
                    .map(|c| c.stages.iter().map(|s| s.block.as_str()).collect())
                    .unwrap_or_default();
                println!(
                    "{} run {} — {} cell(s), {}",
                    r.campaign,
                    r.run,
                    r.cells.len(),
                    if signature.is_empty() { "no stages".to_string() } else { signature.join(" -> ") },
                );
            }
        }
        Some(id) => {
            let matched: Vec<_> = records
                .iter()
                .filter(|r| r.campaign == id && run.is_none_or(|n| r.run == n))
                .collect();
            if matched.is_empty() {
                println!("no campaign runs recorded for {id}");
                return Ok(());
            }
            for r in matched {
                // serde round-trip == the stored bytes (compact, declaration
                // order, sparse fields skipped) — the bare store form.
                println!("{}", serde_json::to_string(r).map_err(|e| e.to_string())?);
            }
        }
    }
    Ok(())
}

/// #272: `Run` threads a failed-cell count (exit 3 on completed-with-failures,
/// via `exit_on_campaign_result`), so it is pulled out of the unified
/// `Result<(), String>` match the other four subcommands still share.
pub fn campaign_cmd(cmd: CampaignCmd, env: &Env) {
    if let CampaignSub::Run { target, parallel_instruments } = &cmd.sub {
        crate::exit_on_campaign_result(crate::campaign_run::run_campaign(
            target,
            env,
            *parallel_instruments,
        ));
        return;
    }
    let result = match &cmd.sub {
        CampaignSub::Validate { file } => validate_campaign_file(file, env),
        CampaignSub::Introspect(i) => {
            guard_one_mode(i, "campaign");
            introspect_campaign(i)
        }
        CampaignSub::Register { file } => register_campaign(file, env),
        CampaignSub::Runs { campaign, run } => campaign_runs(campaign.as_deref(), *run, env),
        CampaignSub::Run { .. } => unreachable!("handled above"),
    };
    if let Err(m) = result {
        eprintln!("aura: {m}");
        std::process::exit(1);
    }
}

pub(crate) fn parse_valid_campaign(file: &PathBuf) -> Result<CampaignDoc, String> {
    let text = read_doc(file)?;
    let doc = parse_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
    let faults = validate_campaign(&doc);
    if !faults.is_empty() {
        return Err(fault_block(
            "campaign document invalid:",
            faults.iter().map(doc_fault_prose).collect(),
        ));
    }
    Ok(doc)
}

fn validate_campaign_file(file: &PathBuf, env: &Env) -> Result<(), String> {
    let doc = parse_valid_campaign(file)?;
    let axes: usize = doc.strategies.iter().map(|s| s.axes.len()).sum();
    let points: usize = doc
        .strategies
        .iter()
        .map(|s| s.axes.values().map(|a| a.values.len()).product::<usize>())
        .sum();
    let regimes = doc.risk.len().max(1);
    let regime_marker = if doc.risk.is_empty() { " (default)" } else { "" };
    let cells =
        doc.strategies.len() * doc.data.instruments.len() * doc.data.windows.len() * regimes;
    println!(
        "campaign document valid (intrinsic): {} strategy(ies), {} axes ({} points), {} instrument(s), {} window(s), {} regime(s){} — {} cell(s)",
        doc.strategies.len(),
        axes,
        points,
        doc.data.instruments.len(),
        doc.data.windows.len(),
        regimes,
        regime_marker,
        cells
    );
    if env.provenance().is_some() {
        let resolve = |t: &str| env.resolve(t);
        let faults = env
            .registry()
            .validate_campaign_refs(&doc, &resolve)
            .map_err(|e| e.to_string())?;
        if !faults.is_empty() {
            return Err(fault_block(
                "campaign references do not resolve:",
                faults.iter().map(ref_fault_prose).collect(),
            ));
        }
        println!("campaign document valid (referential): all references resolve, axes are in the param space");
        // Third tier (#205): the executor's data-free static preflight, so
        // "valid" means "runnable" for every pure document/campaign property.
        // The referential tier just proved the process ref resolves, so the
        // fetch cannot miss short of a store race.
        let proc_id = match &doc.process.r#ref {
            aura_research::DocRef::ContentId(id) | aura_research::DocRef::IdentityId(id) => {
                id.clone()
            }
        };
        let proc_text = env
            .registry()
            .get_process(&proc_id)
            .map_err(|e| e.to_string())?
            .ok_or_else(|| format!("no process {proc_id} in the project store"))?;
        let process = parse_process(&proc_text)
            .map_err(|e| doc_error_prose("stored process document", &e))?;
        if let Err(f) = aura_campaign::preflight(&process, &doc) {
            return Err(fault_block(
                "campaign is not executable:",
                vec![crate::campaign_run::exec_fault_prose(&f)],
            ));
        }
        println!("campaign document valid (executable): pipeline shape and static guards pass");
    } else {
        let cwd = std::env::current_dir()
            .map(|d| d.display().to_string())
            .unwrap_or_default();
        println!("referential checks skipped (no Aura.toml found up from {cwd})");
    }
    Ok(())
}

fn register_campaign(file: &PathBuf, env: &Env) -> Result<(), String> {
    let doc = parse_valid_campaign(file)
        .map_err(|m| format!("refusing to register: {m}"))?;
    let canonical = campaign_to_json(&doc);
    let registry = env.registry();
    let id = registry.put_campaign(&canonical).map_err(|e| e.to_string())?;
    println!(
        "registered campaign {id} ({})",
        registry.campaign_path(&id).display()
    );
    Ok(())
}

fn introspect_campaign(cmd: &DocIntrospectCmd) -> Result<(), String> {
    if cmd.metrics {
        print_metric_roster();
        return Ok(());
    }
    if cmd.vocabulary {
        for b in campaign_vocabulary() {
            println!("{:<18} {}", b.id, b.doc);
        }
        return Ok(());
    }
    if let Some(id) = &cmd.block {
        return describe_one_block(id);
    }
    if let Some(file) = &cmd.unwired {
        let text = read_doc(file)?;
        let slots =
            open_slots_campaign(&text).map_err(|e| doc_error_prose("campaign document", &e))?;
        print_open_slots(&slots);
        return Ok(());
    }
    if let Some(file) = &cmd.content_id {
        let doc = parse_valid_campaign(file)
            .map_err(|m| format!("an invalid document has no canonical form — {m}"))?;
        println!("{}", aura_research::content_id_of(&campaign_to_json(&doc)));
        return Ok(());
    }
    unreachable!("guard_one_mode enforces one mode")
}

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

    /// Exact prose pin for the binding-key referential refusal (#231): the
    /// message is path-addressed, names the offending key, and lists the
    /// strategies' actual roles.
    #[test]
    fn binding_role_unknown_prose_names_key_and_roles() {
        let msg = ref_fault_prose(&RefFault::BindingRoleUnknown {
            role: "sentiment".into(),
            roles: vec!["price".into(), "high".into()],
        });
        assert_eq!(
            msg,
            "data.bindings: key \"sentiment\" names no input role of any campaign \
             strategy (roles: price, high)"
        );
    }

    #[test]
    fn ref_fault_prose_is_debug_free() {
        let cases = [
            ref_fault_prose(&RefFault::ProcessNotFound("4e2d".into())),
            ref_fault_prose(&RefFault::StrategyNotFound("9f3a".into())),
            ref_fault_prose(&RefFault::IdentityUnmatched("7b1c".into())),
            ref_fault_prose(&RefFault::StrategyUnloadable {
                id: "9f3a".into(),
                error: "UnknownKnob(\"fast\")".into(),
            }),
            ref_fault_prose(&RefFault::AxisNotInParamSpace {
                strategy: "9f3a".into(),
                axis: "nope".into(),
            }),
            ref_fault_prose(&RefFault::AxisKindMismatch {
                strategy: "9f3a".into(),
                axis: "fast".into(),
            }),
        ];
        assert_eq!(cases[0], "process 4e2d not found in the project store");
        assert_eq!(cases[1], "strategy 9f3a not found in the blueprint store");
        assert_eq!(cases[2], "identity id 7b1c matches no stored blueprint");
        assert_eq!(cases[3], "strategy 9f3a cannot be loaded: UnknownKnob(\"fast\")");
        assert_eq!(cases[4], "strategy 9f3a: axis \"nope\" is not in the param space");
        assert!(cases[5].contains("declares a kind that is not the param's kind"));
        for c in &cases {
            assert!(
                !c.contains("NotFound") && !c.contains("Mismatch") && !c.contains("Unmatched") && !c.contains("NotInParamSpace"),
                "Debug leak: {c}"
            );
        }
    }

    #[test]
    /// #194 (the prefix trap): a doc ref that carries the display `content:`
    /// prefix (a copy-paste from register/introspect output) is bare-only in
    /// canonical documents — the referential refusal names the prefix as the
    /// cause instead of failing mute. A bare id keeps the plain refusal, so the
    /// hint is conditional, not always-on.
    fn ref_fault_prose_hints_when_a_ref_carries_the_content_prefix() {
        let hash = "a".repeat(64);
        let proc = ref_fault_prose(&RefFault::ProcessNotFound(format!("content:{hash}")));
        let strat = ref_fault_prose(&RefFault::StrategyNotFound(format!("content:{hash}")));
        assert!(proc.contains("drop the 'content:' prefix"), "process ref hint missing: {proc}");
        assert!(strat.contains("drop the 'content:' prefix"), "strategy ref hint missing: {strat}");
        let bare = ref_fault_prose(&RefFault::ProcessNotFound(hash));
        assert!(!bare.contains("drop the 'content:' prefix"), "bare id must not carry the hint: {bare}");
    }

    #[test]
    /// #197 (fieldtest 0107 F9): the intrinsic `unknown metric` refusal points
    /// the author at the verb that enumerates the valid names, instead of
    /// leaving the 17-name vocabulary discoverable only via the glossary. The
    /// name itself is still quoted (the existing contract, pinned by the e2e
    /// `process_validate_refuses_unknown_metric_*` test); only a discovery
    /// pointer is appended.
    fn unknown_metric_prose_points_at_the_metrics_introspection() {
        let prose = doc_fault_prose(&DocFault::UnknownMetric { stage: 0, metric: "netto_r".into() });
        assert!(prose.contains("unknown metric \"netto_r\""), "name still quoted: {prose}");
        assert!(
            prose.contains("aura process introspect --metrics"),
            "the refusal must point at the enumeration verb: {prose}"
        );
    }

    #[test]
    fn zero_walk_forward_length_prose_is_path_addressed_and_debug_free() {
        let prose = doc_fault_prose(&DocFault::ZeroWalkForwardLength { stage: 2, field: "step_ms" });
        assert_eq!(prose, "pipeline[2]: walk_forward step_ms must be > 0");
        assert!(!prose.contains("ZeroWalkForwardLength"), "Debug leak: {prose}");
    }

    #[test]
    /// #201 decision 1 (spec 0109): the unknown-tap refusal is path-addressed
    /// (`presentation.persist_taps[i]`) and enumerates the closed vocabulary
    /// inline — four names are small enough to print, so no introspection
    /// pointer is needed (contrast the 17-name metric roster, which points at
    /// `aura process introspect --metrics` instead).
    fn unknown_tap_prose_is_path_addressed_and_enumerates_the_vocabulary() {
        let prose = doc_fault_prose(&DocFault::UnknownTap { index: 0, tap: "bias".into() });
        assert_eq!(
            prose,
            "presentation.persist_taps[0]: unknown tap \"bias\" \
             (taps: equity | exposure | r_equity | net_r_equity)"
        );
        assert!(!prose.contains("UnknownTap"), "Debug leak: {prose}");
    }

    /// #260: the instrument-map key-mismatch fault is path-addressed and names
    /// both directions — the campaign instruments the map misses AND the map
    /// keys naming no campaign instrument — in one line, no Debug leak. The
    /// extra-keys branch is otherwise unpinned (the e2e covers only missing).
    #[test]
    fn cost_instrument_keys_prose_names_both_directions() {
        let prose = doc_fault_prose(&DocFault::CostInstrumentKeys {
            index: 1,
            missing: vec!["EURUSD".into()],
            extra: vec!["GER40.cash".into()],
        });
        assert_eq!(
            prose,
            "cost[1]: instrument map misses campaign instrument(s) EURUSD; \
             names no campaign instrument(s): GER40.cash"
        );
        assert!(!prose.contains("CostInstrumentKeys"), "Debug leak: {prose}");
    }
}
