from __future__ import annotations

import shlex
from pathlib import Path
from typing import Annotated

from app.utils.logging_compat import get_logger
from fastapi import APIRouter, Depends, Query, Request, status

from app.config import get_settings
from app.dependencies import (
    AuthDep,
    Fail2BanSocketDep,
    Fail2BanStartCommandDep,
    GlobalRateLimiterDep,
    SettingsServiceContextDep,
)
from app.exceptions import OperationError
from app.mappers import config_mappers
from app.models.config import (
    Fail2BanLogResponse,
    GlobalConfigResponse,
    GlobalConfigUpdate,
    LogPreviewRequest,
    LogPreviewResponse,
    MapColorThresholdsResponse,
    MapColorThresholdsUpdate,
    RegexTestRequest,
    RegexTestResponse,
    SecurityHeadersResponse,
    ServiceStatusResponse,
)
from app.services import (
    config_service,
    jail_service,
    log_service,
)
from app.utils.constants import CSRF_HEADER_NAME, CSRF_HEADER_VALUE, RATE_LIMIT_CONFIG_UPDATE_REQUESTS

log = get_logger(__name__)

router: APIRouter = APIRouter(tags=["Config Misc"])

# Rate limit bucket constants
_CONFIG_UPDATE_BUCKET = "config:update"
# 60 seconds per minute
_MINUTE = 60


def _check_config_update_rate_limit(
    request: Request,
    rate_limiter: GlobalRateLimiterDep,
) -> None:
    """Check rate limit for config update operations."""
    from app.utils.client_ip import get_client_ip

    settings = request.app.state.settings
    client_ip = get_client_ip(request, trusted_proxies=settings.trusted_proxies)
    is_allowed, retry_after = rate_limiter.check_allowed_for_bucket(
        _CONFIG_UPDATE_BUCKET, client_ip, RATE_LIMIT_CONFIG_UPDATE_REQUESTS, _MINUTE
    )
    if not is_allowed:
        from app.utils.logging_compat import get_logger

        from app.exceptions import RateLimitError

        log = get_logger(__name__)
        log.warning(
            "config_update_rate_limit_exceeded",
            client_ip=client_ip,
            path=request.url.path,
            retry_after=retry_after,
        )
        raise RateLimitError(
            "Rate limit exceeded for config updates. Please try again later.",
            retry_after_seconds=retry_after,
        )


def _validate_log_target(value: str) -> None:
    """Validate that log_target is either a special value or a valid file path.

    Args:
        value: The log target to validate.

    Raises:
        ValueError: If the target is not a special value and not in allowed directories.
    """
    if value.upper() in ("STDOUT", "STDERR", "SYSLOG"):
        return

    settings = get_settings()
    try:
        resolved_path = Path(value).resolve()
    except (OSError, RuntimeError) as e:
        raise ValueError(f"Cannot resolve path {value!r}: {e}") from e

    for allowed_dir in settings.allowed_log_dirs:
        allowed_path = Path(allowed_dir).resolve()
        try:
            resolved_path.relative_to(allowed_path)
            return
        except ValueError:
            continue

    allowed_dirs_str = ", ".join(settings.allowed_log_dirs)
    raise ValueError(
        f"Log target {value!r} is outside allowed directories: {allowed_dirs_str}"
    )


@router.get(
    "/global",
    response_model=GlobalConfigResponse,
    summary="Return global fail2ban settings",
    responses={
        200: {"description": "Global config returned", "model": GlobalConfigResponse},
        401: {"description": "Session missing, expired, or invalid"},
        502: {"description": "fail2ban unreachable"},
    },
)
async def get_global_config(
    _request: Request,
    _auth: AuthDep,
    socket_path: Fail2BanSocketDep,
) -> GlobalConfigResponse:
    """Return global fail2ban settings.

    Includes log level, log target, and database configuration.

    Args:
        request: Incoming request.
        _auth: Validated session.

    Returns:
        :class:`~app.models.config.GlobalConfigResponse`.

    Raises:
        HTTPException: 502 when fail2ban is unreachable.
    """
    domain_result = await config_service.get_global_config(socket_path)
    return config_mappers.map_domain_global_config_to_response(domain_result)


