"""Tests for the lazy skill loading system."""

import os
import tempfile
from pathlib import Path

import pytest
import yaml

from oclawma.skills import (
    FunctionSkill,
    LazySkill,
    ManifestParser,
    ManifestSkill,
    SkillDiscovery,
    SkillLoadError,
    SkillManifest,
    SkillMetadata,
    SkillNotFoundError,
    SkillRegistry,
    SkillToolError,
    TokenBudgetTracker,
    TokenCache,
    ToolInfo,
    create_lazy_registry,
    create_registry,
)


class TestSkillManifest:
    """Test SkillManifest class."""

    def test_from_yaml(self, tmp_path: Path) -> None:
        """Test loading manifest from YAML."""
        manifest_data = {
            "name": "test_skill",
            "version": "1.0.0",
            "description": "A test skill",
            "author": "Test Author",
            "category": "test",
            "tags": ["test", "example"],
            "requirements": ["pytest"],
            "entry_point": "test_skill:TestSkill",
            "tools": [
                {
                    "name": "echo",
                    "description": "Echo a message",
                    "parameters": [{"name": "message", "type": "string"}],
                    "returns": "string",
                    "token_count": 50,
                }
            ],
        }

        manifest_path = tmp_path / "skill.yaml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)

        manifest = SkillManifest.from_yaml(manifest_path)

        assert manifest.name == "test_skill"
        assert manifest.version == "1.0.0"
        assert manifest.description == "A test skill"
        assert manifest.author == "Test Author"
        assert manifest.category == "test"
        assert manifest.tags == ["test", "example"]
        assert manifest.requirements == ["pytest"]
        assert manifest.entry_point == "test_skill:TestSkill"
        assert len(manifest.tools) == 1
        assert manifest.tools[0].name == "echo"
        assert manifest.tools[0].token_count == 50

    def test_from_yaml_missing_name(self, tmp_path: Path) -> None:
        """Test loading manifest without required name field."""
        manifest_path = tmp_path / "skill.yaml"
        with open(manifest_path, "w") as f:
            yaml.dump({"version": "1.0.0"}, f)

        with pytest.raises(SkillLoadError):
            SkillManifest.from_yaml(manifest_path)

    def test_to_yaml(self, tmp_path: Path) -> None:
        """Test saving manifest to YAML."""
        manifest = SkillManifest(
            name="test_skill",
            version="1.0.0",
            description="A test skill",
            tools=[ToolInfo(name="echo", description="Echo", token_count=50)],
        )

        manifest_path = tmp_path / "skill.yaml"
        manifest.to_yaml(manifest_path)

        assert manifest_path.exists()

        # Load it back and verify
        loaded = SkillManifest.from_yaml(manifest_path)
        assert loaded.name == "test_skill"
        assert loaded.tools[0].name == "echo"

    def test_to_metadata(self) -> None:
        """Test converting manifest to metadata."""
        manifest = SkillManifest(
            name="test_skill",
            version="1.0.0",
            description="A test skill with description",
            tools=[
                ToolInfo(name="tool1", description="Tool 1", token_count=100),
                ToolInfo(name="tool2", description="Tool 2", token_count=200),
            ],
        )

        metadata = manifest.to_metadata()

        assert metadata.name == "test_skill"
        assert metadata.version == "1.0.0"
        assert metadata.tool_count == 2
        assert metadata.estimated_tokens > 300  # Sum of tool tokens + overhead

    def test_validate(self) -> None:
        """Test manifest validation."""
        # Valid manifest
        valid = SkillManifest(
            name="test",
            version="1.0.0",
            entry_point="test:TestSkill",
        )
        assert valid.validate() == []

        # Missing entry point
        invalid = SkillManifest(name="test", version="1.0.0")
        assert len(invalid.validate()) > 0


