"""Unit tests for the skills module."""

from __future__ import annotations

import json
import stat
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock

import pytest

from core.skills import (
    Skill,
    SkillExecutor,
    SkillLoader,
    SkillParameter,
    _make_subprocess_handler,
    to_openai_tool,
)

# ---------------------------------------------------------------------------
# SkillParameter
# ---------------------------------------------------------------------------


class TestSkillParameter:
    """Tests for SkillParameter dataclass."""

    def test_create_parameter_with_defaults(self) -> None:
        param = SkillParameter(
            name="message",
            param_type="string",
            description="A test message",
        )
        assert param.name == "message"
        assert param.param_type == "string"
        assert param.description == "A test message"
        assert param.required is False
        assert param.default is None

    def test_create_required_parameter(self) -> None:
        param = SkillParameter(
            name="expression",
            param_type="string",
            description="Math expression",
            required=True,
        )
        assert param.required is True

    def test_create_parameter_with_default_value(self) -> None:
        param = SkillParameter(
            name="uppercase",
            param_type="boolean",
            description="Uppercase flag",
            default=False,
        )
        assert param.default is False


# ---------------------------------------------------------------------------
# Skill
# ---------------------------------------------------------------------------


class TestSkill:
    """Tests for Skill dataclass."""

    def test_create_skill(self) -> None:
        skill = Skill(
            name="echo",
            description="Echo skill",
            version="1.0.0",
            prompt_template="Echo: {message}",
        )
        assert skill.name == "echo"
        assert skill.description == "Echo skill"
        assert skill.version == "1.0.0"

    def test_render_prompt_simple(self) -> None:
        skill = Skill(
            name="echo",
            description="Echo skill",
            version="1.0.0",
            prompt_template="Echo: {message}",
            parameters=[
                SkillParameter(
                    name="message",
                    param_type="string",
                    description="Message to echo",
                    required=True,
                )
            ],
        )
        result = skill.render_prompt(message="Hello, World!")
        assert result == "Echo: Hello, World!"

    def test_render_prompt_with_default(self) -> None:
        skill = Skill(
            name="echo",
            description="Echo skill",
            version="1.0.0",
            prompt_template="Echo: {message}, Uppercase: {uppercase}",
            parameters=[
                SkillParameter(
                    name="message",
                    param_type="string",
                    description="Message",
                    required=True,
                ),
                SkillParameter(
                    name="uppercase",
                    param_type="boolean",
                    description="Uppercase",
                    default=False,
                ),
            ],
        )
        result = skill.render_prompt(message="test")
        assert result == "Echo: test, Uppercase: False"

    def test_render_prompt_missing_required(self) -> None:
        skill = Skill(
            name="echo",
            description="Echo skill",
            version="1.0.0",
            prompt_template="Echo: {message}",
            parameters=[
                SkillParameter(
                    name="message",
                    param_type="string",
                    description="Message",
                    required=True,
                )
            ],
        )
        with pytest.raises(ValueError, match="Missing required parameter: message"):
            skill.render_prompt()

    def test_render_prompt_multiple_params(self) -> None:
        skill = Skill(
            name="calculator",
            description="Calculator skill",
            version="1.0.0",
            prompt_template="Calculate: {expression} with precision {precision}",
            parameters=[
                SkillParameter(
                    name="expression",
                    param_type="string",
                    description="Expression",
                    required=True,
                ),
                SkillParameter(
                    name="precision",
                    param_type="integer",
                    description="Decimal precision",
                    default=2,
                ),
            ],
        )
        result = skill.render_prompt(expression="2 + 2", precision=4)
        assert result == "Calculate: 2 + 2 with precision 4"

    def test_has_handler_false_by_default(self) -> None:
        skill = Skill(name="test", description="", version="1.0.0", prompt_template="")
        assert skill.has_handler is False
        assert skill.handler is None

    def test_has_handler_true_when_set(self) -> None:
        skill = Skill(
            name="test",
            description="",
            version="1.0.0",
            prompt_template="",
            handler=lambda: "ok",
        )
        assert skill.has_handler is True


