//! Tests for the étage-2 vector recall (LOT C, §14.5.3):
//! - [`should_use_vector`] — the pure routing decision (full truth table);
//! - [`AdaptiveMemoryRecall`] — the étage-1/étage-2 switch, with naïve equivalence
//!   on strategy `none`, routing by size/budget, and the never-fail-hard fallback;
//! - [`VectorMemoryRecall`] — semantic ranking with the deterministic
//!   [`HashEmbedder`], budget truncation, the derived `vectors.json` cache and its
//!   embedder-id/dimension invalidation, and `StubEmbedder` best-effort behaviour;
//! - [`HashEmbedder`] / [`StubEmbedder`] embedder contracts;
//! - [`embedder_from_profile`] strategy → embedder mapping.
//!
//! Everything is in-memory and deterministic (`MemFs` + `HashEmbedder`); no real
//! I/O, no network.

use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};

use async_trait::async_trait;

use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemoryIndexEntry, MemorySlug, MemoryType};
use domain::ports::{
    DirEntry, Embedder, EmbedderError, FileSystem, FsError, MemoryQuery, MemoryRecall, MemoryStore,
    RemotePath,
};
use domain::profile::{EmbedderProfile, EmbedderStrategy};
use domain::project::ProjectPath;
use infrastructure::{
    embedder_from_profile, onnx_model_is_cached, should_use_vector, AdaptiveMemoryRecall,
    FsMemoryStore, HashEmbedder, NaiveMemoryRecall, StubEmbedder, VectorMemoryRecall,
    ONNX_CACHE_SUBDIR, RECOMMENDED_ONNX_MODELS,
};

// ---------------------------------------------------------------------------
// In-memory FileSystem mock (same shape as `memory_recall.rs` / `memory_store.rs`),
// with raw read access to assert on the derived `vectors.json`.
// ---------------------------------------------------------------------------

#[derive(Default)]
struct MemFs {
    files: Mutex<HashMap<String, Vec<u8>>>,
}

impl MemFs {
    fn arc() -> Arc<Self> {
        Arc::new(Self::default())
    }
    fn raw(&self, path: &str) -> Option<String> {
        self.files
            .lock()
            .unwrap()
            .get(path)
            .map(|b| String::from_utf8(b.clone()).unwrap())
    }
}

