use self::splitter::*;

use crate::client::*;
use crate::config::*;
use crate::utils::*;

mod graph;
mod serde_vectors;
mod splitter;

use self::graph::{KnowledgeGraph, extract_entities};

use anyhow::{Context, Result, anyhow, bail};
use bm25::{Language, SearchEngine, SearchEngineBuilder};
use hnsw_rs::prelude::*;
use indexmap::{IndexMap, IndexSet};
use inquire::{Confirm, Select, Text, required, validator::Validation};
use parking_lot::RwLock;
use petgraph::graph::NodeIndex;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
    cmp::Ordering, collections::HashMap, env, fmt, fmt::Debug, fs, hash::Hash, path::Path,
    sync::Arc, time::Duration,
};
use tokio::time::sleep;

const BM25_SEED_SCORE: f32 = 0.5;

const RAG_TEMPLATE: &str = r#"Answer the query based on the context while respecting the rules. (user query, some textual context and rules, all inside xml tags)

<context>
__CONTEXT__
</context>

<sources>
__SOURCES__
</sources>

<rules>
- If you don't know, just say so.
- If you are not sure, ask for clarification.
- Answer in the same language as the user query.
- If the context appears unreadable or of poor quality, tell the user then answer as best as you can.
- If the answer is not in the context but you think you know the answer, explain that to the user then answer with your own knowledge.
- Answer directly and without using xml tags.
- When using information from the context, cite the relevant source from the <sources> section.
</rules>

<user_query>
__INPUT__
</user_query>"#;

pub struct Rag {
    app_config: Arc<AppConfig>,
    name: String,
    path: String,
    embedding_model: Model,
    hnsw: Hnsw<'static, f32, DistCosine>,
    bm25: SearchEngine<DocumentId>,
    data: RagData,
    last_sources: RwLock<Option<String>>,
    node_to_docs: IndexMap<u32, Vec<DocumentId>>,
}

impl Debug for Rag {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("Rag")
            .field("name", &self.name)
            .field("path", &self.path)
            .field("embedding_model", &self.embedding_model)
            .field("data", &self.data)
            .finish()
    }
}

