use crate::{
    invocation::{SkillContext, SkillInput},
    outcome::SkillOutcome,
    skill::{Skill, SkillId},
};
use prism::{build_project_index, index::ProjectIndex, search::lexical_search};
use serde_json::json;

/// Resonator: semantic search over the project index.
///
/// Without the `embeddings` feature: BM25 lexical search (same index as Compass,
/// wider result window).
///
/// With the `embeddings` feature: dense-vector cosine similarity via fastembed-rs
/// (BGE-Small-EN-V1.5, 384-dim). Model files are cached at ~/.bctx/embeddings/
/// on first use. Falls back to BM25 if the model fails to load.
pub struct ResonatorSkill;

impl Skill for ResonatorSkill {
    fn id(&self) -> SkillId {
        SkillId("resonator")
    }
    fn display_name(&self) -> &str {
        "Resonator"
    }

    fn input_schema(&self) -> serde_json::Value {
        json!({
            "type": "object",
            "properties": {
                "query":      { "type": "string" },
                "root":       { "type": "string", "default": "." },
                "top_k":      { "type": "integer", "default": 15 },
                "auto_index": { "type": "boolean", "default": false,
                                "description": "Build index on-the-fly if not found" }
            },
            "required": ["query"]
        })
    }

    fn output_schema(&self) -> serde_json::Value {
        json!({ "type": "object", "properties": {
            "results":      { "type": "array" },
            "total_found":  { "type": "integer" },
            "search_mode":  { "type": "string" },
            "index_source": { "type": "string" }
        }})
    }

    fn execute(&self, input: SkillInput, ctx: &SkillContext) -> anyhow::Result<SkillOutcome> {
        let query = input.get_str("query").unwrap_or("").to_string();
        let root = input
            .get_str("root")
            .unwrap_or(&ctx.working_dir)
            .to_string();
        let top_k = input.get_u64("top_k").unwrap_or(15) as usize;
        let auto_ix = input.get_bool("auto_index").unwrap_or(false);

        // Same path as IndexStore::new() and Compass: <root>/.bctx/index.json
        let index_path = std::path::Path::new(&root).join(".bctx").join("index.json");

        let (index, index_source) = if index_path.exists() {
            let json = std::fs::read_to_string(&index_path).unwrap_or_default();
            let idx = serde_json::from_str::<ProjectIndex>(&json)
                .unwrap_or_else(|_| ProjectIndex::new(&root));
            (idx, "cached")
        } else if auto_ix {
            let idx = build_project_index(&root);
            (idx, "built-inline")
        } else {
            (ProjectIndex::new(&root), "empty")
        };

        #[cfg(feature = "embeddings")]
        let (results, mode) = {
            let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
            let cache_dir = std::path::PathBuf::from(&home)
                .join(".bctx")
                .join("embeddings");
            let _ = std::fs::create_dir_all(&cache_dir);
            match fastembed_search(&index, &query, top_k, &cache_dir) {
                Ok(r) => (r, "fastembed-cosine"),
                Err(e) => {
                    tracing::warn!("fastembed failed, falling back to BM25: {e}");
                    (lexical_search(&index, &query, top_k), "bm25-fallback")
                }
            }
        };

        #[cfg(not(feature = "embeddings"))]
        let (results, mode) = {
            let r = lexical_search(&index, &query, top_k);
            (r, "bm25-lexical")
        };

        let result_json: Vec<serde_json::Value> = results
            .iter()
            .map(|r| {
                json!({
                    "path":           r.path,
                    "symbol":         r.symbol,
                    "snippet":        r.snippet,
                    "score":          r.score,
                    "semantic_score": r.score,
                    "combined_score": r.score
                })
            })
            .collect();

        let total = result_json.len();
        Ok(SkillOutcome::success(
            "resonator",
            json!({
                "results":      result_json,
                "total_found":  total,
                "search_mode":  mode,
                "index_source": index_source,
                "note": if index_source == "empty" {
                    "No index found. Run `bctx index <path>` or pass auto_index:true."
                } else { "" }
            }),
        )
        .with_tokens(total * 20))
    }
}

// ── fastembed dense-vector search ─────────────────────────────────────────────
// Active when the `embeddings` feature is enabled.
// Uses BGE-Small-EN-V1.5 (384-dim, ~25MB quantized).
// Model files are downloaded once and cached at ~/.bctx/embeddings/.

#[cfg(feature = "embeddings")]
use once_cell::sync::OnceCell;

#[cfg(feature = "embeddings")]
static EMBEDDING_MODEL: OnceCell<fastembed::TextEmbedding> = OnceCell::new();

#[cfg(feature = "embeddings")]
fn get_model(cache_dir: &std::path::Path) -> anyhow::Result<&'static fastembed::TextEmbedding> {
    EMBEDDING_MODEL
        .get_or_try_init(|| {
            fastembed::TextEmbedding::try_new(
                fastembed::InitOptions::new(fastembed::EmbeddingModel::BGESmallENV15)
                    .with_show_download_progress(false)
                    .with_cache_dir(cache_dir.to_path_buf()),
            )
        })
        .map_err(|e| anyhow::anyhow!("embedding model init failed: {e}"))
}

#[cfg(feature = "embeddings")]
fn fastembed_search(
    index: &ProjectIndex,
    query: &str,
    top_k: usize,
    cache_dir: &std::path::Path,
) -> anyhow::Result<Vec<prism::search::SearchResult>> {
    let doc_entries: Vec<(usize, String)> = index
        .symbol_maps
        .iter()
        .enumerate()
        .map(|(i, sm)| {
            let text = [
                sm.file.as_str(),
                &sm.symbol_names.join(" "),
                &sm.import_paths.join(" "),
            ]
            .join(" ");
            (i, text)
        })
        .collect();

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

    let model = get_model(cache_dir)?;

    // Embed query + all documents in one batch (query first)
    let mut all_texts: Vec<String> = vec![query.to_string()];
    all_texts.extend(doc_entries.iter().map(|(_, t)| t.clone()));

    let embeddings = model
        .embed(all_texts, None)
        .map_err(|e| anyhow::anyhow!("embedding failed: {e}"))?;

    let (query_emb, doc_embs) = embeddings
        .split_first()
        .ok_or_else(|| anyhow::anyhow!("no embeddings returned"))?;

    let mut scored: Vec<(f32, usize)> = doc_embs
        .iter()
        .enumerate()
        .map(|(i, emb)| (cosine_sim(query_emb, emb), i))
        .collect();
    scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));

    Ok(scored
        .into_iter()
        .take(top_k)
        .filter(|(s, _)| *s > 0.01)
        .map(|(score, i)| {
            let sm = &index.symbol_maps[doc_entries[i].0];
            prism::search::SearchResult {
                path: sm.file.clone(),
                symbol: sm.symbol_names.first().cloned(),
                snippet: sm
                    .symbol_names
                    .iter()
                    .take(8)
                    .cloned()
                    .collect::<Vec<_>>()
                    .join(", "),
                score: score as f64,
            }
        })
        .collect())
}

#[cfg(feature = "embeddings")]
fn cosine_sim(a: &[f32], b: &[f32]) -> f32 {
    let dot: f32 = a.iter().zip(b).map(|(x, y)| x * y).sum();
    let a_norm: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
    let b_norm: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
    if a_norm > 0.0 && b_norm > 0.0 {
        dot / (a_norm * b_norm)
    } else {
        0.0
    }
}
