import pwd
import itertools
import logging
import os

from pathlib import Path
from typing import Optional
from struct import pack
from peewee import DoesNotExist, JOIN
from contextlib import suppress

from defence360agent.feature_management.lookup import feature
from defence360agent.feature_management.constants import PROACTIVE, FULL
from defence360agent.model import instance
from defence360agent.rpc_tools import ValidationError
from defence360agent.rpc_tools.lookup import CommonEndpoints, bind
from defence360agent.utils import Scope
from im360.model.proactive import (
    Proactive,
    ProactiveIgnoredPath,
    ProactiveIgnoredRule,
)
from defence360agent.wordpress.site_repository import get_sites_for_user

MAX_WHITELIST_RULES = 32
MAX_FILE_STRING = 4096
API_VER = 3
WHITELIST_FILENAME = "/usr/share/i360-php-opts/rules_whitelist"
IGNORE_ENTRY_PACK_PATTERN = "I{}s{}I".format(
    MAX_FILE_STRING, MAX_WHITELIST_RULES
)

logger = logging.getLogger(__name__)


@feature(PROACTIVE, [FULL])
class ProactiveEndpoints(CommonEndpoints):
    SCOPE = Scope.IM360

    def _make_bytes_from_path_entry(self, path, rules, path_id):
        """
        :param path: some path string like "/some/path"
        :param rules: some array with ints like [1, 2, 3] or []
        :param path_id:
        :return:
        """

        rules += [0] * (MAX_WHITELIST_RULES - len(rules))
        return pack(
            IGNORE_ENTRY_PACK_PATTERN, path_id, path.encode("utf8"), *rules
        )

    def _generate_ignore_list_file(self):
        logger.info("Recreating proactive ignore list file")
        paths = ProactiveIgnoredPath.select(
            ProactiveIgnoredPath, ProactiveIgnoredRule
        ).join(ProactiveIgnoredRule, JOIN.LEFT_OUTER)

        whitelist_file_bytes = b""
        path_number = 1
        for path, rules in itertools.groupby(paths, lambda x: x.path):
            rules = [
                r.proactiveignoredrule.rule_id
                for r in rules
                if getattr(r, "proactiveignoredrule", None)
            ]

            whitelist_file_bytes += self._make_bytes_from_path_entry(
                path, rules, path_number
            )
            path_number += 1

        if whitelist_file_bytes:
            try:
                with open(WHITELIST_FILENAME, "wb") as whitelist_file:
                    whitelist_file.write(pack("I", API_VER))
                    whitelist_file.write(whitelist_file_bytes)
                os.chmod(WHITELIST_FILENAME, 0o644)

                logger.info("Proactive ignore list file successfully updated")
            except FileNotFoundError as e:
                logger.warning(str(e))
        else:
            with suppress(FileNotFoundError):
                os.remove(WHITELIST_FILENAME)
                logger.info("Proactive ignore list file successfully cleaned")

    def _get_uid(self, username):
        if username is None:
            return 0
        else:
            return pwd.getpwnam(username).pw_uid

    def _get_homedir(self, username):
        if username is not None:
            return Path(pwd.getpwnam(username).pw_dir)
        else:
            return None

    @bind("proactive", "list")
    async def proactive_list(self, user=None, **kwargs):
        user_sites = []
        if user and kwargs.get("site_search"):
            user_info = pwd.getpwnam(user)
            user_sites = get_sites_for_user(user_info)
        return Proactive.fetch(
            uid=self._get_uid(user), user_sites=user_sites, **kwargs
        )

    @bind("proactive", "details")
    async def proactive_details(self, id, user=None):
        try:
            return {"items": Proactive.details(id, self._get_uid(user))}
        except DoesNotExist:
            raise ValidationError("Event {} does not exist".format(id))

    @bind("proactive", "ignore", "list")
    async def proactive_ignore_list(self, user=None, **kwargs):
        return ProactiveIgnoredPath.fetch(self._get_homedir(user), **kwargs)

    def _validate_ignore_item(
        self,
        homedir: Optional[Path],
        path: str,
        rule_id: Optional[int] = None,
        rule_name: Optional[str] = None,
    ):
        if rule_id is not None and rule_name is None:
            raise ValidationError(
                "rule_name should be specified if rule_id is specified"
            )

        if homedir is not None:
            # check if user tries to ignore file from his home directory
            # pw = pwd.getpwnam(user)
            if homedir not in Path(path).parents:
                raise ValidationError(
                    "Unable to add {} to ignore: not permitted".format(path)
                )

    def _add_ignore_item(
        self,
        path: str,
        rule_id: Optional[int] = None,
        rule_name: Optional[str] = None,
    ):
        with instance.db.atomic():
            ignored_path, created = ProactiveIgnoredPath.create_or_get(
                path=path
            )
            if (not created) and (ignored_path.rules.count() == 0):
                # path is already ignored for all rules
                return
            if rule_id:
                ProactiveIgnoredRule.create_or_get(
                    path=ignored_path, rule_id=rule_id, rule_name=rule_name
                )
            else:
                # if rule id is not provided, all rules are ignored
                ProactiveIgnoredRule.delete().where(
                    ProactiveIgnoredRule.path == ignored_path
                ).execute()

    @bind("proactive", "ignore", "addmany")
    async def proactive_ignore_addmany(self, items, user=None):
        homedir = self._get_homedir(user)
        for item in items:
            self._validate_ignore_item(homedir, **item)

        for item in items:
            self._add_ignore_item(**item)
        self._generate_ignore_list_file()

    @bind("proactive", "ignore", "add")
    async def proactive_ignore_add(
        self, path, rule_id=None, rule_name=None, user=None
    ):
        self._validate_ignore_item(
            self._get_homedir(user), path, rule_id, rule_name
        )
        self._add_ignore_item(path, rule_id, rule_name)
        self._generate_ignore_list_file()

    @bind("proactive", "ignore", "delete", "path")
    async def proactive_ignore_delete(self, paths, user=None):
        if user is not None:
            homedir = self._get_homedir(user)
            for p in paths:
                if homedir not in Path(p).parents:
                    raise ValidationError(
                        "Unable to delete {}: not permitted".format(p)
                    )

        for p in paths:
            ProactiveIgnoredPath.delete().where(
                ProactiveIgnoredPath.path == p
            ).execute()
        self._generate_ignore_list_file()

    @bind("proactive", "ignore", "delete", "rule")
    async def proactive_ignore_delete_rule(self, path, id, user=None):
        homedir = self._get_homedir(user)
        if (homedir is not None) and (homedir not in Path(path).parents):
            raise ValidationError("Unable do delete rule: not permitted")
        with instance.db.atomic():
            try:
                path_obj = ProactiveIgnoredPath.get(path=path)
            except DoesNotExist:
                raise ValidationError("Path not found")

            num_deleted = (
                ProactiveIgnoredRule.delete()
                .where(
                    ProactiveIgnoredRule.path_id == path_obj.path,
                    ProactiveIgnoredRule.rule_id == id,
                )
                .execute()
            )
            if num_deleted:
                # check if there are still any rules ignored for this path
                num_rules = (
                    ProactiveIgnoredRule.select()
                    .where(ProactiveIgnoredRule.path_id == path_obj.path)
                    .count()
                )

                # if we deleted last rule, path should be also deleted
                if not num_rules:
                    path_obj.delete_instance()

        self._generate_ignore_list_file()