@router.put(
    "/global",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Update global fail2ban settings",
    dependencies=[Depends(_check_config_update_rate_limit)],
    responses={
        204: {"description": "Global config updated successfully"},
        400: {"description": "Set command rejected or log_target invalid"},
        401: {"description": "Session missing, expired, or invalid"},
        429: {"description": "Rate limit exceeded for config update operations"},
        502: {"description": "fail2ban unreachable"},
    },
)
async def update_global_config(
    _request: Request,
    _auth: AuthDep,
    socket_path: Fail2BanSocketDep,
    body: GlobalConfigUpdate,
) -> None:
    """Update global fail2ban settings.

    Args:
        request: Incoming request.
        _auth: Validated session.
        body: Partial update — only non-None fields are written.

    Raises:
        HTTPException: 400 when a set command is rejected or log_target is invalid.
        HTTPException: 502 when fail2ban is unreachable.
    """
    if body.log_target is not None:
        _validate_log_target(body.log_target)
    await config_service.update_global_config(socket_path, body)


# ---------------------------------------------------------------------------
# Reload endpoint
# ---------------------------------------------------------------------------

@router.post(
    "/reload",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Reload fail2ban to apply configuration changes",
    responses={
        204: {"description": "Fail2ban reloaded successfully"},
        401: {"description": "Session missing, expired, or invalid"},
        409: {"description": "Reload command failed in fail2ban"},
        502: {"description": "fail2ban unreachable"},
    },
)
async def reload_fail2ban(
    _request: Request,
    _auth: AuthDep,
    socket_path: Fail2BanSocketDep,
) -> None:
    """Trigger a full fail2ban reload.

    All jails are stopped and restarted with the current configuration.

    Args:
        request: Incoming request.
        _auth: Validated session.

    Raises:
        HTTPException: 409 when fail2ban reports the reload failed.
        HTTPException: 502 when fail2ban is unreachable.
    """
    await jail_service.reload_all(socket_path)


# Restart endpoint
# ---------------------------------------------------------------------------

@router.post(
    "/restart",
    status_code=status.HTTP_204_NO_CONTENT,
    summary="Restart the fail2ban service",
    responses={
        204: {"description": "Fail2ban restarted successfully"},
        401: {"description": "Session missing, expired, or invalid"},
        409: {"description": "Stop command failed in fail2ban"},
        502: {"description": "fail2ban unreachable for stop command"},
        503: {"description": "fail2ban did not come back online within 10s"},
    },
)
async def restart_fail2ban(
    _request: Request,
    _auth: AuthDep,
    socket_path: Fail2BanSocketDep,
    start_cmd: Fail2BanStartCommandDep,
) -> None:
    """Trigger a full fail2ban service restart.

    Stops the fail2ban daemon via the Unix domain socket, then starts it
    again using the configured ``fail2ban_start_command``.  After starting,
    probes the socket for up to 10 seconds to confirm the daemon came back
    online.

    Args:
        request: Incoming request.
        _auth: Validated session.

    Raises:
        HTTPException: 409 when fail2ban reports the stop command failed.
        HTTPException: 502 when fail2ban is unreachable for the stop command.
        HTTPException: 503 when fail2ban does not come back online within
            10 seconds after being started.  Check the fail2ban log for
            initialisation errors.  Use
            ``POST /api/config/jails/{name}/rollback``
            if a specific jail is suspect.
    """
    start_cmd_parts: list[str] = shlex.split(start_cmd)

    restarted = await jail_service.restart_daemon(
        socket_path,
        start_cmd_parts,
    )

    if not restarted:
        raise OperationError(
            "fail2ban was stopped but did not come back "
            "online within 10 seconds. "
            "Check the fail2ban log for initialisation errors. "
            "Use POST /api/config/jails/{name}/rollback if a "
            "specific jail is suspect."
        )
    log.info("fail2ban_restarted")