#[async_trait]
impl FileSystem for MemFs {
    async fn read(&self, path: &RemotePath) -> Result<Vec<u8>, FsError> {
        self.files
            .lock()
            .unwrap()
            .get(path.as_str())
            .cloned()
            .ok_or_else(|| FsError::NotFound(path.as_str().to_string()))
    }
    async fn write(&self, path: &RemotePath, data: &[u8]) -> Result<(), FsError> {
        self.files
            .lock()
            .unwrap()
            .insert(path.as_str().to_string(), data.to_vec());
        Ok(())
    }
    async fn exists(&self, path: &RemotePath) -> Result<bool, FsError> {
        Ok(self.files.lock().unwrap().contains_key(path.as_str()))
    }
    async fn create_dir_all(&self, _path: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
    async fn list(&self, _path: &RemotePath) -> Result<Vec<DirEntry>, FsError> {
        Ok(Vec::new())
    }
    async fn symlink(&self, _src: &RemotePath, _dst: &RemotePath) -> Result<(), FsError> {
        Ok(())
    }
}

const VECTORS_PATH: &str = "/proj/.ideai/memory/.index/vectors.json";

// ---------------------------------------------------------------------------
// A counting wrapper embedder: delegates to an inner embedder and counts the
// number of *texts* it was asked to embed — to assert the derived cache avoids
// recomputing note vectors on a second recall.
// ---------------------------------------------------------------------------

struct SpyEmbedder {
    inner: HashEmbedder,
    embedded_texts: AtomicUsize,
}

impl SpyEmbedder {
    fn new(id: &str, dim: usize) -> Self {
        Self {
            inner: HashEmbedder::new(id, dim),
            embedded_texts: AtomicUsize::new(0),
        }
    }
}

#[async_trait]
impl Embedder for SpyEmbedder {
    fn id(&self) -> &str {
        self.inner.id()
    }
    async fn embed(&self, texts: &[String]) -> Result<Vec<Vec<f32>>, EmbedderError> {
        self.embedded_texts.fetch_add(texts.len(), Ordering::SeqCst);
        self.inner.embed(texts).await
    }
    fn dimension(&self) -> usize {
        self.inner.dimension()
    }
}

// ---------------------------------------------------------------------------
// Builders.
// ---------------------------------------------------------------------------

fn root() -> ProjectPath {
    ProjectPath::new("/proj").unwrap()
}

/// A note whose index line is `<slug> <hook>` (title == slug). The vector recall
/// embeds exactly `"{slug} {hook}"`, so picking distinct vocabulary words in
/// `slug`/`hook` makes cosine similarity to a chosen query deterministic.
fn note(slug: &str, hook: &str) -> Memory {
    Memory::new(
        MemoryFrontmatter {
            name: MemorySlug::new(slug).unwrap(),
            description: hook.to_string(),
            r#type: MemoryType::Project,
        },
        MarkdownDoc::new("# body"),
    )
    .unwrap()
}

fn query(text: &str, budget: usize) -> MemoryQuery {
    MemoryQuery {
        text: text.to_string(),
        token_budget: budget,
    }
}

fn slugs(entries: &[MemoryIndexEntry]) -> Vec<String> {
    entries
        .iter()
        .map(|e| e.slug.as_str().to_string())
        .collect()
}

async fn store_with(notes: &[Memory], fs: Arc<MemFs>) -> Arc<FsMemoryStore> {
    let store = Arc::new(FsMemoryStore::new(fs));
    for n in notes {
        store.save(&root(), n).await.unwrap();
    }
    store
}

// ===========================================================================
// 1. should_use_vector — pure truth table.
// ===========================================================================

#[test]
fn should_use_vector_none_strategy_is_always_false() {
    // Even when size > budget, a `none` strategy never routes to the vector path.
    assert!(!should_use_vector(0, 0, EmbedderStrategy::None));
    assert!(!should_use_vector(100, 10, EmbedderStrategy::None));
    assert!(!should_use_vector(10, 100, EmbedderStrategy::None));
    assert!(!should_use_vector(usize::MAX, 0, EmbedderStrategy::None));
}

#[test]
fn should_use_vector_non_none_iff_size_exceeds_budget() {
    for strategy in [
        EmbedderStrategy::LocalOnnx,
        EmbedderStrategy::LocalServer,
        EmbedderStrategy::Api,
    ] {
        // size > budget ⇒ true.
        assert!(should_use_vector(11, 10, strategy), "{strategy:?}: 11>10");
        assert!(should_use_vector(1, 0, strategy), "{strategy:?}: 1>0");
        // size == budget ⇒ false (exact boundary).
        assert!(!should_use_vector(10, 10, strategy), "{strategy:?}: 10==10");
        assert!(!should_use_vector(0, 0, strategy), "{strategy:?}: 0==0");
        // size < budget ⇒ false.
        assert!(!should_use_vector(9, 10, strategy), "{strategy:?}: 9<10");
    }
}

// ===========================================================================
// 2. HashEmbedder — determinism, dimension, L2 normalisation.
// ===========================================================================

#[tokio::test]
async fn hash_embedder_is_deterministic() {
    let e = HashEmbedder::new("h", 64);
    let a = e.embed(&["hello world".to_string()]).await.unwrap();
    let b = e.embed(&["hello world".to_string()]).await.unwrap();
    assert_eq!(a, b, "same text must give the same vector");
}

#[tokio::test]
async fn hash_embedder_dimension_is_consistent() {
    let e = HashEmbedder::new("h", 48);
    assert_eq!(e.dimension(), 48);
    let out = e
        .embed(&["alpha beta".to_string(), "gamma".to_string()])
        .await
        .unwrap();
    assert_eq!(out.len(), 2, "one vector per input, order preserved");
    assert!(
        out.iter().all(|v| v.len() == 48),
        "every vector is dimension-long"
    );
}

#[tokio::test]
async fn hash_embedder_vectors_are_l2_normalised() {
    let e = HashEmbedder::new("h", 64);
    let out = e.embed(&["one two three four".to_string()]).await.unwrap();
    let norm = out[0].iter().map(|x| x * x).sum::<f32>().sqrt();
    assert!((norm - 1.0).abs() < 1e-5, "norm ≈ 1, got {norm}");
}

#[tokio::test]
async fn hash_embedder_empty_text_yields_zero_vector_no_panic() {
    // No tokens ⇒ zero vector (norm 0, left unnormalised). Must not panic / NaN.
    let e = HashEmbedder::new("h", 16);
    let out = e.embed(&["".to_string()]).await.unwrap();
    assert_eq!(out[0].len(), 16);
    assert!(out[0].iter().all(|x| *x == 0.0));
}

// ===========================================================================
// 3. StubEmbedder — always Unsupported, never panics.
// ===========================================================================

#[tokio::test]
async fn stub_embedder_returns_unsupported() {
    let e = StubEmbedder::new("stub", 32, "localOnnx");
    assert_eq!(e.dimension(), 32);
    let err = e.embed(&["x".to_string()]).await.unwrap_err();
    assert!(matches!(err, EmbedderError::Unsupported(_)), "got {err:?}");
}

// ===========================================================================
// 4. embedder_from_profile — strategy → embedder mapping.
// ===========================================================================

#[tokio::test]
async fn embedder_from_profile_maps_each_strategy() {
    // The ONNX cache dir is irrelevant to every strategy exercised here (none of
    // these short-circuiting cases loads a model), so a throwaway path is fine.
    let onnx_cache = std::path::Path::new("/unused-onnx-cache");

    // none ⇒ no embedder (recall stays naïve).
    assert!(embedder_from_profile(&EmbedderProfile::none(), onnx_cache).is_none());

    // localOnnx without the `vector-onnx` feature maps to a StubEmbedder
    // (Unsupported); with the feature it is the real OnnxEmbedder. Dimension is
    // preserved either way (the real engine validates dimension only at embed time).
    let onnx =
        EmbedderProfile::new("e", "E", EmbedderStrategy::LocalOnnx, None, None, None, 24).unwrap();
    let embedder =
        embedder_from_profile(&onnx, onnx_cache).expect("localOnnx must yield an embedder");
    assert_eq!(embedder.dimension(), 24);
    #[cfg(not(feature = "vector-onnx"))]
    {
        let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
        assert!(
            matches!(err, EmbedderError::Unsupported(_)),
            "localOnnx stub must be Unsupported without vector-onnx, got {err:?}"
        );
    }

    // localServer / api: a stub *without* the `vector-http` feature (default,
    // dependency-free build), a real HttpEmbedder *with* it. Either way an embedder
    // is produced with the declared dimension.
    for strategy in [EmbedderStrategy::LocalServer, EmbedderStrategy::Api] {
        let profile = EmbedderProfile::new("e", "E", strategy, None, None, None, 24).unwrap();
        let embedder = embedder_from_profile(&profile, onnx_cache)
            .unwrap_or_else(|| panic!("{strategy:?} must yield an embedder"));
        assert_eq!(embedder.dimension(), 24);

        #[cfg(not(feature = "vector-http"))]
        {
            let err = embedder.embed(&["t".to_string()]).await.unwrap_err();
            assert!(
                matches!(err, EmbedderError::Unsupported(_)),
                "{strategy:?} stub must be Unsupported without vector-http, got {err:?}"
            );
        }
    }
}

// ===========================================================================
// 5. VectorMemoryRecall — semantic ranking, budget, cache.
// ===========================================================================

/// Builds a vector recall over `notes` with the given embedder, sharing one fs.
fn vector_recall(
    embedder: Arc<dyn Embedder>,
    store: Arc<FsMemoryStore>,
    fs: Arc<MemFs>,
) -> VectorMemoryRecall {
    VectorMemoryRecall::new(embedder, store, fs)
}

#[tokio::test]
async fn vector_recall_ranks_closest_note_first() {
    // Notes have disjoint vocabularies; the query shares all tokens with `beta`,
    // so `beta` must rank first (cosine == 1 for it, ~0 for the others).
    let fs = MemFs::arc();
    let notes = [
        note("alpha", "apple orange grape"),
        note("beta", "kiwi mango papaya"),
        note("gamma", "carrot potato onion"),
    ];
    let store = store_with(&notes, fs.clone()).await;
    let embedder = Arc::new(HashEmbedder::new("hash", 256));
    let recall = vector_recall(embedder, store, fs);

    // Query identical to beta's index line ("beta kiwi mango papaya").
    let out = recall
        .recall(&root(), &query("beta kiwi mango papaya", 100_000))
        .await
        .unwrap();
    assert_eq!(
        out.first().map(|e| e.slug.as_str()),
        Some("beta"),
        "ranked: {:?}",
        slugs(&out)
    );
    // All three notes fit the ample budget.
    assert_eq!(out.len(), 3);
}

#[tokio::test]
async fn vector_recall_truncates_to_budget() {
    // The closest note ("beta apricot apricot") must survive a tight budget while
    // the rest are pruned. Cost of an entry = ceil((title+hook chars)/4).
    let fs = MemFs::arc();
    let notes = [
        note("beta", "kiwi"),                 // "beta kiwi" → 8 chars → 2 tokens
        note("alpha", "apple orange grape"),  // larger
        note("gamma", "carrot potato onion"), // larger
    ];
    let store = store_with(&notes, fs.clone()).await;
    let embedder = Arc::new(HashEmbedder::new("hash", 256));
    let recall = vector_recall(embedder, store, fs);

    // Budget 2 fits only the top-ranked `beta` (cost 2); the next would exceed.
    let out = recall
        .recall(&root(), &query("beta kiwi", 2))
        .await
        .unwrap();
    assert_eq!(
        slugs(&out),
        vec!["beta"],
        "budget must keep only the closest"
    );
}

#[tokio::test]
async fn vector_recall_zero_budget_and_empty_memory_are_empty() {
    let fs = MemFs::arc();
    let embedder = Arc::new(HashEmbedder::new("hash", 64));

    // Empty memory ⇒ empty, no error.
    let empty_store = Arc::new(FsMemoryStore::new(fs.clone()));
    let recall = vector_recall(embedder.clone(), empty_store, fs.clone());
    assert!(recall
        .recall(&root(), &query("anything", 1000))
        .await
        .unwrap()
        .is_empty());

    // Zero budget ⇒ empty, even with notes present.
    let store = store_with(&[note("a", "x"), note("b", "y")], fs.clone()).await;
    let recall = vector_recall(embedder, store, fs);
    assert!(recall
        .recall(&root(), &query("a x", 0))
        .await
        .unwrap()
        .is_empty());
}

#[tokio::test]
async fn vector_recall_writes_and_reuses_the_derived_cache() {
    let fs = MemFs::arc();
    let notes = [note("alpha", "apple"), note("beta", "banana")];
    let store = store_with(&notes, fs.clone()).await;
    let spy = Arc::new(SpyEmbedder::new("spy", 128));
    let recall = VectorMemoryRecall::new(spy.clone(), store, fs.clone());

    // First recall: embeds 2 note texts + 1 query text = 3.
    let _ = recall
        .recall(&root(), &query("alpha apple", 100_000))
        .await
        .unwrap();
    let after_first = spy.embedded_texts.load(Ordering::SeqCst);
    assert_eq!(after_first, 3, "2 notes + 1 query embedded on a cold cache");

    // The derived cache file now exists with both slugs and the embedder id.
    let doc = fs.raw(VECTORS_PATH).expect("vectors.json written");
    assert!(
        doc.contains("\"embedderId\": \"spy\""),
        "tagged with embedder id: {doc}"
    );
    assert!(doc.contains("\"alpha\""), "alpha vector cached: {doc}");
    assert!(doc.contains("\"beta\""), "beta vector cached: {doc}");

    // Second recall: note vectors come from the cache; only the query is embedded.
    let _ = recall
        .recall(&root(), &query("beta banana", 100_000))
        .await
        .unwrap();
    let delta = spy.embedded_texts.load(Ordering::SeqCst) - after_first;
    assert_eq!(
        delta, 1,
        "cache reuse ⇒ only the query is re-embedded, got {delta}"
    );
}

#[tokio::test]
async fn vector_recall_invalidates_cache_on_embedder_id_change() {
    let fs = MemFs::arc();
    let notes = [note("alpha", "apple"), note("beta", "banana")];
    let store = store_with(&notes, fs.clone()).await;

    // Warm the cache with embedder "first".
    let first = Arc::new(SpyEmbedder::new("first", 128));
    VectorMemoryRecall::new(first.clone(), store.clone(), fs.clone())
        .recall(&root(), &query("alpha apple", 100_000))
        .await
        .unwrap();
    assert!(fs
        .raw(VECTORS_PATH)
        .unwrap()
        .contains("\"embedderId\": \"first\""));

    // A different embedder id must invalidate ⇒ recompute all note vectors.
    let second = Arc::new(SpyEmbedder::new("second", 128));
    VectorMemoryRecall::new(second.clone(), store, fs.clone())
        .recall(&root(), &query("beta banana", 100_000))
        .await
        .unwrap();
    // 2 notes recomputed + 1 query embedded against the new embedder.
    assert_eq!(
        second.embedded_texts.load(Ordering::SeqCst),
        3,
        "id change must invalidate and recompute note vectors"
    );
    // The file is now tagged with the new embedder id.
    assert!(fs
        .raw(VECTORS_PATH)
        .unwrap()
        .contains("\"embedderId\": \"second\""));
}

#[tokio::test]
async fn vector_recall_invalidates_cache_on_dimension_change() {
    let fs = MemFs::arc();
    let notes = [note("alpha", "apple"), note("beta", "banana")];
    let store = store_with(&notes, fs.clone()).await;

    // Warm the cache at dimension 64.
    let dim64 = Arc::new(SpyEmbedder::new("same-id", 64));
    VectorMemoryRecall::new(dim64, store.clone(), fs.clone())
        .recall(&root(), &query("alpha apple", 100_000))
        .await
        .unwrap();

    // Same id, different dimension ⇒ invalidation, all note vectors recomputed.
    let dim128 = Arc::new(SpyEmbedder::new("same-id", 128));
    VectorMemoryRecall::new(dim128.clone(), store, fs.clone())
        .recall(&root(), &query("beta banana", 100_000))
        .await
        .unwrap();
    assert_eq!(
        dim128.embedded_texts.load(Ordering::SeqCst),
        3,
        "dimension change must invalidate and recompute"
    );
    assert!(fs.raw(VECTORS_PATH).unwrap().contains("\"dimension\": 128"));
}

#[tokio::test]
async fn vector_recall_with_stub_embedder_errors_without_panic() {
    // The vector recall surfaces the embedder error as a MemoryError (the Adaptive
    // switch turns that into a naïve fallback — covered below). Here we just pin
    // that StubEmbedder is best-effort: an Err, never a panic.
    let fs = MemFs::arc();
    let store = store_with(&[note("alpha", "apple")], fs.clone()).await;
    let stub = Arc::new(StubEmbedder::new("stub", 64, "localOnnx"));
    let recall = VectorMemoryRecall::new(stub, store, fs);
    let res = recall.recall(&root(), &query("alpha apple", 1000)).await;
    assert!(
        res.is_err(),
        "stub embedder ⇒ Err on the vector path, got {res:?}"
    );
}

// ===========================================================================
// 6. AdaptiveMemoryRecall — the étage-1/étage-2 switch.
// ===========================================================================

/// Assembles an [`AdaptiveMemoryRecall`] over `notes` for the given strategy and
/// embedder, sharing one in-memory fs / store.
async fn adaptive_over(
    notes: &[Memory],
    strategy: EmbedderStrategy,
    embedder: Arc<dyn Embedder>,
) -> (Arc<MemFs>, AdaptiveMemoryRecall) {
    let fs = MemFs::arc();
    let store = store_with(notes, fs.clone()).await;
    let naive: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
    let vector: Arc<dyn MemoryRecall> =
        Arc::new(VectorMemoryRecall::new(embedder, store.clone(), fs.clone()));
    let adaptive = AdaptiveMemoryRecall::new(naive, vector, store, strategy);
    (fs, adaptive)
}

#[tokio::test]
async fn adaptive_none_strategy_matches_naive_exactly() {
    let notes = [
        note("alpha", "apple orange grape"),
        note("beta", "kiwi mango papaya"),
        note("gamma", "carrot potato onion"),
    ];
    let fs = MemFs::arc();
    let store = store_with(&notes, fs.clone()).await;
    let naive = NaiveMemoryRecall::new(store.clone());

    // Adaptive with strategy `none` (embedder present but never used).
    let naive_dyn: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
    let vector_dyn: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
        Arc::new(HashEmbedder::new("hash", 64)),
        store.clone(),
        fs.clone(),
    ));
    let adaptive = AdaptiveMemoryRecall::new(naive_dyn, vector_dyn, store, EmbedderStrategy::None);

    // For several budgets, adaptive(none) == naive, byte-for-byte.
    for budget in [0usize, 3, 6, 100, 100_000] {
        let q = query("kiwi mango papaya", budget);
        let expected = naive.recall(&root(), &q).await.unwrap();
        let got = adaptive.recall(&root(), &q).await.unwrap();
        assert_eq!(
            got, expected,
            "strategy none must equal naive at budget {budget}"
        );
    }
}

