"""Filter configuration management for BanGUI.

Handles parsing, validation, and lifecycle operations (create/update/delete)
for fail2ban filter configurations.
"""

from __future__ import annotations

import asyncio
import contextlib
import os
import re
import tempfile
from pathlib import Path

import structlog

from app.exceptions import (
    ConfigWriteError,
    FilterAlreadyExistsError,
    FilterInvalidRegexError,
    FilterNotFoundError,
    FilterReadonlyError,
    JailNotFoundInConfigError,
)
import app.services.jail_service as jail_service
from app.utils.config_file_utils import (
    _get_active_jail_names as _config_file_get_active_jail_names,
    _parse_jails_sync as _config_file_parse_jails_sync,
    _safe_filter_name,
    _safe_jail_name,
    set_jail_local_key_sync,
)
from app.models.config import (
    AssignFilterRequest,
    FilterConfig,
    FilterConfigUpdate,
    FilterCreateRequest,
    FilterListResponse,
    FilterUpdateRequest,
)
from app.utils import conffile_parser
from app.utils.async_utils import run_blocking

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

# ---------------------------------------------------------------------------
# Internal wrappers for shared config helpers.
# ---------------------------------------------------------------------------

def _parse_jails_sync(config_dir: Path) -> tuple[dict[str, dict[str, str]], Path]:
    return _config_file_parse_jails_sync(config_dir)


async def _get_active_jail_names(socket_path: str) -> set[str]:
    return await _config_file_get_active_jail_names(socket_path)


# ---------------------------------------------------------------------------
# Additional helper functions for this service
# ---------------------------------------------------------------------------


def _resolve_filter(raw_filter: str, jail_name: str, mode: str) -> str:
    """Resolve fail2ban variable placeholders in a filter string.

    Handles the common default ``%(__name__)s[mode=%(mode)s]`` pattern that
    fail2ban uses so the filter name displayed to the user is readable.

    Args:
        raw_filter: Raw ``filter`` value from config (may contain ``%()s``).
        jail_name: The jail's section name, used to substitute ``%(__name__)s``.
        mode: The jail's ``mode`` value, used to substitute ``%(mode)s``.

    Returns:
        Human-readable filter string.
    """
    result = raw_filter.replace("%(__name__)s", jail_name)
    result = result.replace("%(mode)s", mode)
    return result

# ---------------------------------------------------------------------------
# Internal helpers imported from the shared config helper module.
# ---------------------------------------------------------------------------


def _extract_filter_base_name(filter_raw: str) -> str:
    """Extract the base filter name from a raw fail2ban filter string.

    fail2ban jail configs may specify a filter with an optional mode suffix,
    e.g. ``sshd``, ``sshd[mode=aggressive]``, or
    ``%(__name__)s[mode=%(mode)s]``.  This function strips the ``[…]`` mode
    block and any leading/trailing whitespace to return just the file-system
    base name used to look up ``filter.d/{name}.conf``.

    Args:
        filter_raw: Raw ``filter`` value from a jail config (already
                    with ``%(__name__)s`` substituted by the caller).

    Returns:
        Base filter name, e.g. ``"sshd"``.
    """
    bracket = filter_raw.find("[")
    if bracket != -1:
        return filter_raw[:bracket].strip()
    return filter_raw.strip()


def _build_filter_to_jails_map(
    all_jails: dict[str, dict[str, str]],
    active_names: set[str],
) -> dict[str, list[str]]:
    """Return a mapping of filter base name → list of active jail names.

    Iterates over every jail whose name is in *active_names*, resolves its
    ``filter`` config key, and records the jail against the base filter name.

    Args:
        all_jails: Merged jail config dict — ``{jail_name: {key: value}}``.
        active_names: Set of jail names currently running in fail2ban.

    Returns:
        ``{filter_base_name: [jail_name, …]}``.
    """
    mapping: dict[str, list[str]] = {}
    for jail_name, settings in all_jails.items():
        if jail_name not in active_names:
            continue
        raw_filter = settings.get("filter", "")
        mode = settings.get("mode", "normal")
        resolved = _resolve_filter(raw_filter, jail_name, mode) if raw_filter else jail_name
        base = _extract_filter_base_name(resolved)
        if base:
            mapping.setdefault(base, []).append(jail_name)
    return mapping


