#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]

use std::path::Path;

use gray_matter::engine::YAML;
use gray_matter::Matter;

use crate::skills::types::{LoadSkillsResult, Skill, SkillDiagnostic, SkillSource};
use crate::skills::validate::{validate_description, validate_name};

/// Parsed frontmatter fields. `name` is optional in the file because flat
/// `.md` skills derive `name` from the filename if missing.
#[derive(Debug, Default)]
pub(super) struct ParsedFrontmatter {
    pub name: Option<String>,
    pub description: Option<String>,
    pub disable_model_invocation: bool,
}

pub(super) fn parse_frontmatter(raw: &str) -> ParsedFrontmatter {
    let matter = Matter::<YAML>::new();
    let parsed = matter.parse(raw);
    let matter_text = parsed.matter.clone();
    let map = parsed.data.and_then(|data| data.as_hashmap().ok());

    let str_field = |k: &str| -> Option<String> {
        map.as_ref()
            .and_then(|m| m.get(k))
            .and_then(|v| v.as_string().ok())
            .or_else(|| frontmatter_line_field(&matter_text, k))
    };
    let bool_field = |k: &str| -> bool {
        map.as_ref()
            .and_then(|m| m.get(k))
            .and_then(|v| v.as_bool().ok())
            .or_else(|| frontmatter_line_field(&matter_text, k).and_then(|v| v.parse().ok()))
            .unwrap_or(false)
    };

    ParsedFrontmatter {
        name: str_field("name"),
        description: str_field("description"),
        disable_model_invocation: bool_field("disable-model-invocation"),
    }
}

pub(crate) fn strip_frontmatter_body(raw: &str) -> String {
    let Some(rest) = raw.strip_prefix("---\n") else {
        return raw.to_string();
    };
    let Some(end) = rest.find("\n---\n") else {
        return raw.to_string();
    };
    rest[end + "\n---\n".len()..].to_string()
}

#[cfg(test)]
mod tests {
    use super::*;
    use pretty_assertions::assert_eq;

    #[test]
    fn parses_minimal_frontmatter() {
        let raw = "---\nname: foo\ndescription: bar\n---\nbody\n";
        let fm = parse_frontmatter(raw);
        assert_eq!(fm.name.as_deref(), Some("foo"));
        assert_eq!(fm.description.as_deref(), Some("bar"));
        assert!(!fm.disable_model_invocation);
    }

    #[test]
    fn parses_disable_model_invocation() {
        let raw = "---\nname: foo\ndescription: bar\ndisable-model-invocation: true\n---\nbody\n";
        let fm = parse_frontmatter(raw);
        assert!(fm.disable_model_invocation);
    }

    #[test]
    fn missing_frontmatter_returns_default() {
        let fm = parse_frontmatter("just body\n");
        assert!(fm.name.is_none());
        assert!(fm.description.is_none());
        assert!(!fm.disable_model_invocation);
    }

    #[test]
    fn strips_frontmatter_body() {
        let raw = "---\nname: foo\ndescription: bar\n---\nhello body\n";
        assert_eq!(strip_frontmatter_body(raw), "hello body\n");
    }

    #[test]
    fn passes_through_when_no_frontmatter() {
        assert_eq!(
            strip_frontmatter_body("no frontmatter here\n"),
            "no frontmatter here\n"
        );
    }
}