#[tokio::test]
async fn adaptive_small_memory_routes_to_naive_order() {
    // Memory smaller than the budget ⇒ naïve path ⇒ index order, even with a
    // non-none strategy. The query would, on the vector path, reorder by
    // similarity; index order proves naïve was used.
    let notes = [
        note("alpha", "apple"),
        note("beta", "banana"),
        note("gamma", "grape"),
    ];
    let (_fs, adaptive) = adaptive_over(
        &notes,
        EmbedderStrategy::LocalServer,
        Arc::new(HashEmbedder::new("hash", 256)),
    )
    .await;

    // Huge budget ⇒ memory_size <= budget ⇒ naïve ⇒ index order alpha,beta,gamma.
    let out = adaptive
        .recall(&root(), &query("gamma grape", 100_000))
        .await
        .unwrap();
    assert_eq!(
        slugs(&out),
        vec!["alpha", "beta", "gamma"],
        "naïve keeps index order"
    );
}

#[tokio::test]
async fn adaptive_large_memory_routes_to_vector_ranking() {
    // Memory larger than the budget + non-none strategy ⇒ vector path ⇒ the note
    // closest to the query ranks first (not index order).
    let notes = [
        note("alpha", "apple orange grape"),
        note("beta", "kiwi mango papaya"),
        note("gamma", "carrot potato onion"),
    ];
    let (_fs, adaptive) = adaptive_over(
        &notes,
        EmbedderStrategy::LocalOnnx,
        Arc::new(HashEmbedder::new("hash", 256)),
    )
    .await;

    // Budget smaller than the total memory size forces the vector route; the query
    // matches `gamma`, so `gamma` must be first (≠ index order, where it is last).
    let total = {
        let store = FsMemoryStore::new(MemFs::arc());
        for n in &notes {
            store.save(&root(), n).await.unwrap();
        }
        infrastructure::index_token_size(&store.read_index(&root()).await.unwrap())
    };
    assert!(total > 4, "sanity: memory must exceed the chosen budget");

    let out = adaptive
        .recall(&root(), &query("carrot potato onion", total - 1))
        .await
        .unwrap();
    assert_eq!(
        out.first().map(|e| e.slug.as_str()),
        Some("gamma"),
        "vector path ranks the closest note first: {:?}",
        slugs(&out)
    );
}

