use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use std::io::Read as IoRead;
use std::path::{Path, PathBuf};

use vault_core::binary_store;
use vault_core::classify;
use vault_core::ingest;
use vault_core::search;
use vault_core::store::{JsonStore, VaultStore};
use vault_core::types::*;
use vault_core::wal;
use vault_core::zvec;

mod config;
mod llm_classify;
mod serve;
mod watch;

// ─────────────────────────────────────────────────────────────────────────────
// CLI definition
// ─────────────────────────────────────────────────────────────────────────────

#[derive(Parser)]
#[command(name = "p3ak-vault", version, about = "Portable AI memory vault")]
struct Cli {
    /// Path to the .vault file.
    ///
    /// May be specified before or after the subcommand.
    /// Resolution order: --path flag → $P3AK_VAULT_PATH env var → config.toml vault.path.
    #[arg(long, global = true, env = "P3AK_VAULT_PATH")]
    path: Option<PathBuf>,

    /// Encryption passphrase for the vault.
    /// Can also be set via the P3AK_VAULT_PASSPHRASE environment variable.
    /// If omitted, the vault is written/read unencrypted (dev/test only).
    #[arg(long, global = true, env = "P3AK_VAULT_PASSPHRASE")]
    passphrase: Option<String>,

    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Create a new empty vault
    Create {
        #[arg(long)]
        room: Option<String>,
    },
    /// Read vault contents
    Read {
        #[arg(long, rename_all = "lowercase")]
        r#type: ReadType,
        #[arg(long)]
        domain: Option<String>,
    },
    /// Write an entry to the vault
    Write {
        #[arg(long, rename_all = "lowercase")]
        r#type: WriteType,
        /// JSON payload, or "-" to read from stdin
        #[arg(long)]
        payload: String,
    },
    /// Search vault contents
    Search {
        #[arg(long)]
        query: String,
        #[arg(long, default_value = "10")]
        limit: usize,
        /// Search mode: flat (BM25 only), pageindex (tree-guided), or hybrid (BM25+vector+tree)
        #[arg(long, default_value = "flat")]
        mode: SearchMode,
        /// Restrict results to a specific classification store.
        /// One of: HC, LG, FN, OP, HR, TC, MS, IN, GV, ED
        #[arg(long)]
        store: Option<String>,
        /// Restrict results to a specific room/namespace.
        /// Must match the --room tag used at ingest time (case-insensitive).
        #[arg(long)]
        room: Option<String>,
        /// Additional vault(s) to search in parallel (multi-vault federation).
        ///
        /// Results from all vaults are merged and ranked by combined_score.
        /// Each result includes a "vault" field identifying its source file.
        /// Repeat for multiple extra vaults:
        ///   p3ak-vault search --path main.vault --also-vault work.vault --also-vault personal.vault --query "..."
        #[arg(long = "also-vault", action = clap::ArgAction::Append)]
        also_vault: Vec<PathBuf>,
    },
    /// Run accuracy tests against a ground truth file
    AccuracyTest {
        /// Path to ground-truth.json
        #[arg(long)]
        ground_truth: PathBuf,
        /// Search mode: flat, pageindex, or hybrid
        #[arg(long, default_value = "flat")]
        mode: SearchMode,
    },
    /// Ingest one file or an entire directory into the vault.
    ///
    /// Supported formats: .md .txt .html .pdf .docx .csv .tsv .json .svg
    ///
    /// Single file:
    ///   p3ak-vault ingest --path vault.vault --file report.pdf
    ///
    /// Whole directory (all supported formats):
    ///   p3ak-vault ingest --path vault.vault --dir ~/docs --recursive
    ///
    /// Duplicate files are handled automatically:
    ///   - same filename + same checksum → skipped (no change)
    ///   - same filename + different content → updated in place
    Ingest {
        /// Ingest a single file (mutually exclusive with --dir)
        #[arg(long, conflicts_with = "dir")]
        file: Option<PathBuf>,
        /// Ingest all supported files in a directory (mutually exclusive with --file)
        #[arg(long, conflicts_with = "file")]
        dir: Option<PathBuf>,
        /// Recurse into sub-directories (only with --dir)
        #[arg(long, requires = "dir")]
        recursive: bool,
        #[arg(long)]
        room: Option<String>,
    },
    /// Soft-delete a document from the vault by filename.
    ///
    /// The document is marked deleted in the WAL and excluded from all searches.
    /// The vault file size is not reduced immediately; use `export` to compact.
    ///
    /// Example:
    ///   p3ak-vault delete --path my.vault --filename old-policy.pdf
    Delete {
        /// Exact filename as it was ingested (e.g. "report.pdf", not the full path)
        #[arg(long)]
        filename: String,
    },
    /// Classify all documents in the vault using the rule-based classifier.
    /// Updates each doc's classification field in-place. Re-run after ingest.
    Classify {
        /// Only classify documents that don't already have a classification.
        #[arg(long)]
        only_missing: bool,
    },
    /// Export a subset of the vault to a new portable .vault file.
    ///
    /// Filters documents to a single knowledge store and writes a new vault
    /// at `--out`. Useful for safely sharing domain-specific content without
    /// exposing the full vault.
    ///
    /// The exported vault:
    ///   - Contains only documents classified in `--store`
    ///   - Has a fresh WAL (your history is not included in the export)
    ///   - Is encrypted with `--out-passphrase` (or the main passphrase if omitted)
    ///
    /// Example:
    ///   p3ak-vault export --path my.vault --store FN --out finance.vault --out-passphrase "recipient-secret"
    Export {
        /// Knowledge store to export: HC, LG, FN, OP, HR, TC, MS, IN, GV, ED
        #[arg(long)]
        store: String,
        /// Destination path for the exported vault (e.g. finance.vault)
        #[arg(long)]
        out: PathBuf,
        /// Passphrase for the exported vault.
        /// If omitted, the main vault passphrase is reused.
        /// The recipient needs only this passphrase to open the exported file.
        #[arg(long, env = "P3AK_VAULT_OUT_PASSPHRASE")]
        out_passphrase: Option<String>,
    },
    /// Watch a directory for new/modified .md files and auto-ingest them.
    ///
    /// Outputs newline-delimited JSON events to stdout:
    ///   {"event":"started","dir":"/path","room":"work","interval_secs":5}
    ///   {"event":"ingest","file":"doc.md","doc_id":42,"status":"ok","elapsed_ms":120}
    ///   {"event":"tick","scanned":12,"new":0,"modified":0}
    ///   {"event":"stopped","total_ingested":3}
    ///
    /// Send SIGINT (Ctrl-C) to stop cleanly.
    Watch {
        /// Directory to watch (scanned for *.md files)
        #[arg(long)]
        dir: PathBuf,
        /// Assign ingested documents to this room/namespace
        #[arg(long)]
        room: Option<String>,
        /// Poll interval in seconds (default: 5, minimum: 1)
        #[arg(long, default_value = "5")]
        interval: u64,
    },
    /// Register a canary fact for periodic integrity monitoring.
    ///
    /// A canary fact is a known-good query+expected answer pair that should
    /// always retrieve the correct document. Running `canary-check` later
    /// verifies all registered canaries still pass — alerting you if a
    /// document was modified, deleted, or pushed out of the top results.
    CanaryAdd {
        /// Search query that should retrieve the expected document
        #[arg(long)]
        query: String,
        /// Filename that must appear in the top-K results
        #[arg(long)]
        expected_filename: String,
        /// Optional: a snippet of text that should appear in the result's snippet field
        #[arg(long)]
        expected_snippet: Option<String>,
        /// Optional: restrict the check to a specific knowledge store
        #[arg(long)]
        store: Option<String>,
        /// Optional: restrict the check to a specific room
        #[arg(long)]
        room: Option<String>,
        /// Minimum score the expected document must achieve (default: 0.0 = any)
        #[arg(long, default_value = "0.0")]
        min_score: f32,
        /// How many top results to search for the expected filename (default: 5)
        #[arg(long, default_value = "5")]
        top_k: usize,
    },
    /// Run all registered canary checks and report pass/fail.
    ///
    /// Returns exit code 0 when recall ≥ threshold, else non-zero.
    CanaryCheck {
        /// Minimum fraction of canaries that must pass (default: 1.0 = all)
        #[arg(long, default_value = "1.0")]
        threshold: f32,
        /// Search mode to use (flat, pageindex, hybrid)
        #[arg(long, default_value = "hybrid")]
        mode: SearchMode,
    },
    /// Copy documents from one vault to another (vault-to-vault migration/merge).
    ///
    /// Checks each source document against the destination:
    ///   - same filename + same checksum → skipped (already up-to-date)
    ///   - same filename + different content → updated
    ///   - new filename → added
    ///
    /// Outputs newline-delimited JSON per document:
    ///   {"filename":"contract.pdf","action":"added","doc_id":42}
    ///   {"filename":"policy.md","action":"skipped"}
    ///
    /// When complete:
    ///   {"ok":true,"added":3,"updated":1,"skipped":12}
    ///
    /// Example:
    ///   p3ak-vault sync --from main.vault --to backup.vault
    ///   p3ak-vault sync --from main.vault --to legal.vault --room legal
    Sync {
        /// Source vault path (read-only)
        #[arg(long)]
        from: PathBuf,
        /// Destination vault path (modified in place; created if missing)
        #[arg(long)]
        to: PathBuf,
        /// Only sync documents that belong to this room
        #[arg(long)]
        room: Option<String>,
        /// Passphrase for the source vault (falls back to --passphrase / P3AK_VAULT_PASSPHRASE)
        #[arg(long, env = "P3AK_VAULT_FROM_PASSPHRASE")]
        passphrase_from: Option<String>,
        /// Passphrase for the destination vault (falls back to --passphrase / P3AK_VAULT_PASSPHRASE)
        #[arg(long, env = "P3AK_VAULT_TO_PASSPHRASE")]
        passphrase_to: Option<String>,
    },
    /// Start a local REST API server that exposes the vault over HTTP.
    ///
    /// Routes:
    ///   GET  /health                        — liveness check
    ///   GET  /search?q=...&mode=hybrid&limit=5&store=FN&room=work
    ///   GET  /docs                          — all ingested documents
    ///   GET  /wal                           — audit log
    ///   POST /ingest   {"file":"/path","room":"work"}
    ///   POST /write    {"type":"goal","payload":{...}}
    ///   POST /classify {"only_missing":true}
    ///
    /// All responses are JSON. Authentication and TLS are out-of-scope for v1;
    /// bind to 127.0.0.1 (localhost only) for safety.
    ///
    /// Example:
    ///   p3ak-vault serve --port 8080
    ///   curl http://localhost:8080/search?q=revenue&mode=hybrid
    Serve {
        /// Port to listen on (default: 8080, binds to 127.0.0.1 only)
        #[arg(long, default_value = "8080")]
        port: u16,
    },
}

