"""BanGUI FastAPI application factory.

Call :func:`create_app` to obtain a configured :class:`fastapi.FastAPI`
instance suitable for direct use with an ASGI server (e.g. ``uvicorn``) or
in tests via ``httpx.AsyncClient``.

The lifespan handler manages all shared resources — database connection, HTTP
session, and scheduler — so every component can rely on them being available
on ``app.state`` throughout the request lifecycle.
"""

from __future__ import annotations

import logging
import sys
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import AsyncGenerator, Awaitable, Callable

    from starlette.responses import Response as StarletteResponse

import aiohttp
import aiosqlite
import structlog
from apscheduler.schedulers.asyncio import AsyncIOScheduler  # type: ignore[import-untyped]
from fastapi import FastAPI, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.middleware.base import BaseHTTPMiddleware

from app import __version__
from app.config import Settings, get_settings
from app.db import init_db
from app.routers import (
    auth,
    bans,
    blocklist,
    config,
    dashboard,
    file_config,
    geo,
    health,
    history,
    jails,
    server,
    setup,
)
from app.tasks import blocklist_import, geo_cache_flush, geo_re_resolve, health_check, history_sync
from app.utils.fail2ban_client import Fail2BanConnectionError, Fail2BanProtocolError
from app.utils.jail_config import ensure_jail_configs

# ---------------------------------------------------------------------------
# Ensure the bundled fail2ban package is importable from fail2ban-master/
#
# The directory layout differs between local dev and the Docker image:
#   Local:  <repo-root>/backend/app/main.py  → fail2ban-master at parents[2]
#   Docker: /app/app/main.py                 → fail2ban-master at parents[1]
# Walk up from this file until we find a "fail2ban-master" sibling directory
# so the path resolution is environment-agnostic.
# ---------------------------------------------------------------------------


def _find_fail2ban_master() -> Path | None:
    """Return the first ``fail2ban-master`` directory found while walking up.

    Returns:
        Absolute :class:`~pathlib.Path` to the ``fail2ban-master`` directory,
        or ``None`` if no such directory exists among the ancestors.
    """
    here = Path(__file__).resolve()
    for ancestor in here.parents:
        candidate = ancestor / "fail2ban-master"
        if candidate.is_dir():
            return candidate
    return None


_fail2ban_master: Path | None = _find_fail2ban_master()
if _fail2ban_master is not None and str(_fail2ban_master) not in sys.path:
    sys.path.insert(0, str(_fail2ban_master))

log: structlog.stdlib.BoundLogger = structlog.get_logger()


# ---------------------------------------------------------------------------
# Logging configuration
# ---------------------------------------------------------------------------


def _configure_logging(log_level: str) -> None:
    """Configure structlog for production JSON output.

    Args:
        log_level: One of ``debug``, ``info``, ``warning``, ``error``, ``critical``.
    """
    level: int = logging.getLevelName(log_level.upper())
    logging.basicConfig(level=level, stream=sys.stdout, format="%(message)s")
    structlog.configure(
        processors=[
            structlog.contextvars.merge_contextvars,
            structlog.stdlib.filter_by_level,
            structlog.processors.TimeStamper(fmt="iso"),
            structlog.stdlib.add_logger_name,
            structlog.stdlib.add_log_level,
            structlog.stdlib.PositionalArgumentsFormatter(),
            structlog.processors.StackInfoRenderer(),
            structlog.processors.format_exc_info,
            structlog.processors.UnicodeDecoder(),
            structlog.processors.JSONRenderer(),
        ],
        wrapper_class=structlog.stdlib.BoundLogger,
        context_class=dict,
        logger_factory=structlog.stdlib.LoggerFactory(),
        cache_logger_on_first_use=True,
    )


# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------


