"""Tests for history_service.list_history() and history_service.get_ip_detail()."""

from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch

import aiosqlite
import pytest

from app.db import init_db
from app.services import history_service

# ---------------------------------------------------------------------------
# Time helpers
# ---------------------------------------------------------------------------

_NOW: int = int(time.time())
_ONE_HOUR_AGO: int = _NOW - 3600
_TWO_DAYS_AGO: int = _NOW - 2 * 24 * 3600
_THIRTY_DAYS_AGO: int = _NOW - 30 * 24 * 3600


# ---------------------------------------------------------------------------
# DB fixture helpers
# ---------------------------------------------------------------------------


async def _create_f2b_db(path: str, rows: list[dict[str, Any]]) -> None:
    """Create a minimal fail2ban SQLite database with the given ban rows."""
    async with aiosqlite.connect(path) as db:
        await db.execute(
            "CREATE TABLE jails ("
            "name TEXT NOT NULL UNIQUE, "
            "enabled INTEGER NOT NULL DEFAULT 1"
            ")"
        )
        await db.execute(
            "CREATE TABLE bans ("
            "jail TEXT NOT NULL, "
            "ip TEXT, "
            "timeofban INTEGER NOT NULL, "
            "bantime INTEGER NOT NULL, "
            "bancount INTEGER NOT NULL DEFAULT 1, "
            "data JSON"
            ")"
        )
        for row in rows:
            await db.execute(
                "INSERT INTO bans (jail, ip, timeofban, bantime, bancount, data) "
                "VALUES (?, ?, ?, ?, ?, ?)",
                (
                    row["jail"],
                    row["ip"],
                    row["timeofban"],
                    row.get("bantime", 3600),
                    row.get("bancount", 1),
                    json.dumps(row["data"]) if "data" in row else None,
                ),
            )
        await db.commit()


@pytest.fixture
async def f2b_db_path(tmp_path: Path) -> str:
    """Return the path to a test fail2ban SQLite database."""
    path = str(tmp_path / "fail2ban_test.sqlite3")
    await _create_f2b_db(
        path,
        [
            {
                "jail": "sshd",
                "ip": "1.2.3.4",
                "timeofban": _ONE_HOUR_AGO,
                "bantime": 3600,
                "bancount": 3,
                "data": {
                    "matches": ["Mar 1 sshd[1]: Failed password for root"],
                    "failures": 5,
                },
            },
            {
                "jail": "nginx",
                "ip": "5.6.7.8",
                "timeofban": _ONE_HOUR_AGO,
                "bantime": 7200,
                "bancount": 1,
                "data": {"matches": ["GET /admin HTTP/1.1"], "failures": 3},
            },
            {
                "jail": "sshd",
                "ip": "1.2.3.4",
                "timeofban": _TWO_DAYS_AGO,
                "bantime": 3600,
                "bancount": 2,
                "data": {"failures": 5},
            },
            {
                "jail": "sshd",
                "ip": "9.0.0.1",
                "timeofban": _THIRTY_DAYS_AGO,
                "bantime": 3600,
                "bancount": 1,
                "data": None,
            },
        ],
    )
    return path


# ---------------------------------------------------------------------------
# list_history tests
# ---------------------------------------------------------------------------


