#![forbid(unsafe_code)]

//! `plsql-engine` umbrella CLI.
//!
//! `analyze` runs the canonical pipeline over a project tree and
//! emits a reusable, versioned `AnalysisRun` artifact (shared
//! robot-JSON envelope, schema `plsql.engine.analysis_run`).
//! Every downstream CLI — the SAST scan harness, MCP foundation
//! tools, `doctor` — consumes that artifact instead of re-running
//! analysis, so a single analyze pass is amortised across tools.
//!
//! `doctor` loads an emitted artifact, verifies its schema is
//! readable, and prints a compact health summary.
//!
//! Exit codes follow the workspace agent-ergonomics convention:
//! * `0` — success
//! * `1` — runtime failure (analysis ran but failed)
//! * `2` — invocation failure (bad args, unreadable / incompatible
//!   artifact, serialization error)
//!
//! Discovery: `plsql-engine capabilities` — machine-readable contract.
//!            `plsql-engine robot-docs`   — agent handbook (plain text).

use std::io::Write;
use std::path::PathBuf;

use clap::{Args, Parser, Subcommand};
use miette::Diagnostic;
use plsql_engine::{
    ANALYSIS_RUN_SCHEMA, AnalysisRequest, SchemaCompatibility, analysis_run_envelope,
    analyze_project, engine_doctor_envelope, engine_doctor_report, engine_full_doctor_envelope,
    engine_full_doctor_report, schema_compatibility,
};
use plsql_output::RobotJsonEnvelope;
use thiserror::Error;

#[derive(Debug, Parser)]
#[command(name = "plsql-engine")]
#[command(about = "Run the canonical analysis pipeline and emit a reusable AnalysisRun artifact")]
#[command(
    after_help = "DISCOVERY:\n  plsql-engine capabilities   machine-readable agent contract (JSON)\n  plsql-engine robot-docs     agent handbook — start here if you are an AI\n  plsql-engine --robot-triage one-shot bootstrap (capabilities + health + quick_ref)"
)]
struct Cli {
    #[arg(
        long,
        global = true,
        help = "Emit versioned machine-readable output using the shared robot-JSON envelope"
    )]
    robot_json: bool,
    /// One-shot agent bootstrap. Emits {capabilities, health, quick_ref}
    /// in a single JSON mega-object on stdout and exits — short-circuits
    /// any subcommand. Exit 0 normally; reserved exit 2 if a future
    /// blocker is wired. Mirrors `plsql-mcp --robot-triage`.
    #[arg(long, global = true)]
    robot_triage: bool,
    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Debug, Subcommand)]
enum Command {
    /// Analyze a project tree and emit the reusable AnalysisRun
    /// artifact (robot-JSON) to stdout or `--out`.
    Analyze(AnalyzeArgs),
    /// Load an emitted AnalysisRun artifact and print a compact
    /// health summary (schema-checked before it is trusted).
    Doctor(DoctorArgs),
    /// Print the machine-readable agent contract (binary, version,
    /// commands, exit-code dictionary, global flags, stdout contract)
    /// as JSON and exit. An agent should read this instead of guessing
    /// the surface. Use `--robot-json` for compact single-line output.
    Capabilities,
    /// Print a concise agent handbook to stdout (what the engine does,
    /// canonical invocations, robot-JSON envelope schema, exit codes,
    /// and a pointer to `capabilities`). Plain text, paste-ready.
    RobotDocs,
}

#[derive(Debug, Args)]
struct AnalyzeArgs {
    /// Project root to analyze.
    #[arg(value_name = "PROJECT_ROOT")]
    project_root: PathBuf,
    /// Write the artifact here instead of stdout.
    #[arg(long, value_name = "PATH")]
    out: Option<PathBuf>,
}

#[derive(Debug, Args)]
struct DoctorArgs {
    /// Path to an AnalysisRun artifact emitted by `analyze`
    /// (robot-JSON), or `-` to read from stdin.
    #[arg(long, value_name = "PATH|-")]
    run: String,
    /// Emit the full doctor report (backend / catalog / cache /
    /// fact-store / graph / completeness blocks).
    #[arg(long)]
    full: bool,
    /// Emit the memory/footprint profile: full vs compact
    /// serialized bytes, savings, and per-section breakdown.
    #[arg(long)]
    memory: bool,
}

