from __future__ import annotations

import json
from typing import Literal
from unittest.mock import patch

import pytest

from pillar._client import Pillar
from pillar._context import ToolContext
from pillar._signature import _generate_signature

SECRET = "whsec_test"


@pytest.fixture
def pillar() -> Pillar:
    p = Pillar(
        secret=SECRET,
        auto_register=False,
        endpoint_url="https://example.com/pillar",
    )
    return p


@pytest.fixture
def pillar_with_tool(pillar: Pillar) -> Pillar:
    @pillar.tool(description="Look up a customer by email")
    async def lookup_customer(email: str, ctx: ToolContext) -> dict:
        return {"name": "Sarah", "plan": "enterprise"}

    return pillar


def _make_body(
    tool_name: str = "lookup_customer",
    arguments: dict | None = None,
    action: str = "tool_call",
) -> bytes:
    return json.dumps({
        "action": action,
        "call_id": "tc_test",
        "tool_name": tool_name,
        "arguments": arguments or {"email": "sarah@acme.com"},
        "caller": {
            "channel": "web",
            "email": "sarah@acme.com",
            "display_name": "Sarah",
        },
        "conversation_id": "conv_1",
        "timeout_ms": 30000,
    }).encode()


def _signed_headers(body: bytes) -> dict[str, str]:
    return {"X-Pillar-Signature": _generate_signature(body, SECRET)}


class TestPing:
    @pytest.mark.asyncio
    async def test_ping_returns_ok(self, pillar: Pillar) -> None:
        body = json.dumps({"action": "ping"}).encode()
        result, status = await pillar.handle(body)
        assert status == 200
        assert result == {"status": "ok"}

    @pytest.mark.asyncio
    async def test_ping_without_signature(self, pillar: Pillar) -> None:
        body = json.dumps({"action": "ping"}).encode()
        result, status = await pillar.handle(body, headers={})
        assert status == 200


class TestToolDispatch:
    @pytest.mark.asyncio
    async def test_successful_tool_call(self, pillar_with_tool: Pillar) -> None:
        body = _make_body()
        headers = _signed_headers(body)
        result, status = await pillar_with_tool.handle(body, headers)
        assert status == 200
        assert result["success"] is True
        assert result["result"]["name"] == "Sarah"
        assert result["call_id"] == "tc_test"

    @pytest.mark.asyncio
    async def test_tool_not_found(self, pillar_with_tool: Pillar) -> None:
        body = _make_body(tool_name="nonexistent")
        headers = _signed_headers(body)
        result, status = await pillar_with_tool.handle(body, headers)
        assert status == 404
        assert result["success"] is False
        assert "not found" in result["error"]


class TestSignatureVerification:
    @pytest.mark.asyncio
    async def test_invalid_signature_rejected(self, pillar_with_tool: Pillar) -> None:
        body = _make_body()
        headers = {"X-Pillar-Signature": "t=123,v1=invalid"}
        result, status = await pillar_with_tool.handle(body, headers)
        assert status == 401

    @pytest.mark.asyncio
    async def test_missing_signature_rejected(self, pillar_with_tool: Pillar) -> None:
        body = _make_body()
        headers = {"X-Pillar-Signature": ""}
        result, status = await pillar_with_tool.handle(body, headers)
        assert status == 401

    @pytest.mark.asyncio
    async def test_skips_verification_when_no_headers(self) -> None:
        p = Pillar(secret=SECRET, auto_register=False)

        @p.tool(description="Test")
        async def test_tool(x: str) -> dict:
            return {"x": x}

        body = _make_body(tool_name="test_tool", arguments={"x": "hello"})
        result, status = await p.handle(body)
        assert status == 200
        assert result["success"] is True


class TestErrorHandling:
    @pytest.mark.asyncio
    async def test_value_error_returns_200(self, pillar: Pillar) -> None:
        @pillar.tool(description="Raises ValueError")
        async def bad_tool(x: str) -> dict:
            raise ValueError("bad input")

        body = _make_body(tool_name="bad_tool", arguments={"x": "test"})
        headers = _signed_headers(body)
        result, status = await pillar.handle(body, headers)
        assert status == 200
        assert result["success"] is False
        assert "bad input" in result["error"]

    @pytest.mark.asyncio
    async def test_permission_error_returns_200(self, pillar: Pillar) -> None:
        @pillar.tool(description="Raises PermissionError")
        async def restricted(x: str) -> dict:
            raise PermissionError("not allowed")

        body = _make_body(tool_name="restricted", arguments={"x": "test"})
        headers = _signed_headers(body)
        result, status = await pillar.handle(body, headers)
        assert status == 200
        assert result["success"] is False
        assert "not allowed" in result["error"]

    @pytest.mark.asyncio
    async def test_unhandled_error_returns_500(self, pillar: Pillar) -> None:
        @pillar.tool(description="Raises RuntimeError")
        async def crash(x: str) -> dict:
            raise RuntimeError("unexpected")

        body = _make_body(tool_name="crash", arguments={"x": "test"})
        headers = _signed_headers(body)
        result, status = await pillar.handle(body, headers)
        assert status == 500
        assert result["success"] is False
        assert result["error"] == "Internal error"

    @pytest.mark.asyncio
    async def test_invalid_json(self, pillar: Pillar) -> None:
        result, status = await pillar.handle(b"not json")
        assert status == 400

    @pytest.mark.asyncio
    async def test_unknown_action(self, pillar: Pillar) -> None:
        body = json.dumps({"action": "unknown"}).encode()
        headers = _signed_headers(body)
        result, status = await pillar.handle(body, headers)
        assert status == 400


