//! Index command for cqs
//!
//! Indexes codebase files for semantic search.

use std::collections::HashSet;
use std::path::Path;

use anyhow::{Context, Result};

use std::sync::Arc;

use cqs::{parse_notes, Embedder, HnswIndex, HnswKind, ModelInfo, Parser as CqParser, Store};

use crate::cli::commands::{daemon_control_hint, DaemonHint};
use crate::cli::{
    acquire_index_lock, args::IndexArgs, check_interrupted, enumerate_files, find_project_root,
    reset_interrupted, run_index_pipeline, signal, Cli,
};

/// `cqs index --json` summary envelope. `cqs index` is pipeline orchestration
/// (parse → embed → store → HNSW), so the logic stays in this adapter; the
/// typed output is just the schema for the final success summary, built from
/// values already computed during the run (no extra DB round trip).
#[derive(Debug, serde::Serialize)]
pub(crate) struct IndexSummaryOutput {
    pub indexed_files: usize,
    pub indexed_chunks: u64,
    pub took_ms: u64,
    pub model: String,
    pub total_embedded: usize,
    pub total_cached: usize,
    pub gpu_failures: usize,
    pub pruned: u32,
    pub parse_errors: usize,
    pub total_calls: usize,
    pub total_type_edges: usize,
}

/// Run the UMAP projection on the daemon-delegation path.
///
/// When `cqs index` (without `--force`) finds a live daemon, it delegates the
/// reconcile and returns early — before the main build's projection step. The
/// daemon's reconcile does not run the projection, so `--umap` would be a
/// silent no-op whenever the (normally always-on) daemon is up. This runs the
/// projection CLI-side against the active slot's store on that path.
///
/// Returns `Some(rows_projected)` when the projection ran (even `Some(0)` for a
/// graceful skip such as no-Python), or `None` when `umap_flag` is unset or the
/// slot store could not be opened. A projection failure is logged but never
/// fatal — the daemon still keeps the index fresh.
///
/// Factored out of the inline delegation branch so the "project even when
/// delegating" decision is unit-testable without a live daemon: the decision is
/// purely `umap_flag`, and the projection runs against a real store at
/// `index_path`.
fn project_umap_on_delegation(umap_flag: bool, index_path: &Path, quiet: bool) -> Option<usize> {
    if !umap_flag {
        return None;
    }
    // The daemon holds only a shared HNSW lock, so a ReadWrite open for the
    // coord write-back succeeds, and the UMAP UPDATE is safe to run
    // concurrently with the daemon reconcile (SQLite serializes the writers
    // under WAL; a chunk deleted mid-update just no-ops its UPDATE).
    match Store::open(index_path) {
        Ok(slot_store) => {
            if !quiet {
                println!("Running UMAP projection...");
            }
            match super::umap::run_umap_projection(&slot_store, index_path, quiet) {
                Ok(updated) => {
                    if !quiet && updated > 0 {
                        println!("  UMAP: {updated} chunks projected to 2D");
                    }
                    Some(updated)
                }
                Err(e) => {
                    tracing::warn!(error = %e, "UMAP projection failed — cluster view will be unavailable");
                    if !quiet {
                        eprintln!("  Warning: UMAP projection failed ({e}); cluster view skipped");
                    }
                    Some(0)
                }
            }
        }
        Err(e) => {
            tracing::warn!(error = %e, path = %index_path.display(), "UMAP projection skipped — could not open slot store");
            if !quiet {
                eprintln!(
                    "  Warning: UMAP projection skipped (could not open {}): {e}",
                    index_path.display()
                );
            }
            None
        }
    }
}

