"""Tests for server_service functions."""

from __future__ import annotations

from typing import Any
from unittest.mock import AsyncMock, patch

import pytest

from app.models.server import ServerSettingsResponse, ServerSettingsUpdate
from app.services import server_service
from app.services.server_service import ServerOperationError

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_SOCKET = "/fake/fail2ban.sock"

_DEFAULT_RESPONSES: dict[str, Any] = {
    "get|loglevel": (0, "INFO"),
    "get|logtarget": (0, "/var/log/fail2ban.log"),
    "get|syslogsocket": (0, None),
    "get|dbfile": (0, "/var/lib/fail2ban/fail2ban.sqlite3"),
    "get|dbpurgeage": (0, 86400),
    "get|dbmaxmatches": (0, 10),
}


def _make_send(responses: dict[str, Any]) -> AsyncMock:
    async def _side_effect(command: list[Any]) -> Any:
        key = "|".join(str(c) for c in command)
        return responses.get(key, (0, None))

    return AsyncMock(side_effect=_side_effect)


def _patch_client(responses: dict[str, Any]) -> Any:
    mock_send = _make_send(responses)

    class _FakeClient:
        def __init__(self, **_kw: Any) -> None:
            self.send = mock_send

    return patch("app.services.server_service.Fail2BanClient", _FakeClient)


# ---------------------------------------------------------------------------
# get_settings
# ---------------------------------------------------------------------------


class TestGetSettings:
    """Unit tests for :func:`~app.services.server_service.get_settings`."""

    async def test_returns_server_settings_response(self) -> None:
        """get_settings returns a properly populated ServerSettingsResponse."""
        with _patch_client(_DEFAULT_RESPONSES):
            result = await server_service.get_settings(_SOCKET)

        assert isinstance(result, ServerSettingsResponse)
        assert result.settings.log_level == "INFO"
        assert result.settings.log_target == "/var/log/fail2ban.log"
        assert result.settings.db_purge_age == 86400
        assert result.settings.db_max_matches == 10
        assert result.warnings == {"db_purge_age_too_low": False}

    async def test_db_purge_age_warning_when_below_minimum(self) -> None:
        """get_settings sets warning when db_purge_age is below 86400 seconds."""
        responses = {**_DEFAULT_RESPONSES, "get|dbpurgeage": (0, 3600)}
        with _patch_client(responses):
            result = await server_service.get_settings(_SOCKET)

        assert result.settings.db_purge_age == 3600
        assert result.warnings == {"db_purge_age_too_low": True}

    async def test_db_path_parsed(self) -> None:
        """get_settings returns the correct database file path."""
        with _patch_client(_DEFAULT_RESPONSES):
            result = await server_service.get_settings(_SOCKET)

        assert result.settings.db_path == "/var/lib/fail2ban/fail2ban.sqlite3"

    async def test_syslog_socket_none(self) -> None:
        """get_settings returns None for syslog_socket when not configured."""
        with _patch_client(_DEFAULT_RESPONSES):
            result = await server_service.get_settings(_SOCKET)

        assert result.settings.syslog_socket is None

    async def test_fallback_defaults_on_missing_commands(self) -> None:
        """get_settings uses fallback defaults when commands return None."""
        with _patch_client({}):
            result = await server_service.get_settings(_SOCKET)

        assert result.settings.log_level == "INFO"
        assert result.settings.db_max_matches == 10


# ---------------------------------------------------------------------------
# update_settings
# ---------------------------------------------------------------------------


class TestUpdateSettings:
    """Unit tests for :func:`~app.services.server_service.update_settings`."""

    async def test_sends_set_commands_for_non_none_fields(self) -> None:
        """update_settings sends set commands only for non-None fields."""
        sent: list[list[Any]] = []

        async def _send(command: list[Any]) -> Any:
            sent.append(command)
            return (0, "OK")

        class _FakeClient:
            def __init__(self, **_kw: Any) -> None:
                self.send = AsyncMock(side_effect=_send)

        update = ServerSettingsUpdate(log_level="DEBUG", db_purge_age=3600)
        with patch("app.services.server_service.Fail2BanClient", _FakeClient):
            await server_service.update_settings(_SOCKET, update)

        keys = [cmd[1] for cmd in sent if len(cmd) >= 3]
        assert "loglevel" in keys
        assert "dbpurgeage" in keys

    async def test_skips_none_fields(self) -> None:
        """update_settings does not send commands for None fields."""
        sent: list[list[Any]] = []

        async def _send(command: list[Any]) -> Any:
            sent.append(command)
            return (0, "OK")

        class _FakeClient:
            def __init__(self, **_kw: Any) -> None:
                self.send = AsyncMock(side_effect=_send)

        update = ServerSettingsUpdate()  # all None
        with patch("app.services.server_service.Fail2BanClient", _FakeClient):
            await server_service.update_settings(_SOCKET, update)

        assert sent == []

    async def test_raises_server_operation_error_on_failure(self) -> None:
        """update_settings raises ServerOperationError when fail2ban rejects."""

        async def _send(command: list[Any]) -> Any:
            return (1, "invalid log level")

        class _FakeClient:
            def __init__(self, **_kw: Any) -> None:
                self.send = AsyncMock(side_effect=_send)

        update = ServerSettingsUpdate(log_level="INVALID")
        with patch("app.services.server_service.Fail2BanClient", _FakeClient), pytest.raises(ServerOperationError):
            await server_service.update_settings(_SOCKET, update)

    async def test_uppercases_log_level(self) -> None:
        """update_settings uppercases the log_level value before sending."""
        sent: list[list[Any]] = []

        async def _send(command: list[Any]) -> Any:
            sent.append(command)
            return (0, "OK")

        class _FakeClient:
            def __init__(self, **_kw: Any) -> None:
                self.send = AsyncMock(side_effect=_send)

        update = ServerSettingsUpdate(log_level="warning")
        with patch("app.services.server_service.Fail2BanClient", _FakeClient):
            await server_service.update_settings(_SOCKET, update)

        cmd = next(c for c in sent if len(c) >= 3 and c[1] == "loglevel")
        assert cmd[2] == "WARNING"


# ---------------------------------------------------------------------------
# flush_logs
# ---------------------------------------------------------------------------


class TestFlushLogs:
    """Unit tests for :func:`~app.services.server_service.flush_logs`."""

    async def test_returns_result_string(self) -> None:
        """flush_logs returns the string response from fail2ban."""

        async def _send(command: list[Any]) -> Any:
            assert command == ["flushlogs"]
            return (0, "OK")

        class _FakeClient:
            def __init__(self, **_kw: Any) -> None:
                self.send = AsyncMock(side_effect=_send)

        with patch("app.services.server_service.Fail2BanClient", _FakeClient):
            result = await server_service.flush_logs(_SOCKET)

        assert result == "OK"

    async def test_raises_operation_error_on_failure(self) -> None:
        """flush_logs raises ServerOperationError when fail2ban rejects."""

        async def _send(command: list[Any]) -> Any:
            return (1, "flushlogs failed")

        class _FakeClient:
            def __init__(self, **_kw: Any) -> None:
                self.send = AsyncMock(side_effect=_send)

        with patch("app.services.server_service.Fail2BanClient", _FakeClient), pytest.raises(ServerOperationError):
            await server_service.flush_logs(_SOCKET)
