"""Ban service.

Queries the fail2ban SQLite database for ban history.  The fail2ban database
path is obtained at runtime by sending ``get dbfile`` to the fail2ban daemon
via the Unix domain socket.

All database I/O is performed through aiosqlite opened in **read-only** mode
so BanGUI never modifies or locks the fail2ban database.
"""

from __future__ import annotations

import asyncio
import contextlib
import ipaddress
from typing import TYPE_CHECKING, Any, cast

import aiohttp
from app.utils.logging_compat import get_logger

from app.exceptions import JailNotFoundError, JailOperationError
from app.models._common import (
    BUCKET_SECONDS,
    BUCKET_SIZE_LABEL,
    TimeRange,
    bucket_count,
)
from app.models.ban import (
    BLOCKLIST_JAIL,
    BanOrigin,
    _derive_origin,
)
from app.models.ban_domain import (
    DomainActiveBan,
    DomainActiveBanList,
    DomainBansByCountry,
    DomainBansByJail,
    DomainBanTrend,
    DomainBanTrendBucket,
    DomainDashboardBanItem,
    DomainDashboardBanList,
    DomainJailBanCount,
)
from app.repositories import fail2ban_db_repo
from app.repositories import history_archive_repo as default_history_archive_repo
from app.utils.async_utils import logged_task
from app.utils.constants import (
    DEFAULT_PAGE_SIZE,
    FAIL2BAN_SOCKET_TIMEOUT,
)
from app.utils.fail2ban_client import (
    Fail2BanClient,
)
from app.utils.fail2ban_db_utils import parse_data_json, ts_to_iso
from app.utils.fail2ban_response import (
    is_not_found_error,
    ok,
    to_dict,
)
from app.utils.ip_utils import normalise_ip
from app.utils.time_utils import since_unix

if TYPE_CHECKING:
    from collections.abc import Awaitable

    import aiosqlite

    from app.models.geo import GeoCacheLookup, GeoEnricher, GeoInfo
    from app.repositories.protocols import HistoryArchiveRepository
    from app.services.geo_cache import GeoCache

log = get_logger(__name__)


async def get_fail2ban_db_path(socket_path: str) -> str:
    """Return the fail2ban database path using the shared metadata cache."""
    from app.services.fail2ban_metadata_service import (  # noqa: PLC0415
        default_fail2ban_metadata_service,
    )

    return await default_fail2ban_metadata_service.get_db_path(socket_path)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


async def ban_ip(socket_path: str, jail: str, ip: str) -> None:
    """Ban an IP address in the specified jail.

    Error contract: ABORT_ON_ERROR. Raises JailNotFoundError or JailOperationError.
    Router converts to HTTP 404 or 409.
    """
    try:
        ipaddress.ip_address(ip)
    except ValueError as exc:
        raise ValueError(f"Invalid IP address: {ip!r}") from exc

    normalized = normalise_ip(ip)
    client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)

    try:
        ok(await client.send(["set", jail, "banip", normalized]))
    except ValueError as exc:
        if is_not_found_error(exc):
            raise JailNotFoundError(jail) from exc
        raise JailOperationError(str(exc)) from exc


async def unban_ip(socket_path: str, ip: str, jail: str | None = None) -> None:
    """Unban an IP address from a specific jail or all jails."""
    try:
        ipaddress.ip_address(ip)
    except ValueError as exc:
        raise ValueError(f"Invalid IP address: {ip!r}") from exc

    normalized = normalise_ip(ip)
    client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)

    if jail is None:
        ok(await client.send(["unban", normalized]))
        return

    try:
        ok(await client.send(["set", jail, "unbanip", normalized]))
    except ValueError as exc:
        if is_not_found_error(exc):
            raise JailNotFoundError(jail) from exc
        raise JailOperationError(str(exc)) from exc


def _origin_sql_filter(origin: BanOrigin | None) -> tuple[str, tuple[str, ...]]:
    """Return a SQL fragment and its parameters for the origin filter.

    Args:
        origin: ``"blocklist"`` to restrict to the blocklist-import jail,
            ``"selfblock"`` to exclude it, or ``None`` for no restriction.

    Returns:
        A ``(sql_fragment, params)`` pair — the fragment starts with ``" AND"``
        so it can be appended directly to an existing WHERE clause.
    """
    if origin == "blocklist":
        return " AND jail = ?", (BLOCKLIST_JAIL,)
    if origin == "selfblock":
        return " AND jail != ?", (BLOCKLIST_JAIL,)
    return "", ()