impl Clone for Rag {
    fn clone(&self) -> Self {
        Self {
            app_config: self.app_config.clone(),
            name: self.name.clone(),
            path: self.path.clone(),
            embedding_model: self.embedding_model.clone(),
            hnsw: self.data.build_hnsw(),
            bm25: self.data.build_bm25(),
            node_to_docs: self.data.knowledge_graph.build_node_to_docs(),
            data: self.data.clone(),
            last_sources: RwLock::new(None),
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct RagInitConfig {
    pub embedding_model: Option<String>,
    pub chunk_size: Option<usize>,
    pub chunk_overlap: Option<usize>,
    pub reranker_model: Option<String>,
    pub top_k: Option<usize>,
    pub batch_size: Option<usize>,
    pub extractor_model: Option<String>,
    pub extractor_prompt: Option<String>,
    pub graph_hops: Option<usize>,
}

#[derive(Debug, Clone, Default)]
pub struct GraphRagConfig {
    pub extractor_model: Option<String>,
    pub extractor_prompt: Option<String>,
    pub graph_hops: Option<usize>,
}

impl Rag {
    fn create_embeddings_client(&self, model: Model) -> Result<Box<dyn Client>> {
        init_client(&self.app_config, model)
    }

    pub async fn init_with_config(
        app: &AppConfig,
        name: &str,
        save_path: &Path,
        doc_paths: &[String],
        config: &RagInitConfig,
        abort_signal: AbortSignal,
    ) -> Result<Self> {
        if doc_paths.is_empty() {
            bail!("Cannot build RAG knowledge base '{name}' with no documents");
        }
        println!("⚙ Initializing RAG...");
        let data = Self::resolve_init_data(app, config)?;
        let mut rag = Self::create(app, name, save_path, data)?;
        let loaders = app.document_loaders.clone();
        let (spinner, spinner_rx) = Spinner::create("");
        abortable_run_with_spinner_rx(
            rag.sync_documents(doc_paths, true, loaders, Some(spinner)),
            spinner_rx,
            abort_signal,
        )
        .await?;
        if rag.save()? {
            println!("✓ Saved RAG to '{}'.", save_path.display());
        }
        Ok(rag)
    }

    fn resolve_init_data(app: &AppConfig, config: &RagInitConfig) -> Result<RagData> {
        let embedding_model_id = config
            .embedding_model
            .clone()
            .or_else(|| app.rag_embedding_model.clone());
        let embedding_model_id = match embedding_model_id {
            Some(value) => {
                println!("Embedding model: {value}");
                value
            }
            None => {
                if !*IS_STDOUT_TERMINAL {
                    bail!(
                        "RAG knowledge base needs an embedding model. Set `embedding_model` \
                         on the rag node, or run the agent interactively once."
                    );
                }
                let models = list_models(app, ModelType::Embedding);
                if models.is_empty() {
                    bail!("No available embedding model");
                }
                select_embedding_model(&models)?
            }
        };
        let embedding_model =
            Model::retrieve_model(app, &embedding_model_id, ModelType::Embedding)?;

        let chunk_size = match config.chunk_size.or(app.rag_chunk_size) {
            Some(value) => {
                println!("Chunk size: {value}");
                value
            }
            None => {
                if !*IS_STDOUT_TERMINAL {
                    bail!(
                        "RAG knowledge base needs a chunk_size. Set `chunk_size` on the \
                         rag node, or run the agent interactively once."
                    );
                }
                set_chunk_size(&embedding_model)?
            }
        };
        let chunk_overlap = match config.chunk_overlap.or(app.rag_chunk_overlap) {
            Some(value) => {
                println!("Chunk overlap: {value}");
                value
            }
            None => {
                if !*IS_STDOUT_TERMINAL {
                    bail!(
                        "RAG knowledge base needs a chunk_overlap. Set `chunk_overlap` on \
                         the rag node, or run the agent interactively once."
                    );
                }
                set_chunk_overlay(chunk_size / 20)?
            }
        };

        let reranker_model = config
            .reranker_model
            .clone()
            .or_else(|| app.rag_reranker_model.clone());
        let top_k = config.top_k.unwrap_or(app.rag_top_k);
        let batch_size = config
            .batch_size
            .or_else(|| embedding_model.max_batch_size());

        Ok(RagData::new(
            embedding_model.id(),
            chunk_size,
            chunk_overlap,
            reranker_model,
            top_k,
            batch_size,
            GraphRagConfig {
                extractor_model: config
                    .extractor_model
                    .clone()
                    .or_else(|| app.rag_extractor_model.clone()),
                extractor_prompt: config
                    .extractor_prompt
                    .clone()
                    .or_else(|| app.rag_extractor_prompt.clone()),
                graph_hops: Some(config.graph_hops.unwrap_or(app.rag_graph_hops)),
            },
        ))
    }

    pub async fn init(
        app: &AppConfig,
        name: &str,
        save_path: &Path,
        doc_paths: &[String],
        abort_signal: AbortSignal,
    ) -> Result<Self> {
        if !*IS_STDOUT_TERMINAL {
            bail!("Failed to init rag in non-interactive mode");
        }
        println!("⚙ Initializing RAG...");
        let (embedding_model, chunk_size, chunk_overlap) = Self::create_config(app)?;
        let reranker_model = app.rag_reranker_model.clone();
        let top_k = app.rag_top_k;
        let extractor_model = match app.rag_extractor_model.clone() {
            Some(model) => Some(model),
            None => select_extractor_model(app)?,
        };
        let graph_hops = if extractor_model.is_some() {
            set_graph_hops(app.rag_graph_hops)?
        } else {
            app.rag_graph_hops
        };
        let extractor_prompt = app.rag_extractor_prompt.clone();
        let data = RagData::new(
            embedding_model.id(),
            chunk_size,
            chunk_overlap,
            reranker_model,
            top_k,
            embedding_model.max_batch_size(),
            GraphRagConfig {
                extractor_model,
                extractor_prompt,
                graph_hops: Some(graph_hops),
            },
        );
        let mut rag = Self::create(app, name, save_path, data)?;
        let mut paths = doc_paths.to_vec();
        if paths.is_empty() {
            paths = add_documents()?;
        };
        let loaders = app.document_loaders.clone();
        let (spinner, spinner_rx) = Spinner::create("");
        abortable_run_with_spinner_rx(
            rag.sync_documents(&paths, true, loaders, Some(spinner)),
            spinner_rx,
            abort_signal,
        )
        .await?;
        if rag.save()? {
            println!("✓ Saved RAG to '{}'.", save_path.display());
        }
        Ok(rag)
    }

    pub fn load(app: &AppConfig, name: &str, path: &Path) -> Result<Self> {
        let err = || format!("Failed to load rag '{name}' at '{}'", path.display());
        let content = fs::read_to_string(path).with_context(err)?;
        let data: RagData = serde_yaml::from_str(&content).with_context(err)?;
        Self::create(app, name, path, data)
    }

    pub fn create(app: &AppConfig, name: &str, path: &Path, data: RagData) -> Result<Self> {
        let hnsw = data.build_hnsw();
        let bm25 = data.build_bm25();
        let node_to_docs = data.knowledge_graph.build_node_to_docs();
        let embedding_model =
            Model::retrieve_model(app, &data.embedding_model, ModelType::Embedding)?;
        let rag = Rag {
            app_config: Arc::new(app.clone()),
            name: name.to_string(),
            path: path.display().to_string(),
            data,
            embedding_model,
            hnsw,
            bm25,
            node_to_docs,
            last_sources: RwLock::new(None),
        };
        Ok(rag)
    }

    pub fn document_paths(&self) -> &[String] {
        &self.data.document_paths
    }

    pub async fn refresh_document_paths(
        &mut self,
        document_paths: &[String],
        refresh: bool,
        app: &AppConfig,
        abort_signal: AbortSignal,
    ) -> Result<()> {
        let loaders = app.document_loaders.clone();
        let (spinner, spinner_rx) = Spinner::create("");
        abortable_run_with_spinner_rx(
            self.sync_documents(document_paths, refresh, loaders, Some(spinner)),
            spinner_rx,
            abort_signal,
        )
        .await?;
        if self.save()? {
            println!("✓ Saved rag to '{}'.", self.path);
        }
        Ok(())
    }

    pub fn create_config(app: &AppConfig) -> Result<(Model, usize, usize)> {
        let embedding_model_id = app.rag_embedding_model.clone();
        let chunk_size = app.rag_chunk_size;
        let chunk_overlap = app.rag_chunk_overlap;
        let embedding_model_id = match embedding_model_id {
            Some(value) => {
                println!("Select embedding model: {value}");
                value
            }
            None => {
                let models = list_models(app, ModelType::Embedding);
                if models.is_empty() {
                    bail!("No available embedding model");
                }
                select_embedding_model(&models)?
            }
        };
        let embedding_model =
            Model::retrieve_model(app, &embedding_model_id, ModelType::Embedding)?;

        let chunk_size = match chunk_size {
            Some(value) => {
                println!("Set chunk size: {value}");
                value
            }
            None => set_chunk_size(&embedding_model)?,
        };
        let chunk_overlap = match chunk_overlap {
            Some(value) => {
                println!("Set chunk overlay: {value}");
                value
            }
            None => {
                let value = chunk_size / 20;
                set_chunk_overlay(value)?
            }
        };

        Ok((embedding_model, chunk_size, chunk_overlap))
    }

    pub fn get_config(&self) -> (Option<String>, usize) {
        (self.data.reranker_model.clone(), self.data.top_k)
    }

    pub fn get_last_sources(&self) -> Option<String> {
        self.last_sources.read().clone()
    }

    pub fn set_last_sources(&self, ids: &[DocumentId]) {
        let mut sources: IndexMap<String, Vec<String>> = IndexMap::new();
        for id in ids {
            let (file_index, _) = id.split();
            if let Some(file) = self.data.files.get(&file_index) {
                sources
                    .entry(file.path.clone())
                    .or_default()
                    .push(format!("{id:?}"));
            }
        }
        let sources = if sources.is_empty() {
            None
        } else {
            Some(
                sources
                    .into_iter()
                    .map(|(path, ids)| format!("{path} ({})", ids.join(",")))
                    .collect::<Vec<_>>()
                    .join("\n"),
            )
        };
        *self.last_sources.write() = sources;
    }

    pub fn set_reranker_model(&mut self, reranker_model: Option<String>) -> Result<()> {
        self.data.reranker_model = reranker_model;
        self.save()?;
        Ok(())
    }

    pub fn set_top_k(&mut self, top_k: usize) -> Result<()> {
        self.data.top_k = top_k;
        self.save()?;
        Ok(())
    }

    pub fn save(&self) -> Result<bool> {
        if self.is_temp() {
            return Ok(false);
        }
        let path = Path::new(&self.path);
        ensure_parent_exists(path)?;

        let content = serde_yaml::to_string(&self.data)
            .with_context(|| format!("Failed to serde rag '{}'", self.name))?;
        fs::write(path, content).with_context(|| {
            format!("Failed to save rag '{}' to '{}'", self.name, path.display())
        })?;

        Ok(true)
    }

    pub fn export(&self) -> Result<String> {
        let files: Vec<_> = self
            .data
            .files
            .iter()
            .map(|(_, v)| {
                json!({
                    "path": v.path,
                    "num_chunks": v.documents.len(),
                })
            })
            .collect();
        let data = json!({
            "path": self.path,
            "embedding_model": self.embedding_model.id(),
            "chunk_size": self.data.chunk_size,
            "chunk_overlap": self.data.chunk_overlap,
            "reranker_model": self.data.reranker_model,
            "extractor_model": self.data.extractor_model,
            "extractor_prompt": self.data.extractor_prompt,
            "graph_hops": self.data.graph_hops.unwrap_or(1),
            "top_k": self.data.top_k,
            "batch_size": self.data.batch_size,
            "document_paths": self.data.document_paths,
            "files": files,
        });
        let output = serde_yaml::to_string(&data)
            .with_context(|| format!("Unable to show info about rag '{}'", self.name))?;
        Ok(output)
    }

    pub fn name(&self) -> &str {
        &self.name
    }

    pub fn is_temp(&self) -> bool {
        self.name == TEMP_RAG_NAME
    }

    pub fn configured_top_k(&self) -> usize {
        self.data.top_k
    }

    pub fn configured_reranker(&self) -> Option<&str> {
        self.data.reranker_model.as_deref()
    }

    pub async fn search(
        &self,
        text: &str,
        top_k: usize,
        rerank_model: Option<&str>,
        abort_signal: AbortSignal,
    ) -> Result<(String, String, Vec<DocumentId>)> {
        let ret = abortable_run_with_spinner(
            self.hybrid_search(text, top_k, rerank_model),
            "Searching",
            abort_signal,
        )
        .await;
        let results = ret?;
        let ids: Vec<_> = results.iter().map(|(id, _)| *id).collect();
        let embeddings = results
            .iter()
            .map(|(id, content)| {
                let source = self.resolve_source(id);
                format!("[Source: {source}]\n{content}")
            })
            .collect::<Vec<_>>()
            .join("\n\n");
        let sources = self.format_sources(&ids);
        Ok((embeddings, sources, ids))
    }

    pub async fn search_with_template(
        &self,
        app: &AppConfig,
        text: &str,
        abort_signal: AbortSignal,
    ) -> Result<String> {
        let (reranker_model, top_k) = self.get_config();
        let (embeddings, sources, ids) = self
            .search(text, top_k, reranker_model.as_deref(), abort_signal)
            .await?;
        let rag_template = app.rag_template.as_deref().unwrap_or(RAG_TEMPLATE);
        let text = if embeddings.is_empty() {
            text.to_string()
        } else {
            rag_template
                .replace("__CONTEXT__", &embeddings)
                .replace("__SOURCES__", &sources)
                .replace("__INPUT__", text)
        };
        self.set_last_sources(&ids);
        Ok(text)
    }

    fn resolve_source(&self, id: &DocumentId) -> String {
        let (file_index, _) = id.split();
        self.data
            .files
            .get(&file_index)
            .map(|f| f.path.clone())
            .unwrap_or_else(|| "unknown".to_string())
    }

    fn format_sources(&self, ids: &[DocumentId]) -> String {
        let mut seen = IndexSet::new();
        for id in ids {
            let (file_index, _) = id.split();
            if let Some(file) = self.data.files.get(&file_index) {
                seen.insert(file.path.clone());
            }
        }
        seen.into_iter()
            .map(|path| format!("- {path}"))
            .collect::<Vec<_>>()
            .join("\n")
    }

    pub async fn sync_documents(
        &mut self,
        paths: &[String],
        refresh: bool,
        loaders: HashMap<String, String>,
        spinner: Option<Spinner>,
    ) -> Result<()> {
        if let Some(spinner) = &spinner {
            let _ = spinner.set_message(String::new());
        }
        let (document_paths, mut recursive_urls, mut urls, mut protocol_paths, mut local_paths) =
            resolve_paths(&loaders, paths).await?;
        let mut to_deleted: IndexMap<String, Vec<FileId>> = Default::default();
        if refresh {
            for (file_id, file) in &self.data.files {
                to_deleted
                    .entry(file.hash.clone())
                    .or_default()
                    .push(*file_id);
            }
        } else {
            let recursive_urls_cloned = recursive_urls.clone();
            let match_recursive_url = |v: &str| {
                recursive_urls_cloned
                    .iter()
                    .any(|start_url| v.starts_with(start_url))
            };
            recursive_urls = recursive_urls
                .into_iter()
                .filter(|v| !self.data.document_paths.contains(&format!("{v}**")))
                .collect();
            let protocol_paths_cloned = protocol_paths.clone();
            let match_protocol_path =
                |v: &str| protocol_paths_cloned.iter().any(|root| v.starts_with(root));
            protocol_paths = protocol_paths
                .into_iter()
                .filter(|v| !self.data.document_paths.contains(v))
                .collect();
            for (file_id, file) in &self.data.files {
                if is_url(&file.path) {
                    if !urls.swap_remove(&file.path) && !match_recursive_url(&file.path) {
                        to_deleted
                            .entry(file.hash.clone())
                            .or_default()
                            .push(*file_id);
                    }
                } else if is_loader_protocol(&loaders, &file.path) {
                    if !match_protocol_path(&file.path) {
                        to_deleted
                            .entry(file.hash.clone())
                            .or_default()
                            .push(*file_id);
                    }
                } else if !local_paths.swap_remove(&file.path) {
                    to_deleted
                        .entry(file.hash.clone())
                        .or_default()
                        .push(*file_id);
                }
            }
        }

        let mut loaded_documents = vec![];
        let mut has_error = false;
        let mut index = 0;
        let total = recursive_urls.len() + urls.len() + protocol_paths.len() + local_paths.len();
        let handle_error = |error: anyhow::Error, has_error: &mut bool| {
            println!("{}", warning_text(&format!("⚠️ {error}")));
            *has_error = true;
        };
        for start_url in recursive_urls {
            index += 1;
            println!("Load {start_url}** [{index}/{total}]");
            match load_recursive_url(&loaders, &start_url).await {
                Ok(v) => loaded_documents.extend(v),
                Err(err) => handle_error(err, &mut has_error),
            }
        }
        for url in urls {
            index += 1;
            println!("Load {url} [{index}/{total}]");
            match load_url(&loaders, &url).await {
                Ok(v) => loaded_documents.push(v),
                Err(err) => handle_error(err, &mut has_error),
            }
        }
        for protocol_path in protocol_paths {
            index += 1;
            println!("Load {protocol_path} [{index}/{total}]");
            match load_protocol_path(&loaders, &protocol_path) {
                Ok(v) => loaded_documents.extend(v),
                Err(err) => handle_error(err, &mut has_error),
            }
        }
        for local_path in local_paths {
            index += 1;
            println!("Load {local_path} [{index}/{total}]");
            match load_file(&loaders, &local_path).await {
                Ok(v) => loaded_documents.push(v),
                Err(err) => handle_error(err, &mut has_error),
            }
        }

        if has_error {
            let mut aborted = true;
            if *IS_STDOUT_TERMINAL && total > 0 {
                let ans = Confirm::new("Some documents failed to load. Continue?")
                    .with_default(false)
                    .prompt()?;
                aborted = !ans;
            }
            if aborted {
                bail!("Aborted");
            }
        }

        let mut rag_files = vec![];
        for LoadedDocument {
            path,
            contents,
            mut metadata,
        } in loaded_documents
        {
            let hash = sha256(&contents);
            if let Some(file_ids) = to_deleted.get_mut(&hash)
                && let Some((i, _)) = file_ids
                    .iter()
                    .enumerate()
                    .find(|(_, v)| self.data.files[*v].path == path)
            {
                if file_ids.len() == 1 {
                    to_deleted.swap_remove(&hash);
                } else {
                    file_ids.remove(i);
                }
                continue;
            }
            let extension = metadata
                .swap_remove(EXTENSION_METADATA)
                .unwrap_or_else(|| DEFAULT_EXTENSION.into());
            let separator = get_separators(&extension);
            let splitter = RecursiveCharacterTextSplitter::new(
                self.data.chunk_size,
                self.data.chunk_overlap,
                &separator,
            );

            let split_options = SplitterChunkHeaderOptions::default();
            let document = RagDocument::new(contents);
            let split_documents = splitter.split_documents(&[document], &split_options);
            rag_files.push(RagFile {
                hash: hash.clone(),
                path,
                documents: split_documents,
            });
        }

        let mut next_file_id = self.data.next_file_id;
        let mut files = vec![];
        let mut document_ids = vec![];
        let mut embeddings = vec![];
        let mut new_doc_contents: Vec<(DocumentId, String)> = vec![];

        if !rag_files.is_empty() {
            let mut texts = vec![];
            for file in rag_files.into_iter() {
                for (document_index, document) in file.documents.iter().enumerate() {
                    let doc_id = DocumentId::new(next_file_id, document_index);
                    document_ids.push(doc_id);
                    texts.push(document.page_content.clone());
                    if self.data.extractor_model.is_some() {
                        new_doc_contents.push((doc_id, document.page_content.clone()));
                    }
                }
                files.push((next_file_id, file));
                next_file_id += 1;
            }

            let embeddings_data = EmbeddingsData::new(texts, false);
            embeddings = self
                .create_embeddings(embeddings_data, spinner.clone())
                .await?;
        }

        let to_delete_file_ids: Vec<_> = to_deleted.values().flatten().copied().collect();
        self.data.del(to_delete_file_ids);
        self.data.add(next_file_id, files, document_ids, embeddings);
        self.data.document_paths = document_paths.into_iter().collect();

        if self.data.files.is_empty() {
            bail!("No RAG files");
        }

        if !new_doc_contents.is_empty()
            && let Some(extractor_model_id) = self.data.extractor_model.clone()
        {
            match Model::retrieve_model(&self.app_config, &extractor_model_id, ModelType::Chat) {
                Ok(model) => match self.create_embeddings_client(model) {
                    Ok(client) => {
                        let total = new_doc_contents.len();
                        let mut failures = 0usize;
                        for (i, (doc_id, content)) in new_doc_contents.into_iter().enumerate() {
                            progress(
                                &spinner,
                                format!("Extracting entities [{}/{}]", i + 1, total),
                            );
                            match extract_entities(
                                client.as_ref(),
                                &content,
                                self.data.extractor_prompt.as_deref(),
                            )
                            .await
                            {
                                Ok(result) => self.data.knowledge_graph.merge(doc_id, result),
                                Err(e) => {
                                    warn!("Entity extraction failed for doc {doc_id:?}: {e}");
                                    failures += 1;
                                }
                            }
                        }
                        if failures > 0 {
                            progress(
                                &spinner,
                                format!("Entity extraction: {failures}/{total} chunks failed"),
                            );
                        }
                    }
                    Err(e) => warn!("Failed to create extractor client: {e}"),
                },
                Err(e) => warn!("Extractor model not found: {e}"),
            }
        }

        progress(&spinner, "Building store".into());
        self.hnsw = self.data.build_hnsw();
        self.bm25 = self.data.build_bm25();
        self.node_to_docs = self.data.knowledge_graph.build_node_to_docs();

        Ok(())
    }

    async fn hybrid_search(
        &self,
        query: &str,
        top_k: usize,
        rerank_model: Option<&str>,
    ) -> Result<Vec<(DocumentId, String)>> {
        let (vector_search_results, keyword_search_results) = tokio::join!(
            self.vector_search(query, top_k, 0.0),
            self.keyword_search(query, top_k, 0.0),
        );

        let vector_search_results = vector_search_results?;
        debug!("vector_search_results: {vector_search_results:?}",);
        let vector_search_ids: Vec<DocumentId> =
            vector_search_results.into_iter().map(|(v, _)| v).collect();

        let keyword_search_results = keyword_search_results?;
        debug!("keyword_search_results: {keyword_search_results:?}",);
        let keyword_search_ids: Vec<DocumentId> =
            keyword_search_results.into_iter().map(|(v, _)| v).collect();

        let ids = match rerank_model {
            Some(model_id) => {
                let model = Model::retrieve_model(&self.app_config, model_id, ModelType::Reranker)?;
                let client = self.create_embeddings_client(model)?;
                let ids: IndexSet<DocumentId> = [vector_search_ids, keyword_search_ids]
                    .concat()
                    .into_iter()
                    .collect();
                let mut documents = vec![];
                let mut documents_ids = vec![];
                for id in ids {
                    if let Some(document) = self.data.get(id) {
                        documents_ids.push(id);
                        documents.push(document.page_content.to_string());
                    }
                }
                let data = RerankData::new(query.to_string(), documents, top_k);
                let list = client.rerank(&data).await.context("Failed to rerank")?;
                let ids: Vec<_> = list
                    .into_iter()
                    .take(top_k)
                    .filter_map(|item| documents_ids.get(item.index).cloned())
                    .collect();
                debug!("rerank_ids: {ids:?}");
                ids
            }
            None => {
                let ids = if self.data.extractor_model.is_some() {
                    let graph_ids = self.graph_search(query, top_k);
                    debug!("graph_search_ids: {graph_ids:?}");
                    reciprocal_rank_fusion(
                        vec![vector_search_ids, keyword_search_ids, graph_ids],
                        vec![1.125, 1.0, 0.9],
                        top_k,
                    )
                } else {
                    reciprocal_rank_fusion(
                        vec![vector_search_ids, keyword_search_ids],
                        vec![1.125, 1.0],
                        top_k,
                    )
                };
                debug!("rrf_ids: {ids:?}");
                ids
            }
        };
        let output = ids
            .into_iter()
            .filter_map(|id| {
                let document = self.data.get(id)?;
                Some((id, document.page_content.clone()))
            })
            .collect();
        Ok(output)
    }

    async fn vector_search(
        &self,
        query: &str,
        top_k: usize,
        min_score: f32,
    ) -> Result<Vec<(DocumentId, f32)>> {
        let splitter = RecursiveCharacterTextSplitter::new(
            self.data.chunk_size,
            self.data.chunk_overlap,
            &DEFAULT_SEPARATORS,
        );
        let texts = splitter.split_text(query);
        let embeddings_data = EmbeddingsData::new(texts, true);
        let embeddings = self.create_embeddings(embeddings_data, None).await?;
        let output = self
            .hnsw
            .parallel_search(&embeddings, top_k, 30)
            .into_iter()
            .flat_map(|list| {
                list.into_iter()
                    .filter_map(|v| {
                        let score = 1.0 - v.distance;
                        if score > min_score {
                            Some((DocumentId(v.d_id), score))
                        } else {
                            None
                        }
                    })
                    .collect::<Vec<_>>()
            })
            .collect();
        Ok(output)
    }

    async fn keyword_search(
        &self,
        query: &str,
        top_k: usize,
        min_score: f32,
    ) -> Result<Vec<(DocumentId, f32)>> {
        let results = self.bm25.search(query, top_k);
        let output: Vec<(DocumentId, f32)> = results
            .into_iter()
            .filter_map(|v| {
                let score = v.score;
                if score > min_score {
                    Some((v.document.id, score))
                } else {
                    None
                }
            })
            .collect();
        Ok(output)
    }

    fn graph_search(&self, query: &str, top_k: usize) -> Vec<DocumentId> {
        let kg = &self.data.knowledge_graph;
        if kg.entity_index.is_empty() {
            return vec![];
        }

        let query_lower = query.to_lowercase();
        let query_tokens: Vec<&str> = query_lower.split_whitespace().collect();
        let token_count = query_tokens.len().max(1);

        let score_node = |raw: u32| -> f32 {
            let idx = NodeIndex::new(raw as usize);
            if !kg.graph.contains_node(idx) {
                return 0.0;
            }
            let entity = &kg.graph[idx];
            let combined = format!(
                "{} {}",
                entity.name,
                entity.description.as_deref().unwrap_or("")
            )
            .to_lowercase();
            query_tokens
                .iter()
                .filter(|t| combined.contains(*t))
                .count() as f32
                / token_count as f32
        };

        let mut seed_scores: Vec<(u32, f32)> = kg
            .entity_index
            .iter()
            .filter(|(name, _)| {
                let name_str = name.as_str();
                if name_str.contains(' ') {
                    query_lower.contains(name_str)
                } else {
                    // whole-word match: prevents "go" from seeding on every query containing "Django"
                    query_lower
                        .split_whitespace()
                        .any(|token| token.trim_matches(|c: char| !c.is_alphanumeric()) == name_str)
                }
            })
            .map(|(_, &raw)| (raw, score_node(raw).max(BM25_SEED_SCORE)))
            .collect();

        if seed_scores.is_empty() {
            let bm25_results = self.bm25.search(query, top_k * 2);
            'outer: for result in bm25_results {
                if let Some(node_raws) = kg.document_entities.get(&result.document.id.0) {
                    for &raw in node_raws {
                        seed_scores.push((raw, BM25_SEED_SCORE));
                        if seed_scores.len() >= top_k {
                            break 'outer;
                        }
                    }
                }
            }
        }

        if seed_scores.is_empty() {
            return vec![];
        }

        let hops = self.data.graph_hops.unwrap_or(1);
        let mut scored: Vec<(u32, f32)> = kg
            .expand_neighbors_scored(&seed_scores, hops)
            .into_iter()
            .collect();
        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));

        let mut result_ids: IndexSet<DocumentId> = IndexSet::new();
        for (raw, _) in scored {
            if let Some(doc_ids) = self.node_to_docs.get(&raw) {
                for &doc_id in doc_ids {
                    result_ids.insert(doc_id);
                    if result_ids.len() >= top_k {
                        break;
                    }
                }
            }
            if result_ids.len() >= top_k {
                break;
            }
        }
        result_ids.into_iter().collect()
    }

