from PyQt6.QtCore import QObject, pyqtSignal

from opensnitch.database import Database
from opensnitch.database.enums import RuleFields
from opensnitch.utils import logger
from opensnitch.utils.duration import duration
from opensnitch.config import Config

import opensnitch.proto as proto
ui_pb2, ui_pb2_grpc = proto.import_()

import os
import json
from slugify import slugify
from datetime import datetime
from google.protobuf.json_format import MessageToJson, Parse

DefaultRulesPath = "/etc/opensnitchd/rules"

# date format displayed on the GUI (created column)
DBDateFieldFormat = "%Y-%m-%d %H:%M:%S"

log = logger.get(__name__)

class Rule():
    def __init__(self):
        pass

    @staticmethod
    def to_bool(s):
        return s == 'True'

    @staticmethod
    def new_empty():
        pass

    @staticmethod
    def new_from_records(records):
        """Creates a new protobuf Rule from DB records.
        Fields of the record are in the order defined on the DB.
        """
        rule = ui_pb2.Rule(name=records.value(RuleFields.Name))
        rule.enabled = Rule.to_bool(records.value(RuleFields.Enabled))
        rule.precedence = Rule.to_bool(records.value(RuleFields.Precedence))
        rule.action = records.value(RuleFields.Action)
        rule.duration = records.value(RuleFields.Duration)
        rule.operator.type = records.value(RuleFields.OpType)
        rule.operator.sensitive = Rule.to_bool(records.value(RuleFields.OpSensitive))
        rule.operator.operand = records.value(RuleFields.OpOperand)
        rule.operator.data = "" if records.value(RuleFields.OpData) == None else str(records.value(RuleFields.OpData))
        rule.description = records.value(RuleFields.Description)
        rule.nolog = Rule.to_bool(records.value(RuleFields.NoLog))
        created = int(datetime.now().timestamp())
        if records.value(RuleFields.Created) != "":
            created = int(datetime.strptime(
                records.value(RuleFields.Created), DBDateFieldFormat
            ).timestamp())
        rule.created = created

        try:
            # Operator list is always saved as json string to the db,
            # so we need to load the json string.
            if rule.operator.type == Config.RULE_TYPE_LIST:
                operators = json.loads(rule.operator.data)
                for op in operators:
                    rule.operator.list.extend([
                        ui_pb2.Operator(
                            type=op['type'],
                            operand=op['operand'],
                            sensitive=False if op.get('sensitive') == None else op['sensitive'],
                            data="" if op.get('data') == None else op['data']
                        )
                    ])
                rule.operator.data = ""
        except Exception as e:
            log.warning("new_from_records exception parsing operartor list: %s", repr(e))


        return rule

