import asyncio
import json
import logging
import os
import shutil
import zipfile
from contextlib import suppress
from pathlib import Path
from typing import Optional, List, Dict
from urllib.parse import urlparse

from defence360agent.api.integration_conf import IntegrationConfig
from defence360agent.subsys.panels.base import PanelException
from defence360agent.utils import (
    atomic_rewrite,
    async_lru_cache,
)
from im360.subsys.panels.base import (
    FilesVendor,
    FilesVendorList,
    ModSecSettingInterface,
    ModSecurityInterface,
    APACHE,
    LITESPEED,
    NGINX,
    OPENLITESPEED,
)
from defence360agent.subsys.web_server import graceful_restart_sync

logger = logging.getLogger(__name__)

GENERIC_DIR = "/etc/sysconfig/imunify360/generic/"
MODSEC_CONF = os.path.join(GENERIC_DIR, "modsec.conf")
MODSEC_CONF_DIR = os.path.join(GENERIC_DIR, "modsec.conf.d")
RULES_DIR = "/var/imunify360/modsec/generic/rules"
GLOBAL_DISABLED_RULES_CONFIG = os.path.join(
    GENERIC_DIR, "global_disabled_rules.conf"
)
MODSEC_SETTINGS_CONF = os.path.join(GENERIC_DIR, "modsec2.imunify.conf")
MODSEC_SETTINGS_CONF_NGINX = os.path.join(
    GENERIC_DIR, "nginx.modsec3.imunify.conf"
)
_APACHE_COMPATIBLE_WEBSERVERS = (APACHE, LITESPEED, OPENLITESPEED)
_NGINX_COMPATIBLE_WEBSERVERS = (NGINX,)


class GenericPanelModSecException(PanelException):
    pass


class _ModSecSettings(ModSecSettingInterface):
    #: apache-compatible modsec rules
    I360_RULES_INCLUDE_APACHE = (
        "<IfModule security2_module>"
        '\n\tIncludeOptional "{}/*.conf"'
        # Include without asterisk will error out on Apache < 2.4.30.
        '\n\tIncludeOptional "{}"'
        '\n\tIncludeOptional "{}/*"'
        "\n</IfModule>\n"
        "Include {}\n"
    ).format(
        RULES_DIR,
        GLOBAL_DISABLED_RULES_CONFIG,
        MODSEC_CONF_DIR,
        MODSEC_SETTINGS_CONF,
    )

    I360_RULES_INCLUDE_NGINX = (
        "include {}/*.conf\ninclude {}\ninclude {}/*\ninclude {}\n"
    ).format(
        RULES_DIR,
        GLOBAL_DISABLED_RULES_CONFIG,
        MODSEC_CONF_DIR,
        MODSEC_SETTINGS_CONF_NGINX,
    )

    @classmethod
    def get_i360_rules(cls):
        """Return modsec rules for the web server."""
        return (
            cls.I360_RULES_INCLUDE_NGINX
            if _get_web_server_type() == NGINX
            else cls.I360_RULES_INCLUDE_APACHE
        )

    @classmethod
    async def revert(cls, **kwargs):
        try:
            with open(MODSEC_CONF, "w"):
                # Opening in write mode is enough to truncate the file.
                pass
        except (IOError, OSError):
            logger.warning("%s does not exist")

    @classmethod
    async def apply(cls):
        """Populate modsec.conf"""
        # Note: customer is responsible for including it in the server config
        # https://docs.imunify360.com/stand_alone/#interaction-with-modsecurity
        rules = cls.get_i360_rules()
        try:
            atomic_rewrite(MODSEC_CONF, rules, backup=False)
        except (IOError, OSError) as err:
            raise GenericPanelModSecException(
                "Could not access ModSec config for generic panel"
            ) from err


