//! [`FsMemoryStore`] — file implementation of the [`MemoryStore`] port
//! (LOT A, étage 1).
//!
//! Memory notes are the project's persistent, model-agnostic knowledge base. Each
//! note is a single Markdown file with a YAML frontmatter header, stored under the
//! project's `.ideai/memory/`:
//!
//! ```text
//! <project_root>/.ideai/memory/
//! ├── MEMORY.md        # aggregated index: one `- [Title](slug.md) — hook` line per note
//! └── <slug>.md        # a note: YAML frontmatter + Markdown body
//! ```
//!
//! A note file looks like:
//!
//! ```text
//! ---
//! name: my-note
//! description: A one-line hook
//! metadata:
//!   type: project
//! ---
//! # Body
//! ...
//! ```
//!
//! The `.md` files are the **single source of truth**; `MEMORY.md` is derived and
//! kept in sync on every [`save`](MemoryStore::save)/[`delete`](MemoryStore::delete)
//! (idempotent upsert / removal of the note's line). All I/O goes through the
//! [`FileSystem`] port, so the adapter is location-neutral (SSH/WSL work unchanged)
//! and Tauri-agnostic.
//!
//! Like the sibling stores, [`delete`](MemoryStore::delete) drops the note's line
//! from `MEMORY.md` and leaves the orphaned `<slug>.md` on disk (the [`FileSystem`]
//! port exposes no remove); since listing is index-driven, the note is effectively
//! gone.

use std::sync::Arc;

use async_trait::async_trait;

use domain::markdown::MarkdownDoc;
use domain::memory::{
    Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
};
use domain::ports::{
    FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
};
use domain::project::ProjectPath;

/// The `.ideai/` directory name inside a project root.
const IDEAI_DIR: &str = ".ideai";

/// Sub-path of the memory store inside `.ideai/`.
const MEMORY_DIR: &str = "memory";

/// Aggregated index file name inside the memory dir.
const INDEX_FILE: &str = "MEMORY.md";

/// First line of the aggregated index.
const INDEX_HEADER: &str = "# Memory Index";

/// File-backed [`MemoryStore`], composing a [`FileSystem`] port. The project root
/// is supplied **per call**, so a single instance serves every open project
/// (mirroring [`crate::store::FsSkillStore`]).
#[derive(Clone)]
pub struct FsMemoryStore {
    fs: Arc<dyn FileSystem>,
}

impl FsMemoryStore {
    /// Builds the store from an injected [`FileSystem`]. Directories are created
    /// on first write.
    #[must_use]
    pub fn new(fs: Arc<dyn FileSystem>) -> Self {
        Self { fs }
    }

    /// `<root>/.ideai/memory`.
    fn dir(&self, root: &ProjectPath) -> String {
        let base = root.as_str().trim_end_matches(['/', '\\']);
        format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}")
    }

    /// `<memory-dir>/<slug>.md`.
    fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath {
        RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str()))
    }

    /// `<memory-dir>/MEMORY.md`.
    fn index_path(&self, root: &ProjectPath) -> RemotePath {
        RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(root)))
    }

    /// Reads and parses a note by slug.
    async fn load(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
        let bytes = self
            .fs
            .read(&self.md_path(root, slug))
            .await
            .map_err(|e| match e {
                FsError::NotFound(_) => MemoryError::NotFound,
                other => MemoryError::Io(other.to_string()),
            })?;
        let text = String::from_utf8(bytes).map_err(|e| MemoryError::Io(e.to_string()))?;
        parse_note(&text)
    }

    /// Reads the raw `MEMORY.md` text, or `None` if it does not exist yet.
    async fn read_index_text(&self, root: &ProjectPath) -> Result<Option<String>, MemoryError> {
        match self.fs.read(&self.index_path(root)).await {
            Ok(bytes) => String::from_utf8(bytes)
                .map(Some)
                .map_err(|e| MemoryError::Io(e.to_string())),
            Err(FsError::NotFound(_)) => Ok(None),
            Err(e) => Err(MemoryError::Io(e.to_string())),
        }
    }

    /// Rewrites `MEMORY.md` from the given entries, ensuring the dir exists.
    async fn write_index(
        &self,
        root: &ProjectPath,
        entries: &[MemoryIndexEntry],
    ) -> Result<(), MemoryError> {
        self.fs
            .create_dir_all(&RemotePath::new(self.dir(root)))
            .await
            .map_err(|e| MemoryError::Io(e.to_string()))?;
        let text = render_index(entries);
        self.fs
            .write(&self.index_path(root), text.as_bytes())
            .await
            .map_err(|e| MemoryError::Io(e.to_string()))
    }

    /// Lists the slugs known to the index (index-driven listing).
    async fn index_slugs(&self, root: &ProjectPath) -> Result<Vec<MemorySlug>, MemoryError> {
        Ok(self
            .read_index(root)
            .await?
            .into_iter()
            .map(|e| e.slug)
            .collect())
    }
}