/// Index codebase files for semantic search
///
/// Parses source files, generates embeddings, and stores them in the index database.
/// Uses incremental indexing by default (only re-embeds changed files).
pub(crate) fn cmd_index(cli: &Cli, args: &IndexArgs) -> Result<()> {
    // Hard-fail on `cqs index --model <typo>`. The shared resolver
    // `ModelConfig::resolve` silently falls back to the default preset when
    // the name doesn't match any built-in — fine for read paths (`cqs <q>`),
    // wrong for the operator-driven write path because it can produce an
    // index built against a different model than the one the operator typed.
    // The drift check runs against the resolved (post-fallback) name and so
    // misses this case when default happens to align with the existing index.
    // Catch it here before any state mutation puts the typo in front of the
    // operator.
    if let Some(name) = cli.model.as_deref() {
        if cqs::embedder::ModelConfig::from_preset(name).is_none() {
            anyhow::bail!(
                "Unknown model preset `--model {name}`. Run `cqs model list` to see available \
                 presets, or pass a custom HuggingFace repo via `[embedding] repo` in `cqs.toml`."
            );
        }
    }
    let force = args.force;
    let dry_run = args.dry_run;
    let no_ignore = args.no_ignore;
    let umap_flag = args.umap;

    #[cfg(feature = "llm-summaries")]
    let llm_summaries = args.llm_summaries;
    #[cfg(not(feature = "llm-summaries"))]
    let llm_summaries = false;
    #[cfg(feature = "llm-summaries")]
    let improve_docs = args.improve_docs;
    #[cfg(not(feature = "llm-summaries"))]
    let improve_docs = false;
    #[cfg(feature = "llm-summaries")]
    let improve_all = args.improve_all;
    #[cfg(not(feature = "llm-summaries"))]
    let improve_all = false;
    #[cfg(feature = "llm-summaries")]
    let apply_docs = args.apply;
    #[cfg(not(feature = "llm-summaries"))]
    let apply_docs = false;
    #[cfg(feature = "llm-summaries")]
    let max_docs = args.max_docs;
    #[cfg(not(feature = "llm-summaries"))]
    let max_docs: Option<usize> = None;
    #[cfg(feature = "llm-summaries")]
    let hyde_queries = args.hyde_queries;
    #[cfg(not(feature = "llm-summaries"))]
    let hyde_queries = false;
    #[cfg(feature = "llm-summaries")]
    let max_hyde = args.max_hyde;
    #[cfg(not(feature = "llm-summaries"))]
    let max_hyde: Option<usize> = None;

    reset_interrupted();

    // Validate: --improve-docs requires --llm-summaries
    #[cfg(feature = "llm-summaries")]
    if improve_docs && !llm_summaries {
        anyhow::bail!("--improve-docs requires --llm-summaries");
    }
    #[cfg(feature = "llm-summaries")]
    if improve_all && !improve_docs {
        anyhow::bail!("--improve-all requires --improve-docs");
    }
    #[cfg(feature = "llm-summaries")]
    if apply_docs && !improve_docs {
        anyhow::bail!("--apply requires --improve-docs");
    }

    let root = find_project_root();
    let project_cqs_dir = cqs::resolve_index_dir(&root);

    // Consume a leftover `.cqs/.dirty` marker.
    //
    // On Windows-native there is no `cqs watch --serve` daemon (Unix
    // domain sockets are the wire), so `cqs hook fire` falls through
    // to the file-based fallback and writes `.cqs/.dirty`. The Unix
    // consumer in `cli/watch/mod.rs` runs at watch-loop start; on
    // Windows that path is dead. Reading the marker here lets `cqs
    // index` (the foreground reindex command Windows users actually
    // run) clear the marker so the dirty signal isn't permanent and
    // operators see the marker getting consumed in tracing output.
    //
    // Harmless on Unix too: if the daemon isn't running and a hook
    // fires, the next `cqs index` clears the marker on both platforms —
    // the daemon's own consumer just gets there first when it is running.
    if project_cqs_dir.exists() && consume_dirty_marker(&project_cqs_dir) {
        tracing::info!("Consumed .cqs/.dirty marker — reindex triggered by hook");
    }

    // Ensure project `.cqs/` exists before slot resolution / migration so
    // the slot helpers find a real directory to inspect.
    if !project_cqs_dir.exists() {
        std::fs::create_dir_all(&project_cqs_dir)
            .with_context(|| format!("Failed to create {}", project_cqs_dir.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if let Err(e) =
                std::fs::set_permissions(&project_cqs_dir, std::fs::Permissions::from_mode(0o700))
            {
                tracing::debug!(path = %project_cqs_dir.display(), error = %e, "Failed to set file permissions");
            }
        }
    }

    // Idempotent migration: if a legacy `.cqs/index.db` exists and slots/
    // hasn't been seeded yet, move the legacy index into `slots/default/`.
    if let Err(e) = cqs::slot::migrate_legacy_index_to_default_slot(&project_cqs_dir) {
        tracing::warn!(error = %e, "slot migration failed during index; continuing");
    }

    // Resolve slot. `cqs index` accepts the global `--slot` flag, falls back
    // to `CQS_SLOT` / `.cqs/active_slot` / "default" per spec. If the
    // operator runs `cqs index --slot foo` against a non-existent slot dir,
    // create it now (analogous to `cqs slot create foo` followed by index).
    let resolved_slot = cqs::slot::resolve_slot_name(cli.slot.as_deref(), &project_cqs_dir)
        .map_err(anyhow::Error::from)?;
    let cqs_dir = if cqs::slot::slots_root(&project_cqs_dir).exists() {
        cqs::resolve_slot_dir(&project_cqs_dir, &resolved_slot.name)
    } else {
        // Pre-slots layout: `.cqs/index.db` directly. We allowed migration
        // above to fail silently — the only way to reach here with neither
        // slots/ nor a legacy index is a fresh project. Materialize the
        // slot dir so subsequent runs are slot-aware.
        let dir = cqs::resolve_slot_dir(&project_cqs_dir, &resolved_slot.name);
        if !dir.exists() {
            std::fs::create_dir_all(&dir)
                .with_context(|| format!("Failed to create slot dir {}", dir.display()))?;
        }
        dir
    };
    let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);

    // Ensure the slot dir itself exists with restrictive permissions when
    // we materialize it as part of `cqs index --slot <new>` flows. The
    // project_cqs_dir was already chmodded above; the slot dir is a fresh
    // child whose permissions also matter.
    if !cqs_dir.exists() {
        std::fs::create_dir_all(&cqs_dir)
            .with_context(|| format!("Failed to create slot dir {}", cqs_dir.display()))?;
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if let Err(e) =
                std::fs::set_permissions(&cqs_dir, std::fs::Permissions::from_mode(0o700))
            {
                tracing::debug!(path = %cqs_dir.display(), error = %e, "Failed to set slot dir permissions");
            }
        }
    }

    // Span carries slot name + resolution source so failed-index logs
    // surface which slot was being touched without reading code.
    let _slot_span = tracing::info_span!(
        "index_slot",
        slot_name = %resolved_slot.name,
        slot_source = resolved_slot.source.as_str(),
    )
    .entered();

    // Detect a running cqs-watch --serve daemon BEFORE we touch anything.
    // The daemon holds a shared file lock on `index.hnsw.lock` for the
    // lifetime of its in-memory HNSW.
    //
    // Two paths from here, depending on whether `--force` is set:
    //
    // - **Without `--force`**: the daemon is already keeping the index
    //   fresh via inotify-driven incremental reconcile. Trigger an
    //   immediate reconcile pass via the daemon's `reconcile` socket
    //   message and return `Ok(())` — the user gets the freshness they
    //   asked for without paying for a redundant from-scratch rebuild.
    //
    // - **With `--force`**: the operator wants a complete from-scratch
    //   rebuild. That requires invalidating the daemon's in-memory HNSW
    //   (which the daemon won't release without a restart). We can't
    //   transparently swap an in-memory HNSW under the daemon, so bail
    //   with the original "stop the daemon" message — running this would
    //   block indefinitely in `locks_lock_inode_wait` for an exclusive
    //   write lock the daemon will never release. On WSL/NTFS the
    //   "advisory-only" warning fires but the wait still happens.
    //
    // We use a connect-only probe (not the typed `daemon_ping`) so a
    // daemon running an older `PingResponse` schema still gets detected;
    // schema drift would otherwise let the deserialize error fall
    // through and silently restore the old hang behavior on version
    // mismatches.
    //
    // Daemon socket is hashed from the project-level `.cqs/` (one daemon
    // per project, regardless of slot) so we use `project_cqs_dir` here.
    #[cfg(unix)]
    if !dry_run {
        let sock_path = cqs::daemon_translate::daemon_socket_path(&project_cqs_dir);
        if sock_path.exists() {
            use std::os::unix::net::UnixStream;
            use std::time::Duration;
            match UnixStream::connect(&sock_path) {
                Ok(stream) => {
                    // Connected — daemon is alive enough to hold the HNSW
                    // lock. Drop the stream immediately.
                    let _ = stream.set_read_timeout(Some(Duration::from_millis(100)));
                    drop(stream);

                    if force {
                        // --force needs to invalidate the daemon's
                        // in-memory HNSW. Bail with the operator-actionable
                        // message (the original behavior).
                        anyhow::bail!(
                            "A cqs-watch --serve daemon is currently running ({}). It holds a shared lock on \
                             the HNSW index, so this reindex would block indefinitely in locks_lock_inode_wait. \
                             Stop the daemon before reindexing:\n\n  \
                             {stop} && cqs index --force && {start}\n\n\
                             (If you launched the daemon manually, kill that process instead.)",
                            sock_path.display(),
                            stop = daemon_control_hint(DaemonHint::Stop),
                            start = daemon_control_hint(DaemonHint::Start),
                        );
                    }

                    // Plain `cqs index`: route to the daemon's reconcile
                    // RPC instead of fighting it for the lock.
                    match cqs::daemon_translate::daemon_reconcile(
                        &project_cqs_dir,
                        Some("cli-index"),
                        &[],
                    ) {
                        Ok(resp) => {
                            if !cli.quiet {
                                let suffix = if resp.was_pending {
                                    " (a previous reconcile was already pending; the daemon coalesced both)"
                                } else {
                                    ""
                                };
                                println!(
                                    "Daemon at {} is keeping the index fresh; queued an immediate reconcile pass{}.",
                                    sock_path.display(),
                                    suffix
                                );
                                println!(
                                    "Use `cqs index --force` (after stopping the daemon) to fully rebuild from scratch."
                                );
                            }
                            // `--umap` is a CLI-side projection pass the daemon's
                            // reconcile does NOT run. Without this the flag is a
                            // silent no-op whenever the (normally always-on)
                            // daemon is up, because the delegation returns before
                            // the projection at the end of the main build path.
                            // Run it here against the active slot's store
                            // regardless of which delegation branch was taken.
                            let umap_updated =
                                project_umap_on_delegation(umap_flag, &index_path, cli.quiet);

                            if cli.json || args.json {
                                let env = serde_json::json!({
                                    "data": {
                                        "daemon_reconcile_queued": true,
                                        "was_pending": resp.was_pending,
                                        "socket": sock_path.display().to_string(),
                                        "umap_projected": umap_updated,
                                    },
                                    "version": 1,
                                    "error": null,
                                });
                                println!("{}", env);
                            }
                            return Ok(());
                        }
                        Err(e) => {
                            // Socket connected but reconcile RPC failed
                            // (version skew / parse error / disconnect
                            // mid-call). Fall back to the original bail
                            // so the operator sees a deterministic
                            // failure mode.
                            anyhow::bail!(
                                "Daemon detected at {} but reconcile RPC failed: {}.\n\n\
                                 If this persists, restart the daemon:\n  \
                                 {restart}",
                                sock_path.display(),
                                e,
                                restart = daemon_control_hint(DaemonHint::Restart),
                            );
                        }
                    }
                }
                Err(e) => {
                    // Socket file exists but connect failed → stale socket
                    // (kill -9, OOM, power loss left the file behind). Safe
                    // to proceed; the next daemon start will replace it.
                    tracing::debug!(
                        path = %sock_path.display(),
                        error = %e,
                        "stale daemon socket present; proceeding with reindex"
                    );
                }
            }
        }
    }

    // Acquire lock (unless dry run)
    let _lock = if !dry_run {
        Some(acquire_index_lock(&cqs_dir)?)
    } else {
        None
    };

    signal::setup_signal_handler();

    let _span = tracing::info_span!("cmd_index", force = force, dry_run = dry_run).entered();

    // Capture wall-clock start so the optional JSON envelope can report
    // `took_ms`. Honors both global `--json` and the local one.
    let want_json = cli.json || args.json;
    let json_start = std::time::Instant::now();

    if !cli.quiet {
        println!("Scanning files...");
    }

    let parser = CqParser::new()?;
    let files = enumerate_files(&root, &parser, no_ignore)?;

    if !cli.quiet {
        println!("Found {} files", files.len());
    }

    if dry_run {
        for file in &files {
            println!("  {}", file.display());
        }
        println!();
        println!("(dry run - no changes made)");
        return Ok(());
    }

    // Initialize or open store.
    // When --force, back up the old DB instead of deleting it.
    // If interrupted during rebuild, the backup remains recoverable.
    let backup_path = cqs_dir.join("index.db.bak");
    let store = if index_path.exists() && !force {
        let store = Store::open(&index_path)
            .with_context(|| format!("Failed to open store at {}", index_path.display()))?;

        // Catch the silent dim-mismatch footgun. The global `--model` flag
        // parses for `cqs index`, but without `--force` we keep the prior
        // store init — so a `cqs index --model X` against an index that was
        // built for `Y` would feed X-dim embeddings into a store whose schema
        // is pinned to Y-dim. Bail explicitly with a recovery hint so the
        // operator gets the actionable error instead of a downstream embedding
        // write that silently corrupts the index.
        let mc = cli.try_model_config()?;
        let stored = store.try_stored_model_name().with_context(|| {
            format!(
                "Failed to read model_name from {} metadata while checking for \
                 model drift",
                index_path.display()
            )
        })?;
        check_index_model_drift(stored.as_deref(), &mc.name, &mc.repo, &index_path)?;
        store
    } else {
        // Read LLM summaries from existing DB before destroying it.
        // Summaries are keyed by content_hash (blake3 of source content) so they're
        // valid for any chunk with identical source, even after reindex.
        let saved_summaries = if index_path.exists() {
            match Store::open(&index_path) {
                Ok(old_store) => {
                    let summaries = match old_store.get_all_summaries_full() {
                        Ok(s) => s,
                        Err(e) => {
                            tracing::warn!(error = %e, "Failed to read LLM summaries");
                            Vec::new()
                        }
                    };
                    if !summaries.is_empty() {
                        tracing::info!(
                            count = summaries.len(),
                            "Read LLM summaries from existing DB"
                        );
                    }
                    // Explicit close() runs the bounded TRUNCATE checkpoint
                    // so concurrent watch writes are flushed into the main DB
                    // before we delete -wal/-shm below. Plain drop() runs only
                    // PASSIVE, which can return without flushing under reader
                    // contention. close() bounds at 30s and falls back to
                    // PASSIVE on timeout.
                    if let Err(e) = old_store.close() {
                        tracing::warn!(
                            error = %e,
                            "Old store close() failed before --force rebuild; \
                             concurrent writes may not have been flushed"
                        );
                    }
                    summaries
                }
                Err(e) => {
                    tracing::warn!(error = %e, "Failed to read summaries from existing DB");
                    Vec::new()
                }
            }
        } else {
            Vec::new()
        };

        if index_path.exists() {
            std::fs::rename(&index_path, &backup_path)
                .with_context(|| format!("Failed to back up {}", index_path.display()))?;
            // Also remove WAL/SHM files left by SQLite — stale journal
            // files from the old DB would corrupt the fresh database.
            for suffix in &["-wal", "-shm"] {
                let journal = cqs_dir.join(format!("index.db{suffix}"));
                if journal.exists() {
                    if let Err(e) = std::fs::remove_file(&journal) {
                        tracing::warn!(path = %journal.display(), error = %e,
                            "Failed to remove stale SQLite journal file");
                    }
                }
            }
        }
        let mut store = Store::open(&index_path)
            .with_context(|| format!("Failed to create store at {}", index_path.display()))?;
        let mc = cli.try_model_config()?;
        store.init(&ModelInfo::new(&mc.repo, mc.dim))?;
        store.set_dim(mc.dim);

        // Restore saved summaries into the fresh DB
        if !saved_summaries.is_empty() {
            match store.upsert_summaries_batch(&saved_summaries) {
                Ok(n) => tracing::info!(count = n, "Restored LLM summaries"),
                Err(e) => tracing::warn!(error = %e, "Failed to restore LLM summaries"),
            }
        }
        store
    };
    // Stamp the vendored-path prefix list before any chunk upsert so new
    // rows get correct `vendored` flags. Reads `[index].vendored_paths` from
    // `.cqs.toml`; falls back to the built-in default list when absent.
    // Idempotent setter — safe to call regardless of which branch above
    // produced `store`.
    let cfg_for_vendored = cqs::config::Config::load(&root);
    let vendored_override = cfg_for_vendored
        .index
        .as_ref()
        .and_then(|ic| ic.vendored_paths.as_deref());
    store.set_vendored_prefixes(cqs::vendored::effective_prefixes(vendored_override));

    let store = Arc::new(store);

    if !cli.quiet {
        println!("Indexing {} files (pipelined)...", files.len());
    }

    // Mark both HNSW kinds as dirty before writing chunks — if we crash
    // between SQLite commit and HNSW save, the dirty flag tells the next
    // load to fall back to brute-force search until a full rebuild. Base
    // and enriched are built from the same chunks, so both must be marked.
    // The dirty flag is a crash-safety invariant — if we can't set it,
    // abort rather than risk a stale index on crash.
    store
        .set_hnsw_dirty(HnswKind::Enriched, true)
        .context("Failed to mark enriched HNSW dirty before indexing")?;
    store
        .set_hnsw_dirty(HnswKind::Base, true)
        .context("Failed to mark base HNSW dirty before indexing")?;

    // Skip the first-pass embed when `--llm-summaries` is set.
    // The post-summary `enrichment_pass` will overwrite every chunk's
    // embedding anyway (NL = `summary + caller/callee names + content`),
    // so the first embed is wasted work — ~30 min of GPU time on a
    // 12k-chunk Qwen3-4B reindex. Chunks land with a zero-vec sentinel +
    // `needs_embedding=1`; HNSW build and search hide them until
    // enrichment lands the real vector. If enrichment fails or is
    // interrupted, the next `cqs index` invocation observes the
    // `needs_embedding=1` rows and re-runs enrichment to clear them.
    //
    // The LLM summary pass is best-effort independent of this — its
    // failure doesn't block enrichment_pass's local embed work, just
    // means chunks get the base NL (no summary boost) for that run.
    #[cfg(feature = "llm-summaries")]
    let skip_first_pass_embed = llm_summaries;
    #[cfg(not(feature = "llm-summaries"))]
    let skip_first_pass_embed = false;

    // Run the 3-stage pipeline: parse → embed → write
    // Pipeline shares the same Store via Arc (no duplicate DB connections)
    let stats = run_index_pipeline(
        &root,
        files.clone(),
        Arc::clone(&store),
        force,
        cli.quiet,
        cli.try_model_config()?.clone(),
        skip_first_pass_embed,
    )?;
    let total_embedded = stats.total_embedded;
    let total_cached = stats.total_cached;
    let gpu_failures = stats.gpu_failures;

    // Prune missing files
    let existing_files: HashSet<_> = files.into_iter().collect();
    let pruned = store
        .prune_missing(&existing_files, &root)
        .context("Failed to prune deleted files from index")?;

    if !cli.quiet {
        println!();
        println!("Index complete:");
        let newly_embedded = total_embedded - total_cached;
        if total_cached > 0 {
            println!(
                "  Chunks: {} ({} cached, {} embedded)",
                total_embedded, total_cached, newly_embedded
            );
        } else {
            println!("  Embedded: {}", total_embedded);
        }
        if gpu_failures > 0 {
            println!("  GPU failures: {} (fell back to CPU)", gpu_failures);
        }
        if pruned > 0 {
            println!("  Pruned: {} (deleted files)", pruned);
        }
        if stats.parse_errors > 0 {
            println!(
                "  Parse errors: {} (see logs for details)",
                stats.parse_errors
            );
        }
    }

    if !cli.quiet && stats.total_calls > 0 {
        println!("  Call graph: {} calls", stats.total_calls);
    }
    if !cli.quiet && stats.total_type_edges > 0 {
        println!("  Type edges: {} edges", stats.total_type_edges);
    }

    // LLM summary pass: generate one-sentence summaries via Claude API.
    // Runs BEFORE enrichment so summaries are incorporated into enrichment NL.
    #[cfg(feature = "llm-summaries")]
    if !check_interrupted() && llm_summaries {
        if !cli.quiet {
            println!("Generating LLM summaries...");
        }
        let config = cqs::config::Config::load(&root);
        let count = cqs::llm::llm_summary_pass(&store, cli.quiet, &config, Some(&cqs_dir))
            .context("LLM summary pass failed")?;
        if !cli.quiet && count > 0 {
            println!("  LLM summaries: {} new", count);
        }
    }

    // Doc comment generation pass: generate and write back doc comments
    #[cfg(feature = "llm-summaries")]
    if !check_interrupted() && improve_docs {
        if !cli.quiet {
            println!("Generating doc comments...");
        }
        let config = cqs::config::Config::load(&root);
        let doc_results = cqs::llm::doc_comment_pass(
            &store,
            &config,
            max_docs.unwrap_or(0),
            improve_all,
            Some(&cqs_dir),
        )
        .context("Doc comment generation failed")?;

        if !doc_results.is_empty() {
            // Group by file and write back (or stage as review patches)
            use std::collections::HashMap;
            let mut by_file: HashMap<std::path::PathBuf, Vec<_>> = HashMap::new();
            for r in doc_results {
                by_file.entry(r.file.clone()).or_default().push(r);
            }
            let doc_parser = CqParser::new()?;
            if apply_docs {
                if !cli.quiet {
                    println!("  Writing generated doc comments directly to source files (--apply)");
                }
                let mut total = 0;
                for (path, edits) in &by_file {
                    match cqs::doc_writer::rewriter::rewrite_file(path, edits, &doc_parser) {
                        Ok(n) => total += n,
                        Err(e) => tracing::warn!(
                            file = %path.display(),
                            error = %e,
                            "Doc write-back failed"
                        ),
                    }
                }
                if !cli.quiet {
                    println!(
                        "  Doc comments: {} functions across {} files",
                        total,
                        by_file.len()
                    );
                }
            } else {
                let patch_dir = cqs_dir.join("proposed-docs");
                let mut written = 0usize;
                let mut skipped = 0usize;
                for (path, edits) in &by_file {
                    match cqs::doc_writer::rewriter::write_proposed_patch(
                        path,
                        &root,
                        edits,
                        &doc_parser,
                        &patch_dir,
                    ) {
                        Ok(true) => written += 1,
                        Ok(false) => skipped += 1,
                        Err(e) => tracing::warn!(
                            file = %path.display(),
                            error = %e,
                            "Doc patch write failed"
                        ),
                    }
                }
                if !cli.quiet {
                    if written > 0 {
                        println!(
                            "  Doc comments: {} proposed update(s) written to {}",
                            written,
                            patch_dir.display()
                        );
                        println!(
                            "    Review and apply with: git apply {}/**/*.patch",
                            patch_dir.display()
                        );
                        println!("    Or rerun with --apply to write directly (skips review).");
                    } else if skipped > 0 {
                        println!(
                            "  Doc comments: {} candidate file(s) produced no diff",
                            skipped
                        );
                    }
                }
            }
        } else if !cli.quiet {
            println!("  Doc comments: 0 candidates");
        }
    }

    // HyDE query prediction pass: generate hypothetical queries for functions
    #[cfg(feature = "llm-summaries")]
    if !check_interrupted() && hyde_queries {
        if !cli.quiet {
            println!("Generating hyde query predictions...");
        }
        let config = cqs::config::Config::load(&root);
        let count = cqs::llm::hyde_query_pass(
            &store,
            cli.quiet,
            &config,
            max_hyde.unwrap_or(0),
            Some(&cqs_dir),
        )
        .context("Hyde query prediction pass failed")?;
        if !cli.quiet && count > 0 {
            println!("  Hyde predictions: {} new", count);
        }
    }

    // Belt-and-braces final flush of the summary queue before the index
    // lock is dropped. Each LLM pass also flushes on its own way out, but
    // residue from a signal-interrupted pass would otherwise have to wait
    // for the next `cqs index` run. Idempotent on an empty queue; cheap.
    #[cfg(feature = "llm-summaries")]
    if let Err(e) = store.flush_pending_summaries() {
        tracing::warn!(error = %e, "cmd_index: final flush of summary queue failed; rows retained for next run");
    }

    // Prune orphaned llm_summaries rows whose content_hash no longer
    // maps to any chunk. Each reindex that changes any code accumulates
    // orphans (chunk content changed → new hash, old hash sticks around);
    // unpruned, the table grows arbitrarily larger than the corpus and
    // `cqs stats llm_summary_count` overstates real coverage. Runs after the
    // final flush so any rows just written by the LLM passes are anchored
    // to current chunks before we sweep.
    //
    // Opt-out via `--no-prune-summaries` (preserves orphans for cross-slot
    // summary copy by content_hash — the only workflow that benefits from
    // keeping them around).
    if !args.no_prune_summaries {
        match store.prune_orphaned_llm_summaries() {
            Ok(0) => {}
            Ok(pruned) => {
                if !cli.quiet {
                    println!("  Pruned {pruned} orphaned LLM summary rows");
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "cmd_index: failed to prune orphaned llm_summaries; non-fatal");
            }
        }
    }

    // Call-graph enrichment pass: re-embed chunks with caller/callee
    // context. Also fires whenever any chunk is at `needs_embedding=1`
    // — the parser stage marks chunks unembedded when a `--llm-summaries`
    // run skipped the first-pass embed, and `enrichment_pass` is the gate
    // that clears those flags. Without this trigger, a project with no calls
    // would leave first-pass-skipped chunks invisible forever.
    let needs_embed_count = match store.needs_embedding_count() {
        Ok(n) => n,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to count needs_embedding chunks; assuming zero");
            0
        }
    };
    if !check_interrupted() && (stats.total_calls > 0 || needs_embed_count > 0) {
        use crate::cli::enrichment_pass;

        if !cli.quiet {
            if needs_embed_count > 0 {
                println!(
                    "Enriching embeddings with call graph context ({} chunks awaiting first embedding)...",
                    needs_embed_count
                );
            } else {
                println!("Enriching embeddings with call graph context...");
            }
        }
        let model_config = cli.try_model_config()?.clone();
        let embedder = Embedder::new(model_config.clone())
            .context("Failed to create embedder for enrichment pass")?;
        match enrichment_pass(&store, &embedder, &model_config, cli.quiet) {
            Ok(count) => {
                if !cli.quiet && count > 0 {
                    println!("  Enriched: {} chunks", count);
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "Enrichment pass failed, continuing without");
                if !cli.quiet {
                    eprintln!("  Warning: enrichment pass failed: {:?}", e);
                }
            }
        }
    }

    // Index notes if notes.toml exists.
    //
    // First-encounter prompt — if this is the project's first index
    // (no `.accepted-shared-notes` marker yet) AND a committed notes file
    // exists, confirm with the user before proceeding. Bypassable with
    // `--accept-shared-notes` for CI / scripted use; non-TTY stdin
    // auto-skips the index-notes step (loud warn, never hangs).
    let proceed_with_notes =
        first_encounter_notes_gate(&root, &project_cqs_dir, args.accept_shared_notes, cli.quiet)?;
    if !check_interrupted() && proceed_with_notes {
        if !cli.quiet {
            println!("Indexing notes...");
        }

        let (note_count, was_skipped) = index_notes_from_file(&root, &store, force)?;

        if !cli.quiet {
            if was_skipped && note_count == 0 {
                println!("Notes up to date.");
            } else if note_count > 0 {
                let ns = store
                    .note_stats()
                    .context("Failed to read note statistics")?;
                println!(
                    "  Notes: {} total ({} warnings, {} patterns)",
                    ns.total, ns.warnings, ns.patterns
                );
            }
        }
    }

    // SPLADE sparse encoding (if model available).
    //
    // Path resolution is delegated to cqs::splade::resolve_splade_model_dir
    // so the env var (CQS_SPLADE_MODEL) and vocab-mismatch probe stay
    // consistent with the search-time encoder loaders. Critical for index
    // correctness: if the index pass and search pass use different SPLADE
    // models, the sparse vectors are token-incompatible and search-time
    // queries return garbage. Single source of truth.
    if !check_interrupted() {
        if let Some(splade_dir) = cqs::splade::resolve_splade_model_dir() {
            if !cli.quiet {
                println!("Encoding SPLADE sparse vectors...");
            }
            match cqs::splade::SpladeEncoder::new(
                &splade_dir,
                cqs::splade::SpladeEncoder::default_threshold(),
            ) {
                Ok(encoder) => {
                    let _span = tracing::info_span!("splade_index_encode").entered();
                    // Only encode chunks that don't already have sparse
                    // vectors. On --force the DB is fresh so all chunks are
                    // "missing"; on incremental runs this skips the ~95% of
                    // chunks that haven't changed.
                    let chunk_texts = store.chunk_splade_texts_missing()?;
                    let mut sparse_vecs: Vec<(String, cqs::splade::SparseVector)> = Vec::new();
                    let mut encoded = 0usize;
                    let mut failed = 0usize;

                    // Batch encode instead of per-chunk.
                    //
                    // CQS_SPLADE_BATCH overrides the initial batch size
                    // (default 64). Larger SPLADE models (SPLADE-Code 0.6B
                    // at 5.5x params) overflow GPU memory at 64. The inner
                    // loop is also adaptive: on OOM, halve and retry.
                    //
                    // CQS_SPLADE_RESET_EVERY (default 0 = disabled) triggers
                    // a session.clear() every N batches. This frees the ORT
                    // BFC arena which can accumulate cached allocations even
                    // with constant-shape inputs. Set to 32-64 if encoding
                    // a large corpus through a large model leaks memory.
                    let initial_batch: usize = std::env::var("CQS_SPLADE_BATCH")
                        .ok()
                        .and_then(|v| v.parse().ok())
                        .filter(|&n: &usize| n >= 1)
                        .unwrap_or(64);
                    let reset_every: usize = std::env::var("CQS_SPLADE_RESET_EVERY")
                        .ok()
                        .and_then(|v| v.parse().ok())
                        .unwrap_or(0);

                    let total_chunks = chunk_texts.len();
                    let progress_step = (total_chunks / 20).max(1);
                    let mut next_progress_threshold = progress_step;

                    tracing::info!(
                        initial_batch,
                        reset_every,
                        total_chunks,
                        "SPLADE encoding starting"
                    );

                    let mut current_batch_size = initial_batch;
                    let mut idx = 0;
                    let mut batches_done = 0usize;
                    while idx < total_chunks {
                        let end = (idx + current_batch_size).min(total_chunks);
                        let batch = &chunk_texts[idx..end];
                        let ids: Vec<&str> = batch.iter().map(|(id, _)| id.as_str()).collect();
                        let texts: Vec<&str> = batch.iter().map(|(_, t)| t.as_str()).collect();

                        match encoder.encode_batch(&texts) {
                            Ok(svs) => {
                                for (id, sv) in ids.into_iter().zip(svs) {
                                    if !sv.is_empty() {
                                        sparse_vecs.push((id.to_string(), sv));
                                        encoded += 1;
                                    }
                                }
                                idx = end;
                                batches_done += 1;

                                // Periodic arena reset
                                if reset_every > 0 && batches_done.is_multiple_of(reset_every) {
                                    encoder.clear_session();
                                    tracing::debug!(batches_done, "SPLADE periodic session reset");
                                }

                                // Progress logging at ~5% milestones
                                if encoded >= next_progress_threshold {
                                    let pct = encoded * 100 / total_chunks.max(1);
                                    tracing::info!(
                                        encoded,
                                        total = total_chunks,
                                        pct,
                                        batch_size = current_batch_size,
                                        "SPLADE encoding progress"
                                    );
                                    if !cli.quiet {
                                        eprintln!(
                                            "  SPLADE: {}/{} ({}%) batch={}",
                                            encoded, total_chunks, pct, current_batch_size
                                        );
                                    }
                                    next_progress_threshold += progress_step;
                                }
                            }
                            Err(e) if current_batch_size > 1 => {
                                let new_size = (current_batch_size / 2).max(1);
                                tracing::warn!(
                                    old_batch = current_batch_size,
                                    new_batch = new_size,
                                    error = %e,
                                    "SPLADE batch failed (likely OOM) — halving batch size and retrying"
                                );
                                // Clear the session on OOM to free leaked memory
                                // before retrying at the smaller size.
                                encoder.clear_session();
                                current_batch_size = new_size;
                                // Don't advance idx — retry the same range.
                            }
                            Err(e) => {
                                // batch_size already at 1: this chunk truly
                                // can't be encoded. Skip it and move on.
                                tracing::warn!(
                                    chunk_id = ?ids[0],
                                    error = %e,
                                    "SPLADE encoding failed at batch_size=1, skipping chunk"
                                );
                                failed += 1;
                                idx += 1;
                            }
                        }
                    }
                    if !sparse_vecs.is_empty() {
                        store.upsert_sparse_vectors(&sparse_vecs)?;
                    }
                    if !cli.quiet {
                        println!(
                            "  SPLADE: {} chunks encoded (final batch={})",
                            encoded, current_batch_size
                        );
                        if failed > 0 {
                            println!("  SPLADE: {} chunks failed", failed);
                        }
                    }
                    // Persist the SpladeIndex to disk so query-time SPLADE
                    // doesn't have to rebuild it from SQLite on every CLI
                    // invocation. `sparse_vecs` already holds every chunk
                    // we just encoded, so building the in-memory index here
                    // costs only the HashMap insertion loop — no reload from
                    // SQLite. The first search after reindex then skips the
                    // ~45s load step.
                    //
                    // Failure is warned, not fatal — the query-time rebuild
                    // path still works; users just pay the rebuild cost on
                    // first query until the persist is rerun.
                    if !sparse_vecs.is_empty() {
                        match store.splade_generation() {
                            Ok(generation) => {
                                let splade_path =
                                    cqs_dir.join(cqs::splade::index::SPLADE_INDEX_FILENAME);
                                // Load ALL sparse vectors for the persist
                                // (not just the delta we encoded). On --force
                                // this equals sparse_vecs; on incremental it
                                // merges prior + new.
                                let all_vecs = match store.load_all_sparse_vectors() {
                                    Ok(v) => v,
                                    Err(e) => {
                                        // Don't fall back to delta-only: persisting
                                        // just the newly-encoded subset would silently
                                        // drop all previously-encoded chunks from the
                                        // on-disk index.
                                        tracing::warn!(error = %e,
                                            "Failed to load sparse vectors for persist — \
                                             skipping. Next query will rebuild from SQLite.");
                                        Vec::new()
                                    }
                                };
                                if !all_vecs.is_empty() {
                                    let idx = cqs::splade::index::SpladeIndex::build(all_vecs);
                                    match idx.save(&splade_path, generation) {
                                        Ok(()) => {
                                            if !cli.quiet {
                                                println!(
                                                    "  SPLADE index: persisted ({} chunks, {} tokens)",
                                                    idx.len(),
                                                    idx.unique_tokens()
                                                );
                                            }
                                        }
                                        Err(e) => {
                                            tracing::warn!(
                                                error = %e,
                                                path = %splade_path.display(),
                                                "SPLADE index persist failed; query-time rebuild \
                                                 will still work"
                                            );
                                        }
                                    }
                                }
                            }
                            Err(e) => {
                                tracing::warn!(
                                    error = %e,
                                    "Failed to read splade_generation for eager persist — \
                                     skipping. Next SPLADE query will rebuild from SQLite."
                                );
                            }
                        }
                    }
                }
                Err(e) => {
                    tracing::warn!(error = %e, "SPLADE encoder unavailable, skipping sparse encoding");
                }
            }
        }
    }

    // Optional UMAP projection — runs once per `cqs index --umap` invocation.
    // Lives between SPLADE and HNSW build because all final embeddings are
    // settled by this point and HNSW only depends on the dense vectors. The
    // projection itself is opt-in; failures are logged but never fatal so the
    // rest of the index build still succeeds.
    if umap_flag && !check_interrupted() {
        if !cli.quiet {
            println!("Running UMAP projection...");
        }
        match super::umap::run_umap_projection(&store, &index_path, cli.quiet) {
            Ok(updated) => {
                if !cli.quiet && updated > 0 {
                    println!("  UMAP: {updated} chunks projected to 2D");
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "UMAP projection failed — cluster view will be unavailable");
                if !cli.quiet {
                    eprintln!("  Warning: UMAP projection failed ({e}); cluster view skipped");
                }
            }
        }
    }

    // Build HNSW index for fast chunk search (notes use brute-force from SQLite)
    if !check_interrupted() {
        if !cli.quiet {
            println!("Building HNSW index...");
        }

        match build_hnsw_index(&store, &cqs_dir)? {
            Some((total, cqs::hnsw::SaveOutcome::Saved)) => {
                // HNSW saved successfully — clear enriched dirty flag
                if let Err(e) = store.set_hnsw_dirty(HnswKind::Enriched, false) {
                    tracing::warn!(error = %e, "Failed to clear enriched HNSW dirty flag after HNSW save");
                }
                if !cli.quiet {
                    println!("  HNSW index: {} vectors", total);
                }
            }
            Some((_, cqs::hnsw::SaveOutcome::DiscardedStale)) => {
                // A concurrent writer committed chunks during the build; the
                // save was discarded and the dirty flag stays set so search
                // falls back to brute-force until the next rebuild.
                tracing::warn!("HNSW save discarded (concurrent writer); dirty flag stays set");
                if !cli.quiet {
                    println!(
                        "  HNSW index: save discarded (store changed during build; \
                         re-run 'cqs index')"
                    );
                }
            }
            None => {}
        }

        // Also build the base (non-enriched) HNSW index. Non-fatal
        // if it fails — fall back to enriched-only at query time.
        match build_hnsw_base_index(&store, &cqs_dir, None) {
            Ok(Some((total, cqs::hnsw::SaveOutcome::Saved))) => {
                if let Err(e) = store.set_hnsw_dirty(HnswKind::Base, false) {
                    tracing::warn!(error = %e, "Failed to clear base HNSW dirty flag after base HNSW save");
                }
                if !cli.quiet {
                    println!("  HNSW base index: {} vectors", total);
                }
            }
            Ok(Some((_, cqs::hnsw::SaveOutcome::DiscardedStale))) => {
                tracing::warn!(
                    "Base HNSW save discarded (concurrent writer); dirty flag stays set"
                );
                if !cli.quiet {
                    println!("  HNSW base index: save discarded (store changed during build)");
                }
            }
            Ok(None) => {
                if !cli.quiet {
                    println!("  HNSW base index: skipped (no base embeddings yet)");
                }
            }
            Err(e) => {
                tracing::warn!(error = %e, "Base HNSW build failed, enriched index still usable");
                if !cli.quiet {
                    eprintln!("  Warning: base HNSW build failed ({e}); using enriched-only");
                }
            }
        }

        // Invalidate any pre-existing CAGRA index file. cqs index rebuilds
        // HNSW above but has no parallel CAGRA save path — CAGRA is built
        // lazily on first search via cagra_build_from_store. If `index.cagra`
        // exists from a prior run, the lazy path doesn't fire (cagra_load
        // returns the stale file), and search at chunk count ≥
        // CQS_CAGRA_THRESHOLD silently returns pre-rebuild results because the
        // backend selector prefers CAGRA. Removing the file forces a fresh
        // build on the next search. The cost is one CAGRA build per `cqs
        // index` (~seconds to tens-of-seconds depending on corpus size), paid
        // by the next searcher.
        let cagra_path = cqs_dir.join("index.cagra");
        if cagra_path.exists() {
            if let Err(e) = std::fs::remove_file(&cagra_path) {
                tracing::warn!(
                    error = %e,
                    path = %cagra_path.display(),
                    "Failed to invalidate stale CAGRA index — first search will need to manually delete or rebuild"
                );
            } else {
                tracing::info!(
                    "Invalidated stale index.cagra; lazy rebuild will fire on first search"
                );
            }
            // Best-effort meta cleanup — ignore errors, file is opaque metadata.
            let _ = std::fs::remove_file(cqs_dir.join("index.cagra.meta"));
        }
    }

    // Clean up backup from --force (rebuild succeeded)
    if backup_path.exists() {
        let _ = std::fs::remove_file(&backup_path);
    }

    // Emit a structured summary envelope when --json is set. Numbers come
    // from the values already computed above so no extra DB round trip is
    // incurred. Progress prints stay on the human path — they aren't gated on
    // `!want_json` since the index command's banner is informational and many
    // callers tee stdout/stderr; only the *final* shape matters for the JSON
    // contract.
    if want_json {
        // Surface model-config and chunk-count failures via tracing instead
        // of `.unwrap_or_default()` / `.unwrap_or(0)`. The JSON envelope is
        // the agent-facing contract — silently emitting "" for model_name or
        // 0 for chunk_count under a real failure makes downstream tooling
        // believe the index is healthy when it isn't.
        let model_name = match cli.try_model_config() {
            Ok(c) => c.name.clone(),
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "index --json: model config resolution failed — emitting empty model name"
                );
                String::new()
            }
        };
        let chunk_count = match store.chunk_count() {
            Ok(n) => n,
            Err(e) => {
                tracing::warn!(
                    error = %e,
                    "index --json: chunk_count() failed — emitting 0"
                );
                0
            }
        };
        crate::cli::json_envelope::emit_json(&IndexSummaryOutput {
            indexed_files: existing_files.len(),
            indexed_chunks: chunk_count,
            took_ms: json_start.elapsed().as_millis() as u64,
            model: model_name,
            total_embedded,
            total_cached,
            gpu_failures,
            pruned,
            parse_errors: stats.parse_errors,
            total_calls: stats.total_calls,
            total_type_edges: stats.total_type_edges,
        })?;
    }

    Ok(())
}