def _parse_ban_entry(entry: str, jail: str) -> DomainActiveBan | None:
    """Parse a ban entry from ``get <jail> banip --with-time`` output."""
    from datetime import UTC, datetime

    try:
        parts = entry.split("\t", 1)
        ip = parts[0].strip()

        ipaddress.ip_address(ip)

        if len(parts) < 2:
            return DomainActiveBan(
                ip=ip,
                jail=jail,
                banned_at=None,
                expires_at=None,
                ban_count=1,
                country=None,
            )

        time_part = parts[1].strip()
        plus_idx = time_part.find(" + ")
        if plus_idx == -1:
            banned_at_str = time_part.strip()
            expires_at_str: str | None = None
        else:
            banned_at_str = time_part[:plus_idx].strip()
            remainder = time_part[plus_idx + 3 :]
            eq_idx = remainder.find(" = ")
            expires_at_str = remainder[eq_idx + 3 :].strip() if eq_idx != -1 else None

        _date_fmt = "%Y-%m-%d %H:%M:%S"

        def _to_iso(ts: str) -> str:
            dt = datetime.strptime(ts, _date_fmt).replace(tzinfo=UTC)
            return dt.isoformat()

        banned_at_iso: str | None = None
        expires_at_iso: str | None = None

        with contextlib.suppress(ValueError):
            banned_at_iso = _to_iso(banned_at_str)

        with contextlib.suppress(ValueError):
            if expires_at_str:
                expires_at_iso = _to_iso(expires_at_str)

        return DomainActiveBan(
            ip=ip,
            jail=jail,
            banned_at=banned_at_iso,
            expires_at=expires_at_iso,
            ban_count=1,
            country=None,
        )
    except (ValueError, IndexError, AttributeError) as exc:
        log.debug("ban_entry_parse_error", entry=entry, jail=jail, error=str(exc))
        return None


async def _enrich_bans(
    bans: list[DomainActiveBan],
    geo_enricher: GeoEnricher,
) -> list[DomainActiveBan]:
    """Enrich ban records with geo data asynchronously."""
    geo_results: list[object | Exception] = await asyncio.gather(
        *[cast("Awaitable[object]", geo_enricher(ban.ip)) for ban in bans],
        return_exceptions=True,
    )
    enriched: list[DomainActiveBan] = []
    for ban, geo in zip(bans, geo_results, strict=False):
        if geo is not None and not isinstance(geo, Exception):
            geo_info = cast("GeoInfo", geo)
            # Create new instance with updated country
            enriched.append(
                DomainActiveBan(
                    ip=ban.ip,
                    jail=ban.jail,
                    banned_at=ban.banned_at,
                    expires_at=ban.expires_at,
                    ban_count=ban.ban_count,
                    country=geo_info.country_code,
                )
            )
        else:
            enriched.append(ban)
    return enriched