#[async_trait]
impl MemoryStore for FsMemoryStore {
    async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
        let slugs = self.index_slugs(root).await?;
        let mut out = Vec::with_capacity(slugs.len());
        for slug in &slugs {
            out.push(self.load(root, slug).await?);
        }
        Ok(out)
    }

    async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
        self.load(root, slug).await
    }

    async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
        // (1) Write the note file.
        self.fs
            .create_dir_all(&RemotePath::new(self.dir(root)))
            .await
            .map_err(|e| MemoryError::Io(e.to_string()))?;
        let text = render_note(memory);
        self.fs
            .write(&self.md_path(root, memory.slug()), text.as_bytes())
            .await
            .map_err(|e| MemoryError::Io(e.to_string()))?;

        // (2) Upsert the index line idempotently (same slug => one line).
        let mut entries = self.read_index(root).await?;
        let row = memory.index_entry();
        if let Some(slot) = entries.iter_mut().find(|e| e.slug == row.slug) {
            *slot = row;
        } else {
            entries.push(row);
        }
        self.write_index(root, &entries).await
    }

    async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> {
        let mut entries = self.read_index(root).await?;
        let before = entries.len();
        entries.retain(|e| &e.slug != slug);
        if entries.len() == before {
            return Err(MemoryError::NotFound);
        }
        // The orphaned `<slug>.md` is left on disk (no FileSystem delete); the
        // index no longer references it, so it is effectively gone.
        self.write_index(root, &entries).await
    }

    async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
        match self.read_index_text(root).await? {
            Some(text) => Ok(parse_index(&text)),
            None => Ok(Vec::new()),
        }
    }

    async fn resolve_links(
        &self,
        root: &ProjectPath,
        slug: &MemorySlug,
    ) -> Result<Vec<MemoryLink>, MemoryError> {
        let memory = self.load(root, slug).await?;
        let known = self.index_slugs(root).await?;
        // Keep only links whose target resolves to a known note (ignore broken).
        Ok(memory
            .outgoing_links()
            .into_iter()
            .filter(|link| known.contains(&link.target))
            .collect())
    }
}

// ---------------------------------------------------------------------------
// NaiveMemoryRecall — the default, dependency-free MemoryRecall (LOT B).
// ---------------------------------------------------------------------------

/// Heuristic divisor turning a character count into an approximate token count
/// (~4 characters per token, the usual rule of thumb). Shared by every
/// [`MemoryRecall`] adapter so the budget semantics stay identical (DRY).
pub(crate) const CHARS_PER_TOKEN: usize = 4;

/// Approximate token cost of an index entry's textual payload — the single
/// budget-cost function shared by every [`MemoryRecall`] adapter (naïve, vector,
/// adaptive), so truncation semantics are identical across them (Liskov / DRY).
pub(crate) fn entry_cost(entry: &MemoryIndexEntry) -> usize {
    let chars = entry.title.len() + entry.hook.len();
    chars.div_ceil(CHARS_PER_TOKEN)
}

/// Greedily takes entries in the given order while their accumulated
/// [`entry_cost`] stays within `budget`; stops at the first entry that would
/// exceed it (and drops every entry after). A `budget` of `0` yields an empty
/// vec. This is the shared truncation used by every recall adapter.
pub(crate) fn truncate_to_budget(
    entries: impl IntoIterator<Item = MemoryIndexEntry>,
    budget: usize,
) -> Vec<MemoryIndexEntry> {
    if budget == 0 {
        return Vec::new();
    }
    let mut spent = 0usize;
    let mut out = Vec::new();
    for entry in entries {
        let cost = entry_cost(&entry);
        if spent + cost > budget {
            break;
        }
        spent += cost;
        out.push(entry);
    }
    out
}