/// First-encounter shared-notes gate.
///
/// Returns `true` if notes indexing should proceed, `false` to skip the
/// pass entirely (user declined OR non-TTY auto-skip).
///
/// Behaviour:
/// - No committed `docs/notes.toml` → return `true` (nothing to gate on).
/// - Marker file `.cqs/.accepted-shared-notes` already present → `true`.
/// - `accept_flag = true` (i.e. `--accept-shared-notes`) → write marker, `true`.
/// - Stdin not a TTY → loud warn + `false` (auto-skip; never hangs CI).
/// - TTY available → prompt; on Y or empty (default), write marker + `true`.
///   On any other reply → `false`.
fn first_encounter_notes_gate(
    root: &Path,
    project_cqs_dir: &Path,
    accept_flag: bool,
    quiet: bool,
) -> Result<bool> {
    use std::io::{IsTerminal, Write};

    let notes_path = root.join("docs/notes.toml");
    if !notes_path.exists() {
        return Ok(true);
    }
    let marker_path = project_cqs_dir.join(".accepted-shared-notes");
    if marker_path.exists() {
        return Ok(true);
    }

    // Count entries + sentiment buckets so the prompt is informative. A parse
    // failure here is non-fatal — fall through to gating with N=?, the
    // downstream `index_notes_from_file` will surface the parse error.
    let (total, positive, negative) = match cqs::parse_notes(&notes_path) {
        Ok(notes) => {
            let total = notes.len();
            let positive = notes.iter().filter(|n| n.sentiment > 0.0).count();
            let negative = notes.iter().filter(|n| n.sentiment < 0.0).count();
            (Some(total), Some(positive), Some(negative))
        }
        Err(e) => {
            // Log the underlying parse error so operators staring at a
            // `(? entries, ? positive, ? negative)` prompt have a debuggable
            // trail. The downstream `index_notes_from_file` surfaces the error
            // too, but only if the user proceeds — logging here keeps the
            // diagnostic visible at gate time.
            tracing::warn!(
                error = %e,
                path = %notes_path.display(),
                "Failed to parse docs/notes.toml during acceptance check; rendering counts as `?`"
            );
            (None, None, None)
        }
    };

    // Empty notes file → no payload to gate. Don't write the marker either;
    // the next index run with non-empty notes will prompt as expected.
    if matches!(total, Some(0)) {
        return Ok(true);
    }

    let count_label = total
        .map(|n| n.to_string())
        .unwrap_or_else(|| "?".to_string());
    let pos_label = positive
        .map(|n| n.to_string())
        .unwrap_or_else(|| "?".to_string());
    let neg_label = negative
        .map(|n| n.to_string())
        .unwrap_or_else(|| "?".to_string());

    if accept_flag {
        write_notes_acceptance_marker(&marker_path)?;
        if !quiet {
            println!(
                "Accepted committed shared notes ({} entries) — marker written to {}",
                count_label,
                marker_path.display()
            );
        }
        return Ok(true);
    }

    // Non-TTY: never hang. Auto-skip with a warning so CI / scripted runs
    // don't accidentally pull in untrusted shared notes; the user can
    // re-run with `--accept-shared-notes` once they've reviewed them.
    if !std::io::stdin().is_terminal() {
        eprintln!(
            "Warning: docs/notes.toml exists ({} entries) but stdin is not a TTY. \
             Skipping notes indexing on this run. Pass --accept-shared-notes to opt in \
             on subsequent runs.",
            count_label
        );
        return Ok(false);
    }

    // Interactive prompt.
    eprintln!();
    eprintln!(
        "Detected committed notes at {}: {} entries ({} positive, {} negative).",
        notes_path.display(),
        count_label,
        pos_label,
        neg_label
    );
    eprintln!(
        "These will affect search rankings and be shown to AI agents using cqs against this repo."
    );
    eprint!(
        "Index them? [Y/n] (or run with --accept-shared-notes to skip this prompt next time): "
    );
    std::io::stderr().flush().ok();

    let mut reply = String::new();
    std::io::stdin()
        .read_line(&mut reply)
        .context("Failed to read response from stdin")?;
    let trimmed = reply.trim();
    let accepted = trimmed.is_empty()
        || trimmed.eq_ignore_ascii_case("y")
        || trimmed.eq_ignore_ascii_case("yes");
    if accepted {
        write_notes_acceptance_marker(&marker_path)?;
        Ok(true)
    } else {
        eprintln!("Skipping notes indexing on this run.");
        Ok(false)
    }
}