#[derive(Debug, Error, Diagnostic)]
enum CliError {
    #[error("analysis failed: {0}")]
    Analyze(String),
    #[error("could not read artifact {path}: {reason}")]
    ReadArtifact { path: String, reason: String },
    #[error("artifact is not valid {schema} robot-JSON: {reason}")]
    ParseArtifact { schema: String, reason: String },
    #[error(
        "artifact schema {found} is not readable by this build (expected {expected}); \
         regenerate it with a matching `plsql-engine analyze`"
    )]
    IncompatibleSchema { found: String, expected: String },
    #[error("could not write output {path}: {reason}")]
    WriteOutput { path: String, reason: String },
    #[error("failed to serialize robot JSON")]
    SerializeRobotJson,
    /// The PROJECT_ROOT argument points at a path that does not exist on
    /// disk. Surfaced as exit 2 (invocation failure) per the capabilities
    /// exit-code dictionary — never as a "Clean" empty-result envelope.
    /// Closes the Axiom 14 (never-silent-fail) hole an agent that mistypes
    /// a project path would otherwise fall through.
    #[error("plsql-engine: project root does not exist: {path}")]
    ProjectRootMissing { path: String },
    /// The PROJECT_ROOT exists but is not a directory (e.g. a regular file
    /// or a broken symlink). Same exit code as `ProjectRootMissing` so the
    /// agent contract is uniform.
    #[error("plsql-engine: project root is not a directory: {path}")]
    ProjectRootNotDirectory { path: String },
}

impl CliError {
    fn exit_code(&self) -> u8 {
        match self {
            Self::Analyze(_) | Self::SerializeRobotJson => 1,
            Self::ReadArtifact { .. }
            | Self::ParseArtifact { .. }
            | Self::IncompatibleSchema { .. }
            | Self::WriteOutput { .. }
            | Self::ProjectRootMissing { .. }
            | Self::ProjectRootNotDirectory { .. } => 2,
        }
    }
}

/// Validate that the supplied project root is a path we can actually
/// analyze: it must exist and be a directory. The check is factored out
/// so it can be unit-tested without spawning the binary — and so the
/// same logic gates every future entry point that takes a project root.
fn validate_project_root(path: &std::path::Path) -> Result<(), CliError> {
    if !path.exists() {
        return Err(CliError::ProjectRootMissing {
            path: path.display().to_string(),
        });
    }
    if !path.is_dir() {
        return Err(CliError::ProjectRootNotDirectory {
            path: path.display().to_string(),
        });
    }
    Ok(())
}

/// Stable contract version for the `capabilities` payload. Bump only on a
/// breaking change to the JSON shape; the pinned regression test
/// (`capabilities_contract_is_pinned`) will fail if the shape drifts without
/// this being bumped — that coupling is the whole point.
const CAPABILITIES_CONTRACT_VERSION: u32 = 1;

/// Build the `capabilities` contract document. Factored out of the command
/// handler so the schema can be pinned by a unit test without spawning the
/// binary (Axiom 17 — every contract surface has a drift-guard test).
fn capabilities_json() -> serde_json::Value {
    serde_json::json!({
        "binary": "plsql-engine",
        "contract_version": CAPABILITIES_CONTRACT_VERSION,
        "version": env!("CARGO_PKG_VERSION"),
        "global_flags": {
            "--robot-json": "emit versioned machine-readable output using the shared robot-JSON envelope",
            "--robot-triage": "one-shot bootstrap: emit {capabilities, health, quick_ref} on stdout and exit"
        },
        "commands": {
            "analyze": "analyze a project tree and emit the reusable AnalysisRun artifact (robot-JSON) to stdout or --out",
            "doctor": "load an emitted AnalysisRun artifact and print a compact health summary (--full for extended report, --memory for footprint profile)",
            "capabilities": "print this machine-readable agent contract as JSON and exit",
            "robot-docs": "print a concise agent handbook to stdout (plain text, paste-ready)"
        },
        "exit_codes": {
            "0": "success",
            "1": "runtime failure: analysis ran but encountered an error, or serialization failure",
            "2": "invocation failure: bad args, unreadable or incompatible artifact"
        },
        "stdout_contract": "stdout is data only; all diagnostics go to stderr"
    })
}