class GenericPanelModSecurity(ModSecurityInterface):
    REBUILD_HTTPDCONF_CMD = None

    @classmethod
    def _get_conf_dir(cls) -> str:
        return MODSEC_CONF_DIR

    @classmethod
    async def installed_modsec(cls):
        return True

    async def _install_settings(self, reload_wafd=True):
        # configure modsec on a compatible web server
        try:
            await _ModSecSettings.apply()
        except GenericFilesVendorList.CompatiblityCheckFailed as e:
            logger.error(str(e))  # send to sentry, ignore otherwise
        else:
            await GenericFilesVendorList.apply()
            graceful_restart_sync()

    async def modsec_get_directive(self, directive_name, default=None):
        """
        Used for `imunify360-agent check modsec directives` which
        allows to check whether the global ModSecurity directives have values
        recommended by Imunify360.

        N/A for GenericPanel
        """
        raise NotImplementedError

    async def reset_modsec_directives(self):
        """
        Used for `imunify360-agent fix modsec directives` to
        reset ModSecurity settings to values chosen by Imunify360

        N/A for GenericPanel
        """
        raise NotImplementedError

    async def reset_modsec_rulesets(self):
        # Unused.
        raise NotImplementedError

    async def revert_settings(self, reload_wafd=True):
        await _ModSecSettings.revert()
        await GenericFilesVendorList.revert()
        graceful_restart_sync()

    @classmethod
    def detect_cwaf(cls):
        # Unused.
        return False

    @classmethod
    @async_lru_cache(maxsize=1)
    async def _get_release_info_from_file(cls) -> Optional[dict]:
        modsec_release_file = await cls.build_vendor_file_path(
            vendor="Not used", filename="RELEASE"
        )
        try:
            with modsec_release_file.open() as release_f:
                json_data = json.load(release_f)
            return json_data
        except (OSError, json.JSONDecodeError):
            return None

    @classmethod
    async def modsec_vendor_list(cls) -> list:
        """Return a list of installed ModSecurity vendors."""
        vendor_list = []
        vendor = await cls.get_modsec_vendor_from_release_file()
        if vendor:
            vendor_list.append(vendor)
        return vendor_list

    @classmethod
    async def enabled_modsec_vendor_list(cls) -> list:
        """Return a list of enabled ModSecurity vendors."""
        try:
            with open(MODSEC_CONF, "r") as f:
                if not any(
                    line.strip() == _ModSecSettings.get_i360_rules()
                    for line in f
                ):
                    return []
        except FileNotFoundError as e:
            raise GenericPanelModSecException(
                "Rules not configured yet"
            ) from e
        return await cls.modsec_vendor_list()

    @classmethod
    async def build_vendor_file_path(cls, vendor: str, filename: str) -> Path:
        return Path(RULES_DIR) / filename

    @classmethod
    async def _apply_modsec_files_update(cls):
        await GenericFilesVendorList.install_or_update()

    @classmethod
    def get_audit_log_path(cls):
        try:
            return IntegrationConfig.to_dict()["web_server"][
                "modsec_audit_log"
            ]
        except KeyError as err:
            raise GenericPanelModSecException(
                "Integration config is missing modsec_audit_log field"
            ) from err

    @classmethod
    def get_audit_logdir_path(cls):
        try:
            return IntegrationConfig.to_dict()["web_server"][
                "modsec_audit_logdir"
            ]
        except KeyError as err:
            raise GenericPanelModSecException(
                "Integration config is missing modsec_audit_logdir field"
            ) from err

    @classmethod
    def _generate_disabled_rules_apache_config(cls, rule_list: list) -> str:
        # *super* uses Apache compatible config syntax
        return super().generate_disabled_rules_config(rule_list)

    @classmethod
    def _generate_disabled_rules_nginx_config(cls, rule_list: list) -> str:
        text = ""
        if rule_list:
            text = "SecRuleRemoveById {}\n".format(
                " ".join(map(str, rule_list))
            )
        return text

    @classmethod
    def generate_disabled_rules_config(cls, rule_list: list) -> str:
        """
        Returns config text with disabled rules according to used webserver.
        """
        web_server = _get_web_server_type()
        if web_server in _APACHE_COMPATIBLE_WEBSERVERS:
            return cls._generate_disabled_rules_apache_config(rule_list)
        if web_server in _NGINX_COMPATIBLE_WEBSERVERS:
            return cls._generate_disabled_rules_nginx_config(rule_list)

    @classmethod
    def write_global_disabled_rules(cls, rule_list: List[str]) -> bool:
        return atomic_rewrite(
            GLOBAL_DISABLED_RULES_CONFIG,
            cls.generate_disabled_rules_config(rule_list),
            backup=False,
        )

    @classmethod
    async def sync_global_disabled_rules(cls, rule_list: List[str]) -> bool:
        return cls.write_global_disabled_rules(rule_list)

    @classmethod
    async def sync_disabled_rules_for_domains(
        cls, domain_rules_map: Dict[str, list]
    ):
        """
        Disable mod_security rules on domain level for each domain
        specified in a map.
        """

        def to_byte_row(domain: str, config: str) -> bytes:
            return json.dumps({"domain": domain, "content": config}).encode(
                "utf-8"
            )

        try:
            domain_config_script = IntegrationConfig.to_dict()[
                "integration_scripts"
            ]["modsec_domain_config_script"]
        except KeyError as err:
            raise GenericPanelModSecException(
                "Integration config is missing"
                " modsec_domain_config_script field"
            ) from err

        if not domain_config_script:
            raise GenericPanelModSecException(
                "modsec_domain_config_script specified in integration.conf"
                " is empty"
            )

        lines = [
            to_byte_row(domain, cls.generate_disabled_rules_config(rule_list))
            for domain, rule_list in domain_rules_map.items()
        ]

        # don't send empty line to the customer's script
        # the script may not expect this, according to documentation
        # https://docs.imunify360.com/stand_alone/#interaction-with-modsecurity
        if lines:
            try:
                proc = await asyncio.create_subprocess_exec(
                    *domain_config_script.split(),
                    stdin=asyncio.subprocess.PIPE,
                    stdout=asyncio.subprocess.PIPE,
                    stderr=asyncio.subprocess.PIPE,
                )
            except FileNotFoundError as err:
                raise GenericPanelModSecException(
                    "Could not run modsec_domain_config_script: {}."
                    "\nTry specifying a full path.".format(err)
                )
            # add '\n' at the end to avoid possible problems with
            # custom script (see DEF-12901 for details)
            data = b"\n".join(lines) + b"\n"
            stdout, stderr = await proc.communicate(input=data)
            if proc.returncode:
                raise GenericPanelModSecException(
                    "Failed to update domains config using %s\n"
                    "STDOUT:\n%r\nSTDERR:\n%r"
                    % (
                        domain_config_script,
                        stdout,
                        stderr,
                    )
                )
            else:
                logger.info("Successfully updated ModSec domain configs")