class TestManifestParser:
    """Test ManifestParser class."""

    def test_parse(self, tmp_path: Path) -> None:
        """Test parsing a manifest file."""
        manifest_data = {"name": "test", "version": "1.0.0"}
        manifest_path = tmp_path / "skill.yaml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)

        parser = ManifestParser()
        manifest = parser.parse(manifest_path)

        assert manifest.name == "test"

    def test_caching(self, tmp_path: Path) -> None:
        """Test that caching works."""
        manifest_data = {"name": "test", "version": "1.0.0"}
        manifest_path = tmp_path / "skill.yaml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)

        parser = ManifestParser()

        # First parse
        manifest1 = parser.parse(manifest_path)

        # Second parse should use cache
        manifest2 = parser.parse(manifest_path)

        # They should be the same object
        assert manifest1 is manifest2

    def test_no_cache(self, tmp_path: Path) -> None:
        """Test parsing without cache."""
        manifest_data = {"name": "test", "version": "1.0.0"}
        manifest_path = tmp_path / "skill.yaml"
        with open(manifest_path, "w") as f:
            yaml.dump(manifest_data, f)

        parser = ManifestParser()

        manifest1 = parser.parse(manifest_path, use_cache=False)
        manifest2 = parser.parse(manifest_path, use_cache=False)

        # They should be different objects
        assert manifest1 is not manifest2


class TestTokenCache:
    """Test TokenCache class."""

    def test_get_set(self) -> None:
        """Test getting and setting cache entries."""
        cache = TokenCache(cache_dir=tempfile.mkdtemp())

        # Initially not found
        assert cache.get("test_skill") is None

        # Set value
        cache.set("test_skill", 1500, source_hash="abc123")

        # Should now be found
        assert cache.get("test_skill") == 1500

    def test_source_hash_validation(self) -> None:
        """Test that source hash invalidates cache."""
        cache = TokenCache(cache_dir=tempfile.mkdtemp())

        cache.set("test_skill", 1500, source_hash="abc123")

        # Wrong hash should return None
        assert cache.get("test_skill", source_hash="different") is None

        # Correct hash should return value
        assert cache.get("test_skill", source_hash="abc123") == 1500

    def test_expiration(self) -> None:
        """Test cache entry expiration."""
        cache = TokenCache(cache_dir=tempfile.mkdtemp(), max_age_seconds=0.001)

        cache.set("test_skill", 1500)

        # Wait for expiration
        import time

        time.sleep(0.01)

        # Should be expired
        assert cache.get("test_skill") is None

    def test_estimate_tokens(self) -> None:
        """Test token estimation."""
        cache = TokenCache()

        # Rough estimate: 1 token per 4 chars
        text = "a" * 100
        assert cache.estimate_tokens(text) == 25

    def test_compute_hash(self, tmp_path: Path) -> None:
        """Test computing file hash."""
        file1 = tmp_path / "file1.txt"
        file2 = tmp_path / "file2.txt"

        file1.write_text("content1")
        file2.write_text("content2")

        hash1 = TokenCache.compute_hash(file1)
        hash2 = TokenCache.compute_hash(file2)

        # Same content should produce same hash
        assert len(hash1) == 16  # First 16 chars of sha256
        assert hash1 != hash2  # Different content


class TestTokenBudgetTracker:
    """Test TokenBudgetTracker class."""

    def test_allocation(self) -> None:
        """Test token allocation."""
        tracker = TokenBudgetTracker(budget=1000)

        assert tracker.remaining == 1000

        # Allocate some tokens
        assert tracker.allocate("skill1", 500) is True
        assert tracker.remaining == 500
        assert tracker.get_skill_usage("skill1") == 500

        # Try to allocate more than remaining
        assert tracker.allocate("skill2", 600) is False
        assert tracker.remaining == 500

    def test_release(self) -> None:
        """Test releasing tokens."""
        tracker = TokenBudgetTracker(budget=1000)
        tracker.allocate("skill1", 500)

        tracker.release("skill1", 200)

        assert tracker.remaining == 700
        assert tracker.get_skill_usage("skill1") == 300

    def test_percent_used(self) -> None:
        """Test percentage calculation."""
        tracker = TokenBudgetTracker(budget=1000)

        assert tracker.percent_used == 0.0

        tracker.allocate("skill1", 500)
        assert tracker.percent_used == 50.0