#[tokio::test]
async fn adaptive_falls_back_to_naive_when_embedder_fails() {
    // Large memory + non-none strategy routes to the vector path; with a
    // StubEmbedder that path errors, and Adaptive must degrade to naïve — never a
    // hard error. Result therefore equals the naïve recall (index order, truncated).
    let notes = [
        note("alpha", "apple orange grape"),
        note("beta", "kiwi mango papaya"),
        note("gamma", "carrot potato onion"),
    ];
    let fs = MemFs::arc();
    let store = store_with(&notes, fs.clone()).await;
    let naive_ref = NaiveMemoryRecall::new(store.clone());

    let naive_dyn: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(store.clone()));
    let vector_dyn: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
        Arc::new(StubEmbedder::new("stub", 64, "localOnnx")),
        store.clone(),
        fs.clone(),
    ));
    let adaptive =
        AdaptiveMemoryRecall::new(naive_dyn, vector_dyn, store.clone(), EmbedderStrategy::Api);

    let total = infrastructure::index_token_size(&store.read_index(&root()).await.unwrap());
    let q = query("carrot potato onion", total - 1);

    let got = adaptive
        .recall(&root(), &q)
        .await
        .expect("fallback must not error");
    let expected = naive_ref.recall(&root(), &q).await.unwrap();
    assert_eq!(
        got, expected,
        "embedder failure must degrade to the naïve result"
    );
    assert!(!got.is_empty(), "the degraded result still returns entries");
}

