//! Tests for [`NaiveMemoryRecall`] (LOT B, étage 1): the default, dependency-free
//! [`MemoryRecall`] that reads the aggregated index via a composed [`MemoryStore`]
//! and truncates the entries to fit the query's token budget.
//!
//! Budget semantics under test (mirroring the adapter doc): each entry costs
//! `ceil((title.len() + hook.len()) / 4)` tokens; entries are taken in index order,
//! accumulating cost; the first entry that would exceed the budget — and every one
//! after it — is dropped. `token_budget == 0` ⇒ empty; an empty/absent memory ⇒
//! empty, never an error.
//!
//! Two backings are exercised:
//! - the **real** [`FsMemoryStore`] over an in-memory [`FileSystem`] mock (same
//!   `MemFs` shape as `memory_store.rs`), giving end-to-end coverage of the
//!   index round-trip feeding recall;
//! - a tiny **counting fake** `MemoryStore` to assert the `budget == 0`
//!   short-circuit performs *no* `read_index` call.

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, MemoryLink, MemorySlug, MemoryType,
};
use domain::ports::{
    DirEntry, FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
};
use domain::project::ProjectPath;
use infrastructure::{FsMemoryStore, NaiveMemoryRecall};

// ---------------------------------------------------------------------------
// In-memory FileSystem mock (same minimal shape as `memory_store.rs`).
// ---------------------------------------------------------------------------

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

impl MemFs {
    fn arc() -> Arc<Self> {
        Arc::new(Self::default())
    }
}

#[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(())
    }
}

// ---------------------------------------------------------------------------
// Builders
// ---------------------------------------------------------------------------

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

/// A note whose index `title` is its slug (length `slug.len()`) and whose `hook`
/// is `desc` — so the recall cost is `ceil((slug.len() + desc.len()) / 4)`.
fn note(slug: &str, desc: &str) -> Memory {
    Memory::new(
        MemoryFrontmatter {
            name: MemorySlug::new(slug).unwrap(),
            description: desc.to_string(),
            r#type: MemoryType::Project,
        },
        MarkdownDoc::new("# body"),
    )
    .unwrap()
}

/// Saves notes (in order) through the real store and returns a recall over it.
async fn recall_over(notes: &[Memory]) -> NaiveMemoryRecall {
    let store = Arc::new(FsMemoryStore::new(MemFs::arc()));
    for n in notes {
        store.save(&root(), n).await.unwrap();
    }
    NaiveMemoryRecall::new(store)
}

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

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

// ---------------------------------------------------------------------------
// Empty / absent memory ⇒ empty list, never an error.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn absent_index_recalls_empty_without_error() {
    let recall = NaiveMemoryRecall::new(Arc::new(FsMemoryStore::new(MemFs::arc())));
    let out = recall.recall(&root(), &query(1000)).await.unwrap();
    assert!(out.is_empty(), "absent memory must recall empty: {out:?}");
}

// ---------------------------------------------------------------------------
// Budget 0 ⇒ empty list (and short-circuits before touching the store).
// ---------------------------------------------------------------------------

#[tokio::test]
async fn zero_budget_recalls_empty() {
    let recall = recall_over(&[note("aaaa", "bbbb"), note("cccc", "dddd")]).await;
    let out = recall.recall(&root(), &query(0)).await.unwrap();
    assert!(out.is_empty(), "budget 0 must recall empty: {out:?}");
}

/// A `MemoryStore` that counts `read_index` calls and panics on every other
/// method — proving the recall path only reads the index, and that a `0` budget
/// short-circuits before any read.
#[derive(Default)]
struct CountingStore {
    read_index_calls: AtomicUsize,
}