pub(super) fn parse_skill_file(
    file_path: &Path,
    base_dir: &Path,
    source: SkillSource,
    fallback_name: Option<&str>,
) -> Result<Skill, SkillDiagnostic> {
    let raw = std::fs::read_to_string(file_path).map_err(|err| SkillDiagnostic {
        file_path: file_path.to_path_buf(),
        message: format!("read failed: {err}"),
    })?;
    let fm = parse_frontmatter(&raw);

    let name = fm
        .name
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(str::to_string)
        .or_else(|| fallback_name.map(str::to_string));
    let name = match name {
        Some(n) => n,
        None => {
            return Err(SkillDiagnostic {
                file_path: file_path.to_path_buf(),
                message: "name is required (no frontmatter `name` and no filename fallback)".into(),
            });
        }
    };

    let name_errors = validate_name(&name);
    if !name_errors.is_empty() {
        return Err(SkillDiagnostic {
            file_path: file_path.to_path_buf(),
            message: name_errors.join("; "),
        });
    }

    let desc_errors = validate_description(fm.description.as_deref());
    if !desc_errors.is_empty() {
        return Err(SkillDiagnostic {
            file_path: file_path.to_path_buf(),
            message: desc_errors.join("; "),
        });
    }

    Ok(Skill {
        name,
        description: fm.description.unwrap_or_default().trim().to_string(),
        file_path: file_path.to_path_buf(),
        base_dir: base_dir.to_path_buf(),
        disable_model_invocation: fm.disable_model_invocation,
        source,
    })
}

#[cfg(test)]
mod parse_skill_file_tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    fn write(dir: &Path, name: &str, body: &str) -> std::path::PathBuf {
        let p = dir.join(name);
        fs::write(&p, body).unwrap();
        p
    }

    #[test]
    fn parses_valid_skill() {
        let tmp = tempdir().unwrap();
        let p = write(
            tmp.path(),
            "x.md",
            "---\nname: foo\ndescription: bar\n---\nbody\n",
        );
        let skill = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap();
        assert_eq!(skill.name, "foo");
        assert_eq!(skill.description, "bar");
        assert!(!skill.disable_model_invocation);
        assert_eq!(skill.source, SkillSource::Global);
        assert_eq!(skill.file_path, p);
        assert_eq!(skill.base_dir, tmp.path());
    }

    #[test]
    fn flat_md_uses_fallback_name() {
        let tmp = tempdir().unwrap();
        let p = write(
            tmp.path(),
            "quick-tip.md",
            "---\ndescription: bar\n---\nbody\n",
        );
        let skill =
            parse_skill_file(&p, tmp.path(), SkillSource::Global, Some("quick-tip")).unwrap();
        assert_eq!(skill.name, "quick-tip");
    }

    #[test]
    fn invalid_name_produces_diagnostic() {
        let tmp = tempdir().unwrap();
        let p = write(
            tmp.path(),
            "x.md",
            "---\nname: BAD_NAME\ndescription: bar\n---\nbody\n",
        );
        let diag = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap_err();
        assert!(
            diag.message.contains("invalid characters"),
            "{}",
            diag.message
        );
        assert_eq!(diag.file_path, p);
    }

    #[test]
    fn missing_description_produces_diagnostic() {
        let tmp = tempdir().unwrap();
        let p = write(tmp.path(), "x.md", "---\nname: foo\n---\nbody\n");
        let diag = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap_err();
        assert!(
            diag.message.contains("description is required"),
            "{}",
            diag.message
        );
    }

    #[test]
    fn missing_name_with_no_fallback_produces_diagnostic() {
        let tmp = tempdir().unwrap();
        let p = write(tmp.path(), "x.md", "---\ndescription: bar\n---\nbody\n");
        let diag = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap_err();
        assert!(
            diag.message.contains("name is required"),
            "{}",
            diag.message
        );
    }

    #[test]
    fn disable_model_invocation_flag_propagates() {
        let tmp = tempdir().unwrap();
        let p = write(
            tmp.path(),
            "x.md",
            "---\nname: foo\ndescription: bar\ndisable-model-invocation: true\n---\nbody\n",
        );
        let skill = parse_skill_file(&p, tmp.path(), SkillSource::Global, None).unwrap();
        assert!(skill.disable_model_invocation);
    }
}

