//! Read command for cqs
//!
//! Reads a file with context from notes injected as comments.
//! Respects audit mode (skips notes if active).
//!
//! Core logic is in shared functions (`validate_and_read_file`,
//! `build_file_note_header`, `build_focused_output`) so batch mode
//! can reuse them without duplicating ~200 lines.

use std::fmt::Write;
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};

use cqs::audit::load_audit_state;
use cqs::note::{parse_notes, path_matches_mention, Note};
use cqs::parser::ChunkType;
use cqs::store::Store;
use cqs::{compute_hints, FunctionHints, COMMON_TYPES};

/// Maximum type-dependency fragments rendered in a focused read. The
/// type-edge query plus the `COMMON_TYPES` filter can still leave a long tail
/// for a type-heavy chunk; emitting a definition fragment per remaining type
/// floods an agent's token budget. Env override: `CQS_READ_TYPE_DEPS`.
const MAX_READ_TYPE_DEPS_DEFAULT: usize = 50;

/// Ceiling on rows pulled from `get_types_used_by` for the focused-read type
/// section. The list is filtered by `COMMON_TYPES` after the fetch, so the SQL
/// ceiling sits above [`MAX_READ_TYPE_DEPS_DEFAULT`] to leave filtering
/// headroom while still bounding the query.
const READ_TYPE_DEPS_FETCH_CEILING: usize = 200;

/// Resolve `CQS_READ_TYPE_DEPS`, default 50. Parse/warn/default via the shared
/// `cqs::limits::parse_env_usize` (warns on a malformed value).
fn max_read_type_deps() -> usize {
    cqs::limits::parse_env_usize("CQS_READ_TYPE_DEPS", MAX_READ_TYPE_DEPS_DEFAULT)
}

/// Clip `types` to `cap` in place. Returns `Some(message)` describing the
/// truncation ("showing N of M …") when the list was over the cap, or `None`
/// when it fit. The returned message is shared between the rendered body
/// marker and the JSON `warnings[]` entry so the two never drift. The fetch +
/// filter order is preserved, so the kept fragments are the deterministic
/// front of the list.
fn clip_type_deps(types: &mut Vec<cqs::store::TypeUsage>, cap: usize) -> Option<String> {
    let total = types.len();
    if total <= cap {
        return None;
    }
    types.truncate(cap);
    Some(format!(
        "type dependencies truncated: showing {cap} of {total} (raise CQS_READ_TYPE_DEPS to see more)"
    ))
}

// ─── Shared core functions ──────────────────────────────────────────────────

/// Validate path (traversal, size) and read file contents.
/// Returns `(file_path, content)` where `file_path` is root.join(path).
///
/// The existence check is folded into the same "Invalid path" rejection
/// as the traversal check so a daemon client can't use distinguishable
/// error messages as a path-existence oracle for files outside the project
/// root (e.g. `/home/other/.ssh/id_rsa`).
pub(crate) fn validate_and_read_file(root: &Path, path: &str) -> Result<(PathBuf, String)> {
    let file_path = root.join(path);

    // Path traversal protection FIRST so a missing file outside the root
    // and a missing file inside the root produce identical error messages.
    // dunce::canonicalize follows symlinks and resolves filesystem case,
    // so starts_with is correct on NTFS / APFS. dunce strips the Windows
    // UNC `\\?\` prefix automatically.
    //
    // Both `Failed to canonicalize` and `Path traversal not allowed`
    // collapse into the same opaque "Invalid path" so the rejection
    // paths are indistinguishable to the client.
    let canonical = dunce::canonicalize(&file_path).map_err(|_| anyhow::anyhow!("Invalid path"))?;
    let project_canonical = dunce::canonicalize(root).context("Invalid project root")?;
    if !canonical.starts_with(&project_canonical) {
        bail!("Invalid path");
    }

    // `dunce::canonicalize` above already proved existence, so the size cap
    // and the read both route through `&canonical`. Statting and reading the
    // same resolved path keeps the residual TOCTOU at the OS level rather
    // than amplifying it with inconsistent path use here.
    //
    // Env-overridable via CQS_READ_MAX_FILE_SIZE (default 10 MiB).
    let max_file_size = crate::cli::limits::read_max_file_size();
    let metadata = std::fs::metadata(&canonical).context("Failed to read file metadata")?;
    if metadata.len() > max_file_size {
        bail!(
            "File too large: {} bytes (max {} bytes; CQS_READ_MAX_FILE_SIZE)",
            metadata.len(),
            max_file_size
        );
    }

    let content = std::fs::read_to_string(&canonical).context("Failed to read file")?;
    Ok((file_path, content))
}

