use anyhow::Result;
use clap::Parser;
use log::{debug, error, info, trace, warn};
use std::sync::{Arc, RwLock};
use tracing::Level;
use tracing_appender::rolling;
use tracing_subscriber::filter::filter_fn;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{self, EnvFilter, fmt};
use ragrig::{
    ChatAgentSpec, ChunkConfig, DocumentParser, DocumentParsers,
    DocumentType, EmbedderSpec, EpubParserBackend, FsSessionStore, GenerationParams,
    HistoryStrategy, HybridRrfRanker, LlmReranker, LogHistory, MmrDiversityRanker, PaperResult,
    RagAgent, RagrigError, Ranker, ScoredChunk, SessionId,
    SessionStore, SummaryHistory, Turn, TurnRole, WeightedFusionRanker,
    collect_documents, collect_documents_with_stats, download_and_ingest_url, embed_documents,
};
use ragrig::types::{ChatConfig, ContextSizeMode, EmbedConfig, EmbeddingProvider, FileHashEntry, MemoryConfig, ParseConfig, PdfParserBackend, Provider, RagrigConfig};
use ragrig::documents::{HashMetadata, get_document_file_hashes, get_changed_documents, update_file_hashes};
use ragrig::vector::{get_embeddings_file_path, remove_deleted_embeddings};
use ragrig::{parsers, store};
use rustyline::DefaultEditor;
use rustyline::error::ReadlineError;
use std::fs;
use std::io::{Write, stdout};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};

mod search;
use search::{search_arxiv, search_semantic_scholar};

// ── CLI parsing (binary-only) ──────────────────────────────────────────────

/// CLI arguments for the chat / generation sub-system.
#[derive(clap::Args, Debug)]
struct CliChatConfig {
    #[arg(long, default_value = "ollama")]
    pub provider: String,
    #[arg(short, long, default_value = "gemma2:latest")]
    pub model: String,
    #[arg(long, env = "DEEPSEEK_API_KEY")]
    pub deepseek_api_key: Option<String>,
    #[arg(long, default_value = "deepseek-v4-pro")]
    pub deepseek_model: String,
    #[arg(long)]
    pub temperature: Option<f64>,
    #[arg(long)]
    pub top_p: Option<f64>,
    #[arg(long)]
    pub max_tokens: Option<usize>,
    #[arg(long)]
    pub seed: Option<u64>,
    #[arg(long, default_value = "8192")]
    pub context_tokens: usize,
    #[arg(long, default_value = "auto")]
    pub context_size_mode: String,
    #[arg(long)]
    pub prompt_chat: Option<PathBuf>,
}

/// CLI arguments for the embedding / retrieval sub-system.
#[derive(clap::Args, Debug)]
struct CliEmbedConfig {
    #[arg(long = "embedding-provider", default_value = "ollama")]
    pub embedding_provider: String,
    #[arg(short = 'e', long = "embedding-model", default_value = "nomic-embed-text")]
    pub embedding_model: String,
    #[arg(long, default_value = "50")]
    pub top_k: usize,
    #[arg(long, default_value = "0.04")]
    pub similarity_threshold: f64,
}

/// CLI arguments for the document parsing / chunking sub-system.
#[derive(clap::Args, Debug)]
struct CliParseConfig {
    #[arg(long = "pdf-parser", default_value = "extract")]
    pub pdf_parser: String,
    #[arg(long)]
    pub sloppy_pdf: bool,
    #[arg(long, default_value = "1024")]
    pub chunk_size: usize,
    #[arg(long, default_value = "128")]
    pub chunk_overlap: usize,
}

/// CLI arguments for the memory / query-rewrite sub-system.
#[derive(clap::Args, Debug)]
struct CliMemoryConfig {
    #[arg(long = "memory-model", default_value = "qwen2.5:1.5b")]
    pub memory_model: String,
    #[arg(long)]
    pub prompt_rewrite: Option<PathBuf>,
}

/// CLI arguments — thin `clap` wrapper that converts into the library `RagrigConfig`.
#[derive(Parser, Debug)]
#[command(about = "Pure Rust local RAG — chunkedrs + rig + Ollama/DeepSeek/Fastembed")]
struct Cli {
    #[arg(short, long)]
    pub folder: PathBuf,
    /// Load a named profile (JSON) from `.ragrig/profiles/` before applying
    /// CLI overrides.  Use `/profile save <name>` in the REPL to create one.
    #[arg(short = 'p', long)]
    pub profile: Option<String>,
    #[arg(long, env = "SEMANTIC_SCHOLAR_API_KEY")]
    pub semantic_scholar_api_key: Option<String>,

    #[command(flatten)]
    pub chat: CliChatConfig,
    #[command(flatten)]
    pub embed: CliEmbedConfig,
    #[command(flatten)]
    pub parse: CliParseConfig,
    #[command(flatten)]
    pub memory: CliMemoryConfig,
}

impl From<CliChatConfig> for ChatConfig {
    fn from(c: CliChatConfig) -> Self {
        ChatConfig {
            provider: match c.provider.as_str() {
                "deepseek" => Provider::Deepseek,
                _ => Provider::Ollama,
            },
            model: c.model,
            deepseek_api_key: c.deepseek_api_key,
            deepseek_model: c.deepseek_model,
            params: GenerationParams {
                temperature: c.temperature,
                top_p: c.top_p,
                max_tokens: c.max_tokens,
                seed: c.seed,
            },
            context_tokens: c.context_tokens,
            context_size_mode: match c.context_size_mode.as_str() {
                "forced" => ContextSizeMode::Forced,
                _ => ContextSizeMode::Auto,
            },
            system_prompt_path: c.prompt_chat,
        }
    }
}

impl From<CliEmbedConfig> for EmbedConfig {
    fn from(c: CliEmbedConfig) -> Self {
        EmbedConfig {
            provider: match c.embedding_provider.as_str() {
                #[cfg(feature = "internal-embed")]
                "fastembed" => EmbeddingProvider::Fastembed,
                _ => EmbeddingProvider::Ollama,
            },
            model: c.embedding_model,
            top_k: c.top_k,
            similarity_threshold: c.similarity_threshold,
        }
    }
}

impl From<CliParseConfig> for ParseConfig {
    fn from(c: CliParseConfig) -> Self {
        ParseConfig {
            pdf_parser: match c.pdf_parser.as_str() {
                "unpdf" => PdfParserBackend::Unpdf,
                "sink" => PdfParserBackend::Sink,
                "internal" => PdfParserBackend::Internal,
                "vision" => PdfParserBackend::Vision,
                #[cfg(feature = "kreuzberg")]
                "kreuzberg" => PdfParserBackend::Kreuzberg,
                _ => PdfParserBackend::Extract,
            },
            sloppy_pdf: c.sloppy_pdf,
            chunk_size: c.chunk_size,
            chunk_overlap: c.chunk_overlap,
        }
    }
}

impl From<CliMemoryConfig> for MemoryConfig {
    fn from(c: CliMemoryConfig) -> Self {
        MemoryConfig {
            model: c.memory_model,
            rewrite_prompt_path: c.prompt_rewrite,
        }
    }
}

impl From<Cli> for RagrigConfig {
    fn from(c: Cli) -> Self {
        RagrigConfig {
            folder: c.folder,
            chat: c.chat.into(),
            embed: c.embed.into(),
            parse: c.parse.into(),
            memory: c.memory.into(),
            semantic_scholar_api_key: c.semantic_scholar_api_key,
        }
    }
}

// ── Session: carries all context between REPL cycles ──────────────────────

/// Persistent state shared across the REPL loop.
///
/// Holds trait-object agents for every pipeline stage — chat, memory,
/// and embeddings — plus the vector store, parser registry, and
/// conversation log.  Agents are `Box<dyn Trait>` so they can be
/// hot-swapped at runtime via `/chat`, `/memory`, and `/embed`.
///
/// # Construction
///
/// Sessions are built by [`bootstrap`], which takes a [`RagrigConfig`], creates
/// all agents from their spec enums, indexes documents, and opens or
/// creates the vector store.
///
/// ```ignore
/// let config = RagrigConfig::from(Cli::parse());
/// let session = bootstrap(config).await?;
/// // session enters the REPL loop
/// ```
struct Session {
    config: RagrigConfig,
    agent: RagAgent,
    embeddings_file_path: PathBuf,
    last_results: Vec<ScoredChunk>,
    last_search_results: Vec<PaperResult>,
    rl: DefaultEditor,
    history_path: PathBuf,
    http_client: reqwest::Client,
    prompt_memory: Vec<Turn>,
    /// Persistent session store — saves/loads full chat sessions.
    session_store: Box<dyn SessionStore>,
    /// Current session id for auto‑save.
    session_id: SessionId,
    /// History diffusion strategy — blends past session content into the chat prompt.
    /// `None` = no diffusion.  `Some(LogHistory)` = raw transcript of last session.
    /// Set via `/memory log` or `/memory summary`.
    history_strategy: Option<Box<dyn HistoryStrategy>>,
    /// Document parser registry — dispatches `.parse()` to the right
    /// backend based on file extension.  Built once at startup.
    doc_parsers: DocumentParsers,
    /// Currently active PDF parser backend.
    pdf_parser: PdfParserBackend,
    /// EPUB parser backend (currently only one option).
    epub_parser: EpubParserBackend,
    /// Controls context-overflow behaviour: `Auto` retries with fewer chunks,
    /// `Forced` treats overflow as a fatal error.
    context_size_forced: ContextSizeMode,
    /// Whether in-session transcript memory is enabled.  `false` when the
    /// user runs `/memory off` — turns are not accumulated and the
    /// transcript passed to the agent is always empty.
    memory_enabled: bool,
    /// Shared log-level string for the interactive (stderr) output.
    /// Read by the `FilterFn` on every log event, written by `/log`.
    /// Valid values: `"off"`, `"error"`, `"warn"`, `"info"`,
    /// `"ragrig=debug,info"`, `"ragrig=trace,info"`.
    log_level: Arc<RwLock<String>>,
}

// ── Command: parsed user input ────────────────────────────────────────────