#[async_trait]
impl MemoryStore for CountingStore {
    async fn list(&self, _root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
        panic!("recall must not call list")
    }
    async fn get(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<Memory, MemoryError> {
        panic!("recall must not call get")
    }
    async fn save(&self, _root: &ProjectPath, _memory: &Memory) -> Result<(), MemoryError> {
        panic!("recall must not call save")
    }
    async fn delete(&self, _root: &ProjectPath, _slug: &MemorySlug) -> Result<(), MemoryError> {
        panic!("recall must not call delete")
    }
    async fn read_index(&self, _root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
        self.read_index_calls.fetch_add(1, Ordering::SeqCst);
        Ok(Vec::new())
    }
    async fn resolve_links(
        &self,
        _root: &ProjectPath,
        _slug: &MemorySlug,
    ) -> Result<Vec<MemoryLink>, MemoryError> {
        panic!("recall must not call resolve_links")
    }
}

#[tokio::test]
async fn zero_budget_short_circuits_no_read_index() {
    let store = Arc::new(CountingStore::default());
    let recall = NaiveMemoryRecall::new(store.clone());
    let out = recall.recall(&root(), &query(0)).await.unwrap();
    assert!(out.is_empty());
    assert_eq!(
        store.read_index_calls.load(Ordering::SeqCst),
        0,
        "budget 0 must short-circuit before reading the index"
    );
}

#[tokio::test]
async fn nonzero_budget_reads_index_once() {
    let store = Arc::new(CountingStore::default());
    let recall = NaiveMemoryRecall::new(store.clone());
    let _ = recall.recall(&root(), &query(10)).await.unwrap();
    assert_eq!(store.read_index_calls.load(Ordering::SeqCst), 1);
}

// ---------------------------------------------------------------------------
// Sufficient budget ⇒ all entries, in index order.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn ample_budget_returns_all_entries_in_index_order() {
    // Saved order: alpha, beta, gamma → index order is the same.
    let recall = recall_over(&[
        note("alpha", "first hook"),
        note("beta", "second hook"),
        note("gamma", "third hook"),
    ])
    .await;
    let out = recall.recall(&root(), &query(100_000)).await.unwrap();
    assert_eq!(slugs(&out), vec!["alpha", "beta", "gamma"]);
}

// ---------------------------------------------------------------------------
// Intermediate budget ⇒ exact truncation at the cumulative-cost boundary.
//
// Three notes, each title.len()==4 and hook.len()==4 ⇒ 8 chars ⇒ ceil(8/4)==2
// tokens apiece. Cumulative costs: 2, 4, 6.
// ---------------------------------------------------------------------------

#[tokio::test]
async fn intermediate_budget_truncates_at_exact_boundary() {
    let notes = [
        note("aaaa", "bbbb"),
        note("cccc", "dddd"),
        note("eeee", "ffff"),
    ];
    let recall = recall_over(&notes).await;

    // Budget 1 < 2 ⇒ first entry already exceeds ⇒ none.
    assert!(recall.recall(&root(), &query(1)).await.unwrap().is_empty());

    // Budget 2 == cost(1) ⇒ exactly the first entry; second (would reach 4) dropped.
    assert_eq!(
        slugs(&recall.recall(&root(), &query(2)).await.unwrap()),
        vec!["aaaa"]
    );

    // Budget 3 < 4 ⇒ still only the first.
    assert_eq!(
        slugs(&recall.recall(&root(), &query(3)).await.unwrap()),
        vec!["aaaa"]
    );

    // Budget 4 == cost(1)+cost(2) ⇒ first two; third (would reach 6) dropped.
    assert_eq!(
        slugs(&recall.recall(&root(), &query(4)).await.unwrap()),
        vec!["aaaa", "cccc"]
    );

    // Budget 5 < 6 ⇒ still the first two.
    assert_eq!(
        slugs(&recall.recall(&root(), &query(5)).await.unwrap()),
        vec!["aaaa", "cccc"]
    );

    // Budget 6 == total ⇒ all three.
    assert_eq!(
        slugs(&recall.recall(&root(), &query(6)).await.unwrap()),
        vec!["aaaa", "cccc", "eeee"]
    );
}

// ---------------------------------------------------------------------------
// ceil rounding: an odd character count rounds *up* (5 chars ⇒ 2 tokens).
// ---------------------------------------------------------------------------

#[tokio::test]
async fn entry_cost_rounds_up_via_ceil() {
    // slug "ab" (2) + hook "c" (1) = 3 chars ⇒ ceil(3/4) == 1 token.
    // slug "de" (2) + hook "fgh" (3) = 5 chars ⇒ ceil(5/4) == 2 tokens.
    let recall = recall_over(&[note("ab", "c"), note("de", "fgh")]).await;

    // Budget 1 covers only the 1-token first entry; the 2-token second is dropped.
    assert_eq!(
        slugs(&recall.recall(&root(), &query(1)).await.unwrap()),
        vec!["ab"]
    );
    // Budget 2 still cannot fit the second on top of the first (1 + 2 = 3 > 2).
    assert_eq!(
        slugs(&recall.recall(&root(), &query(2)).await.unwrap()),
        vec!["ab"]
    );
    // Budget 3 fits both (1 + 2 == 3).
    assert_eq!(
        slugs(&recall.recall(&root(), &query(3)).await.unwrap()),
        vec!["ab", "de"]
    );
}