class GenericFilesVendor(FilesVendor):
    modsec_interface = GenericPanelModSecurity

    async def _remove_vendor(self):
        with suppress(FileNotFoundError):
            shutil.rmtree(RULES_DIR)
        os.makedirs(RULES_DIR, exist_ok=True, mode=0o700)

    async def apply(self):
        shutil.rmtree(RULES_DIR, ignore_errors=True)
        os.makedirs(RULES_DIR, exist_ok=True, mode=0o700)
        with zipfile.ZipFile(self._item["local_path"]) as zf:
            for member in zf.namelist():
                filename = os.path.basename(member)
                if not filename:
                    continue
                target = os.path.join(RULES_DIR, filename)
                with zf.open(member) as src, open(target, "wb") as dst:
                    shutil.copyfileobj(src, dst)

    def _vendor_id(self):
        basename = os.path.basename(urlparse(self._item["url"]).path)
        basename_no_zip, _ = os.path.splitext(basename)
        return basename_no_zip


class GenericFilesVendorList(FilesVendorList):
    files_vendor = GenericFilesVendor
    modsec_interface = GenericPanelModSecurity

    @classmethod
    def vendor_fit_panel(cls, item):
        # use plesk item from description.json cause plesk,
        # da and generic panel share the same zip
        return item["name"].endswith("plesk")

    @classmethod
    async def _get_compatible_name(cls, installed_vendors):
        web_server = _get_web_server_type()
        basename = "imunify360-{modsec3}{ruleset_suffix}-{webserver}-{panel}"
        return basename.format(
            modsec3="modsec3-" * (web_server == NGINX),
            ruleset_suffix=cls.get_ruleset_suffix(),
            webserver=web_server,
            panel="plesk",
        )


def _get_web_server_type() -> Optional[str]:
    """Return web_server.server_type or None."""
    try:
        web_server = IntegrationConfig.to_dict()["web_server"]["server_type"]
    except KeyError as err:
        raise GenericPanelModSecException(
            "Integration config is missing server_type field"
        ) from err
    if web_server not in (APACHE, LITESPEED, NGINX, OPENLITESPEED):
        raise GenericFilesVendorList.CompatiblityCheckFailed(
            "Imunify360 mod_security vendor does not support '{}' "
            "webserver".format(web_server)
        )
    return web_server