/// Build note-injection header for a full file read.
/// Returns `(header_string, notes_injected)`.
pub(crate) fn build_file_note_header(
    path: &str,
    file_path: &Path,
    audit_state: &cqs::audit::AuditMode,
    notes: &[Note],
) -> (String, bool) {
    let mut header = String::new();
    let mut notes_injected = false;

    if let Some(status) = audit_state.status_line() {
        // write! straight into the destination buffer avoids the throwaway
        // `format!` String per call.
        let _ = writeln!(header, "// {}\n//", status);
    }

    if !audit_state.is_active() {
        let file_name = file_path.file_name().and_then(|n| n.to_str()).unwrap_or("");
        let relevant: Vec<_> = notes
            .iter()
            .filter(|n| {
                n.mentions
                    .iter()
                    .any(|m| m == file_name || m == path || path_matches_mention(path, m))
            })
            .collect();

        if !relevant.is_empty() {
            notes_injected = true;
            header.push_str("// ┌─────────────────────────────────────────────────────────────┐\n");
            header.push_str("// │ [cqs] Context from notes.toml                              │\n");
            header.push_str("// └─────────────────────────────────────────────────────────────┘\n");
            for n in relevant {
                if let Some(first_line) = n.text.lines().next() {
                    let _ = writeln!(header, "// [{}] {}", n.sentiment_label(), first_line.trim());
                }
            }
            header.push_str("//\n");
        }
    }

    (header, notes_injected)
}

/// Result of a focused read operation.
pub(crate) struct FocusedReadResult {
    pub output: String,
    pub hints: Option<FunctionHints>,
    /// Human-readable warnings emitted when an upstream batch query returned
    /// `Err`, so agents see exactly what was missed instead of inferring it
    /// from absent JSON keys.
    pub warnings: Vec<String>,
    /// Whether the resolved focus chunk is indexed under a vendored path
    /// (`vendor/`, `node_modules/`, `third_party/`, …). Mirrors
    /// `ChunkSummary::vendored` (schema v24). Both CLI and daemon JSON paths
    /// emit `trust_level: "vendored-code"` when this is true.
    pub vendored: bool,
    /// Prompt-injection heuristics that fired on the resolved focus chunk's
    /// content. Mirrors the per-result `injection_flags` the search/scout
    /// shape carries (`detect_all_injection_patterns`), so a focused read of a
    /// chunk containing an injection pattern surfaces the same signal a search
    /// hit on that chunk would. Empty in the common case (no pattern fired).
    pub injection_flags: Vec<&'static str>,
}