class TestSkillDiscovery:
    """Test SkillDiscovery class."""

    def test_discover_single_directory(self, tmp_path: Path) -> None:
        """Test discovering skills in a single directory."""
        # Create a skill directory with manifest
        skill_dir = tmp_path / "my_skill"
        skill_dir.mkdir()

        manifest_data = {"name": "my_skill", "version": "1.0.0"}
        with open(skill_dir / "skill.yaml", "w") as f:
            yaml.dump(manifest_data, f)

        discovery = SkillDiscovery()
        manifests = list(discovery.discover(skill_dir, recursive=False))

        assert len(manifests) == 1
        assert manifests[0].name == "my_skill"

    def test_discover_recursive(self, tmp_path: Path) -> None:
        """Test recursive skill discovery."""
        # Create nested skill directories
        skill1_dir = tmp_path / "skill1"
        skill1_dir.mkdir()
        skill2_dir = tmp_path / "sub" / "skill2"
        skill2_dir.mkdir(parents=True)

        with open(skill1_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "skill1", "version": "1.0.0"}, f)

        with open(skill2_dir / "manifest.yaml", "w") as f:
            yaml.dump({"name": "skill2", "version": "2.0.0"}, f)

        discovery = SkillDiscovery()
        manifests = list(discovery.discover(tmp_path, recursive=True))

        assert len(manifests) == 2
        names = {m.name for m in manifests}
        assert names == {"skill1", "skill2"}

    def test_discover_single(self, tmp_path: Path) -> None:
        """Test discovering a single skill."""
        skill_dir = tmp_path / "my_skill"
        skill_dir.mkdir()

        with open(skill_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "my_skill", "version": "1.0.0"}, f)

        discovery = SkillDiscovery()
        manifest = discovery.discover_single(skill_dir)

        assert manifest is not None
        assert manifest.name == "my_skill"

    def test_is_skill_directory(self, tmp_path: Path) -> None:
        """Test checking if a directory contains a skill."""
        skill_dir = tmp_path / "my_skill"
        skill_dir.mkdir()

        # Not a skill directory yet
        assert SkillDiscovery.is_skill_directory(skill_dir) is False

        # Add manifest
        with open(skill_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "my_skill", "version": "1.0.0"}, f)

        assert SkillDiscovery.is_skill_directory(skill_dir) is True


class TestLazySkill:
    """Test LazySkill base class."""

    def test_lazy_loading(self) -> None:
        """Test that skills are loaded lazily."""
        metadata = SkillMetadata(name="test", version="1.0.0")

        class TestSkill(LazySkill):
            def _load(self) -> None:
                self._tools = {"echo": lambda x: x}
                self._loaded = True

        skill = TestSkill(metadata)

        # Not loaded initially
        assert skill.is_loaded is False

        # Access tools triggers loading
        _ = skill.tools
        assert skill.is_loaded is True

    def test_get_tool(self) -> None:
        """Test getting a specific tool."""
        metadata = SkillMetadata(name="test", version="1.0.0")

        class TestSkill(LazySkill):
            def _load(self) -> None:
                self._tools = {"echo": lambda x: x}
                self._loaded = True

        skill = TestSkill(metadata)

        tool = skill.get_tool("echo")
        assert tool is not None

    def test_get_tool_not_found(self) -> None:
        """Test getting a non-existent tool."""
        metadata = SkillMetadata(name="test", version="1.0.0")

        class TestSkill(LazySkill):
            def _load(self) -> None:
                self._tools = {}
                self._loaded = True

        skill = TestSkill(metadata)

        with pytest.raises(SkillToolError):
            skill.get_tool("nonexistent")

    def test_unload(self) -> None:
        """Test unloading a skill."""
        metadata = SkillMetadata(name="test", version="1.0.0")

        class TestSkill(LazySkill):
            def _load(self) -> None:
                self._tools = {"echo": lambda x: x}
                self._loaded = True

        skill = TestSkill(metadata)

        # Load the skill
        _ = skill.tools
        assert skill.is_loaded is True

        # Unload
        skill.unload()
        assert skill.is_loaded is False