    async fn create_embeddings(
        &self,
        data: EmbeddingsData,
        spinner: Option<Spinner>,
    ) -> Result<EmbeddingsOutput> {
        let embedding_client = self.create_embeddings_client(self.embedding_model.clone())?;
        let EmbeddingsData { texts, query } = data;
        let batch_size = self
            .data
            .batch_size
            .or_else(|| self.embedding_model.max_batch_size());
        let batch_size = match self.embedding_model.max_input_tokens() {
            Some(max_input_tokens) => {
                let x = max_input_tokens / self.data.chunk_size;
                match batch_size {
                    Some(y) => x.min(y),
                    None => x,
                }
            }
            None => batch_size.unwrap_or(1),
        };
        let mut output = vec![];
        let batch_chunks = texts.chunks(batch_size.max(1));
        let batch_chunks_len = batch_chunks.len();
        let retry_limit = env::var(get_env_name("embeddings_retry_limit"))
            .ok()
            .and_then(|v| v.parse::<u32>().ok())
            .unwrap_or(2);
        for (index, texts) in batch_chunks.enumerate() {
            progress(
                &spinner,
                format!("Creating embeddings [{}/{batch_chunks_len}]", index + 1),
            );
            let chunk_data = EmbeddingsData {
                texts: texts.to_vec(),
                query,
            };
            let mut retry = 0;
            let chunk_output = loop {
                retry += 1;
                match embedding_client.embeddings(&chunk_data).await {
                    Ok(v) => break v,
                    Err(e) if retry < retry_limit => {
                        debug!("retry {retry} failed: {e}");
                        sleep(Duration::from_secs(2u64.pow(retry - 1))).await;
                        continue;
                    }
                    Err(e) => {
                        return Err(e).with_context(|| {
                            format!("Failed to create embedding after {retry_limit} attempts")
                        })?;
                    }
                }
            };
            output.extend(chunk_output);
        }
        Ok(output)
    }
}