/// Commands recognized by the REPL.  Plain text without a `/` prefix is
/// treated as a RAG query (`RagQuery`).
enum Command {
    #[allow(dead_code)]
    
    Download(String),
    GetPapers(String),
    Help,
    Scholar(String),
    SearchArxiv(String),
    ExtractRefs(String),
    Chat(String),
    Search(String),
    Embed(String),
    Memory(String),
    Hist(String),
    Parser(String),
    Profile(String),
    Prompt(String),
    Log(String),
    RagQuery(String),
    Unknown(String),
    Exit,
}

// ── Bootstrap: build agents, index documents, enter REPL ───────────────────

/// Linear initialisation of the entire RAG session.
///
/// 1. Builds the chat agent, embedding backend, and memory agent from
///    configuration via their `*Spec` factories.
/// 2. Scans the document folder, computes file hashes, and opens or
///    creates the vector store.
/// 3. Incrementally indexes new or changed documents (or builds from
///    scratch on first run).
/// 4. Constructs a [`Session`] carrying all state needed by the REPL.
///
/// This is the only place where the full pipeline is assembled —
/// downstream code just calls `session.execute(cmd).await`.
///
/// Filter the parser list to include the selected PDF backend as primary,
/// plus a panic-fallback (kreuzberg when available, otherwise sloppy-pdf).
fn filtered_parsers(pdf: &PdfParserBackend, _sloppy_pdf: bool) -> Vec<Box<dyn DocumentParser>> {
    #[allow(deprecated)]
    let selected_pdf = match pdf {
        #[cfg(feature = "kreuzberg")]
        PdfParserBackend::Kreuzberg => "kreuzberg",
        PdfParserBackend::Unpdf => "unpdf",
        PdfParserBackend::Sink => "pdfsink",
        PdfParserBackend::Extract => "pdf-extract",
        PdfParserBackend::Internal => "sloppy-pdf",
        PdfParserBackend::Vision => "vision-pdf",
    };
    let fallback = {
        #[cfg(feature = "kreuzberg")]
        { "kreuzberg" }
        #[cfg(not(feature = "kreuzberg"))]
        { "sloppy-pdf" }
    };
    let mut list = parsers::build_parsers();
    list.retain(|p| {
        if p.extensions().contains(&"pdf") {
            // Keep the selected parser plus the fallback (don't duplicate if selected == fallback).
            p.name() == selected_pdf || p.name() == fallback
        } else {
            true
        }
    });
    list
}

async fn bootstrap(
    config: RagrigConfig,
    log_level: Arc<RwLock<String>>,
) -> Result<Session> {
    // Build generation params from config.
    let chat_params = config.chat.params.clone();
    debug!(
        "Generation params: temperature={:?} top_p={:?} max_tokens={:?} seed={:?}",
        chat_params.temperature,
        chat_params.top_p,
        chat_params.max_tokens,
        chat_params.seed,
    );

    // Build the initial chat agent from config.
    let initial_spec = match config.chat.provider {
        Provider::Ollama => ChatAgentSpec::ollama(config.chat.model.clone(), chat_params.clone()),
        Provider::Deepseek => ChatAgentSpec::deepseek(
            config.chat.deepseek_model.clone(),
            config.chat.deepseek_api_key.clone(),
            chat_params.clone(),
        ),
    };
    let chat_agent = initial_spec.build()?;
    info!(
        "Chat: {} ({})  |  chunk_size={}, chunk_overlap={}",
        chat_agent.backend_name(),
        chat_agent.model_name(),
        config.parse.chunk_size,
        config.parse.chunk_overlap
    );

    // Build the initial embedding backend from config.
    let embedder_spec = match config.embed.provider {
        EmbeddingProvider::Ollama => EmbedderSpec::Ollama {
            model: config.embed.model.clone(),
        },
        #[cfg(feature = "internal-embed")]
        EmbeddingProvider::Fastembed => EmbedderSpec::Fastembed,
    };
    let embedder = embedder_spec.build()?;
    info!(
        "Embed: {} ({})",
        embedder.backend_name(),
        embedder.model_name()
    );

    let embeddings_file_path = get_embeddings_file_path(&config.folder);

    // Build the document parser registry (needed before store setup).
    let doc_parsers = DocumentParsers::new(filtered_parsers(&config.parse.pdf_parser, config.parse.sloppy_pdf));
    info!(
        "Parsers: {}  |  Active PDF: {:?}  |  Chunker: markdown-structural",
        doc_parsers.names().join(", "),
        config.parse.pdf_parser
    );

    let current_file_hashes = match get_document_file_hashes(&config.folder) {
        Ok(hashes) => {
            info!("Found {} document files with hashes.", hashes.len());
            hashes
        }
        Err(e) => {
            warn!("Could not compute file hashes: {}", e);
            Vec::new()
        }
    };

    // Open or create the vector store.
    let store = store::open_store(&config.folder).await?;

    // Determine whether we need to build from scratch or update incrementally.
    let chunk_cfg = ChunkConfig { size: config.parse.chunk_size, overlap: config.parse.chunk_overlap };
    if store.is_empty() {
        info!("No existing store found. Creating new one...");
        collect_documents(&*embedder, &doc_parsers, &config.folder, &chunk_cfg, &*store).await?;
    } else {
        info!(
            "Found existing store ({} chunks). Checking for changes...",
            store.len()
        );

        let mut stored_hashes: Vec<FileHashEntry> = Vec::new();
        if embeddings_file_path.exists() {
            match fs::read_to_string(&embeddings_file_path) {
                Ok(json) => {
                    if let Ok(metadata) = serde_json::from_str::<HashMetadata>(&json) {
                        stored_hashes = metadata.file_hashes;
                    }
                }
                Err(e) => warn!("Could not read hash metadata: {}", e),
            }
        }

        if stored_hashes.is_empty() {
            info!("No hash metadata found. Regenerating all embeddings...");
            for source in store.sources() {
                store.delete_by_source(&source.0).await?;
            }
            collect_documents(&*embedder, &doc_parsers, &config.folder, &chunk_cfg, &*store).await?;
        } else {
            let changed_files = get_changed_documents(&current_file_hashes, &stored_hashes);

            if !changed_files.is_empty() {
                info!("Found {} changed/new files.", changed_files.len());
                remove_deleted_embeddings(&*store, &current_file_hashes).await?;
                for (_doc_type, file_name) in &changed_files {
                    store.delete_by_source(file_name).await?;
                }
                let changed_with_types: Vec<(DocumentType, String)> = changed_files
                    .into_iter()
                    .map(|(doc_type, _)| {
                        let file_name = doc_type.file_name().to_string();
                        (doc_type, file_name)
                    })
                    .collect();
                embed_documents(&*embedder, &doc_parsers, &chunk_cfg, changed_with_types, &*store)
                    .await?;
                info!("Database updated.");
            } else {
                info!("No files have changed. Using existing embeddings.");
            }
        }
    }

    update_file_hashes(&current_file_hashes, &embeddings_file_path)?;

    let row_count = store.len();
    if row_count == 0 {
        return Err(anyhow::anyhow!(ragrig::RagrigError::NoDocumentsFound {
            folder: config.folder.to_string_lossy().into_owned(),
        }));
    }
    info!("Vector store initialized with {} total entries.", row_count);

    // Build the rewrite (memory) agent.
    let memory_spec = ChatAgentSpec::ollama(config.memory.model.clone(), chat_params.clone());
    let memory_agent = memory_spec.build()?;
    info!(
        "Memory: {} ({})",
        memory_agent.backend_name(),
        memory_agent.model_name()
    );

    // Build the RagAgent.
    let mut agent_builder = RagAgent::builder()
        .chat(chat_agent)
        .embed(embedder)
        .store(store)
        .rewriter(memory_agent)
        .context_tokens(config.chat.context_tokens)
        .top_k(config.embed.top_k)
        .similarity_threshold(config.embed.similarity_threshold);

    // Optional prompt overrides from CLI.
    if let Some(ref path) = config.chat.system_prompt_path {
        let prompt_text = fs::read_to_string(path)?;
        agent_builder = agent_builder.system_prompt(prompt_text);
    }
    if let Some(ref path) = config.memory.rewrite_prompt_path {
        let rewrite_text = fs::read_to_string(path)?;
        agent_builder = agent_builder.rewrite_prompt(rewrite_text);
    }

    let agent = agent_builder.build();

    let pdf_parser = config.parse.pdf_parser.clone();
    let context_size_forced = config.chat.context_size_mode;

    let mut rl = DefaultEditor::new()?;
    let history_path = config.folder.join(".ragrig_history");
    if history_path.exists()
        && let Err(e) = rl.load_history(&history_path) {
            warn!("Could not load history: {}", e);
        }

    info!("RAG System Online. Commands: /download <url> | /get <nums> | /help | exit");
    info!(
        "Ask questions based on your loaded documents (Arrow-Up for history, Ctrl+C to exit):"
    );

    // ── Session store (filesystem‑backed, one JSON file per session) ──
    let sessions_dir = config.folder.join(".ragrig").join("sessions");
    let session_store: Box<dyn SessionStore> =
        Box::new(FsSessionStore::new(sessions_dir)?);
    let session_id = SessionId(
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| format!("{}", d.as_secs()))
            .unwrap_or_else(|_| "0".to_string()),
    );
    info!("Session: {}", session_id.0);

    Ok(Session {
        config,
        agent,
        embeddings_file_path,
        last_results: Vec::new(),
        last_search_results: Vec::new(),
        rl,
        history_path,
        http_client: reqwest::Client::new(),
        prompt_memory: Vec::new(),
        session_store,
        session_id,
        history_strategy: None,
        doc_parsers,
        pdf_parser,
        epub_parser: EpubParserBackend::Epub,
        context_size_forced,
        memory_enabled: true,
        log_level,
    })
}

// ── Command dispatch ──────────────────────────────────────────────────────