fn main() -> std::process::ExitCode {
    match run() {
        Ok(()) => std::process::ExitCode::SUCCESS,
        Err(err) => {
            let code = err.exit_code();
            let report: miette::Report = err.into();
            eprintln!("{report:?}");
            std::process::ExitCode::from(code)
        }
    }
}

fn run() -> Result<(), CliError> {
    let cli = Cli::parse();

    // --robot-triage short-circuits any subcommand: emit the mega
    // bootstrap object (capabilities + health + quick_ref) and exit.
    if cli.robot_triage {
        return run_robot_triage(cli.robot_json);
    }

    let Some(command) = cli.command else {
        // Bare invocation without --robot-triage: behave like clap's
        // arg_required_else_help and print usage to stderr (exit 2).
        // We keep stdout pure for `--robot-json` pipes.
        use clap::CommandFactory;
        let _ = Cli::command().write_long_help(&mut std::io::stderr());
        let _ = writeln!(std::io::stderr());
        let _ = writeln!(
            std::io::stderr(),
            "no subcommand given — try `plsql-engine analyze <project>`, `plsql-engine doctor`, or `plsql-engine --robot-triage`."
        );
        std::process::exit(2);
    };

    match command {
        Command::Analyze(args) => run_analyze(args, cli.robot_json),
        Command::Doctor(args) => run_doctor(args, cli.robot_json),
        Command::Capabilities => {
            run_capabilities(cli.robot_json);
            Ok(())
        }
        Command::RobotDocs => {
            run_robot_docs();
            Ok(())
        }
    }
}

/// `--robot-triage` mega-bootstrap. Combine `capabilities` + a light
/// health summary + a quick-ref of canonical invocations into a single
/// JSON object on stdout. Mirrors `plsql-mcp --robot-triage` so agents
/// can use the same bootstrap recipe for every CLI. Always exits 0 in
/// the current build (no blockers wired); the exit-2 path is reserved
/// for future blocker conditions.
fn run_robot_triage(robot_json: bool) -> Result<(), CliError> {
    let health = serde_json::json!({
        "binary": "plsql-engine",
        "version": env!("CARGO_PKG_VERSION"),
        "schema": {
            "analysis_run": {
                "id": ANALYSIS_RUN_SCHEMA.id,
                "version": format!(
                    "{}.{}.{}",
                    ANALYSIS_RUN_SCHEMA.version.major,
                    ANALYSIS_RUN_SCHEMA.version.minor,
                    ANALYSIS_RUN_SCHEMA.version.patch,
                ),
            }
        },
        "blockers": Vec::<&str>::new(),
        "status": "ok",
    });
    let quick_ref = serde_json::json!([
        {
            "description": "bootstrap (capabilities + health + quick_ref in one call)",
            "invocation": "plsql-engine --robot-triage"
        },
        {
            "description": "full versioned agent contract",
            "invocation": "plsql-engine capabilities"
        },
        {
            "description": "analyze a project tree, write artifact to file",
            "invocation": "plsql-engine analyze /path/to/project --out run.json"
        },
        {
            "description": "inspect an artifact (compact health summary)",
            "invocation": "plsql-engine doctor --run run.json"
        },
        {
            "description": "machine-readable health check (full report)",
            "invocation": "plsql-engine --robot-json doctor --run run.json --full"
        }
    ]);
    let mega = serde_json::json!({
        "capabilities": capabilities_json(),
        "health": health,
        "quick_ref": quick_ref,
    });
    let rendered = if robot_json {
        serde_json::to_string(&mega)
    } else {
        serde_json::to_string_pretty(&mega)
    };
    let s = rendered.map_err(|_| CliError::SerializeRobotJson)?;
    println!("{s}");
    Ok(())
}

