//! Tests for [`FsMemoryStore`] against an **in-memory mock** [`FileSystem`]:
//! `save` writes the `<slug>.md` AND the `MEMORY.md` index line; get/list/delete;
//! upsert idempotence (same slug => one index line); malformed frontmatter
//! surfaces as [`MemoryError::Frontmatter`]; and `resolve_links` ignores broken
//! links.

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

use async_trait::async_trait;

use domain::markdown::MarkdownDoc;
use domain::memory::{Memory, MemoryFrontmatter, MemorySlug, MemoryType};
use domain::ports::{DirEntry, FileSystem, FsError, MemoryError, MemoryStore, RemotePath};
use domain::project::ProjectPath;
use infrastructure::FsMemoryStore;

/// A minimal in-memory [`FileSystem`] mock: a path -> bytes map behind a mutex.
/// `create_dir_all`/`symlink`/`list` are no-ops or derived; only read/write/exists
/// matter for the memory store.
#[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())
    }
    /// Directly seeds a file (to inject a malformed note bypassing the store).
    fn put(&self, path: &str, content: &str) {
        self.files
            .lock()
            .unwrap()
            .insert(path.to_string(), content.as_bytes().to_vec());
    }
}

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

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

fn slug(s: &str) -> MemorySlug {
    MemorySlug::new(s).unwrap()
}

fn note(slug_str: &str, hook: &str, ty: MemoryType, body: &str) -> Memory {
    Memory::new(
        MemoryFrontmatter {
            name: MemorySlug::new(slug_str).unwrap(),
            description: hook.to_string(),
            r#type: ty,
        },
        MarkdownDoc::new(body),
    )
    .unwrap()
}

#[tokio::test]
async fn missing_index_lists_empty() {
    let store = FsMemoryStore::new(MemFs::arc());
    assert!(store.list(&root()).await.unwrap().is_empty());
    assert!(store.read_index(&root()).await.unwrap().is_empty());
}

#[tokio::test]
async fn save_writes_md_and_index_line() {
    let fs = MemFs::arc();
    let store = FsMemoryStore::new(fs.clone());
    store
        .save(
            &root(),
            &note("alpha", "the hook", MemoryType::Project, "# Body"),
        )
        .await
        .unwrap();

    // The note `.md` exists with frontmatter + body.
    let md = fs
        .raw("/proj/.ideai/memory/alpha.md")
        .expect("note written");
    assert!(md.starts_with("---\n"));
    assert!(md.contains("name: alpha"));
    assert!(md.contains("description: the hook"));
    assert!(md.contains("type: project"));
    assert!(md.contains("# Body"));

    // The index has the header + the line.
    let index = fs
        .raw("/proj/.ideai/memory/MEMORY.md")
        .expect("index written");
    assert!(index.starts_with("# Memory Index"));
    assert!(index.contains("- [alpha](alpha.md) — the hook"));
}

#[tokio::test]
async fn get_roundtrips_the_saved_note() {
    let store = FsMemoryStore::new(MemFs::arc());
    let m = note("topic-x", "hook", MemoryType::Feedback, "content [[other]]");
    store.save(&root(), &m).await.unwrap();

    let got = store.get(&root(), &slug("topic-x")).await.unwrap();
    assert_eq!(got, m);
}

#[tokio::test]
async fn get_missing_is_not_found() {
    let store = FsMemoryStore::new(MemFs::arc());
    assert!(matches!(
        store.get(&root(), &slug("nope")).await.unwrap_err(),
        MemoryError::NotFound
    ));
}

#[tokio::test]
async fn list_returns_all_saved_notes() {
    let store = FsMemoryStore::new(MemFs::arc());
    store
        .save(&root(), &note("a", "ha", MemoryType::User, "A"))
        .await
        .unwrap();
    store
        .save(&root(), &note("b", "hb", MemoryType::Project, "B"))
        .await
        .unwrap();

    let mut slugs: Vec<_> = store
        .list(&root())
        .await
        .unwrap()
        .into_iter()
        .map(|m| m.slug().as_str().to_string())
        .collect();
    slugs.sort();
    assert_eq!(slugs, vec!["a".to_string(), "b".to_string()]);
}

#[tokio::test]
async fn save_is_idempotent_upsert_single_index_line() {
    let fs = MemFs::arc();
    let store = FsMemoryStore::new(fs.clone());
    store
        .save(&root(), &note("dup", "hook v1", MemoryType::Project, "v1"))
        .await
        .unwrap();
    store
        .save(&root(), &note("dup", "hook v2", MemoryType::Project, "v2"))
        .await
        .unwrap();

    // Content was replaced.
    assert_eq!(
        store
            .get(&root(), &slug("dup"))
            .await
            .unwrap()
            .body
            .as_str(),
        "v2"
    );
    // Exactly one index entry for the slug.
    let entries = store.read_index(&root()).await.unwrap();
    assert_eq!(entries.len(), 1);
    assert_eq!(entries[0].hook, "hook v2");
    // And the raw index has only one matching line.
    let index = fs.raw("/proj/.ideai/memory/MEMORY.md").unwrap();
    assert_eq!(index.matches("- [dup]").count(), 1);
}

#[tokio::test]
async fn delete_removes_index_line_and_is_not_found_twice() {
    let store = FsMemoryStore::new(MemFs::arc());
    store
        .save(&root(), &note("gone", "h", MemoryType::User, "x"))
        .await
        .unwrap();

    store.delete(&root(), &slug("gone")).await.unwrap();
    assert!(store.list(&root()).await.unwrap().is_empty());
    assert!(matches!(
        store.delete(&root(), &slug("gone")).await.unwrap_err(),
        MemoryError::NotFound
    ));
}

#[tokio::test]
async fn malformed_frontmatter_surfaces_as_error() {
    let fs = MemFs::arc();
    // Seed the index so the slug is listed, plus a malformed note file.
    fs.put(
        "/proj/.ideai/memory/MEMORY.md",
        "# Memory Index\n\n- [bad](bad.md) — h\n",
    );
    fs.put(
        "/proj/.ideai/memory/bad.md",
        "no frontmatter fence here\njust body",
    );
    let store = FsMemoryStore::new(fs);

    assert!(matches!(
        store.get(&root(), &slug("bad")).await.unwrap_err(),
        MemoryError::Frontmatter(_)
    ));
    // list propagates the same error.
    assert!(matches!(
        store.list(&root()).await.unwrap_err(),
        MemoryError::Frontmatter(_)
    ));
}

#[tokio::test]
async fn resolve_links_ignores_broken_links() {
    let store = FsMemoryStore::new(MemFs::arc());
    // `target` exists; `ghost` does not.
    store
        .save(&root(), &note("target", "h", MemoryType::Project, "T"))
        .await
        .unwrap();
    store
        .save(
            &root(),
            &note(
                "src",
                "h",
                MemoryType::Project,
                "links to [[target]] and [[ghost]]",
            ),
        )
        .await
        .unwrap();

    let resolved = store.resolve_links(&root(), &slug("src")).await.unwrap();
    let targets: Vec<_> = resolved
        .into_iter()
        .map(|l| l.target.as_str().to_string())
        .collect();
    assert_eq!(targets, vec!["target".to_string()]);
}

#[tokio::test]
async fn resolve_links_missing_source_is_not_found() {
    let store = FsMemoryStore::new(MemFs::arc());
    assert!(matches!(
        store
            .resolve_links(&root(), &slug("nope"))
            .await
            .unwrap_err(),
        MemoryError::NotFound
    ));
}