/// Write the `.accepted-shared-notes` marker with a UTC timestamp body.
fn write_notes_acceptance_marker(marker_path: &Path) -> Result<()> {
    if let Some(parent) = marker_path.parent() {
        if !parent.exists() {
            std::fs::create_dir_all(parent)
                .with_context(|| format!("Failed to create {}", parent.display()))?;
        }
    }
    let now = chrono::Utc::now().to_rfc3339();
    std::fs::write(marker_path, format!("accepted_at = {}\n", now))
        .with_context(|| format!("Failed to write {}", marker_path.display()))?;
    Ok(())
}

/// Index notes from notes.toml if it exists and needs reindexing
///
/// Returns (indexed_count, was_skipped) where was_skipped is true if notes were up to date.
fn index_notes_from_file(root: &Path, store: &Store, force: bool) -> Result<(usize, bool)> {
    let notes_path = root.join("docs/notes.toml");
    if !notes_path.exists() {
        return Ok((0, true));
    }

    // Check if notes need reindexing (Some(mtime) = needs reindex, None = up to date)
    let needs_reindex = force
        || store
            .notes_need_reindex(&notes_path)
            .unwrap_or_else(|e| {
                tracing::warn!(error = %e, "Failed to check notes reindex status, forcing reindex");
                Some(0)
            })
            .is_some();

    if !needs_reindex {
        return Ok((0, true));
    }

    match parse_notes(&notes_path) {
        Ok(notes) => {
            if notes.is_empty() {
                return Ok((0, false));
            }

            let count = cqs::index_notes(&notes, &notes_path, store)?;
            Ok((count, false))
        }
        Err(e) => {
            tracing::warn!(error = %e, "Failed to parse notes");
            eprintln!("Warning: notes.toml parse error — notes not indexed: {}", e);
            Ok((0, false))
        }
    }
}