# ---------------------------------------------------------------------------
# SkillLoader
# ---------------------------------------------------------------------------


def _make_skill_dir(
    parent: Path,
    name: str,
    config: dict[str, Any],
    *,
    prompt: str | None = None,
    handler_code: str | None = None,
) -> Path:
    """Helper to create a skill directory for tests."""
    skill_dir = parent / name
    skill_dir.mkdir()
    (skill_dir / "skill.json").write_text(json.dumps(config))
    if prompt is not None:
        (skill_dir / "prompt.txt").write_text(prompt)
    if handler_code is not None:
        (skill_dir / "handler.py").write_text(handler_code)
    return skill_dir


class TestSkillLoader:
    """Tests for SkillLoader class."""

    @pytest.fixture
    def temp_skills_dir(self, tmp_path: Path) -> Path:
        """Create a temporary skills directory with prompt-only skills."""
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)

        _make_skill_dir(
            skills_dir,
            "echo",
            {
                "name": "echo",
                "description": "Echo skill",
                "version": "1.0.0",
                "parameters": {
                    "message": {
                        "type": "string",
                        "description": "Message to echo",
                        "required": True,
                    }
                },
            },
            prompt="Echo: {message}",
        )

        _make_skill_dir(
            skills_dir,
            "calculator",
            {
                "name": "calculator",
                "description": "Calculator skill",
                "version": "1.0.0",
                "parameters": {
                    "expression": {
                        "type": "string",
                        "description": "Math expression",
                        "required": True,
                    }
                },
            },
            prompt="Calculate: {expression}",
        )

        # Incomplete skill (no prompt.txt, no handler.py — only skill.json)
        _make_skill_dir(
            skills_dir,
            "incomplete",
            {
                "name": "incomplete",
                "description": "Incomplete skill",
                "version": "1.0.0",
            },
        )

        return skills_dir

    @pytest.fixture
    def temp_skills_with_handlers(self, tmp_path: Path) -> Path:
        """Create a skills directory with handler.py files."""
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)

        _make_skill_dir(
            skills_dir,
            "echo",
            {
                "name": "echo",
                "description": "Echo skill",
                "version": "1.0.0",
                "parameters": {
                    "message": {
                        "type": "string",
                        "description": "Message",
                        "required": True,
                    },
                    "uppercase": {
                        "type": "boolean",
                        "description": "Uppercase",
                        "default": False,
                    },
                },
            },
            prompt="Echo: {message}",
            handler_code=(
                "def handle(message: str, uppercase: bool = False) -> str:\n"
                "    return message.upper() if uppercase else message\n"
            ),
        )

        _make_skill_dir(
            skills_dir,
            "no_handle_fn",
            {
                "name": "no_handle_fn",
                "description": "Skill with handler.py but no handle function",
                "version": "1.0.0",
                "parameters": {},
            },
            handler_code="def something_else() -> str:\n    return 'nope'\n",
        )

        return skills_dir

    def test_load_all_skills(self, temp_skills_dir: Path) -> None:
        loader = SkillLoader(temp_skills_dir)
        skills = loader.load_all()

        # incomplete now loads (only skill.json required), but echo + calc + incomplete = 3
        assert "echo" in skills
        assert "calculator" in skills

    def test_get_skill(self, temp_skills_dir: Path) -> None:
        loader = SkillLoader(temp_skills_dir)
        loader.load_all()

        skill = loader.get_skill("echo")
        assert skill is not None
        assert skill.name == "echo"

    def test_get_nonexistent_skill(self, temp_skills_dir: Path) -> None:
        loader = SkillLoader(temp_skills_dir)
        loader.load_all()

        skill = loader.get_skill("nonexistent")
        assert skill is None

    def test_list_skills(self, temp_skills_dir: Path) -> None:
        loader = SkillLoader(temp_skills_dir)
        loader.load_all()

        skill_names = loader.list_skills()
        assert "echo" in skill_names
        assert "calculator" in skill_names

    def test_load_nonexistent_directory(self) -> None:
        loader = SkillLoader("/nonexistent/path")
        with pytest.raises(FileNotFoundError):
            loader.load_all()

    def test_skill_parameters_loaded_correctly(self, temp_skills_dir: Path) -> None:
        loader = SkillLoader(temp_skills_dir)
        loader.load_all()

        echo_skill = loader.get_skill("echo")
        assert echo_skill is not None
        assert len(echo_skill.parameters) == 1
        assert echo_skill.parameters[0].name == "message"
        assert echo_skill.parameters[0].required is True

    # --- Handler loading tests ---

    def test_handler_loaded(self, temp_skills_with_handlers: Path) -> None:
        loader = SkillLoader(temp_skills_with_handlers)
        skills = loader.load_all()
        echo = skills["echo"]
        assert echo.handler is not None
        assert echo.has_handler is True

    def test_handler_callable(self, temp_skills_with_handlers: Path) -> None:
        loader = SkillLoader(temp_skills_with_handlers)
        skills = loader.load_all()
        echo = skills["echo"]
        assert echo.handler is not None
        assert echo.handler("hello") == "hello"
        assert echo.handler("hello", uppercase=True) == "HELLO"

    def test_skill_without_handler(self, temp_skills_dir: Path) -> None:
        loader = SkillLoader(temp_skills_dir)
        skills = loader.load_all()
        echo = skills["echo"]
        assert echo.handler is None
        assert echo.has_handler is False

    def test_handler_without_handle_function(self, temp_skills_with_handlers: Path) -> None:
        loader = SkillLoader(temp_skills_with_handlers)
        skills = loader.load_all()
        skill = skills["no_handle_fn"]
        assert skill.handler is None
        assert skill.has_handler is False

    def test_handler_with_syntax_error(self, tmp_path: Path) -> None:
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)
        _make_skill_dir(
            skills_dir,
            "bad",
            {"name": "bad", "description": "Bad handler", "version": "1.0.0"},
            handler_code="def handle( -> str:\n",
        )
        loader = SkillLoader(skills_dir)
        skills = loader.load_all()
        assert skills["bad"].handler is None