/// Total approximate token cost of an index — a **pure** function of the entries,
/// used by [`crate::store::AdaptiveMemoryRecall`] to decide naïve vs. vector
/// recall without any I/O.
#[must_use]
pub fn index_token_size(entries: &[MemoryIndexEntry]) -> usize {
    entries.iter().map(entry_cost).sum()
}

/// The default [`MemoryRecall`]: dependency-free, ignores semantic relevance.
///
/// It composes an [`Arc<dyn MemoryStore>`], reads the aggregated index via
/// [`MemoryStore::read_index`], and returns the entries **in index order**,
/// truncated to fit the query's token budget. It is the baseline against which a
/// future `VectorMemoryRecall` (LOT C) is substitutable.
///
/// ## Budget semantics
/// `token_budget` is an *approximate* budget. Each entry's cost is estimated as
/// `ceil((title.len() + hook.len()) / 4)` tokens (≈ 4 chars/token, counting only
/// the index line's textual payload). Entries are taken in order, accumulating
/// their cost; the first entry whose inclusion would exceed the budget — and every
/// entry after it — is dropped. A budget of `0` therefore yields an empty list;
/// an empty or missing memory yields an empty list without error.
#[derive(Clone)]
pub struct NaiveMemoryRecall {
    store: Arc<dyn MemoryStore>,
}

impl NaiveMemoryRecall {
    /// Builds the recall adapter from a composed [`MemoryStore`].
    #[must_use]
    pub fn new(store: Arc<dyn MemoryStore>) -> Self {
        Self { store }
    }
}

#[async_trait]
impl MemoryRecall for NaiveMemoryRecall {
    async fn recall(
        &self,
        root: &ProjectPath,
        query: &MemoryQuery,
    ) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
        // Budget-0 short-circuits before any I/O: a zero budget can hold no entry,
        // so there is nothing to read (homogeneous with every recall adapter).
        if query.token_budget == 0 {
            return Ok(Vec::new());
        }
        let entries = self.store.read_index(root).await?;
        Ok(truncate_to_budget(entries, query.token_budget))
    }
}

// ---------------------------------------------------------------------------
// On-disk format: YAML frontmatter + body, and the MEMORY.md index.
//
// We hand-roll a tiny, well-scoped YAML reader/writer for exactly the frontmatter
// shape we own (`name`, `description`, `metadata.type`). This keeps the crate free
// of a YAML dependency for a fixed, simple schema; any deviation surfaces as a
// `MemoryError::Frontmatter`.
// ---------------------------------------------------------------------------