#[derive(Clone, clap::ValueEnum)]
enum ReadType {
    Goals,
    Docs,
    Wal,
}

#[derive(Clone, clap::ValueEnum)]
enum WriteType {
    Goal,
    Doc,
    Plan,
    Review,
}

#[derive(Clone, clap::ValueEnum)]
enum SearchMode {
    Flat,
    Pageindex,
    Hybrid,
}

// ─────────────────────────────────────────────────────────────────────────────
// Storage helpers — format detection + passphrase resolution
// ─────────────────────────────────────────────────────────────────────────────

/// Load a vault from disk.
///
/// Format is detected by file extension:
/// - `.json`  → legacy plaintext JSON (backward compat)
/// - anything else → binary `.vault` format (AES-256-GCM if passphrase provided)
///
/// Emits a stderr warning when a non-JSON vault has no passphrase.
pub(crate) fn vault_load(path: &Path, passphrase: Option<&str>) -> Result<Vault> {
    if path.extension().is_some_and(|e| e == "json") {
        return JsonStore::load(path);
    }
    if passphrase.is_none() {
        eprintln!(
            "⚠  WARNING: no passphrase — vault is stored unencrypted. \
             Set --passphrase or P3AK_VAULT_PASSPHRASE for production use."
        );
    }
    binary_store::load(path, passphrase)
}

/// Save a vault to disk, using the same format-detection logic as `vault_load`.
pub(crate) fn vault_save(vault: &Vault, path: &Path, passphrase: Option<&str>) -> Result<()> {
    if path.extension().is_some_and(|e| e == "json") {
        return JsonStore::save(path, vault);
    }
    if passphrase.is_none() {
        eprintln!(
            "⚠  WARNING: no passphrase — vault is stored unencrypted. \
             Set --passphrase or P3AK_VAULT_PASSPHRASE for production use."
        );
    }
    binary_store::save(vault, path, passphrase)
}

// ─────────────────────────────────────────────────────────────────────────────
// Entry point
// ─────────────────────────────────────────────────────────────────────────────

fn main() {
    let cli = Cli::parse();
    if let Err(e) = run(cli) {
        eprintln!("{:#}", e);
        std::process::exit(1);
    }
}