# ---------------------------------------------------------------------------
# to_openai_tool
# ---------------------------------------------------------------------------


class TestToOpenaiTool:
    """Tests for the to_openai_tool conversion function."""

    def test_basic_conversion(self) -> None:
        skill = Skill(
            name="echo",
            description="Echo skill",
            version="1.0.0",
            prompt_template="",
            parameters=[
                SkillParameter(
                    name="message",
                    param_type="string",
                    description="The message",
                    required=True,
                ),
            ],
        )
        tool = to_openai_tool(skill)

        assert tool["type"] == "function"
        assert tool["function"]["name"] == "echo"
        assert tool["function"]["description"] == "Echo skill"
        assert "message" in tool["function"]["parameters"]["properties"]
        assert tool["function"]["parameters"]["required"] == ["message"]

    def test_with_default_value(self) -> None:
        skill = Skill(
            name="echo",
            description="Echo",
            version="1.0.0",
            prompt_template="",
            parameters=[
                SkillParameter(
                    name="message",
                    param_type="string",
                    description="Msg",
                    required=True,
                ),
                SkillParameter(
                    name="uppercase",
                    param_type="boolean",
                    description="UC",
                    default=False,
                ),
            ],
        )
        tool = to_openai_tool(skill)
        props = tool["function"]["parameters"]["properties"]

        assert props["uppercase"]["default"] is False
        assert "uppercase" not in tool["function"]["parameters"]["required"]

    def test_type_mapping(self) -> None:
        skill = Skill(
            name="test",
            description="Test",
            version="1.0.0",
            prompt_template="",
            parameters=[
                SkillParameter(name="a", param_type="string", description=""),
                SkillParameter(name="b", param_type="integer", description=""),
                SkillParameter(name="c", param_type="number", description=""),
                SkillParameter(name="d", param_type="boolean", description=""),
                SkillParameter(name="e", param_type="unknown_type", description=""),
            ],
        )
        tool = to_openai_tool(skill)
        props = tool["function"]["parameters"]["properties"]

        assert props["a"]["type"] == "string"
        assert props["b"]["type"] == "integer"
        assert props["c"]["type"] == "number"
        assert props["d"]["type"] == "boolean"
        assert props["e"]["type"] == "string"  # fallback

    def test_no_parameters(self) -> None:
        skill = Skill(
            name="noop",
            description="No-op",
            version="1.0.0",
            prompt_template="",
        )
        tool = to_openai_tool(skill)

        assert tool["function"]["parameters"]["properties"] == {}
        assert tool["function"]["parameters"]["required"] == []