async def get_active_bans(
    socket_path: str,
    geo_cache: GeoCache | None = None,
    geo_enricher: GeoEnricher | None = None,
    http_session: aiohttp.ClientSession | None = None,
    app_db: aiosqlite.Connection | None = None,
) -> DomainActiveBanList:
    """Return all currently banned IPs across every jail.

    For each jail the ``get <jail> banip --with-time`` command is used
    to retrieve ban start and expiry times alongside the IP address.

    Geo enrichment strategy (highest priority first):

    1. When *http_session* is provided the entire set of banned IPs is resolved
       in a single :meth:`GeoCache.lookup_batch` call (up to
       100 IPs per HTTP request).  This is far more efficient than concurrent
       per-IP lookups and stays within ip-api.com rate limits.
    2. When only *geo_enricher* is provided (legacy / test path) each IP is
       resolved individually via the supplied async callable.

    Args:
        socket_path: Path to the fail2ban Unix domain socket.
        geo_enricher: Optional async callable ``(ip) -> GeoInfo | None``.
            Used to enrich each ban entry with country and ASN data.
            Ignored when *http_session* is provided.
        http_session: Optional shared :class:`aiohttp.ClientSession`.  When
            provided, :meth:`GeoCache.lookup_batch` is used
            for efficient bulk geo resolution.
        app_db: Optional BanGUI application database connection used to
            persist newly resolved geo entries across restarts.  Only
            meaningful when *http_session* is provided.

    Returns:
        :class:`~app.models.ban_domain.DomainActiveBanList` with all active bans.

    Raises:
        ~app.utils.fail2ban_client.Fail2BanConnectionError: If the socket
            cannot be reached.
    """

    client = Fail2BanClient(socket_path=socket_path, timeout=FAIL2BAN_SOCKET_TIMEOUT)

    global_status = to_dict(ok(await client.send(["status"])))
    jail_list_raw: str = str(global_status.get("Jail list", "") or "").strip()
    jail_names: list[str] = (
        [j.strip() for j in jail_list_raw.split(",") if j.strip()]
        if jail_list_raw
        else []
    )

    if not jail_names:
        return DomainActiveBanList(bans=[], total=0)

    results: list[object | Exception] = await asyncio.gather(
        *[client.send(["get", jn, "banip", "--with-time"]) for jn in jail_names],
        return_exceptions=True,
    )

    bans: list[DomainActiveBan] = []
    for jail_name, raw_result in zip(jail_names, results, strict=False):
        if isinstance(raw_result, Exception):
            log.warning(
                "active_bans_fetch_error",
                jail=jail_name,
                error=str(raw_result),
            )
            continue

        try:
            ban_list: list[str] = cast("list[str]", ok(raw_result)) or []
        except (TypeError, ValueError) as exc:
            log.warning(
                "active_bans_parse_error",
                jail=jail_name,
                error=str(exc),
            )
            continue

        for entry in ban_list:
            ban = _parse_ban_entry(str(entry), jail_name)
            if ban is not None:
                bans.append(ban)

    if http_session is not None and bans and geo_cache is not None:
        all_ips: list[str] = [ban.ip for ban in bans]
        try:
            geo_map = await geo_cache.lookup_batch(all_ips, http_session, db=app_db)
        except (TimeoutError, aiohttp.ClientError, OSError):
            log.warning("active_bans_batch_geo_failed")
            geo_map = {}
        enriched: list[DomainActiveBan] = []
        for ban in bans:
            geo = geo_map.get(ban.ip)
            if geo is not None:
                enriched.append(DomainActiveBan(
                    ip=ban.ip,
                    jail=ban.jail,
                    banned_at=ban.banned_at,
                    expires_at=ban.expires_at,
                    ban_count=ban.ban_count,
                    country=geo.country_code,
                ))
            else:
                enriched.append(ban)
        bans = enriched
    elif geo_enricher is not None:
        bans = await _enrich_bans(bans, geo_enricher)

    log.info("active_bans_fetched", total=len(bans))
    return DomainActiveBanList(bans=bans, total=len(bans))

# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------