/// Build focused-read output: header + hints + notes + target + type deps.
/// Shared between CLI `cmd_read --focus` and batch `dispatch_read --focus`.
pub(crate) fn build_focused_output<Mode>(
    store: &Store<Mode>,
    focus: &str,
    root: &Path,
    audit_state: &cqs::audit::AuditMode,
    notes: &[Note],
) -> Result<FocusedReadResult> {
    let resolved = cqs::resolve_target(store, focus)?;
    let chunk = &resolved.chunk;
    let rel_file = cqs::rel_display(&chunk.file, root);

    let mut output = String::new();

    // Header — write! into output buffer avoids throwaway `format!` String
    // per fragment.
    let _ = writeln!(
        output,
        "// [cqs] Focused read: {} ({}:{}-{})",
        chunk.name, rel_file, chunk.line_start, chunk.line_end
    );

    // Hints (function/method only)
    let hints = if chunk.chunk_type.is_callable() {
        match compute_hints(store, &chunk.name, None) {
            Ok(h) => Some(h),
            Err(e) => {
                tracing::warn!(function = %chunk.name, error = %e, "Failed to compute hints");
                None
            }
        }
    } else {
        None
    };
    if let Some(ref h) = hints {
        let caller_label = if h.caller_count == 0 {
            "! 0 callers".to_string()
        } else {
            format!("{} callers", h.caller_count)
        };
        let test_label = if h.test_count == 0 {
            "! 0 tests".to_string()
        } else {
            format!("{} tests", h.test_count)
        };
        let _ = writeln!(output, "// [cqs] {} | {}", caller_label, test_label);
    }

    // Audit mode status
    if let Some(status) = audit_state.status_line() {
        let _ = writeln!(output, "// {}", status);
    }

    // Note injection (skip in audit mode)
    if !audit_state.is_active() {
        let relevant: Vec<_> = notes
            .iter()
            .filter(|n| {
                n.mentions
                    .iter()
                    .any(|m| m == &chunk.name || m == &rel_file)
            })
            .collect();
        for n in &relevant {
            if let Some(first_line) = n.text.lines().next() {
                let _ = writeln!(output, "// [{}] {}", n.sentiment_label(), first_line.trim());
            }
        }
        if !relevant.is_empty() {
            output.push_str("//\n");
        }
    }

    // Target function
    output.push_str("\n// --- Target ---\n");
    if let Some(ref doc) = chunk.doc {
        output.push_str(doc);
        output.push('\n');
    }
    output.push_str(&chunk.content);
    output.push('\n');

    // Type dependencies.
    // The SQL fetch is bounded by READ_TYPE_DEPS_FETCH_CEILING; the display
    // path filters by COMMON_TYPES then caps the rendered fragments to
    // `max_read_type_deps()` so a type-heavy chunk can't flood the focused
    // read with hundreds of type-definition fragments. The dropped count is
    // surfaced in `output` (a `// [cqs] ...` marker) and `warnings`.
    let type_deps = match store.get_types_used_by(&chunk.name, READ_TYPE_DEPS_FETCH_CEILING) {
        Ok(pairs) => pairs,
        Err(e) => {
            tracing::warn!(function = %chunk.name, error = %e, "Failed to query type deps");
            Vec::new()
        }
    };
    let mut seen_types = std::collections::HashSet::new();
    let mut filtered_types: Vec<cqs::store::TypeUsage> = type_deps
        .into_iter()
        .filter(|t| !COMMON_TYPES.contains(t.type_name.as_str()))
        .filter(|t| seen_types.insert(t.type_name.clone()))
        .collect();
    // Capture failures and truncation as structured warnings rather than
    // silently dropping. Both the batch-fetch failure and the type-deps cap
    // surface here so downstream JSON callers see a `warnings` entry telling
    // them what was missed instead of inferring it from absent fragments.
    let mut warnings: Vec<String> = Vec::new();
    let type_deps_cap = max_read_type_deps();
    if let Some(msg) = clip_type_deps(&mut filtered_types, type_deps_cap) {
        tracing::warn!(
            function = %chunk.name,
            cap = type_deps_cap,
            "Focused read: type dependencies truncated to CQS_READ_TYPE_DEPS"
        );
        // Mark the truncation in the rendered body too — the focused-read
        // content is what a text consumer sees, so a capped list must not read
        // as complete there either.
        let _ = writeln!(output, "// [cqs] {msg}");
        warnings.push(msg);
    }
    tracing::debug!(
        type_count = filtered_types.len(),
        "Type deps for focused read"
    );

    // Batch lookup instead of N+1 queries
    let type_names: Vec<&str> = filtered_types
        .iter()
        .map(|t| t.type_name.as_str())
        .collect();
    let batch_results = match store.search_by_names_batch(&type_names, 5) {
        Ok(m) => m,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to batch-lookup type definitions for focused read");
            warnings.push(format!(
                "search_by_names_batch failed: {e}; type definitions omitted"
            ));
            std::collections::HashMap::new()
        }
    };

    for t in &filtered_types {
        let type_name = &t.type_name;
        let edge_kind = &t.edge_kind;
        if let Some(results) = batch_results.get(type_name.as_str()) {
            let type_def = results.iter().find(|r| {
                r.chunk.name == *type_name
                    && matches!(
                        r.chunk.chunk_type,
                        ChunkType::Struct
                            | ChunkType::Enum
                            | ChunkType::Trait
                            | ChunkType::Interface
                            | ChunkType::Class
                    )
            });
            if let Some(r) = type_def {
                let dep_rel = cqs::rel_display(&r.chunk.file, root);
                let kind_label = if edge_kind.is_empty() {
                    String::new()
                } else {
                    format!(" [{}]", edge_kind)
                };
                let _ = writeln!(
                    output,
                    "\n// --- Type: {}{} ({}:{}-{}) ---",
                    r.chunk.name, kind_label, dep_rel, r.chunk.line_start, r.chunk.line_end
                );
                output.push_str(&r.chunk.content);
                output.push('\n');
            }
        }
    }

    // Run the injection detector on the focus chunk content so both surfaces
    // emit a real `injection_flags` list, matching the per-result shape search
    // and scout carry. Empty when no heuristic fired (the common case).
    let injection_flags = cqs::llm::validation::detect_all_injection_patterns(&chunk.content);

    Ok(FocusedReadResult {
        output,
        hints,
        warnings,
        // Surface the resolved chunk's vendored flag so both CLI and daemon
        // JSON paths emit the correct `trust_level` instead of `"user-code"`.
        vendored: chunk.vendored,
        injection_flags,
    })
}