#[derive(Clone, Serialize, Deserialize)]
pub struct RagData {
    pub embedding_model: String,
    pub chunk_size: usize,
    pub chunk_overlap: usize,
    pub reranker_model: Option<String>,
    pub top_k: usize,
    pub batch_size: Option<usize>,
    pub next_file_id: FileId,
    pub document_paths: Vec<String>,
    pub files: IndexMap<FileId, RagFile>,
    #[serde(with = "serde_vectors")]
    pub vectors: IndexMap<DocumentId, Vec<f32>>,
    #[serde(default)]
    pub extractor_model: Option<String>,
    #[serde(default)]
    pub extractor_prompt: Option<String>,
    #[serde(default)]
    pub graph_hops: Option<usize>,
    #[serde(default)]
    pub knowledge_graph: KnowledgeGraph,
}

impl Debug for RagData {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("RagData")
            .field("embedding_model", &self.embedding_model)
            .field("chunk_size", &self.chunk_size)
            .field("chunk_overlap", &self.chunk_overlap)
            .field("reranker_model", &self.reranker_model)
            .field("top_k", &self.top_k)
            .field("batch_size", &self.batch_size)
            .field("next_file_id", &self.next_file_id)
            .field("document_paths", &self.document_paths)
            .field("files", &self.files)
            .field("extractor_model", &self.extractor_model)
            .field("extractor_prompt", &self.extractor_prompt)
            .field("graph_hops", &self.graph_hops)
            .finish()
    }
}