class Rules(QObject):
    __instance = None
    updated = pyqtSignal(int)

    LOG_TAG = "[Rules]: "

    @staticmethod
    def instance():
        if Rules.__instance == None:
            Rules.__instance = Rules()
        return Rules.__instance

    def __init__(self):
        QObject.__init__(self)
        self._db = Database.instance()

    def add(self, time, node, name, description, enabled, precedence, nolog, action, duration, op_type, op_sensitive, op_operand, op_data, created):
        # don't add rule if the user has selected to exclude temporary
        # rules
        if duration in Config.RULES_DURATION_FILTER:
            return

        self._db.insert("rules",
                  "(time, node, name, description, enabled, precedence, nolog, action, duration, operator_type, operator_sensitive, operator_operand, operator_data, created)",
                  (time, node, name, description, enabled, precedence, nolog, action, duration, op_type, op_sensitive, op_operand, op_data, created),
                        action_on_conflict="REPLACE")

    def add_rules(self, addr, rules):
        try:
            for _,r in enumerate(rules):
                # Operator list is always saved as json string to the db.
                rjson = json.loads(MessageToJson(r))
                if r.operator.type == Config.RULE_TYPE_LIST and \
                        rjson.get('operator') != None and \
                        rjson.get('operator').get('list') != None:

                    r.operator.data = json.dumps(rjson.get('operator').get('list'))

                self.add(datetime.now().strftime(DBDateFieldFormat),
                         addr,
                         r.name, r.description, str(r.enabled),
                         str(r.precedence), str(r.nolog), r.action, r.duration,
                         r.operator.type,
                         str(r.operator.sensitive),
                         r.operator.operand, r.operator.data,
                         str(datetime.fromtimestamp(r.created).strftime(DBDateFieldFormat)))

            return True
        except Exception as e:
            log.warning("exception adding node rules to db: %s", repr(e))
            return False

    def delete(self, name, addr, callback):
        rule = ui_pb2.Rule(name=name)
        rule.enabled = False
        rule.action = ""
        rule.duration = ""
        rule.operator.type = ""
        rule.operator.operand = ""
        rule.operator.data = ""

        if not self._db.delete_rule(rule.name, addr):
            return None

        return rule

    def delete_by_field(self, field, values):
        return self._db.delete_rules_by_field(field, values)

    def get_by_name(self, node, name):
        return self._db.get_rule(name, node)

    def get_all(self):
        return self._db.get_rules()

    def get_all_by_node(self, node):
        return self._db.get_rules(node)

    def get_by_field(self, node, field, value, orderby=None):
        return self._db.get_rule_by_field(node, field, value, orderby)

    def exists(self, rule, node_addr):
        return self._db.rule_exists(rule, node_addr)

    def new_unique_name(self, rule_name, node_addr, prefix):
        """generate a new name, if the supplied one already exists
        """
        if self._db.get_rule(rule_name, node_addr).next() == False:
            return rule_name

        for idx in range(0, 100):
            new_rule_name = "{0}-{1}".format(rule_name, idx)
            if self._db.get_rule(new_rule_name, node_addr).next() == False:
                return new_rule_name

        return rule_name

    def disable(self, addr, name):
        """Mark a rule as not enabled in the DB"""
        self._db.update(
            "rules",
            "enabled='False'",
            (name, addr),
            "name=? AND node=?",
            action_on_conflict="OR REPLACE"
        )

    def update_time(self, time, name, addr):
        """Updates the time of a rule, whenever a new connection matched a
        rule.
        """
        self._db.update("rules",
                        "time=?",
                        (time, name, addr),
                        "name=? AND node=?",
                        action_on_conflict="OR REPLACE"
                        )

    def _timestamp_to_rfc3339(self, time):
        """converts timestamp to rfc3339 format"""
        return "{0}Z".format(
            datetime.fromtimestamp(time).isoformat(timespec='microseconds')
        )

    def disable_expired(self, is_start=False):
        """disable all expired rules on the database.

        if is_start is True (i.e., the GUI has just been launched) "until-restart"
        rules will be disabled.

        The rules will be updated only on the GUI side, not in the daemon,
        so if the daemon still has the exact temporary rules active, they'll be
        added back to the db when connecting to the GUI."""
        rules = self.get_all()
        if rules is None:
            return
        while rules.next():
            enabled = Rule.to_bool(rules.value(RuleFields.Enabled))
            if not enabled:
                continue
            dur = rules.value(RuleFields.Duration)
            if dur == Config.DURATION_ALWAYS:
                continue
            timeout = duration.to_seconds(dur)

            name = rules.value(RuleFields.Name)
            node = rules.value(RuleFields.Node)
            created = rules.value(RuleFields.Created)
            created_timestamp = int(datetime.strptime(
                created, DBDateFieldFormat
            ).timestamp())
            rule_valid_timestamp = created_timestamp+timeout
            now = int(datetime.now().timestamp())
            if now > rule_valid_timestamp or (is_start and dur == Config.DURATION_UNTIL_RESTART):
                self.disable(node, name)

    def rule_to_json(self, node, rule_name):
        try:
            records = self._db.get_rule(rule_name, node)
            if records == None or records == -1:
                return None
            if not records.next():
                return None
            rule = Rule.new_from_records(records)
            # exclude this field when exporting to json
            tempRule = MessageToJson(rule)
            jRule = json.loads(tempRule)
            jRule['created'] = self._timestamp_to_rfc3339(rule.created)
            return json.dumps(jRule, indent="    ")
        except Exception as e:
            log.warning("rule_to_json() exception: %s", repr(e))
            return None

    def _export_rule_common(self, node, records, outdir):
        try:
            rule = Rule.new_from_records(records)
            rulename = rule.name
            if ".json" not in rulename:
                rulename = rulename + ".json"
            with open(outdir  + "/" + rulename, 'w') as jsfile:
                actual_json_text = MessageToJson(rule)
                jRule = json.loads(actual_json_text)
                jRule['created'] = self._timestamp_to_rfc3339(rule.created)
                actual_json_text = json.dumps(jRule, indent="    ")
                jsfile.write( actual_json_text )

            return True
        except Exception as e:
            log.warning("export_rules(%s, %s) exception: %s", node, outdir, repr(e))

        return False

    def export_rule(self, node, rule_name, outdir):
        """Gets the rule from the DB and writes it out to a directory.
        A new directory per node will be created.
        """
        try:
            records = self._db.get_rule(rule_name, node)
            if records.next() == False:
                log.warning("export_rule() get_error 2: %s", repr(records))
                return False

            rulesdir = outdir + "/" + slugify(node)
            try:
                os.makedirs(rulesdir, 0o700)
            except Exception as e:
                log.warning("exception creating dirs: %s", repr(e))

            return self._export_rule_common(node, records, rulesdir)

        except Exception as e:
            log.warning("export_rules(%s, %s) exception: %s", node, rulesdir, repr(e))

        return False

    def export_rules(self, node, outdir):
        """Gets the rules from the DB and writes them out to a directory.
        A new directory per node will be created.
        """
        records = self._db.get_rules(node)
        if records == None:
            return False

        rulesdir = outdir + "/" + slugify(node)
        try:
            os.makedirs(rulesdir, 0o700)
        except Exception as e:
            log.warning("exception creating dirs %s: %s", rulesdir, repr(e))
        try:
            while records.next() != False:
                self._export_rule_common(node, records, rulesdir)

        except Exception as e:
            log.warning("export_rules(%s, %s) exception: %s", node, rulesdir, repr(e))
            return False

        return True

    def import_rules(self, rulesdir):
        """Read a directory with rules in json format, and parse them out to protobuf
        Returns a list of rules on success, or None on error.
        """
        try:
            rules = []
            for rulename in os.listdir(rulesdir):
                with open(rulesdir  + "/" + rulename, 'r') as f:
                    jsrule = f.read()
                    # up until v1.6.5/v1.7.0, 'created' field was exported as timestamp.
                    # since > v1.6.5 it's exported in rfc3339 format, so if we fail to
                    # parse the rule, we'll try to convert the 'created' value from
                    # timestamp to rfc3339.
                    try:
                        pb_rule = Parse(text=jsrule, message=ui_pb2.Rule(), ignore_unknown_fields=True)
                    except:
                        jRule = json.loads(jsrule)
                        created = int(datetime.strptime(
                            jRule['created'], "%Y-%m-%dT%H:%M:%S.%fZ"
                        ).timestamp())
                        jRule['created'] = created
                        jsrule = json.dumps(jRule)
                        pb_rule = Parse(text=jsrule, message=ui_pb2.Rule(), ignore_unknown_fields=True)
                    rules.append(pb_rule)

            return rules
        except Exception as e:
            log.warning("import_rules() %s exception: %s", rulesdir, repr(e))

        return None