async def list_bans(
    socket_path: str,
    range_: TimeRange,
    *,
    source: str = "fail2ban",
    page: int = 1,
    page_size: int = DEFAULT_PAGE_SIZE,
    max_page_size: int = 500,
    http_session: aiohttp.ClientSession | None = None,
    app_db: aiosqlite.Connection | None = None,
    geo_cache: GeoCache | None = None,
    geo_enricher: GeoEnricher | None = None,
    history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
    origin: BanOrigin | None = None,
) -> DomainDashboardBanList:
    """Return a paginated list of bans within the selected time window.

    Queries the fail2ban database ``bans`` table for records whose
    ``timeofban`` falls within the specified *range_*.  Results are ordered
    newest-first.

    Geo enrichment strategy (highest priority first):

    1. When *http_session* is provided the entire page of IPs is resolved in
       one :meth:`GeoCache.lookup_batch` call (up to 100 IPs
       per HTTP request).  This avoids the 45 req/min rate limit of the
       single-IP endpoint and is the preferred production path.
    2. When only *geo_enricher* is provided (legacy / test path) each IP is
       resolved individually via the supplied async callable.

    Args:
        socket_path: Path to the fail2ban Unix domain socket.
        range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
            ``"365d"``).
        page: 1-based page number (default: ``1``).
        page_size: Maximum items per page, capped at ``max_page_size``
            (default: ``100``).
        max_page_size: Deployment-configured maximum page size (default: ``500``).
        http_session: Optional shared :class:`aiohttp.ClientSession`.  When
            provided, :meth:`GeoCache.lookup_batch` is used
            for efficient bulk geo resolution.
        app_db: Optional BanGUI application database used to persist newly
            resolved geo entries and to read back cached results.
        geo_enricher: Optional async callable ``(ip: str) -> GeoInfo | None``.
            Used as a fallback when *http_session* is ``None`` (e.g. tests).
        origin: Optional origin filter — ``"blocklist"`` restricts results to
            the ``blocklist-import`` jail, ``"selfblock"`` excludes it.

    Returns:
        :class:`~app.models.ban_domain.DomainDashboardBanList` containing the
        paginated items and total count.
    """

    since: int = since_unix(range_)
    effective_page_size: int = min(page_size, max_page_size)
    offset: int = (page - 1) * effective_page_size

    if source not in ("fail2ban", "archive"):
        raise ValueError(f"Unsupported source: {source!r}")

    if source == "archive":
        if app_db is None:
            raise ValueError("app_db must be provided when source is 'archive'")

        rows, total = await history_archive_repo.get_archived_history(
            db=app_db,
            since=since,
            origin=origin,
            action="ban",
            page=page,
            page_size=effective_page_size,
        )
    else:
        db_path: str = await get_fail2ban_db_path(socket_path)
        log.info(
            "ban_service_list_bans",
            db_path=db_path,
            since=since,
            range=range_,
            origin=origin,
        )

        rows, total = await fail2ban_db_repo.get_currently_banned(
            db_path=db_path,
            since=since,
            origin=origin,
            limit=effective_page_size,
            offset=offset,
        )

    # Batch-resolve geo data for all IPs on this page in a single API call.
    # This avoids hitting the 45 req/min single-IP rate limit when the
    # page contains many bans (e.g. after a large blocklist import).
    geo_map: dict[str, GeoInfo] = {}
    if http_session is not None and rows and geo_cache is not None:
        page_ips: list[str] = [r.ip for r in rows]
        try:
            geo_map = await geo_cache.lookup_batch(page_ips, http_session, db=app_db)
        except (TimeoutError, aiohttp.ClientError, OSError):
            log.warning("ban_service_batch_geo_failed_list_bans")

    items: list[DomainDashboardBanItem] = []
    for row in rows:
        if source == "archive":
            jail = str(row["jail"])
            ip = str(row["ip"])
            banned_at = ts_to_iso(int(row["timeofban"]))
            ban_count = int(row["bancount"])
            matches, _ = parse_data_json(row["data"])
        else:
            jail = row.jail
            ip = row.ip
            banned_at = ts_to_iso(row.timeofban)
            ban_count = row.bancount
            matches, _ = parse_data_json(row.data)

        service: str | None = matches[0] if matches else None

        country_code: str | None = None
        country_name: str | None = None
        asn: str | None = None
        org: str | None = None

        if geo_map:
            geo = geo_map.get(ip)
            if geo is not None:
                country_code = geo.country_code
                country_name = geo.country_name
                asn = geo.asn
                org = geo.org
        elif geo_enricher is not None:
            try:
                geo = await geo_enricher(ip)
                if geo is not None:
                    country_code = geo.country_code
                    country_name = geo.country_name
                    asn = geo.asn
                    org = geo.org
            except (TimeoutError, aiohttp.ClientError, OSError):
                log.warning("ban_service_geo_lookup_failed", ip=ip)
            except Exception as exc:
                log.error("ban_service_geo_lookup_unexpected_error", ip=ip, error=type(exc).__name__)
                raise  # Bubble programming errors to global handler

        items.append(
            DomainDashboardBanItem(
                ip=ip,
                jail=jail,
                banned_at=banned_at,
                service=service,
                country_code=country_code,
                country_name=country_name,
                asn=asn,
                org=org,
                ban_count=ban_count,
                origin=_derive_origin(jail),
            )
        )

    return DomainDashboardBanList(
        items=items,
        total=total,
        page=page,
        page_size=effective_page_size,
    )


# ---------------------------------------------------------------------------
# bans_by_country
# ---------------------------------------------------------------------------

#: Maximum rows returned in the companion table alongside the map.
_MAX_COMPANION_BANS: int = 200


# ---------------------------------------------------------------------------
# bans_by_country — implementation helpers
# ---------------------------------------------------------------------------

