//! [`FsSkillStore`] — file implementation of the [`SkillStore`] port
//! (ARCHITECTURE §14.2, L12).
//!
//! Skills are reusable, model-agnostic workflows. They live in **two isolated
//! scopes**, each backed by its own directory but the same on-disk shape
//! (mirroring [`crate::store::FsTemplateStore`]): a small JSON index carrying the
//! metadata needed to list without parsing every `.md`, plus the Markdown bodies
//! under `md/`:
//!
//! ```text
//! <app_data_dir>/skills/            # SkillScope::Global  (shared across projects)
//! <project_root>/.ideai/skills/     # SkillScope::Project (travels with the code)
//! ├── index.json   # { version, skills: [{ id, name, contentHash }] }
//! └── md/
//!     └── <id>.md   # a skill's Markdown content
//! ```
//!
//! The two scopes never bleed into one another: a `Project` skill is invisible to
//! a `Global` listing and vice-versa, because each resolves to a different
//! directory. All I/O goes through the [`FileSystem`] port, so the adapter is
//! location-neutral (a project hosted over SSH/WSL works unchanged) and
//! Tauri-agnostic.
//!
//! Like the template store, [`delete`](SkillStore::delete) drops the index row
//! and leaves the orphaned `md/<id>.md` on disk (the [`FileSystem`] port exposes
//! no remove); since listing is index-driven, the skill is effectively gone.

use std::hash::{Hash, Hasher};
use std::sync::Arc;

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use domain::ids::SkillId;
use domain::markdown::MarkdownDoc;
use domain::ports::{FileSystem, RemotePath, SkillStore, StoreError};
use domain::project::ProjectPath;
use domain::skill::{Skill, SkillScope};

/// Directory (under app-data) holding the global skills store.
const GLOBAL_SKILLS_DIR: &str = "skills";

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

/// Sub-path of the project-scoped skills store inside `.ideai/`.
const PROJECT_SKILLS_DIR: &str = "skills";

/// Index file name inside a skills dir.
const INDEX_FILE: &str = "index.json";

/// Current schema version of the index file.
const INDEX_VERSION: u32 = 1;

/// One metadata row in `index.json` (the `.md` content lives separately).
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexEntry {
    id: SkillId,
    name: String,
    /// One-line affordance description (feature « skills à la MCP »). `#[serde(default)]`
    /// is **mandatory** for backward-compat: legacy `index.json` written before this
    /// field existed deserialise with `None` instead of failing.
    #[serde(default)]
    description: Option<String>,
    content_hash: String,
}

/// On-disk shape of a scope's `index.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct IndexDoc {
    version: u32,
    skills: Vec<IndexEntry>,
}

impl Default for IndexDoc {
    fn default() -> Self {
        Self {
            version: INDEX_VERSION,
            skills: Vec::new(),
        }
    }
}

/// A stable, dependency-free digest of Markdown content for out-of-app edit
/// detection — deterministic across runs and platforms (fixed-key hasher).
fn content_hash(md: &MarkdownDoc) -> String {
    let mut hasher = std::collections::hash_map::DefaultHasher::new();
    md.as_str().hash(&mut hasher);
    format!("{:016x}", hasher.finish())
}

/// File-backed [`SkillStore`], composing a [`FileSystem`] port. Holds only the
/// machine-global app-data dir; the project root for [`SkillScope::Project`] is
/// supplied **per call**, so a single instance serves every open project
/// (mirroring [`crate::store::IdeaiContextStore`]).
#[derive(Clone)]
pub struct FsSkillStore {
    fs: Arc<dyn FileSystem>,
    app_data_dir: String,
}

impl FsSkillStore {
    /// Builds the store from an injected [`FileSystem`] and the global app-data
    /// dir (used for [`SkillScope::Global`]). Directories are created on first
    /// write.
    #[must_use]
    pub fn new(fs: Arc<dyn FileSystem>, app_data_dir: impl Into<String>) -> Self {
        Self {
            fs,
            app_data_dir: app_data_dir.into(),
        }
    }