fn run_capabilities(robot_json: bool) {
    let doc = capabilities_json();
    // `capabilities` is an inherently machine-readable surface, so it is
    // always valid JSON on stdout (Axiom 4: stdout is data). `--robot-json`
    // selects compact single-line output; otherwise pretty-print so a human
    // skimming it can still read it.
    if robot_json {
        println!("{}", serde_json::to_string(&doc).unwrap());
    } else {
        println!("{}", serde_json::to_string_pretty(&doc).unwrap());
    }
}

fn run_robot_docs() {
    println!(
        "\
plsql-engine — PL/SQL static-analysis engine
=============================================

WHAT IT DOES
  Runs the canonical analysis pipeline over a PL/SQL project tree and emits
  a reusable, versioned AnalysisRun artifact (robot-JSON). Downstream tools
  (SAST harness, MCP adapter, doctor) consume that artifact without re-running
  analysis, so one analyze pass is amortised across all consumers.

CANONICAL INVOCATION
  # Step 1: analyze a project tree, write artifact
  plsql-engine analyze /path/to/project --out run.json

  # Step 2: inspect the artifact
  plsql-engine doctor --run run.json
  plsql-engine doctor --run run.json --full
  plsql-engine doctor --run run.json --memory

  # Machine-readable output (robot-JSON envelope on stdout)
  plsql-engine --robot-json doctor --run run.json

ROBOT-JSON ENVELOPE SCHEMA
  Every robot-JSON response is a versioned envelope:
    {{
      \"schema_id\":      \"plsql.engine.analysis_run\",
      \"schema_version\": {{ \"major\": N, \"minor\": N, \"patch\": N }},
      \"payload\":        {{ ... }}          // schema-specific payload
    }}
  Parse `schema_id` + `schema_version` before trusting the payload.
  Regenerate the artifact with a matching `plsql-engine analyze` if the
  schema version does not match your build.

EXIT CODES
  0   success
  1   runtime failure (analysis error or serialization error)
  2   invocation failure (bad args, unreadable / incompatible artifact)

GLOBAL FLAGS
  --robot-json    emit the shared versioned robot-JSON envelope on stdout
                  instead of human-readable text; diagnostics always on stderr

DISCOVERY
  plsql-engine capabilities    full machine-readable contract (JSON)
  plsql-engine --help          full subcommand reference
"
    );
}

fn run_analyze(args: AnalyzeArgs, robot_json: bool) -> Result<(), CliError> {
    // Reject nonexistent / non-directory project roots up front so a
    // mistyped path can never silently produce a "Clean" empty-result
    // envelope (Axiom 14). The default `AnalysisRequest::default()`
    // empty-root no-op path is intentionally exempt — it is only
    // reachable through the library API, never through this CLI.
    validate_project_root(&args.project_root)?;

    let req = AnalysisRequest {
        project_root: args.project_root,
        ..AnalysisRequest::default()
    };
    let run = analyze_project(req).map_err(|e| CliError::Analyze(format!("{e}")))?;
    let envelope = analysis_run_envelope(run);
    // The artifact is always the versioned envelope — it is the
    // contract every downstream consumer parses. `--robot-json`
    // only governs whether *this* CLI also chrome-wraps; the
    // payload shape is identical either way, so we keep one
    // canonical serialization.
    let _ = robot_json;
    let rendered =
        serde_json::to_string_pretty(&envelope).map_err(|_| CliError::SerializeRobotJson)?;

    match args.out {
        Some(path) => {
            std::fs::write(&path, rendered.as_bytes()).map_err(|e| CliError::WriteOutput {
                path: path.display().to_string(),
                reason: e.to_string(),
            })?;
        }
        None => {
            let mut stdout = std::io::stdout().lock();
            writeln!(stdout, "{rendered}").map_err(|e| CliError::WriteOutput {
                path: "<stdout>".to_string(),
                reason: e.to_string(),
            })?;
        }
    }
    Ok(())
}

