//! Skills manager extension.
//!
//! This module provides:
//! - Loading skills from a directory tree of `SKILL.md` files into [`SubAgent`] instances.
//! - Reading loaded skill files via the [`SkillManager`] tool.
//!
//! Each `SKILL.md` follows the [Agent Skills specification](https://agentskills.io):
//! YAML frontmatter (`---` delimiters) with `name`, `description`, and optional
//! `license`, `compatibility`, `metadata`, `allowed-tools` fields. The Markdown body
//! becomes the agent's `instructions`.
//!
//! Skill names use kebab-case on disk (e.g. `my-skill`); they are normalised to
//! snake_case (`my_skill`) when loaded as [`SubAgent`] instances.

use anda_core::{
    Agent, BoxError, FunctionDefinition, Resource, Tool, ToolOutput, select_resources,
};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::{
    any::Any,
    collections::BTreeMap,
    ffi::OsStr,
    path::{Path, PathBuf},
    sync::Arc,
};

use crate::{
    context::BaseCtx,
    extension::fs::{ensure_file_size_within_limit, ensure_regular_file},
    subagent::{SubAgent, SubAgentSet},
};

mod types;
pub use types::*;

// ---------------------------------------------------------------------------
// SkillManager
// ---------------------------------------------------------------------------

const MAX_SKILL_FILE_BYTES: u64 = 512 * 1024;

/// Arguments for reading a skill via the [`SkillManager`] tool.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct SkillArgs {
    /// Skill name in kebab-case (e.g. `pdf-processing`). 1-64 chars,
    /// lowercase alphanumeric and hyphens only.
    pub name: String,
}

/// Content returned by the [`SkillManager`] tool.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct SkillContentOutput {
    /// Skill name from the SKILL.md frontmatter.
    pub name: String,
    /// Normalised subagent name exposed by this skill.
    pub agent_name: String,
    /// Skill description from the SKILL.md frontmatter.
    pub description: String,
    /// Path to SKILL.md, relative to the configured skills directory when possible.
    pub path: String,
    /// Full SKILL.md content including YAML frontmatter and Markdown body.
    pub content: String,
}

/// Manages skills loaded from `SKILL.md` files on disk.
///
/// [`SkillManager`] implements [`Tool<BaseCtx>`] so that LLMs can inspect skill
/// files at runtime. Skills loaded here are exposed as [`SubAgent`] instances
/// that the engine can invoke.
pub struct SkillManager {
    skills_dir: PathBuf,
    skills: RwLock<BTreeMap<String, Skill>>,
    description: String,
    default_skill_tools: Vec<String>,
}

static DEFAULT_SKILL_TOOLS: &[&str] = &[
    "shell",
    "read_file",
    "search_file",
    "write_file",
    "edit_file",
    "todo",
    "tools_select",
];

impl SkillManager {
    /// Tool name used for registration.
    pub const NAME: &'static str = "skills_manager";

    /// Create a new, empty manager rooted at `skills_dir`.
    pub fn new(skills_dir: PathBuf) -> Self {
        Self {
            skills: RwLock::new(BTreeMap::new()),
            description: format!(
                "Load reusable skills following the Agent Skills specification and read \
            a skill's SKILL.md content by name. Agent Skills are folders of instructions, \
            scripts, and resources that agents can follow directly or invoke as subagents. Skills: {}",
                skills_dir.display()
            ),
            skills_dir,
            default_skill_tools: DEFAULT_SKILL_TOOLS.iter().map(|s| s.to_string()).collect(),
        }
    }

    pub fn with_description(mut self, description: String) -> Self {
        self.description = description;
        self
    }

    pub fn with_default_skill_tools(mut self, tools: Vec<String>) -> Self {
        self.default_skill_tools = tools;
        self
    }

    fn with_default_tools(&self, agent: SubAgent) -> SubAgent {
        let mut tools = self.default_skill_tools.clone();
        for tool in agent.tools {
            if !tools.contains(&tool) {
                tools.push(tool);
            }
        }

        SubAgent { tools, ..agent }
    }

    async fn read_text_file(&self, path: &Path, max_size: u64) -> Result<String, BoxError> {
        let meta = tokio::fs::symlink_metadata(path).await.map_err(|err| {
            format!(
                "Failed to inspect file metadata (path: {}): {err}",
                path.display()
            )
        })?;
        ensure_regular_file(&meta, path, "Reading multiply-linked files is not allowed")?;
        ensure_file_size_within_limit(&meta, path, max_size)?;

        let data = tokio::fs::read(path)
            .await
            .map_err(|err| format!("Failed to read file (path: {}): {err}", path.display()))?;
        String::from_utf8(data).map_err(|_| {
            format!(
                "Only UTF-8 skill files are supported by skills_manager (path: {})",
                path.display()
            )
            .into()
        })
    }

