import itertools
import time
import operator

from peewee import (
    CompositeKey,
    DoesNotExist,
    ForeignKeyField,
    IntegerField,
    JOIN,
    PrimaryKeyField,
    TextField,
    CharField,
    SQL,
)
from functools import reduce
from defence360agent.model import instance, Model
from defence360agent.model.simplification import apply_order_by
from im360.model.country import Country
from im360.model.firewall import IPList, IPListPurpose


class Proactive(Model):
    """Proactive defense php plugin events."""

    class Meta:
        database = instance.db
        db_table = "proactive"
        schema = "proactive"

    id = PrimaryKeyField()
    #: When the event happened.
    timestamp = IntegerField(null=False)
    #: The IP that made the request - a string representation.
    ip = TextField(null=True)
    #: The IP that made the request - a numeric representation.
    ip_int = IntegerField(null=True)
    #: The IP that made the request - version (either `4` or `6`).
    ip_version = IntegerField(null=True)
    #: A country code for the IP, based on GeoDB data.
    ip_country_id = CharField(null=True)
    #: .. deprecated:: 4.6.0 no longer filled after DEF-10708.
    description = TextField(null=True)
    #: Action taken, may be `LOG`, `BLOCK`, `KILL`.
    action = TextField(null=False)
    #: The name of the server host under which the script was executed.
    host = TextField(null=True)
    #: The full PHP script path.
    path = TextField(null=False)
    #: The full URL of the request.
    url = TextField(null=True)
    #: The number of times the same event happened (if it's aggregated).
    count = IntegerField(null=False)
    #: User ID of the process.
    uid = IntegerField(null=False)
    #: Group ID of the process.
    gid = IntegerField(null=False)

    #: The ID of the matched rule (recognizer sub ID).
    rule_id = IntegerField(null=True)
    #: Human-readable name of the matched rule.
    rule_name = TextField(null=False)

    @classmethod
    def _iplist_join(cls):
        return (IPList.ip == cls.ip) & (~IPList.is_expired())

    @classmethod
    def fetch(
        cls,
        uid,
        since=None,
        to=None,
        limit=None,
        offset=None,
        search=None,
        order_by=None,
        site_search=None,
        user_sites=None,
        **kwargs,
    ):
        q = cls.select(
            cls.id,
            cls.timestamp,
            cls.ip,
            cls.action,
            cls.host,
            cls.path,
            cls.count,
            cls.rule_id,
            cls.rule_name,
            IPList.listname,
        ).join(IPList, JOIN.LEFT_OUTER, on=cls._iplist_join())

        # Collect all WHERE conditions in a list to build a flat expression tree
        # It is workaround for `cursor stack overflowed` error
        # Since it is counted as one function call, when it is single clause
        # Otherwise sql driver will rise stack for each condition
        where_conditions = []

        if uid != 0:
            where_conditions.append(cls.uid == uid)
        if since is not None:
            where_conditions.append(cls.timestamp >= since)
        if to is not None:
            where_conditions.append(cls.timestamp <= to)
        if search is not None:
            where_conditions.append(
                cls.host.contains(search)
                | cls.path.contains(search)
                | cls.rule_name.contains(search)
                | cls.ip.contains(search)
            )

        if user_sites and site_search:
            # Condition 1: The event path must start with the requested site's path.
            where_conditions.append(cls.path.startswith(site_search))

            # Condition 2: The event path must NOT start with any more specific paths.
            # We add a sanity check to ensure we only process sub-paths.
            longer_paths = [
                p
                for p in user_sites
                if len(p) > len(site_search) and p.startswith(site_search)
            ]

            if longer_paths:
                # Build a single, flat SQL object to avoid recursion depth errors.
                # This is applied to the 'path' column in this model.
                sql_chunks = []
                sql_params = []
                for path in longer_paths:
                    sql_chunks.append("path NOT LIKE ?")
                    sql_params.append(f"{path}%")

                combined_sql_string = " AND ".join(sql_chunks)
                where_conditions.append(
                    SQL(f"({combined_sql_string})", sql_params)
                )

        # If there are any conditions, reduce them to a single clause and apply it.
        if where_conditions:
            final_clause = reduce(operator.and_, where_conditions)
            q = q.where(final_clause)

        if order_by is not None:
            q = apply_order_by(order_by, cls, q)
        if limit is not None:
            q = q.limit(limit)
        if offset is not None:
            q = q.offset(offset)

        result = []
        for item in q.dicts():
            # add new field purpose
            # but save old listname as backward compatibility
            item["purpose"] = (
                IPListPurpose.listname2purpose(item["listname"])
                if item["listname"]
                else item["listname"]
            )
            result.append(item)
        return result

    @classmethod
    def details(cls, id, uid):
        q = (
            cls.select(
                cls.id,
                cls.timestamp,
                cls.ip,
                cls.description,
                cls.url,
                cls.action,
                cls.path,
                cls.count,
                cls.rule_id,
                cls.rule_name,
                IPList.listname,
                Country.code.alias("country"),
            )
            .join(IPList, JOIN.LEFT_OUTER, on=cls._iplist_join())
            .switch()
            .join(
                Country, JOIN.LEFT_OUTER, on=(cls.ip_country_id == Country.id)
            )
            .where(cls.id == id)
        )
        if uid != 0:
            q = q.where(cls.uid == uid)
        q = q.dicts()
        if q.count() < 1:
            raise DoesNotExist
        event = next(iter(q))
        q = (
            ProactiveEnv.select(ProactiveEnv.name, ProactiveEnv.value)
            .where(ProactiveEnv.event_id == event["id"])
            .tuples()
        )
        event["env"] = dict(q)
        event["purpose"] = (
            IPListPurpose.listname2purpose(event["listname"])
            if event["listname"]
            else event["listname"]
        )
        return event