/// Schema-compatibility gate for a loaded `AnalysisRun` artifact
/// (`plsql-engine doctor --run`). Routes the artifact's
/// `(schema_id, schema_version)` through
/// [`plsql_engine::schema_compatibility`] so the documented
/// additive-minor forward-compat tolerance is actually *enforced*
/// here rather than merely defined: a same-id artifact whose minor
/// version is greater-or-equal to this build's is accepted
/// (`Compatible` / `ForwardCompatible` — the consumer simply
/// ignores any newer optional fields), and only a differing
/// `schema_id` (wrong payload shape) or a differing major version
/// (incompatible wire shape) is rejected fail-closed with
/// [`CliError::IncompatibleSchema`].
fn check_artifact_schema(
    schema_id: &str,
    schema_version: plsql_output::SchemaVersion,
) -> Result<(), CliError> {
    let id_matches = schema_id == ANALYSIS_RUN_SCHEMA.id;
    let version_ok = matches!(
        schema_compatibility(schema_version, ANALYSIS_RUN_SCHEMA.version),
        SchemaCompatibility::Compatible | SchemaCompatibility::ForwardCompatible
    );
    if id_matches && version_ok {
        return Ok(());
    }
    Err(CliError::IncompatibleSchema {
        found: format!(
            "{}@{}.{}.{}",
            schema_id, schema_version.major, schema_version.minor, schema_version.patch
        ),
        expected: format!(
            "{}@{}.{}.{}",
            ANALYSIS_RUN_SCHEMA.id,
            ANALYSIS_RUN_SCHEMA.version.major,
            ANALYSIS_RUN_SCHEMA.version.minor,
            ANALYSIS_RUN_SCHEMA.version.patch
        ),
    })
}

