"""Tests for the blocklist import background task.

Validates that :func:`~app.tasks.blocklist_import._run_import` correctly
delegates to :func:`~app.services.blocklist_service.import_all`, handles
unexpected exceptions gracefully, and that :func:`~app.tasks.blocklist_import._apply_schedule`
registers the correct APScheduler trigger for each frequency preset.
"""

from __future__ import annotations

from typing import Any
from unittest.mock import ANY, AsyncMock, MagicMock, call, patch

import pytest

from app.models.blocklist import ImportRunResult, ScheduleConfig, ScheduleFrequency
from app.tasks.blocklist_import import JOB_ID, _apply_schedule, _run_import

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


def _make_app(
    import_result: ImportRunResult | None = None,
    import_side_effect: Exception | None = None,
) -> MagicMock:
    """Build a minimal mock ``app`` for blocklist import task tests.

    Args:
        import_result: Value returned by the mocked ``import_all`` call.
        import_side_effect: If provided, ``import_all`` raises this exception.

    Returns:
        A :class:`unittest.mock.MagicMock` that mimics ``fastapi.FastAPI``.
    """
    app = MagicMock()
    app.state.db = MagicMock()
    app.state.db.close = AsyncMock()
    app.state.http_session = MagicMock()
    app.state.settings = MagicMock(
        fail2ban_socket="/var/run/fail2ban/fail2ban.sock",
        database_path="/tmp/fake.db",
    )
    app.state.runtime_settings = None
    return app


def _make_import_result(
    total_imported: int = 50,
    total_skipped: int = 5,
    errors_count: int = 0,
) -> ImportRunResult:
    """Construct a minimal :class:`ImportRunResult` for testing.

    Args:
        total_imported: Number of IPs successfully imported.
        total_skipped: Number of skipped entries.
        errors_count: Number of sources that encountered errors.

    Returns:
        An :class:`ImportRunResult` with the given counters.
    """
    return ImportRunResult(
        results=[],
        total_imported=total_imported,
        total_skipped=total_skipped,
        errors_count=errors_count,
    )


def _make_scheduler(has_existing_job: bool = False) -> MagicMock:
    """Build a mock APScheduler-like object.

    Args:
        has_existing_job: Whether ``get_job(JOB_ID)`` should return a truthy value.

    Returns:
        A :class:`unittest.mock.MagicMock` that mimics a scheduler.
    """
    scheduler = MagicMock()
    scheduler.get_job.return_value = MagicMock() if has_existing_job else None
    return scheduler


# ---------------------------------------------------------------------------
# Tests for _run_import
# ---------------------------------------------------------------------------


