//! Info dispatch handlers: stats, context, explain, similar, read, blame, onboard.
//!
//! Handlers take a single `&XArgs` argument so the macro-driven
//! `BatchCmd::dispatch` calls every row uniformly.

use anyhow::Result;

use super::super::BatchView;
use crate::cli::args::{BlameArgs, ContextArgs, ExplainArgs, OnboardArgs, ReadArgs, SimilarArgs};

/// Dispatches a blame analysis request for a specified target and returns the results as JSON.
/// This function orchestrates the blame operation by building blame data for the given target and converting it to JSON format. It uses tracing instrumentation to log the operation.
/// # Arguments
/// * `ctx` - The batch context containing the store and root directory path
/// * `target` - The target identifier to analyze for blame information
/// * `depth` - The depth level for traversing blame dependencies
/// * `show_callers` - Whether to include caller information in the blame data
/// # Returns
/// Returns a `Result` containing a `serde_json::Value` representing the blame analysis in JSON format, or an error if the blame data construction fails.
/// # Errors
/// Returns an error if building the blame data fails, such as when the target cannot be found or accessed in the store.
pub(in crate::cli::batch) fn dispatch_blame(
    ctx: &BatchView,
    args: &BlameArgs,
) -> Result<serde_json::Value> {
    let target = args.name.as_str();
    let _span = tracing::info_span!("batch_blame", target).entered();
    // Thin adapter over the shared `blame_core` — identical JSON shape across
    // the CLI and daemon surfaces (both serialize via `blame_to_json`).
    let core_args = crate::cli::commands::blame::BlameArgs {
        name: target.to_string(),
        commits: args.commits,
        callers: args.callers,
    };
    let data = crate::cli::commands::blame::blame_core(&ctx.store(), &ctx.root, &core_args)?;
    Ok(crate::cli::commands::blame::blame_to_json(&data, &ctx.root))
}

/// Dispatches an explain request for a target in batch mode, retrieving and formatting explanation data.
/// # Arguments
/// * `ctx` - The batch execution context providing access to the vector index, embedder, store, and configuration.
/// * `target` - The name or identifier of the target to explain.
/// * `tokens` - Optional token limit for embedder processing. If provided, the embedder will be initialized.
/// # Returns
/// A JSON value containing the formatted explanation data for the specified target.
/// # Errors
/// Returns an error if the vector index cannot be retrieved, the embedder fails to initialize (when tokens are specified), or if the explanation data cannot be built or converted to JSON.
pub(in crate::cli::batch) fn dispatch_explain(
    ctx: &BatchView,
    args: &ExplainArgs,
) -> Result<serde_json::Value> {
    let target = args.name.as_str();
    let tokens = args.tokens;
    let _span =
        tracing::info_span!("batch_explain", target, limit = args.limit_arg.limit).entered();
    // Shared cap with `cmd_explain`. Truncates the per-section lists
    // (callers / callees / similar) before serialization.
    let limit = args.limit_arg.limit.clamp(1, crate::cli::GRAPH_LIMIT_CAP);

    let index = ctx.vector_index()?;
    let index = index.as_deref();
    let embedder = if tokens.is_some() {
        Some(ctx.embedder()?)
    } else {
        None
    };

    let mut data = crate::cli::commands::explain::build_explain_data(
        &ctx.store(),
        &ctx.cqs_dir,
        target,
        tokens,
        Some(index),
        embedder,
        &ctx.model_config,
    )?;
    data.callers.truncate(limit);
    data.callees.truncate(limit);
    data.similar.truncate(limit);

    let output = crate::cli::commands::explain::build_explain_output(&data, &ctx.root);
    Ok(serde_json::to_value(&output)?)
}