fn run(cli: Cli) -> Result<()> {
    // Load user config (~/.p3ak/config.toml) — silently fall back on error.
    let cfg = config::load().unwrap_or_default();

    // Resolve passphrase: --passphrase / $P3AK_VAULT_PASSPHRASE (clap env) >
    // config.vault.passphrase_env (reads THAT env-var's value at runtime)
    let mut _cfg_pp_cache: Option<String> = None;
    let resolved_pp = config::resolve_passphrase(cli.passphrase, &cfg, &mut _cfg_pp_cache);
    let pp = resolved_pp.as_deref();

    // Resolve vault path: --path / $P3AK_VAULT_PATH > config.vault.path
    // Most subcommands require a path; we resolve lazily so `--help` still works
    // without a path being set.
    let resolve_path = || config::resolve_required_path(cli.path.clone(), &cfg);

    match cli.command {
        Commands::Create { room: _ } => cmd_create(&resolve_path()?, pp),
        Commands::Read { r#type, domain } => {
            cmd_read(&resolve_path()?, r#type, domain.as_deref(), pp)
        }
        Commands::Write { r#type, payload } => cmd_write(&resolve_path()?, r#type, &payload, pp),
        Commands::Search {
            query,
            limit,
            mode,
            store,
            room,
            also_vault,
        } => cmd_search(
            &resolve_path()?,
            &also_vault,
            &query,
            limit,
            &mode,
            store.as_deref(),
            room.as_deref(),
            pp,
        ),
        Commands::AccuracyTest { ground_truth, mode } => {
            cmd_accuracy_test(&resolve_path()?, &ground_truth, &mode, pp)
        }
        Commands::Ingest {
            file,
            dir,
            recursive,
            room,
        } => {
            let path = resolve_path()?;
            match (file, dir) {
                (Some(f), None) => cmd_ingest_file(&path, &f, room.as_deref(), pp),
                (None, Some(d)) => cmd_ingest_dir(&path, &d, recursive, room.as_deref(), pp),
                _ => bail!("specify either --file <FILE> or --dir <DIR>"),
            }
        }
        Commands::Delete { filename } => cmd_delete(&resolve_path()?, &filename, pp),
        Commands::Classify { only_missing } => cmd_classify(&resolve_path()?, only_missing, pp),
        Commands::Export {
            store,
            out,
            out_passphrase,
        } => {
            let out_pp = out_passphrase.as_deref().or(pp);
            cmd_export(&resolve_path()?, &store, &out, out_pp, pp)
        }
        Commands::Watch {
            dir,
            room,
            interval,
        } => watch::run_watch(&resolve_path()?, &dir, room.as_deref(), interval, pp),
        Commands::CanaryAdd {
            query,
            expected_filename,
            expected_snippet,
            store,
            room,
            min_score,
            top_k,
        } => cmd_canary_add(
            &resolve_path()?,
            &query,
            &expected_filename,
            expected_snippet.as_deref(),
            store.as_deref(),
            room.as_deref(),
            min_score,
            top_k,
            pp,
        ),
        Commands::CanaryCheck { threshold, mode } => {
            cmd_canary_check(&resolve_path()?, threshold, &mode, pp)
        }
        Commands::Sync {
            from,
            to,
            room,
            passphrase_from,
            passphrase_to,
        } => {
            // Per-vault passphrase resolution:
            // explicit --passphrase-from/--passphrase-to → global --passphrase → None
            let pp_from = passphrase_from.or_else(|| resolved_pp.clone());
            let pp_to = passphrase_to.or_else(|| resolved_pp.clone());
            cmd_sync(&from, &to, room.as_deref(), pp_from, pp_to)
        }
        Commands::Serve { port } => serve::cmd_serve(&resolve_path()?, port, resolved_pp),
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// Command implementations
// ─────────────────────────────────────────────────────────────────────────────

fn cmd_create(path: &Path, passphrase: Option<&str>) -> Result<()> {
    if path.exists() {
        bail!("Vault already exists: {}", path.display());
    }
    let vault = Vault::default();
    vault_save(&vault, path, passphrase)?;
    let output = serde_json::json!({
        "ok": true,
        "path": path.display().to_string(),
        "encrypted": passphrase.is_some(),
        "message": if passphrase.is_some() {
            "Encrypted vault created"
        } else {
            "Vault created (unencrypted — set --passphrase for production)"
        }
    });
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

fn cmd_read(
    path: &Path,
    read_type: ReadType,
    domain: Option<&str>,
    passphrase: Option<&str>,
) -> Result<()> {
    let vault = vault_load(path, passphrase)?;

    match read_type {
        ReadType::Goals => {
            let goals: Vec<&WalEntry> = vault
                .wal
                .iter()
                .filter(|e| e.is_current && e.action_type == "write_goal")
                .filter(|e| {
                    if let Some(d) = domain {
                        e.payload
                            .as_ref()
                            .and_then(|p| p.get("domain"))
                            .and_then(|v| v.as_str())
                            .map(|v| v == d)
                            .unwrap_or(false)
                    } else {
                        true
                    }
                })
                .collect();
            println!("{}", serde_json::to_string_pretty(&goals)?);
        }
        ReadType::Docs => {
            let docs: Vec<&Doc> = if let Some(d) = domain {
                vault.docs.iter().filter(|doc| doc.room == d).collect()
            } else {
                vault.docs.iter().collect()
            };
            println!("{}", serde_json::to_string_pretty(&docs)?);
        }
        ReadType::Wal => {
            println!("{}", serde_json::to_string_pretty(&vault.wal)?);
        }
    }
    Ok(())
}

fn cmd_write(
    path: &Path,
    write_type: WriteType,
    payload_str: &str,
    passphrase: Option<&str>,
) -> Result<()> {
    let mut vault = vault_load(path, passphrase)?;

    let payload: serde_json::Value = if payload_str == "-" {
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .context("Failed to read payload from stdin")?;
        serde_json::from_str(&buf).context("Invalid JSON from stdin")?
    } else {
        serde_json::from_str(payload_str).context("Invalid JSON payload")?
    };

    let action_type = match write_type {
        WriteType::Goal => "write_goal",
        WriteType::Doc => "write_doc",
        WriteType::Plan => "write_plan",
        WriteType::Review => "write_review",
    };

    // For goals, supersede previous goals with the same intent
    if action_type == "write_goal" {
        if let Some(intent) = payload.get("intent").and_then(|v| v.as_str()) {
            let old_ids: Vec<u64> = vault
                .wal
                .iter()
                .filter(|e| {
                    e.is_current
                        && e.action_type == "write_goal"
                        && e.payload
                            .as_ref()
                            .and_then(|p| p.get("intent"))
                            .and_then(|v| v.as_str())
                            == Some(intent)
                })
                .map(|e| e.entry_id)
                .collect();

            let new_id = wal::append_entry(&mut vault, action_type, Some(payload));

            for old_id in old_ids {
                wal::mark_superseded(&mut vault, old_id, new_id);
            }
        } else {
            wal::append_entry(&mut vault, action_type, Some(payload));
        }
    } else {
        wal::append_entry(&mut vault, action_type, Some(payload));
    }

    let entry_id = vault.wal.last().unwrap().entry_id;
    vault_save(&vault, path, passphrase)?;

    let output = serde_json::json!({
        "ok": true,
        "entry_id": entry_id,
        "vaultPath": path.display().to_string()
    });
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

#[allow(clippy::too_many_arguments)] // CLI dispatcher — naturally many params
fn cmd_search(
    path: &Path,
    also_vault: &[PathBuf],
    query: &str,
    limit: usize,
    mode: &SearchMode,
    store: Option<&str>,
    room: Option<&str>,
    passphrase: Option<&str>,
) -> Result<()> {
    // H3: multi-vault federation — collect all vault paths, search each, merge results.
    if !also_vault.is_empty() {
        let mut all_paths: Vec<&Path> = vec![path];
        all_paths.extend(also_vault.iter().map(|p| p.as_path()));
        return cmd_search_multi(&all_paths, query, limit, mode, store, room, passphrase);
    }
    use vault_core::types::StoreId;

    let vault = vault_load(path, passphrase)?;
    let index_path = PathBuf::from(format!("{}.idx", path.display()));

    // Build a doc_id → (filename, room, store_label) lookup so every result includes
    // human-readable identifiers without requiring the caller to join on vault.docs.
    let doc_meta: std::collections::HashMap<u64, (&str, &str, Option<String>)> = vault
        .docs
        .iter()
        .map(|d| {
            let store_label = d
                .classification
                .as_ref()
                .map(|c| c.store_id.as_str().to_string());
            (
                d.doc_id,
                (d.filename.as_str(), d.room.as_str(), store_label),
            )
        })
        .collect();

    /// Enrich a raw serde_json::Value result with filename/room/store fields.
    fn enrich(
        mut v: serde_json::Value,
        doc_meta: &std::collections::HashMap<u64, (&str, &str, Option<String>)>,
    ) -> serde_json::Value {
        if let Some(doc_id) = v.get("doc_id").and_then(|x| x.as_u64()) {
            if let Some(&(filename, room, ref store_label)) = doc_meta.get(&doc_id) {
                let obj = v.as_object_mut().unwrap();
                obj.insert("filename".into(), serde_json::json!(filename));
                obj.insert("room".into(), serde_json::json!(room));
                if let Some(lbl) = store_label {
                    obj.insert("store".into(), serde_json::json!(lbl));
                }
            }
        }
        v
    }

    // Parse optional store filter
    let store_filter: Option<StoreId> = match store {
        Some(s) => Some(StoreId::from_str(s).ok_or_else(|| {
            anyhow::anyhow!(
                "unknown store '{}'. Valid stores: HC, LG, FN, OP, HR, TC, MS, IN, GV, ED",
                s
            )
        })?),
        None => None,
    };

    // Parse optional room filter (non-empty string only)
    let room_filter: Option<String> = room
        .filter(|r| !r.trim().is_empty())
        .map(|r| r.trim().to_string());

    match mode {
        SearchMode::Flat => {
            search::build_index(&vault, &index_path)?;
            let mut results = search::search(&index_path, query, limit)?;
            if let Some(ref sid) = store_filter {
                let allowed = search::store_filtered_doc_ids(&vault, sid);
                results.retain(|r| allowed.contains(&r.doc_id));
            }
            if let Some(ref rm) = room_filter {
                let allowed = search::room_filtered_doc_ids(&vault, rm);
                results.retain(|r| allowed.contains(&r.doc_id));
            }
            let enriched: Vec<_> = results
                .into_iter()
                .map(|r| enrich(serde_json::to_value(r).unwrap(), &doc_meta))
                .collect();
            println!("{}", serde_json::to_string_pretty(&enriched)?);
        }
        SearchMode::Pageindex => {
            let config = search::PageIndexSearchConfig {
                store_filter,
                room_filter,
                ..search::PageIndexSearchConfig::default()
            };
            let results = search::pageindex_search(&vault, &index_path, query, limit, &config)?;
            let enriched: Vec<_> = results
                .into_iter()
                .map(|r| enrich(serde_json::to_value(r).unwrap(), &doc_meta))
                .collect();
            println!("{}", serde_json::to_string_pretty(&enriched)?);
        }
        SearchMode::Hybrid => {
            // H2: load vocab from sidecar cache — avoids O(N×doc_len) rebuild per query.
            // On cache miss, get_or_build_vocab rebuilds and saves a fresh sidecar.
            let vocab = zvec::get_or_build_vocab(&vault, path);
            let config = search::HybridSearchConfig {
                store_filter,
                room_filter,
                ..search::HybridSearchConfig::default()
            };
            let results = search::hybrid_search_with_vocab(
                &vault,
                &index_path,
                query,
                limit,
                &config,
                &vocab,
            )?;
            let enriched: Vec<_> = results
                .into_iter()
                .map(|r| enrich(serde_json::to_value(r).unwrap(), &doc_meta))
                .collect();
            println!("{}", serde_json::to_string_pretty(&enriched)?);
        }
    }
    Ok(())
}

/// Multi-vault federation search.
///
/// Searches each vault independently (hybrid only for now — BM25+ZVec per vault)
/// then merges all results sorted by `combined_score` descending, returning
/// the top `limit` results.  Each result includes a `vault` field (the vault
/// file path) so callers can see which vault a document came from.
#[allow(clippy::too_many_arguments)] // CLI dispatcher — naturally many params
fn cmd_search_multi(
    paths: &[&Path],
    query: &str,
    limit: usize,
    mode: &SearchMode,
    store: Option<&str>,
    room: Option<&str>,
    passphrase: Option<&str>,
) -> Result<()> {
    use vault_core::types::StoreId;

    let store_filter: Option<StoreId> = match store {
        Some(s) => Some(StoreId::from_str(s).ok_or_else(|| {
            anyhow::anyhow!(
                "unknown store '{}'. Valid stores: HC, LG, FN, OP, HR, TC, MS, IN, GV, ED",
                s
            )
        })?),
        None => None,
    };
    let room_filter: Option<String> = room
        .filter(|r| !r.trim().is_empty())
        .map(|r| r.trim().to_string());

    let mut all_results: Vec<serde_json::Value> = Vec::new();
    let mut load_errors: Vec<String> = Vec::new();

    for &vault_path in paths {
        let vault = match vault_load(vault_path, passphrase) {
            Ok(v) => v,
            Err(e) => {
                load_errors.push(format!("{}: {e:#}", vault_path.display()));
                continue;
            }
        };

        let index_path = PathBuf::from(format!("{}.idx", vault_path.display()));

        // Build doc_meta lookup for this vault
        let doc_meta: std::collections::HashMap<u64, (&str, &str, Option<String>)> = vault
            .docs
            .iter()
            .map(|d| {
                let store_label = d
                    .classification
                    .as_ref()
                    .map(|c| c.store_id.as_str().to_string());
                (
                    d.doc_id,
                    (d.filename.as_str(), d.room.as_str(), store_label),
                )
            })
            .collect();

        let results_raw = match mode {
            SearchMode::Flat => {
                search::build_index(&vault, &index_path)?;
                let mut r = search::search(&index_path, query, limit)?;
                if let Some(ref sid) = store_filter {
                    let allowed = search::store_filtered_doc_ids(&vault, sid);
                    r.retain(|x| allowed.contains(&x.doc_id));
                }
                if let Some(ref rm) = room_filter {
                    let allowed = search::room_filtered_doc_ids(&vault, rm);
                    r.retain(|x| allowed.contains(&x.doc_id));
                }
                r.into_iter()
                    .map(|r| serde_json::to_value(r).unwrap())
                    .collect::<Vec<_>>()
            }
            SearchMode::Pageindex => {
                let cfg = search::PageIndexSearchConfig {
                    store_filter: store_filter.clone(),
                    room_filter: room_filter.clone(),
                    ..Default::default()
                };
                search::pageindex_search(&vault, &index_path, query, limit, &cfg)?
                    .into_iter()
                    .map(|r| serde_json::to_value(r).unwrap())
                    .collect()
            }
            SearchMode::Hybrid => {
                let vocab = zvec::get_or_build_vocab(&vault, vault_path);
                let cfg = search::HybridSearchConfig {
                    store_filter: store_filter.clone(),
                    room_filter: room_filter.clone(),
                    ..Default::default()
                };
                search::hybrid_search_with_vocab(&vault, &index_path, query, limit, &cfg, &vocab)?
                    .into_iter()
                    .map(|r| serde_json::to_value(r).unwrap())
                    .collect()
            }
        };

        for mut result in results_raw {
            // Add vault path and enrich with filename/room/store
            if let Some(doc_id) = result.get("doc_id").and_then(|x| x.as_u64()) {
                if let Some(&(filename, room_str, ref store_label)) = doc_meta.get(&doc_id) {
                    let obj = result.as_object_mut().unwrap();
                    obj.insert("filename".into(), serde_json::json!(filename));
                    obj.insert("room".into(), serde_json::json!(room_str));
                    obj.insert(
                        "vault".into(),
                        serde_json::json!(vault_path.display().to_string()),
                    );
                    if let Some(lbl) = store_label {
                        obj.insert("store".into(), serde_json::json!(lbl));
                    }
                }
            }
            all_results.push(result);
        }
    }

    if !load_errors.is_empty() {
        for e in &load_errors {
            eprintln!("⚠  vault load error: {e}");
        }
    }

    // Sort by combined_score descending, deduplicate by filename (keep highest score).
    all_results.sort_by(|a, b| {
        let sa = a
            .get("combined_score")
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);
        let sb = b
            .get("combined_score")
            .and_then(|v| v.as_f64())
            .unwrap_or(0.0);
        sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
    });

    // Dedup by (vault, filename) — same doc from the same vault should appear once.
    let mut seen: std::collections::HashSet<(String, String)> = std::collections::HashSet::new();
    all_results.retain(|r| {
        let vault_str = r
            .get("vault")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        let fname = r
            .get("filename")
            .and_then(|v| v.as_str())
            .unwrap_or("")
            .to_string();
        seen.insert((vault_str, fname))
    });

    // Return top `limit` results
    all_results.truncate(limit);
    println!("{}", serde_json::to_string_pretty(&all_results)?);
    Ok(())
}

fn cmd_accuracy_test(
    path: &Path,
    ground_truth_path: &Path,
    mode: &SearchMode,
    passphrase: Option<&str>,
) -> Result<()> {
    let vault = vault_load(path, passphrase)?;
    let index_path = PathBuf::from(format!("{}.idx", path.display()));

    let gt_content = std::fs::read_to_string(ground_truth_path).with_context(|| {
        format!(
            "Failed to read ground truth: {}",
            ground_truth_path.display()
        )
    })?;
    let gt: serde_json::Value =
        serde_json::from_str(&gt_content).context("Invalid JSON in ground truth file")?;

    let queries = gt["queries"]
        .as_array()
        .context("No queries array in ground truth")?;

    search::build_index(&vault, &index_path)?;

    let mut results_report = Vec::new();
    let mut top_1_hits = 0u32;
    let mut top_3_hits = 0u32;
    let mut snippet_hits = 0u32;
    let total = queries.len() as u32;

    for query_entry in queries {
        let id = query_entry["id"].as_str().unwrap_or("");
        let query_text = query_entry["query"].as_str().unwrap_or("");
        let expected_doc = query_entry["expected"]["doc_filename"]
            .as_str()
            .unwrap_or("");
        let answer_variants: Vec<String> = query_entry["expected"]["answer_variants"]
            .as_array()
            .map(|a| {
                a.iter()
                    .filter_map(|v| v.as_str().map(String::from))
                    .collect()
            })
            .unwrap_or_default();
        let snippet_must_contain = query_entry["expected"]["snippet_must_contain"]
            .as_str()
            .unwrap_or("");

        let (top_result_doc, found_top1, found_top3, found_snippet) = match mode {
            SearchMode::Flat => {
                let results = search::search(&index_path, query_text, 10).unwrap_or_default();
                let top1 = results.first().is_some_and(|r| {
                    vault
                        .docs
                        .iter()
                        .any(|d| d.doc_id == r.doc_id && d.filename.ends_with(expected_doc))
                });
                let top3 = results.iter().take(3).any(|r| {
                    vault
                        .docs
                        .iter()
                        .any(|d| d.doc_id == r.doc_id && d.filename.ends_with(expected_doc))
                });
                let snip = results.iter().take(3).any(|r| {
                    let lower = r.snippet.to_lowercase();
                    answer_variants
                        .iter()
                        .any(|v| lower.contains(&v.to_lowercase()))
                        || lower.contains(&snippet_must_contain.to_lowercase())
                });
                let top_doc = results
                    .first()
                    .and_then(|r| vault.docs.iter().find(|d| d.doc_id == r.doc_id))
                    .map(|d| d.filename.clone())
                    .unwrap_or_default();
                (top_doc, top1, top3, snip)
            }
            SearchMode::Pageindex => {
                let config = search::PageIndexSearchConfig::default();
                let results =
                    search::pageindex_search(&vault, &index_path, query_text, 10, &config)
                        .unwrap_or_default();
                let top1 = results.first().is_some_and(|r| {
                    vault
                        .docs
                        .iter()
                        .any(|d| d.doc_id == r.doc_id && d.filename.ends_with(expected_doc))
                });
                let top3 = results.iter().take(3).any(|r| {
                    vault
                        .docs
                        .iter()
                        .any(|d| d.doc_id == r.doc_id && d.filename.ends_with(expected_doc))
                });
                let snip = results.iter().take(3).any(|r| {
                    let lower = r.node_summary.to_lowercase();
                    answer_variants
                        .iter()
                        .any(|v| lower.contains(&v.to_lowercase()))
                        || lower.contains(&snippet_must_contain.to_lowercase())
                });
                let top_doc = results
                    .first()
                    .and_then(|r| vault.docs.iter().find(|d| d.doc_id == r.doc_id))
                    .map(|d| d.filename.clone())
                    .unwrap_or_default();
                (top_doc, top1, top3, snip)
            }
            SearchMode::Hybrid => {
                let config = search::HybridSearchConfig::default();
                let results = search::hybrid_search(&vault, &index_path, query_text, 10, &config)
                    .unwrap_or_default();
                let top1 = results.first().is_some_and(|r| {
                    vault
                        .docs
                        .iter()
                        .any(|d| d.doc_id == r.doc_id && d.filename.ends_with(expected_doc))
                });
                let top3 = results.iter().take(3).any(|r| {
                    vault
                        .docs
                        .iter()
                        .any(|d| d.doc_id == r.doc_id && d.filename.ends_with(expected_doc))
                });
                let snip = results.iter().take(3).any(|r| {
                    let lower = r.snippet.to_lowercase();
                    answer_variants
                        .iter()
                        .any(|v| lower.contains(&v.to_lowercase()))
                        || lower.contains(&snippet_must_contain.to_lowercase())
                });
                let top_doc = results
                    .first()
                    .and_then(|r| vault.docs.iter().find(|d| d.doc_id == r.doc_id))
                    .map(|d| d.filename.clone())
                    .unwrap_or_default();
                (top_doc, top1, top3, snip)
            }
        };

        if found_top1 {
            top_1_hits += 1;
        }
        if found_top3 {
            top_3_hits += 1;
        }
        if found_snippet {
            snippet_hits += 1;
        }

        results_report.push(serde_json::json!({
            "id": id,
            "query": query_text,
            "top_1_match": found_top1,
            "top_3_contains": found_top3,
            "snippet_match": found_snippet,
            "top_result": top_result_doc,
            "expected": expected_doc,
        }));
    }

    let report = serde_json::json!({
        "mode": match mode {
            SearchMode::Flat => "flat",
            SearchMode::Pageindex => "pageindex",
            SearchMode::Hybrid => "hybrid",
        },
        "total_queries": total,
        "top_1_accuracy": format!("{:.1}%", (top_1_hits as f64 / total as f64) * 100.0),
        "top_3_contains": format!("{:.1}%", (top_3_hits as f64 / total as f64) * 100.0),
        "snippet_match": format!("{:.1}%", (snippet_hits as f64 / total as f64) * 100.0),
        "top_1_hits": top_1_hits,
        "top_3_hits": top_3_hits,
        "snippet_hits": snippet_hits,
        "details": results_report,
    });

    println!("{}", serde_json::to_string_pretty(&report)?);
    Ok(())
}

fn cmd_ingest_file(
    path: &Path,
    file: &Path,
    room: Option<&str>,
    passphrase: Option<&str>,
) -> Result<()> {
    let mut vault = vault_load(path, passphrase)?;
    let room_str = room.unwrap_or("default");

    let report = ingest::ingest_file(&mut vault, file, room_str)?;

    let filename = file
        .file_name()
        .map(|f| f.to_string_lossy().to_string())
        .unwrap_or_else(|| "unknown".to_string());

    // Only save if the vault was actually modified
    if report.action != ingest::IngestAction::Skipped {
        vault_save(&vault, path, passphrase)?;
        // Invalidate vocab sidecar so next search rebuilds with the new corpus.
        zvec::invalidate_sidecar(path);
    }

    let output = serde_json::json!({
        "ok": true,
        "doc_id": report.doc_id,
        "filename": filename,
        "action": format!("{:?}", report.action).to_lowercase(),
        "source_format": report.source_format,
        "encrypted": passphrase.is_some(),
        "vaultPath": path.display().to_string()
    });
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

/// Bulk-ingest all supported files in a directory.
///
/// Outputs one NDJSON event per file, then a summary line.
fn cmd_ingest_dir(
    vault_path: &Path,
    dir: &Path,
    recursive: bool,
    room: Option<&str>,
    passphrase: Option<&str>,
) -> Result<()> {
    use vault_core::convert::SUPPORTED_EXTENSIONS;

    if !dir.is_dir() {
        bail!("not a directory: {}", dir.display());
    }

    let room_str = room.unwrap_or("default");
    let mut vault = vault_load(vault_path, passphrase)?;

    // Collect all files matching supported extensions
    let files = collect_files(dir, recursive, SUPPORTED_EXTENSIONS);

    let mut added = 0usize;
    let mut updated = 0usize;
    let mut skipped = 0usize;
    let mut errors = 0usize;
    let mut unsupported = 0usize;
    let mut vault_dirty = false;

    for file in &files {
        let fname = file
            .file_name()
            .map(|f| f.to_string_lossy().to_string())
            .unwrap_or_else(|| "unknown".to_string());
        let t0 = std::time::Instant::now();

        match ingest::ingest_file(&mut vault, file, room_str) {
            Ok(report) => {
                let elapsed = t0.elapsed().as_millis();
                let action_str = match report.action {
                    ingest::IngestAction::Added => {
                        added += 1;
                        vault_dirty = true;
                        "added"
                    }
                    ingest::IngestAction::Updated => {
                        updated += 1;
                        vault_dirty = true;
                        "updated"
                    }
                    ingest::IngestAction::Skipped => {
                        skipped += 1;
                        "skipped"
                    }
                };
                let event = serde_json::json!({
                    "file": fname,
                    "doc_id": report.doc_id,
                    "action": action_str,
                    "format": report.source_format,
                    "elapsed_ms": elapsed
                });
                println!("{}", serde_json::to_string(&event)?);
            }
            Err(e) => {
                let msg = e.to_string();
                if msg.contains("unsupported format") {
                    unsupported += 1;
                    let event = serde_json::json!({
                        "file": fname,
                        "action": "skipped",
                        "reason": "unsupported_format"
                    });
                    println!("{}", serde_json::to_string(&event)?);
                } else {
                    errors += 1;
                    let event = serde_json::json!({
                        "file": fname,
                        "action": "error",
                        "error": msg
                    });
                    println!("{}", serde_json::to_string(&event)?);
                }
            }
        }
    }

    if vault_dirty {
        vault_save(&vault, vault_path, passphrase)?;
        // Invalidate vocab sidecar so next search rebuilds with the updated corpus.
        zvec::invalidate_sidecar(vault_path);
    }

    let summary = serde_json::json!({
        "summary": {
            "found": files.len(),
            "added": added,
            "updated": updated,
            "skipped": skipped,
            "unsupported": unsupported,
            "errors": errors,
        }
    });
    println!("{}", serde_json::to_string(&summary)?);
    Ok(())
}

/// Recursively collect files with any of the given extensions.
fn collect_files(dir: &Path, recursive: bool, extensions: &[&str]) -> Vec<std::path::PathBuf> {
    let mut results = Vec::new();
    let Ok(entries) = std::fs::read_dir(dir) else {
        return results;
    };
    for entry in entries.flatten() {
        let p = entry.path();
        if p.is_dir() && recursive {
            results.extend(collect_files(&p, true, extensions));
        } else if p.is_file() {
            let ext = p
                .extension()
                .and_then(|e| e.to_str())
                .map(|e| e.to_lowercase())
                .unwrap_or_default();
            if extensions.contains(&ext.as_str()) {
                results.push(p);
            }
        }
    }
    results.sort(); // deterministic order
    results
}

fn cmd_delete(path: &Path, filename: &str, passphrase: Option<&str>) -> Result<()> {
    let mut vault = vault_load(path, passphrase)?;

    let idx = vault
        .docs
        .iter()
        .position(|d| d.filename == filename && !d.deleted)
        .ok_or_else(|| anyhow::anyhow!("document not found: {filename}"))?;

    let doc_id = vault.docs[idx].doc_id;
    vault.docs[idx].deleted = true;

    vault_core::wal::append_entry(
        &mut vault,
        "delete_doc",
        Some(serde_json::json!({
            "doc_id": doc_id,
            "filename": filename,
        })),
    );

    vault_save(&vault, path, passphrase)?;
    zvec::invalidate_sidecar(path); // deletion shrinks corpus — old sidecar is stale

    let output = serde_json::json!({
        "ok": true,
        "doc_id": doc_id,
        "filename": filename,
    });
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

fn cmd_classify(path: &Path, only_missing: bool, passphrase: Option<&str>) -> Result<()> {
    let mut vault = vault_load(path, passphrase)?;
    let mut classified = 0usize;
    let mut skipped = 0usize;
    let mut llm_classified = 0usize;
    let mut llm_errors = 0usize;
    let llm_available = std::env::var("ANTHROPIC_API_KEY")
        .map(|k| !k.trim().is_empty())
        .unwrap_or(false);

    for doc in &mut vault.docs {
        if only_missing && doc.classification.is_some() {
            skipped += 1;
            continue;
        }

        let title_stem = std::path::Path::new(&doc.filename)
            .file_stem()
            .and_then(|s| s.to_str())
            .unwrap_or(&doc.filename)
            .replace(['-', '_'], " ");

        let mut results = classify::classify_document(&title_stem, &doc.normalized_text);

        // Check if the rule-based result is high-confidence
        let rule_confidence = results.first().map(|r| r.confidence).unwrap_or(0.0);
        let needs_llm = rule_confidence < classify::REVIEW_THRESHOLD && llm_available;

        if !results.is_empty() && !needs_llm {
            // Rule-based result is confident enough
            let primary = results.remove(0);
            doc.classification = Some(primary);
            classified += 1;
        } else if needs_llm {
            // Low-confidence or no rule-based result — try LLM fallback
            match llm_classify::llm_classify(&title_stem, &doc.normalized_text) {
                Ok(Some(llm_result)) => {
                    // LLM returned a confident classification
                    doc.classification = Some(llm_result);
                    llm_classified += 1;
                    classified += 1;
                }
                Ok(None) => {
                    // LLM returned no result — use rule-based if available
                    if let Some(primary) = results.into_iter().next() {
                        doc.classification = Some(primary);
                        classified += 1;
                    } else {
                        skipped += 1;
                    }
                }
                Err(e) => {
                    // Network error or parse error — log to stderr, use rule-based
                    eprintln!(
                        "⚠  LLM classify failed for '{}': {} — using rule-based fallback",
                        doc.filename, e
                    );
                    llm_errors += 1;
                    if let Some(primary) = results.into_iter().next() {
                        doc.classification = Some(primary);
                        classified += 1;
                    } else {
                        skipped += 1;
                    }
                }
            }
        } else {
            // No rule-based result and LLM not available
            skipped += 1;
        }
    }

    vault_save(&vault, path, passphrase)?;

    let mut output = serde_json::json!({
        "ok": true,
        "classified": classified,
        "skipped": skipped,
        "total_docs": vault.docs.len()
    });
    if llm_available {
        output["llm_classified"] = serde_json::json!(llm_classified);
        output["llm_errors"] = serde_json::json!(llm_errors);
    }
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

fn cmd_export(
    path: &Path,
    store_str: &str,
    out: &Path,
    out_passphrase: Option<&str>,
    passphrase: Option<&str>,
) -> Result<()> {
    use vault_core::types::StoreId;

    // Validate the store ID
    let store_id = StoreId::from_str(store_str).ok_or_else(|| {
        anyhow::anyhow!(
            "unknown store '{}'. Valid stores: HC, LG, FN, OP, HR, TC, MS, IN, GV, ED",
            store_str
        )
    })?;

    // Prevent overwriting the source vault
    if out.canonicalize().ok() == path.canonicalize().ok() {
        bail!("--out path must be different from the source vault path");
    }

    // Load and filter
    let source = vault_load(path, passphrase)?;

    // Collect doc_ids in the target store
    let allowed_ids = search::store_filtered_doc_ids(&source, &store_id);
    if allowed_ids.is_empty() {
        bail!(
            "no documents classified in store '{}'. \
             Run `p3ak-vault classify --path {}` first.",
            store_str,
            path.display()
        );
    }

    // Build the filtered vault
    let mut export_vault = Vault {
        header: source.header.clone(),
        docs: Vec::new(),
        pageindex_trees: Vec::new(),
        zvec_index: ZVecIndex::default(),
        wal: Vec::new(), // fresh WAL — don't expose source audit history
    };

    // Copy matching docs
    for doc in &source.docs {
        if allowed_ids.contains(&doc.doc_id) {
            export_vault.docs.push(doc.clone());
        }
    }

    // Copy matching PageIndex trees
    for tree in &source.pageindex_trees {
        if allowed_ids.contains(&tree.doc_id) {
            export_vault.pageindex_trees.push(tree.clone());
        }
    }

    // Write the export WAL entry (compute doc_count before mutable borrow)
    let doc_count = export_vault.docs.len();
    wal::append_entry(
        &mut export_vault,
        "export",
        Some(serde_json::json!({
            "source": path.display().to_string(),
            "store": store_str,
            "doc_count": doc_count
        })),
    );

    // Save — use out_passphrase if provided, else fall back to source passphrase
    vault_save(&export_vault, out, out_passphrase)?;

    let output = serde_json::json!({
        "ok": true,
        "out": out.display().to_string(),
        "store": store_str,
        "doc_count": doc_count,
        "encrypted": out_passphrase.or(passphrase).is_some(),
        "note": if out_passphrase.is_some() {
            "Exported vault uses a separate passphrase. Share it with the recipient via secure channel."
        } else {
            "Exported vault uses the same passphrase as the source vault."
        }
    });
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

// ─── Canary commands ──────────────────────────────────────────────────────────

#[allow(clippy::too_many_arguments)]
fn cmd_canary_add(
    path: &Path,
    query: &str,
    expected_filename: &str,
    expected_snippet: Option<&str>,
    store: Option<&str>,
    room: Option<&str>,
    min_score: f32,
    top_k: usize,
    passphrase: Option<&str>,
) -> Result<()> {
    let mut vault = vault_load(path, passphrase)?;

    let mut payload = serde_json::json!({
        "query": query,
        "expected_filename": expected_filename,
        "min_score": min_score,
        "top_k": top_k,
    });
    if let Some(s) = expected_snippet {
        payload["expected_snippet"] = serde_json::json!(s);
    }
    if let Some(s) = store {
        payload["store"] = serde_json::json!(s);
    }
    if let Some(r) = room {
        payload["room"] = serde_json::json!(r);
    }

    let entry_id = wal::append_entry(&mut vault, "write_canary", Some(payload));
    vault_save(&vault, path, passphrase)?;

    let output = serde_json::json!({
        "ok": true,
        "canary_id": entry_id,
        "query": query,
        "expected_filename": expected_filename,
    });
    println!("{}", serde_json::to_string_pretty(&output)?);
    Ok(())
}

fn cmd_canary_check(
    path: &Path,
    threshold: f32,
    mode: &SearchMode,
    passphrase: Option<&str>,
) -> Result<()> {
    use vault_core::types::StoreId;

    let vault = vault_load(path, passphrase)?;
    let index_path = std::path::PathBuf::from(format!("{}.idx", path.display()));

    // Collect all active canaries from the WAL
    let canaries: Vec<serde_json::Value> = vault
        .wal
        .iter()
        .filter(|e| e.is_current && e.action_type == "write_canary")
        .filter_map(|e| e.payload.clone())
        .collect();

    if canaries.is_empty() {
        let output = serde_json::json!({
            "ok": true,
            "total": 0,
            "passed": 0,
            "failed": 0,
            "recall": 1.0,
            "threshold": threshold,
            "status": "no_canaries",
            "message": "No canaries registered. Add some with `p3ak-vault canary-add`.",
        });
        println!("{}", serde_json::to_string_pretty(&output)?);
        return Ok(());
    }

    // Build search index once for all checks
    search::build_index(&vault, &index_path)?;

    let mut results_detail = Vec::new();
    let mut passed = 0usize;

    for canary in &canaries {
        let query = canary["query"].as_str().unwrap_or("");
        let expected_filename = canary["expected_filename"].as_str().unwrap_or("");
        let expected_snippet = canary["expected_snippet"].as_str();
        let top_k = canary["top_k"].as_u64().unwrap_or(5) as usize;
        let min_score = canary["min_score"].as_f64().unwrap_or(0.0) as f32;
        let store_str = canary["store"].as_str();
        let room_str = canary["room"].as_str();

        // Parse filters
        let store_filter: Option<StoreId> = store_str.and_then(StoreId::from_str);
        let room_filter: Option<String> = room_str.map(|r| r.to_string());

        // Run search
        let search_results: Vec<(u64, f32, String)> = match mode {
            SearchMode::Flat => {
                let results = search::search(&index_path, query, top_k).unwrap_or_default();
                results
                    .into_iter()
                    .map(|r| (r.doc_id, r.score, r.snippet))
                    .collect()
            }
            SearchMode::Pageindex => {
                let config = search::PageIndexSearchConfig {
                    store_filter: store_filter.clone(),
                    room_filter: room_filter.clone(),
                    ..search::PageIndexSearchConfig::default()
                };
                let results = search::pageindex_search(&vault, &index_path, query, top_k, &config)
                    .unwrap_or_default();
                results
                    .into_iter()
                    .map(|r| (r.doc_id, r.combined_score, r.node_summary))
                    .collect()
            }
            SearchMode::Hybrid => {
                let config = search::HybridSearchConfig {
                    store_filter: store_filter.clone(),
                    room_filter: room_filter.clone(),
                    ..search::HybridSearchConfig::default()
                };
                let results = search::hybrid_search(&vault, &index_path, query, top_k, &config)
                    .unwrap_or_default();
                results
                    .into_iter()
                    .map(|r| (r.doc_id, r.combined_score, r.snippet))
                    .collect()
            }
        };

        // Check pass/fail
        let mut found_filename = false;
        let mut found_snippet = true; // true if no snippet requirement
        let mut actual_score = 0.0f32;

        for (doc_id, score, snippet) in &search_results {
            let doc_matches = vault
                .docs
                .iter()
                .any(|d| d.doc_id == *doc_id && d.filename.ends_with(expected_filename));

            if doc_matches {
                found_filename = true;
                actual_score = *score;
                if let Some(snip_needle) = expected_snippet {
                    found_snippet = snippet
                        .to_lowercase()
                        .contains(snip_needle.to_lowercase().as_str());
                }
                break;
            }
        }

        let score_ok = actual_score >= min_score || !found_filename;
        let pass = found_filename && found_snippet && score_ok;
        if pass {
            passed += 1;
        }

        let top_filename = search_results
            .first()
            .and_then(|(doc_id, _, _)| vault.docs.iter().find(|d| d.doc_id == *doc_id))
            .map(|d| d.filename.as_str())
            .unwrap_or("");

        results_detail.push(serde_json::json!({
            "query": query,
            "expected_filename": expected_filename,
            "pass": pass,
            "found_filename": found_filename,
            "found_snippet": found_snippet,
            "actual_score": actual_score,
            "min_score_required": min_score,
            "top_result": top_filename,
        }));
    }

    let total = canaries.len();
    let recall = if total == 0 {
        1.0f32
    } else {
        passed as f32 / total as f32
    };
    let recall_ok = recall >= threshold;

    let output = serde_json::json!({
        "ok": recall_ok,
        "total": total,
        "passed": passed,
        "failed": total - passed,
        "recall": recall,
        "threshold": threshold,
        "status": if recall_ok { "pass" } else { "fail" },
        "mode": match mode {
            SearchMode::Flat => "flat",
            SearchMode::Pageindex => "pageindex",
            SearchMode::Hybrid => "hybrid",
        },
        "canaries": results_detail,
    });
    println!("{}", serde_json::to_string_pretty(&output)?);

    if !recall_ok {
        // Use a custom exit code to signal threshold violation
        std::process::exit(2);
    }
    Ok(())
}

// ─── J: vault-to-vault sync ───────────────────────────────────────────────────

fn cmd_sync(
    from: &Path,
    to: &Path,
    room_filter: Option<&str>,
    pp_from: Option<String>,
    pp_to: Option<String>,
) -> Result<()> {
    let src = vault_load(from, pp_from.as_deref())?;

    // Destination vault: load if it exists, otherwise create a fresh one.
    let mut dst = if to.exists() {
        vault_load(to, pp_to.as_deref())?
    } else {
        Vault::default()
    };

    // Build filename → (doc_id, checksum) lookup for the destination.
    let dst_by_filename: std::collections::HashMap<String, (u64, String)> = dst
        .docs
        .iter()
        .filter(|d| !d.deleted)
        .map(|d| (d.filename.clone(), (d.doc_id, d.checksum.clone())))
        .collect();

    let mut added = 0usize;
    let mut updated = 0usize;
    let mut skipped = 0usize;

    let room_norm = room_filter.map(|r| r.to_lowercase());

    for src_doc in src.docs.iter().filter(|d| !d.deleted) {
        // Room filter
        if let Some(ref rm) = room_norm {
            if src_doc.room.to_lowercase() != *rm {
                continue;
            }
        }

        let action = if let Some((_, dst_checksum)) = dst_by_filename.get(&src_doc.filename) {
            if *dst_checksum == src_doc.checksum {
                // Same content — nothing to do.
                skipped += 1;
                println!(
                    "{}",
                    serde_json::to_string(&serde_json::json!({
                        "filename": src_doc.filename,
                        "action": "skipped",
                    }))?
                );
                continue;
            }
            // Same filename, different checksum → update
            updated += 1;
            "updated"
        } else {
            added += 1;
            "added"
        };

        // Upsert the doc into dst
        let mut cloned = src_doc.clone();

        // Copy the corresponding PageIndexTree (if any)
        let src_tree = src
            .pageindex_trees
            .iter()
            .find(|t| t.doc_id == src_doc.doc_id)
            .cloned();

        match action {
            "updated" => {
                // Replace existing doc in dst.docs
                if let Some(pos) = dst.docs.iter().position(|d| d.filename == cloned.filename) {
                    cloned.doc_id = dst.docs[pos].doc_id; // preserve dst's doc_id
                    dst.docs[pos] = cloned.clone();
                }
                // Replace PageIndexTree
                if let Some(tree) = src_tree {
                    let mut new_tree = tree;
                    new_tree.doc_id = cloned.doc_id;
                    if let Some(tp) = dst
                        .pageindex_trees
                        .iter()
                        .position(|t| t.doc_id == cloned.doc_id)
                    {
                        dst.pageindex_trees[tp] = new_tree;
                    } else {
                        dst.pageindex_trees.push(new_tree);
                    }
                }
            }
            _ => {
                // "added" — push doc + tree
                dst.docs.push(cloned.clone());
                if let Some(tree) = src_tree {
                    dst.pageindex_trees.push(tree);
                }
            }
        }

        // WAL entry in dst
        wal::append_entry(
            &mut dst,
            &format!("sync_{action}"),
            Some(serde_json::json!({
                "filename": cloned.filename,
                "source_vault": from.display().to_string(),
            })),
        );

        println!(
            "{}",
            serde_json::to_string(&serde_json::json!({
                "filename": src_doc.filename,
                "action": action,
                "doc_id": src_doc.doc_id,
            }))?
        );
    }

    vault_save(&dst, to, pp_to.as_deref())?;
    zvec::invalidate_sidecar(to);

    // Summary as a single compact line so callers can split on '\n' and parse
    println!(
        "{}",
        serde_json::to_string(&serde_json::json!({
            "ok": true,
            "added": added,
            "updated": updated,
            "skipped": skipped,
        }))?
    );
    Ok(())
}