# ---------------------------------------------------------------------------
# SkillExecutor
# ---------------------------------------------------------------------------


class TestSkillExecutor:
    """Tests for SkillExecutor class."""

    @pytest.fixture
    def mock_client(self) -> MagicMock:
        """Create a mock Azure OpenAI client."""
        client = MagicMock()
        mock_response = MagicMock()
        mock_choice = MagicMock()
        mock_message = MagicMock()
        mock_message.content = "Mock response"
        mock_choice.message = mock_message
        mock_response.choices = [mock_choice]
        client.chat.completions.create.return_value = mock_response
        return client

    @pytest.fixture
    def sample_skill(self) -> Skill:
        """Create a sample skill for testing."""
        return Skill(
            name="echo",
            description="Echo skill",
            version="1.0.0",
            prompt_template="Echo: {message}",
            parameters=[
                SkillParameter(
                    name="message",
                    param_type="string",
                    description="Message to echo",
                    required=True,
                )
            ],
        )

    def test_execute_skill(self, mock_client: MagicMock, sample_skill: Skill) -> None:
        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.execute(sample_skill, message="Hello!")

        assert result == "Mock response"
        mock_client.chat.completions.create.assert_called_once()

    def test_execute_skill_with_custom_temperature(self, mock_client: MagicMock, sample_skill: Skill) -> None:
        executor = SkillExecutor(mock_client, "gpt-4")
        executor.execute(sample_skill, temperature=0.8, message="Hello!")

        call_kwargs = mock_client.chat.completions.create.call_args.kwargs
        assert call_kwargs["temperature"] == 0.8

    def test_execute_skill_uses_correct_deployment(self, mock_client: MagicMock, sample_skill: Skill) -> None:
        executor = SkillExecutor(mock_client, "my-deployment")
        executor.execute(sample_skill, message="Hello!")

        call_kwargs = mock_client.chat.completions.create.call_args.kwargs
        assert call_kwargs["model"] == "my-deployment"

    def test_execute_skill_renders_prompt(self, mock_client: MagicMock, sample_skill: Skill) -> None:
        executor = SkillExecutor(mock_client, "gpt-4")
        executor.execute(sample_skill, message="Test message")

        call_kwargs = mock_client.chat.completions.create.call_args.kwargs
        messages = call_kwargs["messages"]
        assert len(messages) == 2
        assert messages[1]["content"] == "Echo: Test message"

    def test_execute_skill_with_custom_system_prompt(self, mock_client: MagicMock, sample_skill: Skill) -> None:
        executor = SkillExecutor(mock_client, "gpt-4", system_prompt="Custom system prompt")
        executor.execute(sample_skill, message="Hello!")

        call_kwargs = mock_client.chat.completions.create.call_args.kwargs
        messages = call_kwargs["messages"]
        assert messages[0]["content"] == "Custom system prompt"

    def test_execute_skill_handles_empty_response(self, mock_client: MagicMock, sample_skill: Skill) -> None:
        mock_client.chat.completions.create.return_value.choices[0].message.content = None
        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.execute(sample_skill, message="Hello!")

        assert result == ""