impl RagData {
    pub fn new(
        embedding_model: String,
        chunk_size: usize,
        chunk_overlap: usize,
        reranker_model: Option<String>,
        top_k: usize,
        batch_size: Option<usize>,
        graph: GraphRagConfig,
    ) -> Self {
        Self {
            embedding_model,
            chunk_size,
            chunk_overlap,
            reranker_model,
            top_k,
            batch_size,
            next_file_id: 0,
            document_paths: Default::default(),
            files: Default::default(),
            vectors: Default::default(),
            extractor_model: graph.extractor_model,
            extractor_prompt: graph.extractor_prompt,
            graph_hops: graph.graph_hops,
            knowledge_graph: KnowledgeGraph::default(),
        }
    }

    pub fn get(&self, id: DocumentId) -> Option<&RagDocument> {
        let (file_index, document_index) = id.split();
        let file = self.files.get(&file_index)?;
        let document = file.documents.get(document_index)?;
        Some(document)
    }

    pub fn del(&mut self, file_ids: Vec<FileId>) {
        let mut graph_doc_ids = vec![];
        for file_id in file_ids {
            if let Some(file) = self.files.swap_remove(&file_id) {
                for (document_index, _) in file.documents.iter().enumerate() {
                    let document_id = DocumentId::new(file_id, document_index);
                    self.vectors.swap_remove(&document_id);
                    graph_doc_ids.push(document_id);
                }
            }
        }
        self.knowledge_graph.remove_documents(&graph_doc_ids);
    }

