"""Action configuration management for BanGUI.

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

from __future__ import annotations

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

import structlog

from app.exceptions import (
    ActionAlreadyExistsError,
    ActionNameError,
    ActionNotFoundError,
    ActionReadonlyError,
    ConfigWriteError,
    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_jail_name,
    build_parser,
)
from app.models.config import (
    ActionConfig,
    ActionConfigUpdate,
    ActionCreateRequest,
    ActionListResponse,
    ActionUpdateRequest,
    AssignActionRequest,
)
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)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

_SOCKET_TIMEOUT: float = 10.0

# Allowlist pattern for action names used in path construction.
_SAFE_ACTION_NAME_RE: re.Pattern[str] = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")

# Sections that are not jail definitions.
_META_SECTIONS: frozenset[str] = frozenset({"INCLUDES", "DEFAULT"})


# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------



# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------


def _safe_action_name(name: str) -> str:
    """Validate *name* and return it unchanged or raise :class:`ActionNameError`.

    Args:
        name: Proposed action name (without extension).

    Returns:
        The name unchanged if valid.

    Raises:
        ActionNameError: If *name* contains unsafe characters.
    """
    if not _SAFE_ACTION_NAME_RE.match(name):
        raise ActionNameError(
            f"Action name {name!r} contains invalid characters. "
            "Only alphanumeric characters, hyphens, underscores, and dots are "
            "allowed; must start with an alphanumeric character."
        )
    return name


def _extract_action_base_name(action_str: str) -> str | None:
    """Return the base action name from an action assignment string.

    Returns ``None`` for complex fail2ban expressions that cannot be resolved
    to a single filename (e.g. ``%(action_)s`` interpolations or multi-token
    composite actions).

    Args:
        action_str: A single line from the jail's ``action`` setting.

    Returns:
        Simple base name suitable for a filesystem lookup, or ``None``.
    """
    if "%" in action_str or "$" in action_str:
        return None
    base = action_str.split("[")[0].strip()
    if _SAFE_ACTION_NAME_RE.match(base):
        return base
    return None


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

    Iterates over every jail whose name is in *active_names*, resolves each
    entry in its ``action`` config key to an action base name (stripping
    ``[…]`` parameter blocks), and records the jail against each base name.

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

    Returns:
        ``{action_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_action = settings.get("action", "")
        if not raw_action:
            continue
        for line in raw_action.splitlines():
            stripped = line.strip()
            if not stripped or stripped.startswith("#"):
                continue
            # Strip optional [key=value] parameter block to get the base name.
            bracket = stripped.find("[")
            base = stripped[:bracket].strip() if bracket != -1 else stripped
            if base:
                mapping.setdefault(base, []).append(jail_name)
    return mapping


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

    Each tuple contains:

    - ``name``        — action base name (``"iptables"``).
    - ``filename``    — actual filename (``"iptables.conf"``).
    - ``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) actions.

    Also discovers ``.local``-only files (user-created actions with no
    corresponding ``.conf``).

    Args:
        action_d: Path to the ``action.d`` directory.

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

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

    # ---- .conf-based actions (with optional .local override) ----------------
    for conf_path in sorted(action_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("action_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")
                content = content + "\n" + local_content
            except OSError as exc:
                log.warning(
                    "action_local_read_error",
                    name=name,
                    path=str(local_path),
                    error=str(exc),
                )

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

    # ---- .local-only actions (user-created, no corresponding .conf) ----------
    for local_path in sorted(action_d.glob("*.local")):
        if not local_path.is_file():
            continue
        name = local_path.stem
        if name in conf_names:
            continue
        try:
            content = local_path.read_text(encoding="utf-8")
        except OSError as exc:
            log.warning(
                "action_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("actions_scanned", count=len(results), action_d=str(action_d))
    return results


def _append_jail_action_sync(
    config_dir: Path,
    jail_name: str,
    action_entry: str,
) -> None:
    """Append an action entry to the ``action`` key in ``jail.d/{jail_name}.local``.

    If the ``.local`` file already contains an ``action`` key under the jail
    section, the new entry is appended as an additional line (multi-line
    configparser format) unless it is already present.  If no ``action`` key
    exists, one is created.

    Args:
        config_dir: The fail2ban configuration root directory.
        jail_name: Validated jail name.
        action_entry: Full action string including any ``[…]`` parameters.

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

    local_path = jail_d / f"{jail_name}.local"

    parser = build_parser()
    if local_path.is_file():
        try:
            parser.read(str(local_path), encoding="utf-8")
        except (configparser.Error, OSError) as exc:
            log.warning(
                "jail_local_read_for_update_error",
                jail=jail_name,
                error=str(exc),
            )

    if not parser.has_section(jail_name):
        parser.add_section(jail_name)

    existing_raw = parser.get(jail_name, "action") if parser.has_option(jail_name, "action") else ""
    existing_lines = [
        line.strip() for line in existing_raw.splitlines() if line.strip() and not line.strip().startswith("#")
    ]

    # Extract base names from existing entries for duplicate checking.
    def _base(entry: str) -> str:
        bracket = entry.find("[")
        return entry[:bracket].strip() if bracket != -1 else entry.strip()

    new_base = _base(action_entry)
    if not any(_base(e) == new_base for e in existing_lines):
        existing_lines.append(action_entry)

    if existing_lines:
        # configparser multi-line: continuation lines start with whitespace.
        new_value = existing_lines[0] + "".join(f"\n           {line}" for line in existing_lines[1:])
        parser.set(jail_name, "action", new_value)
    else:
        parser.set(jail_name, "action", action_entry)

    buf = io.StringIO()
    buf.write("# Managed by BanGUI — do not edit manually\n\n")
    parser.write(buf)
    content = buf.getvalue()

    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=jail_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(
        "jail_action_appended",
        jail=jail_name,
        action=action_entry,
        path=str(local_path),
    )