/// HNSW insert batch size.
///
/// Scales with embedding dim so a 4096-dim model holds ~40 MB per batch
/// (matching a 1024-dim model's 40 MB) instead of 160 MB.
/// `dim_scaled_batch(10_000, dim, 500, 50_000)` keeps the per-batch heap
/// footprint roughly constant. Env override `CQS_HNSW_BATCH_SIZE` wins.
fn hnsw_batch_size(dim: usize) -> usize {
    std::env::var("CQS_HNSW_BATCH_SIZE")
        .ok()
        .and_then(|v| v.parse().ok())
        .filter(|&n: &usize| n > 0)
        .unwrap_or_else(|| cqs::limits::dim_scaled_batch(10_000, dim, 500, 50_000))
}

/// Build HNSW index from store embeddings
///
/// Creates an HNSW index containing chunk embeddings only.
///
/// Notes are excluded from HNSW — they use brute-force search from SQLite
/// so that notes are immediately searchable without rebuild.
///
/// Returns `Ok(Some((len, outcome)))` when an index was built; callers may
/// clear the `hnsw_dirty` flag only when `outcome` is
/// [`cqs::hnsw::SaveOutcome::Saved`] — a `DiscardedStale` save left the
/// on-disk index (and the flag's meaning) untouched.
pub(crate) fn build_hnsw_index(
    store: &Store,
    cqs_dir: &Path,
) -> Result<Option<(usize, cqs::hnsw::SaveOutcome)>> {
    Ok(build_hnsw_index_owned(store, cqs_dir, None)?.map(|(h, _, outcome)| (h.len(), outcome)))
}