    pub fn add(
        &mut self,
        next_file_id: FileId,
        files: Vec<(FileId, RagFile)>,
        document_ids: Vec<DocumentId>,
        embeddings: EmbeddingsOutput,
    ) {
        self.next_file_id = next_file_id;
        self.files.extend(files);
        self.vectors
            .extend(document_ids.into_iter().zip(embeddings));
    }

    pub fn build_hnsw(&self) -> Hnsw<'static, f32, DistCosine> {
        let hnsw = Hnsw::new(32, self.vectors.len(), 16, 200, DistCosine {});
        let list: Vec<_> = self.vectors.iter().map(|(k, v)| (v, k.0)).collect();
        hnsw.parallel_insert(&list);
        hnsw
    }

    pub fn build_bm25(&self) -> SearchEngine<DocumentId> {
        let mut documents = vec![];
        for (file_index, file) in self.files.iter() {
            for (document_index, document) in file.documents.iter().enumerate() {
                let id = DocumentId::new(*file_index, document_index);
                documents.push(bm25::Document::new(id, &document.page_content))
            }
        }
        SearchEngineBuilder::<DocumentId>::with_documents(Language::English, documents)
            .k1(1.5)
            .b(0.75)
            .build()
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RagFile {
    hash: String,
    path: String,
    documents: Vec<RagDocument>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RagDocument {
    pub page_content: String,
    pub metadata: DocumentMetadata,
}

impl RagDocument {
    pub fn new<S: Into<String>>(page_content: S) -> Self {
        RagDocument {
            page_content: page_content.into(),
            metadata: IndexMap::new(),
        }
    }
}

impl Default for RagDocument {
    fn default() -> Self {
        RagDocument {
            page_content: "".to_string(),
            metadata: IndexMap::new(),
        }
    }
}

pub type FileId = usize;

#[derive(Clone, Copy, Hash, Eq, PartialEq, Ord, PartialOrd)]
pub struct DocumentId(usize);

impl Debug for DocumentId {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let (file_index, document_index) = self.split();
        f.write_fmt(format_args!("{file_index}-{document_index}"))
    }
}

impl DocumentId {
    pub fn new(file_index: usize, document_index: usize) -> Self {
        let value = (file_index << (usize::BITS / 2)) | document_index;
        Self(value)
    }

    pub fn split(self) -> (usize, usize) {
        let value = self.0;
        let low_mask = (1 << (usize::BITS / 2)) - 1;
        let low = value & low_mask;
        let high = value >> (usize::BITS / 2);
        (high, low)
    }
}

fn select_embedding_model(models: &[&Model]) -> Result<String> {
    let max_width = models.iter().map(|v| v.id().len()).max().unwrap_or(0);
    let models: Vec<_> = models
        .iter()
        .map(|v| SelectOption::new(v.id(), v.description(), max_width))
        .collect();
    let result = Select::new("Select embedding model:", models)
        .with_formatter(&|opt| opt.value.value.clone())
        .prompt()?;
    Ok(result.value)
}

const EXTRACTOR_SKIP: &str = "Skip";

fn select_extractor_model(app: &AppConfig) -> Result<Option<String>> {
    let models = list_models(app, ModelType::Chat);
    if models.is_empty() {
        return Ok(None);
    }
    let pad = models
        .iter()
        .map(|v| v.id().len())
        .max()
        .unwrap_or(0)
        .max(EXTRACTOR_SKIP.len());
    let mut options = vec![SelectOption::new(
        EXTRACTOR_SKIP.to_string(),
        "vector + full text search only (no graph)".to_string(),
        pad,
    )];
    options.extend(
        models
            .iter()
            .map(|v| SelectOption::new(v.id(), v.description(), pad)),
    );
    let result = Select::new("Extractor model for graph-based RAG (optional):", options)
        .with_formatter(&|opt| opt.value.value.clone())
        .prompt()?;
    Ok(if result.value == EXTRACTOR_SKIP {
        None
    } else {
        Some(result.value)
    })
}

#[derive(Debug)]
struct SelectOption {
    pub value: String,
    pub display: String,
}

impl SelectOption {
    pub fn new(value: String, description: String, pad: usize) -> Self {
        let display = if description.is_empty() {
            format!("{value:<pad$}")
        } else {
            format!("{value:<pad$} ({description})")
        };
        Self { value, display }
    }
}

impl fmt::Display for SelectOption {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{}", self.display)
    }
}

fn set_chunk_size(model: &Model) -> Result<usize> {
    let default_value = model.default_chunk_size().to_string();
    let help_message = model
        .max_tokens_per_chunk()
        .map(|v| format!("The model's max_tokens is {v}"));

    let mut text = Text::new("Set chunk size:")
        .with_default(&default_value)
        .with_validator(move |text: &str| {
            let out = match text.parse::<usize>() {
                Ok(_) => Validation::Valid,
                Err(_) => Validation::Invalid("Must be a integer".into()),
            };
            Ok(out)
        });
    if let Some(help_message) = &help_message {
        text = text.with_help_message(help_message);
    }
    let value = text.prompt()?;
    value.parse().map_err(|_| anyhow!("Invalid chunk_size"))
}

