"""Tests for the health-check background task.

Validates that :func:`~app.tasks.health_check._run_probe` correctly stores
the probe result on ``app.state.server_status``, logs online/offline
transitions, and that :func:`~app.tasks.health_check.register` configures
the scheduler and primes the initial status.
"""

from __future__ import annotations

import datetime
from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from app.models.config import PendingRecovery
from app.models.server import ServerStatus
from app.tasks.health_check import HEALTH_CHECK_INTERVAL, _run_probe, register
from app.utils.runtime_state import ApplicationState, RuntimeState

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


def _make_app(prev_online: bool = False) -> MagicMock:
    """Build a minimal mock ``app`` for health-check task tests.

    Args:
        prev_online: Whether the previous ``server_status`` was online.

    Returns:
        A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``.
    """
    runtime_state = RuntimeState(
        server_status=ServerStatus(online=prev_online),
        pending_recovery=None,
        last_activation=None,
    )
    app = MagicMock()
    app.state = ApplicationState(runtime_state)
    app.state.settings = MagicMock(fail2ban_socket="/var/run/fail2ban/fail2ban.sock")
    app.state.scheduler = MagicMock()
    return app


# ---------------------------------------------------------------------------
# Tests for _run_probe
# ---------------------------------------------------------------------------


class TestRunProbe:
    """Tests for :func:`~app.tasks.health_check._run_probe`."""

    @pytest.mark.asyncio
    async def test_run_probe_updates_server_status(self) -> None:
        """``_run_probe`` must store the probe result on ``app.state.server_status``."""
        app = _make_app(prev_online=False)
        new_status = ServerStatus(online=True, version="0.11.2", active_jails=3)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=new_status,
        ):
            await _run_probe(app)

        assert app.state.server_status is new_status

    @pytest.mark.asyncio
    async def test_run_probe_logs_came_online_transition(self) -> None:
        """When fail2ban comes online, ``"fail2ban_came_online"`` must be logged."""
        app = _make_app(prev_online=False)
        new_status = ServerStatus(online=True, version="0.11.2", active_jails=2)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=new_status,
        ), patch("app.utils.runtime_state.log") as mock_log:
            await _run_probe(app)

        online_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "fail2ban_came_online"]
        assert len(online_calls) == 1

    @pytest.mark.asyncio
    async def test_run_probe_logs_went_offline_transition(self) -> None:
        """When fail2ban goes offline, ``"fail2ban_went_offline"`` must be logged."""
        app = _make_app(prev_online=True)
        new_status = ServerStatus(online=False)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=new_status,
        ), patch("app.utils.runtime_state.log") as mock_log:
            await _run_probe(app)

        offline_calls = [c for c in mock_log.warning.call_args_list if c[0][0] == "fail2ban_went_offline"]
        assert len(offline_calls) == 1

    @pytest.mark.asyncio
    async def test_run_probe_stable_online_no_transition_log(self) -> None:
        """When status stays online, no transition events must be emitted."""
        app = _make_app(prev_online=True)
        new_status = ServerStatus(online=True, version="0.11.2", active_jails=1)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=new_status,
        ), patch("app.utils.runtime_state.log") as mock_log:
            await _run_probe(app)

        transition_calls = [
            c
            for c in mock_log.info.call_args_list
            if c[0][0] in ("fail2ban_came_online", "fail2ban_went_offline")
        ]
        transition_calls += [
            c
            for c in mock_log.warning.call_args_list
            if c[0][0] in ("fail2ban_came_online", "fail2ban_went_offline")
        ]
        assert transition_calls == []

    @pytest.mark.asyncio
    async def test_run_probe_stable_offline_no_transition_log(self) -> None:
        """When status stays offline, no transition events must be emitted."""
        app = _make_app(prev_online=False)
        new_status = ServerStatus(online=False)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=new_status,
        ), patch("app.utils.runtime_state.log") as mock_log:
            await _run_probe(app)

        transition_calls = [
            c
            for c in mock_log.info.call_args_list
            if c[0][0] == "fail2ban_came_online"
        ]
        transition_calls += [
            c
            for c in mock_log.warning.call_args_list
            if c[0][0] == "fail2ban_went_offline"
        ]
        assert transition_calls == []

    @pytest.mark.asyncio
    async def test_run_probe_uses_socket_path_from_settings(self) -> None:
        """``_run_probe`` must pass the socket path from ``app.state.settings``."""
        expected_socket = "/custom/fail2ban.sock"
        app = _make_app()
        app.state.settings.fail2ban_socket = expected_socket
        new_status = ServerStatus(online=False)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=new_status,
        ) as mock_probe:
            await _run_probe(app)

        mock_probe.assert_awaited_once_with(expected_socket)

    @pytest.mark.asyncio
    async def test_run_probe_uses_default_offline_status_when_state_missing(self) -> None:
        """``_run_probe`` must handle missing ``server_status`` on first run."""
        app = _make_app()
        # Simulate first run: no previous server_status attribute set yet.
        del app.state.server_status
        new_status = ServerStatus(online=True, version="0.11.2", active_jails=0)

        with (
            patch(
                "app.tasks.health_check.health_service.probe",
                new_callable=AsyncMock,
                return_value=new_status,
            ),
            patch("app.utils.runtime_state.log"),
        ):
            # Must not raise even with no prior status.
            await _run_probe(app)

        assert app.state.server_status is new_status