// ---------------------------------------------------------------------------
// Output structs
// ---------------------------------------------------------------------------

/// JSON output for a full file read — the union schema both surfaces emit.
///
/// `notes_injected` is always present (it's the read-time signal that
/// `docs/notes.toml` context was prepended to the content). `trust_level` is
/// skip-when-default (`"user-code"`): the full read tags a vendored path
/// (`node_modules/`, `vendor/`, …) so a `cqs read node_modules/lodash.js`
/// reports `"vendored-code"`, matching the per-chunk search shape and
/// SECURITY.md's trust-signal contract. The path-vendored detection is shared
/// between surfaces via `vendored_prefixes`.
#[derive(Debug, serde::Serialize)]
struct FullReadOutput {
    path: String,
    content: String,
    /// Whether note context from `docs/notes.toml` was injected into the
    /// content header. Always present (a meaningful read-time signal even when
    /// `false`), unlike the skip-when-default trust/injection fields below.
    notes_injected: bool,
    /// Skip-when-default (`"user-code"`). Emitted as `"vendored-code"` when the
    /// requested path matches a configured vendored prefix.
    #[serde(skip_serializing_if = "is_user_code")]
    trust_level: &'static str,
}

/// `serde` skip predicate: a `"user-code"` trust level is the default and is
/// omitted from the wire shape, matching the per-chunk `to_json_with_origin`
/// skip-when-default rule.
fn is_user_code(level: &&'static str) -> bool {
    *level == "user-code"
}

/// Hints about caller/test coverage for a focused function.
#[derive(Debug, serde::Serialize)]
struct ReadHints {
    caller_count: usize,
    test_count: usize,
    no_callers: bool,
    no_tests: bool,
}

