//! `mcptest skill` handler.
//!
//! Prints or installs the packaged "test-mcp-server" agent skill so a
//! coding agent reaches for mcptest on its own instead of being told
//! about it each session. The skill and its companion subagent are
//! bundled into the binary, so a user with the CLI installed and no
//! repo checkout still gets the exact files the repo ships.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use mcptest_core::discovery::clients::{
    install::{register_mcptest, InstallOutcome, InstallSpec},
    ClientSource,
};

/// Vendored copy of `examples/agent-skill/SKILL.md`. Single source of
/// truth is the example; `scripts/check-vendored-assets.sh` fails the
/// gate if this copy drifts from it.
const SKILL: &str = include_str!("../../skill/SKILL.md");

/// Vendored copy of `examples/agent-skill/test-mcp-server.subagent.md`,
/// kept in sync the same way as [`SKILL`].
const SUBAGENT: &str = include_str!("../../skill/test-mcp-server.subagent.md");

/// Skill directory name under `<base>/skills/`, matching the `name:`
/// field in the skill frontmatter so Claude Code loads it by name.
const SKILL_DIR: &str = "test-mcp-server";

/// Subagent file name under `<base>/agents/`, matching the `name:`
/// field in the subagent frontmatter (Claude Code convention).
const SUBAGENT_FILE: &str = "mcp-server-tester.md";

/// Which coding agent `mcptest skill` targets.
///
/// Plain enum so the clap surface stays in the `args` module. Claude Code gets
/// the bundled skill pack; the others get an `AGENTS.md` authoring guide plus a
/// registration of the mcptest MCP server in that client's own config.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SkillAgent {
    /// Claude Code: `.claude/skills` + `.claude/agents` skill pack.
    #[default]
    Claude,
    /// Codex: `AGENTS.md` guide + `~/.codex/mcp.json` registration.
    Codex,
    /// Cursor: `AGENTS.md` guide + `~/.cursor/mcp.json` registration.
    Cursor,
    /// Windsurf: `AGENTS.md` guide + Windsurf mcp-config registration.
    Windsurf,
}

impl SkillAgent {
    /// The discovery client this agent registers the mcptest server into, or
    /// `None` for Claude (served by the skill pack + `mcp-server --install`).
    fn client(self) -> Option<ClientSource> {
        match self {
            SkillAgent::Claude => None,
            SkillAgent::Codex => Some(ClientSource::Codex),
            SkillAgent::Cursor => Some(ClientSource::Cursor),
            SkillAgent::Windsurf => Some(ClientSource::Windsurf),
        }
    }
}

/// Args for [`run_skill`].
///
/// Flat plain-data shape so the CLI module can build it from clap
/// matches without pulling the clap derive surface into this module.
#[derive(Debug, Default, Clone)]
pub struct SkillRunArgs {
    /// Install the skill files instead of printing the skill body.
    pub install: bool,
    /// Install under `~/.claude` instead of `./.claude`.
    pub global: bool,
    /// Install under this base directory instead of a `.claude` dir.
    pub dir: Option<PathBuf>,
    /// Overwrite files that already exist at the install paths.
    pub force: bool,
    /// Which coding agent's layout to target.
    pub agent: SkillAgent,
}

/// Handle `mcptest skill`.
///
/// Default: print the bundled SKILL.md to stdout (mirrors `mcptest
/// prompt`). With `--install`: write the skill and subagent under the
/// resolved base directory, refusing existing files unless `--force`
/// (exit 2, consistent with `mcptest init`).
pub fn run_skill(args: SkillRunArgs) -> Result<i32> {
    if !args.install {
        match args.agent {
            // `print!` because the bundled body already ends with a newline.
            SkillAgent::Claude => print!("{SKILL}"),
            _ => print!("{}", agents_guide()),
        }
        return Ok(0);
    }
    match args.agent.client() {
        None => {
            let base = install_base(&args)?;
            install(&base, args.force)
        }
        Some(client) => install_for_client(&args, client),
    }
}

/// Resolve the base directory the `skills/` and `agents/` trees go
/// under: `--dir` verbatim, `~/.claude` with `--global`, otherwise
/// `./.claude` so the skill lands project-local like `mcp-server
/// --install` does.
fn install_base(args: &SkillRunArgs) -> Result<PathBuf> {
    if let Some(dir) = &args.dir {
        return Ok(dir.clone());
    }
    if args.global {
        let home = etcetera::home_dir().map_err(|err| {
            anyhow::anyhow!("could not resolve a home directory for --global: {err}")
        })?;
        return Ok(home.join(".claude"));
    }
    let cwd = std::env::current_dir().context("failed to read current directory")?;
    Ok(cwd.join(".claude"))
}