class TestFunctionSkill:
    """Test FunctionSkill class."""

    def test_register_tool(self) -> None:
        """Test registering tools."""
        metadata = SkillMetadata(name="test", version="1.0.0")

        def echo(msg: str) -> str:
            return msg

        skill = FunctionSkill(metadata, tools={"echo": echo})

        assert "echo" in skill.list_tools()

        tool = skill.get_tool("echo")
        assert tool("hello") == "hello"

    def test_register_after_load(self) -> None:
        """Test registering tools after loading."""
        metadata = SkillMetadata(name="test", version="1.0.0")
        skill = FunctionSkill(metadata)

        # Load the skill
        _ = skill.tools

        # Register new tool
        def new_tool():
            return "new"

        skill.register_tool("new", new_tool)

        assert "new" in skill.list_tools()


class TestSkillRegistry:
    """Test SkillRegistry class."""

    def test_discover_skills(self, tmp_path: Path) -> None:
        """Test discovering skills."""
        # Create skill directories
        skill1_dir = tmp_path / "skill1"
        skill1_dir.mkdir()
        skill2_dir = tmp_path / "skill2"
        skill2_dir.mkdir()

        with open(skill1_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "skill1", "version": "1.0.0"}, f)

        with open(skill2_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "skill2", "version": "2.0.0"}, f)

        registry = SkillRegistry()
        discovered = registry.discover_skills(tmp_path)

        assert set(discovered) == {"skill1", "skill2"}
        assert registry.list_available() == discovered

    def test_get_skill_not_found(self) -> None:
        """Test getting a non-existent skill."""
        registry = SkillRegistry()

        with pytest.raises(SkillNotFoundError):
            registry.get_skill("nonexistent")

    def test_register_skill(self) -> None:
        """Test registering a skill directly."""
        metadata = SkillMetadata(name="test", version="1.0.0")
        skill = FunctionSkill(metadata, tools={"echo": lambda x: x})

        registry = SkillRegistry()
        registry.register_skill(skill)

        assert registry.has_skill("test")
        assert "test" in registry.list_available()

    def test_list_loaded(self) -> None:
        """Test listing loaded skills."""
        metadata = SkillMetadata(name="test", version="1.0.0")
        skill = FunctionSkill(metadata, tools={"echo": lambda x: x})

        registry = SkillRegistry()
        registry.register_skill(skill)

        # Not loaded yet
        assert registry.list_loaded() == []

        # Load the skill
        registry.load_skill("test")
        assert registry.list_loaded() == ["test"]

    def test_unload_all(self) -> None:
        """Test unloading all skills."""
        metadata = SkillMetadata(name="test", version="1.0.0")
        skill = FunctionSkill(metadata, tools={"echo": lambda x: x})

        registry = SkillRegistry()
        registry.register_skill(skill)
        registry.load_skill("test")

        assert len(registry.list_loaded()) == 1

        registry.unload_all()

        assert registry.list_loaded() == []

    def test_clear(self) -> None:
        """Test clearing the registry."""
        metadata = SkillMetadata(name="test", version="1.0.0")
        skill = FunctionSkill(metadata, tools={"echo": lambda x: x})

        registry = SkillRegistry()
        registry.register_skill(skill)

        assert len(registry) == 1

        registry.clear()

        assert len(registry) == 0
        assert registry.list_available() == []