// ===========================================================================
// 7. Always-available ONNX data/inspection helpers (compiled WITHOUT the
//    `vector-onnx` feature — they are pure data + pure-FS, no `fastembed`).
// ===========================================================================

/// A unique, self-cleaning scratch dir under the OS temp dir (project convention;
/// see tests/project_store.rs), used to exercise `onnx_model_is_cached` on real FS.
struct TempDir(std::path::PathBuf);
impl TempDir {
    fn new() -> Self {
        let p = std::env::temp_dir().join(format!("idea-onnx-cached-{}", uuid::Uuid::new_v4()));
        std::fs::create_dir_all(&p).unwrap();
        Self(p)
    }
    fn path(&self) -> &std::path::Path {
        &self.0
    }
}
impl Drop for TempDir {
    fn drop(&mut self) {
        let _ = std::fs::remove_dir_all(&self.0);
    }
}

#[test]
fn recommended_onnx_models_advertise_e5_small_384() {
    assert_eq!(
        RECOMMENDED_ONNX_MODELS.len(),
        1,
        "exactly one curated model"
    );
    let m = RECOMMENDED_ONNX_MODELS[0];
    assert_eq!(m.id, "multilingual-e5-small");
    assert_eq!(m.dimension, 384);
    assert!(
        m.recommended,
        "the single curated model is the recommended default"
    );
}