def _remove_jail_action_sync(
    config_dir: Path,
    jail_name: str,
    action_name: str,
) -> None:
    """Remove an action entry from the ``action`` key in ``jail.d/{jail_name}.local``.

    Reads the ``.local`` file, removes any ``action`` entries whose base name
    matches *action_name*, and writes the result back atomically.  If no
    ``.local`` file exists, this is a no-op.

    Args:
        config_dir: The fail2ban configuration root directory.
        jail_name: Validated jail name.
        action_name: Base name of the action to remove (without ``[…]``).

    Raises:
        ConfigWriteError: If writing fails.
    """
    jail_d = config_dir / "jail.d"
    local_path = jail_d / f"{jail_name}.local"

    if not local_path.is_file():
        return

    parser = build_parser()
    try:
        parser.read(str(local_path), encoding="utf-8")
    except (configparser.Error, OSError) as exc:
        log.warning(
            "jail_local_read_for_update_error",
            jail=jail_name,
            error=str(exc),
        )
        return

    if not parser.has_section(jail_name) or not parser.has_option(jail_name, "action"):
        return

    existing_raw = parser.get(jail_name, "action")
    existing_lines = [
        line.strip() for line in existing_raw.splitlines() if line.strip() and not line.strip().startswith("#")
    ]

    def _base(entry: str) -> str:
        bracket = entry.find("[")
        return entry[:bracket].strip() if bracket != -1 else entry.strip()

    filtered = [e for e in existing_lines if _base(e) != action_name]

    if len(filtered) == len(existing_lines):
        # Action was not found — silently return (idempotent).
        return

    if filtered:
        new_value = filtered[0] + "".join(f"\n           {line}" for line in filtered[1:])
        parser.set(jail_name, "action", new_value)
    else:
        parser.remove_option(jail_name, "action")

    buf = io.StringIO()
    buf.write("# Managed by BanGUI — do not edit manually\n\n")
    parser.write(buf)
    content = buf.getvalue()

    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=jail_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(
        "jail_action_removed",
        jail=jail_name,
        action=action_name,
        path=str(local_path),
    )


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

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

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

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

    local_path = action_d / f"{name}.local"
    try:
        with tempfile.NamedTemporaryFile(
            mode="w",
            encoding="utf-8",
            dir=action_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("action_local_written", action=name, path=str(local_path))


# ---------------------------------------------------------------------------
# Public API — action discovery
# ---------------------------------------------------------------------------


async def list_actions(
    config_dir: str,
    socket_path: str,
) -> ActionListResponse:
    """Return all available actions from ``action.d/`` with active/inactive status.

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

    An action is considered *active* when its base name appears in the
    ``action`` 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.ActionListResponse` with all actions
        sorted alphabetically, active ones carrying non-empty
        ``used_by_jails`` lists.
    """
    action_d = Path(config_dir) / "action.d"

    raw_actions: list[tuple[str, str, str, bool, str]] = await run_blocking(_parse_actions_sync, action_d)

    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

    action_to_jails = _build_action_to_jails_map(all_jails, active_names)

    actions: list[ActionConfig] = []
    for name, filename, content, has_local, source_path in raw_actions:
        cfg = conffile_parser.parse_action_file(content, name=name, filename=filename)
        used_by = sorted(action_to_jails.get(name, []))
        actions.append(
            ActionConfig(
                name=cfg.name,
                filename=cfg.filename,
                before=cfg.before,
                after=cfg.after,
                actionstart=cfg.actionstart,
                actionstop=cfg.actionstop,
                actioncheck=cfg.actioncheck,
                actionban=cfg.actionban,
                actionunban=cfg.actionunban,
                actionflush=cfg.actionflush,
                definition_vars=cfg.definition_vars,
                init_vars=cfg.init_vars,
                active=len(used_by) > 0,
                used_by_jails=used_by,
                source_file=source_path,
                has_local_override=has_local,
            )
        )

    log.info("actions_listed", total=len(actions), active=sum(1 for a in actions if a.active))
    return ActionListResponse(actions=actions, total=len(actions))


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

    Reads ``{config_dir}/action.d/{name}.conf``, merges any ``.local``
    override, and enriches the parsed :class:`~app.models.config.ActionConfig`
    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: Action base name (e.g. ``"iptables"`` or ``"iptables.conf"``).

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

    Raises:
        ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` file
            exists in ``action.d/``.
    """
    if name.endswith(".conf"):
        base_name = name[:-5]
    elif name.endswith(".local"):
        base_name = name[:-6]
    else:
        base_name = name

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

    def _read() -> tuple[str, bool, str]:
        """Read action 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(
                        "action_local_read_error",
                        name=base_name,
                        path=str(local_path),
                        error=str(exc),
                    )
            return content, has_local, str(conf_path)
        elif has_local:
            content = local_path.read_text(encoding="utf-8")
            return content, False, str(local_path)
        else:
            raise ActionNotFoundError(base_name)

    content, has_local, source_path = await run_blocking(_read)

    cfg = conffile_parser.parse_action_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
    action_to_jails = _build_action_to_jails_map(all_jails, active_names)

    used_by = sorted(action_to_jails.get(base_name, []))
    log.info("action_fetched", name=base_name, active=len(used_by) > 0)
    return ActionConfig(
        name=cfg.name,
        filename=cfg.filename,
        before=cfg.before,
        after=cfg.after,
        actionstart=cfg.actionstart,
        actionstop=cfg.actionstop,
        actioncheck=cfg.actioncheck,
        actionban=cfg.actionban,
        actionunban=cfg.actionunban,
        actionflush=cfg.actionflush,
        definition_vars=cfg.definition_vars,
        init_vars=cfg.init_vars,
        active=len(used_by) > 0,
        used_by_jails=used_by,
        source_file=source_path,
        has_local_override=has_local,
    )


# ---------------------------------------------------------------------------
# Public API — action write operations
# ---------------------------------------------------------------------------


async def update_action(
    config_dir: str,
    socket_path: str,
    name: str,
    req: ActionUpdateRequest,
    do_reload: bool = False,
) -> ActionConfig:
    """Update an action's ``.local`` override with new lifecycle command 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 ``action.d/{name}.local``.  The
    original ``.conf`` file is never modified.

    Args:
        config_dir: Absolute path to the fail2ban configuration directory.
        socket_path: Path to the fail2ban Unix domain socket.
        name: Action base name (e.g. ``"iptables"`` or ``"iptables.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.ActionConfig` reflecting the updated state.

    Raises:
        ActionNameError: If *name* contains invalid characters.
        ActionNotFoundError: If no ``{name}.conf`` or ``{name}.local`` exists.
        ConfigWriteError: If writing the ``.local`` file fails.
    """
    base_name = name[:-5] if name.endswith((".conf", ".local")) else name
    _safe_action_name(base_name)

    current = await get_action(config_dir, socket_path, base_name)

    update = ActionConfigUpdate(
        actionstart=req.actionstart,
        actionstop=req.actionstop,
        actioncheck=req.actioncheck,
        actionban=req.actionban,
        actionunban=req.actionunban,
        actionflush=req.actionflush,
        definition_vars=req.definition_vars,
        init_vars=req.init_vars,
    )

    merged = conffile_parser.merge_action_update(current, update)
    content = conffile_parser.serialize_action_config(merged)

    action_d = Path(config_dir) / "action.d"
    await run_blocking( _write_action_local_sync, action_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_action_update_failed",
                action=base_name,
                error=str(exc),
            )

    log.info("action_updated", action=base_name, reload=do_reload)
    return await get_action(config_dir, socket_path, base_name)


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

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

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

    Returns:
        :class:`~app.models.config.ActionConfig` for the newly created action.

    Raises:
        ActionNameError: If ``req.name`` contains invalid characters.
        ActionAlreadyExistsError: If a ``.conf`` or ``.local`` already exists.
        ConfigWriteError: If writing fails.
    """
    _safe_action_name(req.name)

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

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

    await run_blocking( _check_not_exists)

    cfg = ActionConfig(
        name=req.name,
        filename=f"{req.name}.local",
        actionstart=req.actionstart,
        actionstop=req.actionstop,
        actioncheck=req.actioncheck,
        actionban=req.actionban,
        actionunban=req.actionunban,
        actionflush=req.actionflush,
        definition_vars=req.definition_vars,
        init_vars=req.init_vars,
    )
    content = conffile_parser.serialize_action_config(cfg)

    await run_blocking( _write_action_local_sync, action_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_action_create_failed",
                action=req.name,
                error=str(exc),
            )

    log.info("action_created", action=req.name, reload=do_reload)
    return await get_action(config_dir, socket_path, req.name)


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

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

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

    Raises:
        ActionNameError: If *name* contains invalid characters.
        ActionNotFoundError: If no action file is found for *name*.
        ActionReadonlyError: If only a shipped ``.conf`` exists (no ``.local``).
        ConfigWriteError: If deletion of the ``.local`` file fails.
    """
    base_name = name[:-5] if name.endswith((".conf", ".local")) else name
    _safe_action_name(base_name)

    action_d = Path(config_dir) / "action.d"
    conf_path = action_d / f"{base_name}.conf"
    local_path = action_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 ActionNotFoundError(base_name)

        if has_conf and not has_local:
            raise ActionReadonlyError(base_name)

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

        log.info("action_local_deleted", action=base_name, path=str(local_path))

    await run_blocking( _delete)


async def assign_action_to_jail(
    config_dir: str,
    socket_path: str,
    jail_name: str,
    req: AssignActionRequest,
    do_reload: bool = False,
) -> None:
    """Add an action to a jail by updating the jail's ``.local`` file.

    Appends ``{req.action_name}[{params}]`` (or just ``{req.action_name}`` when
    no params are given) to the ``action`` key in the ``[{jail_name}]`` section
    of ``jail.d/{jail_name}.local``.  If the action is already listed it is not
    duplicated.  If the ``.local`` file does not exist it is created.

    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 action name and optional parameters.
        do_reload: When ``True``, trigger a full fail2ban reload after writing.

    Raises:
        JailNameError: If *jail_name* contains invalid characters.
        ActionNameError: If ``req.action_name`` contains invalid characters.
        JailNotFoundError: If *jail_name* is not defined in any config file.
        ActionNotFoundError: If ``req.action_name`` does not exist in
            ``action.d/``.
        ConfigWriteError: If writing fails.
    """
    _safe_jail_name(jail_name)
    _safe_action_name(req.action_name)

    all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir))
    if jail_name not in all_jails:
        raise JailNotFoundInConfigError(jail_name)

    action_d = Path(config_dir) / "action.d"

    def _check_action() -> None:
        if (
            not (action_d / f"{req.action_name}.conf").is_file()
            and not (action_d / f"{req.action_name}.local").is_file()
        ):
            raise ActionNotFoundError(req.action_name)

    await run_blocking( _check_action)

    # Build the action string with optional parameters.
    if req.params:
        param_str = ", ".join(f"{k}={v}" for k, v in sorted(req.params.items()))
        action_entry = f"{req.action_name}[{param_str}]"
    else:
        action_entry = req.action_name

    await run_blocking(_append_jail_action_sync,
        Path(config_dir),
        jail_name,
        action_entry,
    )

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

    log.info(
        "action_assigned_to_jail",
        jail=jail_name,
        action=req.action_name,
        reload=do_reload,
    )


async def remove_action_from_jail(
    config_dir: str,
    socket_path: str,
    jail_name: str,
    action_name: str,
    do_reload: bool = False,
) -> None:
    """Remove an action from a jail's ``.local`` config.

    Reads ``jail.d/{jail_name}.local``, removes the line(s) that reference
    ``{action_name}`` from the ``action`` key (including any ``[…]`` parameter
    blocks), and writes the file back atomically.

    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.
        action_name: Base name of the action to remove.
        do_reload: When ``True``, trigger a full fail2ban reload after writing.

    Raises:
        JailNameError: If *jail_name* contains invalid characters.
        ActionNameError: If *action_name* contains invalid characters.
        JailNotFoundError: If *jail_name* is not defined in any config.
        ConfigWriteError: If writing fails.
    """
    _safe_jail_name(jail_name)
    _safe_action_name(action_name)

    all_jails, _src = await run_blocking(_parse_jails_sync, Path(config_dir))
    if jail_name not in all_jails:
        raise JailNotFoundInConfigError(jail_name)

    await run_blocking(_remove_jail_action_sync,
        Path(config_dir),
        jail_name,
        action_name,
    )

    if do_reload:
        try:
            await jail_service.reload_all(socket_path)
        except Exception as exc:  # noqa: BLE001
            log.warning(
                "reload_after_remove_action_failed",
                jail=jail_name,
                action=action_name,
                error=str(exc),
            )

    log.info(
        "action_removed_from_jail",
        jail=jail_name,
        action=action_name,
        reload=do_reload,
    )