/// Load every skill under `dir`. Entries that are `<name>/SKILL.md` or
/// flat `<name>.md` become skills. Other entries are ignored. Invalid
/// skills produce diagnostics (non-fatal). Missing `dir` returns empty.
pub fn load_from_dir(dir: &Path, source: SkillSource) -> LoadSkillsResult {
    let mut result = LoadSkillsResult::default();
    let entries = match std::fs::read_dir(dir) {
        Ok(it) => it,
        Err(_) => return result,
    };
    for entry in entries.flatten() {
        let path = entry.path();
        let file_type = match entry.file_type() {
            Ok(ft) => ft,
            Err(_) => continue,
        };

        if file_type.is_dir() {
            let skill_md = path.join("SKILL.md");
            if !skill_md.is_file() {
                result.diagnostics.push(SkillDiagnostic {
                    file_path: path.clone(),
                    message: "skill directory missing SKILL.md; skipped".into(),
                });
                continue;
            }
            match parse_skill_file(&skill_md, &path, source, file_stem(&path).as_deref()) {
                Ok(skill) => result.skills.push(skill),
                Err(d) => result.diagnostics.push(d),
            }
        } else if file_type.is_file() {
            if path.extension().and_then(|s| s.to_str()) != Some("md") {
                continue;
            }
            let fallback = file_stem(&path);
            match parse_skill_file(&path, dir, source, fallback.as_deref()) {
                Ok(skill) => result.skills.push(skill),
                Err(d) => result.diagnostics.push(d),
            }
        }
    }
    result
}

fn frontmatter_line_field(matter: &str, key: &str) -> Option<String> {
    matter.lines().find_map(|line| {
        let (k, v) = line.split_once(':')?;
        (k.trim() == key).then(|| v.trim().to_string())
    })
}

fn file_stem(p: &Path) -> Option<String> {
    p.file_stem()
        .and_then(|s| s.to_str())
        .map(|s| s.trim_start_matches('.').to_string())
}

#[cfg(test)]
mod load_from_dir_tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    fn touch(p: &Path, body: &str) {
        if let Some(parent) = p.parent() {
            fs::create_dir_all(parent).unwrap();
        }
        fs::write(p, body).unwrap();
    }

    #[test]
    fn missing_dir_returns_empty() {
        let r = load_from_dir(Path::new("/no/such/path/for-test"), SkillSource::Global);
        assert!(r.skills.is_empty() && r.diagnostics.is_empty());
    }

    #[test]
    fn loads_directory_skill_via_skill_md() {
        let tmp = tempdir().unwrap();
        touch(
            &tmp.path().join("foo").join("SKILL.md"),
            "---\nname: foo\ndescription: d\n---\nbody\n",
        );
        let r = load_from_dir(tmp.path(), SkillSource::Global);
        assert_eq!(r.skills.len(), 1);
        assert!(r.diagnostics.is_empty());
        assert_eq!(r.skills[0].name, "foo");
        assert_eq!(r.skills[0].base_dir, tmp.path().join("foo"));
    }

    #[test]
    fn loads_flat_md_skill() {
        let tmp = tempdir().unwrap();
        touch(
            &tmp.path().join("quick-tip.md"),
            "---\ndescription: d\n---\nbody\n",
        );
        let r = load_from_dir(tmp.path(), SkillSource::Global);
        assert_eq!(r.skills.len(), 1, "{r:?}");
        assert_eq!(r.skills[0].name, "quick-tip");
    }

    #[test]
    fn directory_without_skill_md_emits_diagnostic() {
        let tmp = tempdir().unwrap();
        fs::create_dir_all(tmp.path().join("empty-dir")).unwrap();
        let r = load_from_dir(tmp.path(), SkillSource::Global);
        assert!(r.skills.is_empty());
        assert_eq!(r.diagnostics.len(), 1);
        assert!(r.diagnostics[0].message.contains("SKILL.md"));
    }

    #[test]
    fn invalid_skill_emits_diagnostic_and_continues() {
        let tmp = tempdir().unwrap();
        touch(&tmp.path().join("good.md"), "---\ndescription: d\n---\n");
        touch(
            &tmp.path().join("bad.md"),
            "---\nname: BAD\ndescription: d\n---\n",
        );
        let r = load_from_dir(tmp.path(), SkillSource::Global);
        assert_eq!(r.skills.len(), 1);
        assert_eq!(r.diagnostics.len(), 1);
        assert!(r.diagnostics[0].file_path.ends_with("bad.md"));
    }

    #[test]
    fn ignores_non_md_files() {
        let tmp = tempdir().unwrap();
        touch(&tmp.path().join("README.txt"), "ignored");
        touch(&tmp.path().join(".hidden.md"), "---\ndescription:d\n---\n");
        let r = load_from_dir(tmp.path(), SkillSource::Global);
        // .hidden.md is loadable (flat md, name="hidden")
        assert_eq!(r.skills.len(), 1);
    }
}