#[test]
fn onnx_cache_subdir_is_stable() {
    assert_eq!(ONNX_CACHE_SUBDIR, "embedders/onnx");
}

#[test]
fn onnx_model_is_cached_false_on_empty_dir() {
    // A fresh, empty cache dir ⇒ nothing cached.
    let tmp = TempDir::new();
    assert!(
        !onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
        "an empty cache dir reports no cached model"
    );
}

#[test]
fn onnx_model_is_cached_false_when_dir_missing() {
    // A path that does not exist must report `false`, never panic.
    let missing = std::path::Path::new("/idea-onnx-cache-definitely-missing-xyz");
    assert!(!onnx_model_is_cached(missing, "multilingual-e5-small"));
}

#[test]
fn onnx_model_is_cached_true_when_model_subdir_present_and_nonempty() {
    // The heuristic looks for a non-empty subdirectory whose (lowercased) name
    // *contains* the model token (slashes/underscores normalised to `-`), mirroring
    // hf-hub's `models--<org>--<name>` layout.
    let tmp = TempDir::new();
    let model_dir = tmp
        .path()
        .join("models--Qdrant--multilingual-e5-small-onnx");
    std::fs::create_dir_all(&model_dir).unwrap();
    // The subdir must be non-empty to count as "cached".
    std::fs::write(model_dir.join("model.onnx"), b"not really a model").unwrap();

    assert!(
        onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
        "a non-empty matching subdir ⇒ cached"
    );
}