/// 64-bit fingerprint of `(id, content_hash)` used by
/// [`build_hnsw_index_owned`] / `RebuildResult` to track which exact
/// (chunk_id, content_hash) pairs landed in the rebuilt index.
///
/// The swap-time drain (`drain_pending_rebuild`) compares each delta
/// entry against this snapshot to decide whether to replay. A `HashSet<u64>`
/// holds ~24 B per entry (~400 KB at 17 k chunks) across the rebuild window.
/// Collision risk: blake3 truncated to 64 bits, ~600 M-entry birthday bound
/// vs the ~10 K per-rebuild N — false positives are below the per-rebuild
/// already-orphan rate from `insert_batch`'s no-deletion API. A
/// false-positive collision (delta entry skipped that shouldn't have
/// been) just leaves a chunk unindexed until the next threshold
/// rebuild — same recovery path as a saturated delta or HNSW orphan.
pub(crate) fn snapshot_fingerprint(id: &str, hash: &str) -> u64 {
    let mut hasher = blake3::Hasher::new();
    hasher.update(id.as_bytes());
    hasher.update(b"\0");
    hasher.update(hash.as_bytes());
    let bytes = hasher.finalize();
    let b = bytes.as_bytes();
    u64::from_le_bytes([b[0], b[1], b[2], b[3], b[4], b[5], b[6], b[7]])
}

/// Build HNSW index and return the Owned index plus a per-(id, content_hash)
/// fingerprint set for continued incremental use.
///
/// Builds from all chunk embeddings in the store, saves to disk, and returns
/// `(HnswIndex, snapshot_keys)` where `snapshot_keys` contains
/// [`snapshot_fingerprint`]`(id, hash)` for every (id, hash) that landed
/// in the rebuilt index. Used by watch mode to keep a mutable index in
/// memory for `insert_batch` calls and (on the background-rebuild path)
/// to detect entries that were re-embedded mid-rebuild — the
/// swap-time drain replays them with the fresh vector instead of
/// dedup'ing them by id-only and silently dropping the update.
///
/// Both the embeddings and the hashes come from a single SQL pass via
/// [`Store::embedding_and_hash_batches`] so they're consistent under
/// concurrent writers (WAL snapshot isolation only holds within a
/// transaction).
///
/// The snapshot is a `HashSet<u64>` (~400 KB on a 17 k-chunk corpus). See
/// [`snapshot_fingerprint`] for the collision-rate analysis.
pub(crate) fn build_hnsw_index_owned<M>(
    store: &cqs::store::Store<M>,
    cqs_dir: &Path,
    cancel: Option<&std::sync::atomic::AtomicBool>,
) -> Result<
    Option<(
        HnswIndex,
        std::collections::HashSet<u64>,
        cqs::hnsw::SaveOutcome,
    )>,
> {
    // Capture the store-state stamp BEFORE streaming embeddings: it names
    // the store state the built index reflects. `save_stamped` re-reads the
    // live stamp under the exclusive save lock and discards the save if the
    // store moved past this snapshot.
    let snapshot = cqs::hnsw::StoreStamp::read(store)
        .context("Failed to read store stamp before HNSW build")?;
    let chunk_count = snapshot.chunk_count as usize;
    let _span = tracing::info_span!("build_hnsw_index_owned", chunk_count).entered();

    if chunk_count == 0 {
        return Ok(None);
    }

    let batch_size = hnsw_batch_size(store.dim());

    // Tee the (id, embedding, hash) stream: HNSW build consumes
    // (id, embedding) pairs while we accumulate fingerprints into a side
    // set. Single SQL pass — no second query, no risk of the hash drifting
    // from the embedding under concurrent writers.
    let mut snapshot_keys: std::collections::HashSet<u64> =
        std::collections::HashSet::with_capacity(chunk_count);
    let chunk_batches = store.embedding_and_hash_batches(batch_size).map(|batch| {
        batch.map(|triples| {
            let mut pairs = Vec::with_capacity(triples.len());
            for (id, emb, hash) in triples {
                snapshot_keys.insert(snapshot_fingerprint(&id, &hash));
                pairs.push((id, emb));
            }
            pairs
        })
    });

    let hnsw = HnswIndex::build_batched_with_dim(chunk_batches, chunk_count, store.dim())?;
    // Shutdown check before entering the save: daemon exit detaches the
    // rebuild thread after a bounded join, so a save started after the
    // shutdown signal could be killed at an arbitrary point. Skipping it
    // leaves the dirty flag set and the previous on-disk index intact —
    // the next daemon start rebuilds.
    if cancel
        .map(|c| c.load(std::sync::atomic::Ordering::Acquire))
        .unwrap_or(false)
    {
        anyhow::bail!("shutdown requested — skipping HNSW save (next start rebuilds)");
    }
    let outcome = hnsw.save_stamped(cqs_dir, "index", store, snapshot)?;

    Ok(Some((hnsw, snapshot_keys, outcome)))
}

/// Build the base HNSW index from `embedding_base` and save as
/// `index_base.hnsw.{graph,data,ids}`.
///
/// The base index contains the raw-NL embedding for each chunk (no LLM summary,
/// no call-graph enrichment). It's queried by the router when classification
/// picks a [`SearchStrategy::DenseBase`] — typically conceptual, behavioral,
/// and negation queries, where enrichment hurts signal.
///
/// Returns `Ok(None)` when the column is entirely NULL (no index pass has
/// populated it yet). In that case the router falls back to the enriched
/// index.
///
/// On `Ok(Some((len, outcome)))`, callers may clear the Base `hnsw_dirty`
/// flag only when `outcome` is [`cqs::hnsw::SaveOutcome::Saved`].
pub(crate) fn build_hnsw_base_index<M>(
    store: &cqs::store::Store<M>,
    cqs_dir: &Path,
    cancel: Option<&std::sync::atomic::AtomicBool>,
) -> Result<Option<(usize, cqs::hnsw::SaveOutcome)>> {
    let _span = tracing::info_span!("build_hnsw_base_index").entered();

    // Stamp captured before streaming — see `build_hnsw_index_owned`. The
    // base index is sized by `base_embedding_count`, but the stamp is the
    // store-level state (chunk count + write generation) like the enriched
    // one: it names a point in the store's history, not the row subset.
    let snapshot = cqs::hnsw::StoreStamp::read(store)
        .context("Failed to read store stamp before base HNSW build")?;

    // If the column hasn't been populated yet, skip the build so we don't
    // write an empty HNSW file that misleads readers into thinking dual
    // indexing is active.
    let base_count = store
        .base_embedding_count()
        .context("Failed to count rows with embedding_base")? as usize;

    if base_count == 0 {
        tracing::info!("No embedding_base rows yet — skipping base HNSW build");
        return Ok(None);
    }

    let batch_size = hnsw_batch_size(store.dim());

    let chunk_batches = store.embedding_base_batches(batch_size);
    let hnsw = HnswIndex::build_batched_with_dim(chunk_batches, base_count, store.dim())?;
    // Shutdown check before entering the save — see `build_hnsw_index_owned`.
    if cancel
        .map(|c| c.load(std::sync::atomic::Ordering::Acquire))
        .unwrap_or(false)
    {
        anyhow::bail!("shutdown requested — skipping base HNSW save (next start rebuilds)");
    }
    let outcome = hnsw.save_stamped(cqs_dir, "index_base", store, snapshot)?;

    tracing::info!(base_count, ?outcome, "Base HNSW index built");
    Ok(Some((hnsw.len(), outcome)))
}