/// Renders a note to its on-disk `---`-fenced frontmatter + body form.
fn render_note(memory: &Memory) -> String {
    let fm = &memory.frontmatter;
    format!(
        "---\nname: {}\ndescription: {}\nmetadata:\n  type: {}\n---\n{}",
        fm.name.as_str(),
        fm.description,
        type_to_str(fm.r#type),
        memory.body.as_str(),
    )
}

/// Parses a note from its on-disk form.
fn parse_note(text: &str) -> Result<Memory, MemoryError> {
    let rest = text
        .strip_prefix("---\n")
        .or_else(|| text.strip_prefix("---\r\n"))
        .ok_or_else(|| MemoryError::Frontmatter("missing opening `---` fence".to_string()))?;

    // Find the closing `---` fence at the start of a line.
    let (fm_block, body) = split_frontmatter(rest)
        .ok_or_else(|| MemoryError::Frontmatter("missing closing `---` fence".to_string()))?;

    let frontmatter = parse_frontmatter(fm_block)?;
    Memory::new(frontmatter, MarkdownDoc::new(body))
        .map_err(|e| MemoryError::Frontmatter(e.to_string()))
}

/// Splits the post-opening-fence text into `(frontmatter_block, body)` at the
/// closing `---` line. Returns `None` if no closing fence is present.
fn split_frontmatter(rest: &str) -> Option<(&str, &str)> {
    let mut offset = 0;
    for line in rest.split_inclusive('\n') {
        let trimmed = line.trim_end_matches(['\n', '\r']);
        if trimmed == "---" {
            let fm = &rest[..offset];
            let body = &rest[offset + line.len()..];
            return Some((fm, body));
        }
        offset += line.len();
    }
    None
}

/// Parses the frontmatter key/values into a validated [`MemoryFrontmatter`].
fn parse_frontmatter(block: &str) -> Result<MemoryFrontmatter, MemoryError> {
    let err = |reason: &str| MemoryError::Frontmatter(reason.to_string());

    let mut name: Option<String> = None;
    let mut description: Option<String> = None;
    let mut type_str: Option<String> = None;
    let mut in_metadata = false;

    for raw in block.lines() {
        if raw.trim().is_empty() {
            continue;
        }
        let indented = raw.starts_with(' ') || raw.starts_with('\t');
        let (key, value) = raw
            .split_once(':')
            .ok_or_else(|| err("frontmatter line missing `:`"))?;
        let key = key.trim();
        let value = value.trim();

        if !indented {
            in_metadata = false;
            match key {
                "name" => name = Some(value.to_string()),
                "description" => description = Some(value.to_string()),
                "metadata" => {
                    in_metadata = true;
                    if !value.is_empty() {
                        return Err(err("`metadata` must be a nested block"));
                    }
                }
                _ => return Err(err("unknown frontmatter key")),
            }
        } else if in_metadata && key == "type" {
            type_str = Some(value.to_string());
        } else {
            return Err(err("unexpected indented frontmatter line"));
        }
    }

    let name = name.ok_or_else(|| err("missing `name`"))?;
    let description = description.ok_or_else(|| err("missing `description`"))?;
    let type_str = type_str.ok_or_else(|| err("missing `metadata.type`"))?;

    let name = MemorySlug::new(name).map_err(|e| MemoryError::Frontmatter(e.to_string()))?;
    let r#type = str_to_type(&type_str).ok_or_else(|| err("unknown `metadata.type` value"))?;

    Ok(MemoryFrontmatter {
        name,
        description,
        r#type,
    })
}

/// Renders the aggregated `MEMORY.md` index.
fn render_index(entries: &[MemoryIndexEntry]) -> String {
    let mut out = String::from(INDEX_HEADER);
    out.push('\n');
    if !entries.is_empty() {
        out.push('\n');
        for e in entries {
            out.push_str(&format!(
                "- [{}]({}.md) — {}\n",
                e.title,
                e.slug.as_str(),
                e.hook
            ));
        }
    }
    out
}

/// Parses the `MEMORY.md` index lines back into structured entries. Lines that do
/// not match the `- [Title](slug.md) — hook` shape are skipped (tolerant read);
/// the `type` is not stored in the index line and defaults to
/// [`MemoryType::Reference`].
fn parse_index(text: &str) -> Vec<MemoryIndexEntry> {
    let mut out = Vec::new();
    for line in text.lines() {
        let line = line.trim();
        if !line.starts_with("- [") {
            continue;
        }
        if let Some(entry) = parse_index_line(line) {
            out.push(entry);
        }
    }
    out
}

/// Parses one `- [Title](slug.md) — hook` line.
fn parse_index_line(line: &str) -> Option<MemoryIndexEntry> {
    let rest = line.strip_prefix("- [")?;
    let (title, rest) = rest.split_once("](")?;
    let (target, rest) = rest.split_once(')')?;
    let slug_str = target.strip_suffix(".md").unwrap_or(target);
    let slug = MemorySlug::new(slug_str).ok()?;
    let hook = rest.trim_start().strip_prefix('—').unwrap_or(rest).trim();
    Some(MemoryIndexEntry {
        slug,
        title: title.to_string(),
        hook: hook.to_string(),
        r#type: MemoryType::Reference,
    })
}

/// Maps a [`MemoryType`] to its YAML/string form (matches serde camelCase).
fn type_to_str(t: MemoryType) -> &'static str {
    match t {
        MemoryType::User => "user",
        MemoryType::Feedback => "feedback",
        MemoryType::Project => "project",
        MemoryType::Reference => "reference",
    }
}

/// Parses a [`MemoryType`] from its string form.
fn str_to_type(s: &str) -> Option<MemoryType> {
    match s {
        "user" => Some(MemoryType::User),
        "feedback" => Some(MemoryType::Feedback),
        "project" => Some(MemoryType::Project),
        "reference" => Some(MemoryType::Reference),
        _ => None,
    }
}
