"""Testy dla mcp_helpers.signed_tool."""
import base64
import hashlib
import inspect
import json
import time

import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey

from mcp_helpers.signed_tool import SignedTool, _hash_args, signed


# ---------------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------------

def _generate_keypair() -> tuple[Ed25519PrivateKey, str]:
    """Zwraca (klucz_prywatny, base64(klucz_publiczny_raw))."""
    private_key = Ed25519PrivateKey.generate()
    public_key = private_key.public_key()
    raw_pub = public_key.public_bytes_raw()
    return private_key, base64.b64encode(raw_pub).decode()


def _sign(private_key: Ed25519PrivateKey, tool_name: str, timestamp: int, args: dict) -> str:
    args_hash = _hash_args(args)
    message = f"{tool_name}:{timestamp}:{args_hash}".encode()
    sig = private_key.sign(message)
    return base64.b64encode(sig).decode()


# ---------------------------------------------------------------------------
# fixtures
# ---------------------------------------------------------------------------

@pytest.fixture(autouse=True)
def reset_keys():
    """Czyści klucze przed każdym testem."""
    SignedTool._keys = {}
    yield
    SignedTool._keys = {}


@pytest.fixture()
def alice_keypair():
    return _generate_keypair()


@pytest.fixture()
def loaded_alice(alice_keypair):
    _, pub_b64 = alice_keypair
    SignedTool.load_keys({"alice": pub_b64})
    return alice_keypair


# ---------------------------------------------------------------------------
# load_keys
# ---------------------------------------------------------------------------

def test_load_keys_stores_key(alice_keypair):
    _, pub_b64 = alice_keypair
    SignedTool.load_keys({"alice": pub_b64})
    assert "alice" in SignedTool._keys


def test_load_keys_replaces_previous(alice_keypair):
    _, pub_b64 = alice_keypair
    SignedTool.load_keys({"alice": pub_b64, "bob": pub_b64})
    SignedTool.load_keys({"charlie": pub_b64})
    assert list(SignedTool._keys) == ["charlie"]


def test_get_key_unknown_raises():
    with pytest.raises(ValueError, match="Nieznany klucz"):
        SignedTool.get_key("nobody")


# ---------------------------------------------------------------------------
# @signed — sygnatura i docstring
# ---------------------------------------------------------------------------

def test_signed_adds_timestamp_and_signature_params():
    @signed("alice")
    async def my_tool(path: str) -> str:
        return path

    sig = inspect.signature(my_tool)
    assert "timestamp" in sig.parameters
    assert "signature" in sig.parameters
    assert sig.parameters["timestamp"].annotation is int
    assert sig.parameters["signature"].annotation is str


def test_signed_preserves_original_params():
    @signed("alice")
    async def my_tool(path: str, content: str) -> str:
        return path

    sig = inspect.signature(my_tool)
    assert "path" in sig.parameters
    assert "content" in sig.parameters


def test_signed_adds_docstring_note():
    @signed("alice")
    async def my_tool() -> str:
        """Oryginalna dokumentacja."""
        return ""

    assert "WYMAGA PODPISU" in my_tool.__doc__
    assert "alice" in my_tool.__doc__
    assert "Oryginalna dokumentacja." in my_tool.__doc__


def test_signed_preserves_function_name():
    @signed("alice")
    async def update_code() -> str:
        return ""

    assert update_code.__name__ == "update_code"


# ---------------------------------------------------------------------------
# @signed — weryfikacja podpisu
# ---------------------------------------------------------------------------

@pytest.mark.asyncio
async def test_valid_signature_passes(loaded_alice):
    private_key, _ = loaded_alice

    @signed("alice")
    async def my_tool(path: str) -> str:
        return path

    ts = int(time.time())
    args = {"path": "/tmp/test.py"}
    sig = _sign(private_key, "my_tool", ts, args)

    result = await my_tool(path="/tmp/test.py", timestamp=ts, signature=sig)
    assert result == "/tmp/test.py"


@pytest.mark.asyncio
async def test_invalid_signature_raises(loaded_alice):
    @signed("alice")
    async def my_tool(path: str) -> str:
        return path

    ts = int(time.time())
    bad_sig = base64.b64encode(b"x" * 64).decode()

    with pytest.raises(ValueError, match="Nieprawidłowy podpis"):
        await my_tool(path="/tmp/test.py", timestamp=ts, signature=bad_sig)


@pytest.mark.asyncio
async def test_expired_timestamp_raises(loaded_alice):
    private_key, _ = loaded_alice

    @signed("alice")
    async def my_tool(path: str) -> str:
        return path

    old_ts = int(time.time()) - 400  # poza oknem ±300s
    args = {"path": "/tmp/test.py"}
    sig = _sign(private_key, "my_tool", old_ts, args)

    with pytest.raises(ValueError, match="Timestamp poza oknem"):
        await my_tool(path="/tmp/test.py", timestamp=old_ts, signature=sig)


@pytest.mark.asyncio
async def test_custom_window(loaded_alice):
    private_key, _ = loaded_alice

    @signed("alice", window_seconds=600)
    async def my_tool(path: str) -> str:
        return path

    ts = int(time.time()) - 500  # poza domyślnym oknem, ale w oknie 600s
    args = {"path": "/tmp/test.py"}
    sig = _sign(private_key, "my_tool", ts, args)

    result = await my_tool(path="/tmp/test.py", timestamp=ts, signature=sig)
    assert result == "/tmp/test.py"


@pytest.mark.asyncio
async def test_unknown_key_raises():
    @signed("nobody")
    async def my_tool() -> str:
        return ""

    ts = int(time.time())
    sig = base64.b64encode(b"x" * 64).decode()

    with pytest.raises(ValueError, match="Nieznany klucz"):
        await my_tool(timestamp=ts, signature=sig)


@pytest.mark.asyncio
async def test_missing_timestamp_raises(loaded_alice):
    @signed("alice")
    async def my_tool() -> str:
        return ""

    with pytest.raises(ValueError, match="Brakujące pole"):
        await my_tool(signature="abc")


@pytest.mark.asyncio
async def test_signature_does_not_leak_to_original_fn(loaded_alice):
    """Oryginalna funkcja nie powinna otrzymać timestamp ani signature."""
    private_key, _ = loaded_alice
    received_kwargs = {}

    @signed("alice")
    async def my_tool(path: str) -> str:
        received_kwargs.update({"path": path})
        return path

    ts = int(time.time())
    args = {"path": "/x"}
    sig = _sign(private_key, "my_tool", ts, args)

    await my_tool(path="/x", timestamp=ts, signature=sig)
    assert "timestamp" not in received_kwargs
    assert "signature" not in received_kwargs


# ---------------------------------------------------------------------------
# _hash_args
# ---------------------------------------------------------------------------

def test_hash_args_is_deterministic():
    args = {"b": 2, "a": 1}
    assert _hash_args(args) == _hash_args(args)


def test_hash_args_sort_keys():
    assert _hash_args({"a": 1, "b": 2}) == _hash_args({"b": 2, "a": 1})


def test_hash_args_different_values():
    assert _hash_args({"a": 1}) != _hash_args({"a": 2})


def test_hash_args_known_value():
    args = {}
    expected_json = "{}"
    expected = hashlib.sha256(expected_json.encode()).hexdigest()
    assert _hash_args(args) == expected