class TestSyncToolHandler:
    @pytest.mark.asyncio
    async def test_sync_handler_works(self, pillar: Pillar) -> None:
        @pillar.tool(description="Sync tool")
        def sync_tool(x: str) -> dict:
            return {"upper": x.upper()}

        body = _make_body(tool_name="sync_tool", arguments={"x": "hello"})
        headers = _signed_headers(body)
        result, status = await pillar.handle(body, headers)
        assert status == 200
        assert result["result"]["upper"] == "HELLO"


class TestRegistration:
    def test_register_requires_endpoint_url(self) -> None:
        p = Pillar(secret=SECRET, auto_register=False)
        with pytest.raises(ValueError, match="endpoint_url"):
            p.register()

    @pytest.mark.asyncio
    async def test_auto_register_on_first_handle(self) -> None:
        p = Pillar(
            secret=SECRET,
            auto_register=True,
            endpoint_url="https://example.com/pillar",
        )

        @p.tool(description="Test")
        async def my_tool(x: str) -> dict:
            return {"x": x}

        body = _make_body(tool_name="my_tool", arguments={"x": "hi"})

        with patch("pillar._client.register_tools") as mock_reg:
            mock_reg.return_value = {"registered": ["my_tool"]}
            result, status = await p.handle(body)
            assert mock_reg.called
            assert status == 200


class TestToolConfirm:
    @pytest.mark.asyncio
    async def test_tool_confirm_action(self, pillar: Pillar) -> None:
        @pillar.tool(description="Create a plan")
        async def create_plan(name: str, ctx: ToolContext) -> dict:
            if not ctx.confirmed:
                return {
                    "confirmation_required": True,
                    "title": "Create plan",
                    "message": f"Create {name}?",
                    "confirm_payload": {"name": name},
                }
            return {"created": True, "plan": name}

        call_body = json.dumps({
            "action": "tool_call",
            "call_id": "tc_confirm",
            "tool_name": "create_plan",
            "arguments": {"name": "Pro"},
            "caller": {"channel": "slack"},
            "conversation_id": "conv_1",
        }).encode()
        result, status = await pillar.handle(call_body)
        assert status == 200
        assert result["success"] is True
        assert result["result"]["confirmation_required"] is True
        assert result["result"]["title"] == "Create plan"

        confirm_body = json.dumps({
            "action": "tool_confirm",
            "call_id": "tc_confirm",
            "tool_name": "create_plan",
            "confirm_payload": {"name": "Pro"},
            "caller": {"channel": "slack"},
            "conversation_id": "conv_1",
        }).encode()
        result, status = await pillar.handle(confirm_body)
        assert status == 200
        assert result["success"] is True
        assert result["result"]["created"] is True
        assert result["result"]["plan"] == "Pro"

    @pytest.mark.asyncio
    async def test_confirmed_context_set(self, pillar: Pillar) -> None:
        confirmed_values: list[bool] = []

        @pillar.tool(description="Track confirmed")
        async def tracker(ctx: ToolContext) -> dict:
            confirmed_values.append(ctx.confirmed)
            return {"ok": True}

        call_body = json.dumps({
            "action": "tool_call",
            "call_id": "tc_ctx",
            "tool_name": "tracker",
            "arguments": {},
            "caller": {"channel": "web"},
            "conversation_id": "conv_2",
        }).encode()
        await pillar.handle(call_body)

        confirm_body = json.dumps({
            "action": "tool_confirm",
            "call_id": "tc_ctx",
            "tool_name": "tracker",
            "confirm_payload": {},
            "caller": {"channel": "web"},
            "conversation_id": "conv_2",
        }).encode()
        await pillar.handle(confirm_body)

        assert confirmed_values == [False, True]


class TestToolDecorator:
    def test_tool_decorator_via_pillar_instance(self, pillar: Pillar) -> None:
        @pillar.tool(description="Greet")
        def greet(name: str) -> str:
            return f"Hello, {name}"

        assert "greet" in pillar._tools
        assert greet("world") == "Hello, world"