# ---------------------------------------------------------------------------
# Tests for register
# ---------------------------------------------------------------------------


class TestRegister:
    """Tests for :func:`~app.tasks.health_check.register`."""

    def test_register_adds_interval_job_to_scheduler(self) -> None:
        """``register`` must add a job with an ``"interval"`` trigger."""
        app = _make_app()

        register(app)

        app.state.scheduler.add_job.assert_called_once()
        _, kwargs = app.state.scheduler.add_job.call_args
        assert kwargs["trigger"] == "interval"
        assert kwargs["seconds"] == HEALTH_CHECK_INTERVAL

    def test_register_primes_offline_server_status(self) -> None:
        """``register`` must set an initial offline status before the first probe fires."""
        app = _make_app()
        # Reset any value set by _make_app.
        del app.state.server_status

        register(app)

        assert isinstance(app.state.server_status, ServerStatus)
        assert app.state.server_status.online is False

    def test_register_uses_stable_job_id(self) -> None:
        """``register`` must register the job under the stable id ``"health_check"``."""
        app = _make_app()

        register(app)

        _, kwargs = app.state.scheduler.add_job.call_args
        assert kwargs["id"] == "health_check"

    def test_register_sets_replace_existing(self) -> None:
        """``register`` must use ``replace_existing=True`` to avoid duplicate jobs."""
        app = _make_app()

        register(app)

        _, kwargs = app.state.scheduler.add_job.call_args
        assert kwargs["replace_existing"] is True

    def test_register_passes_resources_in_kwargs(self) -> None:
        """The scheduled job must receive explicit resources instead of app."""
        app = _make_app()

        register(app)

        _, kwargs = app.state.scheduler.add_job.call_args
        assert kwargs["kwargs"] == {
            "settings": app.state.settings,
            "runtime_state": app.state.runtime_state,
        }

    def test_register_initialises_last_activation_none(self) -> None:
        """``register`` must set ``app.state.last_activation = None``."""
        app = _make_app()

        register(app)

        assert app.state.last_activation is None

    def test_register_initialises_pending_recovery_none(self) -> None:
        """``register`` must set ``app.state.pending_recovery = None``."""
        app = _make_app()

        register(app)

        assert app.state.pending_recovery is None


# ---------------------------------------------------------------------------
# Crash detection (Task 3)
# ---------------------------------------------------------------------------


class TestCrashDetection:
    """Tests for activation-crash detection in _run_probe."""

    @pytest.mark.asyncio
    async def test_crash_within_window_creates_pending_recovery(self) -> None:
        """An online→offline transition within 60 s of activation must set pending_recovery."""
        app = _make_app(prev_online=True)
        now = datetime.datetime.now(tz=datetime.UTC)
        app.state.last_activation = {
            "jail_name": "sshd",
            "at": now - datetime.timedelta(seconds=10),
        }
        app.state.pending_recovery = None

        offline_status = ServerStatus(online=False)

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=offline_status,
        ):
            await _run_probe(app)

        assert app.state.pending_recovery is not None
        assert isinstance(app.state.pending_recovery, PendingRecovery)
        assert app.state.pending_recovery.jail_name == "sshd"
        assert app.state.pending_recovery.recovered is False

    @pytest.mark.asyncio
    async def test_crash_outside_window_does_not_create_pending_recovery(self) -> None:
        """A crash more than 60 s after activation must NOT set pending_recovery."""
        app = _make_app(prev_online=True)
        app.state.last_activation = {
            "jail_name": "sshd",
            "at": datetime.datetime.now(tz=datetime.UTC)
            - datetime.timedelta(seconds=120),
        }
        app.state.pending_recovery = None

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=ServerStatus(online=False),
        ):
            await _run_probe(app)

        assert app.state.pending_recovery is None

    @pytest.mark.asyncio
    async def test_came_online_marks_pending_recovery_resolved(self) -> None:
        """An offline→online transition must mark an existing pending_recovery as recovered."""
        app = _make_app(prev_online=False)
        activated_at = datetime.datetime.now(tz=datetime.UTC) - datetime.timedelta(seconds=30)
        detected_at = datetime.datetime.now(tz=datetime.UTC)
        app.state.pending_recovery = PendingRecovery(
            jail_name="sshd",
            activated_at=activated_at,
            detected_at=detected_at,
            recovered=False,
        )

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=ServerStatus(online=True),
        ):
            await _run_probe(app)

        assert app.state.pending_recovery.recovered is True

    @pytest.mark.asyncio
    async def test_crash_without_recent_activation_does_nothing(self) -> None:
        """A crash when last_activation is None must not create a pending_recovery."""
        app = _make_app(prev_online=True)
        app.state.last_activation = None
        app.state.pending_recovery = None

        with patch(
            "app.tasks.health_check.health_service.probe",
            new_callable=AsyncMock,
            return_value=ServerStatus(online=False),
        ):
            await _run_probe(app)

        assert app.state.pending_recovery is None