    async fn find_skill_dir(&self, name: &str) -> Result<Option<PathBuf>, BoxError> {
        validate_skill_name(name)?;

        let mut matches = Vec::new();

        {
            let skills = self.skills.read();
            for skill in skills.values() {
                let dir_name_matches = skill.base_dir.file_name() == Some(OsStr::new(name));
                if (skill.frontmatter.name == name || dir_name_matches)
                    && !matches.iter().any(|path| path == &skill.base_dir)
                {
                    matches.push(skill.base_dir.clone());
                }
            }
        }

        if self.skills_dir.is_dir() {
            for path in find_skill_files(&self.skills_dir).await? {
                let Some(base_dir) = path.parent() else {
                    continue;
                };
                let base_dir = base_dir.to_path_buf();
                let dir_name_matches = base_dir.file_name() == Some(OsStr::new(name));
                let frontmatter_name_matches = if dir_name_matches {
                    true
                } else if let Ok(content) = tokio::fs::read_to_string(&path).await {
                    parse_skill_md(base_dir.clone(), &content)
                        .map(|skill| skill.frontmatter.name == name)
                        .unwrap_or(false)
                } else {
                    false
                };

                if frontmatter_name_matches
                    && !matches.iter().any(|candidate| candidate == &base_dir)
                {
                    matches.push(base_dir);
                }
            }
        }

        match matches.len() {
            0 => Ok(None),
            1 => Ok(matches.pop()),
            _ => Err(format!(
                "multiple skills named {:?} exist under {}",
                name,
                self.skills_dir.display()
            )
            .into()),
        }
    }

    fn display_path(&self, path: &Path) -> String {
        if let Ok(stripped) = path.strip_prefix(&self.skills_dir) {
            return stripped.display().to_string();
        }

        if let Ok(root) = std::fs::canonicalize(&self.skills_dir)
            && let Ok(stripped) = path.strip_prefix(&root)
        {
            return stripped.display().to_string();
        }

        path.display().to_string()
    }

    async fn read_skill_action(&self, args: SkillArgs) -> Result<SkillContentOutput, BoxError> {
        validate_skill_name(&args.name)?;
        let skill_dir = self
            .find_skill_dir(&args.name)
            .await?
            .ok_or_else(|| format!("skill {:?} not found", args.name))?;
        let target = skill_dir.join("SKILL.md");

        let content = self.read_text_file(&target, MAX_SKILL_FILE_BYTES).await?;
        let skill = parse_skill_md(skill_dir, &content)?;
        if skill.frontmatter.name != args.name {
            return Err(format!(
                "SKILL.md frontmatter name {:?} must match requested skill name {:?}",
                skill.frontmatter.name, args.name
            )
            .into());
        }
        self.skills
            .write()
            .entry(skill.agent_name.clone())
            .insert_entry(skill.clone());

        Ok(SkillContentOutput {
            name: skill.frontmatter.name,
            agent_name: skill.agent_name,
            description: skill.frontmatter.description,
            path: self.display_path(&target),
            content,
        })
    }

    /// Recursively load all `SKILL.md` files from the configured directory.
    pub async fn load(&self) -> Result<(), BoxError> {
        if !self.skills_dir.is_dir() {
            log::error!(
                "skills directory {} does not exist, skipping load",
                self.skills_dir.display()
            );
            return Ok(());
        }

        let skills = load_skills_from_dir(&self.skills_dir).await?;
        log::info!(
            "loaded {} skill(s) from {}",
            skills.len(),
            self.skills_dir.display()
        );
        *self.skills.write() = skills;
        Ok(())
    }

    /// Retrieve the full [`Skill`] by its normalised name.
    pub fn get_skill(&self, lowercase_name: &str) -> Option<Skill> {
        self.skills.read().get(lowercase_name).cloned()
    }

    /// Return all loaded skills as [`SubAgent`]s with default tools included.
    pub fn subagents(&self) -> Vec<SubAgent> {
        self.skills
            .read()
            .values()
            .map(SubAgent::from)
            .map(|agent| self.with_default_tools(agent))
            .collect::<Vec<_>>()
    }

    /// Return all loaded skills.
    pub fn list(&self) -> BTreeMap<String, Skill> {
        self.skills.read().clone()
    }
}

impl SubAgentSet for SkillManager {
    fn into_any(self: Arc<Self>) -> Arc<dyn Any + Send + Sync> {
        self
    }

    fn contains_lowercase(&self, lowercase_name: &str) -> bool {
        self.skills.read().contains_key(lowercase_name)
    }

    fn get_lowercase(&self, lowercase_name: &str) -> Option<SubAgent> {
        self.skills
            .read()
            .get(lowercase_name)
            .map(SubAgent::from)
            .map(|agent| self.with_default_tools(agent))
    }