class TestListHistory:
    """history_service.list_history()."""

    async def test_returns_all_bans_with_no_filter(
        self, f2b_db_path: str
    ) -> None:
        """No filter returns every record in the database."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history("fake_socket")
        assert result.total == 4
        assert len(result.items) == 4

    async def test_time_range_filter_excludes_old_bans(
        self, f2b_db_path: str
    ) -> None:
        """The ``range_`` filter excludes bans older than the window."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            # "24h" window should include only the two recent bans
            result = await history_service.list_history(
                "fake_socket", range_="24h"
            )
        assert result.total == 2

    async def test_jail_filter(self, f2b_db_path: str) -> None:
        """Jail filter restricts results to bans from that jail."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history("fake_socket", jail="nginx")
        assert result.total == 1
        assert result.items[0].jail == "nginx"

    async def test_ip_prefix_filter(self, f2b_db_path: str) -> None:
        """IP prefix filter restricts results to matching IPs."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", ip_filter="1.2.3"
            )
        assert result.total == 2
        for item in result.items:
            assert item.ip.startswith("1.2.3")

    async def test_combined_filters(self, f2b_db_path: str) -> None:
        """Jail + IP prefix filters applied together narrow the result set."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", jail="sshd", ip_filter="1.2.3.4"
            )
        # 2 sshd bans for 1.2.3.4
        assert result.total == 2

    async def test_origin_filter_selfblock(self, f2b_db_path: str) -> None:
        """Origin filter should include only selfblock entries."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", origin="selfblock"
            )

        assert result.total == 4
        assert all(item.jail != "blocklist-import" for item in result.items)

    async def test_unknown_ip_returns_empty(self, f2b_db_path: str) -> None:
        """Filtering by a non-existent IP returns an empty result set."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", ip_filter="99.99.99.99"
            )
        assert result.total == 0
        assert result.items == []

    async def test_failures_extracted_from_data(
        self, f2b_db_path: str
    ) -> None:
        """``failures`` field is parsed from the JSON ``data`` column."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", ip_filter="5.6.7.8"
            )
        assert result.total == 1
        assert result.items[0].failures == 3

    async def test_matches_extracted_from_data(
        self, f2b_db_path: str
    ) -> None:
        """``matches`` list is parsed from the JSON ``data`` column."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", ip_filter="1.2.3.4", range_="24h"
            )
        # Most recent record for 1.2.3.4 has a matches list
        recent = result.items[0]
        assert len(recent.matches) == 1
        assert "Failed password" in recent.matches[0]

    async def test_http_session_geo_lookup_is_used(
        self, f2b_db_path: str
    ) -> None:
        """A provided HTTP session is used for geo enrichment by the service."""
        fake_session = AsyncMock()

        mock_geo = AsyncMock()
        mock_geo.country_code = "US"
        mock_geo.country_name = "United States"
        mock_geo.asn = "AS15169"
        mock_geo.org = "Google"

        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ), patch(
            "app.services.history_service.geo_service.lookup",
            new=AsyncMock(return_value=mock_geo),
        ) as mock_lookup:
            result = await history_service.list_history(
                "fake_socket",
                ip_filter="1.2.3.4",
                http_session=fake_session,
            )

        assert mock_lookup.call_args.args == ("1.2.3.4", fake_session)
        assert result.items[0].country_code == "US"
        assert result.items[0].country_name == "United States"
        assert result.items[0].asn == "AS15169"
        assert result.items[0].org == "Google"

    async def test_null_data_column_handled_gracefully(
        self, f2b_db_path: str
    ) -> None:
        """Records with ``data=NULL`` produce failures=0 and matches=[]."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", ip_filter="9.0.0.1"
            )
        assert result.total == 1
        item = result.items[0]
        assert item.failures == 0
        assert item.matches == []

    async def test_pagination(self, f2b_db_path: str) -> None:
        """Pagination returns the correct slice."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.list_history(
                "fake_socket", page=1, page_size=2
            )
        assert result.total == 4
        assert len(result.items) == 2
        assert result.page == 1
        assert result.page_size == 2

    async def test_source_archive_reads_from_archive(self, f2b_db_path: str, tmp_path: Path) -> None:
        """Using source='archive' reads from the BanGUI archive table."""
        app_db_path = str(tmp_path / "app_archive.db")
        async with aiosqlite.connect(app_db_path) as db:
            db.row_factory = aiosqlite.Row
            await init_db(db)
            await db.execute(
                "INSERT INTO history_archive (jail, ip, timeofban, bancount, data, action) VALUES (?, ?, ?, ?, ?, ?)",
                ("sshd", "10.0.0.1", _ONE_HOUR_AGO, 1, '{"matches": [], "failures": 0}', "ban"),
            )
            await db.commit()

            with patch(
                "app.services.history_service.get_fail2ban_db_path",
                new=AsyncMock(return_value=f2b_db_path),
            ):
                result = await history_service.list_history(
                    "fake_socket",
                    source="archive",
                    db=db,
                )

        assert result.total == 1
        assert result.items[0].ip == "10.0.0.1"


# ---------------------------------------------------------------------------
# get_ip_detail tests
# ---------------------------------------------------------------------------


class TestGetIpDetail:
    """history_service.get_ip_detail()."""

    async def test_returns_none_for_unknown_ip(
        self, f2b_db_path: str
    ) -> None:
        """Returns ``None`` when the IP has no records in the database."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.get_ip_detail("fake_socket", "99.99.99.99")
        assert result is None

    async def test_returns_ip_detail_for_known_ip(
        self, f2b_db_path: str
    ) -> None:
        """Returns an IpDetailResponse with correct totals for a known IP."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")

        assert result is not None
        assert result.ip == "1.2.3.4"
        assert result.total_bans == 2
        assert result.total_failures == 10  # 5 + 5

    async def test_timeline_ordered_newest_first(
        self, f2b_db_path: str
    ) -> None:
        """Timeline events are ordered newest-first."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")

        assert result is not None
        assert len(result.timeline) == 2
        # First event should be the most recent
        assert result.timeline[0].banned_at > result.timeline[1].banned_at

    async def test_last_ban_at_is_most_recent(self, f2b_db_path: str) -> None:
        """``last_ban_at`` matches the banned_at of the first timeline event."""
        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.get_ip_detail("fake_socket", "1.2.3.4")

        assert result is not None
        assert result.last_ban_at == result.timeline[0].banned_at

    async def test_geo_enrichment_applied_when_provided(
        self, f2b_db_path: str
    ) -> None:
        """Geolocation is applied when a geo_enricher is provided."""
        from app.models.geo import GeoInfo

        mock_geo = GeoInfo(
            country_code="US",
            country_name="United States",
            asn="AS15169",
            org="Google",
        )
        fake_enricher = AsyncMock(return_value=mock_geo)

        with patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value=f2b_db_path),
        ):
            result = await history_service.get_ip_detail(
                "fake_socket", "1.2.3.4", geo_enricher=fake_enricher
            )

        assert result is not None
        assert result.country_code == "US"
        assert result.country_name == "United States"
        assert result.asn == "AS15169"
        assert result.org == "Google"


