"""Blocklist import workflow orchestrator.

Coordinates the download, parse, validate, ban, and logging steps for
importing blocklist sources. This thin orchestration layer composes the
individual components.

Implements idempotent retries: if the process crashes after downloading but
before completing, retry will detect the cached operation and skip duplicate
bans while re-warming the geo cache.
"""

from __future__ import annotations

import hashlib
from typing import TYPE_CHECKING

import aiohttp
import aiosqlite
from app.utils.logging_compat import get_logger

from app.models.blocklist import BlocklistSource, ImportSourceResult
from app.repositories import import_run_repo
from app.services.blocklist_ban_executor import BanExecutor
from app.services.blocklist_downloader import BlocklistDownloader
from app.services.blocklist_parser import BlocklistParser

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

    from app.services.geo_cache import GeoCache

log = get_logger(__name__)

#: fail2ban jail name for blocklist-origin bans.
BLOCKLIST_JAIL: str = "blocklist-import"


def _aiohttp_timeout(seconds: float) -> aiohttp.ClientTimeout:
    """Return an aiohttp ClientTimeout with the given total timeout."""
    return aiohttp.ClientTimeout(total=seconds)


def _compute_content_hash(content: str) -> str:
    """Compute SHA256 hash of blocklist content for idempotency detection.

    Args:
        content: Raw blocklist content as string.

    Returns:
        Hex-encoded SHA256 hash.
    """
    return hashlib.sha256(content.encode()).hexdigest()