/// Searches for chunks similar to a specified target chunk using vector embeddings.
/// Resolves the target chunk by name, retrieves its embedding, and performs a similarity search against the vector index. Returns the top matching chunks ranked by similarity score, excluding the target chunk itself.
/// # Arguments
/// * `ctx` - The batch processing context containing the data store and vector index
/// * `target` - The name or identifier of the chunk to find similar chunks for
/// * `limit` - Maximum number of results to return (clamped to 1-100)
/// * `threshold` - Minimum similarity score (0.0-1.0) for results to be included
/// # Returns
/// A JSON object containing:
/// * `results` - Array of matching chunks with their names, file paths, and similarity scores
/// * `target` - Name of the queried chunk
/// * `total` - Number of results returned
/// # Errors
/// Returns an error if:
/// * The threshold is not a finite number
/// * The named chunk cannot be resolved
/// * The chunk embedding cannot be loaded
/// * The vector index is unavailable or search fails
pub(in crate::cli::batch) fn dispatch_similar(
    ctx: &BatchView,
    args: &SimilarArgs,
) -> Result<serde_json::Value> {
    let name = args.name.as_str();
    let _span = tracing::info_span!("batch_similar", name).entered();

    // Thin adapter over the shared `similar_core`. The daemon path honors the
    // `--lang` / `--path` scope flags (forwarded onto this subcommand tail by
    // the CLI translator) via the same `build_similar_filter` helper the CLI
    // uses, then runs the core over its cached `dyn VectorIndex`.
    let filter = crate::cli::commands::search::similar::build_similar_filter(
        args.lang.as_deref(),
        args.path.as_deref(),
    )?;
    let index = ctx.vector_index()?;
    let store = ctx.store();
    let core_args = crate::cli::commands::search::similar::SimilarArgs {
        name: name.to_string(),
        limit: args.limit_arg.limit,
        threshold: args.threshold,
    };
    let matches = crate::cli::commands::search::similar::similar_core(
        &store,
        index.as_deref(),
        &filter,
        &core_args,
    )?;

    Ok(serde_json::to_value(
        crate::cli::commands::search::similar::build_similar_output(&matches),
    )?)
}

/// Dispatches a context query for a given file path in batch mode, returning JSON data.
/// # Arguments
/// * `ctx` - The batch context containing the indexed data store
/// * `path` - The file path to query context for
/// * `summary` - If true, returns aggregated caller/callee counts; if false, returns full context data
/// * `compact` - If true, returns compacted context data regardless of other flags
/// * `tokens` - Optional token limit for packing the full context response
/// # Returns
/// Returns a `Result` containing a `serde_json::Value` with the context data. The structure varies based on flags: compact mode returns compacted representation, summary mode returns total caller/callee counts, and full mode returns detailed context information.
/// # Errors
/// Returns an error if the file at `path` is not indexed or if data retrieval from the store fails.
pub(in crate::cli::batch) fn dispatch_context(
    ctx: &BatchView,
    args: &ContextArgs,
) -> Result<serde_json::Value> {
    let path = args.path.as_str();
    let _span = tracing::info_span!("batch_context", path).entered();

    // Thin adapter over the shared `context_core` — identical JSON shape across
    // the CLI and daemon surfaces (compact / summary / full all route through
    // the same schema sources, so the daemon's full-context path now carries
    // the external_callers / external_callees / dependent_files /
    // injection_flags / line_start/line_end shape the CLI always emitted).
    // The embedder for full-mode `--tokens` packing comes from the cached view.
    let embedder = if args.tokens.is_some() && !args.compact && !args.summary {
        Some(ctx.embedder()?)
    } else {
        None
    };
    let core_args = crate::cli::commands::context::ContextArgs {
        path: path.to_string(),
        summary: args.summary,
        compact: args.compact,
        tokens: args.tokens,
    };
    crate::cli::commands::context::context_core(&ctx.store(), &ctx.root, &core_args, embedder)
}

/// Collects and aggregates statistics from the batch processing context into a JSON response.
/// This function gathers various metrics from the store including chunk counts, file counts, notes, errors, call graph statistics, type graph statistics, and breakdowns by language and type. All statistics are combined into a single JSON object for reporting.
/// # Arguments
/// `ctx` - The batch processing context containing the store and error counter.
/// # Returns
/// A JSON value containing aggregated statistics with the following top-level fields: `total_chunks`, `total_files`, `notes`, `errors`, `call_graph` (with `total_calls`, `unique_callers`, `unique_callees`), `type_graph` (with `total_edges`, `unique_types`), `by_language`, `by_type`, `model`, and `schema_version`.
/// # Errors
/// Returns an error if any of the store queries fail (stats, note_count, function_call_stats, or type_edge_stats).
pub(in crate::cli::batch) fn dispatch_stats(ctx: &BatchView) -> Result<serde_json::Value> {
    let _span = tracing::info_span!("batch_stats").entered();
    let errors = ctx.error_count.load(std::sync::atomic::Ordering::Relaxed);

    // The CLI and daemon now share `stats_core` so freshness / created_at /
    // hnsw_vectors are computed identically on both surfaces. The daemon
    // layers on its request-error counter afterward — `errors` is the only
    // field that has no CLI analogue.
    let mut output = crate::cli::commands::stats_core(
        &ctx.store(),
        &ctx.root,
        &ctx.cqs_dir,
        &crate::cli::commands::StatsArgs::default(),
    )?;
    output.errors = Some(errors as usize);

    Ok(serde_json::to_value(&output)?)
}

