"""Tests for the blocklist router (9 endpoints)."""

from __future__ import annotations

from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch

import aiosqlite
import pytest
from httpx import ASGITransport, AsyncClient

from app.config import Settings
from app.db import init_db
from app.main import create_app
from app.models.blocklist import (
    BlocklistListResponse,
    BlocklistSource,
    ImportLogListResponse,
    ImportRunResult,
    ImportSourceResult,
    PreviewResponse,
    ScheduleConfig,
    ScheduleFrequency,
    ScheduleInfo,
)

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

_SETUP_PAYLOAD = {
    "master_password": "testpassword1",
    "database_path": "bangui.db",
    "fail2ban_socket": "/var/run/fail2ban/fail2ban.sock",
    "timezone": "UTC",
    "session_duration_minutes": 60,
}


def _make_source(source_id: int = 1) -> BlocklistSource:
    return BlocklistSource(
        id=source_id,
        name="Test Source",
        url="https://test.test/ips.txt",
        enabled=True,
        created_at="2026-01-01T00:00:00Z",
        updated_at="2026-01-01T00:00:00Z",
    )


def _make_source_list() -> BlocklistListResponse:
    return BlocklistListResponse(sources=[_make_source(1), _make_source(2)])


def _make_schedule_info() -> ScheduleInfo:
    return ScheduleInfo(
        config=ScheduleConfig(
            frequency=ScheduleFrequency.daily,
            interval_hours=24,
            hour=3,
            minute=0,
            day_of_week=0,
        ),
        next_run_at="2026-02-01T03:00:00+00:00",
        last_run_at=None,
    )


def _make_import_result() -> ImportRunResult:
    return ImportRunResult(
        results=[
            ImportSourceResult(
                source_id=1,
                source_url="https://test.test/ips.txt",
                ips_imported=5,
                ips_skipped=1,
                error=None,
            )
        ],
        total_imported=5,
        total_skipped=1,
        errors_count=0,
    )


def _make_log_response() -> ImportLogListResponse:
    return ImportLogListResponse(
        items=[], total=0, page=1, page_size=50, total_pages=1
    )


def _make_preview() -> PreviewResponse:
    return PreviewResponse(
        entries=["1.2.3.4", "5.6.7.8"],
        total_lines=10,
        valid_count=8,
        skipped_count=2,
    )


# ---------------------------------------------------------------------------
# Fixture
# ---------------------------------------------------------------------------


@pytest.fixture
async def bl_client(tmp_path: Path) -> AsyncClient:  # type: ignore[misc]
    """Provide an authenticated AsyncClient for blocklist endpoint tests."""
    settings = Settings(
        database_path=str(tmp_path / "bl_router_test.db"),
        fail2ban_socket="/tmp/fake_fail2ban.sock",
        session_secret="test-bl-secret",
        session_duration_minutes=60,
        timezone="UTC",
        log_level="debug",
    )
    app = create_app(settings=settings)

    db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
    db.row_factory = aiosqlite.Row
    await init_db(db)
    app.state.db = db
    app.state.http_session = MagicMock()

    # Provide a minimal scheduler stub so the router can call .get_job().
    scheduler_stub = MagicMock()
    scheduler_stub.get_job = MagicMock(return_value=None)
    app.state.scheduler = scheduler_stub

    transport = ASGITransport(app=app)
    async with AsyncClient(transport=transport, base_url="http://test") as ac:
        resp = await ac.post("/api/setup", json=_SETUP_PAYLOAD)
        assert resp.status_code == 201

        login_resp = await ac.post(
            "/api/auth/login",
            json={"password": _SETUP_PAYLOAD["master_password"]},
        )
        assert login_resp.status_code == 200

        yield ac

    await db.close()


# ---------------------------------------------------------------------------
# GET /api/blocklists
# ---------------------------------------------------------------------------


class TestListBlocklists:
    async def test_authenticated_returns_200(self, bl_client: AsyncClient) -> None:
        """Authenticated request to list sources returns HTTP 200."""
        with patch(
            "app.routers.blocklist.blocklist_service.list_sources",
            new=AsyncMock(return_value=_make_source_list().sources),
        ):
            resp = await bl_client.get("/api/blocklists")
        assert resp.status_code == 200

    async def test_returns_401_unauthenticated(self, client: AsyncClient) -> None:
        """Unauthenticated request returns 401."""
        await client.post("/api/setup", json=_SETUP_PAYLOAD)
        resp = await client.get("/api/blocklists")
        assert resp.status_code == 401

    async def test_response_contains_sources_key(self, bl_client: AsyncClient) -> None:
        """Response body has a 'sources' array."""
        with patch(
            "app.routers.blocklist.blocklist_service.list_sources",
            new=AsyncMock(return_value=[_make_source()]),
        ):
            resp = await bl_client.get("/api/blocklists")
        body = resp.json()
        assert "sources" in body
        assert isinstance(body["sources"], list)