# ---------------------------------------------------------------------------
# Regex tester (stateless)
# ---------------------------------------------------------------------------

@router.post(
    "/regex-test",
    response_model=RegexTestResponse,
    summary="Test a fail regex pattern against a sample log line",
    responses={
        200: {"description": "Regex test result", "model": RegexTestResponse},
        401: {"description": "Session missing, expired, or invalid"},
        422: {"description": "Invalid regex pattern"},
    },
)
async def regex_test(
    _auth: AuthDep,
    body: RegexTestRequest,
) -> RegexTestResponse:
    """Test whether a regex pattern matches a given log line.

    This endpoint is entirely in-process — no fail2ban socket call is made.
    Returns the match result and any captured groups.

    Args:
        _auth: Validated session.
        body: Sample log line and regex pattern.

    Returns:
        :class:`~app.models.config.RegexTestResponse` with match result and
        groups.
    """
    return log_service.test_regex(body)


# ---------------------------------------------------------------------------
# Log path management
# ---------------------------------------------------------------------------

@router.post(
    "/preview-log",
    response_model=LogPreviewResponse,
    summary="Preview log file lines against a regex pattern",
    responses={
        200: {"description": "Log preview result", "model": LogPreviewResponse},
        401: {"description": "Session missing, expired, or invalid"},
        422: {"description": "Invalid regex pattern"},
    },
)
async def preview_log(
    _auth: AuthDep,
    body: LogPreviewRequest,
) -> LogPreviewResponse:
    """Read the last N lines of a log file and test a regex against each one.

    Returns each line with a flag indicating whether the regex matched, and
    the captured groups for matching lines.  The log file is read from the
    server's local filesystem.

    Args:
        _auth: Validated session.
        body: Log file path, regex pattern, and number of lines to read.

    Returns:
        :class:`~app.models.config.LogPreviewResponse` with per-line results.
    """
    return await log_service.preview_log(body)


# ---------------------------------------------------------------------------
# Map color thresholds
# ---------------------------------------------------------------------------

@router.get(
    "/map-color-thresholds",
    response_model=MapColorThresholdsResponse,
    summary="Get map color threshold configuration",
    responses={
        200: {"description": "Color thresholds returned", "model": MapColorThresholdsResponse},
        401: {"description": "Session missing, expired, or invalid"},
    },
)
async def get_map_color_thresholds(
    _request: Request,
    _auth: AuthDep,
    settings_ctx: SettingsServiceContextDep,
) -> MapColorThresholdsResponse:
    """Return the configured map color thresholds.

    Args:
        _request: FastAPI request object.
        _auth: Validated session.
        settings_ctx: Settings service context containing db and repository.

    Returns:
        :class:`~app.models.config.MapColorThresholdsResponse` with
        current thresholds.
    """
    return await config_service.get_map_color_thresholds(settings_ctx.db)

@router.put(
    "/map-color-thresholds",
    response_model=MapColorThresholdsResponse,
    summary="Update map color threshold configuration",
    dependencies=[Depends(_check_config_update_rate_limit)],
    responses={
        200: {"description": "Color thresholds updated", "model": MapColorThresholdsResponse},
        400: {"description": "Validation error (thresholds not properly ordered)"},
        401: {"description": "Session missing, expired, or invalid"},
        429: {"description": "Rate limit exceeded for config update operations"},
    },
)
async def update_map_color_thresholds(
    _request: Request,
    _auth: AuthDep,
    settings_ctx: SettingsServiceContextDep,
    body: MapColorThresholdsUpdate,
) -> MapColorThresholdsResponse:
    """Update the map color threshold configuration.

    Args:
        _request: FastAPI request object.
        _auth: Validated session.
        settings_ctx: Settings service context containing db and repository.
        body: New threshold values.

    Returns:
        :class:`~app.models.config.MapColorThresholdsResponse` with
        updated thresholds.

    Raises:
        HTTPException: 400 if validation fails (thresholds not
        properly ordered).
    """
    await config_service.update_map_color_thresholds(settings_ctx.db, body)
    return await config_service.get_map_color_thresholds(settings_ctx.db)