async def _bans_by_country_load_data(
    *,
    source: str,
    socket_path: str,
    since: int,
    origin: BanOrigin | None,
    history_archive_repo: HistoryArchiveRepository,
    app_db: aiosqlite.Connection | None,
) -> tuple[dict[str, int], int, list[str]]:
    """Load per-IP ban counts and total for the requested time window.

    Returns:
        Tuple of (agg_rows dict mapping ip->event_count, total_ban_count, unique_ip_list).
    """
    if source == "archive":
        if app_db is None:
            raise ValueError("app_db must be provided when source is 'archive'")

        ip_counts = await history_archive_repo.get_ip_ban_counts(
            db=app_db,
            since=since,
            origin=origin,
            action="ban",
        )

        agg_rows = {row["ip"]: int(row["event_count"]) for row in ip_counts}
        total = sum(agg_rows.values())
        unique_ips = list(agg_rows.keys())
    else:
        db_path: str = await get_fail2ban_db_path(socket_path)
        log.info(
            "ban_service_bans_by_country",
            db_path=db_path,
            since=since,
            origin=origin,
        )

        _, total = await fail2ban_db_repo.get_currently_banned(
            db_path=db_path,
            since=since,
            origin=origin,
            limit=0,
            offset=0,
        )

        agg_rows_list = await fail2ban_db_repo.get_ban_event_counts(
            db_path=db_path,
            since=since,
            origin=origin,
        )

        agg_rows = {r.ip: r.event_count for r in agg_rows_list}
        unique_ips = list(agg_rows.keys())

    return agg_rows, total, unique_ips


async def _bans_by_country_resolve_geo(
    unique_ips: list[str],
    *,
    http_session: aiohttp.ClientSession | None,
    geo_cache_lookup: GeoCacheLookup | None,
    geo_cache: GeoCache | None,
    geo_enricher: GeoEnricher | None,
    app_db: aiosqlite.Connection | None,
) -> dict[str, GeoInfo]:
    """Resolve geo information for a list of unique IPs.

    Uses the geo cache when available; falls back to legacy enricher.
    Uncached IPs are scheduled for background resolution to warm the cache.
    """
    if not unique_ips:
        return {}

    geo_map: dict[str, GeoInfo] = {}

    if http_session is not None and geo_cache_lookup is not None:
        geo_map, uncached = geo_cache_lookup(unique_ips)
        if uncached:
            log.info(
                "ban_service_geo_background_scheduled",
                uncached=len(uncached),
                cached=len(geo_map),
            )
            if geo_cache is not None:
                asyncio.create_task(
                    logged_task(
                        geo_cache.lookup_batch(uncached, http_session, db=app_db),
                        "geo_bans_by_country",
                    ),
                    name="geo_bans_by_country",
                )
    elif geo_enricher is not None:
        async def _safe_lookup(ip: str) -> tuple[str, GeoInfo | None]:
            try:
                return ip, await geo_enricher(ip)
            except (TimeoutError, aiohttp.ClientError, OSError):
                log.warning("ban_service_geo_lookup_failed", ip=ip)
                return ip, None
            except Exception as exc:
                log.error(
                    "ban_service_geo_lookup_unexpected_error",
                    ip=ip,
                    error=type(exc).__name__,
                )
                raise

        results = await asyncio.gather(*(_safe_lookup(ip) for ip in unique_ips))
        geo_map = {ip: geo for ip, geo in results if geo is not None}

    return geo_map


async def _bans_by_country_load_companion(
    *,
    source: str,
    country_code: str | None,
    geo_map: dict[str, GeoInfo],
    since: int,
    origin: BanOrigin | None,
    db_path: str | None,
    app_db: aiosqlite.Connection | None,
    history_archive_repo: HistoryArchiveRepository,
) -> tuple[list[dict[str, Any] | fail2ban_db_repo.BanRecord], list[str]]:
    """Load companion ban rows and matched IPs for the given country filter.

    Returns:
        Tuple of (companion_rows, matched_ips_for_country).
    """
    if country_code is None:
        if source == "archive":
            rows, _ = await history_archive_repo.get_archived_history(
                db=app_db,
                since=since,
                origin=origin,
                action="ban",
                page=1,
                page_size=_MAX_COMPANION_BANS,
            )
        else:
            rows, _ = await fail2ban_db_repo.get_currently_banned(
                db_path=db_path,
                since=since,
                origin=origin,
                limit=_MAX_COMPANION_BANS,
                offset=0,
            )
        return rows, []

    matched_ips = [
        ip
        for ip, geo in geo_map.items()
        if geo is not None and geo.country_code == country_code
    ]

    if not matched_ips:
        return [], matched_ips

    if source == "archive":
        rows, _ = await history_archive_repo.get_archived_history(
            db=app_db,
            since=since,
            origin=origin,
            action="ban",
            ip_filter=matched_ips,
            page=1,
            page_size=_MAX_COMPANION_BANS,
        )
    else:
        rows, _ = await fail2ban_db_repo.get_currently_banned(
            db_path=db_path,
            since=since,
            origin=origin,
            ip_filter=matched_ips,
        )

    return rows, matched_ips