/// Dispatches an onboarding request that identifies relevant code entry points and their relationships, with optional token-based budget limiting.
/// # Arguments
/// * `ctx` - Batch execution context containing the code store and embedder
/// * `query` - Search query string to find relevant code entry points
/// * `depth` - Traversal depth for call chain exploration (clamped to 1-5)
/// * `tokens` - Optional token budget; if provided, limits serialization to fit within budget
/// # Returns
/// Returns a JSON value containing the onboarding result with the entry point, call chain hierarchy with depth-based scoring, and related callers. If tokens budget is not specified, returns the complete serialized result. If budget is specified, performs batch fetching of code chunks to optimize token usage.
/// # Errors
/// Returns an error if embedder initialization fails, onboarding query fails, or serialization fails.
pub(in crate::cli::batch) fn dispatch_onboard(
    ctx: &BatchView,
    args: &OnboardArgs,
) -> Result<serde_json::Value> {
    let query = args.query.as_str();
    let direction = args.direction;
    let _span = tracing::info_span!(
        "batch_onboard",
        query,
        depth = args.depth,
        ?direction,
        limit = args.limit_arg.limit
    )
    .entered();
    let embedder = ctx.embedder()?;

    // Thin adapter over the shared `onboard_core` — identical JSON shape across
    // the CLI and daemon surfaces.
    let core_args = crate::cli::commands::onboard::OnboardArgs {
        query: query.to_string(),
        depth: args.depth,
        direction,
        limit: args.limit_arg.limit,
        tokens: args.tokens,
    };
    crate::cli::commands::onboard::onboard_core(&ctx.store(), embedder, &ctx.root, &core_args)
}

/// Dispatches a read operation on a file within a batch context, optionally with focused reading on a specific note.
/// # Arguments
/// * `ctx` - The batch execution context containing root directory and audit state
/// * `path` - The file path to read, relative to the context root
/// * `focus` - Optional focus identifier to read a specific note instead of the full file
/// # Returns
/// A JSON object containing:
/// * `path` - The requested file path
/// * `content` - The file content, optionally prepended with an audit note header
/// * `notes_injected` - Boolean indicating whether notes were injected into the header
/// # Errors
/// Returns an error if file validation or reading fails.
pub(in crate::cli::batch) fn dispatch_read(
    ctx: &BatchView,
    args: &ReadArgs,
) -> Result<serde_json::Value> {
    let path = args.path.as_str();
    let _span = tracing::info_span!("batch_read", path).entered();

    // Thin adapter over the shared `read_core` — identical union JSON shape
    // across the CLI and daemon surfaces (full + focused). The adapter resolves
    // the cached audit state, the always-fresh notes parse, and the vendored
    // prefixes from the cached `Config`; the core owns the schema.
    let audit_state = ctx.audit_state();
    let notes = ctx.notes();
    let cfg = ctx.config();
    let prefixes = cqs::vendored::effective_prefixes(
        cfg.index
            .as_ref()
            .and_then(|ic| ic.vendored_paths.as_deref()),
    );
    crate::cli::commands::read::read_core(
        &ctx.store(),
        &ctx.root,
        args,
        &audit_state,
        &notes,
        &prefixes,
    )
}

// Happy-path coverage for the embedder-free info dispatchers. The
// integration suite (`tests/cli_batch_test.rs`) covers the dispatch line
// parser but not the per-handler SQL → JSON contract. These pin
// `dispatch_stats` against a freshly-seeded store to catch any regression in
// `build_stats` schema fields without paying the embedder load cost.
#[cfg(test)]
mod tests {
    use super::*;
    use crate::cli::batch::create_test_context;
    use cqs::embedder::Embedding;
    use cqs::parser::{Chunk, ChunkType, Language};
    use cqs::store::{ModelInfo, Store};
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn make_chunk(id: &str, name: &str) -> Chunk {
        let content = format!("fn {name}() {{ }}");
        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        Chunk {
            id: id.to_string(),
            file: PathBuf::from("src/lib.rs"),
            language: Language::Rust,
            chunk_type: ChunkType::Function,
            name: name.to_string(),
            signature: format!("fn {name}()"),
            content,
            doc: None,
            line_start: 1,
            line_end: 5,
            byte_start: 0,
            content_hash,
            canonical_hash: String::new(),
            parent_id: None,
            window_idx: None,
            parent_type_name: None,
            parser_version: 0,
        }
    }