@router.get(
    "/fail2ban-log",
    response_model=Fail2BanLogResponse,
    summary="Read the tail of the fail2ban daemon log file",
    responses={
        200: {"description": "Log file lines returned", "model": Fail2BanLogResponse},
        400: {"description": "Log target not a file or path outside allowed directory"},
        401: {"description": "Session missing, expired, or invalid"},
        502: {"description": "fail2ban unreachable"},
    },
)
async def get_fail2ban_log(
    _request: Request,
    _auth: AuthDep,
    socket_path: Fail2BanSocketDep,
    lines: Annotated[
        int,
        Query(
            ge=1,
            le=2000,
            description="Number of lines to return from the tail.",
        ),
    ] = 200,
    filter_: Annotated[  # noqa: A002
        str | None,
        Query(
            alias="filter",
            description=(
                "Plain-text substring filter; "
                "only matching lines are returned."
            ),
        ),
    ] = None,
) -> Fail2BanLogResponse:
    """Return the tail of the fail2ban daemon log file.

    Queries the fail2ban socket for the current log target and log level,
    reads the last *lines* entries from the file, and optionally filters
    them by *filter*.  Only file-based log targets are supported.

    Args:
        request: Incoming request.
        _auth: Validated session — enforces authentication.
        lines: Number of tail lines to return (1–2000, default 200).
        filter: Optional plain-text substring — only matching lines returned.

    Returns:
        :class:`~app.models.config.Fail2BanLogResponse`.

    Raises:
        HTTPException: 400 when the log target is not a file or path is outside
            the allowed directory.
        HTTPException: 502 when fail2ban is unreachable.
    """
    return await log_service.read_fail2ban_log(socket_path, lines, filter_)


@router.get(
    "/service-status",
    response_model=ServiceStatusResponse,
    summary="Return fail2ban service health status with log configuration",
    responses={
        200: {"description": "Service status returned", "model": ServiceStatusResponse},
        401: {"description": "Session missing, expired, or invalid"},
        502: {"description": "fail2ban unreachable"},
    },
)
async def get_service_status(
    _request: Request,
    _auth: AuthDep,
    socket_path: Fail2BanSocketDep,
) -> ServiceStatusResponse:
    """Return fail2ban service health and current log configuration.

    Probes the fail2ban daemon to determine online/offline state, then
    augments the result with the current log level and log target values.

    Args:
        request: Incoming request.
        _auth: Validated session — enforces authentication.

    Returns:
        :class:`~app.models.config.ServiceStatusResponse`.

    Raises:
        HTTPException: 502 when fail2ban is unreachable (the service itself
            handles this gracefully and returns ``online=False``).
    """
    from app.services import health_service

    domain_result = await health_service.get_service_status(
        socket_path,
        probe_fn=health_service.probe,
    )
    return config_mappers.map_domain_service_status_to_response(domain_result)


@router.get(
    "/security-headers",
    response_model=SecurityHeadersResponse,
    summary="Return security-relevant header configuration",
    responses={
        200: {"description": "Security header names and values returned", "model": SecurityHeadersResponse},
        401: {"description": "Session missing, expired, or invalid"},
    },
)
async def get_security_headers(
    _request: Request,
    _auth: AuthDep,
) -> SecurityHeadersResponse:
    """Return the header name and value used for CSRF protection.

    This endpoint allows the frontend to discover the required CSRF header
    name and value at runtime rather than hard-coding them. The response
    is derived from the same constants used by the backend CSRF middleware,
    ensuring a single source of truth.

    Args:
        request: Incoming request.
        _auth: Validated session — enforces authentication.

    Returns:
        :class:`~app.models.config.SecurityHeadersResponse` with
        ``csrf_header_name`` and ``csrf_header_value``.
    """
    return SecurityHeadersResponse(
        csrf_header_name=CSRF_HEADER_NAME,
        csrf_header_value=CSRF_HEADER_VALUE,
    )