def _parse_filters_sync(
    filter_d: Path,
) -> list[tuple[str, str, str, bool, str]]:
    """Synchronously scan ``filter.d/`` and return per-filter tuples.

    Each tuple contains:

    - ``name``          — filter base name (``"sshd"``).
    - ``filename``      — actual filename (``"sshd.conf"`` or ``"sshd.local"``).
    - ``content``       — merged file content (``conf`` overridden by ``local``).
    - ``has_local``     — whether a ``.local`` override exists alongside a ``.conf``.
    - ``source_path``   — absolute path to the primary (``conf``) source file, or
                          to the ``.local`` file for user-created (local-only) filters.

    Also discovers ``.local``-only files (user-created filters with no
    corresponding ``.conf``).  These are returned with ``has_local = False``
    and ``source_path`` pointing to the ``.local`` file itself.

    Args:
        filter_d: Path to the ``filter.d`` directory.

    Returns:
        List of ``(name, filename, content, has_local, source_path)`` tuples,
        sorted by name.
    """
    if not filter_d.is_dir():
        log.warning("filter_d_not_found", path=str(filter_d))
        return []

    conf_names: set[str] = set()
    results: list[tuple[str, str, str, bool, str]] = []

    # ---- .conf-based filters (with optional .local override) ----------------
    for conf_path in sorted(filter_d.glob("*.conf")):
        if not conf_path.is_file():
            continue
        name = conf_path.stem
        filename = conf_path.name
        conf_names.add(name)
        local_path = conf_path.with_suffix(".local")
        has_local = local_path.is_file()

        try:
            content = conf_path.read_text(encoding="utf-8")
        except OSError as exc:
            log.warning("filter_read_error", name=name, path=str(conf_path), error=str(exc))
            continue

        if has_local:
            try:
                local_content = local_path.read_text(encoding="utf-8")
                # Append local content after conf so configparser reads local
                # values last (higher priority).
                content = content + "\n" + local_content
            except OSError as exc:
                log.warning(
                    "filter_local_read_error",
                    name=name,
                    path=str(local_path),
                    error=str(exc),
                )

        results.append((name, filename, content, has_local, str(conf_path)))

    # ---- .local-only filters (user-created, no corresponding .conf) ----------
    for local_path in sorted(filter_d.glob("*.local")):
        if not local_path.is_file():
            continue
        name = local_path.stem
        if name in conf_names:
            # Already covered above as a .conf filter with a .local override.
            continue
        try:
            content = local_path.read_text(encoding="utf-8")
        except OSError as exc:
            log.warning(
                "filter_local_read_error",
                name=name,
                path=str(local_path),
                error=str(exc),
            )
            continue
        results.append((name, local_path.name, content, False, str(local_path)))

    results.sort(key=lambda t: t[0])
    log.debug("filters_scanned", count=len(results), filter_d=str(filter_d))
    return results


def _validate_regex_patterns(patterns: list[str]) -> None:
    """Validate each pattern in *patterns* using Python's ``re`` module.

    Args:
        patterns: List of regex strings to validate.

    Raises:
        FilterInvalidRegexError: If any pattern fails to compile.
    """
    for pattern in patterns:
        try:
            re.compile(pattern)
        except re.error as exc:
            raise FilterInvalidRegexError(pattern, str(exc)) from exc


def _write_filter_local_sync(filter_d: Path, name: str, content: str) -> None:
    """Write *content* to ``filter.d/{name}.local`` atomically.

    The write is atomic: content is written to a temp file first, then
    renamed into place.  The ``filter.d/`` directory is created if absent.

    Args:
        filter_d: Path to the ``filter.d`` directory.
        name: Validated filter base name (used as filename stem).
        content: Full serialized filter content to write.

    Raises:
        ConfigWriteError: If writing fails.
    """
    try:
        filter_d.mkdir(parents=True, exist_ok=True)
    except OSError as exc:
        raise ConfigWriteError(f"Cannot create filter.d directory: {exc}") from exc

    local_path = filter_d / f"{name}.local"
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=filter_d,
            delete=False,
            suffix=".tmp",
        ) as tmp:
            tmp.write(content)
            tmp_name = tmp.name
        os.replace(tmp_name, local_path)
    except OSError as exc:
        with contextlib.suppress(OSError):
            os.unlink(tmp_name)  # noqa: F821
        raise ConfigWriteError(f"Failed to write {local_path}: {exc}") from exc

    log.info("filter_local_written", filter=name, path=str(local_path))


# ---------------------------------------------------------------------------
# Public API — filter discovery
# ---------------------------------------------------------------------------