class ProactiveEnv(Model):
    """Proactive defence php plugin environment variables."""

    #: A reference to the :class:`Proactive` table.
    event_id = IntegerField(null=False)
    #: The name of the environment variable.
    name = TextField(null=False)
    #: The value of the environment variable.
    value = TextField(null=True)

    class Meta:
        database = instance.db
        db_table = "proactive_env"
        schema = "proactive"
        primary_key = CompositeKey("event_id", "name", "value")


class ProactiveIgnoredPath(Model):
    """Ignore list for proactive defence."""

    #: Script path to be ignored.
    path = TextField(null=False, primary_key=True)
    #: Timestamp when the ignore record was added.
    timestamp = IntegerField(null=False, default=time.time)

    class Meta:
        database = instance.db
        db_table = "proactive_ignored_path"

    @classmethod
    def _apply_order(cls, q, order_by=None):
        """
        To be able to use itertools.groupby, we need result
        to be sorted by both path and timestamp, so in this method
        we add this fields to order_by if they was not passed by
        caller
        """
        if order_by is None:
            order_by = []
        order = []
        fields = cls._meta.sorted_field_names
        order_by_fields = [f for f, _ in order_by]
        for field_name, desc in order_by:
            field = getattr(cls, field_name, None)
            if field is not None:
                order.append(field.desc() if desc else field)

        for field_name in fields:
            if field_name not in order_by_fields:
                order.append(getattr(cls, field_name))

        order.append(ProactiveIgnoredRule.rule_name)
        return q.order_by(*order)

    @classmethod
    def fetch(
        cls,
        limit_homedir=None,
        since=None,
        to=None,
        search=None,
        order_by=None,
        limit=50,
        offset=0,
    ):
        q = cls.select(
            cls.path,
            cls.timestamp,
            ProactiveIgnoredRule.rule_id,
            ProactiveIgnoredRule.rule_name,
        ).join(ProactiveIgnoredRule, JOIN.LEFT_OUTER)
        if limit_homedir is not None:
            # it's ok to use startswith() with path here,
            # because we are using normalized path when inserting
            q = q.where(cls.path.startswith(str(limit_homedir)))
        if search is not None:
            q = q.where(cls.path.contains(search))

        if since is not None:
            q = q.where(cls.timestamp >= since)
        if to is not None:
            q = q.where(cls.timestamp <= to)

        q = cls._apply_order(q, order_by)

        result = []
        max_count = 0
        for p, g in itertools.groupby(
            q.dicts(), key=lambda r: (r["path"], r["timestamp"])
        ):
            if (max_count >= offset) and (len(result) < limit):
                path, timestamp = p
                result.append(
                    {
                        "path": path,
                        "timestamp": timestamp,
                        "rules": [
                            {"id": row["rule_id"], "name": row["rule_name"]}
                            for row in g
                            if row["rule_id"] is not None
                        ],
                    }
                )
            max_count += 1

        return max_count, result


class ProactiveIgnoredRule(Model):
    """Specific rules ignored."""

    #: A reference to the :class:`ProactiveIgnoredPath` table.
    path = ForeignKeyField(
        ProactiveIgnoredPath,
        null=False,
        on_delete="CASCADE",
        related_name="rules",
    )
    #: The ID of the rule to be ignored.
    rule_id = IntegerField(null=False)
    #: A human-readable name of the rule to be ignored (just for information
    #: purposes - doesn't affect the logic).
    rule_name = TextField(null=False)

    class Meta:
        database = instance.db
        db_table = "proactive_ignored_rule"
        indexes = ((("path", "rule_id"), True),)