def _bans_by_country_aggregate(
    agg_rows: dict[str, int],
    geo_map: dict[str, GeoInfo],
    source: str,
) -> tuple[dict[str, int], dict[str, str]]:
    """Aggregate ban counts by country code.

    Returns:
        Tuple of (countries dict mapping cc->count, country_names dict mapping cc->name).
    """
    countries: dict[str, int] = {}
    country_names: dict[str, str] = {}

    for ip, event_count in agg_rows.items():
        geo = geo_map.get(ip)
        cc: str | None = geo.country_code if geo else None
        cn: str | None = geo.country_name if geo else None

        if cc:
            countries[cc] = countries.get(cc, 0) + event_count
            if cn and cc not in country_names:
                country_names[cc] = cn

    return countries, country_names


def _bans_by_country_build_ban_items(
    companion_rows: list[dict[str, Any] | fail2ban_db_repo.BanRecord],
    geo_map: dict[str, GeoInfo],
    source: str,
) -> list[DomainDashboardBanItem]:
    """Build DomainDashboardBanItem list from raw companion rows."""
    bans: list[DomainDashboardBanItem] = []

    for companion_row in companion_rows:
        if source == "archive":
            ip = companion_row["ip"]
            jail = companion_row["jail"]
            banned_at = ts_to_iso(int(companion_row["timeofban"]))
            ban_count = int(companion_row["bancount"])
            service = None
        else:
            ip = companion_row.ip
            jail = companion_row.jail
            banned_at = ts_to_iso(companion_row.timeofban)
            ban_count = companion_row.bancount
            matches, _ = parse_data_json(companion_row.data)
            service = matches[0] if matches else None

        geo = geo_map.get(ip)
        cc = geo.country_code if geo else None
        cn = geo.country_name if geo else None
        asn: str | None = geo.asn if geo else None
        org: str | None = geo.org if geo else None

        bans.append(
            DomainDashboardBanItem(
                ip=ip,
                jail=jail,
                banned_at=banned_at,
                service=service,
                country_code=cc,
                country_name=cn,
                asn=asn,
                org=org,
                ban_count=ban_count,
                origin=_derive_origin(jail),
            )
        )

    return bans