async def list_filters(
    config_dir: str,
    socket_path: str,
) -> FilterListResponse:
    """Return all available filters from ``filter.d/`` with active/inactive status.

    Scans ``{config_dir}/filter.d/`` for ``.conf`` files, merges any
    corresponding ``.local`` overrides, parses each file into a
    :class:`~app.models.config.FilterConfig`, and cross-references with the
    currently running jails to determine which filters are active.

    A filter is considered *active* when its base name matches the ``filter``
    field of at least one currently running jail.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        socket_path: Path to the fail2ban Unix domain socket.

    Returns:
        :class:`~app.models.config.FilterListResponse` with all filters
        sorted alphabetically, active ones carrying non-empty
        ``used_by_jails`` lists.
    """
    filter_d = Path(config_dir) / "filter.d"

    # Run the synchronous scan in a thread-pool executor.
    raw_filters: list[tuple[str, str, str, bool, str]] = await run_blocking(_parse_filters_sync, filter_d)

    # Fetch active jail names and their configs concurrently.
    all_jails_result, active_names = await asyncio.gather(
        run_blocking(_parse_jails_sync, Path(config_dir)),
        _get_active_jail_names(socket_path),
    )
    all_jails, _source_files = all_jails_result

    filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)

    filters: list[FilterConfig] = []
    for name, filename, content, has_local, source_path in raw_filters:
        cfg = conffile_parser.parse_filter_file(content, name=name, filename=filename)
        used_by = sorted(filter_to_jails.get(name, []))
        filters.append(
            FilterConfig(
                name=cfg.name,
                filename=cfg.filename,
                before=cfg.before,
                after=cfg.after,
                variables=cfg.variables,
                prefregex=cfg.prefregex,
                failregex=cfg.failregex,
                ignoreregex=cfg.ignoreregex,
                maxlines=cfg.maxlines,
                datepattern=cfg.datepattern,
                journalmatch=cfg.journalmatch,
                active=len(used_by) > 0,
                used_by_jails=used_by,
                source_file=source_path,
                has_local_override=has_local,
            )
        )

    log.info("filters_listed", total=len(filters), active=sum(1 for f in filters if f.active))
    return FilterListResponse(filters=filters, total=len(filters))


async def get_filter(
    config_dir: str,
    socket_path: str,
    name: str,
) -> FilterConfig:
    """Return a single filter from ``filter.d/`` with active/inactive status.

    Reads ``{config_dir}/filter.d/{name}.conf``, merges any ``.local``
    override, and enriches the parsed :class:`~app.models.config.FilterConfig`
    with ``active``, ``used_by_jails``, ``source_file``, and
    ``has_local_override``.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        socket_path: Path to the fail2ban Unix domain socket.
        name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``).

    Returns:
        :class:`~app.models.config.FilterConfig` with status fields populated.

    Raises:
        FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
            exists in ``filter.d/``.
    """
    # Normalise — strip extension if provided (.conf=5 chars, .local=6 chars).
    if name.endswith(".conf"):
        base_name = name[:-5]
    elif name.endswith(".local"):
        base_name = name[:-6]
    else:
        base_name = name

    filter_d = Path(config_dir) / "filter.d"
    conf_path = filter_d / f"{base_name}.conf"
    local_path = filter_d / f"{base_name}.local"

    def _read() -> tuple[str, bool, str]:
        """Read filter content and return (content, has_local_override, source_path)."""
        has_local = local_path.is_file()
        if conf_path.is_file():
            content = conf_path.read_text(encoding="utf-8")
            if has_local:
                try:
                    content += "\n" + local_path.read_text(encoding="utf-8")
                except OSError as exc:
                    log.warning(
                        "filter_local_read_error",
                        name=base_name,
                        path=str(local_path),
                        error=str(exc),
                    )
            return content, has_local, str(conf_path)
        elif has_local:
            # Local-only filter: created by the user, no shipped .conf base.
            content = local_path.read_text(encoding="utf-8")
            return content, False, str(local_path)
        else:
            raise FilterNotFoundError(base_name)

    content, has_local, source_path = await run_blocking( _read)

    cfg = conffile_parser.parse_filter_file(content, name=base_name, filename=f"{base_name}.conf")

    all_jails_result, active_names = await asyncio.gather(
        run_blocking(_parse_jails_sync, Path(config_dir)),
        _get_active_jail_names(socket_path),
    )
    all_jails, _source_files = all_jails_result
    filter_to_jails = _build_filter_to_jails_map(all_jails, active_names)

    used_by = sorted(filter_to_jails.get(base_name, []))
    log.info("filter_fetched", name=base_name, active=len(used_by) > 0)
    return FilterConfig(
        name=cfg.name,
        filename=cfg.filename,
        before=cfg.before,
        after=cfg.after,
        variables=cfg.variables,
        prefregex=cfg.prefregex,
        failregex=cfg.failregex,
        ignoreregex=cfg.ignoreregex,
        maxlines=cfg.maxlines,
        datepattern=cfg.datepattern,
        journalmatch=cfg.journalmatch,
        active=len(used_by) > 0,
        used_by_jails=used_by,
        source_file=source_path,
        has_local_override=has_local,
    )


