//! Skill entity — reusable, model-agnostic workflows assignable to agents.
//!
//! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but
//! without any dependency on a particular model's `/command` syntax
//! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the
//! agent's generated convention file at activation — there is no proprietary
//! CLI mechanism involved.

use serde::{Deserialize, Serialize};

use crate::error::DomainError;
use crate::ids::SkillId;
use crate::markdown::MarkdownDoc;

/// Where a skill lives, which also selects the store used to resolve it.
///
/// - [`SkillScope::Global`] skills are stored in the global IDE store
///   (`<app_data>/IdeA/skills/`) and reusable across projects.
/// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are
///   specific to one project.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SkillScope {
    /// Reusable across projects (global IDE store).
    Global,
    /// Specific to a single project (`.ideai/skills/`).
    Project,
}

/// A reusable workflow assignable to one or more agents.
///
/// Invariants enforced here:
/// - `name` non-empty,
/// - `content_md` non-empty (an empty skill carries no behaviour).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Skill {
    /// Stable identifier.
    pub id: SkillId,
    /// Display name (also used as the `.md` file stem on disk).
    pub name: String,
    /// Optional one-line description surfaced as an *affordance* at high altitude
    /// in an agent's convention file (« à la MCP »), so the agent knows the skill
    /// exists and what it does without reading the whole body. `None`/empty ⇒
    /// [`Skill::effective_description`] falls back to the first line of the body.
    #[serde(default)]
    pub description: Option<String>,
    /// Markdown body — the workflow injected into an agent's convention file.
    pub content_md: MarkdownDoc,
    /// Scope (selects the backing store).
    pub scope: SkillScope,
}

impl Skill {
    /// Builds a validated skill.
    ///
    /// # Errors
    /// - [`DomainError::EmptyField`] if `name` is empty,
    /// - [`DomainError::EmptyField`] if `content_md` is empty.
    pub fn new(
        id: SkillId,
        name: impl Into<String>,
        content_md: MarkdownDoc,
        scope: SkillScope,
    ) -> Result<Self, DomainError> {
        let name = name.into();
        crate::validation::non_empty(&name, "skill.name")?;
        if content_md.is_empty() {
            return Err(DomainError::EmptyField {
                field: "skill.content_md",
            });
        }
        Ok(Self {
            id,
            name,
            description: None,
            content_md,
            scope,
        })
    }

    /// Returns this skill with its one-line [`Skill::description`] set (builder).
    ///
    /// Kept separate from [`Skill::new`] so every existing call site is untouched
    /// (the field is purely additive). An empty/whitespace description is treated
    /// as absent by [`Skill::effective_description`], so no normalisation here.
    #[must_use]
    pub fn with_description(mut self, description: Option<String>) -> Self {
        self.description = description;
        self
    }

    /// The description to surface for this skill, with a deterministic fallback.
    ///
    /// Returns [`Skill::description`] when present and non-blank (trimmed);
    /// otherwise the **first non-empty line** of the body, stripped of any leading
    /// `#` (Markdown heading marker) and trimmed. When the body has no usable line
    /// either, returns an **empty `String`** (chosen over `Option` so callers can
    /// format it unconditionally — an empty affordance line is harmless).
    #[must_use]
    pub fn effective_description(&self) -> String {
        if let Some(desc) = &self.description {
            let trimmed = desc.trim();
            if !trimmed.is_empty() {
                return trimmed.to_owned();
            }
        }
        self.content_md
            .as_str()
            .lines()
            .map(str::trim)
            .find(|line| !line.is_empty())
            .map(|line| line.trim_start_matches('#').trim().to_owned())
            .unwrap_or_default()
    }

    /// Returns a copy of this skill with replaced content, re-validating the
    /// non-empty invariant and **preserving** the [`Skill::description`].
    ///
    /// # Errors
    /// [`DomainError::EmptyField`] if `content_md` is empty.
    pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
        Ok(
            Skill::new(self.id, self.name.clone(), content_md, self.scope)?
                .with_description(self.description.clone()),
        )
    }
}