impl From<&str> for Command {
    fn from(input: &str) -> Self {
        let input = input.trim();

        // Non‑slash input is always a RAG query.
        if !input.starts_with('/') {
            return Command::RagQuery(input.to_string());
        }

        if input == "exit" || input == "quit"
            || input == "/exit" || input == "/bye"
        {
            return Command::Exit;
        }
        if input == "/help" {
            return Command::Help;
        }

        // Helper: safe substring after a known prefix.
        let after = |prefix: &str| -> &str {
            if input.len() > prefix.len() + 1 {
                &input[prefix.len()..]
            } else {
                ""
            }
        };

        if input.starts_with("/download ") {
            let url = strip_ansi(after("/download ")).trim().to_string();
            return Command::Download(url);
        }
        if input.starts_with("/get ") {
            return Command::GetPapers(after("/get ").trim().to_string());
        }
        if input.starts_with("/scholar ") {
            return Command::Scholar(after("/scholar ").trim().to_string());
        }
        if input.eq_ignore_ascii_case("/scholar") {
            return Command::Scholar(String::new());
        }
        if input.eq_ignore_ascii_case("/search") {
            return Command::Search(String::new());
        }
        if input.starts_with("/search ") {
            return Command::Search(after("/search ").trim().to_string());
        }
        if input.starts_with("/arxiv ") {
            return Command::SearchArxiv(after("/arxiv ").trim().to_string());
        }
        if input.starts_with("/refs") {
            return Command::ExtractRefs(after("/refs").trim().to_string());
        }
        if input.starts_with("/chat") {
            return Command::Chat(after("/chat").trim().to_string());
        }
        if input.starts_with("/embed") {
            return Command::Embed(after("/embed").trim().to_string());
        }
        if input.starts_with("/memory") {
            return Command::Memory(after("/memory").trim().to_string());
        }
        if input.starts_with("/hist") {
            return Command::Hist(after("/hist").trim().to_string());
        }
        if input.starts_with("/prompt") {
            return Command::Prompt(after("/prompt").trim().to_string());
        }
        if input.starts_with("/log") {
            return Command::Log(after("/log").trim().to_string());
        }
        if input.starts_with("/parser") {
            return Command::Parser(after("/parser").trim().to_string());
        }
        if input.starts_with("/profile") {
            return Command::Profile(after("/profile").trim().to_string());
        }

        // Any other slash‑prefixed input is an unknown command, not a query.
        Command::Unknown(input.to_string())
    }
}

impl Session {
    /// Auto‑save the current session to the store.
    async fn auto_save(&self) -> Result<()> {
        let config = ragrig::SessionConfig {
            chat_backend: self.agent.chat_agent().backend_name().to_string(),
            chat_model: self.agent.chat_agent().model_name().to_string(),
            embed_backend: self.agent.embedder().backend_name().to_string(),
            embed_model: self.agent.embedder().model_name().to_string(),
            memory_strategy: if self.agent.rewriter().is_some() {
                ragrig::MemoryStrategyKind::Rewrite
            } else {
                ragrig::MemoryStrategyKind::Off
            },
            memory_backend: String::new(),
            memory_model: String::new(),
            top_k: self.agent.top_k(),
            similarity_threshold: self.agent.similarity_threshold(),
            model_ctx_tokens: self.agent.context_tokens(),
        };
        let data = ragrig::SessionData {
            id: self.session_id.clone(),
            created: std::time::UNIX_EPOCH,
            updated: std::time::SystemTime::now(),
            config,
            turns: self.prompt_memory.clone(),
        };
        self.session_store.save(&data).await
    }

    async fn execute(&mut self, cmd: Command) -> Result<()> {
        match cmd {
            Command::Download(url) => self.cmd_download(&url).await,
            Command::GetPapers(range) => self.cmd_get_papers(&range).await,
            Command::Help => {
                self.cmd_help();
                Ok(())
            }
            Command::Scholar(q) => self.cmd_search_scholar(&q).await,
            Command::SearchArxiv(q) => self.cmd_search_arxiv(&q).await,
            Command::ExtractRefs(filter) => self.cmd_extract_refs(&filter).await,
            Command::Chat(args_str) => self.cmd_chat(&args_str).await,
            Command::Search(args) => self.cmd_search(&args).await,
            Command::Embed(args_str) => self.cmd_embed(&args_str).await,
            Command::Memory(args_str) => self.cmd_memory(&args_str).await,
            Command::Hist(args_str) => self.cmd_hist(&args_str).await,
            Command::Prompt(args_str) => self.cmd_prompt(&args_str).await,
            Command::Log(args_str) => self.cmd_log(&args_str).await,
            Command::Parser(args_str) => self.cmd_parser(&args_str).await,
            Command::Profile(args_str) => self.cmd_profile(&args_str).await,
            Command::RagQuery(q) => self.cmd_rag_query(&q).await,
            Command::Unknown(cmd) => {
                println!("Unknown command: '{}'", cmd);
                Ok(())
            }
            Command::Exit => Ok(()),
        }
    }

    // ── /download <url> ───────────────────────────────────────────────

    async fn cmd_download(&mut self, url: &str) -> Result<()> {
        if url.is_empty() {
            println!("Usage: /download <url>");
            return Ok(());
        }
        info!("Downloading and ingesting: {} ...", url);
        debug!("URL bytes: {:?}", url.as_bytes());
        match download_and_ingest_url(
            self.agent.embedder(),
            &self.doc_parsers,
            &self.config.folder,
            &ChunkConfig { size: self.config.parse.chunk_size, overlap: self.config.parse.chunk_overlap },
            &self.http_client,
            self.agent.store(),
            url,
        )
        .await
        {
            Ok(summary) => {
                println!("{}", summary);
                update_file_hashes(
                    &get_document_file_hashes(&self.config.folder).unwrap_or_default(),
                    &self.embeddings_file_path,
                )?;
            }
            Err(e) => RagrigError::log_or(&e, "Download failed"),
        }
        Ok(())
    }

    // ── /get <nums> ──────────────────────────────────────────────────

    async fn cmd_get_papers(&mut self, range_str: &str) -> Result<()> {
        if self.last_search_results.is_empty() {
            println!("No search results available. Run /scholar or /arxiv first.");
            return Ok(());
        }
        if range_str.is_empty() {
            println!("Usage: /get 1,2,3-4,8");
            return Ok(());
        }

        let indices = match parse_number_range(range_str) {
            Ok(ids) => ids,
            Err(e) => {
                println!("Invalid range: {}", e);
                return Ok(());
            }
        };

        let mut downloaded = 0;
        let mut failed = 0;
        for idx in &indices {
            if *idx >= self.last_search_results.len() {
                println!(
                    "  Skipping [{}]: out of range (max {})",
                    idx + 1,
                    self.last_search_results.len()
                );
                failed += 1;
                continue;
            }
            let paper = &self.last_search_results[*idx];
            let url = strip_ansi(&paper.best_pdf_url());

            if url.is_empty() {
                println!(
                    "  [{:2}] {} — no download URL available",
                    idx + 1,
                    paper.title
                );
                failed += 1;
                continue;
            }

            print!("  [{:2}] {} ... ", idx + 1, paper.title);
            stdout().flush()?;
            match download_and_ingest_url(
                self.agent.embedder(),
                &self.doc_parsers,
                &self.config.folder,
                &ChunkConfig { size: self.config.parse.chunk_size, overlap: self.config.parse.chunk_overlap },
                &self.http_client,
                self.agent.store(),
                &url,
            )
            .await
            {
                Ok(_) => {
                    println!("done");
                    downloaded += 1;
                    update_file_hashes(
                        &get_document_file_hashes(&self.config.folder).unwrap_or_default(),
                        &self.embeddings_file_path,
                    )?;
                }
                Err(e) => {
                    error!("Paper download failed: {}", e);
                    println!("failed: {}", e);
                    failed += 1;
                }
            }
        }

        println!(
            "Download complete: {} added, {} failed, {} skipped.",
            downloaded,
            failed,
            indices.len().saturating_sub(downloaded + failed)
        );

        Ok(())
    }

    // ── /help ────────────────────────────────────────────────────────

    fn cmd_help(&self) {
        println!("/download <url>  — download and ingest a PDF into the document pool");
        println!("/scholar <q>   — search Semantic Scholar (free API key for higher limits)");
        println!("/arxiv <q>      — search arXiv (no API key needed, no rate limits)");
        println!("/search         — show / adjust search parameters (topk, threshold, rank)");
        println!("/get 1,2,3-4    — download papers by number from last search");
        println!(
            "/refs [topic]   — extract references from last query results (optionally filtered by topic)"
        );
        println!("/chat <backend> [model] [api_key] | context <N> — hot-swap chat engine or adjust context window");
        println!("/embed <backend> [model] | purge | index — hot-swap embedding backend");
        println!("/memory <backend> [model] [key] | transcript | log | summary | off | purge — hot-swap memory + history diffusion");
        println!("/hist [list | load <id> | delete <id>] — manage saved sessions");
        println!("/prompt chat|rewrite <file> | reset — load custom system prompts");
        println!("/log [off|error|warn|info|debug|trace] — show or change log verbosity");
        println!(
            "/parser pdf unpdf|sink|extract|internal | epub epub — hot-swap parser per format"
        );
        println!("/profile save|show|load|list [name] — manage configuration profiles");
        println!("exit / quit     — end the session");
    }

    // ── /scholar <q> ──────────────────────────────────────────────────

    async fn cmd_search_scholar(&mut self, q: &str) -> Result<()> {
        if q.is_empty() {
            println!("Usage: /scholar <query>");
            return Ok(());
        }
        info!("Searching Semantic Scholar for: {} ...", q);
        match search_semantic_scholar(self.config.semantic_scholar_api_key.as_deref(), &self.http_client, q, 20).await {
            Ok(papers) if papers.is_empty() => {
                println!("No papers found.");
            }
            Ok(papers) => {
                println!("\nResults:");
                for (i, p) in papers.iter().enumerate() {
                    println!(
                        "  [{:2}] {} — {}{}",
                        i + 1,
                        p.title,
                        p.format_authors(),
                        p.format_year()
                    );
                    let url = p.best_pdf_url();
                    if !url.is_empty() {
                        println!("       /download {}", url);
                    }
                }
                self.last_search_results = papers.clone();
                println!("\nUse /download <url> to ingest any paper.");
            }
            Err(e) => error!("Scholar search error: {}", e),
        }
        Ok(())
    }

    // ── /arxiv <q> ───────────────────────────────────────────────────