class TestManifestSkill:
    """Test ManifestSkill class."""

    def test_list_tools_from_manifest(self) -> None:
        """Test listing tools from manifest without loading."""
        manifest = SkillManifest(
            name="test",
            version="1.0.0",
            tools=[
                ToolInfo(name="tool1", description="Tool 1", token_count=50),
                ToolInfo(name="tool2", description="Tool 2", token_count=100),
            ],
        )

        skill = ManifestSkill(manifest)

        # Should list tools from manifest without loading
        assert not skill.is_loaded
        tools = skill.list_tools()
        assert tools == ["tool1", "tool2"]
        assert not skill.is_loaded  # Still not loaded

    def test_has_tool_from_manifest(self) -> None:
        """Test checking tool existence from manifest."""
        manifest = SkillManifest(
            name="test",
            version="1.0.0",
            tools=[
                ToolInfo(name="tool1", description="Tool 1", token_count=50),
            ],
        )

        skill = ManifestSkill(manifest)

        # Should check manifest without loading
        assert not skill.is_loaded
        assert skill.has_tool("tool1") is True
        assert skill.has_tool("tool2") is False


class TestIntegration:
    """Integration tests for the skill system."""

    @pytest.mark.asyncio
    async def test_full_workflow(self, tmp_path: Path) -> None:
        """Test the complete lazy loading workflow."""
        # Create a skill directory
        skill_dir = tmp_path / "echo_skill"
        skill_dir.mkdir()

        # Create manifest
        manifest_data = {
            "name": "echo",
            "version": "1.0.0",
            "description": "Echo skill",
            "entry_point": "echo_skill:EchoSkill",
            "tools": [
                {
                    "name": "say",
                    "description": "Echo a message",
                    "token_count": 50,
                }
            ],
        }

        with open(skill_dir / "skill.yaml", "w") as f:
            yaml.dump(manifest_data, f)

        # Create skill implementation
        skill_code = """
from oclawma.skills import LazySkill, SkillMetadata

class EchoSkill(LazySkill):
    def _load(self):
        self._tools = {"say": self._say}
        self._loaded = True

    async def _say(self, message: str = ""):
        return {"success": True, "output": message}
"""
        with open(skill_dir / "echo_skill.py", "w") as f:
            f.write(skill_code)

        # Create registry and discover
        registry = SkillRegistry()
        registry.discover_skills(tmp_path)

        # Skill should be discovered but not loaded
        assert "echo" in registry.list_available()
        assert registry.list_loaded() == []

        # Execute tool (this should load the skill)
        # Note: This would fail in real execution since we can't import
        # the module in this test context, but it demonstrates the flow


class TestCreateRegistry:
    """Test create_registry convenience function."""

    def test_create_registry(self, tmp_path: Path) -> None:
        """Test creating a registry with discovery."""
        # Create a skill
        skill_dir = tmp_path / "test_skill"
        skill_dir.mkdir()

        with open(skill_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "test", "version": "1.0.0"}, f)

        # Temporarily override skills path
        old_env = os.environ.get("OCLAWMA_SKILLS_PATH")
        os.environ["OCLAWMA_SKILLS_PATH"] = str(tmp_path)

        try:
            registry = create_registry()
            assert "test" in registry.list_available()
        finally:
            if old_env is not None:
                os.environ["OCLAWMA_SKILLS_PATH"] = old_env
            else:
                del os.environ["OCLAWMA_SKILLS_PATH"]

    def test_create_lazy_registry(self, tmp_path: Path) -> None:
        """Test create_lazy_registry function."""
        # Create a skill
        skill_dir = tmp_path / "test_skill"
        skill_dir.mkdir()

        with open(skill_dir / "skill.yaml", "w") as f:
            yaml.dump({"name": "test", "version": "1.0.0"}, f)

        registry = create_lazy_registry(str(tmp_path))

        assert "test" in registry.list_available()
