import asyncio
import functools
import logging
import time

from peewee import DoesNotExist, IntegrityError

from defence360agent.contracts.messages import MessageType
from defence360agent.model.simplification import run_in_executor
from defence360agent.rpc_tools import ValidationError, lookup
from defence360agent.rpc_tools.utils import run_in_executor_decorator
from defence360agent.subsys import web_server
from defence360agent.utils import Scope
from im360.contracts.config import Modsec, Permissions
from im360.model.incident import DisabledRule, DisabledRuleDomain
from im360.subsys import modsec_app_version_detector
from im360.subsys import shared_disabled_rules as disabled_rules
from im360.subsys import waf_rules_configurator
from im360.subsys.panels import hosting_panel
from im360.subsys.panels.generic.mod_security import (
    GenericPanelModSecException,
)

logger = logging.getLogger(__name__)


class DisabledRulesEndpoints(lookup.RootEndpoints):
    SCOPE = Scope.IM360

    def __init__(self, sink):
        super().__init__(sink)
        self.hp = hosting_panel.HostingPanel()

    @lookup.bind("rules", "disable")
    async def disable_rule(self, plugin, id, name, domains=None):
        self.__check_edit_is_enabled()
        domains = domains or []

        if domains and plugin != "modsec":
            raise ValidationError("Domains only allowed for plugin=modsec")

        # validate domain
        panel_domains = set(await self.hp.get_user_domains())
        if not set(domains).issubset(panel_domains):
            raise ValidationError(
                "Some of the provided domains do not exist: {}".format(
                    set(domains) - panel_domains,
                )
            )

        # NOTE: we can't call _store_disabled_rule after
        # _sync_modsec_configs, because we need to form a union of
        # specified domains and domains for which the specified rule is
        # already disabled. We might refactor this method in DEF-10761.
        sync_domains = await self._store_disabled_rule(
            plugin, id, name, domains
        )

        if plugin == "modsec":
            await self._sync_modsec_configs(set(sync_domains) & panel_domains)

        await self._sink.process_message(
            MessageType.RuleDisabled(
                plugin_id=plugin,
                rule=id,
                name=name,
                domains=(domains or None),
                timestamp=time.time(),
            )
        )

    async def _delete_disabled_rule(self, plugin, id):
        loop = asyncio.get_event_loop()
        await run_in_executor(
            loop,
            lambda: DisabledRule.delete()
            .where(DisabledRule.plugin == plugin, DisabledRule.rule_id == id)
            .execute(),
        )

    @lookup.bind("rules", "enable")
    async def enable_rule(self, plugin, id):
        self.__check_edit_is_enabled()
        loop = asyncio.get_event_loop()

        try:
            dr = await run_in_executor(
                loop, lambda: DisabledRule.get(plugin=plugin, rule_id=id)
            )
        except DoesNotExist:
            return

        if plugin == "modsec":
            domains = [
                d[0]
                for d in await run_in_executor(
                    loop,
                    lambda: DisabledRuleDomain.select(
                        DisabledRuleDomain.domain
                    )
                    .where(DisabledRuleDomain.disabled_rule_id_id == dr.id)
                    .tuples(),
                )
            ]
            await self._delete_disabled_rule(plugin, id)
            panel_domains = set(await self.hp.get_user_domains())
            await self._sync_modsec_configs(set(domains) & panel_domains)
        else:
            await self._delete_disabled_rule(plugin, id)

        await self._sink.process_message(
            MessageType.RuleEnabled(
                plugin_id=plugin, rule=id, timestamp=time.time()
            )
        )

    @lookup.bind("rules", "list-disabled")
    @run_in_executor_decorator
    def list_disabled_rules(self, limit, offset, order_by=None):
        return DisabledRule.fetch(limit, offset, order_by)

    @lookup.bind("rules", "update-app-specific-rules")
    async def update_app_based_rules(self):
        if not Modsec.APP_SPECIFIC_RULESET:
            raise ValidationError("App specific ruleset setting is disabled.")
        try:
            await waf_rules_configurator.update_waf_rules_config()
        except (
            waf_rules_configurator.NotSupportedWebserverError,
            modsec_app_version_detector.DatabaseNotFoundError,
            NotImplementedError,
        ) as e:
            raise ValidationError(str(e))

    @lookup.bind("rules", "update-shared-disabled-rules")
    async def list_update_shared_disabled_rules(self):
        rules_file = disabled_rules.get_shared_disabled_modsec_rules_ids()
        rules_db = set(DisabledRule.get_global_disabled("modsec"))
        rules = list([str(rule) for rule in rules_db.union(rules_file)])
        logger.info(
            "Syncing shared disabled rules: %s",
            " ".join(rules) if rules else "(empty)",
        )
        changed = await self.hp.sync_global_disabled_rules(rules)
        if changed:
            await web_server.graceful_restart()

    @run_in_executor_decorator
    def _store_disabled_rule(self, plugin, id, name, domains):
        sync_domains = set(domains)
        try:
            inserted_id = DisabledRule.insert(
                plugin=plugin, rule_id=id, name=name
            ).execute()
        except IntegrityError:
            dr = DisabledRule.get(plugin=plugin, rule_id=id)
            for d in DisabledRuleDomain.select().where(
                DisabledRuleDomain.disabled_rule_id_id == dr.id
            ):
                sync_domains.add(d.domain)
            DisabledRuleDomain.delete().where(
                DisabledRuleDomain.disabled_rule_id_id == dr.id
            ).execute()

            for d in domains:
                DisabledRuleDomain.create_or_get(
                    disabled_rule_id_id=dr.id, domain=d
                )
        else:
            for d in domains:
                DisabledRuleDomain.create(
                    disabled_rule_id_id=inserted_id, domain=d
                )
        return list(sync_domains)

    async def _sync_modsec_configs(self, domains: set):
        loop = asyncio.get_event_loop()
        domain_list = list(domains)
        rules_list = await asyncio.gather(
            *(
                run_in_executor(
                    loop,
                    functools.partial(
                        DisabledRule.get_domain_disabled, "modsec", d
                    ),
                )
                for d in domain_list
            )
        )
        try:
            if domain_list:
                await self.hp.sync_disabled_rules_for_domains(
                    dict(zip(domain_list, rules_list))
                )
        except GenericPanelModSecException as e:
            # don't send errors from generic panel to Sentry;
            # panel admin is responsible for configuring generic panel
            raise ValidationError(str(e)) from e

        rules = await run_in_executor(
            loop, lambda: DisabledRule.get_global_disabled("modsec")
        )
        changed = await self.hp.sync_global_disabled_rules(rules)
        if changed:
            await web_server.graceful_restart()

    @staticmethod
    def __check_edit_is_enabled():
        if not Permissions.ALLOW_LOCAL_RULES_MANAGEMENT:
            raise ValidationError("Local rule management is disabled.")