// The data-flow for the dual HNSW build is covered by
// `store::chunks::async_helpers::tests::test_embedding_base_batches_*` in
// the library crate — those tests exercise populate-on-insert and
// NULL-row skipping, which are the two branches that matter here.
// The HNSW builder itself is covered by `hnsw::build` unit tests.

/// Check `.cqs/.dirty` and consume it (delete) at startup.
///
/// On Windows-native there is no `cqs watch --serve` daemon (Unix domain
/// sockets are the wire), so `cqs hook fire` falls through to the file-based
/// fallback in `cli/commands/infra/hook.rs` and writes `.cqs/.dirty`. The
/// Unix daemon's consumer in `cli/watch/mod.rs` is `#[cfg(unix)]`-gated, so
/// on Windows the marker is written but never read.
///
/// This helper bridges the gap: `cqs index` (the foreground reindex command
/// Windows users actually run) clears the marker as evidence that the
/// requested reindex has occurred. Returns `true` if a marker was present
/// and removed, `false` otherwise. Both branches are non-fatal — the marker
/// is best-effort, and a stale marker would only cause one extra info-level
/// trace line on the next `cqs index`.
///
/// Harmless on Unix too: if a hook fires while the daemon is down, the next
/// `cqs index` clears the marker on both platforms — the daemon's own
/// consumer just gets there first when it is running.
pub(crate) fn consume_dirty_marker(cqs_dir: &Path) -> bool {
    let _span =
        tracing::debug_span!("consume_dirty_marker", cqs_dir = %cqs_dir.display()).entered();
    let marker = cqs_dir.join(".dirty");
    if marker.exists() {
        if let Err(e) = std::fs::remove_file(&marker) {
            tracing::warn!(
                path = %marker.display(),
                error = %e,
                "consume_dirty_marker: failed to remove .cqs/.dirty (will retry on next index)"
            );
        }
        true
    } else {
        false
    }
}

// Direct happy-path tests for the two HNSW build helpers invoked by
// `cmd_index` and the watch loop. Lower-level unit tests cover
// `embedding_batches` / `embedding_base_batches` and the HNSW builder; these
// pin the join — store + cqs_dir → on-disk + in-memory index — with a minimal
// `dim=16` corpus so the HNSW build runs in milliseconds.

/// Bail if the existing index's stored model name doesn't match the resolved
/// model. Match permissively against both the canonical preset name (e.g.
/// `bge-large`) and the repo string (e.g. `BAAI/bge-large-en-v1.5`) —
/// operators pass either form interchangeably, as `cmd_model_swap`'s no-op
/// short-circuit also does.
fn check_index_model_drift(
    stored: Option<&str>,
    requested_name: &str,
    requested_repo: &str,
    index_path: &Path,
) -> Result<()> {
    let Some(stored_name) = stored else {
        return Ok(());
    };
    let target_matches = stored_name == requested_name || stored_name == requested_repo;
    if target_matches {
        return Ok(());
    }
    // Emit a structured warn alongside the bail string so daemon-mode
    // operators (and journald harvesters) see the drift event in their
    // structured log stream — stderr alone wouldn't reach a daemon-spawned
    // `cqs index`'s alert path.
    tracing::warn!(
        stored_model = %stored_name,
        requested_model = %requested_name,
        index_path = %index_path.display(),
        "Index model drift detected — refusing to clobber the existing index"
    );
    anyhow::bail!(
        "Index at {} was built for model `{stored}`, but `--model` resolved to \
         `{requested}`. Re-run with `cqs index --force --model {requested}` to rebuild \
         against the new model (the old index is backed up to `index.db.bak`). \
         Pass `--model {stored}` (or unset --model / CQS_EMBEDDING_MODEL) to keep \
         incremental indexing on the existing model.",
        index_path.display(),
        stored = stored_name,
        requested = requested_name,
    );
}

#[cfg(test)]
mod tests {
    use super::*;
    use cqs::embedder::Embedding;
    use cqs::parser::{Chunk, ChunkType, Language};
    use cqs::store::ModelInfo;
    use std::path::PathBuf;
    use tempfile::TempDir;

    fn make_chunk(id: &str, dim: usize, seed: f32) -> (Chunk, Embedding) {
        let content = format!("fn {}() {{ }}", id);
        let content_hash = blake3::hash(content.as_bytes()).to_hex().to_string();
        let chunk = Chunk {
            id: id.to_string(),
            file: PathBuf::from("src/lib.rs"),
            language: Language::Rust,
            chunk_type: ChunkType::Function,
            name: id.to_string(),
            signature: format!("fn {}()", id),
            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,
        };
        // Distinct unit-ish vectors per chunk so the HNSW builder has
        // something non-degenerate to insert.
        let mut emb = vec![0.0_f32; dim];
        emb[0] = seed;
        emb[1] = 1.0 - seed;
        (chunk, Embedding::new(emb))
    }

    /// Open a fresh store at the given dim and seed `n` chunks. Returns
    /// the temp dir (kept alive for test lifetime) and `cqs_dir` path.
    fn seed_store(n: usize, dim: usize) -> (TempDir, PathBuf) {
        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 store = Store::open(&index_path).expect("open store");
        store
            .init(&ModelInfo::new("test/model", dim))
            .expect("init store");
        // `Store::open` sees no metadata on a fresh DB and defaults `dim`
        // to EMBEDDING_DIM (1024). After `init` writes the real dim, sync
        // the cached value so the upsert dim-check below sees `dim` and
        // not the stale fallback.
        store.set_dim(dim);
        let pairs: Vec<_> = (0..n)
            .map(|i| make_chunk(&format!("c{i}"), dim, (i as f32 + 1.0) * 0.1))
            .collect();
        if !pairs.is_empty() {
            store.upsert_chunks_batch(&pairs, Some(0)).expect("upsert");
        }
        drop(store);
        (dir, cqs_dir)
    }

    #[test]
    fn build_hnsw_index_owned_returns_index_with_chunk_count() {
        let dim = 16;
        let n = 5;
        let (_tmp, cqs_dir) = seed_store(n, dim);
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);
        let store = Store::open_readonly_after_init(&index_path, |_| Ok(())).expect("ro store");