    async fn cmd_search_arxiv(&mut self, q: &str) -> Result<()> {
        if q.is_empty() {
            println!("Usage: /arxiv <query>");
            return Ok(());
        }
        info!("Searching arXiv for: {} ...", q);
        match search_arxiv(&self.http_client, q, 20).await {
            Ok(papers) if papers.is_empty() => {
                println!("No papers found.");
            }
            Ok(papers) => {
                println!("\nResults (arXiv):");
                for (i, p) in papers.iter().enumerate() {
                    println!(
                        "  [{:2}] {} — {}{}",
                        i + 1,
                        p.title,
                        p.format_authors(),
                        p.format_year()
                    );
                    let url = p.best_pdf_url();
                    if !url.is_empty() {
                        println!("       /download {}", url);
                    }
                }
                self.last_search_results = papers;
                println!("\nUse /download <url> to ingest any paper.");
            }
            Err(e) => error!("arXiv search error: {}", e),
        }
        Ok(())
    }

    // ── /search [topk|threshold] ─────────────────────────────────────

    async fn cmd_search(&mut self, args_str: &str) -> Result<()> {
        let mut parts = args_str.split_whitespace();
        let sub = parts.next().unwrap_or("");

        if sub.is_empty() {
            println!("Vector search parameters:");
            println!("  top-k:     {}  (change: /search topk <N>)", self.agent.top_k());
            println!("  threshold: {:.3}  (change: /search threshold <F>)", self.agent.similarity_threshold());
            if let Some(name) = self.agent.ranker_name() {
                println!("  ranker:    {}  (change: /search rank <name> [key value]*)", name);
            } else {
                println!("  ranker:    (opaque — store backend handles ranking)");
            }
            return Ok(());
        }

        if sub == "topk" {
            match parts.next().and_then(|s| s.parse::<usize>().ok()) {
                Some(n) if n > 0 => {
                    self.agent.set_top_k(n);
                    info!("Top-k set to {}.", n);
                }
                _ => println!("Usage: /search topk <N>  (current: {})", self.agent.top_k()),
            }
            return Ok(());
        }

        if sub == "threshold" {
            match parts.next().and_then(|s| s.parse::<f64>().ok()) {
                Some(f) if f >= 0.0 => {
                    self.agent.set_similarity_threshold(f);
                    info!("Similarity threshold set to {:.3}.", f);
                }
                _ => println!(
                    "Usage: /search threshold <F>  (current: {:.3})",
                    self.agent.similarity_threshold()
                ),
            }
            return Ok(());
        }

        if sub == "rank" {
            let name = parts.next().unwrap_or("");
            if name.is_empty() {
                if let Some(current) = self.agent.ranker_name() {
                    println!("Current ranker: {}", current);
                    println!("Available: RRFFusion, Cosine, BM25, Weighted, MMR, LLM");
                    println!("Usage: /search rank <name> [key value]*");
                    println!("  /search rank RRFFusion [k <F>]");
                    println!("  /search rank Cosine");
                    println!("  /search rank BM25");
                    println!("  /search rank Weighted [alpha <F>]");
                    println!("  /search rank MMR [lambda <F>] [inner <name>]");
                    println!("  /search rank LLM [inner <name>] [provider <p>] [model <m>]");
                } else {
                    println!("This store backend handles ranking internally.");
                }
                return Ok(());
            }

            // Collect remaining key-value pairs.
            let mut params: Vec<(&str, &str)> = Vec::new();
            while let (Some(key), Some(val)) = (parts.next(), parts.next()) {
                params.push((key, val));
            }

            // Helper: construct a default-initialised ranker by name.
            fn build_default_ranker(name: &str) -> Option<Box<dyn Ranker>> {
                match name {
                    "RRFFusion" | "rrffusion" => Some(Box::new(HybridRrfRanker::default())),
                    "Cosine" | "cosine" => Some(Box::new(WeightedFusionRanker { alpha: 1.0 })),
                    "BM25" | "bm25" => Some(Box::new(WeightedFusionRanker { alpha: 0.0 })),
                    "Weighted" | "weighted" => Some(Box::new(WeightedFusionRanker::default())),
                    _ => None,
                }
            }

            let ranker: Box<dyn Ranker> = match name {
                "RRFFusion" | "rrffusion" => {
                    let mut k: f64 = 60.0;
                    for (key, val) in &params {
                        if *key == "k" {
                            k = val.parse::<f64>().unwrap_or(60.0);
                        }
                    }
                    Box::new(HybridRrfRanker { k })
                }
                "Cosine" | "cosine" => {
                    Box::new(WeightedFusionRanker { alpha: 1.0 })
                }
                "BM25" | "bm25" => {
                    Box::new(WeightedFusionRanker { alpha: 0.0 })
                }
                "Weighted" | "weighted" => {
                    let mut alpha: f64 = 0.5;
                    for (key, val) in &params {
                        if *key == "alpha" {
                            alpha = val.parse::<f64>().unwrap_or(0.5);
                        }
                    }
                    Box::new(WeightedFusionRanker { alpha })
                }
                "MMR" | "mmr" => {
                    let mut lambda: f64 = 0.5;
                    let mut inner_name: Option<String> = None;
                    for (key, val) in &params {
                        if *key == "lambda" {
                            lambda = val.parse::<f64>().unwrap_or(0.5);
                        } else if *key == "inner" {
                            inner_name = Some(val.to_string());
                        }
                    }
                    let inner = match inner_name.as_deref() {
                        Some(n) => match build_default_ranker(n) {
                            Some(r) => r,
                            None => {
                                println!("Unknown inner ranker: '{}'", n);
                                return Ok(());
                            }
                        },
                        None => {
                            // Default inner: use current ranker name, fall back to RRFFusion.
                            let cur = self.agent.ranker_name().unwrap_or_default();
                            build_default_ranker(&cur).unwrap_or_else(|| {
                                Box::new(HybridRrfRanker::default())
                            })
                        }
                    };
                    Box::new(MmrDiversityRanker { lambda, inner })
                }
                "LLM" | "llm" => {
                    let mut inner_name: Option<String> = None;
                    let mut provider: String = "ollama".into();
                    let mut model: String = self.config.memory.model.clone();
                    let mut api_key: Option<String> = None;
                    for (key, val) in &params {
                        match *key {
                            "inner" => inner_name = Some(val.to_string()),
                            "provider" => provider = val.to_string(),
                            "model" => model = val.to_string(),
                            "key" => api_key = Some(val.to_string()),
                            _ => {}
                        }
                    }
                    let inner = match inner_name.as_deref() {
                        Some(n) => match build_default_ranker(n) {
                            Some(r) => r,
                            None => {
                                println!("Unknown inner ranker: '{}'", n);
                                return Ok(());
                            }
                        },
                        None => Box::new(HybridRrfRanker::default()),
                    };
                    let spec = match ChatAgentSpec::parse(
                        &provider, Some(&model), api_key.as_deref(), None,
                    ) {
                        Ok(s) => s,
                        Err(e) => {
                            println!("Could not build LLM reranker generator: {e}");
                            return Ok(());
                        }
                    };
                    let generator: Box<dyn ragrig::Generator> = match spec.try_into() {
                        Ok(g) => g,
                        Err(e) => {
                            println!("Could not build LLM reranker generator: {e}");
                            return Ok(());
                        }
                    };
                    info!(
                        "LLM reranker using {} ({})",
                        generator.backend_name(),
                        generator.model_name()
                    );
                    Box::new(LlmReranker {
                        generator,
                        inner,
                        prompt_template: String::new(),
                    })
                }
                _ => {
                    println!(
                        "Unknown ranker: '{}'. Available: RRFFusion, Cosine, BM25, Weighted, MMR, LLM",
                        name
                    );
                    return Ok(());
                }
            };

            match self.agent.set_ranker(ranker) {
                Ok(()) => {
                    let current = self.agent.ranker_name().unwrap_or_default();
                    info!("Ranker set to {}.", current);
                }
                Err(e) => {
                    println!("Could not set ranker: {}", e);
                }
            }
            return Ok(());
        }

        println!(
            "Unknown subcommand: '{}'. Use /search, /search topk <N>, /search threshold <F>, or /search rank <name>.",
            sub
        );
        Ok(())
    }

    // ── /refs [topic] ────────────────────────────────────────────────

    async fn cmd_extract_refs(&mut self, filter: &str) -> Result<()> {
        if self.last_results.is_empty() {
            println!("No previous query results. Ask a question first, then use /refs.");
            return Ok(());
        }

        let filter_hint = if filter.is_empty() {
            String::new()
        } else {
            format!(
                " Focus specifically on references related to: \"{}\".",
                filter
            )
        };

        let mut context = String::new();
        for (i, sc) in self.last_results.iter().take(5).enumerate() {
            context.push_str(&format!(
                "[Document {} | Source: {}]\n{}\n\n",
                i + 1,
                sc.chunk.source_file,
                sc.chunk.text
            ));
        }

        let extract_prompt = format!(
            "Extract all academic paper references (cited works with title, authors, year) from the documents below.{}\n\n\
            Return ONLY a numbered list. For each reference, include:\n\
            - Title of the cited paper\n\
            - Authors (last name of first author + et al. if multiple)\n\
            - Year\n\
            - If an arXiv ID or DOI is visible, include it as a URL.\n\n\
            Documents:\n{}",
            filter_hint, context
        );

        info!("Extracting references...");
        print!("Assistant > ");
        stdout().flush()?;

        let got_response = AtomicBool::new(false);
        match self
            .agent
            .chat_agent()
            .generate_stream(&extract_prompt, &|text: String| {
                print!("{}", text);
                let _ = stdout().flush();
                got_response.store(true, Ordering::Relaxed);
            })
            .await
        {
            Ok(()) => {}
            Err(e) => RagrigError::log_or(&e, "Reference extraction failed"),
        }
        if !got_response.load(Ordering::Relaxed) {
            println!("(no references found)");
        }
        println!();

        Ok(())
    }

    // ── /chat <backend> [model] [api_key] ─────────────────────────────