# ---------------------------------------------------------------------------
# sync_from_fail2ban_db tests
# ---------------------------------------------------------------------------


class TestSyncFromFail2BanDb:
    async def test_archives_new_records_and_returns_count(self) -> None:
        from types import SimpleNamespace

        fake_db = AsyncMock()
        fake_rows = [
            SimpleNamespace(
                jail="sshd",
                ip="1.2.3.4",
                timeofban=1000,
                bancount=1,
                data="{}",
            )
        ]

        with patch(
            "app.services.history_service._get_last_archive_ts",
            new=AsyncMock(return_value=1000),
        ), patch(
            "app.services.history_service.get_fail2ban_db_path",
            new=AsyncMock(return_value="/tmp/fake.sqlite3"),
        ), patch(
            "app.services.history_service.fail2ban_db_repo.get_history_page",
            new=AsyncMock(return_value=(fake_rows, 1)),
        ) as mock_page, patch(
            "app.services.history_service.archive_ban_event",
            new=AsyncMock(return_value=True),
        ) as archive_mock:
            count = await history_service.sync_from_fail2ban_db(
                fake_db, "/tmp/fake.sock"
            )

        assert count == 1
        mock_page.assert_awaited_once_with(
            db_path="/tmp/fake.sqlite3",
            since=1001,
            page=1,
            page_size=500,
        )
        archive_mock.assert_awaited_once_with(
            db=fake_db,
            jail="sshd",
            ip="1.2.3.4",
            timeofban=1000,
            bancount=1,
            data="{}",
            action="ban",
        )