        let (idx, snapshot_keys, outcome) = build_hnsw_index_owned(&store, &cqs_dir, None)
            .expect("build_hnsw_index_owned must succeed")
            .expect("non-empty store must produce an index");
        assert_eq!(
            outcome,
            cqs::hnsw::SaveOutcome::Saved,
            "no concurrent writer in this test — the save must land"
        );
        assert_eq!(idx.len(), n, "index len must equal seeded chunk count");
        // Snapshot must carry one fingerprint entry per built
        // (id, content_hash) so the rebuild-window drain can detect stale
        // entries. It's a HashSet<u64> of `snapshot_fingerprint(id, hash)`.
        assert_eq!(
            snapshot_keys.len(),
            n,
            "snapshot_keys must contain one entry per chunk"
        );
        // Reconstruct each (id, content_hash) the test seeded and confirm
        // its fingerprint is in the set — same round-trip the swap-time
        // drain performs.
        for i in 0..n {
            let id = format!("c{i}");
            let content = format!("fn {}() {{ }}", id);
            let hash = blake3::hash(content.as_bytes()).to_hex().to_string();
            let fp = super::snapshot_fingerprint(&id, &hash);
            assert!(
                snapshot_keys.contains(&fp),
                "snapshot_keys missing fingerprint for {id}"
            );
        }
        // The hnsw save side-effect should land at `<cqs_dir>/index.hnsw.*`.
        // We can't easily round-trip-load without exposing private save
        // path constants, so just verify the in-memory state is correct.
    }

    #[test]
    fn build_hnsw_index_owned_returns_none_for_empty_store() {
        let dim = 16;
        let (_tmp, cqs_dir) = seed_store(0, dim);
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);
        let store = Store::open_readonly_after_init(&index_path, |_| Ok(())).expect("ro store");
        let result = build_hnsw_index_owned(&store, &cqs_dir, None).expect("must not error");
        assert!(
            result.is_none(),
            "empty store must yield None (no on-disk index, no in-memory index)"
        );
    }

    #[test]
    fn build_hnsw_base_index_returns_some_when_base_populated() {
        // `upsert_chunks_batch` seeds embedding_base with the same bytes as
        // the enriched embedding on insert (see
        // `store::chunks::async_helpers`). So a freshly-seeded store has
        // base_embedding_count == n, and the base HNSW build is not
        // skipped. Pin that contract — a regression that stopped seeding
        // embedding_base on insert would silently break the dual-index
        // router.
        let dim = 16;
        let n = 4;
        let (_tmp, cqs_dir) = seed_store(n, dim);
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);
        let store = Store::open_readonly_after_init(&index_path, |_| Ok(())).expect("ro store");
        let result = build_hnsw_base_index(&store, &cqs_dir, None).expect("must not error");
        assert_eq!(
            result,
            Some((n, cqs::hnsw::SaveOutcome::Saved)),
            "base index must contain all {n} seeded chunks and the save must land"
        );
    }

    #[test]
    fn build_hnsw_base_index_returns_none_for_empty_store() {
        let dim = 16;
        let (_tmp, cqs_dir) = seed_store(0, dim);
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);
        let store = Store::open_readonly_after_init(&index_path, |_| Ok(())).expect("ro store");
        let result = build_hnsw_base_index(&store, &cqs_dir, None).expect("must not error");
        assert!(
            result.is_none(),
            "empty store must yield None for base HNSW build"
        );
    }

    // ===== first_encounter_notes_gate =====

    #[test]
    fn first_encounter_gate_proceeds_when_no_notes_file() {
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");
        std::fs::create_dir_all(&cqs_dir).unwrap();
        let proceed = first_encounter_notes_gate(tmp.path(), &cqs_dir, false, true).unwrap();
        assert!(proceed, "no notes file → proceed");
    }

    #[test]
    fn first_encounter_gate_proceeds_when_marker_present() {
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");
        std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
        std::fs::write(
            tmp.path().join("docs/notes.toml"),
            r#"
[[note]]
text = "Watch out for X"
sentiment = -0.5
mentions = []
"#,
        )
        .unwrap();
        std::fs::create_dir_all(&cqs_dir).unwrap();
        std::fs::write(cqs_dir.join(".accepted-shared-notes"), "x").unwrap();
        let proceed = first_encounter_notes_gate(tmp.path(), &cqs_dir, false, true).unwrap();
        assert!(proceed, "marker present → proceed");
    }

    #[test]
    fn first_encounter_gate_accept_flag_writes_marker() {
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");
        std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
        std::fs::write(
            tmp.path().join("docs/notes.toml"),
            r#"
[[note]]
text = "Suspicious instruction"
sentiment = 0.0
mentions = []
"#,
        )
        .unwrap();
        std::fs::create_dir_all(&cqs_dir).unwrap();
        let proceed = first_encounter_notes_gate(tmp.path(), &cqs_dir, true, true).unwrap();
        assert!(proceed, "--accept-shared-notes → proceed");
        let marker = cqs_dir.join(".accepted-shared-notes");
        assert!(
            marker.exists(),
            "marker must be written when accept_flag set"
        );
        let body = std::fs::read_to_string(&marker).unwrap();
        assert!(
            body.contains("accepted_at"),
            "marker body should contain timestamp marker, got: {body}"
        );
    }

    #[test]
    fn first_encounter_gate_proceeds_when_notes_file_empty() {
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");
        std::fs::create_dir_all(tmp.path().join("docs")).unwrap();
        std::fs::write(tmp.path().join("docs/notes.toml"), "").unwrap();
        std::fs::create_dir_all(&cqs_dir).unwrap();
        let proceed = first_encounter_notes_gate(tmp.path(), &cqs_dir, false, true).unwrap();
        assert!(proceed, "empty notes → proceed (nothing to gate on)");
        // No marker written for the zero-entry case — the next non-empty
        // index run should still prompt.
        assert!(
            !cqs_dir.join(".accepted-shared-notes").exists(),
            "no marker should be written for empty notes file"
        );
    }

    // ===== consume_dirty_marker =====

    /// Marker absent → returns false, no side effects. Pinned because the
    /// Unix daemon path also calls into the same logic via `cli/watch/mod.rs`
    /// and we don't want a stale-state false positive on first start.
    #[test]
    fn consume_dirty_marker_returns_false_when_absent() {
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");
        std::fs::create_dir_all(&cqs_dir).unwrap();
        assert!(!consume_dirty_marker(&cqs_dir));
        // No marker should appear as a side effect.
        assert!(!cqs_dir.join(".dirty").exists());
    }

    /// Marker present → returns true and the file is gone afterwards. This
    /// is the load-bearing assertion for the Windows-native fix: a Windows
    /// `cqs hook fire` writes the marker and the next `cqs index` must
    /// clear it (the Unix daemon's consumer is `#[cfg(unix)]`-gated and
    /// never runs on Windows-native).
    #[test]
    fn consume_dirty_marker_removes_file_when_present() {
        let tmp = TempDir::new().unwrap();
        let cqs_dir = tmp.path().join(".cqs");
        std::fs::create_dir_all(&cqs_dir).unwrap();
        let marker = cqs_dir.join(".dirty");
        std::fs::write(&marker, b"").unwrap();
        assert!(marker.exists());
        assert!(consume_dirty_marker(&cqs_dir));
        assert!(
            !marker.exists(),
            "marker must be removed once consumed so the next index run sees a clean slate"
        );
    }

    // ====== `cqs index --model` drift detection ======

    /// The strict-preset gate at the top of `cmd_index` rejects unknown CLI
    /// presets so an operator typo doesn't silently resolve to the default.
    /// The unit-level analogue: `from_preset` returns `None` for the typo,
    /// which is what the bail-on-`is_none()` branch keys off of. Pin the
    /// contract here so a future preset-rename can't make the check vacuous.
    #[test]
    fn unknown_preset_rejected_by_from_preset() {
        assert!(cqs::embedder::ModelConfig::from_preset("bgelarge").is_none());
        assert!(cqs::embedder::ModelConfig::from_preset("not-a-real-preset").is_none());
        assert!(cqs::embedder::ModelConfig::from_preset("").is_none());
        // Sanity: a real preset still resolves.
        assert!(cqs::embedder::ModelConfig::from_preset("embeddinggemma-300m").is_some());
    }

    /// No stored model (fresh DB) → drift check is a no-op.
    #[test]
    fn check_index_model_drift_no_stored_passes() {
        let path = Path::new("/tmp/x/index.db");
        check_index_model_drift(None, "bge-large", "BAAI/bge-large-en-v1.5", path)
            .expect("no stored model must pass through");
    }

    /// Stored == requested name → pass.
    #[test]
    fn check_index_model_drift_name_match_passes() {
        let path = Path::new("/tmp/x/index.db");
        check_index_model_drift(
            Some("bge-large"),
            "bge-large",
            "BAAI/bge-large-en-v1.5",
            path,
        )
        .expect("matching name must pass");
    }

    /// Drift detection is byte-exact `==`. Pin case-sensitivity so a future
    /// case-fold is a deliberate change — `--model BGE-Large` against stored
    /// `bge-large` DOES bail. The operator who hits this needs to know it's a
    /// name-not-content drift instead of running a 30-min `--force` rebuild
    /// they don't actually need.
    #[test]
    fn check_index_model_drift_is_case_sensitive_pin() {
        let path = Path::new("/tmp/x/index.db");
        let result = check_index_model_drift(
            Some("bge-large"),
            "BGE-Large",
            "BAAI/bge-large-en-v1.5",
            path,
        );
        assert!(
            result.is_err(),
            "current contract: byte-exact match — case differences trigger drift"
        );
    }

    /// Whitespace in the stored model name (e.g. a stray `format!("{}\n",
    /// name)`) fails the `stored == requested` check. Pin so a future
    /// write-time trim flips this test deliberately.
    #[test]
    fn check_index_model_drift_rejects_whitespace_drift() {
        let path = Path::new("/tmp/x/index.db");
        let result = check_index_model_drift(
            Some("bge-large\n"),
            "bge-large",
            "BAAI/bge-large-en-v1.5",
            path,
        );
        assert!(
            result.is_err(),
            "whitespace drift in stored name currently bails — pin so a future trim is deliberate"
        );
    }

    /// Stored == requested repo (but not the short name) → pass. Operators
    /// often have `model_name = "BAAI/bge-large-en-v1.5"` from a `cqs index
    /// --force` of an earlier session and pass `--model bge-large` later.
    #[test]
    fn check_index_model_drift_repo_match_passes() {
        let path = Path::new("/tmp/x/index.db");
        check_index_model_drift(
            Some("BAAI/bge-large-en-v1.5"),
            "bge-large",
            "BAAI/bge-large-en-v1.5",
            path,
        )
        .expect("matching repo must pass");
    }

    /// The footgun case: stored = bge-large, requested = embeddinggemma-300m.
    /// Feeding 768-dim embeddings into a 1024-dim store would corrupt it, so
    /// the index command bails with a recovery hint.
    #[test]
    fn check_index_model_drift_mismatch_bails() {
        let path = Path::new("/tmp/x/index.db");
        let err = check_index_model_drift(
            Some("bge-large"),
            "embeddinggemma-300m",
            "google/embeddinggemma-300m",
            path,
        )
        .expect_err("mismatch must bail");
        let msg = format!("{err}");
        assert!(
            msg.contains("`bge-large`"),
            "error must name the stored model: {msg}"
        );
        assert!(
            msg.contains("`embeddinggemma-300m`"),
            "error must name the requested model: {msg}"
        );
        assert!(
            msg.contains("--force"),
            "error must point at --force as the recovery path: {msg}"
        );
    }

    // ===== project_umap_on_delegation =====
    //
    // The daemon-delegation path returns early, before the main build's
    // projection step. These pin that `--umap` still drives the projection on
    // that path (BUG: it was a silent no-op whenever the daemon was up). A live
    // daemon is impractical in a unit test, so the testable unit is the
    // factored helper: the decision to project is purely `umap_flag`.

    /// `--umap` unset on the delegation path must NOT run the projection.
    #[test]
    fn project_umap_on_delegation_skips_when_flag_unset() {
        let (_tmp, cqs_dir) = seed_store(3, 16);
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);
        let updated = project_umap_on_delegation(false, &index_path, true);
        assert_eq!(
            updated, None,
            "umap_flag=false must skip the projection entirely on the delegation path"
        );
    }

    /// `--umap` set on the delegation path must REACH the projection against
    /// the active slot's store — the regression being guarded is that the
    /// delegation early-return dropped the projection entirely whenever the
    /// daemon was up. Reaching the projection yields `Some(_)`; a dropped
    /// projection would be `None` (the unset-flag shape).
    ///
    /// PATH is pointed at an empty dir so the projection takes the instant
    /// graceful no-Python skip (`Ok(0)` → `Some(0)`) rather than shelling out
    /// to umap-learn — that keeps the test fast and deterministic while still
    /// proving control reaches `run_umap_projection`. `#[serial]` because it
    /// mutates the process-global PATH.
    #[test]
    #[serial_test::serial]
    fn project_umap_on_delegation_runs_when_flag_set() {
        let (_tmp, cqs_dir) = seed_store(3, 16);
        let index_path = cqs_dir.join(cqs::INDEX_DB_FILENAME);

        let saved_path = std::env::var_os("PATH");
        let empty_dir = TempDir::new().expect("empty PATH dir");
        std::env::set_var("PATH", empty_dir.path());

        let updated = project_umap_on_delegation(true, &index_path, true);

        // Restore PATH before asserting so a panic can't leak it into the suite.
        match saved_path {
            Some(v) => std::env::set_var("PATH", v),
            None => std::env::remove_var("PATH"),
        }

        assert_eq!(
            updated,
            Some(0),
            "umap_flag=true must reach the projection on the delegation path \
             (here Some(0) via the no-Python graceful skip); None would mean the \
             projection was dropped before the early return — the silent-no-op regression"
        );
    }
}