async def bans_by_country(
    socket_path: str,
    range_: TimeRange,
    *,
    source: str = "fail2ban",
    http_session: aiohttp.ClientSession | None = None,
    geo_cache_lookup: GeoCacheLookup | None = None,
    geo_cache: GeoCache | None = None,
    geo_enricher: GeoEnricher | None = None,
    history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
    app_db: aiosqlite.Connection | None = None,
    origin: BanOrigin | None = None,
    country_code: str | None = None,
) -> DomainBansByCountry:
    """Aggregate ban counts per country for the selected time window.

    Uses a two-step strategy optimised for large datasets:

    1. Queries the fail2ban DB with ``GROUP BY ip`` to get the per-IP ban
       counts for all unique IPs in the window — no row-count cap.
    2. Serves geo data from the in-memory cache only (non-blocking).
       Any IPs not yet in the cache are scheduled for background resolution
       via :func:`asyncio.create_task` so the response is returned immediately
       and subsequent requests benefit from the warmed cache.
    3. Returns a ``{country_code: count}`` aggregation and the 200 most
       recent raw rows for the companion table.

    Note:
        On the very first request a large number of IPs may be uncached and
        the country map will be sparse.  The background task will resolve them
        and the next request will return a complete map.  This trade-off keeps
        the endpoint fast regardless of dataset size.

    Args:
        socket_path: Path to the fail2ban Unix domain socket.
        range_: Time-range preset.
        http_session: Optional :class:`aiohttp.ClientSession` for background
            geo lookups.  When ``None``, only cached data is used.
        geo_enricher: Legacy async ``(ip) -> GeoInfo | None`` callable;
            used when *http_session* is ``None`` (e.g. tests).
        app_db: Optional BanGUI application database used to persist newly
            resolved geo entries across restarts.
        origin: Optional origin filter — ``"blocklist"`` restricts results to
            the ``blocklist-import`` jail, ``"selfblock"`` excludes it.

    Returns:
        :class:`~app.models.ban_domain.DomainBansByCountry` with per-country
        aggregation and the companion ban list.
    """

    since: int = since_unix(range_)

    if source not in ("fail2ban", "archive"):
        raise ValueError(f"Unsupported source: {source!r}")

    # Step 1: Load per-IP ban counts and total.
    db_path: str | None = None
    if source == "fail2ban":
        db_path = await get_fail2ban_db_path(socket_path)

    agg_rows, total, unique_ips = await _bans_by_country_load_data(
        source=source,
        socket_path=socket_path,
        since=since,
        origin=origin,
        history_archive_repo=history_archive_repo,
        app_db=app_db,
    )

    # Step 2: Resolve geo for unique IPs (from cache or enricher).
    geo_map = await _bans_by_country_resolve_geo(
        unique_ips,
        http_session=http_session,
        geo_cache_lookup=geo_cache_lookup,
        geo_cache=geo_cache,
        geo_enricher=geo_enricher,
        app_db=app_db,
    )

    # Step 3: Load companion ban rows (filtered by country if provided).
    companion_rows, _ = await _bans_by_country_load_companion(
        source=source,
        country_code=country_code,
        geo_map=geo_map,
        since=since,
        origin=origin,
        db_path=db_path,
        app_db=app_db,
        history_archive_repo=history_archive_repo,
    )

    # Step 4: Aggregate counts by country.
    countries, country_names = _bans_by_country_aggregate(agg_rows, geo_map, source)

    # Step 5: Build companion ban items for the response.
    bans = _bans_by_country_build_ban_items(companion_rows, geo_map, source)

    return DomainBansByCountry(
        countries=countries,
        country_names=country_names,
        items=bans,
        total=total,
    )


# ---------------------------------------------------------------------------
# ban_trend
# ---------------------------------------------------------------------------


async def ban_trend(
    socket_path: str,
    range_: TimeRange,
    *,
    source: str = "fail2ban",
    history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
    app_db: aiosqlite.Connection | None = None,
    origin: BanOrigin | None = None,
) -> DomainBanTrend:
    """Return ban counts aggregated into equal-width time buckets.

    Queries the fail2ban database ``bans`` table and groups records by a
    computed bucket index so the frontend can render a continuous time-series
    chart.  All buckets within the requested window are returned — buckets
    that contain zero bans are included as zero-count entries so the
    frontend always receives a complete, gap-free series.

    Bucket sizes per time-range preset:

    * ``24h``  → 1-hour buckets  (24 total)
    * ``7d``   → 6-hour buckets  (28 total)
    * ``30d``  → 1-day buckets   (30 total)
    * ``365d`` → 7-day buckets   (~53 total)

    Args:
        socket_path: Path to the fail2ban Unix domain socket.
        range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
            ``"365d"``).
        origin: Optional origin filter — ``"blocklist"`` restricts to the
            ``blocklist-import`` jail, ``"selfblock"`` excludes it.

    Returns:
        :class:`~app.models.ban_domain.DomainBanTrend` with a full bucket list
        and the human-readable bucket-size label.
    """
    since: int = since_unix(range_)
    bucket_secs: int = BUCKET_SECONDS[range_]
    num_buckets: int = bucket_count(range_)

    if source not in ("fail2ban", "archive"):
        raise ValueError(f"Unsupported source: {source!r}")

    if source == "archive":
        if app_db is None:
            raise ValueError("app_db must be provided when source is 'archive'")

        # SQL aggregation — no row materialisation into Python memory.
        counts = await history_archive_repo.get_ban_counts_by_bucket(
            db=app_db,
            since=since,
            bucket_secs=bucket_secs,
            num_buckets=num_buckets,
            origin=origin,
            action="ban",
        )

        log.info(
            "ban_service_ban_trend",
            source=source,
            since=since,
            range=range_,
            origin=origin,
            bucket_secs=bucket_secs,
            num_buckets=num_buckets,
        )
    else:
        db_path: str = await get_fail2ban_db_path(socket_path)
        log.info(
            "ban_service_ban_trend",
            db_path=db_path,
            since=since,
            range=range_,
            origin=origin,
            bucket_secs=bucket_secs,
            num_buckets=num_buckets,
        )

        counts = await fail2ban_db_repo.get_ban_counts_by_bucket(
            db_path=db_path,
            since=since,
            bucket_secs=bucket_secs,
            num_buckets=num_buckets,
            origin=origin,
        )

    buckets: list[DomainBanTrendBucket] = [
        DomainBanTrendBucket(
            timestamp=ts_to_iso(since + i * bucket_secs),
            count=counts[i],
        )
        for i in range(num_buckets)
    ]

    return DomainBanTrend(
        buckets=buckets,
        bucket_size=BUCKET_SIZE_LABEL[range_],
    )