    fn definitions(&self, names: Option<&[String]>) -> Vec<FunctionDefinition> {
        match names {
            None => self
                .skills
                .read()
                .values()
                .map(SubAgent::from)
                .map(|agent| agent.definition())
                .collect(),
            Some(names) => {
                let skills = self.skills.read();
                names
                    .iter()
                    .filter_map(|name| {
                        skills
                            .get(&name.to_ascii_lowercase())
                            .map(SubAgent::from)
                            .map(|agent| agent.definition())
                    })
                    .collect()
            }
        }
    }

    fn select_resources(&self, name: &str, resources: &mut Vec<Resource>) -> Vec<Resource> {
        if resources.is_empty() {
            return Vec::new();
        }

        self.skills
            .read()
            .get(&name.to_ascii_lowercase())
            .map(SubAgent::from)
            .map(|agent| {
                let supported_tags = agent.supported_resource_tags();
                select_resources(resources, &supported_tags)
            })
            .unwrap_or_default()
    }
}

impl Tool<BaseCtx> for SkillManager {
    type Args = SkillArgs;
    type Output = SkillContentOutput;

    fn name(&self) -> String {
        Self::NAME.to_string()
    }

    fn description(&self) -> String {
        self.description.clone()
    }

    fn definition(&self) -> FunctionDefinition {
        FunctionDefinition {
            name: self.name(),
            description: self.description(),
            parameters: json!({
                "type": "object",
                "description": "Read a reusable skill's SKILL.md file content by skill name. Create or update skills by editing files directly with shell or file tools, then reload the manager.",
                "properties": {
                    "name": {
                        "type": "string",
                        "description": "Skill name in kebab-case (e.g. 'pdf-processing'). Returns the matching SKILL.md content so the agent can follow it directly.",
                        "pattern": "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$",
                        "maxLength": 64
                    }
                },
                "required": ["name"],
                "additionalProperties": false
            }),
            strict: Some(true),
        }
    }

    async fn call(
        &self,
        _ctx: BaseCtx,
        args: Self::Args,
        _resources: Vec<Resource>,
    ) -> Result<ToolOutput<Self::Output>, BoxError> {
        Ok(ToolOutput::new(self.read_skill_action(args).await?))
    }
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::{context::BaseCtx, engine::EngineBuilder, subagent::SubAgentSet};
    use std::sync::Arc;

    fn mock_ctx() -> BaseCtx {
        EngineBuilder::new().mock_ctx().base
    }

    fn skill_md(name: &str, description: &str, body: &str, allowed_tools: Option<&str>) -> String {
        let mut content = format!("---\nname: {name}\ndescription: {description}\n");
        if let Some(allowed_tools) = allowed_tools {
            content.push_str(&format!("allowed-tools: {allowed_tools}\n"));
        }
        content.push_str("---\n\n");
        content.push_str(body);
        if !body.ends_with('\n') {
            content.push('\n');
        }
        content
    }

    // -- Tool definition --

    #[test]
    fn skill_manager_tool_definition_schema() {
        let mgr = SkillManager::new(PathBuf::from("/tmp/skills"));
        let def = mgr.definition();
        assert_eq!(def.name, "skills_manager");
        assert!(def.description.contains("Agent Skills specification"));
        assert_eq!(def.parameters["additionalProperties"], json!(false));
        assert_eq!(def.parameters["required"], json!(["name"]));
        assert!(def.parameters["properties"].get("action").is_none());
        assert_eq!(def.parameters["properties"]["name"]["maxLength"], json!(64));
    }

    // -- integration: load and read --