# ---------------------------------------------------------------------------
# POST /api/blocklists
# ---------------------------------------------------------------------------


class TestCreateBlocklist:
    async def test_create_returns_201(self, bl_client: AsyncClient) -> None:
        """POST /api/blocklists creates a source and returns HTTP 201."""
        with patch(
            "app.routers.blocklist.blocklist_service.create_source",
            new=AsyncMock(return_value=_make_source()),
        ):
            resp = await bl_client.post(
                "/api/blocklists",
                json={"name": "Test", "url": "https://test.test/", "enabled": True},
            )
        assert resp.status_code == 201

    async def test_create_source_id_in_response(self, bl_client: AsyncClient) -> None:
        """Created source response includes the id field."""
        with patch(
            "app.routers.blocklist.blocklist_service.create_source",
            new=AsyncMock(return_value=_make_source(42)),
        ):
            resp = await bl_client.post(
                "/api/blocklists",
                json={"name": "Test", "url": "https://test.test/", "enabled": True},
            )
        assert resp.json()["id"] == 42


# ---------------------------------------------------------------------------
# PUT /api/blocklists/{id}
# ---------------------------------------------------------------------------


class TestUpdateBlocklist:
    async def test_update_returns_200(self, bl_client: AsyncClient) -> None:
        """PUT /api/blocklists/1 returns 200 for a found source."""
        updated = _make_source()
        updated.enabled = False
        with patch(
            "app.routers.blocklist.blocklist_service.update_source",
            new=AsyncMock(return_value=updated),
        ):
            resp = await bl_client.put(
                "/api/blocklists/1",
                json={"enabled": False},
            )
        assert resp.status_code == 200

    async def test_update_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
        """PUT /api/blocklists/999 returns 404 when source does not exist."""
        with patch(
            "app.routers.blocklist.blocklist_service.update_source",
            new=AsyncMock(return_value=None),
        ):
            resp = await bl_client.put(
                "/api/blocklists/999",
                json={"enabled": False},
            )
        assert resp.status_code == 404


# ---------------------------------------------------------------------------
# DELETE /api/blocklists/{id}
# ---------------------------------------------------------------------------


class TestDeleteBlocklist:
    async def test_delete_returns_204(self, bl_client: AsyncClient) -> None:
        """DELETE /api/blocklists/1 returns 204 for a found source."""
        with patch(
            "app.routers.blocklist.blocklist_service.delete_source",
            new=AsyncMock(return_value=True),
        ):
            resp = await bl_client.delete("/api/blocklists/1")
        assert resp.status_code == 204

    async def test_delete_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
        """DELETE /api/blocklists/999 returns 404 when source does not exist."""
        with patch(
            "app.routers.blocklist.blocklist_service.delete_source",
            new=AsyncMock(return_value=False),
        ):
            resp = await bl_client.delete("/api/blocklists/999")
        assert resp.status_code == 404


# ---------------------------------------------------------------------------
# GET /api/blocklists/{id}/preview
# ---------------------------------------------------------------------------


class TestPreviewBlocklist:
    async def test_preview_returns_200(self, bl_client: AsyncClient) -> None:
        """GET /api/blocklists/1/preview returns 200 for existing source."""
        with patch(
            "app.routers.blocklist.blocklist_service.get_source",
            new=AsyncMock(return_value=_make_source()),
        ), patch(
            "app.routers.blocklist.blocklist_service.preview_source",
            new=AsyncMock(return_value=_make_preview()),
        ):
            resp = await bl_client.get("/api/blocklists/1/preview")
        assert resp.status_code == 200

    async def test_preview_returns_404_for_missing(self, bl_client: AsyncClient) -> None:
        """GET /api/blocklists/999/preview returns 404 when source not found."""
        with patch(
            "app.routers.blocklist.blocklist_service.get_source",
            new=AsyncMock(return_value=None),
        ):
            resp = await bl_client.get("/api/blocklists/999/preview")
        assert resp.status_code == 404

    async def test_preview_returns_502_on_download_error(
        self, bl_client: AsyncClient
    ) -> None:
        """GET /api/blocklists/1/preview returns 502 when URL is unreachable."""
        with patch(
            "app.routers.blocklist.blocklist_service.get_source",
            new=AsyncMock(return_value=_make_source()),
        ), patch(
            "app.routers.blocklist.blocklist_service.preview_source",
            new=AsyncMock(side_effect=ValueError("Connection refused")),
        ):
            resp = await bl_client.get("/api/blocklists/1/preview")
        assert resp.status_code == 502

    async def test_preview_response_shape(self, bl_client: AsyncClient) -> None:
        """Preview response has entries, valid_count, skipped_count, total_lines."""
        with patch(
            "app.routers.blocklist.blocklist_service.get_source",
            new=AsyncMock(return_value=_make_source()),
        ), patch(
            "app.routers.blocklist.blocklist_service.preview_source",
            new=AsyncMock(return_value=_make_preview()),
        ):
            resp = await bl_client.get("/api/blocklists/1/preview")
        body = resp.json()
        assert "entries" in body
        assert "valid_count" in body
        assert "skipped_count" in body
        assert "total_lines" in body