/// JSON output for a focused read — the union schema both surfaces emit.
#[derive(Debug, serde::Serialize)]
struct FocusedReadJsonOutput {
    focus: String,
    content: String,
    /// `read --focus` reads from the project store only (no reference-store
    /// fan-in), so the value is either `"user-code"` (default, skipped) or
    /// `"vendored-code"` depending on the resolved chunk's `chunks.vendored`
    /// flag (schema v24). Skip-when-default mirrors the per-chunk search shape.
    /// SECURITY.md's mitigation contract is that agents can branch safely on
    /// this field; absence means the default `"user-code"`.
    #[serde(skip_serializing_if = "is_user_code")]
    trust_level: &'static str,
    /// Prompt-injection heuristics that fired on the focus chunk content.
    /// Mirrors the per-result `injection_flags` search/scout carry; skipped
    /// when empty (no heuristic fired — the common case).
    #[serde(skip_serializing_if = "Vec::is_empty")]
    injection_flags: Vec<&'static str>,
    #[serde(skip_serializing_if = "Option::is_none")]
    hints: Option<ReadHints>,
    /// Warnings emitted by the underlying assembly (e.g.
    /// `search_by_names_batch` failed). Mirrors `SummaryOutput::warnings` —
    /// agents need to distinguish "no type deps" from "type-deps lookup
    /// failed silently".
    #[serde(skip_serializing_if = "Vec::is_empty")]
    warnings: Vec<String>,
}

// ─── Surface-agnostic core ────────────────────────────────────────────────────

/// Surface-agnostic JSON core for `cqs read`. Returns the union-schema
/// `serde_json::Value` both the CLI (`cmd_read --json`) and the daemon
/// (`dispatch_read`) emit, so the wire shape no longer depends on which surface
/// served the request. Full and focused reads route through the same
/// vendored-detection and note-injection logic.
///
/// The adapter supplies the resolved `vendored_prefixes` (CLI loads config,
/// daemon reuses its cached `Config`) so the path-vendored detection for full
/// reads is shared rather than daemon-only. Reads no env and never prints.
pub(crate) fn read_core<Mode>(
    store: &Store<Mode>,
    root: &Path,
    args: &crate::cli::args::ReadArgs,
    audit_state: &cqs::audit::AuditMode,
    notes: &[Note],
    vendored_prefixes: &[String],
) -> Result<serde_json::Value> {
    let _span = tracing::info_span!("read_core", path = %args.path).entered();

    // Focused read mode.
    if let Some(focus) = args.focus.as_deref() {
        let result = build_focused_output(store, focus, root, audit_state, notes)?;
        let hints = result.hints.as_ref().map(|h| ReadHints {
            caller_count: h.caller_count,
            test_count: h.test_count,
            no_callers: h.caller_count == 0,
            no_tests: h.test_count == 0,
        });
        let output = FocusedReadJsonOutput {
            focus: focus.to_string(),
            content: result.output,
            trust_level: if result.vendored {
                "vendored-code"
            } else {
                "user-code"
            },
            injection_flags: result.injection_flags,
            hints,
            warnings: result.warnings,
        };
        return Ok(serde_json::to_value(&output)?);
    }

    // Full file read.
    let path = args.path.as_str();
    let (file_path, content) = validate_and_read_file(root, path)?;
    let (header, notes_injected) = build_file_note_header(path, &file_path, audit_state, notes);
    let enriched = if header.is_empty() {
        content
    } else {
        format!("{}{}", header, content)
    };

    // Path-vendored detection (shared with the daemon's prior behavior): match
    // the user-supplied relative path against the configured prefixes so
    // `cqs read node_modules/lodash.js` reports `"vendored-code"`, matching the
    // chunks-side labeling.
    let normalized = cqs::normalize_path(Path::new(path));
    let trust_level = if cqs::vendored::is_vendored_origin(&normalized, vendored_prefixes) {
        "vendored-code"
    } else {
        "user-code"
    };

    let output = FullReadOutput {
        path: path.to_string(),
        content: enriched,
        notes_injected,
        trust_level,
    };
    Ok(serde_json::to_value(&output)?)
}

// ─── CLI commands ───────────────────────────────────────────────────────────