# ---------------------------------------------------------------------------
# bans_by_jail
# ---------------------------------------------------------------------------


async def bans_by_jail(
    socket_path: str,
    range_: TimeRange,
    *,
    source: str = "fail2ban",
    history_archive_repo: HistoryArchiveRepository = default_history_archive_repo,
    app_db: aiosqlite.Connection | None = None,
    origin: BanOrigin | None = None,
) -> DomainBansByJail:
    """Return ban counts aggregated per jail for the selected time window.

    Queries the fail2ban database ``bans`` table, groups records by jail
    name, and returns them ordered by count descending.  The origin filter
    is applied when provided so callers can restrict results to blocklist-
    imported bans or organic fail2ban bans.

    Args:
        socket_path: Path to the fail2ban Unix domain socket.
        range_: Time-range preset (``"24h"``, ``"7d"``, ``"30d"``, or
            ``"365d"``).
        origin: Optional origin filter — ``"blocklist"`` restricts to the
            ``blocklist-import`` jail, ``"selfblock"`` excludes it.

    Returns:
        :class:`~app.models.ban_domain.DomainBansByJail` with per-jail counts
        sorted descending and the total ban count.
    """
    since: int = since_unix(range_)

    if source not in ("fail2ban", "archive"):
        raise ValueError(f"Unsupported source: {source!r}")

    if source == "archive":
        if app_db is None:
            raise ValueError("app_db must be provided when source is 'archive'")

        # SQL aggregation — no row materialisation into Python memory.
        total, jail_rows = await history_archive_repo.get_jail_ban_counts(
            db=app_db,
            since=since,
            origin=origin,
            action="ban",
        )

        jail_counts = [
            DomainJailBanCount(jail=str(row["jail"]), count=int(row["event_count"]))
            for row in jail_rows
        ]

        log.debug(
            "ban_service_bans_by_jail",
            source=source,
            since=since,
            since_iso=ts_to_iso(since),
            range=range_,
            origin=origin,
        )
    else:
        origin_clause, origin_params = _origin_sql_filter(origin)

        db_path: str = await get_fail2ban_db_path(socket_path)
        log.debug(
            "ban_service_bans_by_jail",
            db_path=db_path,
            since=since,
            since_iso=ts_to_iso(since),
            range=range_,
            origin=origin,
        )

        total, jail_counts_repo = await fail2ban_db_repo.get_bans_by_jail(
            db_path=db_path,
            since=since,
            origin=origin,
        )

        # Convert repository models to domain models
        jail_counts = [
            DomainJailBanCount(jail=jc.jail, count=jc.count)
            for jc in jail_counts_repo
        ]

        # Diagnostic guard: if zero results were returned, check whether the table
        # has *any* rows and log a warning with min/max timeofban so operators can
        # diagnose timezone or filter mismatches from logs.
        if total == 0:
            table_row_count, min_timeofban, max_timeofban = await fail2ban_db_repo.get_bans_table_summary(db_path)
            if table_row_count > 0:
                log.warning(
                    "ban_service_bans_by_jail_empty_despite_data",
                    table_row_count=table_row_count,
                    min_timeofban=min_timeofban,
                    max_timeofban=max_timeofban,
                    since=since,
                    range=range_,
                )

    log.debug(
        "ban_service_bans_by_jail_result",
        total=total,
        jail_count=len(jail_counts),
    )

    return DomainBansByJail(
        jails=jail_counts,
        total=total,
    )