class TestRunImport:
    """Tests for :func:`~app.tasks.blocklist_import._run_import`."""

    @pytest.mark.asyncio
    async def test_run_import_happy_path_calls_import_all(self) -> None:
        """``_run_import`` must delegate to ``blocklist_service.import_all``."""
        app = _make_app()
        result = _make_import_result(total_imported=100, total_skipped=2, errors_count=0)

        with patch(
            "app.tasks.db.open_db",
            new_callable=AsyncMock,
            return_value=app.state.db,
        ), patch(
            "app.tasks.blocklist_import.blocklist_service.import_all",
            new_callable=AsyncMock,
            return_value=result,
        ) as mock_import_all:
            await _run_import(app)

        mock_import_all.assert_awaited_once_with(
            app.state.db,
            app.state.http_session,
            app.state.settings.fail2ban_socket,
            ban_ip=ANY,
        )

    @pytest.mark.asyncio
    async def test_run_import_logs_counters_on_success(self) -> None:
        """``_run_import`` must emit a structured log event with import counters."""
        app = _make_app()
        result = _make_import_result(total_imported=42, total_skipped=3, errors_count=1)

        with patch(
            "app.tasks.db.open_db",
            new_callable=AsyncMock,
            return_value=app.state.db,
        ), patch(
            "app.tasks.blocklist_import.blocklist_service.import_all",
            new_callable=AsyncMock,
            return_value=result,
        ), patch("app.tasks.blocklist_import.log") as mock_log:
            await _run_import(app)

        info_calls = [c for c in mock_log.info.call_args_list if c[0][0] == "blocklist_import_finished"]
        assert len(info_calls) == 1
        kwargs = info_calls[0][1]
        assert kwargs["total_imported"] == 42
        assert kwargs["total_skipped"] == 3
        assert kwargs["errors"] == 1

    @pytest.mark.asyncio
    async def test_run_import_logs_start_event(self) -> None:
        """``_run_import`` must emit a ``blocklist_import_starting`` event."""
        app = _make_app()
        result = _make_import_result()

        with patch(
            "app.tasks.db.open_db",
            new_callable=AsyncMock,
            return_value=app.state.db,
        ), patch(
            "app.tasks.blocklist_import.blocklist_service.import_all",
            new_callable=AsyncMock,
            return_value=result,
        ), patch("app.tasks.blocklist_import.log") as mock_log:
            await _run_import(app)

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

    @pytest.mark.asyncio
    async def test_run_import_handles_unexpected_exception(self) -> None:
        """``_run_import`` must catch unexpected exceptions and log them."""
        app = _make_app()

        with patch(
            "app.tasks.db.open_db",
            new_callable=AsyncMock,
            return_value=app.state.db,
        ), patch(
            "app.tasks.blocklist_import.blocklist_service.import_all",
            new_callable=AsyncMock,
            side_effect=RuntimeError("unexpected failure"),
        ), patch("app.tasks.blocklist_import.log") as mock_log:
            # Must not raise — the task swallows unexpected errors.
            await _run_import(app)

        mock_log.exception.assert_called_once_with("blocklist_import_unexpected_error")


# ---------------------------------------------------------------------------
# Tests for _apply_schedule
# ---------------------------------------------------------------------------


class TestApplySchedule:
    """Tests for :func:`~app.tasks.blocklist_import._apply_schedule`."""

    def _make_app_with_scheduler(self, scheduler: Any) -> MagicMock:
        """Return a mock ``app`` whose ``state.scheduler`` is *scheduler*.

        Args:
            scheduler: Mock scheduler object.

        Returns:
            Mock FastAPI application instance.
        """
        app = MagicMock()
        app.state.scheduler = scheduler
        app.state.runtime_settings = None
        return app

    def test_apply_schedule_daily_registers_cron_trigger(self) -> None:
        """Daily frequency must register a ``"cron"`` trigger with hour and minute."""
        scheduler = _make_scheduler()
        app = self._make_app_with_scheduler(scheduler)
        config = ScheduleConfig(frequency=ScheduleFrequency.daily, hour=3, minute=0)

        _apply_schedule(app, config)

        scheduler.add_job.assert_called_once()
        _, kwargs = scheduler.add_job.call_args
        assert kwargs["trigger"] == "cron"
        assert kwargs["hour"] == 3
        assert kwargs["minute"] == 0
        assert "day_of_week" not in kwargs

    def test_apply_schedule_hourly_registers_interval_trigger(self) -> None:
        """Hourly frequency must register an ``"interval"`` trigger with correct hours."""
        scheduler = _make_scheduler()
        app = self._make_app_with_scheduler(scheduler)
        config = ScheduleConfig(frequency=ScheduleFrequency.hourly, interval_hours=6)

        _apply_schedule(app, config)

        scheduler.add_job.assert_called_once()
        _, kwargs = scheduler.add_job.call_args
        assert kwargs["trigger"] == "interval"
        assert kwargs["hours"] == 6

    def test_apply_schedule_weekly_registers_cron_trigger_with_day(self) -> None:
        """Weekly frequency must register a ``"cron"`` trigger including ``day_of_week``."""
        scheduler = _make_scheduler()
        app = self._make_app_with_scheduler(scheduler)
        config = ScheduleConfig(
            frequency=ScheduleFrequency.weekly,
            day_of_week=0,
            hour=4,
            minute=30,
        )

        _apply_schedule(app, config)

        scheduler.add_job.assert_called_once()
        _, kwargs = scheduler.add_job.call_args
        assert kwargs["trigger"] == "cron"
        assert kwargs["day_of_week"] == 0
        assert kwargs["hour"] == 4
        assert kwargs["minute"] == 30

    def test_apply_schedule_removes_existing_job_before_adding(self) -> None:
        """If a job with ``JOB_ID`` exists, it must be removed before a new one is added."""
        scheduler = _make_scheduler(has_existing_job=True)
        app = self._make_app_with_scheduler(scheduler)
        config = ScheduleConfig(frequency=ScheduleFrequency.daily)

        _apply_schedule(app, config)

        scheduler.remove_job.assert_called_once_with(JOB_ID)
        assert scheduler.remove_job.call_args_list.index(call(JOB_ID)) < len(
            scheduler.add_job.call_args_list
        )

    def test_apply_schedule_skips_remove_when_no_existing_job(self) -> None:
        """If no job exists, ``remove_job`` must not be called."""
        scheduler = _make_scheduler(has_existing_job=False)
        app = self._make_app_with_scheduler(scheduler)
        config = ScheduleConfig(frequency=ScheduleFrequency.daily)

        _apply_schedule(app, config)

        scheduler.remove_job.assert_not_called()

    def test_apply_schedule_uses_stable_job_id(self) -> None:
        """The registered job must use the module-level ``JOB_ID`` constant."""
        scheduler = _make_scheduler()
        app = self._make_app_with_scheduler(scheduler)
        config = ScheduleConfig(frequency=ScheduleFrequency.daily)

        _apply_schedule(app, config)

        _, kwargs = scheduler.add_job.call_args
        assert kwargs["id"] == JOB_ID

    def test_apply_schedule_passes_resources_in_kwargs(self) -> None:
        """The scheduled job must receive explicit resources instead of app."""
        scheduler = _make_scheduler()
        app = self._make_app_with_scheduler(scheduler)
        app.state.settings = MagicMock(database_path="/tmp/fake.db")
        app.state.http_session = MagicMock()
        config = ScheduleConfig(frequency=ScheduleFrequency.daily)

        _apply_schedule(app, config)

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