    /// Hot-swap the chat agent or adjust the context budget.
    ///
    /// # Agent swap
    ///
    /// Builds a new `Box<dyn Generator>` from a `ChatAgentSpec` and
    /// replaces the session's chat agent without touching the vector
    /// store, conversation memory, or document index.
    ///
    /// ```text
    /// /chat ollama gemma2:latest         # switch to local model
    /// /chat deepseek deepseek-chat sk-…  # switch to cloud
    /// ```
    ///
    /// # Context budget
    ///
    /// `context <N>` adjusts the prompt-truncation budget in tokens
    /// without changing the chat engine.
    ///
    /// ```text
    /// /chat context 4096                 # shrink for 4K-window models
    /// /chat context 131072               # expand for cloud models
    /// ```
    async fn cmd_chat(&mut self, args_str: &str) -> Result<()> {
        let mut parts = args_str.split_whitespace();
        let backend = parts.next().unwrap_or("");
        if backend == "context" {
            match parts.next().and_then(|s| s.parse::<usize>().ok()) {
                Some(n) if n > 0 => {
                    self.agent.set_context_tokens(n);
                    let ctx = self.agent.context_tokens();
                    info!(
                        "Context window set to {} tokens (prompt budget ~{} chars).",
                        ctx,
                        (ctx.saturating_sub(1024)).saturating_mul(3)
                    );
                }
                _ => println!(
                    "Usage: /chat context <tokens>  (current: {})",
                    self.agent.context_tokens()
                ),
            }
            return Ok(());
        }
        if backend == "temperature" {
            match parts.next().and_then(|s| s.parse::<f64>().ok()) {
                Some(t) if t >= 0.0 => {
                    self.rebuild_chat_with_param(|p| p.temperature = Some(t));
                }
                _ => println!(
                    "Usage: /chat temperature <F>  (0.0 = deterministic)"
                ),
            }
            return Ok(());
        }
        if backend == "top_p" {
            match parts.next().and_then(|s| s.parse::<f64>().ok()) {
                Some(p) if (0.0..=1.0).contains(&p) => {
                    self.rebuild_chat_with_param(|gp| gp.top_p = Some(p));
                }
                _ => println!(
                    "Usage: /chat top_p <F>  (0.0–1.0)"
                ),
            }
            return Ok(());
        }
        if backend == "max_tokens" {
            match parts.next().and_then(|s| s.parse::<usize>().ok()) {
                Some(n) if n > 0 => {
                    self.rebuild_chat_with_param(|p| p.max_tokens = Some(n));
                }
                _ => println!(
                    "Usage: /chat max_tokens <N>"
                ),
            }
            return Ok(());
        }
        if backend == "seed" {
            match parts.next().and_then(|s| s.parse::<u64>().ok()) {
                Some(s) => {
                    self.rebuild_chat_with_param(|p| p.seed = Some(s));
                }
                _ => println!(
                    "Usage: /chat seed <N>"
                ),
            }
            return Ok(());
        }
        if backend.is_empty() {
            println!(
                "Chat: {} ({}) — context window: {} tokens",
                self.agent.chat_agent().backend_name(),
                self.agent.chat_agent().model_name(),
                self.agent.context_tokens(),
            );
            println!("Usage: /chat <backend> [model] [api_key]  |  context <N>  |  temperature <F>  |  top_p <F>  |  max_tokens <N>  |  seed <N>");
            println!("  backends: ollama, deepseek");
            return Ok(());
        }

        let model = parts.next();
        let api_key = parts.next();

        let spec = match ChatAgentSpec::parse(backend, model, api_key, None) {
            Ok(s) => s,
            Err(e) => {
                error!("Chat agent spec parse error: {}", e);
                return Ok(());
            }
        };

        match spec.build() {
            Ok(new_agent) => {
                let old_backend = self.agent.chat_agent().backend_name();
                let old_model = self.agent.chat_agent().model_name().to_string();
                self.agent.set_chat_agent(new_agent);
                info!(
                    "Chat agent swapped: {} ({}) → {} ({})",
                    old_backend,
                    old_model,
                    self.agent.chat_agent().backend_name(),
                    self.agent.chat_agent().model_name()
                );
            }
            Err(e) => RagrigError::log_or(&e, "Failed to build chat agent"),
        }
        Ok(())
    }

    // ── /embed <backend> [model] ──────────────────────────────────────
    /// Rebuild the current chat agent with a modified `GenerationParams`, keeping
    /// the same backend and model.
    fn rebuild_chat_with_param(&mut self, f: impl FnOnce(&mut GenerationParams)) {
        let current = self.agent.chat_agent();
        let backend = current.backend_name();
        let model = current.model_name();

        // We need to reconstruct the spec with updated params.
        // Since we can't introspect the existing generator's params,
        // we start fresh and apply the mutation.
        let mut params = GenerationParams::default();
        f(&mut params);

        let spec = match ChatAgentSpec::parse(backend, Some(model), None, Some(params)) {
            Ok(spec) => spec,
            Err(e) => {
                error!("Cannot rebuild unknown backend: {}", e);
                return;
            }
        };

        match spec.build() {
            Ok(new_agent) => {
                self.agent.set_chat_agent(new_agent);
                let agent = self.agent.chat_agent();
                info!(
                    "Chat params updated: {} ({})",
                    agent.backend_name(),
                    agent.model_name()
                );
            }
            Err(e) => RagrigError::log_or(&e, "Failed to rebuild chat agent"),
        }
    }

    // ── /embed <backend> [model] ──────────────────────────────────────

    async fn cmd_embed(&mut self, args_str: &str) -> Result<()> {
        let mut parts = args_str.split_whitespace();
        let backend = parts.next().unwrap_or("");
        if backend.is_empty() {
            println!(
                "Embed: {} ({}) — top‑k: {}, threshold: {}",
                self.agent.embedder().backend_name(),
                self.agent.embedder().model_name(),
                self.agent.top_k(),
                self.agent.similarity_threshold(),
            );
            println!(
                "Usage: /embed <backend> [model]  |  purge  |  index  |  topk <N>  |  threshold <F>"
            );
            println!(
                "  backends: {}",
                EmbedderSpec::available_backends().join(", ")
            );
            return Ok(());
        }

        if backend.eq_ignore_ascii_case("purge") {
            let store = self.agent.store();
            let count = store.len();
            let sources: Vec<_> = store.sources().into_iter().collect();
            for source in &sources {
                store.delete_by_source(&source.0).await?;
            }
            info!(
                "Vector store purged ({} chunks across {} source files).",
                count,
                sources.len()
            );
            return Ok(());
        }

        if backend.eq_ignore_ascii_case("index") {
            info!(
                "Re-indexing all documents in {}...",
                self.config.folder.display()
            );
            let chunk_cfg = ChunkConfig { size: self.config.parse.chunk_size, overlap: self.config.parse.chunk_overlap };
            let stats = collect_documents_with_stats(self.agent.embedder(), &self.doc_parsers, &self.config.folder, &chunk_cfg, self.agent.store()).await?;
            info!(
                "Re-indexing complete. Store size: {} chunks.",
                self.agent.store().len()
            );
            // Print per-file result table.
            let ok_count = stats.iter().filter(|s| s.ok).count();
            let fail_count = stats.len() - ok_count;
            let total_chunks: usize = stats.iter().map(|s| s.chunks).sum();
            let total_chars: usize = stats.iter().map(|s| s.chars).sum();
            let total_kb: u64 = stats.iter().map(|s| s.file_size_kb).sum();
            println!(
                "\n{} files processed ({} ok, {} failed), {} chunks, {} chars, {} KB total.\n",
                stats.len(), ok_count, fail_count, total_chunks, total_chars, total_kb
            );
            if !stats.is_empty() {
                println!(
                    "{:<44} {:>6} {:>7} {:>8} {:>6}",
                    "File", "KB", "Chunks", "Chars", "Avg/Ch"
                );
                println!("{}", "─".repeat(78));
                for s in &stats {
                    let name = if s.file_name.0.len() > 42 {
                        format!("{}…", &s.file_name.0[..41])
                    } else {
                        s.file_name.0.clone()
                    };
                    if s.ok {
                        println!(
                            "{:<44} {:>6} {:>7} {:>8} {:>6.0}",
                            name, s.file_size_kb, s.chunks, s.chars, s.avg_chars_per_chunk()
                        );
                    } else {
                        println!(
                            "{:<44} {:>6} {:>7} {:>8} {:>6}  FAIL",
                            name, s.file_size_kb, "—", "—", "—"
                        );
                    }
                }
            }
            return Ok(());
        }

        if backend == "topk" {
            match parts.next().and_then(|s| s.parse::<usize>().ok()) {
                Some(n) if n > 0 => {
                    self.agent.set_top_k(n);
                    info!("Top-k set to {}.", n);
                }
                _ => println!("Usage: /embed topk <N>  (current: {})", self.agent.top_k()),
            }
            return Ok(());
        }

        if backend == "threshold" {
            match parts.next().and_then(|s| s.parse::<f64>().ok()) {
                Some(f) if f >= 0.0 => {
                    self.agent.set_similarity_threshold(f);
                    info!("Similarity threshold set to {:.3}.", f);
                }
                _ => println!(
                    "Usage: /embed threshold <F>  (current: {:.3})",
                    self.agent.similarity_threshold()
                ),
            }
            return Ok(());
        }

        let model = parts.next();

        let spec = match EmbedderSpec::parse(backend, model) {
            Ok(s) => s,
            Err(e) => {
                error!("Embed spec parse error: {}", e);
                return Ok(());
            }
        };

        match spec.build() {
            Ok(new_embedder) => {
                let old_backend = self.agent.embedder().backend_name();
                let old_model = self.agent.embedder().model_name().to_string();
                self.agent.set_embedder(new_embedder);
                info!(
                    "Embedder swapped: {} ({}) → {} ({})",
                    old_backend,
                    old_model,
                    self.agent.embedder().backend_name(),
                    self.agent.embedder().model_name()
                );
            }
            Err(e) => RagrigError::log_or(&e, "Failed to build embedder"),
        }
        Ok(())
    }

    // ── /hist [list | load <id> | delete <id>] ─────────────────────