pub(crate) fn cmd_read(
    ctx: &crate::cli::CommandContext<'_, cqs::store::ReadOnly>,
    path: &str,
    focus: Option<&str>,
    json: bool,
) -> Result<()> {
    let _span = tracing::info_span!("cmd_read", path).entered();

    let root = &ctx.root;
    // Audit-mode is project-scoped: resolve `audit-mode.json` from the project
    // `.cqs/`, not the slot dir, so CLI-direct `cqs read` suppresses note
    // injection in audit mode identically to the daemon. (`ctx.cqs_dir` is the
    // slot dir and would miss the file on slot-migrated projects.)
    let audit_mode = load_audit_state(&ctx.project_cqs_dir);
    let notes_path = root.join("docs/notes.toml");
    let notes = if notes_path.exists() {
        parse_notes(&notes_path).unwrap_or_else(|e| {
            tracing::warn!(path = %notes_path.display(), error = %e, "Failed to parse notes.toml");
            vec![]
        })
    } else {
        vec![]
    };

    // Text mode keeps the prose renderers (the daemon-forward gate bypasses
    // text mode entirely). JSON mode routes through the shared `read_core` so
    // the CLI and daemon emit the identical union shape.
    if json {
        let args = crate::cli::args::ReadArgs {
            path: path.to_string(),
            focus: focus.map(str::to_string),
        };
        // Resolve the vendored prefixes from project config so the full-read
        // path tags vendored paths identically to the daemon adapter.
        let cfg = cqs::config::Config::load(root);
        let prefixes = cqs::vendored::effective_prefixes(
            cfg.index
                .as_ref()
                .and_then(|ic| ic.vendored_paths.as_deref()),
        );
        let value = read_core(&ctx.store, root, &args, &audit_mode, &notes, &prefixes)?;
        crate::cli::json_envelope::emit_json(&value)?;
        return Ok(());
    }

    // Focused text read.
    if let Some(focus) = focus {
        let result = build_focused_output(&ctx.store, focus, root, &audit_mode, &notes)?;
        // Surface warnings on stderr so non-JSON callers also see them.
        for w in &result.warnings {
            eprintln!("warning: {w}");
        }
        print!("{}", result.output);
        return Ok(());
    }

    // Full text read.
    let (file_path, content) = validate_and_read_file(root, path)?;
    let (header, _notes_injected) = build_file_note_header(path, &file_path, &audit_mode, &notes);
    let enriched = if header.is_empty() {
        content
    } else {
        format!("{}{}", header, content)
    };
    print!("{}", enriched);

    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn make_type_deps(n: usize) -> Vec<cqs::store::TypeUsage> {
        (0..n)
            .map(|i| cqs::store::TypeUsage {
                type_name: format!("Type{i}"),
                edge_kind: "Param".to_string(),
            })
            .collect()
    }

    /// An over-cap type-deps list is clipped to the cap and returns a
    /// truncation message reporting the true total ("showing N of M").
    #[test]
    fn clip_type_deps_over_cap_clips_and_reports_total() {
        let mut deps = make_type_deps(120);
        let msg = clip_type_deps(&mut deps, 50).expect("over-cap must report truncation");
        assert_eq!(deps.len(), 50, "clipped to the cap");
        assert!(
            msg.contains("showing 50 of 120"),
            "message reports total: {msg}"
        );
        assert!(
            msg.contains("CQS_READ_TYPE_DEPS"),
            "message names the knob: {msg}"
        );
        // Front of the deterministic order is preserved.
        assert_eq!(deps[0].type_name, "Type0");
    }

    /// An under-cap list is unchanged and emits no truncation signal.
    #[test]
    fn clip_type_deps_under_cap_unchanged_no_message() {
        let mut deps = make_type_deps(12);
        assert!(
            clip_type_deps(&mut deps, 50).is_none(),
            "under cap must not report truncation"
        );
        assert_eq!(deps.len(), 12, "list unchanged below the cap");
    }

    /// At exactly the cap there is no truncation (boundary).
    #[test]
    fn clip_type_deps_at_cap_no_message() {
        let mut deps = make_type_deps(50);
        assert!(clip_type_deps(&mut deps, 50).is_none());
        assert_eq!(deps.len(), 50);
    }

    #[test]
    fn full_read_output_serialization() {
        // Union shape: path + content + notes_injected always present;
        // trust_level skipped when "user-code".
        let output = FullReadOutput {
            path: "src/lib.rs".into(),
            content: "fn main() {}".into(),
            notes_injected: false,
            trust_level: "user-code",
        };
        let json = serde_json::to_value(&output).unwrap();
        assert_eq!(json["path"], "src/lib.rs");
        assert_eq!(json["content"], "fn main() {}");
        assert_eq!(json["notes_injected"], false);
        assert!(
            json.get("trust_level").is_none(),
            "trust_level skip-when-default (user-code), got: {json}"
        );
    }

    #[test]
    fn full_read_output_emits_vendored_trust_level() {
        let output = FullReadOutput {
            path: "node_modules/lib.js".into(),
            content: "module.exports = {}".into(),
            notes_injected: false,
            trust_level: "vendored-code",
        };
        let json = serde_json::to_value(&output).unwrap();
        assert_eq!(
            json["trust_level"], "vendored-code",
            "non-default trust_level must serialize"
        );
    }

    #[test]
    fn focused_read_output_with_hints() {
        let output = FocusedReadJsonOutput {
            focus: "search".into(),
            content: "fn search() { ... }".into(),
            trust_level: "user-code",
            injection_flags: Vec::new(),
            hints: Some(ReadHints {
                caller_count: 3,
                test_count: 2,
                no_callers: false,
                no_tests: false,
            }),
            warnings: Vec::new(),
        };
        let json = serde_json::to_value(&output).unwrap();
        assert_eq!(json["focus"], "search");
        assert_eq!(json["hints"]["caller_count"], 3);
        assert_eq!(json["hints"]["test_count"], 2);
        assert_eq!(json["hints"]["no_callers"], false);
        assert_eq!(json["hints"]["no_tests"], false);
        // warnings field omitted when empty.
        assert!(json.get("warnings").is_none());
    }

    #[test]
    fn focused_read_output_no_hints() {
        let output = FocusedReadJsonOutput {
            focus: "MyStruct".into(),
            content: "struct MyStruct {}".into(),
            trust_level: "user-code",
            injection_flags: Vec::new(),
            hints: None,
            warnings: Vec::new(),
        };
        let json = serde_json::to_value(&output).unwrap();
        assert_eq!(json["focus"], "MyStruct");
        assert!(json.get("hints").is_none());
    }

    /// `warnings` populated when batch lookup fails. Verified at the
    /// JSON-shape level here; the production wiring goes through
    /// `build_focused_output` which has integration coverage.
    #[test]
    fn focused_read_output_with_warnings() {
        let output = FocusedReadJsonOutput {
            focus: "MyStruct".into(),
            content: "struct MyStruct {}".into(),
            trust_level: "user-code",
            injection_flags: Vec::new(),
            hints: None,
            warnings: vec!["search_by_names_batch failed: db locked".into()],
        };
        let json = serde_json::to_value(&output).unwrap();
        let warns = json["warnings"].as_array().unwrap();
        assert_eq!(warns.len(), 1);
        assert!(warns[0].as_str().unwrap().contains("db locked"));
    }

    /// SECURITY.md's trust-signal contract for `read --focus` JSON:
    /// `trust_level` and `injection_flags` are skip-when-default (their absence
    /// means the default `"user-code"` / no-pattern case, identical to the
    /// per-chunk search shape). When non-default they must serialize. This
    /// regression-pin keeps both halves honest.
    #[test]
    fn focused_read_output_trust_signals_skip_when_default() {
        // Default case: both fields absent.
        let default_out = FocusedReadJsonOutput {
            focus: "f".into(),
            content: "fn f() {}".into(),
            trust_level: "user-code",
            injection_flags: Vec::new(),
            hints: None,
            warnings: Vec::new(),
        };
        let json = serde_json::to_value(&default_out).unwrap();
        assert!(
            json.get("trust_level").is_none(),
            "trust_level skipped when user-code, got: {json}"
        );
        assert!(
            json.get("injection_flags").is_none(),
            "injection_flags skipped when empty, got: {json}"
        );

        // Non-default case: both fields present.
        let flagged = FocusedReadJsonOutput {
            focus: "f".into(),
            content: "fn f() {}".into(),
            trust_level: "vendored-code",
            injection_flags: vec!["ignore_previous_instructions"],
            hints: None,
            warnings: Vec::new(),
        };
        let json = serde_json::to_value(&flagged).unwrap();
        assert_eq!(json["trust_level"], "vendored-code");
        let flags = json["injection_flags"]
            .as_array()
            .expect("injection_flags serializes as array when non-empty");
        assert_eq!(flags.len(), 1);
    }

    /// A file exceeding `CQS_READ_MAX_FILE_SIZE` is rejected with the
    /// documented error message including both the actual size and the cap.
    /// The size-cap branch is the only DoS-prevention layer for
    /// arbitrary-content reads — a flipped comparison sign would slip
    /// through CI without a regression test.
    #[test]
    fn read_rejects_oversized_file() {
        use std::sync::Mutex;
        // CQS_READ_MAX_FILE_SIZE is process-global; serialise the env edit.
        static READ_SIZE_LOCK: Mutex<()> = Mutex::new(());
        let _guard = READ_SIZE_LOCK.lock().unwrap();

        let dir = tempfile::TempDir::new().unwrap();
        let big_path = dir.path().join("big.rs");
        // 100 bytes; cap below is 50.
        std::fs::write(&big_path, vec![b'a'; 100]).unwrap();

        let prev = std::env::var("CQS_READ_MAX_FILE_SIZE").ok();
        std::env::set_var("CQS_READ_MAX_FILE_SIZE", "50");
        let err = super::validate_and_read_file(dir.path(), "big.rs")
            .expect_err("oversized file must error");
        // Restore env regardless of assert outcome.
        match prev {
            Some(v) => std::env::set_var("CQS_READ_MAX_FILE_SIZE", v),
            None => std::env::remove_var("CQS_READ_MAX_FILE_SIZE"),
        }

        let msg = err.to_string();
        assert!(
            msg.contains("File too large"),
            "error must mention 'File too large', got {msg:?}"
        );
        assert!(
            msg.contains("100"),
            "error must include actual size (100), got {msg:?}"
        );
        assert!(
            msg.contains("50"),
            "error must include the cap (50), got {msg:?}"
        );
        assert!(
            msg.contains("CQS_READ_MAX_FILE_SIZE"),
            "error must name the env var so users can tune, got {msg:?}"
        );
    }

    /// `validate_and_read_file` must produce identical error text for "file
    /// outside project root" and "file not found" so a daemon client can't
    /// probe filesystem layout via distinguishable messages.
    #[test]
    fn read_rejects_path_outside_root_with_same_message_as_nonexistent() {
        let root_dir = tempfile::TempDir::new().unwrap();
        // A path whose canonical resolution lands outside the project root.
        // The traversal goes up enough levels to escape the temp dir.
        let outside_err = super::validate_and_read_file(root_dir.path(), "../../../etc/hostname")
            .unwrap_err()
            .to_string();
        // A path that simply doesn't exist within the root.
        let nonexistent_err =
            super::validate_and_read_file(root_dir.path(), "definitely_missing_xyz.rs")
                .unwrap_err()
                .to_string();
        assert_eq!(
            outside_err, nonexistent_err,
            "outside-root and nonexistent paths must produce indistinguishable errors \
             (no path-existence oracle), got outside={outside_err:?} nonexistent={nonexistent_err:?}"
        );
        assert_eq!(
            outside_err, "Invalid path",
            "expected opaque rejection text 'Invalid path', got: {outside_err:?}"
        );
    }
}