    #[tokio::test]
    async fn load_and_read_from_temp_dir() {
        let tmp =
            std::env::temp_dir().join(format!("anda-skills-test-{:016x}", rand::random::<u64>()));
        tokio::fs::create_dir_all(tmp.join("alpha")).await.unwrap();
        tokio::fs::create_dir_all(tmp.join("beta-skill"))
            .await
            .unwrap();

        tokio::fs::write(
            tmp.join("alpha/SKILL.md"),
            "\
---
name: alpha
description: Alpha skill for testing.
---

Alpha instructions.
",
        )
        .await
        .unwrap();

        tokio::fs::write(
            tmp.join("beta-skill/SKILL.md"),
            "\
---
name: beta-skill
description: Beta skill for testing.
license: MIT
allowed-tools: shell fetch
---

Beta instructions.
",
        )
        .await
        .unwrap();

        let mgr = SkillManager::new(tmp.clone());
        mgr.load().await.unwrap();

        assert!(mgr.contains_lowercase("skill_alpha"));
        assert!(mgr.contains_lowercase("skill_beta_skill"));
        assert!(!mgr.contains_lowercase("skill_gamma"));

        let alpha = mgr.get_lowercase("skill_alpha").unwrap();
        assert_eq!(alpha.description, "Alpha skill for testing.");
        assert_eq!(alpha.tools, DEFAULT_SKILL_TOOLS);

        let beta = mgr.get_lowercase("skill_beta_skill").unwrap();
        assert_eq!(
            beta.tools,
            vec![
                "shell",
                "read_file",
                "search_file",
                "write_file",
                "edit_file",
                "todo",
                "tools_select",
                "fetch"
            ]
        );
        assert!(beta.instructions.contains("Beta instructions."));

        let beta_skill = mgr.get_skill("skill_beta_skill").unwrap();
        assert_eq!(beta_skill.frontmatter.license.as_deref(), Some("MIT"));

        let beta_content = mgr
            .call_raw(mock_ctx(), json!({ "name": "beta-skill" }), Vec::new())
            .await
            .unwrap();
        assert_eq!(beta_content.output["name"], json!("beta-skill"));
        assert_eq!(beta_content.output["agent_name"], json!("skill_beta_skill"));
        assert_eq!(beta_content.output["path"], json!("beta-skill/SKILL.md"));
        assert!(
            beta_content.output["content"]
                .as_str()
                .unwrap()
                .contains("Beta instructions.")
        );

        tokio::fs::create_dir_all(tmp.join("gamma")).await.unwrap();
        tokio::fs::write(
            tmp.join("gamma/SKILL.md"),
            skill_md(
                "gamma",
                "Gamma skill for testing.",
                "Gamma instructions.",
                None,
            ),
        )
        .await
        .unwrap();

        mgr.load().await.unwrap();

        assert!(mgr.contains_lowercase("skill_gamma"));
        assert!(tmp.join("gamma/SKILL.md").exists());

        // Verify on-disk content is valid SKILL.md.
        let on_disk = tokio::fs::read_to_string(tmp.join("gamma/SKILL.md"))
            .await
            .unwrap();
        let reparsed = parse_skill_md(tmp.to_path_buf(), &on_disk).unwrap();
        assert_eq!(reparsed.frontmatter.name, "gamma");

        // Definitions.
        let defs = mgr.definitions(None);
        assert_eq!(defs.len(), 3);

        let defs_filtered = mgr.definitions(Some(&["skill_gamma".to_string()]));
        assert_eq!(defs_filtered.len(), 1);
        assert_eq!(defs_filtered[0].name, "skill_gamma");

        // Clean up.
        let _ = tokio::fs::remove_dir_all(&tmp).await;
    }

    #[tokio::test]
    #[ignore = "skip"]
    async fn load_skips_mismatched_dir_name() {
        let tmp = std::env::temp_dir().join(format!(
            "anda-skills-mismatch-{:016x}",
            rand::random::<u64>()
        ));
        tokio::fs::create_dir_all(tmp.join("wrong-dir"))
            .await
            .unwrap();

        tokio::fs::write(
            tmp.join("wrong-dir/SKILL.md"),
            "\
---
name: correct-name
description: Name does not match directory.
---

Body.
",
        )
        .await
        .unwrap();

        let mgr = SkillManager::new(tmp.clone());
        mgr.load().await.unwrap();

        // Should be skipped because dir name != skill name.
        assert!(!mgr.contains_lowercase("skill_correct_name"));

        let _ = tokio::fs::remove_dir_all(&tmp).await;
    }

    #[tokio::test(flavor = "current_thread")]
    async fn tool_requires_name() {
        let tmp = std::env::temp_dir().join(format!(
            "anda-skills-requires-name-{:016x}",
            rand::random::<u64>()
        ));
        let mgr = SkillManager::new(tmp.clone());

        let err = mgr
            .call_raw(mock_ctx(), json!({}), Vec::new())
            .await
            .unwrap_err();

        assert!(err.to_string().contains("missing field `name`"));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn tool_rejects_mutation_fields() {
        let tmp = std::env::temp_dir().join(format!(
            "anda-skills-rejects-action-{:016x}",
            rand::random::<u64>()
        ));
        let mgr = SkillManager::new(tmp.clone());

        let err = mgr
            .call_raw(
                mock_ctx(),
                json!({
                    "action": "create",
                    "name": "golf"
                }),
                Vec::new(),
            )
            .await
            .unwrap_err();

        assert!(err.to_string().contains("unknown field `action`"));
    }

    #[tokio::test(flavor = "current_thread")]
    async fn sub_agents_manager_register_skills_manager() {
        let tmp =
            std::env::temp_dir().join(format!("anda-skills-val-{:016x}", rand::random::<u64>()));
        let tool = SkillManager::new(tmp.clone());
        let engine = EngineBuilder::new().empty().await.unwrap();
        assert!(engine.sub_agents_manager().insert(Arc::new(tool)).is_none());
    }
}