@asynccontextmanager
async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    """Manage the lifetime of all shared application resources.

    Resources are initialised in order on startup and released in reverse
    order on shutdown.  They are stored on ``app.state`` so they are
    accessible to dependency providers and tests.

    Args:
        app: The :class:`fastapi.FastAPI` instance being started.
    """
    settings: Settings = app.state.settings
    _configure_logging(settings.log_level)

    log.info("bangui_starting_up", database_path=settings.database_path)

    # --- Ensure required jail config files are present ---
    ensure_jail_configs(Path(settings.fail2ban_config_dir) / "jail.d")

    # --- Application database ---
    db_path: Path = Path(settings.database_path)
    db_path.parent.mkdir(parents=True, exist_ok=True)
    log.debug("database_directory_ensured", directory=str(db_path.parent))
    db: aiosqlite.Connection = await aiosqlite.connect(settings.database_path)
    db.row_factory = aiosqlite.Row
    await init_db(db)
    app.state.db = db

    # --- Shared HTTP client session ---
    http_session: aiohttp.ClientSession = aiohttp.ClientSession()
    app.state.http_session = http_session

    # --- Pre-warm geo cache from the persistent store ---
    from app.services import geo_service  # noqa: PLC0415

    geo_service.init_geoip(settings.geoip_db_path)
    await geo_service.load_cache_from_db(db)

    # Log unresolved geo entries so the operator can see the scope of the issue.
    unresolved_count = await geo_service.count_unresolved(db)
    if unresolved_count > 0:
        log.warning("geo_cache_unresolved_ips", unresolved=unresolved_count)

    # --- Background task scheduler ---
    scheduler: AsyncIOScheduler = AsyncIOScheduler(timezone="UTC")
    scheduler.start()
    app.state.scheduler = scheduler

    # --- Health-check background probe ---
    health_check.register(app)

    # --- Blocklist import scheduled task ---
    blocklist_import.register(app)

    # --- Periodic geo cache flush to SQLite ---
    geo_cache_flush.register(app)

    # --- Periodic re-resolve of NULL-country geo entries ---
    geo_re_resolve.register(app)

    # --- Periodic history sync from fail2ban into BanGUI archive ---
    history_sync.register(app)

    log.info("bangui_started")

    try:
        yield
    finally:
        log.info("bangui_shutting_down")
        scheduler.shutdown(wait=False)
        await http_session.close()
        await db.close()
        log.info("bangui_shut_down")


# ---------------------------------------------------------------------------
# Exception handlers
# ---------------------------------------------------------------------------


async def _unhandled_exception_handler(
    request: Request,
    exc: Exception,
) -> JSONResponse:
    """Return a sanitised 500 JSON response for any unhandled exception.

    The exception is logged with full context before the response is sent.
    No stack trace is leaked to the client.

    Args:
        request: The incoming FastAPI request.
        exc: The unhandled exception.

    Returns:
        A :class:`fastapi.responses.JSONResponse` with status 500.
    """
    log.error(
        "unhandled_exception",
        path=request.url.path,
        method=request.method,
        exc_info=exc,
    )
    return JSONResponse(
        status_code=500,
        content={"detail": "An unexpected error occurred. Please try again later."},
    )


async def _fail2ban_connection_handler(
    request: Request,
    exc: Fail2BanConnectionError,
) -> JSONResponse:
    """Return a ``502 Bad Gateway`` response when fail2ban is unreachable.

    Args:
        request: The incoming FastAPI request.
        exc: The :class:`~app.utils.fail2ban_client.Fail2BanConnectionError`.

    Returns:
        A :class:`fastapi.responses.JSONResponse` with status 502.
    """
    log.warning(
        "fail2ban_connection_error",
        path=request.url.path,
        method=request.method,
        error=str(exc),
    )
    return JSONResponse(
        status_code=502,
        content={"detail": f"Cannot reach fail2ban: {exc}"},
    )


async def _fail2ban_protocol_handler(
    request: Request,
    exc: Fail2BanProtocolError,
) -> JSONResponse:
    """Return a ``502 Bad Gateway`` response for fail2ban protocol errors.

    Args:
        request: The incoming FastAPI request.
        exc: The :class:`~app.utils.fail2ban_client.Fail2BanProtocolError`.

    Returns:
        A :class:`fastapi.responses.JSONResponse` with status 502.
    """
    log.warning(
        "fail2ban_protocol_error",
        path=request.url.path,
        method=request.method,
        error=str(exc),
    )
    return JSONResponse(
        status_code=502,
        content={"detail": f"fail2ban protocol error: {exc}"},
    )


# ---------------------------------------------------------------------------
# Setup-redirect middleware
# ---------------------------------------------------------------------------