    /// Root directory of a scope's store. `root` is ignored for `Global`.
    fn dir(&self, scope: SkillScope, root: &ProjectPath) -> String {
        match scope {
            SkillScope::Global => {
                let base = self.app_data_dir.trim_end_matches(['/', '\\']);
                format!("{base}/{GLOBAL_SKILLS_DIR}")
            }
            SkillScope::Project => {
                let base = root.as_str().trim_end_matches(['/', '\\']);
                format!("{base}/{IDEAI_DIR}/{PROJECT_SKILLS_DIR}")
            }
        }
    }

    /// `<scope-dir>/index.json`.
    fn index_path(&self, scope: SkillScope, root: &ProjectPath) -> RemotePath {
        RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(scope, root)))
    }

    /// `<scope-dir>/md/<id>.md`.
    fn md_path(&self, scope: SkillScope, root: &ProjectPath, id: SkillId) -> RemotePath {
        RemotePath::new(format!("{}/md/{id}.md", self.dir(scope, root)))
    }

    /// Reads a scope's index, returning an empty default if absent.
    async fn read_index(
        &self,
        scope: SkillScope,
        root: &ProjectPath,
    ) -> Result<IndexDoc, StoreError> {
        match self.fs.read(&self.index_path(scope, root)).await {
            Ok(bytes) => {
                serde_json::from_slice(&bytes).map_err(|e| StoreError::Serialization(e.to_string()))
            }
            Err(domain::ports::FsError::NotFound(_)) => Ok(IndexDoc::default()),
            Err(e) => Err(StoreError::Io(e.to_string())),
        }
    }

    /// Writes a scope's index, ensuring its directory exists.
    async fn write_index(
        &self,
        scope: SkillScope,
        root: &ProjectPath,
        doc: &IndexDoc,
    ) -> Result<(), StoreError> {
        self.fs
            .create_dir_all(&RemotePath::new(self.dir(scope, root)))
            .await
            .map_err(|e| StoreError::Io(e.to_string()))?;
        let bytes =
            serde_json::to_vec_pretty(doc).map_err(|e| StoreError::Serialization(e.to_string()))?;
        self.fs
            .write(&self.index_path(scope, root), &bytes)
            .await
            .map_err(|e| StoreError::Io(e.to_string()))
    }

    /// Reconstructs the [`Skill`] for an index entry by reading its `.md`.
    async fn load(
        &self,
        scope: SkillScope,
        root: &ProjectPath,
        entry: &IndexEntry,
    ) -> Result<Skill, StoreError> {
        let bytes = self
            .fs
            .read(&self.md_path(scope, root, entry.id))
            .await
            .map_err(|e| match e {
                domain::ports::FsError::NotFound(_) => StoreError::NotFound,
                other => StoreError::Io(other.to_string()),
            })?;
        let content =
            String::from_utf8(bytes).map_err(|e| StoreError::Serialization(e.to_string()))?;
        Skill::new(
            entry.id,
            entry.name.clone(),
            MarkdownDoc::new(content),
            scope,
        )
        .map(|skill| skill.with_description(entry.description.clone()))
        .map_err(|e| StoreError::Serialization(e.to_string()))
    }
}

#[async_trait]
impl SkillStore for FsSkillStore {
    async fn list(&self, scope: SkillScope, root: &ProjectPath) -> Result<Vec<Skill>, StoreError> {
        let index = self.read_index(scope, root).await?;
        let mut out = Vec::with_capacity(index.skills.len());
        for entry in &index.skills {
            out.push(self.load(scope, root, entry).await?);
        }
        Ok(out)
    }