# ---------------------------------------------------------------------------
# Tests for register / reschedule
# ---------------------------------------------------------------------------


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

    @pytest.mark.asyncio
    async def test_register_calls_apply_schedule(self) -> None:
        """``register`` must call ``_apply_schedule`` after reading the stored config."""
        from app.tasks.blocklist_import import register

        app = MagicMock()
        app.state.db = MagicMock()
        app.state.db.close = AsyncMock()
        app.state.settings = MagicMock(database_path="/tmp/fake.db")
        app.state.scheduler = MagicMock()
        app.state.scheduler.get_job.return_value = None

        config = ScheduleConfig(frequency=ScheduleFrequency.daily, hour=3, minute=0)

        with patch(
            "app.tasks.db.open_db",
            new_callable=AsyncMock,
            return_value=app.state.db,
        ), patch(
            "app.tasks.blocklist_import.blocklist_service.get_schedule",
            new_callable=AsyncMock,
            return_value=config,
        ), patch("app.tasks.blocklist_import._apply_schedule") as mock_apply:
            await register(app)

        mock_apply.assert_called_once_with(app, config)


class TestReschedule:
    """Tests for :func:`~app.tasks.blocklist_import.reschedule`."""

    def test_reschedule_calls_ensure_future(self) -> None:
        """``reschedule`` must schedule the re-registration with ``asyncio.ensure_future``."""
        from app.tasks.blocklist_import import reschedule

        app = MagicMock()
        app.state.db = MagicMock()
        app.state.db.close = AsyncMock()
        app.state.settings = MagicMock(database_path="/tmp/fake.db")
        app.state.scheduler = MagicMock()

        def _close_coro(coro: Any) -> None:
            coro.close()

        with patch(
            "app.tasks.db.open_db",
            new_callable=AsyncMock,
            return_value=app.state.db,
        ), patch("asyncio.ensure_future", side_effect=_close_coro) as mock_ensure_future:
            reschedule(app)

        mock_ensure_future.assert_called_once()