fn set_graph_hops(default_value: usize) -> Result<usize> {
    let value = Text::new("Set graph expansion hops:")
        .with_default(&default_value.to_string())
        .with_help_message("Number of hops to expand from matched entities (0 = seed nodes only, 1 = direct neighbors, 2 = neighbors of neighbors)")
        .with_validator(move |text: &str| {
            let out = match text.parse::<usize>() {
                Ok(_) => Validation::Valid,
                _ => Validation::Invalid("Must be a non-negative integer".into()),
            };
            Ok(out)
        })
        .prompt()?;
    value.parse().map_err(|_| anyhow!("Invalid graph_hops"))
}

fn set_chunk_overlay(default_value: usize) -> Result<usize> {
    let value = Text::new("Set chunk overlay:")
        .with_default(&default_value.to_string())
        .with_validator(move |text: &str| {
            let out = match text.parse::<usize>() {
                Ok(_) => Validation::Valid,
                Err(_) => Validation::Invalid("Must be a integer".into()),
            };
            Ok(out)
        })
        .prompt()?;
    value.parse().map_err(|_| anyhow!("Invalid chunk_overlay"))
}

fn add_documents() -> Result<Vec<String>> {
    let text = Text::new("Add documents:")
        .with_validator(required!("This field is required"))
        .with_help_message("e.g. file;dir/;dir/**/*.{md,mdx};loader:resource;url;website/**")
        .prompt()?;
    let paths = text
        .split(';')
        .filter_map(|v| {
            let v = v.trim().to_string();
            if v.is_empty() { None } else { Some(v) }
        })
        .collect();
    Ok(paths)
}

async fn resolve_paths<T: AsRef<str>>(
    loaders: &HashMap<String, String>,
    paths: &[T],
) -> Result<(
    IndexSet<String>,
    IndexSet<String>,
    IndexSet<String>,
    IndexSet<String>,
    IndexSet<String>,
)> {
    let mut document_paths = IndexSet::new();
    let mut recursive_urls = IndexSet::new();
    let mut urls = IndexSet::new();
    let mut protocol_paths = IndexSet::new();
    let mut absolute_paths = vec![];
    for path in paths {
        let path = path.as_ref().trim();
        if is_url(path) {
            if let Some(start_url) = path.strip_suffix("**") {
                recursive_urls.insert(start_url.to_string());
            } else {
                urls.insert(path.to_string());
            }
            document_paths.insert(path.to_string());
        } else if is_loader_protocol(loaders, path) {
            protocol_paths.insert(path.to_string());
            document_paths.insert(path.to_string());
        } else {
            let resolved_path = resolve_home_dir(path);
            let absolute_path = to_absolute_path(&resolved_path)
                .with_context(|| format!("Invalid path '{path}'"))?;
            absolute_paths.push(resolved_path);
            document_paths.insert(absolute_path);
        }
    }
    let local_paths = expand_glob_paths(&absolute_paths, false).await?;
    Ok((
        document_paths,
        recursive_urls,
        urls,
        protocol_paths,
        local_paths,
    ))
}

fn progress(spinner: &Option<Spinner>, message: String) {
    if let Some(spinner) = spinner {
        let _ = spinner.set_message(message);
    }
}