/// Load skills from `agent_dir/skills/` (global) then `cwd/.capo/skills/`
/// (project). Project entries with the same `name` replace global; a
/// diagnostic is emitted noting the override.
pub fn load_all(cwd: &Path, agent_dir: &Path) -> LoadSkillsResult {
    let mut result = LoadSkillsResult::default();
    let global = load_from_dir(&agent_dir.join("skills"), SkillSource::Global);
    let project = load_from_dir(&cwd.join(".capo").join("skills"), SkillSource::Project);

    let project_names: std::collections::HashSet<_> =
        project.skills.iter().map(|s| s.name.clone()).collect();

    for s in global.skills {
        if project_names.contains(&s.name) {
            result.diagnostics.push(SkillDiagnostic {
                file_path: s.file_path.clone(),
                message: format!(
                    "global skill `{}` overridden by project (project/.capo/skills wins)",
                    s.name
                ),
            });
        } else {
            result.skills.push(s);
        }
    }
    result.skills.extend(project.skills);
    result.diagnostics.extend(global.diagnostics);
    result.diagnostics.extend(project.diagnostics);
    result
}

#[cfg(test)]
mod load_all_tests {
    use super::*;
    use std::fs;
    use tempfile::tempdir;

    #[test]
    fn project_overrides_global_same_name() {
        let agent_dir = tempdir().unwrap();
        let cwd = tempdir().unwrap();

        let global = agent_dir.path().join("skills");
        fs::create_dir_all(&global).unwrap();
        fs::write(global.join("foo.md"), "---\ndescription: GLOBAL\n---\n").unwrap();

        let project = cwd.path().join(".capo").join("skills");
        fs::create_dir_all(&project).unwrap();
        fs::write(project.join("foo.md"), "---\ndescription: PROJECT\n---\n").unwrap();

        let r = load_all(cwd.path(), agent_dir.path());
        let names: Vec<_> = r
            .skills
            .iter()
            .map(|s| (&s.name, &s.description, s.source))
            .collect();
        assert_eq!(names.len(), 1);
        assert_eq!(names[0].0, "foo");
        assert_eq!(names[0].1, "PROJECT");
        assert_eq!(names[0].2, SkillSource::Project);
        assert!(r
            .diagnostics
            .iter()
            .any(|d| d.message.contains("overridden by project")));
    }

    #[test]
    fn global_and_project_skills_coexist_when_distinct_names() {
        let agent_dir = tempdir().unwrap();
        let cwd = tempdir().unwrap();

        let global = agent_dir.path().join("skills");
        fs::create_dir_all(&global).unwrap();
        fs::write(global.join("a.md"), "---\ndescription: A\n---\n").unwrap();

        let project = cwd.path().join(".capo").join("skills");
        fs::create_dir_all(&project).unwrap();
        fs::write(project.join("b.md"), "---\ndescription: B\n---\n").unwrap();

        let r = load_all(cwd.path(), agent_dir.path());
        let names: Vec<&str> = r.skills.iter().map(|s| s.name.as_str()).collect();
        assert!(names.contains(&"a"));
        assert!(names.contains(&"b"));
    }

    #[test]
    fn missing_dirs_return_empty() {
        let agent_dir = tempdir().unwrap();
        let cwd = tempdir().unwrap();
        let r = load_all(cwd.path(), agent_dir.path());
        assert!(r.skills.is_empty() && r.diagnostics.is_empty());
    }
}