# ---------------------------------------------------------------------------
# POST /api/blocklists/import
# ---------------------------------------------------------------------------


class TestRunImport:
    async def test_import_returns_200(self, bl_client: AsyncClient) -> None:
        """POST /api/blocklists/import returns 200 with aggregated results."""
        with patch(
            "app.routers.blocklist.blocklist_service.import_all",
            new=AsyncMock(return_value=_make_import_result()),
        ):
            resp = await bl_client.post("/api/blocklists/import")
        assert resp.status_code == 200

    async def test_import_response_shape(self, bl_client: AsyncClient) -> None:
        """Import response has results, total_imported, total_skipped, errors_count."""
        with patch(
            "app.routers.blocklist.blocklist_service.import_all",
            new=AsyncMock(return_value=_make_import_result()),
        ):
            resp = await bl_client.post("/api/blocklists/import")
        body = resp.json()
        assert "total_imported" in body
        assert "total_skipped" in body
        assert "errors_count" in body
        assert "results" in body


# ---------------------------------------------------------------------------
# GET /api/blocklists/schedule
# ---------------------------------------------------------------------------


class TestGetSchedule:
    async def test_schedule_returns_200(self, bl_client: AsyncClient) -> None:
        """GET /api/blocklists/schedule returns 200."""
        with patch(
            "app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
            new=AsyncMock(return_value=_make_schedule_info()),
        ):
            resp = await bl_client.get("/api/blocklists/schedule")
        assert resp.status_code == 200

    async def test_schedule_response_has_config(self, bl_client: AsyncClient) -> None:
        """Schedule response includes the config sub-object."""
        with patch(
            "app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
            new=AsyncMock(return_value=_make_schedule_info()),
        ):
            resp = await bl_client.get("/api/blocklists/schedule")
        body = resp.json()
        assert "config" in body
        assert "next_run_at" in body
        assert "last_run_at" in body

    async def test_schedule_response_includes_last_run_errors(
        self, bl_client: AsyncClient
    ) -> None:
        """GET /api/blocklists/schedule includes last_run_errors field."""
        info_with_errors = ScheduleInfo(
            config=ScheduleConfig(
                frequency=ScheduleFrequency.daily,
                interval_hours=24,
                hour=3,
                minute=0,
                day_of_week=0,
            ),
            next_run_at=None,
            last_run_at="2026-03-01T03:00:00+00:00",
            last_run_errors=True,
        )
        with patch(
            "app.routers.blocklist.blocklist_service.get_schedule_info_with_runtime",
            new=AsyncMock(return_value=info_with_errors),
        ):
            resp = await bl_client.get("/api/blocklists/schedule")
        body = resp.json()
        assert "last_run_errors" in body
        assert body["last_run_errors"] is True


# ---------------------------------------------------------------------------
# PUT /api/blocklists/schedule
# ---------------------------------------------------------------------------


class TestUpdateSchedule:
    async def test_update_schedule_returns_200(self, bl_client: AsyncClient) -> None:
        """PUT /api/blocklists/schedule persists new config and returns 200."""
        new_info = ScheduleInfo(
            config=ScheduleConfig(
                frequency=ScheduleFrequency.hourly,
                interval_hours=12,
                hour=0,
                minute=0,
                day_of_week=0,
            ),
            next_run_at=None,
            last_run_at=None,
        )
        with patch(
            "app.routers.blocklist.blocklist_service.update_schedule",
            new=AsyncMock(return_value=new_info),
        ):
            resp = await bl_client.put(
                "/api/blocklists/schedule",
                json={
                    "frequency": "hourly",
                    "interval_hours": 12,
                    "hour": 0,
                    "minute": 0,
                    "day_of_week": 0,
                },
            )
        assert resp.status_code == 200


# ---------------------------------------------------------------------------
# GET /api/blocklists/log
# ---------------------------------------------------------------------------


class TestImportLog:
    async def test_log_returns_200(self, bl_client: AsyncClient) -> None:
        """GET /api/blocklists/log returns 200."""
        resp = await bl_client.get("/api/blocklists/log")
        assert resp.status_code == 200

    async def test_log_response_shape(self, bl_client: AsyncClient) -> None:
        """Log response has items, total, page, page_size, total_pages."""
        resp = await bl_client.get("/api/blocklists/log")
        body = resp.json()
        for key in ("items", "total", "page", "page_size", "total_pages"):
            assert key in body

    async def test_log_empty_when_no_runs(self, bl_client: AsyncClient) -> None:
        """Log returns empty items list when no import runs have occurred."""
        resp = await bl_client.get("/api/blocklists/log")
        body = resp.json()
        assert body["total"] == 0
        assert body["items"] == []