/// A reference from an agent to one assigned skill.
///
/// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these.
/// The `scope` is kept alongside the id so the application layer knows which
/// store to resolve the skill from without a global lookup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillRef {
    /// The assigned skill.
    pub skill_id: SkillId,
    /// Scope of the assigned skill (selects its store).
    pub scope: SkillScope,
}

impl SkillRef {
    /// Builds a reference to an assigned skill.
    #[must_use]
    pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self {
        Self { skill_id, scope }
    }
}

impl From<&Skill> for SkillRef {
    fn from(skill: &Skill) -> Self {
        Self::new(skill.id, skill.scope)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ids::SkillId;

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

    // -- effective_description --------------------------------------------

    #[test]
    fn effective_description_returns_explicit_description_trimmed() {
        // (a) explicit non-empty description ⇒ returned trimmed.
        let s = skill("# Heading\n\nbody").with_description(Some("  Refactors code  ".to_owned()));
        assert_eq!(s.effective_description(), "Refactors code");
    }

    #[test]
    fn effective_description_falls_back_to_first_body_line_without_hash() {
        // (b) description None ⇒ first non-empty body line, `#` stripped & trimmed.
        let s = skill("\n\n#   My Skill Title  \n\nrest of body");
        assert_eq!(s.effective_description(), "My Skill Title");
    }

    #[test]
    fn effective_description_first_line_without_heading_marker() {
        // (b') first non-empty line that is not a heading is returned verbatim (trimmed).
        let s = skill("  just a plain first line\nsecond");
        assert_eq!(s.effective_description(), "just a plain first line");
    }

    #[test]
    fn effective_description_blank_body_yields_empty_string() {
        // (c) body that is only whitespace ⇒ empty String.
        let s = skill("   \n\t\n   ");
        assert_eq!(s.effective_description(), "");
    }

    #[test]
    fn effective_description_blank_description_falls_back_to_body() {
        // (d) description Some("") / whitespace-only ⇒ treated as absent ⇒ body fallback.
        let empty = skill("# Body Heading").with_description(Some(String::new()));
        assert_eq!(empty.effective_description(), "Body Heading");

        let spaces = skill("# Body Heading").with_description(Some("   \t  ".to_owned()));
        assert_eq!(spaces.effective_description(), "Body Heading");
    }

    // -- with_content preserves description -------------------------------

    #[test]
    fn with_content_preserves_description() {
        let s = skill("old body").with_description(Some("kept".to_owned()));
        let updated = s.with_content(MarkdownDoc::new("new body")).unwrap();
        assert_eq!(updated.description.as_deref(), Some("kept"));
        assert_eq!(updated.content_md.as_str(), "new body");
    }

    #[test]
    fn with_content_rejects_empty_body() {
        let s = skill("old body");
        assert!(s.with_content(MarkdownDoc::new("")).is_err());
    }

    // -- serde: description is additive / defaults to None ----------------

    #[test]
    fn deserialize_legacy_skill_without_description_defaults_to_none() {
        // A skill JSON written before `description` existed must deserialise with
        // `description: None` thanks to `#[serde(default)]`.
        let id = uuid::Uuid::from_u128(7);
        let json =
            format!(r##"{{"id":"{id}","name":"legacy","contentMd":"# body","scope":"global"}}"##);
        let parsed: Skill = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.description, None);
        assert_eq!(parsed.name, "legacy");
    }

    #[test]
    fn serde_round_trip_with_description() {
        let original = skill("# body").with_description(Some("affordance".to_owned()));
        let json = serde_json::to_string(&original).unwrap();
        let back: Skill = serde_json::from_str(&json).unwrap();
        assert_eq!(back, original);
        assert_eq!(back.description.as_deref(), Some("affordance"));
    }
}