    async fn get(
        &self,
        scope: SkillScope,
        root: &ProjectPath,
        id: SkillId,
    ) -> Result<Skill, StoreError> {
        let index = self.read_index(scope, root).await?;
        let entry = index
            .skills
            .iter()
            .find(|e| e.id == id)
            .ok_or(StoreError::NotFound)?;
        self.load(scope, root, entry).await
    }

    async fn save(&self, skill: &Skill, root: &ProjectPath) -> Result<(), StoreError> {
        let scope = skill.scope;
        // (1) Write the Markdown content.
        self.fs
            .create_dir_all(&RemotePath::new(format!("{}/md", self.dir(scope, root))))
            .await
            .map_err(|e| StoreError::Io(e.to_string()))?;
        self.fs
            .write(
                &self.md_path(scope, root, skill.id),
                skill.content_md.as_str().as_bytes(),
            )
            .await
            .map_err(|e| StoreError::Io(e.to_string()))?;

        // (2) Upsert the index metadata.
        let mut index = self.read_index(scope, root).await?;
        let row = IndexEntry {
            id: skill.id,
            name: skill.name.clone(),
            description: skill.description.clone(),
            content_hash: content_hash(&skill.content_md),
        };
        if let Some(slot) = index.skills.iter_mut().find(|e| e.id == skill.id) {
            *slot = row;
        } else {
            index.skills.push(row);
        }
        self.write_index(scope, root, &index).await
    }

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

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::HashMap;
    use std::sync::Mutex;

    use domain::ports::{DirEntry, FsError};

    /// In-memory [`FileSystem`] mock (same minimal shape as the memory-store tests).
    #[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(())
        }
    }

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

    fn skill(id: u128, name: &str, body: &str) -> Skill {
        Skill::new(
            SkillId::from_uuid(uuid::Uuid::from_u128(id)),
            name,
            MarkdownDoc::new(body),
            SkillScope::Global,
        )
        .unwrap()
    }

    #[tokio::test]
    async fn legacy_index_without_description_loads_with_none() {
        // A legacy `index.json` written before the `description` field existed must
        // deserialise (thanks to `#[serde(default)]`) and load with `description: None`.
        let fs = MemFs::arc();
        let id = uuid::Uuid::from_u128(42);
        let legacy_index = format!(
            r#"{{"version":1,"skills":[{{"id":"{id}","name":"legacy","contentHash":"abc"}}]}}"#
        );
        fs.write(
            &RemotePath::new("/app/skills/index.json".to_owned()),
            legacy_index.as_bytes(),
        )
        .await
        .unwrap();
        fs.write(
            &RemotePath::new(format!("/app/skills/md/{id}.md")),
            b"# legacy body",
        )
        .await
        .unwrap();

        let store = FsSkillStore::new(fs, "/app");
        let listed = store.list(SkillScope::Global, &root()).await.unwrap();

        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].name, "legacy");
        assert_eq!(listed[0].description, None);
        assert_eq!(listed[0].content_md.as_str(), "# legacy body");
    }

    #[tokio::test]
    async fn save_then_load_round_trips_description() {
        let store = FsSkillStore::new(MemFs::arc(), "/app");
        let s = skill(1, "refactor", "# Refactor\n\nbody")
            .with_description(Some("Refactors code".to_owned()));

        store.save(&s, &root()).await.unwrap();

        let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
        assert_eq!(got.description.as_deref(), Some("Refactors code"));
        assert_eq!(got, s);

        // Also surfaces through `list`.
        let listed = store.list(SkillScope::Global, &root()).await.unwrap();
        assert_eq!(listed.len(), 1);
        assert_eq!(listed[0].description.as_deref(), Some("Refactors code"));
    }

    #[tokio::test]
    async fn save_without_description_loads_as_none() {
        let store = FsSkillStore::new(MemFs::arc(), "/app");
        let s = skill(2, "plain", "# Plain\n\nbody");

        store.save(&s, &root()).await.unwrap();

        let got = store.get(SkillScope::Global, &root(), s.id).await.unwrap();
        assert_eq!(got.description, None);
    }
}