    fn seed_minimal_ctx() -> (TempDir, crate::cli::batch::BatchContext) {
        let dir = TempDir::new().expect("tempdir");
        let cqs_dir = dir.path().join(".cqs");
        std::fs::create_dir_all(&cqs_dir).expect("mkdir .cqs");
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);

        let mut emb_vec = vec![0.0_f32; cqs::EMBEDDING_DIM];
        emb_vec[0] = 1.0;
        let embedding = Embedding::new(emb_vec);

        {
            let store = Store::open(&index_path).expect("open store");
            store.init(&ModelInfo::default()).expect("init");
            let chunks = vec![(make_chunk("src/lib.rs:1:foo", "foo"), embedding)];
            store.upsert_chunks_batch(&chunks, Some(0)).expect("upsert");
        }
        let ctx = create_test_context(&cqs_dir).expect("ctx");
        (dir, ctx)
    }

    #[test]
    fn dispatch_stats_returns_expected_envelope_shape() {
        let (_dir, ctx) = seed_minimal_ctx();
        let json = dispatch_stats(&ctx.build_view(None)).expect("dispatch_stats");
        assert!(json.is_object(), "stats must be a JSON object, got: {json}");
        // Pin the canonical CLI/daemon-parity field set the production
        // `cmd_stats` emits — `total_chunks` is the smoke value.
        let total = json
            .get("total_chunks")
            .unwrap_or_else(|| panic!("`total_chunks` missing in stats: {json}"));
        assert!(
            total.as_u64().is_some_and(|n| n >= 1),
            "total_chunks must reflect the seeded chunk: got {total}"
        );
        // `errors` is always present (set from ctx.error_count, default 0).
        assert!(
            json.get("errors").and_then(|v| v.as_u64()).is_some(),
            "stats must carry an `errors` field, got: {json}"
        );
        // Post-core-unification: the daemon now shares `stats_core` with the
        // CLI, so the formerly CLI-only `created_at` / `hnsw_vectors` keys are
        // present on the daemon path too (hnsw_vectors is null with no HNSW).
        assert!(
            json.get("created_at").is_some(),
            "stats must carry `created_at` post-unification: {json}"
        );
        assert!(
            json.as_object().unwrap().contains_key("hnsw_vectors")
                || json.get("hnsw_vectors").is_none(),
            "hnsw_vectors key handling is core-owned"
        );
    }

    /// Daemon `dispatch_stale` (non-count-only) equals `stale_core(...)` over
    /// the daemon's cached file_set. Parity by construction (the dispatcher
    /// calls this core), so the test also carries a fixture-grounded value
    /// assert — the seeded indexed file surfaces as missing — to catch a
    /// both-sides-empty regression.
    #[test]
    fn parity_stale_dispatch_equals_core() {
        let (_dir, ctx) = seed_minimal_ctx();
        let view = ctx.build_view(None);

        let file_set = view.file_set().expect("file_set");
        let core = crate::cli::commands::stale_core(
            &view.store(),
            &view.root,
            &file_set,
            &crate::cli::commands::StaleArgs::default(),
        )
        .expect("stale_core");
        let core_val = serde_json::to_value(&core).expect("serialize core");

        // Fixture-grounded value assert (not just shape): the seeded chunk
        // lives in `src/lib.rs`, which doesn't exist on disk in the tempdir,
        // so the core must report it indexed-and-missing. Without this both
        // sides could be empty and the equality below would still pass.
        assert!(
            core.total_indexed >= 1,
            "core must count the seeded indexed file, got: {core_val}"
        );
        assert!(
            core.missing.iter().any(|f| f.contains("lib.rs")),
            "seeded src/lib.rs (absent on disk) must surface as missing, got: {core_val}"
        );

        let dispatched = super::super::analysis::dispatch_stale(
            &view,
            &crate::cli::args::StaleArgs { count_only: false },
        )
        .expect("dispatch_stale");

        assert_eq!(
            dispatched, core_val,
            "dispatch_stale (full) must equal stale_core output"
        );
    }

    /// Daemon `dispatch_stats` equals `stats_core(...)` plus the daemon-only
    /// `errors` field. Parity by construction (the dispatcher calls this
    /// core); asserts the adapter adds nothing beyond `errors`, with a
    /// fixture-grounded value assert so an empty-index regression still fails.
    #[test]
    fn parity_stats_dispatch_equals_core_plus_errors() {
        let (_dir, ctx) = seed_minimal_ctx();
        let view = ctx.build_view(None);

        let dispatched = dispatch_stats(&view).expect("dispatch_stats");
        let core = crate::cli::commands::stats_core(
            &view.store(),
            &view.root,
            &view.cqs_dir,
            &crate::cli::commands::StatsArgs::default(),
        )
        .expect("stats_core");
        let mut core_val = serde_json::to_value(&core).expect("serialize core");
        // Fixture-grounded: the seeded `foo` chunk must be counted, so neither
        // side reports an empty index (which by-construction parity wouldn't
        // catch).
        assert!(
            core_val["total_chunks"].as_u64().is_some_and(|n| n >= 1),
            "stats core must count the seeded chunk: {core_val}"
        );
        // The adapter layers on `errors`; everything else must match exactly.
        let errors = dispatched.get("errors").cloned().expect("errors present");
        core_val
            .as_object_mut()
            .unwrap()
            .insert("errors".to_string(), errors);
        assert_eq!(
            dispatched, core_val,
            "dispatch_stats must equal stats_core + errors"
        );
    }

    /// A vendored chunk surfaces `trust_level: "vendored-code"` from
    /// `dispatch_read --focus`, honoring the vendored-code boundary regardless
    /// of the daemon path.
    fn make_chunk_at(id: &str, name: &str, file: &str) -> Chunk {
        let content = format!("fn {name}() {{ }}");
        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        Chunk {
            id: id.to_string(),
            file: PathBuf::from(file),
            language: Language::Rust,
            chunk_type: ChunkType::Function,
            name: name.to_string(),
            signature: format!("fn {name}()"),
            content,
            doc: None,
            line_start: 1,
            line_end: 5,
            byte_start: 0,
            content_hash,
            canonical_hash: String::new(),
            parent_id: None,
            window_idx: None,
            parent_type_name: None,
            parser_version: 0,
        }
    }

    fn seed_with_vendored_chunk() -> (TempDir, crate::cli::batch::BatchContext) {
        let dir = TempDir::new().expect("tempdir");
        let cqs_dir = dir.path().join(".cqs");
        std::fs::create_dir_all(&cqs_dir).expect("mkdir .cqs");
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);

        let mut emb_vec = vec![0.0_f32; cqs::EMBEDDING_DIM];
        emb_vec[0] = 1.0;
        let emb1 = Embedding::new(emb_vec.clone());
        let emb2 = Embedding::new(emb_vec);

        {
            let store = Store::open(&index_path).expect("open store");
            store.init(&ModelInfo::default()).expect("init");
            // Apply default vendored prefixes BEFORE upsert so `node_modules/`
            // is flagged at write time. `set_vendored_prefixes` is on a
            // OnceLock — write order matters: must precede `upsert_chunks_batch`.
            store.set_vendored_prefixes(cqs::vendored::effective_prefixes(None));
            let chunks = vec![
                (
                    make_chunk_at(
                        "node_modules/lib.js:1:lib_fn",
                        "lib_fn",
                        "node_modules/lib.js",
                    ),
                    emb1,
                ),
                (
                    make_chunk_at("src/lib.rs:1:user_fn", "user_fn", "src/lib.rs"),
                    emb2,
                ),
            ];
            store.upsert_chunks_batch(&chunks, Some(0)).expect("upsert");
        }
        let ctx = create_test_context(&cqs_dir).expect("ctx");
        (dir, ctx)
    }

    /// Drive `dispatch_read` in focused mode for the vendored-trust tests.
    fn dispatch_read_focus(view: &BatchView, focus: &str) -> serde_json::Value {
        dispatch_read(
            view,
            &ReadArgs {
                path: String::new(),
                focus: Some(focus.to_string()),
            },
        )
        .expect("dispatch_read focused")
    }

    #[test]
    fn dispatch_read_focus_emits_vendored_code_for_vendored_chunk() {
        let (_dir, ctx) = seed_with_vendored_chunk();
        let view = ctx.build_view(None);
        // `lib_fn` lives in `node_modules/lib.js` so it must be tagged vendored.
        let json = dispatch_read_focus(&view, "lib_fn");
        assert_eq!(
            json["trust_level"], "vendored-code",
            "vendored chunk must surface trust_level=vendored-code, got: {json}"
        );
    }

    #[test]
    fn dispatch_read_focus_emits_user_code_for_normal_chunk() {
        let (_dir, ctx) = seed_with_vendored_chunk();
        let view = ctx.build_view(None);
        // `user_fn` lives in `src/lib.rs` so it's tagged user-code, which is
        // skip-when-default in the union schema — assert the field is absent.
        let json = dispatch_read_focus(&view, "user_fn");
        assert!(
            json.get("trust_level").is_none(),
            "non-vendored chunk: trust_level skip-when-default (user-code), got: {json}"
        );
    }

    /// Parity: `dispatch_read` (full file) is byte-equal to `read_core` driven
    /// with the same args + resolved prefixes. Pins the union schema —
    /// `notes_injected` always present, `trust_level` skip-when-default.
    #[test]
    fn parity_read_full_dispatch_equals_core() {
        let (dir, ctx) = seed_minimal_ctx();
        // A readable file under the project root.
        std::fs::write(dir.path().join("hello.txt"), "hello world\n").expect("write file");
        let view = ctx.build_view(None);

        let args = ReadArgs {
            path: "hello.txt".into(),
            focus: None,
        };
        let dispatched = dispatch_read(&view, &args).expect("dispatch_read");

        let audit_state = view.audit_state();
        let notes = view.notes();
        let cfg = view.config();
        let prefixes = cqs::vendored::effective_prefixes(
            cfg.index
                .as_ref()
                .and_then(|ic| ic.vendored_paths.as_deref()),
        );
        let core = crate::cli::commands::read::read_core(
            &view.store(),
            &view.root,
            &args,
            &audit_state,
            &notes,
            &prefixes,
        )
        .expect("read_core");

        assert_eq!(
            dispatched, core,
            "dispatch_read (full) must equal read_core output"
        );
        // Fixture-grounded: notes_injected present, content carries the file.
        assert_eq!(dispatched["notes_injected"], false);
        assert!(dispatched["content"]
            .as_str()
            .is_some_and(|c| c.contains("hello world")));
    }

    /// Parity: `dispatch_context` (the daemon adapter) is byte-equal to
    /// `context_core` driven with the same args. Compact mode is embedder-free,
    /// so this runs without an ONNX load. Pins the Phase-2b convergence — the
    /// daemon no longer hand-rolls per-chunk JSON.
    #[test]
    fn parity_context_compact_daemon_equals_core() {
        let (_dir, ctx) = seed_minimal_ctx();
        let view = ctx.build_view(None);

        let args = ContextArgs {
            path: "src/lib.rs".into(),
            summary: false,
            compact: true,
            tokens: None,
        };
        let daemon = dispatch_context(&view, &args).expect("dispatch_context");
        let core = crate::cli::commands::context::context_core(
            &view.store(),
            &view.root,
            &crate::cli::commands::context::ContextArgs {
                path: "src/lib.rs".into(),
                summary: false,
                compact: true,
                tokens: None,
            },
            None,
        )
        .expect("context_core");
        assert_eq!(
            daemon, core,
            "daemon dispatch_context must equal context_core (compact)"
        );
    }

    /// Parity for the full-context path (the path whose schema converged onto
    /// the CLI's `FullOutput` in Phase 2b). Embedder-free because `tokens` is
    /// `None`.
    #[test]
    fn parity_context_full_daemon_equals_core() {
        let (_dir, ctx) = seed_minimal_ctx();
        let view = ctx.build_view(None);

        let args = ContextArgs {
            path: "src/lib.rs".into(),
            summary: false,
            compact: false,
            tokens: None,
        };
        let daemon = dispatch_context(&view, &args).expect("dispatch_context");
        let core = crate::cli::commands::context::context_core(
            &view.store(),
            &view.root,
            &crate::cli::commands::context::ContextArgs {
                path: "src/lib.rs".into(),
                summary: false,
                compact: false,
                tokens: None,
            },
            None,
        )
        .expect("context_core");
        assert_eq!(
            daemon, core,
            "daemon dispatch_context must equal context_core (full)"
        );
        // The full path carries the converged schema — these keys were absent
        // from the daemon's old inline JSON.
        assert!(daemon.get("external_callers").is_some());
        assert!(daemon.get("external_callees").is_some());
        assert!(daemon.get("dependent_files").is_some());
    }
}