#[test]
fn onnx_model_is_cached_false_when_matching_subdir_is_empty() {
    // A matching but *empty* subdir does not count as a present cached model.
    let tmp = TempDir::new();
    std::fs::create_dir_all(
        tmp.path()
            .join("models--Qdrant--multilingual-e5-small-onnx"),
    )
    .unwrap();
    assert!(
        !onnx_model_is_cached(tmp.path(), "multilingual-e5-small"),
        "an empty matching subdir is not a cached model"
    );
}

// ===========================================================================
// 8. AdaptiveMemoryRecall — the étage-1/étage-2 switch (cont.).
// ===========================================================================

#[tokio::test]
async fn adaptive_empty_memory_and_zero_budget_are_empty() {
    // Liskov common contract: empty list, never an error, both paths.
    let fs = MemFs::arc();
    let empty_store = Arc::new(FsMemoryStore::new(fs.clone()));
    let naive: Arc<dyn MemoryRecall> = Arc::new(NaiveMemoryRecall::new(empty_store.clone()));
    let vector: Arc<dyn MemoryRecall> = Arc::new(VectorMemoryRecall::new(
        Arc::new(HashEmbedder::new("hash", 64)),
        empty_store.clone(),
        fs.clone(),
    ));
    let adaptive =
        AdaptiveMemoryRecall::new(naive, vector, empty_store, EmbedderStrategy::LocalOnnx);

    // Empty memory.
    assert!(adaptive
        .recall(&root(), &query("anything", 1000))
        .await
        .unwrap()
        .is_empty());
    // Zero budget.
    assert!(adaptive
        .recall(&root(), &query("anything", 0))
        .await
        .unwrap()
        .is_empty());
}
