"""Tests for SkillProvider, SkillsDirectoryProvider, and ClaudeSkillsProvider."""

import json
from pathlib import Path

import pytest
from mcp.types import TextResourceContents
from pydantic import AnyUrl

from fastmcp import Client, FastMCP
from fastmcp.server.providers.skills import (
    ClaudeSkillsProvider,
    SkillProvider,
    SkillsDirectoryProvider,
    SkillsProvider,
)
from fastmcp.server.providers.skills._common import parse_frontmatter


class TestParseFrontmatter:
    def test_no_frontmatter(self):
        content = "# Just markdown\n\nSome content."
        frontmatter, body = parse_frontmatter(content)
        assert frontmatter == {}
        assert body == content

    def test_basic_frontmatter(self):
        content = """---
description: A test skill
version: "1.0.0"
---

# Skill Content
"""
        frontmatter, body = parse_frontmatter(content)
        assert frontmatter["description"] == "A test skill"
        assert frontmatter["version"] == "1.0.0"
        assert body.strip().startswith("# Skill Content")

    def test_frontmatter_with_tags_list(self):
        content = """---
description: Test
tags: [tag1, tag2, tag3]
---

Content
"""
        frontmatter, body = parse_frontmatter(content)
        assert frontmatter["tags"] == ["tag1", "tag2", "tag3"]

    def test_frontmatter_with_quoted_strings(self):
        content = """---
description: "A skill with quotes"
version: '2.0.0'
---

Content
"""
        frontmatter, body = parse_frontmatter(content)
        assert frontmatter["description"] == "A skill with quotes"
        assert frontmatter["version"] == "2.0.0"