# ---------------------------------------------------------------------------
# SkillExecutor.run (tool-calling loop)
# ---------------------------------------------------------------------------


def _make_assistant_msg(
    content: str | None = None,
    tool_calls: list[Any] | None = None,
) -> MagicMock:
    """Build a mock assistant message."""
    msg = MagicMock()
    msg.content = content
    msg.tool_calls = tool_calls
    if tool_calls is not None:
        msg.model_dump.return_value = {
            "role": "assistant",
            "content": content,
            "tool_calls": [
                {
                    "id": tc.id,
                    "type": "function",
                    "function": {
                        "name": tc.function.name,
                        "arguments": tc.function.arguments,
                    },
                }
                for tc in tool_calls
            ],
        }
    return msg


def _make_tool_call(call_id: str, fn_name: str, arguments: dict[str, Any]) -> MagicMock:
    """Build a mock tool call."""
    tc = MagicMock()
    tc.id = call_id
    tc.function.name = fn_name
    tc.function.arguments = json.dumps(arguments)
    return tc


class TestSkillExecutorRun:
    """Tests for the tool-calling run() method."""

    @pytest.fixture
    def mock_client(self) -> MagicMock:
        return MagicMock()

    def test_run_no_tool_calls(self, mock_client: MagicMock) -> None:
        """LLM responds directly without calling tools."""
        msg = _make_assistant_msg(content="I can help with that!")
        mock_client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])

        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.run("Hello", {})
        assert result == "I can help with that!"

    def test_run_with_tool_call(self, mock_client: MagicMock) -> None:
        """LLM calls a tool, handler executes, LLM gives final answer."""
        tc = _make_tool_call("call_1", "echo", {"message": "hi", "uppercase": True})
        first_msg = _make_assistant_msg(tool_calls=[tc])
        second_msg = _make_assistant_msg(content="HI")

        mock_client.chat.completions.create.side_effect = [
            MagicMock(choices=[MagicMock(message=first_msg)]),
            MagicMock(choices=[MagicMock(message=second_msg)]),
        ]

        skill = Skill(
            name="echo",
            description="Echo",
            version="1.0.0",
            prompt_template="",
            parameters=[
                SkillParameter(name="message", param_type="string", description="", required=True),
                SkillParameter(
                    name="uppercase",
                    param_type="boolean",
                    description="",
                    default=False,
                ),
            ],
            handler=lambda message, uppercase=False: message.upper() if uppercase else message,
        )

        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.run("echo hi in uppercase", {"echo": skill})
        assert result == "HI"

    def test_run_handler_exception(self, mock_client: MagicMock) -> None:
        """Handler raises — error is returned to LLM as tool result."""
        tc = _make_tool_call("call_2", "bad", {})
        first_msg = _make_assistant_msg(tool_calls=[tc])
        second_msg = _make_assistant_msg(content="Sorry, that tool failed.")

        mock_client.chat.completions.create.side_effect = [
            MagicMock(choices=[MagicMock(message=first_msg)]),
            MagicMock(choices=[MagicMock(message=second_msg)]),
        ]

        def exploding_handler() -> str:
            raise ValueError("Something broke")

        skill = Skill(
            name="bad",
            description="Bad",
            version="1.0.0",
            prompt_template="",
            handler=exploding_handler,
        )

        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.run("do the thing", {"bad": skill})
        assert result == "Sorry, that tool failed."

    def test_run_unknown_tool(self, mock_client: MagicMock) -> None:
        """LLM calls a tool that doesn't exist in the skills dict."""
        tc = _make_tool_call("call_3", "nonexistent", {})
        first_msg = _make_assistant_msg(tool_calls=[tc])
        second_msg = _make_assistant_msg(content="That tool doesn't exist.")

        mock_client.chat.completions.create.side_effect = [
            MagicMock(choices=[MagicMock(message=first_msg)]),
            MagicMock(choices=[MagicMock(message=second_msg)]),
        ]

        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.run("use nonexistent", {})
        assert result == "That tool doesn't exist."

    def test_run_max_turns_exceeded(self, mock_client: MagicMock) -> None:
        """Safety valve: loop exits after max_turns."""
        tc = _make_tool_call("call_loop", "echo", {"message": "loop"})
        loop_msg = _make_assistant_msg(tool_calls=[tc])

        # Always return a tool call, never a final answer
        mock_client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=loop_msg)])

        skill = Skill(
            name="echo",
            description="Echo",
            version="1.0.0",
            prompt_template="",
            handler=lambda message: message,
        )

        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.run("loop forever", {"echo": skill}, max_turns=3)
        assert result == "ERROR: Maximum conversation turns exceeded."

    def test_run_empty_response(self, mock_client: MagicMock) -> None:
        """LLM returns empty content with no tool calls."""
        msg = _make_assistant_msg(content=None)
        mock_client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])

        executor = SkillExecutor(mock_client, "gpt-4")
        result = executor.run("Hello", {})
        assert result == ""

    def test_run_skills_without_handlers_excluded(self, mock_client: MagicMock) -> None:
        """Skills without handlers are not registered as tools."""
        msg = _make_assistant_msg(content="No tools available.")
        mock_client.chat.completions.create.return_value = MagicMock(choices=[MagicMock(message=msg)])

        prompt_only_skill = Skill(
            name="prompt_only",
            description="Prompt only",
            version="1.0.0",
            prompt_template="test",
        )

        executor = SkillExecutor(mock_client, "gpt-4")
        executor.run("hello", {"prompt_only": prompt_only_skill})

        call_kwargs = mock_client.chat.completions.create.call_args.kwargs
        assert "tools" not in call_kwargs