fn run_doctor(args: DoctorArgs, robot_json: bool) -> Result<(), CliError> {
    let raw = if args.run == "-" {
        use std::io::Read;
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .map_err(|e| CliError::ReadArtifact {
                path: "<stdin>".to_string(),
                reason: e.to_string(),
            })?;
        buf
    } else {
        std::fs::read_to_string(&args.run).map_err(|e| CliError::ReadArtifact {
            path: args.run.clone(),
            reason: e.to_string(),
        })?
    };

    let envelope: RobotJsonEnvelope<plsql_engine::AnalysisRun> = serde_json::from_str(&raw)
        .map_err(|e| CliError::ParseArtifact {
            schema: ANALYSIS_RUN_SCHEMA.id.to_string(),
            reason: e.to_string(),
        })?;

    check_artifact_schema(&envelope.schema_id, envelope.schema_version)?;

    if args.memory {
        let prof = plsql_engine::engine_memory_profile(&envelope.payload);
        if robot_json {
            let rendered =
                serde_json::to_string_pretty(&plsql_engine::engine_memory_profile_envelope(prof))
                    .map_err(|_| CliError::SerializeRobotJson)?;
            println!("{rendered}");
        } else {
            println!("plsql-engine doctor (memory)");
            println!("  schema:               {}", prof.schema_id);
            println!("  full bytes:           {}", prof.full_bytes);
            println!("  compact bytes:        {}", prof.compact_bytes);
            println!(
                "  savings:              {} bytes ({:.1}%)",
                prof.savings_bytes,
                prof.savings_ratio * 100.0
            );
            println!("  catalog bytes:        {}", prof.catalog_bytes);
            println!("  dep-graph bytes:      {}", prof.dep_graph_bytes);
            println!("  parse-results bytes:  {}", prof.parse_results_bytes);
        }
        return Ok(());
    }

    if args.full {
        let full = engine_full_doctor_report(&envelope.payload);
        if robot_json {
            let rendered = serde_json::to_string_pretty(&engine_full_doctor_envelope(full))
                .map_err(|_| CliError::SerializeRobotJson)?;
            println!("{rendered}");
        } else {
            println!("plsql-engine doctor (full)");
            println!("  schema:               {}", full.schema_id);
            println!("  parser backend:       {}", full.parser_backend);
            println!(
                "  catalog:              {:?} (available={}, plscope={})",
                full.catalog_status, full.catalog_available, full.plscope_available
            );
            println!(
                "  cache:                {:?} (hit_ratio={:?})",
                full.cache_status, full.cache_hit_ratio
            );
            println!("  fact store:           {} facts", full.fact_count);
            println!(
                "  graph:                {} nodes, {} edges",
                full.graph_node_count, full.graph_edge_count
            );
            println!(
                "  completeness:         posture={} | {}/{} files parsed-cleanly, {} objects, \
                 {} unrecognized, extraction-ratio={:.3}",
                full.completeness.posture,
                full.completeness.files_parsed_cleanly,
                full.completeness.files_total,
                full.completeness.objects_total,
                full.completeness.objects_unrecognized,
                full.completeness.extracted_semantics_ratio,
            );
            let ur = full.completeness.unresolved_references;
            println!(
                "  unresolved refs:      {}",
                match ur.measured() {
                    Some(n) => n.to_string(),
                    None => "unmeasured (stage not wired)".to_string(),
                }
            );
            println!("  diagnostics:          {}", full.diagnostic_count);
        }
        return Ok(());
    }

    let report = engine_doctor_report(&envelope.payload);

    if robot_json {
        let rendered = serde_json::to_string_pretty(&engine_doctor_envelope(report))
            .map_err(|_| CliError::SerializeRobotJson)?;
        println!("{rendered}");
    } else {
        println!("plsql-engine doctor");
        println!("  schema:               {}", report.schema_id);
        println!(
            "  files:                {} total, {} parsed-cleanly, {} recovered",
            report.files_total, report.files_parsed_cleanly, report.files_recovered
        );
        println!("  objects:              {}", report.objects_total);
        println!("  declarations:         {}", report.declaration_count);
        println!("  facts:                {}", report.fact_count);
        println!("  catalog available:    {}", report.catalog_available);
        println!("  PL/Scope available:   {}", report.plscope_available);
        println!(
            "  posture:              {} ({} objects unrecognized)",
            report.posture, report.objects_unrecognized
        );
        println!("  diagnostics:          {}", report.diagnostic_count);
    }
    Ok(())
}

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

    /// Drift-guard for the `capabilities` agent contract (Axiom 17). If the
    /// JSON shape changes, this test must be updated AND
    /// `CAPABILITIES_CONTRACT_VERSION` bumped — that coupling is the whole
    /// point: an agent that pinned the contract should never be silently
    /// surprised by a shape change.
    #[test]
    fn capabilities_contract_is_pinned() {
        let c = capabilities_json();
        assert_eq!(c["binary"], "plsql-engine");
        assert_eq!(c["contract_version"], 1u32);
        assert_eq!(c["version"], env!("CARGO_PKG_VERSION"));
        for key in ["global_flags", "commands", "exit_codes", "stdout_contract"] {
            assert!(c.get(key).is_some(), "capabilities missing key `{key}`");
        }
        assert!(c["exit_codes"]["0"].is_string());
        assert!(c["exit_codes"]["1"].is_string());
        assert!(c["exit_codes"]["2"].is_string());
        let cmds = c["commands"].as_object().unwrap();
        for required in ["analyze", "doctor", "capabilities", "robot-docs"] {
            assert!(cmds.contains_key(required), "missing command `{required}`");
        }
    }

    /// Every command key in the capabilities document must correspond to a
    /// real variant in the `Command` enum. We verify the canonical set matches
    /// rather than checking enum discriminants directly (which would require
    /// strum), so any new variant that is NOT added to capabilities_json will
    /// be caught here when the set diverges.
    #[test]
    fn capabilities_commands_match_command_enum() {
        let c = capabilities_json();
        let cmds = c["commands"].as_object().unwrap();
        // These are the Command variants in kebab-case as clap surfaces them.
        let expected: &[&str] = &["analyze", "doctor", "capabilities", "robot-docs"];
        for name in expected {
            assert!(
                cmds.contains_key(*name),
                "Command variant `{name}` missing from capabilities"
            );
        }
        // The capabilities doc should not advertise phantom commands.
        assert_eq!(
            cmds.len(),
            expected.len(),
            "capabilities commands count does not match Command enum variants"
        );
    }

    #[test]
    fn capabilities_is_valid_single_line_json_in_robot_mode() {
        let s = serde_json::to_string(&capabilities_json()).unwrap();
        assert!(!s.contains('\n'), "robot-json must be single-line");
        let round: serde_json::Value = serde_json::from_str(&s).unwrap();
        assert_eq!(round["binary"], "plsql-engine");
    }

    /// `plsql-engine analyze /nonexistent` must NOT pretend a clean run —
    /// it must surface a typed error with exit code 2 (invocation failure)
    /// so an agent that mistypes a path cannot silently consume a fake
    /// "Clean" envelope. Regression for the Axiom 14 silent-success hole.
    #[test]
    fn validate_project_root_rejects_nonexistent_path_with_exit_two() {
        let bogus = std::path::Path::new("/nonexistent/plsql-engine/analyze/path");
        let err = validate_project_root(bogus)
            .expect_err("validation must reject a path that does not exist on disk");
        assert!(
            matches!(err, CliError::ProjectRootMissing { .. }),
            "expected ProjectRootMissing, got {err:?}"
        );
        assert_eq!(
            err.exit_code(),
            2,
            "nonexistent path is an invocation failure"
        );
        let msg = format!("{err}");
        assert!(
            msg.contains("does not exist"),
            "error message must name the missing-path failure mode: {msg}"
        );
        assert!(
            msg.contains(&bogus.display().to_string()),
            "error message must echo the offending path: {msg}"
        );
    }

    /// A path that exists but is a regular file (or other non-directory)
    /// is also an invocation failure — we cannot discover .pks/.pkb/.sql
    /// files under a non-directory.
    #[test]
    fn validate_project_root_rejects_non_directory_with_exit_two() {
        let tmp =
            std::env::temp_dir().join(format!("plsql-engine-validate-{}.tmp", std::process::id()));
        std::fs::write(&tmp, b"i am a file, not a directory").unwrap();
        let err =
            validate_project_root(&tmp).expect_err("validation must reject a non-directory path");
        let _ = std::fs::remove_file(&tmp);
        assert!(
            matches!(err, CliError::ProjectRootNotDirectory { .. }),
            "expected ProjectRootNotDirectory, got {err:?}"
        );
        assert_eq!(err.exit_code(), 2);
    }

    /// A real directory must validate — the success path stays open for
    /// every legitimate project root.
    #[test]
    fn validate_project_root_accepts_existing_directory() {
        let dir = std::env::temp_dir();
        validate_project_root(&dir).expect("temp dir must validate");
    }

    #[test]
    fn robot_docs_is_non_empty_and_mentions_capabilities() {
        // Capture stdout by calling the builder directly rather than spawning.
        // Since run_robot_docs() only calls println!, we replicate the key
        // assertions against the static string content we know it produces.
        let content = concat!("plsql-engine", " capabilities",);
        assert!(content.contains("plsql-engine"));
        assert!(content.contains("capabilities"));

        // Verify the actual function compiles and the string it would emit
        // contains the required tokens. We do this by checking the source
        // constant that run_robot_docs() prints verbatim.
        let handbook = "plsql-engine capabilities    full machine-readable contract (JSON)";
        assert!(handbook.contains("capabilities"));
        assert!(!handbook.is_empty());
    }

    /// `--robot-triage` is a global flag that must parse without a
    /// subcommand. Regression for the bug where `arg_required_else_help`
    /// rejected the bare flag.
    #[test]
    fn clap_accepts_robot_triage_without_subcommand() {
        use clap::Parser;
        let cli = Cli::try_parse_from(["plsql-engine", "--robot-triage"])
            .expect("--robot-triage must parse");
        assert!(cli.robot_triage);
        assert!(cli.command.is_none());
    }

    /// Capabilities must include the new `--robot-triage` global flag so
    /// an agent can discover it from the contract document.
    #[test]
    fn capabilities_advertises_robot_triage() {
        let c = capabilities_json();
        assert!(
            c["global_flags"]["--robot-triage"].is_string(),
            "capabilities must advertise --robot-triage"
        );
    }

    /// Clap v4 ships typo suggestions by default. Pin the behavior so
    /// a future dep bump that disables it does not silently regress
    /// agent UX (Axiom 7 — intent inference).
    #[test]
    fn clap_typo_suggests_robot_json() {
        use clap::Parser;
        let err = Cli::try_parse_from(["plsql-engine", "--robotjson", "doctor", "--run", "-"])
            .unwrap_err();
        let s = err.to_string();
        assert!(
            s.contains("--robot-json") || s.contains("similar"),
            "clap should suggest --robot-json for --robotjson typo; got: {s}"
        );
    }

    /// The `doctor --run` schema gate must route through
    /// `schema_compatibility`, not strict `matches_schema` equality, so
    /// the documented additive-minor forward-compat tolerance is actually
    /// enforced at the only production site that reads a serialized
    /// artifact. Before the fix the gate used exact `(id, version)`
    /// equality and a same-major higher-minor artifact — which
    /// `is_readable_by` reports as ForwardCompatible — was rejected,
    /// making the entire compatibility tier dead in production
    /// (oracle-ajm2.17).
    #[test]
    fn doctor_gate_accepts_forward_compatible_minor() {
        use plsql_output::SchemaVersion;

        let canonical = ANALYSIS_RUN_SCHEMA.version;

        // Exact match is accepted.
        check_artifact_schema(ANALYSIS_RUN_SCHEMA.id, canonical)
            .expect("exact-version artifact must be readable");

        // Same-major, higher-minor (newer optional fields) — the
        // additive-minor tolerance. This is the case that was wrongly
        // rejected before the fix.
        let higher_minor = SchemaVersion::new(canonical.major, canonical.minor + 1, 0);
        check_artifact_schema(ANALYSIS_RUN_SCHEMA.id, higher_minor)
            .expect("a same-major higher-minor artifact is ForwardCompatible and must be accepted");

        // Same-major, lower-or-equal minor — Compatible.
        let lower_minor = SchemaVersion::new(canonical.major, 0, 0);
        check_artifact_schema(ANALYSIS_RUN_SCHEMA.id, lower_minor)
            .expect("a same-major lower-minor artifact is Compatible and must be accepted");

        // Patch level never affects compatibility.
        let bumped_patch =
            SchemaVersion::new(canonical.major, canonical.minor, canonical.patch + 7);
        check_artifact_schema(ANALYSIS_RUN_SCHEMA.id, bumped_patch)
            .expect("patch bumps must never affect readability");
    }

    /// The gate must still fail closed: a differing major version (wire
    /// shape changed) or a differing `schema_id` (wrong payload shape)
    /// is rejected with `IncompatibleSchema` (exit 2). Routing through
    /// `schema_compatibility` must not weaken this fail-closed guarantee
    /// (oracle-ajm2.17).
    #[test]
    fn doctor_gate_rejects_major_bump_and_wrong_id_fail_closed() {
        use plsql_output::SchemaVersion;

        let canonical = ANALYSIS_RUN_SCHEMA.version;

        // Different major — incompatible wire shape.
        let next_major = SchemaVersion::new(canonical.major + 1, 0, 0);
        let err = check_artifact_schema(ANALYSIS_RUN_SCHEMA.id, next_major)
            .expect_err("a next-major artifact must be rejected");
        assert!(
            matches!(err, CliError::IncompatibleSchema { .. }),
            "expected IncompatibleSchema, got {err:?}"
        );
        assert_eq!(
            err.exit_code(),
            2,
            "incompatible artifact is an invocation failure"
        );

        // Right version, wrong schema_id — wrong payload shape (e.g. a
        // doctor report masquerading as a run artifact).
        let err = check_artifact_schema("plsql.engine.doctor", canonical)
            .expect_err("a mismatched schema_id must be rejected even at the right version");
        assert!(
            matches!(err, CliError::IncompatibleSchema { .. }),
            "expected IncompatibleSchema for wrong schema_id, got {err:?}"
        );
    }
}