class TestSkillProvider:
    """Tests for SkillProvider - single skill folder."""

    @pytest.fixture
    def single_skill_dir(self, tmp_path: Path) -> Path:
        """Create a single skill directory with files."""
        skill_dir = tmp_path / "my-skill"
        skill_dir.mkdir()
        (skill_dir / "SKILL.md").write_text(
            """---
description: A test skill
version: "1.0.0"
---

# My Skill

This is my skill content.
"""
        )
        (skill_dir / "reference.md").write_text("# Reference\n\nExtra docs.")
        (skill_dir / "scripts").mkdir()
        (skill_dir / "scripts" / "helper.py").write_text('print("helper")')
        return skill_dir

    def test_loads_skill_at_init(self, single_skill_dir: Path):
        provider = SkillProvider(skill_path=single_skill_dir)
        assert provider.skill_info.name == "my-skill"
        assert provider.skill_info.description == "A test skill"
        assert len(provider.skill_info.files) == 3

    def test_raises_if_directory_missing(self, tmp_path: Path):
        with pytest.raises(FileNotFoundError, match="Skill directory not found"):
            SkillProvider(skill_path=tmp_path / "nonexistent")

    def test_raises_if_main_file_missing(self, tmp_path: Path):
        skill_dir = tmp_path / "no-main"
        skill_dir.mkdir()
        with pytest.raises(FileNotFoundError, match="Main skill file not found"):
            SkillProvider(skill_path=skill_dir)

    async def test_list_resources_default_template_mode(self, single_skill_dir: Path):
        """In template mode (default), only main file and manifest are resources."""
        provider = SkillProvider(skill_path=single_skill_dir)
        resources = await provider.list_resources()

        assert len(resources) == 2
        names = {r.name for r in resources}
        assert "my-skill/SKILL.md" in names
        assert "my-skill/_manifest" in names

    async def test_list_resources_supporting_files_as_resources(
        self, single_skill_dir: Path
    ):
        """In resources mode, supporting files are also exposed as resources."""
        provider = SkillProvider(
            skill_path=single_skill_dir, supporting_files="resources"
        )
        resources = await provider.list_resources()

        # 2 standard + 2 supporting files
        assert len(resources) == 4
        names = {r.name for r in resources}
        assert "my-skill/SKILL.md" in names
        assert "my-skill/_manifest" in names
        assert "my-skill/reference.md" in names
        assert "my-skill/scripts/helper.py" in names

    async def test_list_templates_default_mode(self, single_skill_dir: Path):
        """In template mode (default), one template is exposed."""
        provider = SkillProvider(skill_path=single_skill_dir)
        templates = await provider.list_resource_templates()

        assert len(templates) == 1
        assert templates[0].name == "my-skill_files"

    async def test_list_templates_resources_mode(self, single_skill_dir: Path):
        """In resources mode, no templates are exposed."""
        provider = SkillProvider(
            skill_path=single_skill_dir, supporting_files="resources"
        )
        templates = await provider.list_resource_templates()

        assert templates == []

    async def test_read_main_file(self, single_skill_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillProvider(skill_path=single_skill_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(AnyUrl("skill://my-skill/SKILL.md"))
            assert len(result) == 1
            assert isinstance(result[0], TextResourceContents)
            assert "# My Skill" in result[0].text

    async def test_read_manifest(self, single_skill_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillProvider(skill_path=single_skill_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(AnyUrl("skill://my-skill/_manifest"))
            manifest = json.loads(result[0].text)
            assert manifest["skill"] == "my-skill"
            assert len(manifest["files"]) == 3
            paths = {f["path"] for f in manifest["files"]}
            assert "SKILL.md" in paths
            assert "reference.md" in paths
            assert "scripts/helper.py" in paths

    async def test_manifest_ignores_symlink_target_outside_skill(self, tmp_path: Path):
        skill_dir = tmp_path / "symlinked-skill"
        skill_dir.mkdir()
        (skill_dir / "SKILL.md").write_text("# Skill\n")

        outside_file = tmp_path / "outside.txt"
        outside_file.write_text("secret")
        (skill_dir / "leak.txt").symlink_to(outside_file)

        mcp = FastMCP("Test")
        mcp.add_provider(SkillProvider(skill_path=skill_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(
                AnyUrl("skill://symlinked-skill/_manifest")
            )
            manifest = json.loads(result[0].text)

        paths = {f["path"] for f in manifest["files"]}
        assert "SKILL.md" in paths
        assert "leak.txt" not in paths

    async def test_read_supporting_file_via_template(self, single_skill_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillProvider(skill_path=single_skill_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
            assert "# Reference" in result[0].text

    async def test_read_supporting_file_via_resource_mode(self, single_skill_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(
            SkillProvider(skill_path=single_skill_dir, supporting_files="resources")
        )

        async with Client(mcp) as client:
            result = await client.read_resource(AnyUrl("skill://my-skill/reference.md"))
            assert "# Reference" in result[0].text

    async def test_skill_resource_meta(self, single_skill_dir: Path):
        """SkillResource populates meta with skill name and is_manifest."""
        provider = SkillProvider(skill_path=single_skill_dir)
        resources = await provider.list_resources()

        by_name = {r.name: r for r in resources}

        main_meta = by_name["my-skill/SKILL.md"].get_meta()
        assert main_meta["fastmcp"]["skill"] == {
            "name": "my-skill",
            "is_manifest": False,
        }

        manifest_meta = by_name["my-skill/_manifest"].get_meta()
        assert manifest_meta["fastmcp"]["skill"] == {
            "name": "my-skill",
            "is_manifest": True,
        }

    async def test_skill_file_resource_meta(self, single_skill_dir: Path):
        """SkillFileResource populates meta with skill name."""
        provider = SkillProvider(
            skill_path=single_skill_dir, supporting_files="resources"
        )
        resources = await provider.list_resources()

        by_name = {r.name: r for r in resources}
        file_meta = by_name["my-skill/reference.md"].get_meta()
        assert file_meta["fastmcp"]["skill"] == {"name": "my-skill"}

    async def test_skill_meta_survives_mounting(self, single_skill_dir: Path):
        """Skill metadata in _meta is preserved when accessed through a mounted server."""
        child = FastMCP("child")
        child.add_provider(SkillProvider(skill_path=single_skill_dir))

        parent = FastMCP("parent")
        parent.mount(child, "skills")

        resources = await parent.list_resources()
        by_name = {r.name: r for r in resources}

        main_meta = by_name["my-skill/SKILL.md"].get_meta()
        assert main_meta["fastmcp"]["skill"] == {
            "name": "my-skill",
            "is_manifest": False,
        }

        manifest_meta = by_name["my-skill/_manifest"].get_meta()
        assert manifest_meta["fastmcp"]["skill"] == {
            "name": "my-skill",
            "is_manifest": True,
        }


class TestSkillsDirectoryProvider:
    """Tests for SkillsDirectoryProvider - scans directory for skill folders."""

    @pytest.fixture
    def skills_dir(self, tmp_path: Path) -> Path:
        """Create a test skills directory with sample skills."""
        skills_root = tmp_path / "skills"
        skills_root.mkdir()

        # Create a simple skill
        simple_skill = skills_root / "simple-skill"
        simple_skill.mkdir()
        (simple_skill / "SKILL.md").write_text(
            """---
description: A simple test skill
version: "1.0.0"
---

# Simple Skill

This is a simple skill for testing.
"""
        )

        # Create a skill with supporting files
        complex_skill = skills_root / "complex-skill"
        complex_skill.mkdir()
        (complex_skill / "SKILL.md").write_text(
            """---
description: A complex skill with supporting files
---

# Complex Skill

See [reference](reference.md) for more details.
"""
        )
        (complex_skill / "reference.md").write_text(
            """# Reference

Additional documentation.
"""
        )
        (complex_skill / "scripts").mkdir()
        (complex_skill / "scripts" / "helper.py").write_text(
            'print("Hello from helper")'
        )

        return skills_root

    async def test_list_resources_discovers_skills(self, skills_dir: Path):
        provider = SkillsDirectoryProvider(roots=skills_dir)
        resources = await provider.list_resources()

        # Should have 2 resources per skill (main file + manifest)
        assert len(resources) == 4

        # Check resource names
        resource_names = {r.name for r in resources}
        assert "simple-skill/SKILL.md" in resource_names
        assert "simple-skill/_manifest" in resource_names
        assert "complex-skill/SKILL.md" in resource_names
        assert "complex-skill/_manifest" in resource_names

    async def test_list_resources_includes_descriptions(self, skills_dir: Path):
        provider = SkillsDirectoryProvider(roots=skills_dir)
        resources = await provider.list_resources()

        # Find the simple-skill main resource
        simple_skill = next(r for r in resources if r.name == "simple-skill/SKILL.md")
        assert simple_skill.description == "A simple test skill"

    async def test_read_main_skill_file(self, skills_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(AnyUrl("skill://simple-skill/SKILL.md"))
            assert len(result) == 1
            assert isinstance(result[0], TextResourceContents)
            assert "# Simple Skill" in result[0].text

    async def test_read_manifest(self, skills_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(
                AnyUrl("skill://complex-skill/_manifest")
            )
            assert len(result) == 1
            assert isinstance(result[0], TextResourceContents)

            manifest = json.loads(result[0].text)
            assert manifest["skill"] == "complex-skill"
            assert len(manifest["files"]) == 3  # SKILL.md, reference.md, helper.py

            # Check file paths
            paths = {f["path"] for f in manifest["files"]}
            assert "SKILL.md" in paths
            assert "reference.md" in paths
            assert "scripts/helper.py" in paths

            # Check hashes are present
            for file_info in manifest["files"]:
                assert file_info["hash"].startswith("sha256:")
                assert file_info["size"] > 0

    async def test_list_resource_templates(self, skills_dir: Path):
        provider = SkillsDirectoryProvider(roots=skills_dir)
        templates = await provider.list_resource_templates()

        # One template per skill
        assert len(templates) == 2

        template_names = {t.name for t in templates}
        assert "simple-skill_files" in template_names
        assert "complex-skill_files" in template_names

    async def test_read_supporting_file_via_template(self, skills_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(
                AnyUrl("skill://complex-skill/reference.md")
            )
            assert len(result) == 1
            assert isinstance(result[0], TextResourceContents)
            assert "# Reference" in result[0].text

    async def test_read_nested_file_via_template(self, skills_dir: Path):
        mcp = FastMCP("Test")
        mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))

        async with Client(mcp) as client:
            result = await client.read_resource(
                AnyUrl("skill://complex-skill/scripts/helper.py")
            )
            assert len(result) == 1
            assert isinstance(result[0], TextResourceContents)
            assert "Hello from helper" in result[0].text

    async def test_empty_skills_directory(self, tmp_path: Path):
        empty_dir = tmp_path / "empty"
        empty_dir.mkdir()

        provider = SkillsDirectoryProvider(roots=empty_dir)
        resources = await provider.list_resources()
        assert resources == []

        templates = await provider.list_resource_templates()
        assert templates == []

    async def test_nonexistent_skills_directory(self, tmp_path: Path):
        nonexistent = tmp_path / "does-not-exist"
        provider = SkillsDirectoryProvider(roots=nonexistent)

        resources = await provider.list_resources()
        assert resources == []

    async def test_reload_mode(self, skills_dir: Path):
        provider = SkillsDirectoryProvider(roots=skills_dir, reload=True)

        # Initial load
        resources = await provider.list_resources()
        assert len(resources) == 4

        # Add a new skill
        new_skill = skills_dir / "new-skill"
        new_skill.mkdir()
        (new_skill / "SKILL.md").write_text(
            """---
description: A new skill
---

# New Skill
"""
        )

        # Reload should pick up the new skill
        resources = await provider.list_resources()
        assert len(resources) == 6

    async def test_skill_without_frontmatter_uses_header_as_description(
        self, tmp_path: Path
    ):
        skills_dir = tmp_path / "skills"
        skills_dir.mkdir()
        skill = skills_dir / "no-frontmatter"
        skill.mkdir()
        (skill / "SKILL.md").write_text("# My Skill Title\n\nSome content.")

        provider = SkillsDirectoryProvider(roots=skills_dir)
        resources = await provider.list_resources()

        main_resource = next(
            r for r in resources if r.name == "no-frontmatter/SKILL.md"
        )
        assert main_resource.description == "My Skill Title"

    async def test_supporting_files_as_resources(self, skills_dir: Path):
        """Test that supporting_files='resources' shows all files."""
        provider = SkillsDirectoryProvider(
            roots=skills_dir, supporting_files="resources"
        )
        resources = await provider.list_resources()

        # 2 skills * 2 standard resources + complex skill has 2 supporting files
        # simple-skill: SKILL.md, _manifest (2)
        # complex-skill: SKILL.md, _manifest, reference.md, scripts/helper.py (4)
        assert len(resources) == 6

        names = {r.name for r in resources}
        assert "complex-skill/reference.md" in names
        assert "complex-skill/scripts/helper.py" in names

    async def test_supporting_files_as_resources_no_templates(self, skills_dir: Path):
        """In resources mode, no templates should be exposed."""
        provider = SkillsDirectoryProvider(
            roots=skills_dir, supporting_files="resources"
        )
        templates = await provider.list_resource_templates()
        assert templates == []


class TestMultiDirectoryProvider:
    """Tests for multi-directory support in SkillsDirectoryProvider."""

    @pytest.fixture
    def multi_skills_dirs(self, tmp_path: Path) -> tuple[Path, Path]:
        """Create two separate skills directories."""
        root1 = tmp_path / "skills1"
        root1.mkdir()
        skill1 = root1 / "skill-a"
        skill1.mkdir()
        (skill1 / "SKILL.md").write_text(
            """---
description: Skill A from root 1
---
# Skill A
"""
        )

        root2 = tmp_path / "skills2"
        root2.mkdir()
        skill2 = root2 / "skill-b"
        skill2.mkdir()
        (skill2 / "SKILL.md").write_text(
            """---
description: Skill B from root 2
---
# Skill B
"""
        )

        return root1, root2

    async def test_multiple_roots_discover_all_skills(self, multi_skills_dirs):
        """Test that skills from multiple roots are all discovered."""
        root1, root2 = multi_skills_dirs
        provider = SkillsDirectoryProvider(roots=[root1, root2])

        resources = await provider.list_resources()
        # 2 skills * 2 resources each = 4 total
        assert len(resources) == 4

        resource_names = {r.name for r in resources}
        assert "skill-a/SKILL.md" in resource_names
        assert "skill-a/_manifest" in resource_names
        assert "skill-b/SKILL.md" in resource_names
        assert "skill-b/_manifest" in resource_names

    async def test_duplicate_skill_names_first_wins(self, tmp_path: Path):
        """Test that if a skill appears in multiple roots, first one wins."""
        root1 = tmp_path / "root1"
        root1.mkdir()
        skill1 = root1 / "duplicate-skill"
        skill1.mkdir()
        (skill1 / "SKILL.md").write_text(
            """---
description: First occurrence
---
# First
"""
        )

        root2 = tmp_path / "root2"
        root2.mkdir()
        skill2 = root2 / "duplicate-skill"
        skill2.mkdir()
        (skill2 / "SKILL.md").write_text(
            """---
description: Second occurrence
---
# Second
"""
        )

        provider = SkillsDirectoryProvider(roots=[root1, root2])
        resources = await provider.list_resources()

        # Should only have one skill (first one)
        assert len(resources) == 2  # SKILL.md + _manifest

        # Should be the first one
        main_resource = next(
            r for r in resources if r.name == "duplicate-skill/SKILL.md"
        )
        assert main_resource.description == "First occurrence"

    async def test_single_path_as_list(self, multi_skills_dirs):
        """Test that single path can be passed as a list."""
        root1, _ = multi_skills_dirs
        provider = SkillsDirectoryProvider(roots=[root1])

        resources = await provider.list_resources()
        assert len(resources) == 2  # skill-a has 2 resources

    async def test_single_path_as_string(self, multi_skills_dirs):
        """Test that single path can be passed as string."""
        root1, _ = multi_skills_dirs
        provider = SkillsDirectoryProvider(roots=str(root1))

        resources = await provider.list_resources()
        assert len(resources) == 2

    async def test_nonexistent_roots_handled_gracefully(self, tmp_path: Path):
        """Test that non-existent roots don't cause errors."""
        existent = tmp_path / "exists"
        existent.mkdir()
        skill = existent / "test-skill"
        skill.mkdir()
        (skill / "SKILL.md").write_text("# Test\n\nContent")

        nonexistent = tmp_path / "does-not-exist"

        provider = SkillsDirectoryProvider(roots=[existent, nonexistent])
        resources = await provider.list_resources()

        # Should still find skills from existing root
        assert len(resources) == 2

    async def test_empty_roots_list(self, tmp_path: Path):
        """Test that empty roots list results in no skills."""
        provider = SkillsDirectoryProvider(roots=[])
        resources = await provider.list_resources()
        assert resources == []


class TestSkillsProviderAlias:
    """Test that SkillsProvider is a backwards-compatible alias."""

    def test_skills_provider_is_alias(self):
        assert SkillsProvider is SkillsDirectoryProvider


class TestClaudeSkillsProvider:
    def test_default_root_is_claude_skills_dir(self, tmp_path: Path, monkeypatch):
        # Mock Path.home() to return a temp path (use tmp_path for cross-platform compatibility)
        monkeypatch.setattr(Path, "home", lambda: tmp_path)

        provider = ClaudeSkillsProvider()
        assert provider._roots == [tmp_path / ".claude" / "skills"]

    def test_main_file_name_is_skill_md(self):
        provider = ClaudeSkillsProvider()
        assert provider._main_file_name == "SKILL.md"

    def test_supporting_files_parameter(self):
        provider = ClaudeSkillsProvider(supporting_files="resources")
        assert provider._supporting_files == "resources"


class TestPathTraversalPrevention:
    async def test_path_traversal_blocked(self, tmp_path: Path):
        skills_dir = tmp_path / "skills"
        skills_dir.mkdir()
        skill = skills_dir / "test-skill"
        skill.mkdir()
        (skill / "SKILL.md").write_text("# Test\n\nContent")

        # Create a file outside the skill directory
        secret_file = tmp_path / "secret.txt"
        secret_file.write_text("SECRET DATA")

        mcp = FastMCP("Test")
        mcp.add_provider(SkillsDirectoryProvider(roots=skills_dir))

        async with Client(mcp) as client:
            # Path traversal attempts should fail — the secret must never be returned
            with pytest.raises(Exception):
                await client.read_resource(
                    AnyUrl("skill://test-skill/../../../secret.txt")
                )


async def test_skill_provider_loads_and_serves_utf8_skill_md(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    """SKILL.md and its supporting text files must be read as utf-8.

    Regression test for #4084. On Windows the default encoding is cp1252,
    so a bare ``read_text()`` call fails to decode UTF-8 content with
    ``UnicodeDecodeError: 'charmap' codec can't decode byte ...``. The fix
    passes ``encoding="utf-8"`` explicitly at every read site: skill load,
    main-file resource read, and supporting-file template/resource reads.
    This test simulates the Windows behavior on any platform by failing
    every text read that doesn't pass ``encoding="utf-8"``.
    """
    skill_dir = tmp_path / "test-skill"
    skill_dir.mkdir()
    (skill_dir / "SKILL.md").write_text(
        "---\n"
        "name: test-skill\n"
        "description: Test skill with UTF-8 characters\n"
        "---\n"
        "# Test Skill 🎯\n"
        "- ✅ Success indicator\n",
        encoding="utf-8",
    )
    (skill_dir / "reference.md").write_text(
        "# Reference\n- ✨ utf-8 supporting file\n", encoding="utf-8"
    )

    original_read_text = Path.read_text

    def strict_read_text(self: Path, *args, **kwargs):
        if kwargs.get("encoding") != "utf-8":
            raise UnicodeDecodeError(
                "charmap", b"\x9d", 0, 1, "simulated cp1252 default"
            )
        return original_read_text(self, *args, **kwargs)

    monkeypatch.setattr(Path, "read_text", strict_read_text)

    mcp = FastMCP("Test")
    mcp.add_provider(SkillProvider(skill_path=skill_dir))

    async with Client(mcp) as client:
        main = await client.read_resource(AnyUrl("skill://test-skill/SKILL.md"))
        assert isinstance(main[0], TextResourceContents)
        assert "🎯" in main[0].text

        ref = await client.read_resource(AnyUrl("skill://test-skill/reference.md"))
        assert isinstance(ref[0], TextResourceContents)
        assert "✨" in ref[0].text