    async fn cmd_hist(&mut self, args_str: &str) -> Result<()> {
        let arg = args_str.trim();
        if arg.is_empty() || arg == "list" {
            match self.session_store.list().await {
                Ok(manifests) if manifests.is_empty() => {
                    println!("No saved sessions.");
                }
                Ok(manifests) => {
                    println!("{} saved session(s):", manifests.len());
                    for m in &manifests {
                        println!(
                            "  {} — {} turns — {:?}",
                            m.id.0, m.turn_count, m.created
                        );
                    }
                }
                Err(e) => error!("Error listing sessions: {}", e),
            }
            return Ok(());
        }
        let mut parts = arg.split_whitespace();
        let sub = parts.next().unwrap_or("");
        let id = parts.next().unwrap_or("");
        match sub {
            "load" if !id.is_empty() => {
                let sid = SessionId(id.to_string());
                match self.session_store.load(&sid).await {
                    Ok(Some(session)) => {
                        self.prompt_memory = session.turns;
                        info!(
                            "Loaded session {} ({} turns).",
                            id,
                            self.prompt_memory.len()
                        );
                    }
                    Ok(None) => println!("Session '{}' not found.", id),
                    Err(e) => error!("Error loading session: {}", e),
                }
            }
            "delete" if !id.is_empty() => {
                let sid = SessionId(id.to_string());
                match self.session_store.delete(&sid).await {
                    Ok(()) => info!("Deleted session '{}'.", id),
                    Err(e) => error!("Error deleting session: {}", e),
                }
            }
            _ => {
                println!("Usage: /hist [list | load <id> | delete <id>]");
            }
        }
        Ok(())
    }

    // ── /memory <backend> [model] [api_key] | off ───────────────────

    async fn cmd_memory(&mut self, args_str: &str) -> Result<()> {
        let arg = args_str.trim();
        if arg.is_empty() {
            // ── Current config ──────────────────────────────────────
            let mem = if self.agent.rewriter().is_some() { "rewrite" } else { "off" };
            let diff = match &self.history_strategy {
                Some(s) => s.name(),
                None => "off",
            };
            println!(
                "Memory: {} — {} turns  |  history diffusion: {}",
                mem,
                self.prompt_memory.len(),
                diff,
            );
            // ── Usage ──────────────────────────────────────────────
            println!(
                "Usage: /memory <backend> [model] [api_key]  |  transcript  |  log  |  summary  |  off  |  purge"
            );
            println!("  backends: ollama, deepseek");
            println!("  modes:    transcript — raw memory, no query rewriting");
            println!("            log       — enable history diffusion (raw last session)");
            println!("            summary   — enable history diffusion (LLM summarisation)");
            return Ok(());
        }

        if arg.eq_ignore_ascii_case("purge") {
            let count = self.prompt_memory.len();
            self.prompt_memory.clear();
            if let Some(rewriter) = self.agent.rewriter()
                && let Err(e) = rewriter.clear_memory().await {
                    warn!("Memory clear failed: {}", e);
                }
            info!("Conversation memory purged ({} entries removed).", count);
            return Ok(());
        }

        if arg.eq_ignore_ascii_case("off") || arg.eq_ignore_ascii_case("none") {
            let was = self.agent.rewriter().is_some();
            self.agent.set_rewriter(None);
            self.memory_enabled = false;
            let cleared = self.prompt_memory.len();
            self.prompt_memory.clear();
            if was {
                info!("Memory disabled ({} turns cleared).", cleared);
            } else if cleared > 0 {
                info!("Memory off — {} turns cleared.", cleared);
            } else {
                info!("Memory already off.");
            }
            return Ok(());
        }

        if arg.eq_ignore_ascii_case("log") {
            self.memory_enabled = true;
            let old = self.history_strategy.replace(Box::new(LogHistory));
            match old {
                Some(o) if o.name() == "log" => {
                    info!("History diffusion unchanged: log");
                }
                Some(o) => {
                    info!("History diffusion: {} → log", o.name());
                }
                None => info!("History diffusion enabled: log"),
            }
            return Ok(());
        }

        if arg.eq_ignore_ascii_case("summary") {
            self.memory_enabled = true;
            let summary_spec = ChatAgentSpec::ollama(
                self.config.memory.model.clone(),
                GenerationParams::default(),
            );
            match summary_spec.build() {
                Ok(summary_agent) => {
                    let strat: Box<dyn HistoryStrategy> =
                        Box::new(SummaryHistory::new(summary_agent));
                    let old = self.history_strategy.replace(strat);
                    match old {
                        Some(o) if o.name() == "summary" => {
                            info!("History diffusion unchanged: summary");
                        }
                        Some(o) => {
                            info!("History diffusion: {} → summary", o.name());
                        }
                        None => info!("History diffusion enabled: summary"),
                    }
                }
                Err(e) => RagrigError::log_or(&e, "Failed to build summary agent"),
            }
            return Ok(());
        }

        if arg.eq_ignore_ascii_case("transcript") {
            let was = self.agent.rewriter().is_some();
            self.agent.set_rewriter(None);
            self.memory_enabled = true;
            if was {
                info!("Memory strategy: rewrite → transcript");
            } else {
                info!("Memory unchanged: transcript");
            }
            return Ok(());
        }

        // ── LLM-backed memory (rewrite mode) ───────────────────────

        self.memory_enabled = true;
        let mut parts = arg.split_whitespace();
        let backend = parts.next().unwrap_or("");
        let model = parts.next();
        let api_key = parts.next();

        let spec = match ChatAgentSpec::parse(backend, model, api_key, None) {
            Ok(s) => s,
            Err(e) => {
                error!("Memory spec parse error: {}", e);
                return Ok(());
            }
        };

        match spec.build() {
            Ok(new_rewriter) => {
                let new_backend = new_rewriter.backend_name();
                let new_model = new_rewriter.model_name().to_string();
                let was = self.agent.rewriter().is_some();
                self.agent.set_rewriter(Some(new_rewriter));
                if was {
                    info!("Memory agent: {} ({})", new_backend, new_model);
                } else {
                    info!("Memory enabled: rewrite — {} ({})", new_backend, new_model);
                }
            }
            Err(e) => RagrigError::log_or(&e, "Failed to build memory agent"),
        }
        Ok(())
    }

    // ── /prompt [chat|rewrite|reset] [file] ──────────────────────────

    async fn cmd_prompt(&mut self, args_str: &str) -> Result<()> {
        let mut parts = args_str.split_whitespace();
        let sub = parts.next().unwrap_or("");
        if sub.is_empty() {
            println!("Current prompts:");
            println!(
                "  chat (docs):    {:.80}",
                self.agent.system_prompt().trim()
            );
            println!(
                "  chat (no docs): {:.80}",
                self.agent.chat_without_docs_prompt().trim()
            );
            println!("  rewrite:        {:.80}", self.agent.rewrite_prompt().trim());
            println!("Usage: /prompt chat|rewrite <file>  or  /prompt reset");
            return Ok(());
        }

        match sub {
            "reset" => {
                self.agent.set_system_prompt(
                    "You are a helpful document assistant. Answer the user's question \
                     explicitly using the provided Context snippets.\n\
                     \n\
                     Context:\n{context}\n".to_string()
                );
                self.agent.set_rewrite_prompt(
                    "You are a query rewriter. Given the conversation and the \
                     latest question, produce a single self-contained search query \
                     that captures all relevant context. Output ONLY the rewritten \
                     query, nothing else.\n\n\
                     Latest question: {question}".to_string()
                );
                info!("Prompts reset to defaults.");
            }
            "chat" => {
                let file = parts.next();
                let Some(file) = file else {
                    println!("Usage: /prompt chat <file>");
                    return Ok(());
                };
                match fs::read_to_string(file) {
                    Ok(text) => {
                        self.agent.set_system_prompt(text);
                        info!("Chat prompt loaded from {}", file);
                    }
                    Err(e) => error!("Failed to load chat prompt from {}: {}", file, e),
                }
            }
            "rewrite" => {
                let file = parts.next();
                let Some(file) = file else {
                    println!("Usage: /prompt rewrite <file>");
                    return Ok(());
                };
                match fs::read_to_string(file) {
                    Ok(text) => {
                        self.agent.set_rewrite_prompt(text);
                        info!("Rewrite prompt loaded from {}", file);
                    }
                    Err(e) => error!("Failed to load rewrite prompt from {}: {}", file, e),
                }
            }
            other => {
                println!(
                    "Unknown sub-command: {}. Use chat, rewrite, or reset.",
                    other
                );
            }
        }
        Ok(())
    }

    // ── /parser [pdf|epub] [sink|extract|internal|epub] ────────────

    async fn cmd_parser(&mut self, args_str: &str) -> Result<()> {
        let mut parts = args_str.split_whitespace();
        let format = parts.next().unwrap_or("");
        if format.is_empty() {
            println!("PDF:  {:?}", self.pdf_parser);
            println!("EPUB: {:?}", self.epub_parser);
            println!("Usage: /parser pdf unpdf|sink|extract|internal|vision");
            println!("       /parser epub epub");
            return Ok(());
        }

        let choice = parts.next().unwrap_or("");
        if choice.is_empty() {
            println!("Usage: /parser {} <backend>", format);
            return Ok(());
        }

        match format.to_lowercase().as_str() {
            "pdf" => {
                #[allow(deprecated)]
                let new = match choice.to_lowercase().as_str() {
                    #[cfg(feature = "kreuzberg")]
                    "kreuzberg" => PdfParserBackend::Kreuzberg,
                    "unpdf" => PdfParserBackend::Unpdf,
                    "sink" => PdfParserBackend::Sink,
                    "extract" => PdfParserBackend::Extract,
                    "internal" => PdfParserBackend::Internal,
                    "vision" => PdfParserBackend::Vision,
                    other => {
                        println!(
                            "Unknown PDF parser: {}. Use unpdf, sink, extract, internal, or vision.",
                            other
                        );
                        return Ok(());
                    }
                };
                let old = std::mem::replace(&mut self.pdf_parser, new.clone());
                info!("PDF parser: {:?} → {:?}", old, new);
                // Rebuild the parser registry so the selected backend takes effect.
                self.doc_parsers =
                    DocumentParsers::new(filtered_parsers(&new, self.config.parse.sloppy_pdf));
                info!("Active parsers: {}", self.doc_parsers.names().join(", "));
            }
            "epub" => {
                let new = match choice.to_lowercase().as_str() {
                    "epub" => EpubParserBackend::Epub,
                    other => {
                        println!("Unknown EPUB parser: {}. The only option is 'epub'.", other);
                        return Ok(());
                    }
                };
                let old = std::mem::replace(&mut self.epub_parser, new.clone());
                info!("EPUB parser: {:?} → {:?}", old, new);
            }
            other => {
                println!("Unknown format: {}. Use pdf or epub.", other);
            }
        }
        Ok(())
    }