class BlocklistImportWorkflow:
    """Orchestrates the complete blocklist import flow for a single source."""

    def __init__(
        self,
        http_session: aiohttp.ClientSession,
        ban_ip: Callable[[str, str, str], Awaitable[None]],
        log_result: Callable[
            [aiosqlite.Connection, BlocklistSource, int, int, str | None],
            Awaitable[None],
        ],
    ) -> None:
        """Initialize the workflow.

        Args:
            http_session: Shared aiohttp session.
            ban_ip: Function to ban an IP address.
            log_result: Function to log import result to database.
        """
        self.downloader = BlocklistDownloader(http_session)
        self.parser = BlocklistParser()
        self.ban_executor = BanExecutor(ban_ip)
        self.log_result = log_result

    async def import_source(
        self,
        source: BlocklistSource,
        socket_path: str,
        db: aiosqlite.Connection,
        *,
        geo_is_cached: Callable[[str], bool] | None = None,
        geo_cache: GeoCache | None = None,
    ) -> ImportSourceResult:
        """Download and apply bans from a single blocklist source.

        Implements idempotent retries: if the process crashes mid-operation,
        retry will detect the cached import run and skip duplicate bans.

        The workflow:
        1. Download the URL with retries for transient failures.
        2. Compute content hash for idempotency detection.
        3. Check if this exact content has already been imported.
        4. If yes (retry case): skip banning, but re-warm geo cache.
        5. If no: mark as pending, parse, ban, mark as completed, pre-warm cache.

        After a successful import, the geo cache is pre-warmed by batch-resolving
        all newly banned IPs. This ensures the dashboard and map show country
        data immediately after import.

        Args:
            source: The blocklist source to import.
            socket_path: Path to the fail2ban Unix socket.
            db: Application database for logging.
            geo_is_cached: Optional function to check if an IP is cached.
            geo_cache: Optional GeoCache instance for pre-warming.

        Returns:
            ImportSourceResult with counters and error info.
        """
        # --- Download ---
        try:
            status, content = await self.downloader.download(
                source.url,
                _aiohttp_timeout(30),
            )
            if status != 200:
                error_msg = f"HTTP {status}"
                await self.log_result(db, source, 0, 0, error_msg)
                log.warning(
                    "blocklist_import_download_failed",
                    url=source.url,
                    status=status,
                )
                return ImportSourceResult(
                    source_id=source.id,
                    source_url=source.url,
                    ips_imported=0,
                    ips_skipped=0,
                    error=error_msg,
                )
        except (TimeoutError, aiohttp.ClientError) as exc:
            error_msg = str(exc)
            await self.log_result(db, source, 0, 0, error_msg)
            log.warning(
                "blocklist_import_download_error",
                url=source.url,
                error=error_msg,
            )
            return ImportSourceResult(
                source_id=source.id,
                source_url=source.url,
                ips_imported=0,
                ips_skipped=0,
                error=error_msg,
            )

        # --- Compute content hash for idempotency ---
        content_hash = _compute_content_hash(content)

        # --- Check if this import has already been completed ---
        existing_run = await import_run_repo.get_by_source_and_hash(
            db,
            source.id,
            content_hash,
        )

        if existing_run is not None and existing_run.status == "completed":
            log.info(
                "blocklist_import_already_completed",
                source_id=source.id,
                content_hash=content_hash[:8],
                imported=existing_run.imported_count,
                skipped=existing_run.skipped_count,
            )
            # Skip banning (already done), but still offer to pre-warm cache
            await self._prewarm_geo_cache(
                source,
                existing_run.imported_count,
                content,
                geo_is_cached,
                geo_cache,
            )
            return ImportSourceResult(
                source_id=source.id,
                source_url=source.url,
                ips_imported=existing_run.imported_count,
                ips_skipped=existing_run.skipped_count,
                error=None,
            )

        # --- Parse and validate ---
        parsed = self.parser.parse(content)
        valid_ips = parsed.valid_ips
        skipped = parsed.skipped_entries

        # --- Create or update pending import run entry ---
        if existing_run is None:
            run_id = await import_run_repo.upsert_pending(
                db,
                source.id,
                content_hash,
            )
            # Commit the implicit transaction opened by RETURNING.
            await db.commit()
            log.info(
                "blocklist_import_tracking_created",
                source_id=source.id,
                run_id=run_id,
                content_hash=content_hash[:8],
            )
        else:
            # Retry case: existing run is pending or failed, try again
            run_id = existing_run.id
            log.info(
                "blocklist_import_retrying",
                source_id=source.id,
                run_id=run_id,
                content_hash=content_hash[:8],
                previous_status=existing_run.status,
            )

        # --- Ban ---
        imported, failed, ban_error = await self.ban_executor.ban_ips(
            socket_path,
            BLOCKLIST_JAIL,
            valid_ips,
        )

        # --- Update import run status ---
        if ban_error is not None:
            await import_run_repo.mark_failed(db, run_id, ban_error)
            log.warning(
                "blocklist_import_banning_failed",
                source_id=source.id,
                run_id=run_id,
                error=ban_error,
            )
        else:
            await import_run_repo.mark_completed(
                db,
                run_id,
                imported,
                skipped + failed,
            )

        # --- Log result ---
        await self.log_result(db, source, imported, skipped + failed, ban_error)
        log.info(
            "blocklist_source_imported",
            source_id=source.id,
            url=source.url,
            imported=imported,
            skipped=skipped + failed,
            error=ban_error,
        )

        # --- Pre-warm geo cache for newly imported IPs ---
        await self._prewarm_geo_cache(
            source,
            imported,
            content,
            geo_is_cached,
            geo_cache,
        )

        return ImportSourceResult(
            source_id=source.id,
            source_url=source.url,
            ips_imported=imported,
            ips_skipped=skipped + failed,
            error=ban_error,
        )

    async def _prewarm_geo_cache(
        self,
        source: BlocklistSource,
        imported: int,
        content: str,
        geo_is_cached: Callable[[str], bool] | None,
        geo_cache: GeoCache | None,
    ) -> None:
        """Pre-warm geo cache with newly imported IPs.

        Extracted into helper to support both first-run and retry scenarios.

        Args:
            source: The blocklist source.
            imported: Number of IPs that were (or have already been) banned.
            content: The downloaded content to extract IPs from.
            geo_is_cached: Optional function to check if an IP is cached.
            geo_cache: Optional GeoCache instance for pre-warming.
        """
        if imported == 0 or geo_is_cached is None or geo_cache is None:
            return

        # Re-parse content to get IPs (needed for retry case)
        parsed = self.parser.parse(content)
        imported_ips = parsed.valid_ips[:imported] if imported > 0 else []

        if not imported_ips:
            return

        uncached_ips: list[str] = [
            ip for ip in imported_ips if not geo_is_cached(ip)
        ]
        skipped_geo: int = len(imported_ips) - len(uncached_ips)

        if skipped_geo > 0:
            log.info(
                "blocklist_geo_prewarm_cache_hit",
                source_id=source.id,
                skipped=skipped_geo,
                to_lookup=len(uncached_ips),
            )

        if uncached_ips:
            try:
                await geo_cache.lookup_batch(uncached_ips, self.downloader.http_session, db=None)
                log.info(
                    "blocklist_geo_prewarm_complete",
                    source_id=source.id,
                    count=len(uncached_ips),
                )
            except (TimeoutError, aiohttp.ClientError, OSError):
                log.warning(
                    "blocklist_geo_prewarm_failed",
                    source_id=source.id,
                )