# ---------------------------------------------------------------------------
# Public API — filter write operations
# ---------------------------------------------------------------------------


async def update_filter(
    config_dir: str,
    socket_path: str,
    name: str,
    req: FilterUpdateRequest,
    do_reload: bool = False,
) -> FilterConfig:
    """Update a filter's ``.local`` override with new regex/pattern values.

    Reads the current merged configuration for *name* (``conf`` + any existing
    ``local``), applies the non-``None`` fields in *req* on top of it, and
    writes the resulting definition to ``filter.d/{name}.local``.  The
    original ``.conf`` file is never modified.

    All regex patterns in *req* are validated with Python's ``re`` module
    before any write occurs.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        socket_path: Path to the fail2ban Unix domain socket.
        name: Filter base name (e.g. ``"sshd"`` or ``"sshd.conf"``).
        req: Partial update — only non-``None`` fields are applied.
        do_reload: When ``True``, trigger a full fail2ban reload after writing.

    Returns:
        :class:`~app.models.config.FilterConfig` reflecting the updated state.

    Raises:
        FilterNameError: If *name* contains invalid characters.
        FilterNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
        FilterInvalidRegexError: If any supplied regex pattern is invalid.
        ConfigWriteError: If writing the ``.local`` file fails.
    """
    base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
    _safe_filter_name(base_name)

    # Validate regex patterns before touching the filesystem.
    patterns: list[str] = []
    if req.failregex is not None:
        patterns.extend(req.failregex)
    if req.ignoreregex is not None:
        patterns.extend(req.ignoreregex)
    _validate_regex_patterns(patterns)

    # Fetch the current merged config (raises FilterNotFoundError if absent).
    current = await get_filter(config_dir, socket_path, base_name)

    # Build a FilterConfigUpdate from the request fields.
    update = FilterConfigUpdate(
        failregex=req.failregex,
        ignoreregex=req.ignoreregex,
        datepattern=req.datepattern,
        journalmatch=req.journalmatch,
    )

    merged = conffile_parser.merge_filter_update(current, update)
    content = conffile_parser.serialize_filter_config(merged)

    filter_d = Path(config_dir) / "filter.d"
    await run_blocking( _write_filter_local_sync, filter_d, base_name, content)

    if do_reload:
        try:
            await jail_service.reload_all(socket_path)
        except Exception as exc:  # noqa: BLE001
            log.warning(
                "reload_after_filter_update_failed",
                filter=base_name,
                error=str(exc),
            )

    log.info("filter_updated", filter=base_name, reload=do_reload)
    return await get_filter(config_dir, socket_path, base_name)


async def create_filter(
    config_dir: str,
    socket_path: str,
    req: FilterCreateRequest,
    do_reload: bool = False,
) -> FilterConfig:
    """Create a brand-new user-defined filter in ``filter.d/{name}.local``.

    No ``.conf`` is written; fail2ban loads ``.local`` files directly.  If a
    ``.conf`` or ``.local`` file already exists for the requested name, a
    :class:`FilterAlreadyExistsError` is raised.

    All regex patterns are validated with Python's ``re`` module before
    writing.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        socket_path: Path to the fail2ban Unix domain socket.
        req: Filter name and definition fields.
        do_reload: When ``True``, trigger a full fail2ban reload after writing.

    Returns:
        :class:`~app.models.config.FilterConfig` for the newly created filter.

    Raises:
        FilterNameError: If ``req.name`` contains invalid characters.
        FilterAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
        FilterInvalidRegexError: If any regex pattern is invalid.
        ConfigWriteError: If writing fails.
    """
    _safe_filter_name(req.name)

    filter_d = Path(config_dir) / "filter.d"
    conf_path = filter_d / f"{req.name}.conf"
    local_path = filter_d / f"{req.name}.local"

    def _check_not_exists() -> None:
        if conf_path.is_file() or local_path.is_file():
            raise FilterAlreadyExistsError(req.name)

    await run_blocking( _check_not_exists)

    # Validate regex patterns.
    patterns: list[str] = list(req.failregex) + list(req.ignoreregex)
    _validate_regex_patterns(patterns)

    # Build a FilterConfig and serialise it.
    cfg = FilterConfig(
        name=req.name,
        filename=f"{req.name}.local",
        failregex=req.failregex,
        ignoreregex=req.ignoreregex,
        prefregex=req.prefregex,
        datepattern=req.datepattern,
        journalmatch=req.journalmatch,
    )
    content = conffile_parser.serialize_filter_config(cfg)

    await run_blocking( _write_filter_local_sync, filter_d, req.name, content)

    if do_reload:
        try:
            await jail_service.reload_all(socket_path)
        except Exception as exc:  # noqa: BLE001
            log.warning(
                "reload_after_filter_create_failed",
                filter=req.name,
                error=str(exc),
            )

    log.info("filter_created", filter=req.name, reload=do_reload)
    # Re-fetch to get the canonical FilterConfig (source_file, active, etc.).
    return await get_filter(config_dir, socket_path, req.name)