    // ── /profile [save|show|load|list] [name] ────────────────────

    /// Save, show, load, or list configuration profiles.
    ///
    /// Profiles are stored as JSON files in `.ragrig/profiles/`.
    /// Use `--profile <name>` at startup to load a profile automatically.
    async fn cmd_profile(&mut self, args_str: &str) -> Result<()> {
        let mut parts = args_str.split_whitespace();
        let sub = parts.next().unwrap_or("");
        let name = parts.next().unwrap_or("default");

        match sub {
            "" | "list" => {
                let profiles = RagrigConfig::list_profiles(&self.config.folder)?;
                if profiles.is_empty() {
                    println!("No saved profiles. Use /profile save <name> to create one.");
                } else {
                    println!("Saved profiles:");
                    for p in &profiles {
                        let marker = if p == name || (profiles.len() == 1 && sub.is_empty()) {
                            " *"
                        } else {
                            ""
                        };
                        println!("  {}{marker}", p);
                    }
                    if !profiles.contains(&name.to_string()) && !sub.is_empty() {
                        println!("  (profile '{}' not found)", name);
                    }
                }
            }
            "save" => {
                // Update the in-memory config from the running agent state
                // before saving, so the profile reflects current runtime settings.
                self.sync_config_from_agent();
                self.config.save_to_profile(&self.config.folder, name)?;
                                println!("Profile '{}' saved.", name);
            }
            "show" => {
                let config = if name == "current" || parts.next().is_none() && name == "default"
                    && RagrigConfig::list_profiles(&self.config.folder)?.is_empty()
                {
                    self.sync_config_from_agent();
                    self.config.clone()
                } else {
                    match RagrigConfig::load_from_profile(&self.config.folder, name) {
                        Ok(c) => c,
                        Err(e) => {
                            // Try showing the current in-memory config if named profile not found.
                            if name == "current" {
                                self.sync_config_from_agent();
                                self.config.clone()
                            } else {
                                println!("{}", e);
                                return Ok(());
                            }
                        }
                    }
                };
                println!("{}", serde_json::to_string_pretty(&config)?);
            }
            "load" => {
                let profile = RagrigConfig::load_from_profile(&self.config.folder, name)?;
                // Keep the current folder — profiles don't override it.
                let folder = self.config.folder.clone();
                self.config = profile;
                self.config.folder = folder;
                println!("Profile '{}' loaded. Use /chat, /embed, /memory to apply.", name);
                info!(
                    "Loaded profile '{}': chat={} embed={} memory={}",
                    name,
                    self.config.chat.model,
                    self.config.embed.model,
                    self.config.memory.model,
                );
            }
            _ => {
                println!("Usage: /profile [save|show|load|list] [name]");
                println!("  save <name>  — save current config as a profile");
                println!("  show [name]  — display a profile as JSON (or 'current' for running state)");
                println!("  load <name>  — load a profile (use /chat, /embed, /memory to apply)");
                println!("  list         — list saved profiles");
            }
        }
        Ok(())
    }

    /// Copy the running agent state back into `self.config` so profile
    /// saves reflect any runtime hot-swaps.
    fn sync_config_from_agent(&mut self) {
        self.config.chat.model = self.agent.chat_agent().model_name().to_string();
        self.config.embed.model = self.agent.embedder().model_name().to_string();
        self.config.embed.top_k = self.agent.top_k();
        self.config.embed.similarity_threshold = self.agent.similarity_threshold();
        self.config.chat.context_tokens = self.agent.context_tokens();
        if let Some(rw) = self.agent.rewriter() {
            self.config.memory.model = rw.model_name().to_string();
        }
    }

    // ── /log [level] ───────────────────────────────────────────────

    /// Show or change the log verbosity at runtime.
    ///
    /// Without an argument prints the current filter.  With an argument
    /// sets it: `off`, `error`, `warn`, `info`, `debug`, or `trace`.
    async fn cmd_log(&mut self, args_str: &str) -> Result<()> {
        let level = args_str.trim().to_lowercase();
        if level.is_empty() {
            println!(
                "Interactive log level: {} — change with /log <off|error|warn|info|debug|trace>",
                self.log_level.read().unwrap()
            );
            return Ok(());
        }

        // `debug` and `trace` use target-filtered directives to
        // avoid keystroke‑level noise from `rustyline` and other
        // dependencies.  Only the `ragrig` crate (binary + library)
        // gets the elevated level; everything else stays at `info`.
        // (The log file always records at `ragrig=debug` independently.)
        let filter_str = match level.as_str() {
            "off" => "off",
            "error" => "error",
            "warn" => "warn",
            "info" => "info",
            "debug" => "ragrig=debug,info",
            "trace" => "ragrig=trace,info",
            other => {
                println!(
                    "Unknown log level '{}'. Use off, error, warn, info, debug, or trace.",
                    other
                );
                return Ok(());
            }
        };

        *self.log_level.write().unwrap() = filter_str.to_string();
        info!("Interactive log level set to {}.", level);
        Ok(())
    }

    // ── Normal RAG query ─────────────────────────────────────────────

    /// Execute a full RAG pipeline: rewrite → embed → search → prompt → generate.
    ///
    /// 1. **Rewrite** — the memory agent expands pronouns and implicit
    ///    context into a self-contained search query (skipped if `/memory off`).
    /// 2. **Embed + Search** — the embedder vectorises the rewritten query;
    ///    the vector store performs hybrid BM25 + cosine RRF retrieval.
    /// 3. **Prompt construction** — system prompt + retrieved context +
    ///    conversation Memory (when enabled) + current question, formatted
    ///    as a single string with chat-template tokens.
    /// 4. **Generate** — the chat agent streams the response token by token.
    ///
    /// Retrieved context is truncated to `(model_ctx_tokens − 1024) × 3`
    /// chars to avoid exceeding the model's context window.
    async fn cmd_rag_query(&mut self, query: &str) -> Result<()> {
        trace!("Query: {:?}", query);

        // ── History diffusion (past sessions → context preamble) ─────
        let history_context = if let Some(ref strat) = self.history_strategy {
            match strat.build_context(&*self.session_store, query).await {
                Ok(ctx) if !ctx.is_empty() => {
                    debug!("History diffusion: {} chars", ctx.len());
                    trace!("History context:\n{:.500}", ctx);
                    Some(ctx)
                }
                _ => None,
            }
        } else {
            None
        };

        // Prepend history context to the query if available.
        let effective_query = if let Some(ref hc) = history_context {
            format!("{hc}\n\nCurrent question: {query}")
        } else {
            query.to_string()
        };

        // Build transcript from prompt_memory (empty when memory is off).
        let turns: &[Turn] = if self.memory_enabled {
            &self.prompt_memory
        } else {
            &[]
        };

        trace!("Transcript turns: {}", turns.len());
        trace!("Agent config: {:?}", self.agent);
        debug!(
            "Provider: {} | Model: {}",
            self.agent.chat_agent().backend_name(),
            self.agent.chat_agent().model_name()
        );

        print!("Assistant > ");
        stdout().flush()?;

        let start = std::time::Instant::now();
        let response = self
            .agent
            .generate_with_turns(&effective_query, turns)
            .await;

        match response {
            Ok(resp) => {
                println!("{}", resp.answer.trim());

                // ── Trace: pipeline metadata ────────────────────
                if let Some(ref rw) = resp.rewritten_query
                    && rw != query
                {
                    trace!("Rewritten query: {:?}", rw);
                }
                trace!(
                    "Chunks retrieved: {:?}  |  Sources: {:?}",
                    resp.chunks_retrieved,
                    resp.sources
                );
                trace!(
                    "System prompt ({} chars): {:.300}...",
                    resp.system_prompt.len(),
                    &resp.system_prompt[..300.min(resp.system_prompt.len())]
                );
                trace!("User prompt ({} chars): {}", resp.user_prompt.len(), resp.user_prompt);
                trace!("Elapsed: {:?}", resp.elapsed);

                // Info header.
                let chunks = resp.chunks_retrieved.unwrap_or(0);
                let secs = start.elapsed().as_secs_f64();
                if let Some(ref sources) = resp.sources {
                    let names: Vec<&str> = sources.iter().map(|s| s.0.as_str()).collect();
                    println!(
                        "--- {} chunks from [{}] in {:.1}s ---",
                        chunks,
                        names.join(", "),
                        secs
                    );
                } else {
                    println!("--- {} chunks in {:.1}s ---", chunks, secs);
                }
                // Accumulate memory only when enabled.
                let reply = resp.answer.trim().to_string();
                if self.memory_enabled && !reply.is_empty() {
                    self.prompt_memory.push(Turn {
                        role: TurnRole::User,
                        text: query.to_string(),
                        perf: None,
                    });
                    self.prompt_memory.push(Turn {
                        role: TurnRole::Assistant,
                        text: reply,
                        perf: None,
                    });
                    let _ = self.auto_save().await;
                }
            }
            Err(e) => {
                if self.context_size_forced == ContextSizeMode::Auto
                    && self.agent.try_recover_from_error(&e)
                {
                    if let Some(re) = e.downcast_ref::<RagrigError>() {
                        error!(
                            "Context overflow: model allows {} tokens, prompt needed {}. Budget auto-adjusted to {}.",
                            re.max_size(),
                            re.current_size(),
                            self.agent.context_tokens(),
                        );
                        eprintln!(
                            "\n*** Context overflow: model allows {} tokens, prompt needed {}. ***",
                            re.max_size(),
                            re.current_size()
                        );
                        eprintln!(
                            "*** Budget auto-adjusted to {} tokens. Use `/chat context {}` to override. ***",
                            self.agent.context_tokens(),
                            re.max_size().saturating_sub(512)
                        );
                    }
                } else {
                    RagrigError::log_or(&e, "Generation failed");
                }
            }
        }
        println!();

        Ok(())
    }
}