/// Write both files under `base`, refusing to clobber without `force`.
///
/// The refusal path mirrors `mcptest init`: name every conflicting
/// file on stderr, suggest `--force`, exit 2 without touching anything.
fn install(base: &Path, force: bool) -> Result<i32> {
    let skill_path = base.join("skills").join(SKILL_DIR).join("SKILL.md");
    let subagent_path = base.join("agents").join(SUBAGENT_FILE);

    let conflicts: Vec<&PathBuf> = [&skill_path, &subagent_path]
        .into_iter()
        .filter(|p| p.exists())
        .collect();
    if !conflicts.is_empty() && !force {
        for path in &conflicts {
            eprintln!("mcptest skill: refusing to overwrite {}", path.display());
        }
        eprintln!("mcptest skill: pass --force to overwrite existing files.");
        return Ok(2);
    }

    write_file(&skill_path, SKILL)?;
    write_file(&subagent_path, SUBAGENT)?;
    println!("wrote {}", skill_path.display());
    println!("wrote {}", subagent_path.display());
    println!("Installed. Pair with: mcptest mcp-server --install --enable-writes");
    Ok(0)
}

/// Idempotency marker prepended to the `AGENTS.md` guide so a repeat install
/// appends nothing rather than duplicating the section.
const AGENTS_MARKER: &str = "<!-- mcptest:test-mcp-server -->";

/// The cross-agent authoring guide: the bundled skill body with its Claude
/// frontmatter stripped and the marker prepended, so it reads as an `AGENTS.md`
/// section (the convention Codex and Cursor load) rather than a Claude skill
/// file. Single source of truth stays [`SKILL`].
fn agents_guide() -> String {
    format!("{AGENTS_MARKER}\n{}", strip_frontmatter(SKILL))
}

/// Strip a leading YAML frontmatter block (`---` ... `---`) from `text`,
/// returning the body. Returns `text` unchanged when it carries none.
fn strip_frontmatter(text: &str) -> &str {
    let Some(rest) = text.strip_prefix("---\n") else {
        return text;
    };
    match rest.find("\n---\n") {
        Some(end) => rest[end + "\n---\n".len()..].trim_start_matches('\n'),
        None => text,
    }
}

/// Install the authoring pack for a non-Claude `client`: an `AGENTS.md` guide
/// (under `--dir` or the current directory) plus a registration of the mcptest
/// MCP server in the client's own config, so the agent can call the authoring
/// verbs. Writes are enabled because the loop needs `run_tool_test` and
/// `propose_assertions`.
fn install_for_client(args: &SkillRunArgs, client: ClientSource) -> Result<i32> {
    let dir = match &args.dir {
        Some(d) => d.clone(),
        None => std::env::current_dir().context("failed to read current directory")?,
    };
    let agents_path = dir.join("AGENTS.md");
    match install_agents_md(&agents_path, &agents_guide())? {
        AgentsWrite::Created => println!("wrote {}", agents_path.display()),
        AgentsWrite::Appended => {
            println!("appended the mcptest section to {}", agents_path.display())
        }
        AgentsWrite::AlreadyPresent => {
            println!("{} already has the mcptest section", agents_path.display())
        }
    }

    let mcp_path = client.install_config_path().with_context(|| {
        format!(
            "could not resolve the {} MCP config path (is the home directory set?)",
            client.label()
        )
    })?;
    let binary = std::env::current_exe()
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| "mcptest".to_string());
    let outcome = register_mcptest(&mcp_path, &InstallSpec::for_mcp_server(binary, true))
        .with_context(|| format!("failed to register mcptest in {}", mcp_path.display()))?;
    let verb = match outcome {
        InstallOutcome::Added => "registered",
        InstallOutcome::Replaced => "updated",
    };
    println!(
        "{verb} mcptest as an MCP server for {} in {} (with writes).",
        client.label(),
        mcp_path.display()
    );
    println!("Restart {} to pick up the server.", client.label());
    Ok(0)
}

/// Outcome of writing the `AGENTS.md` guide, so the CLI prints what it did.
enum AgentsWrite {
    /// The file did not exist and was created with the guide.
    Created,
    /// The file existed without the guide; the section was appended.
    Appended,
    /// The file already carried the marker; nothing was written.
    AlreadyPresent,
}

/// Create or append the guide to `path`, never duplicating it (keyed on
/// [`AGENTS_MARKER`]) and never clobbering unrelated `AGENTS.md` content.
fn install_agents_md(path: &Path, guide: &str) -> Result<AgentsWrite> {
    match fs::read_to_string(path) {
        Ok(existing) if existing.contains(AGENTS_MARKER) => Ok(AgentsWrite::AlreadyPresent),
        Ok(existing) => {
            let mut next = existing;
            if !next.ends_with('\n') {
                next.push('\n');
            }
            next.push('\n');
            next.push_str(guide);
            write_file(path, &next)?;
            Ok(AgentsWrite::Appended)
        }
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
            write_file(path, guide)?;
            Ok(AgentsWrite::Created)
        }
        Err(err) => {
            Err(anyhow::Error::new(err).context(format!("failed to read {}", path.display())))
        }
    }
}