# Paths that are always reachable, even before setup is complete.
_ALWAYS_ALLOWED: frozenset[str] = frozenset(
    {"/api/setup", "/api/health"},
)


class SetupRedirectMiddleware(BaseHTTPMiddleware):
    """Redirect all API requests to ``/api/setup`` until setup is done.

    Once setup is complete this middleware is a no-op.  Paths listed in
    :data:`_ALWAYS_ALLOWED` are exempt so the setup endpoint itself is
    always reachable.
    """

    async def dispatch(
        self,
        request: Request,
        call_next: Callable[[Request], Awaitable[StarletteResponse]],
    ) -> StarletteResponse:
        """Intercept requests before they reach the router.

        Args:
            request: The incoming HTTP request.
            call_next: The next middleware / router handler.

        Returns:
            Either a ``307 Temporary Redirect`` to ``/api/setup`` or the
            normal router response.
        """
        path: str = request.url.path.rstrip("/") or "/"

        # Allow requests that don't need setup guard.
        if any(path.startswith(allowed) for allowed in _ALWAYS_ALLOWED):
            return await call_next(request)

        # If setup is not complete, block all other API requests.
        # Fast path: setup completion is a one-way transition.  Once it is
        # True it is cached on app.state so all subsequent requests skip the
        # DB query entirely.  The flag is reset only when the app restarts.
        if path.startswith("/api") and not getattr(
            request.app.state, "_setup_complete_cached", False
        ):
            from app.services import setup_service  # noqa: PLC0415

            db: aiosqlite.Connection | None = getattr(request.app.state, "db", None)
            if db is None or not await setup_service.is_setup_complete(db):
                return RedirectResponse(
                    url="/api/setup",
                    status_code=status.HTTP_307_TEMPORARY_REDIRECT,
                )
            request.app.state._setup_complete_cached = True

        return await call_next(request)


# ---------------------------------------------------------------------------
# Application factory
# ---------------------------------------------------------------------------


def create_app(settings: Settings | None = None) -> FastAPI:
    """Create and configure the BanGUI FastAPI application.

    This factory is the single entry point for creating the application.
    Tests can pass a custom ``settings`` object to override defaults
    without touching environment variables.

    Args:
        settings: Optional pre-built :class:`~app.config.Settings` instance.
            If ``None``, settings are loaded from the environment via
            :func:`~app.config.get_settings`.

    Returns:
        A fully configured :class:`fastapi.FastAPI` application ready for use.
    """
    resolved_settings: Settings = settings if settings is not None else get_settings()

    app: FastAPI = FastAPI(
        title="BanGUI",
        description="Web interface for monitoring, managing, and configuring fail2ban.",
        version=__version__,
        lifespan=_lifespan,
    )

    # Store settings on app.state so the lifespan handler can access them.
    app.state.settings = resolved_settings

    # --- CORS ---
    # In production the frontend is served by the same origin.
    # CORS is intentionally permissive only in development.
    app.add_middleware(
        CORSMiddleware,
        allow_origins=["http://localhost:5173"],  # Vite dev server
        allow_credentials=True,
        allow_methods=["*"],
        allow_headers=["*"],
    )

    # --- Middleware ---
    # Note: middleware is applied in reverse order of registration.
    # The setup-redirect must run *after* CORS, so it is added last.
    app.add_middleware(SetupRedirectMiddleware)

    # --- Exception handlers ---
    # Ordered from most specific to least specific.  FastAPI evaluates handlers
    # in the order they were registered, so fail2ban network errors get a 502
    # rather than falling through to the generic 500 handler.
    app.add_exception_handler(Fail2BanConnectionError, _fail2ban_connection_handler)  # type: ignore[arg-type]
    app.add_exception_handler(Fail2BanProtocolError, _fail2ban_protocol_handler)  # type: ignore[arg-type]
    app.add_exception_handler(Exception, _unhandled_exception_handler)

    # --- Routers ---
    app.include_router(health.router)
    app.include_router(setup.router)
    app.include_router(auth.router)
    app.include_router(dashboard.router)
    app.include_router(jails.router)
    app.include_router(bans.router)
    app.include_router(geo.router)
    app.include_router(config.router)
    app.include_router(file_config.router)
    app.include_router(server.router)
    app.include_router(history.router)
    app.include_router(blocklist.router)

    return app