# ---------------------------------------------------------------------------
# Subprocess handler
# ---------------------------------------------------------------------------


def _write_shell_handler(path: Path, script: str) -> None:
    """Write a shell script handler and make it executable."""
    path.write_text(script)
    path.chmod(path.stat().st_mode | stat.S_IEXEC)


class TestSubprocessHandler:
    """Tests for _make_subprocess_handler and subprocess detection in SkillLoader."""

    def test_subprocess_handler_receives_json_stdin(self, tmp_path: Path) -> None:
        """Handler receives kwargs as JSON on stdin and returns stdout."""
        script = tmp_path / "echo_args.sh"
        # Shell script that reads stdin and echoes it back
        _write_shell_handler(script, "#!/bin/sh\ncat\n")

        handler = _make_subprocess_handler(["sh", "echo_args.sh"], tmp_path)
        result = handler(name="Alice", count=3)

        parsed = json.loads(result)
        assert parsed == {"name": "Alice", "count": 3}

    def test_subprocess_handler_stdout_returned(self, tmp_path: Path) -> None:
        """Handler stdout is returned as the result string."""
        script = tmp_path / "greet.sh"
        _write_shell_handler(
            script,
            '#!/bin/sh\necho "Hello from shell"\n',
        )

        handler = _make_subprocess_handler(["sh", "greet.sh"], tmp_path)
        result = handler()
        assert result == "Hello from shell"

    def test_subprocess_handler_nonzero_exit(self, tmp_path: Path) -> None:
        """Non-zero exit code returns an error string."""
        script = tmp_path / "fail.sh"
        _write_shell_handler(
            script,
            '#!/bin/sh\necho "something broke" >&2\nexit 1\n',
        )

        handler = _make_subprocess_handler(["sh", "fail.sh"], tmp_path)
        result = handler()
        assert result.startswith("ERROR:")
        assert "something broke" in result

    def test_subprocess_handler_timeout(self, tmp_path: Path) -> None:
        """Handler that exceeds timeout returns error."""
        script = tmp_path / "slow.sh"
        _write_shell_handler(script, "#!/bin/sh\nsleep 60\n")

        handler = _make_subprocess_handler(["sh", "slow.sh"], tmp_path)
        # Patch timeout to 1s so test doesn't wait 30s
        from core import skills

        original = skills._SUBPROCESS_TIMEOUT
        skills._SUBPROCESS_TIMEOUT = 1
        try:
            result = handler()
        finally:
            skills._SUBPROCESS_TIMEOUT = original

        assert "timed out" in result

    def test_loader_detects_explicit_command(self, tmp_path: Path) -> None:
        """skill.json with 'handler.command' loads a subprocess handler."""
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)
        skill_dir = skills_dir / "greet"
        skill_dir.mkdir()

        (skill_dir / "skill.json").write_text(
            json.dumps(
                {
                    "name": "greet",
                    "description": "Greet",
                    "version": "1.0.0",
                    "handler": {"command": ["sh", "greet.sh"]},
                    "parameters": {
                        "name": {
                            "type": "string",
                            "description": "Name",
                            "required": True,
                        }
                    },
                }
            )
        )
        _write_shell_handler(
            skill_dir / "greet.sh",
            '#!/bin/sh\necho "hello"\n',
        )

        loader = SkillLoader(skills_dir)
        skills = loader.load_all()

        assert "greet" in skills
        assert skills["greet"].has_handler
        assert skills["greet"].handler() == "hello"

    def test_loader_autodetects_executable_handler(self, tmp_path: Path) -> None:
        """Executable file named 'handler' (no ext) is auto-detected."""
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)
        skill_dir = skills_dir / "bintest"
        skill_dir.mkdir()

        (skill_dir / "skill.json").write_text(
            json.dumps(
                {
                    "name": "bintest",
                    "description": "Binary test",
                    "version": "1.0.0",
                    "parameters": {},
                }
            )
        )
        _write_shell_handler(
            skill_dir / "handler",
            '#!/bin/sh\necho "from binary"\n',
        )

        loader = SkillLoader(skills_dir)
        skills = loader.load_all()

        assert "bintest" in skills
        assert skills["bintest"].has_handler
        assert skills["bintest"].handler() == "from binary"

    def test_loader_python_handler_takes_priority(self, tmp_path: Path) -> None:
        """handler.py is preferred over handler (executable) in same dir."""
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)
        skill_dir = skills_dir / "dual"
        skill_dir.mkdir()

        (skill_dir / "skill.json").write_text(
            json.dumps(
                {
                    "name": "dual",
                    "description": "Dual handler",
                    "version": "1.0.0",
                    "parameters": {},
                }
            )
        )
        (skill_dir / "handler.py").write_text("def handle() -> str:\n    return 'from python'\n")
        _write_shell_handler(
            skill_dir / "handler",
            '#!/bin/sh\necho "from shell"\n',
        )

        loader = SkillLoader(skills_dir)
        skills = loader.load_all()

        assert skills["dual"].handler() == "from python"

    def test_loader_no_handler_detected(self, tmp_path: Path) -> None:
        """Skill with only skill.json has no handler."""
        skills_dir = tmp_path / ".chatixia" / "skills"
        skills_dir.mkdir(parents=True)
        skill_dir = skills_dir / "bare"
        skill_dir.mkdir()

        (skill_dir / "skill.json").write_text(
            json.dumps(
                {
                    "name": "bare",
                    "description": "Bare",
                    "version": "1.0.0",
                    "parameters": {},
                }
            )
        )

        loader = SkillLoader(skills_dir)
        skills = loader.load_all()

        assert skills["bare"].has_handler is False
