"""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 typing import TYPE_CHECKING

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

    from starlette.responses import Response as StarletteResponse

import structlog
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.exceptions import (
    ActionAlreadyExistsError,
    ActionNotFoundError,
    ActionReadonlyError,
    ConfigFileExistsError,
    ConfigFileNameError,
    ConfigFileNotFoundError,
    ConfigOperationError,
    ConfigValidationError,
    ConfigWriteError,
    Fail2BanConnectionError,
    Fail2BanProtocolError,
    FilterAlreadyExistsError,
    FilterNotFoundError,
    FilterReadonlyError,
    JailAlreadyActiveError,
    JailAlreadyInactiveError,
    JailNotFoundError,
    JailOperationError,
)
from app.routers import (
    auth,
    bans,
    blocklist,
    config,
    dashboard,
    file_config,
    geo,
    health,
    history,
    jails,
    server,
    setup,
)
from app.startup import startup_shared_resources
from app.utils.runtime_state import ApplicationState, RuntimeState
from app.utils.session_cache import InMemorySessionCache, NoOpSessionCache
from app.utils.setup_state import is_setup_complete_cached, set_setup_complete_cache

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)

    http_session, scheduler = await startup_shared_resources(app, settings)
    app.state.http_session = http_session
    app.state.scheduler = scheduler

    log.info("bangui_started")

    try:
        yield
    finally:
        log.info("bangui_shutting_down")
        scheduler.shutdown(wait=False)
        await http_session.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.exceptions.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.exceptions.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}"},
    )


async def _not_found_handler(
    request: Request,
    exc: Exception,
) -> JSONResponse:
    """Return a ``404 Not Found`` response for missing domain entities.

    Args:
        request: The incoming FastAPI request.
        exc: The not-found exception.

    Returns:
        A :class:`fastapi.responses.JSONResponse` with status 404.
    """
    log.warning(
        "domain_not_found",
        path=request.url.path,
        method=request.method,
        error=str(exc),
    )
    return JSONResponse(
        status_code=status.HTTP_404_NOT_FOUND,
        content={"detail": str(exc)},
    )


async def _bad_request_handler(
    request: Request,
    exc: Exception,
) -> JSONResponse:
    """Return a ``400 Bad Request`` response for validation and domain contract errors.

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

    Returns:
        A :class:`fastapi.responses.JSONResponse` with status 400.
    """
    log.warning(
        "domain_bad_request",
        path=request.url.path,
        method=request.method,
        error=str(exc),
    )
    return JSONResponse(
        status_code=status.HTTP_400_BAD_REQUEST,
        content={"detail": str(exc)},
    )


async def _conflict_handler(
    request: Request,
    exc: Exception,
) -> JSONResponse:
    """Return a ``409 Conflict`` response for domain state conflicts."""
    log.warning(
        "domain_conflict",
        path=request.url.path,
        method=request.method,
        error=str(exc),
    )
    return JSONResponse(
        status_code=status.HTTP_409_CONFLICT,
        content={"detail": str(exc)},
    )


async def _domain_error_handler(
    request: Request,
    exc: Exception,
) -> JSONResponse:
    """Return a ``500 Internal Server Error`` response for domain write failures."""
    log.error(
        "domain_internal_error",
        path=request.url.path,
        method=request.method,
        error=str(exc),
        exc_info=exc,
    )
    return JSONResponse(
        status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
        content={"detail": str(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.
        # The setup completion state is resolved at startup and stored in
        # ``app.state.setup_complete_cached`` so this middleware does not
        # perform any database queries during normal request handling.
        if path.startswith("/api") and not is_setup_complete_cached(request.app):
            return RedirectResponse(
                url="/api/setup",
                status_code=status.HTTP_307_TEMPORARY_REDIRECT,
            )

        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 immutable configuration and the dedicated runtime state manager on
    # app.state. Runtime state values are proxied through the wrapper so the
    # shared Starlette state bag itself does not hold mutable business state.
    app.state = ApplicationState(RuntimeState())
    app.state.settings = resolved_settings
    app.state.session_cache = (
        InMemorySessionCache()
        if resolved_settings.session_cache_enabled and resolved_settings.session_cache_ttl_seconds > 0.0
        else NoOpSessionCache()
    )
    set_setup_complete_cache(app, False)

    # --- CORS ---
    # Allow origins configured by the runtime environment. In production,
    # this should be explicitly set to the frontend origin(s) or left empty
    # when the UI is served from the same origin as the API.
    if resolved_settings.cors_allowed_origins:
        app.add_middleware(
            CORSMiddleware,
            allow_origins=resolved_settings.cors_allowed_origins,
            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(JailNotFoundError, _not_found_handler)  # type: ignore[arg-type]
    app.add_exception_handler(FilterNotFoundError, _not_found_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ActionNotFoundError, _not_found_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ConfigFileNotFoundError, _not_found_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ConfigValidationError, _bad_request_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ConfigFileNameError, _bad_request_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ConfigOperationError, _bad_request_handler)  # type: ignore[arg-type]
    app.add_exception_handler(JailOperationError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(JailAlreadyActiveError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(JailAlreadyInactiveError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(FilterAlreadyExistsError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ActionAlreadyExistsError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(FilterReadonlyError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ActionReadonlyError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ConfigFileExistsError, _conflict_handler)  # type: ignore[arg-type]
    app.add_exception_handler(ConfigWriteError, _domain_error_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