fn reciprocal_rank_fusion(
    list_of_document_ids: Vec<Vec<DocumentId>>,
    list_of_weights: Vec<f32>,
    top_k: usize,
) -> Vec<DocumentId> {
    let rrf_k = top_k * 2;
    let mut map: IndexMap<DocumentId, f32> = IndexMap::new();
    for (document_ids, weight) in list_of_document_ids.into_iter().zip(list_of_weights) {
        for (index, &item) in document_ids.iter().enumerate() {
            *map.entry(item).or_default() += (1.0 / ((rrf_k + index + 1) as f32)) * weight;
        }
    }
    let mut sorted_items: Vec<(DocumentId, f32)> = map.into_iter().collect();
    sorted_items.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(Ordering::Equal));

    sorted_items
        .into_iter()
        .take(top_k)
        .map(|(v, _)| v)
        .collect()
}

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

    #[test]
    fn document_id_round_trip() {
        let id = DocumentId::new(5, 17);
        let (file, doc) = id.split();
        assert_eq!(file, 5);
        assert_eq!(doc, 17);
    }

    #[test]
    fn document_id_zero_zero() {
        let id = DocumentId::new(0, 0);
        let (file, doc) = id.split();
        assert_eq!(file, 0);
        assert_eq!(doc, 0);
    }

    #[test]
    fn document_id_large_values() {
        let id = DocumentId::new(1000, 9999);
        let (file, doc) = id.split();
        assert_eq!(file, 1000);
        assert_eq!(doc, 9999);
    }

    #[test]
    fn document_id_debug_format() {
        let id = DocumentId::new(3, 7);
        let formatted = format!("{id:?}");
        assert_eq!(formatted, "3-7");
    }

    #[test]
    fn document_id_equality() {
        let a = DocumentId::new(1, 2);
        let b = DocumentId::new(1, 2);
        assert_eq!(a, b);
    }

    #[test]
    fn document_id_inequality() {
        let a = DocumentId::new(1, 2);
        let b = DocumentId::new(1, 3);
        assert_ne!(a, b);
    }

    #[test]
    fn document_id_ordering() {
        let a = DocumentId::new(0, 1);
        let b = DocumentId::new(1, 0);
        assert!(a < b);
    }

    #[test]
    fn rag_document_new() {
        let doc = RagDocument::new("hello world");
        assert_eq!(doc.page_content, "hello world");
        assert!(doc.metadata.is_empty());
    }

    #[test]
    fn rag_document_default() {
        let doc = RagDocument::default();
        assert_eq!(doc.page_content, "");
        assert!(doc.metadata.is_empty());
    }

    #[test]
    fn rag_data_new_defaults() {
        let data = RagData::new(
            "model".into(),
            1000,
            20,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        assert_eq!(data.embedding_model, "model");
        assert_eq!(data.chunk_size, 1000);
        assert_eq!(data.chunk_overlap, 20);
        assert_eq!(data.top_k, 5);
        assert!(data.reranker_model.is_none());
        assert!(data.files.is_empty());
        assert!(data.vectors.is_empty());
        assert!(data.document_paths.is_empty());
        assert_eq!(data.next_file_id, 0);
    }

    #[test]
    fn rag_data_get_returns_document() {
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let file = RagFile {
            hash: "abc".into(),
            path: "test.txt".into(),
            documents: vec![RagDocument::new("first"), RagDocument::new("second")],
        };
        data.files.insert(0, file);

        let doc = data.get(DocumentId::new(0, 0)).unwrap();
        assert_eq!(doc.page_content, "first");

        let doc = data.get(DocumentId::new(0, 1)).unwrap();
        assert_eq!(doc.page_content, "second");
    }

    #[test]
    fn rag_data_get_returns_none_for_missing_file() {
        let data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        assert!(data.get(DocumentId::new(99, 0)).is_none());
    }

    #[test]
    fn rag_data_get_returns_none_for_missing_document() {
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let file = RagFile {
            hash: "abc".into(),
            path: "test.txt".into(),
            documents: vec![RagDocument::new("only one")],
        };
        data.files.insert(0, file);
        assert!(data.get(DocumentId::new(0, 5)).is_none());
    }

    #[test]
    fn rag_data_del_removes_files_and_vectors() {
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let file = RagFile {
            hash: "abc".into(),
            path: "test.txt".into(),
            documents: vec![RagDocument::new("doc")],
        };
        data.files.insert(0, file);
        let doc_id = DocumentId::new(0, 0);
        data.vectors.insert(doc_id, vec![0.1, 0.2, 0.3]);

        assert!(data.files.contains_key(&0));
        assert!(data.vectors.contains_key(&doc_id));

        data.del(vec![0]);

        assert!(!data.files.contains_key(&0));
        assert!(!data.vectors.contains_key(&doc_id));
    }

    #[test]
    fn rag_data_del_nonexistent_is_noop() {
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        data.del(vec![99]);
        assert!(data.files.is_empty());
    }

    #[test]
    fn rag_data_add_inserts_files_and_vectors() {
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let file = RagFile {
            hash: "xyz".into(),
            path: "new.txt".into(),
            documents: vec![RagDocument::new("content")],
        };
        let doc_id = DocumentId::new(0, 0);
        let embeddings = vec![vec![0.5, 0.6, 0.7]];

        data.add(1, vec![(0, file)], vec![doc_id], embeddings);

        assert_eq!(data.next_file_id, 1);
        assert!(data.files.contains_key(&0));
        assert!(data.vectors.contains_key(&doc_id));
        assert_eq!(data.vectors[&doc_id], vec![0.5, 0.6, 0.7]);
    }

    #[test]
    fn rag_template_contains_placeholders() {
        assert!(RAG_TEMPLATE.contains("__CONTEXT__"));
        assert!(RAG_TEMPLATE.contains("__SOURCES__"));
        assert!(RAG_TEMPLATE.contains("__INPUT__"));
    }

    #[test]
    fn get_separators_returns_language_specific() {
        let rs_seps = get_separators("rs");
        assert!(rs_seps.iter().any(|s| s.contains("fn ")));

        let py_seps = get_separators("py");
        assert!(py_seps.iter().any(|s| s.contains("def ")));

        let md_seps = get_separators("md");
        assert!(md_seps.iter().any(|s| s.contains("# ")));
    }

    #[test]
    fn get_separators_unknown_returns_defaults() {
        let seps = get_separators("xyz");
        assert_eq!(seps, DEFAULT_SEPARATORS.to_vec());
    }

    #[test]
    fn get_separators_all_known_extensions() {
        let known = [
            "c", "cc", "cpp", "go", "java", "js", "mjs", "cjs", "php", "proto", "py", "rst", "rb",
            "rs", "scala", "swift", "md", "mkd", "tex", "htm", "html", "sol",
        ];
        for ext in known {
            let seps = get_separators(ext);
            assert_ne!(
                seps,
                DEFAULT_SEPARATORS.to_vec(),
                "Extension '{ext}' should have language-specific separators"
            );
        }
    }

    #[test]
    fn rag_data_build_bm25_empty() {
        let data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let engine = data.build_bm25();
        let results = engine.search("anything", 5);
        assert!(results.is_empty());
    }

    #[test]
    fn rag_data_build_bm25_finds_documents() {
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let file = RagFile {
            hash: "h".into(),
            path: "test.txt".into(),
            documents: vec![
                RagDocument::new("rust programming language"),
                RagDocument::new("python scripting language"),
            ],
        };
        data.files.insert(0, file);

        let engine = data.build_bm25();
        let results = engine.search("rust", 5);
        assert!(!results.is_empty());
        let top = &results[0];
        let (file_idx, doc_idx) = top.document.id.split();
        assert_eq!(file_idx, 0);
        assert_eq!(doc_idx, 0);
    }

    #[test]
    fn rag_data_del_removes_graph_entities() {
        use super::graph::{ExtractedEntity, ExtractionResult};
        let mut data = RagData::new(
            "m".into(),
            100,
            10,
            None,
            5,
            None,
            GraphRagConfig::default(),
        );
        let file = RagFile {
            hash: "abc".into(),
            path: "test.txt".into(),
            documents: vec![RagDocument::new("Python is great")],
        };
        data.files.insert(0, file);
        let doc_id = DocumentId::new(0, 0);
        data.knowledge_graph.merge(
            doc_id,
            ExtractionResult {
                entities: vec![ExtractedEntity {
                    name: "Python".to_string(),
                    entity_type: "TECHNOLOGY".to_string(),
                    description: None,
                }],
                relationships: vec![],
            },
        );
        assert!(
            data.knowledge_graph.entity_index.contains_key("python"),
            "entity should exist before del"
        );
        data.del(vec![0]);
        assert!(
            !data.knowledge_graph.entity_index.contains_key("python"),
            "entity should be removed after del"
        );
    }

    #[test]
    fn reciprocal_rank_fusion_empty_lists() {
        let result = super::reciprocal_rank_fusion(vec![], vec![], 5);
        assert!(result.is_empty(), "empty input should produce empty output");
    }

    #[test]
    fn reciprocal_rank_fusion_deduplicates_across_signals() {
        let doc_a = DocumentId::new(0, 0);
        let doc_b = DocumentId::new(0, 1);
        let result = super::reciprocal_rank_fusion(
            vec![vec![doc_a, doc_b], vec![doc_a, doc_b]],
            vec![1.0, 1.0],
            5,
        );
        let unique: std::collections::HashSet<_> = result.iter().collect();
        assert_eq!(
            unique.len(),
            result.len(),
            "each document should appear at most once"
        );
        assert_eq!(result.len(), 2);
    }

    #[test]
    fn reciprocal_rank_fusion_respects_top_k() {
        let docs: Vec<DocumentId> = (0..10).map(|i| DocumentId::new(0, i)).collect();
        let result = super::reciprocal_rank_fusion(vec![docs], vec![1.0], 3);
        assert_eq!(result.len(), 3, "result should be capped at top_k=3");
    }

    #[test]
    fn reciprocal_rank_fusion_weights_affect_ranking() {
        let doc_a = DocumentId::new(0, 0);
        let doc_b = DocumentId::new(0, 1);
        let result = super::reciprocal_rank_fusion(
            vec![vec![doc_a, doc_b], vec![doc_b, doc_a]],
            vec![10.0, 1.0],
            2,
        );
        assert_eq!(
            result[0], doc_a,
            "higher-weight signal's top doc should rank first"
        );
    }
}