// ── main: parse → bootstrap → central match loop ──────────────────────────

#[tokio::main]
async fn main() -> Result<()> {
    let cli = Cli::parse();
    let profile_name = cli.profile.clone();
    let cli_config: RagrigConfig = cli.into();

    // If --profile was given, load it and merge CLI overrides on top.
    let config = if let Some(ref name) = profile_name {
        match RagrigConfig::load_from_profile(&cli_config.folder, name) {
            Ok(mut profile) => {
                profile.override_with(&cli_config);
                info!("Loaded profile '{}' with CLI overrides applied.", name);
                profile
            }
            Err(e) => {
                // Profile not found or corrupt — warn and use CLI values only.
                warn!("Profile '{}' could not be loaded: {}", name, e);
                cli_config
            }
        }
    } else {
        cli_config
    };

    // ── File logging (always debug level, daily rotation) ───────────
    let log_dir = config.folder.join(".ragrig");
    let _ = std::fs::create_dir_all(&log_dir);
    let file_appender = rolling::daily(&log_dir, "ragrig.log");
    let (non_blocking, _log_guard) = tracing_appender::non_blocking(file_appender);

    // ── Interactive (stderr) filter: changed at runtime via /log ────
    let initial_level = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
    let stderr_level: Arc<RwLock<String>> = Arc::new(RwLock::new(initial_level));

    let stderr_filter = {
        let lvl = stderr_level.clone();
        filter_fn(move |meta| {
            let filter_str = lvl.read().unwrap();
            match filter_str.as_str() {
                "off" => false,
                "error" => *meta.level() <= Level::ERROR,
                "warn" => *meta.level() <= Level::WARN,
                "info" => *meta.level() <= Level::INFO,
                "debug" => *meta.level() <= Level::DEBUG,
                "trace" => true,
                // Target-filtered: only ragrig at elevated level
                "ragrig=debug,info" => {
                    if meta.target().starts_with("ragrig") {
                        *meta.level() <= Level::DEBUG
                    } else {
                        *meta.level() <= Level::INFO
                    }
                }
                "ragrig=trace,info" => {
                    if meta.target().starts_with("ragrig") {
                        true
                    } else {
                        *meta.level() <= Level::INFO
                    }
                }
                _ => *meta.level() <= Level::INFO,
            }
        })
    };

    tracing_subscriber::registry()
        .with(
            fmt::layer()
                .without_time()
                .with_target(false)
                .with_filter(stderr_filter),
        )
        .with(
            fmt::layer()
                .without_time()
                .with_target(false)
                .with_writer(non_blocking)
                .with_filter(EnvFilter::new("ragrig=debug")),
        )
        .init();

    let mut session = bootstrap(config, stderr_level).await?;

    loop {
        let readline = session.rl.readline("Query > ");

        let cmd = match readline {
            Ok(line) => {
                let trimmed = line.trim().to_string();
                if trimmed.is_empty() {
                    continue;
                }
                session.rl.add_history_entry(&trimmed)?;
                Command::from(trimmed.as_str())
            }
            Err(ReadlineError::Interrupted) => {
                info!("Session interrupted.");
                break;
            }
            Err(ReadlineError::Eof) => {
                info!("Session ended via Ctrl+D.");
                break;
            }
            Err(err) => {
                error!("Error reading input: {}", err);
                break;
            }
        };

        match cmd {
            Command::Exit => break,
            Command::RagQuery(ref q) if q.is_empty() => continue,
            _ => {
                if let Err(e) = session.execute(cmd).await {
                    RagrigError::log_or(&e, "Command execution error");
                }
            }
        }
    }

    // Auto‑save the session before exiting.
    if !session.prompt_memory.is_empty()
        && let Err(e) = session.auto_save().await {
            warn!("Failed to save session on exit: {}", e);
        }
    session.rl.save_history(&session.history_path)?;
    Ok(())
}

// ── Utility functions ─────────────────────────────────────────────────────

/// Strip ANSI escape sequences (bracketed paste, colors, etc.) from a string.
fn strip_ansi(input: &str) -> String {
    let mut result = String::with_capacity(input.len());
    let mut chars = input.chars().peekable();
    while let Some(c) = chars.next() {
        if c == '\x1b' && chars.peek() == Some(&'[') {
            // Consume the escape sequence: ESC[... until a letter
            chars.next(); // skip '['
            while let Some(&nc) = chars.peek() {
                chars.next();
                if nc.is_alphabetic() || nc == '~' {
                    break;
                }
            }
        } else {
            result.push(c);
        }
    }
    result
}

/// Parse "1,2,3-4,8" into zero-based indices [0,1,2,3,7]
fn parse_number_range(input: &str) -> Result<Vec<usize>, String> {
    let mut indices = Vec::new();
    for part in input.split(',') {
        let part = part.trim();
        if part.is_empty() {
            continue;
        }
        if let Some((start, end)) = part.split_once('-') {
            let s: usize = start
                .trim()
                .parse()
                .map_err(|_| format!("invalid number: {}", start))?;
            let e: usize = end
                .trim()
                .parse()
                .map_err(|_| format!("invalid number: {}", end))?;
            if s == 0 || e == 0 {
                return Err("Indices start at 1".to_string());
            }
            if s > e {
                return Err(format!("invalid range: {}-{}", s, e));
            }
            for n in s..=e {
                indices.push(n - 1); // convert to zero-based
            }
        } else {
            let n: usize = part
                .parse()
                .map_err(|_| format!("invalid number: {}", part))?;
            if n == 0 {
                return Err("Indices start at 1".to_string());
            }
            indices.push(n - 1);
        }
    }
    Ok(indices)
}

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

    // ── Command::from ─────────────────────────────────────────────────

    #[test]
    fn parse_unknown_slash_command_is_not_query() {
        // Any input starting with / that doesn't match a known command
        // must be Unknown, never RagQuery.
        let cmd = Command::from("/foobar");
        assert!(matches!(cmd, Command::Unknown(_)));
    }

    #[test]
    fn parse_unknown_slash_with_args() {
        let cmd = Command::from("/bogus arg1 arg2");
        assert!(matches!(cmd, Command::Unknown(c) if c.contains("arg1")));
    }

    #[test]
    fn parse_plain_text_is_rag_query() {
        let cmd = Command::from("What is RAG?");
        assert!(matches!(cmd, Command::RagQuery(q) if q == "What is RAG?"));
    }

    #[test]
    fn parse_memory_no_args_does_not_panic() {
        // Regression: /memory with no trailing content used to panic
        // on input[8..] when input was only 7 chars.
        let cmd = Command::from("/memory");
        assert!(matches!(cmd, Command::Memory(s) if s.is_empty()));
    }

    #[test]
    fn parse_memory_with_args() {
        let cmd = Command::from("/memory transcript");
        assert!(matches!(cmd, Command::Memory(s) if s == "transcript"));
    }

    #[test]
    fn parse_hist_no_args_does_not_panic() {
        let cmd = Command::from("/hist");
        assert!(matches!(cmd, Command::Hist(s) if s.is_empty()));
    }

    #[test]
    fn parse_chat_command_recognised() {
        // Regression: /chat was broken and fell through to RagQuery.
        let cmd = Command::from("/chat ollama");
        assert!(matches!(cmd, Command::Chat(s) if s == "ollama"));
    }

    #[test]
    fn parse_embed_no_args_does_not_panic() {
        let cmd = Command::from("/embed");
        assert!(matches!(cmd, Command::Embed(s) if s.is_empty()));
    }

    #[test]
    fn parse_refs_no_args_does_not_panic() {
        let cmd = Command::from("/refs");
        assert!(matches!(cmd, Command::ExtractRefs(s) if s.is_empty()));
    }

    #[test]
    fn parse_slash_exit() {
        assert!(matches!(Command::from("/exit"), Command::Exit));
    }

    #[test]
    fn parse_slash_bye() {
        assert!(matches!(Command::from("/bye"), Command::Exit));
    }

    // ── Integration test ─────────────────────────────────────────────

    /// Full RAG integration test — requires a running Ollama server
    /// with gemma4:e4b pulled, and tests/fixtures/formats/pdf indexed.
    ///
    /// Run with: cargo test --features ollama-embed -- --ignored
    #[tokio::test]
    #[ignore = "requires Ollama with gemma4:e4b and tests/fixtures/formats/pdf"]
    async fn gemma4_rag_answer_exceeds_20_words() {
        let log_level = Arc::new(RwLock::new("warn".into()));

        let config = RagrigConfig {
            folder: "tests/fixtures/formats/pdf".into(),
            chat: ChatConfig {
                model: "gemma4:e4b".into(),
                ..Default::default()
            },
            embed: EmbedConfig {
                model: "nomic-embed-text".into(),
                ..Default::default()
            },
            ..Default::default()
        };
        let mut session = match bootstrap(config, log_level).await {
            Ok(s) => s,
            Err(e) => {
                eprintln!("bootstrap failed (Ollama not running?): {}", e);
                return;
            }
        };
        let question = "I have used a 7-item Likert scale in my research. What should I do?";
        match session.cmd_rag_query(question).await {
            Ok(()) => {
                let memory = &session.prompt_memory;
                let answer = memory
                    .last()
                    .filter(|t| t.role == TurnRole::Assistant)
                    .map(|t| t.text.as_str())
                    .unwrap_or("");
                let word_count = answer.split_whitespace().count();
                eprintln!("Answer ({} words): {}", word_count, answer);
                assert!(
                    word_count > 20,
                    "Expected >20 words, got {}: '{}'",
                    word_count,
                    answer
                );
            }
            Err(e) => {
                eprintln!("RAG query failed: {}", e);
                // Don't panic on API errors, but do report them.
                // The test still fails if we get here because the
                // assertion above never runs.
                panic!("cmd_rag_query returned error: {}", e);
            }
        }
    }

}
