from __future__ import annotations

from asyncio import AbstractEventLoop, Event
from collections.abc import Callable
from logging import getLogger
from pathlib import Path
from typing import Any

from imav.malwarelib.subsys.ainotify import Event as IEvent
from imav.malwarelib.subsys.ainotify import Inotify, Watcher

from defence360agent.utils import recurring_check

log = getLogger(__name__)

_PLUGIN_NAMES = (
    "cphulk",
    "lfd",
    "modsec",
    "ossec",
)
_DEFAULT_PATH = Path("/etc/imunify360/rules/disabled-rules")
_WAIT_DIR_TIMEOUT = 10


class _RuleParsingError(Exception):
    pass


def _parse_rule(line: str) -> tuple[str, int]:
    if ":" not in line:
        raise _RuleParsingError("Delimiter ':' is not found in rule:")
    fields = line.split(":", maxsplit=2)
    if len(fields) != 3:
        raise _RuleParsingError(
            f"Wrong amount of fields, 3 expected but {len(fields)} found:"
        )
    plugin_id = fields[0].strip().lower()
    if plugin_id not in _PLUGIN_NAMES:
        raise _RuleParsingError(f"Unknown plugin ID value '{plugin_id!s}':")
    rule_value = fields[1]
    try:
        rule_id = int(rule_value)
    except ValueError as error:
        raise _RuleParsingError(
            f"Invalid rule ID value '{rule_value!s}':"
        ) from error
    return plugin_id, rule_id


def _load_rules(path: Path) -> dict[str, set[int]]:
    if not path.is_file():
        log.debug(
            "Config '%s' with shared disabled rules is not found.",
            path,
        )
        return {}
    result = {}
    with path.open(mode="rt") as rules_file:
        for line_no, raw_line in enumerate(rules_file, start=1):
            if not (line := raw_line.strip()):
                continue
            try:
                plugin_id, rule_id = _parse_rule(line)
            except _RuleParsingError as error:
                log.warning(
                    "%s:%d: %s.",
                    path,
                    line_no,
                    str(error),
                )
            except Exception:
                log.exception("%s:%d", path, line_no)
            else:
                result.setdefault(plugin_id, set()).add(rule_id)
    return result


def get_shared_disabled_modsec_rules_ids(
    *, path: Path | None = None
) -> set[int]:
    return _load_rules(path or _DEFAULT_PATH).get("modsec", set())


def get_shared_disabled_rules_list(
    *, path: Path | None = None
) -> list[dict[str, Any]]:
    """
    Returns list of the rules, extracted from "disabled-rules" file in the
    format, like {"plugin": "modsec", "rule_id": 1234}
    """
    rules: list[dict[str, Any]] = []
    for plugin_name, plugin_rules in _load_rules(
        path or _DEFAULT_PATH
    ).items():
        rules.extend(
            {"plugin": plugin_name, "rule_id": rule_id}
            for rule_id in plugin_rules
        )
    return rules


class DisabledRulesWatcher:
    def __init__(
        self,
        loop: AbstractEventLoop,
        *,
        path: Path = None,
        on_change_cb: Callable[..., None] = None,
    ):
        self.__cb = on_change_cb
        self.__event = Event()
        self.__path = path or _DEFAULT_PATH
        self.__name = self.__path.name.encode("ascii")
        self.__rules = {}
        self.__watcher = None
        self.__task = None
        self.__start(loop)

    def __start(self, loop: AbstractEventLoop):
        if not (dir_path := self.__path.parent).is_dir():
            log.error(
                "Shared disabled rules directory '%s' does not exist.",
                dir_path,
            )
            return
        self.__rules = _load_rules(self.__path)
        self.__watcher = Watcher(loop, coro_callback=self.__on_io_notify)
        self.__watcher.watch(
            str(dir_path).encode("ascii"),
            Inotify.CLOSE_WRITE | Inotify.MOVED_TO | Inotify.DELETE,
        )
        self.__task = loop.create_task(self.__process_events())

    async def __on_io_notify(self, io_event: IEvent):
        # Squash many inotify events into one asyncio event.
        # It allows to prevent too fast rules reloading.
        if io_event.name == self.__name:
            self.__event.set()

    @recurring_check(0)
    async def __process_events(self):
        try:
            await self.__event.wait()
        finally:
            self.__event.clear()
        self.__rules = _load_rules(self.__path)
        if self.__cb is not None:
            self.__cb()

    def close(self):
        if self.__task is not None:
            self.__task.cancel()
        if self.__watcher is not None:
            self.__watcher.close()

    def match(self, plugin_id: str, rule_id: int) -> bool:
        return rule_id in self.__rules.get(plugin_id, set())

    def count(self) -> int:
        return sum(map(len, self.__rules.values()))