/// Create the parent directory and write `content`, with the failing
/// path in the error so the user can see which write broke.
fn write_file(path: &Path, content: &str) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("failed to create {}", parent.display()))?;
    }
    fs::write(path, content).with_context(|| format!("failed to write {}", path.display()))
}

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

    #[test]
    fn bundled_skill_is_substantial_and_names_the_loop_verbs() {
        assert!(SKILL.len() > 1000, "bundled skill should be substantial");
        for verb in [
            "scaffold_suite",
            "propose_assertions",
            "validate_suite",
            "run_tool_test",
        ] {
            assert!(SKILL.contains(verb), "skill should mention `{verb}`");
        }
    }

    #[test]
    fn bundled_files_carry_their_frontmatter_names() {
        assert!(
            SKILL.contains("name: test-mcp-server"),
            "skill frontmatter should name the skill directory"
        );
        assert!(
            SUBAGENT.contains("name: mcp-server-tester"),
            "subagent frontmatter should match the installed file name"
        );
    }

    #[test]
    fn bundled_files_contain_no_em_dashes() {
        // Belt and suspenders for the workspace em-dash linter.
        assert!(
            !SKILL.contains('\u{2014}'),
            "SKILL.md must not contain em-dashes"
        );
        assert!(
            !SUBAGENT.contains('\u{2014}'),
            "subagent file must not contain em-dashes"
        );
    }

    #[test]
    fn install_under_dir_override_writes_both_files() {
        let dir = tempdir().expect("tempdir");
        let code = run_skill(SkillRunArgs {
            install: true,
            dir: Some(dir.path().to_path_buf()),
            ..Default::default()
        })
        .expect("run");
        assert_eq!(code, 0);
        let skill = fs::read_to_string(dir.path().join("skills/test-mcp-server/SKILL.md")).unwrap();
        let subagent = fs::read_to_string(dir.path().join("agents/mcp-server-tester.md")).unwrap();
        assert_eq!(skill, SKILL);
        assert_eq!(subagent, SUBAGENT);
    }

    #[test]
    fn install_refuses_existing_subagent_without_force_and_leaves_it_alone() {
        let dir = tempdir().expect("tempdir");
        let existing = dir.path().join("agents/mcp-server-tester.md");
        fs::create_dir_all(existing.parent().unwrap()).unwrap();
        fs::write(&existing, "mine").unwrap();
        let code = run_skill(SkillRunArgs {
            install: true,
            dir: Some(dir.path().to_path_buf()),
            ..Default::default()
        })
        .expect("run");
        assert_eq!(code, 2);
        assert_eq!(fs::read_to_string(&existing).unwrap(), "mine");
        assert!(
            !dir.path().join("skills/test-mcp-server/SKILL.md").exists(),
            "a refused install must not write the other file either"
        );
    }

    #[test]
    fn strip_frontmatter_removes_the_yaml_block() {
        let body = strip_frontmatter("---\nname: x\ndescription: y\n---\n\n# Heading\ntext\n");
        assert!(body.starts_with("# Heading"));
        assert!(!body.contains("name: x"));
    }

    #[test]
    fn agents_guide_carries_the_marker_and_loop_verbs_without_frontmatter() {
        let guide = agents_guide();
        assert!(guide.starts_with(AGENTS_MARKER));
        assert!(!guide.contains("name: test-mcp-server"));
        for verb in ["scaffold_suite", "validate_suite", "propose_assertions"] {
            assert!(guide.contains(verb), "guide should mention `{verb}`");
        }
    }

    #[test]
    fn install_agents_md_creates_then_is_idempotent() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("AGENTS.md");
        let guide = agents_guide();
        assert!(matches!(
            install_agents_md(&path, &guide).unwrap(),
            AgentsWrite::Created
        ));
        assert!(matches!(
            install_agents_md(&path, &guide).unwrap(),
            AgentsWrite::AlreadyPresent
        ));
        let body = fs::read_to_string(&path).unwrap();
        assert_eq!(body.matches(AGENTS_MARKER).count(), 1);
    }

    #[test]
    fn install_agents_md_appends_without_clobbering_existing_content() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("AGENTS.md");
        fs::write(&path, "# My project\nExisting rules.\n").unwrap();
        assert!(matches!(
            install_agents_md(&path, &agents_guide()).unwrap(),
            AgentsWrite::Appended
        ));
        let body = fs::read_to_string(&path).unwrap();
        assert!(body.contains("# My project"));
        assert!(body.contains(AGENTS_MARKER));
    }

    #[test]
    fn non_claude_print_path_emits_the_guide() {
        let code = run_skill(SkillRunArgs {
            agent: SkillAgent::Codex,
            ..Default::default()
        })
        .expect("run");
        assert_eq!(code, 0);
    }
}