async def delete_filter(
    config_dir: str,
    name: str,
) -> None:
    """Delete a user-created filter's ``.local`` file.

    Deletion rules:
    - If only a ``.conf`` file exists (shipped default, no user override) →
      :class:`FilterReadonlyError`.
    - If a ``.local`` file exists (whether or not a ``.conf`` also exists) →
      the ``.local`` file is deleted.  The shipped ``.conf`` is never touched.
    - If neither file exists → :class:`FilterNotFoundError`.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        name: Filter base name (e.g. ``"sshd"``).

    Raises:
        FilterNameError: If *name* contains invalid characters.
        FilterNotFoundError: If no filter file is found for *name*.
        FilterReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
        ConfigWriteError: If deletion of the ``.local`` file fails.
    """
    base_name = name[:-5] if name.endswith(".conf") or name.endswith(".local") else name
    _safe_filter_name(base_name)

    filter_d = Path(config_dir) / "filter.d"
    conf_path = filter_d / f"{base_name}.conf"
    local_path = filter_d / f"{base_name}.local"

    def _delete() -> None:
        has_conf = conf_path.is_file()
        has_local = local_path.is_file()

        if not has_conf and not has_local:
            raise FilterNotFoundError(base_name)

        if has_conf and not has_local:
            # Shipped default — nothing user-writable to remove.
            raise FilterReadonlyError(base_name)

        try:
            local_path.unlink()
        except OSError as exc:
            raise ConfigWriteError(f"Failed to delete {local_path}: {exc}") from exc

        log.info("filter_local_deleted", filter=base_name, path=str(local_path))

    await run_blocking( _delete)


async def assign_filter_to_jail(
    config_dir: str,
    socket_path: str,
    jail_name: str,
    req: AssignFilterRequest,
    do_reload: bool = False,
) -> None:
    """Assign a filter to a jail by updating the jail's ``.local`` file.

    Writes ``filter = {req.filter_name}`` into the ``[{jail_name}]`` section
    of ``jail.d/{jail_name}.local``.  If the ``.local`` file already contains
    other settings for this jail they are preserved.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        socket_path: Path to the fail2ban Unix domain socket.
        jail_name: Name of the jail to update.
        req: Request containing the filter name to assign.
        do_reload: When ``True``, trigger a full fail2ban reload after writing.

    Raises:
        JailNameError: If *jail_name* contains invalid characters.
        FilterNameError: If ``req.filter_name`` contains invalid characters.
        JailNotFoundError: If *jail_name* is not defined in any config file.
        FilterNotFoundError: If ``req.filter_name`` does not exist in
            ``filter.d/``.
        ConfigWriteError: If writing fails.
    """
    _safe_jail_name(jail_name)
    _safe_filter_name(req.filter_name)

    _safe_jail_name(jail_name)

    # Verify the jail exists in config.
    all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir))
    if jail_name not in all_jails:
        raise JailNotFoundInConfigError(jail_name)

    # Verify the filter exists (conf or local).
    filter_d = Path(config_dir) / "filter.d"

    def _check_filter() -> None:
        conf_exists = (filter_d / f"{req.filter_name}.conf").is_file()
        local_exists = (filter_d / f"{req.filter_name}.local").is_file()
        if not conf_exists and not local_exists:
            raise FilterNotFoundError(req.filter_name)

    await run_blocking( _check_filter)

    await run_blocking(set_jail_local_key_sync,
        Path(config_dir),
        jail_name,
        "filter",
        req.filter_name,
    )

    if do_reload:
        try:
            await jail_service.reload_all(socket_path)
        except Exception as exc:  # noqa: BLE001
            log.warning(
                "reload_after_assign_filter_failed",
                jail=jail_name,
                filter=req.filter_name,
                error=str(exc),
            )

    log.info(
        "filter_assigned_to_jail",
        jail=jail_name,
        filter=req.filter_name,
        reload=do_reload,
    )
