import logging
from dateutil import parser
from django.core.validators import MaxValueValidator, MinValueValidator
from django.contrib.postgres.fields import ArrayField
from django.db import connection, models
from django.db.models import Count, Q
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from zentral.contrib.inventory.models import BaseEnrollment, Certificate, File, Tag


logger = logging.getLogger("zentral.contrib.santa.models")


# Configuration / Enrollment


class ConfigurationManager(models.Manager):
    def summary(self):
        query = (
            "select c.id as pk, c.name, c.created_at,"
            "(select count(*) from santa_enrollment where configuration_id = c.id) as enrollment_count,"
            "(select count(*) from santa_enrolledmachine as m "
            " join santa_enrollment as e on (m.enrollment_id = e.id) "
            " where e.configuration_id = c.id) as machine_count,"
            "(select count(*) from santa_rule where configuration_id = c.id) as rule_count "
            "from santa_configuration as c "
            "order by c.name, c.created_at"
        )
        cursor = connection.cursor()
        cursor.execute(query)
        columns = [c.name for c in cursor.description]
        return [dict(zip(columns, row)) for row in cursor.fetchall()]


class Configuration(models.Model):
    MONITOR_MODE = 1
    LOCKDOWN_MODE = 2
    CLIENT_MODE_CHOICES = (
        (MONITOR_MODE, "Monitor"),
        (LOCKDOWN_MODE, "Lockdown"),
    )
    PREFLIGHT_MONITOR_MODE = "MONITOR"
    PREFLIGHT_LOCKDOWN_MODE = "LOCKDOWN"
    DEFAULT_BATCH_SIZE = 50
    DEFAULT_FULL_SYNC_INTERVAL = 600
    LOCAL_CONFIGURATION_ATTRIBUTES = {
        'client_mode',
        'file_changes_regex',
        'file_changes_prefix_filters',
        'allowed_path_regex',
        'blocked_path_regex',
        'enable_page_zero_protection',
        'enable_bad_signature_protection',
        'enable_sysx_cache',
        'more_info_url',
        'event_detail_url',
        'event_detail_text',
        'unknown_block_message',
        'banned_block_message',
        'mode_notification_monitor',
        'mode_notification_lockdown',
        'machine_owner_plist',
        'machine_owner_key',
        'client_auth_certificate_issuer_cn',
    }
    SYNC_SERVER_CONFIGURATION_ATTRIBUTES = {
        # 'client_mode', has to be translated to a string value
        'batch_size',
        'full_sync_interval',
        'allowed_path_regex',
        'blocked_path_regex',
        'enable_bundles',
        'enable_transitive_rules'
    }
    DEPRECATED_ATTRIBUTES_MAPPING_1_14 = {
        'allowed_path_regex': 'whitelist_regex',
        'blocked_path_regex': 'blacklist_regex',
        'enable_transitive_rules': 'enabled_transitive_whitelisting',
    }

    name = models.CharField(max_length=256, unique=True)

    client_mode = models.IntegerField(choices=CLIENT_MODE_CHOICES, default=MONITOR_MODE)
    file_changes_regex = models.TextField(
        blank=True,
        help_text="The regex of paths to log file changes. Regexes are specified in ICU format."
    )
    file_changes_prefix_filters = models.TextField(
        blank=True,
        help_text=("A list of ignore prefixes which are checked in-kernel. "
                   "This is more performant than FileChangesRegex when ignoring whole directory trees.")
    )
    allowed_path_regex = models.TextField(
        blank=True,
        help_text="Matching binaries will be allowed to run, in both modes."
                  "Events will be logged with the 'ALLOW_SCOPE' decision."
    )
    blocked_path_regex = models.TextField(
        blank=True,
        help_text="In Monitor mode, executables whose paths are matched by this regex will be blocked."
    )
    enable_page_zero_protection = models.BooleanField(
        default=True,
        help_text="If this flag is set to YES, 32-bit binaries that are missing the __PAGEZERO segment will be blocked"
                  " even in MONITOR mode, unless the binary is whitelisted by an explicit rule."
    )
    enable_bad_signature_protection = models.BooleanField(
        default=False,
        help_text="When enabled, a binary that is signed but has a bad signature (cert revoked, binary tampered with, "
                  "etc.) will be blocked regardless of client-mode unless a binary whitelist."
    )
    enable_sysx_cache = models.BooleanField(
        "Enable system extension cache",
        default=False,
        help_text="When enabled, a self-managed cache for decision responses will be used to help improve performance "
                  "when running Santa as a system extension alongside another system extension."
    )
    more_info_url = models.URLField(
        blank=True,
        help_text='The URL to open when the user clicks "More Info..." when opening Santa.app. '
                  'If unset, the button will not be displayed.'
    )
    event_detail_url = models.URLField(
        blank=True,
        max_length=1024,
        help_text="When the user gets a block notification, a button can be displayed which will take them "
                  "to a web page with more information about that event."
                  "This property contains a kind of format string to be turned into the URL to send them to. "
                  "The following sequences will be replaced in the final URL: "
                  "%file_sha%, "
                  "%machine_id%, "
                  "%username%, "
                  "%bundle_id%, "
                  "%bundle_ver%."
    )
    event_detail_text = models.TextField(
        blank=True,
        help_text="Related to the above property, this string represents the text to show on the button."
    )
    unknown_block_message = models.TextField(
        default="The following application has been blocked from executing<br/>\n"
                "because its trustworthiness cannot be determined.",
        help_text="In Lockdown mode this is the message shown to the user when an unknown binary is blocked."
    )
    banned_block_message = models.TextField(
        default="The following application has been blocked from executing<br/>\n"
                "because it has been deemed malicious.",
        help_text="This is the message shown to the user when a binary is blocked because of a rule "
                  "if that rule doesn't provide a custom message."
    )
    mode_notification_monitor = models.TextField(
        default="Switching into Monitor mode",
        help_text="The notification text to display when the client goes into Monitor mode."
    )
    mode_notification_lockdown = models.TextField(
        default="Switching into Lockdown mode",
        help_text="The notification text to display when the client goes into Lockdown mode."
    )
    machine_owner_plist = models.CharField(
        blank=True,
        max_length=512,
        help_text="The path to a plist that contains the machine owner key / value pair."
    )
    machine_owner_key = models.CharField(
        blank=True,
        max_length=128,
        help_text="The key to use on the machine owner plist."
    )

    # Zentral options

    allow_unknown_shard = models.IntegerField(
        validators=[MinValueValidator(0), MaxValueValidator(100)],
        default=100,
        help_text="Restrict the reporting of 'Allow Unknown' events to a percentage (0-100) of hosts"
    )

    # TLS

    # for the client cert authentication
    client_certificate_auth = models.BooleanField(
        "Client certificate authentication",
        default=False,
        help_text="If set, a client certificate will be required for sync authentication. "
                  "Santa will automatically look for a matching certificate "
                  "and its private key in the System keychain, "
                  "if the TLS server advertises the accepted CA certificates. "
                  "If the CA certificates are not sent to the client, "
                  "use the Client Auth Certificate Issuer CN setting."
    )
    client_auth_certificate_issuer_cn = models.CharField(
        "Client auth certificate issuer CN",
        blank=True,
        max_length=255,
        help_text="If set, this is the Issuer Name of a certificate in the System keychain "
                  "to be used for sync authentication. "
                  "The corresponding private key must also be in the keychain."
    )

    # the extra ones only provided via server sync
    # https://santa.readthedocs.io/en/latest/deployment/configuration/#sync-server-provided-configuration

    batch_size = models.IntegerField(
        default=DEFAULT_BATCH_SIZE,
        validators=[MinValueValidator(5), MaxValueValidator(100)],
        help_text="The number of rules to download or events to upload per request. "
                  "Multiple requests will be made if there is more work than can fit in single request."
    )
    full_sync_interval = models.IntegerField(
        default=DEFAULT_FULL_SYNC_INTERVAL,
        validators=[MinValueValidator(60), MaxValueValidator(86400)],
        help_text="The max time to wait in seconds before performing a full sync with the server. "
                  "Minimum: 60s, hardcoded in Santa."
    )
    enable_bundles = models.BooleanField(
        default=False,
        help_text="If set, the bundle scanning feature is enabled."
    )
    enable_transitive_rules = models.BooleanField(
        default=False,
        help_text="If set, the transitive rule feature is enabled."
    )

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = ConfigurationManager()

    def __str__(self):
        return self.name

    def get_absolute_url(self):
        return reverse("santa:configuration", args=(self.pk,))

    def is_monitor_mode(self):
        return self.client_mode == self.MONITOR_MODE

    def get_sync_server_config(self, santa_version):
        config = {k: getattr(self, k)
                  for k in self.SYNC_SERVER_CONFIGURATION_ATTRIBUTES}

        # translate client mode
        if self.client_mode == self.MONITOR_MODE:
            config["client_mode"] = self.PREFLIGHT_MONITOR_MODE
        elif self.client_mode == self.LOCKDOWN_MODE:
            config["client_mode"] = self.PREFLIGHT_LOCKDOWN_MODE
        else:
            raise NotImplementedError("Unknown santa client mode: {}".format(self.client_mode))

        # provide non matching regexp if the regexp are empty
        for attr in ("allowed_path_regex",
                     "blocked_path_regex"):
            if not config.get(attr):
                config[attr] = "NON_MATCHING_PLACEHOLDER_{}".format(get_random_string(8))

        # translate attributes for older santa agents
        santa_version = tuple(int(i) for i in santa_version.split("."))
        if santa_version < (1, 14):
            for attr, deprecated_attr in self.DEPRECATED_ATTRIBUTES_MAPPING_1_14.items():
                config[deprecated_attr] = config.pop(attr)

        return config

    def get_local_config(self, min_supported_santa_version=(1, 13)):
        config = {}
        for k in self.LOCAL_CONFIGURATION_ATTRIBUTES:
            v = getattr(self, k)
            if not v:
                continue
            if min_supported_santa_version < (1, 14) and k in self.DEPRECATED_ATTRIBUTES_MAPPING_1_14:
                k = self.DEPRECATED_ATTRIBUTES_MAPPING_1_14[k]
            config_attr_items = []
            for i in k.split("_"):
                if i == "url":
                    i = "URL"
                elif i == "cn":
                    i = "CN"
                else:
                    i = i.capitalize()
                config_attr_items.append(i)
            config_attr = "".join(config_attr_items)
            config[config_attr] = v
        return config

    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)
        for enrollment in self.enrollment_set.all():
            # per default, will bump the enrollment version
            # and notify their distributors
            enrollment.save()

    def serialize_for_event(self, basic=False):
        d = {"pk": self.pk, "name": self.name}
        if basic:
            return d
        return d


class Enrollment(BaseEnrollment):
    configuration = models.ForeignKey(Configuration, on_delete=models.CASCADE)

    def get_description_for_distributor(self):
        return "Santa configuration: {}".format(self.configuration)

    def serialize_for_event(self):
        enrollment_dict = super().serialize_for_event()
        enrollment_dict["configuration"] = {"pk": self.configuration.pk,
                                            "name": self.configuration.name}
        return enrollment_dict

    def get_absolute_url(self):
        return "{}#enrollment_{}".format(reverse("santa:configuration", args=(self.configuration.pk,)), self.pk)


class EnrolledMachine(models.Model):
    enrollment = models.ForeignKey(Enrollment, on_delete=models.CASCADE)

    hardware_uuid = models.UUIDField()  # DB index?
    serial_number = models.TextField(db_index=True)

    primary_user = models.TextField(null=True)
    client_mode = models.IntegerField(choices=Configuration.CLIENT_MODE_CHOICES)
    santa_version = models.TextField()

    binary_rule_count = models.IntegerField(null=True)
    certificate_rule_count = models.IntegerField(null=True)
    compiler_rule_count = models.IntegerField(null=True)
    transitive_rule_count = models.IntegerField(null=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = ("enrollment", "hardware_uuid")


# Rules


class TargetManager(models.Manager):
    def summary(self):
        query = (
            "with collected_files as ("
            "  select f.sha_256, f.signed_by_id, f.name"
            "  from inventory_file as f"
            "  join inventory_source as s on (f.source_id = s.id)"
            "  where s.module = 'zentral.contrib.santa' and s.name = 'Santa events'"
            "  group by f.sha_256, f.signed_by_id, f.name"
            "), collected_certificates as ("
            "  select distinct c.sha_256"
            "  from inventory_certificate as c"
            "  join collected_files as f on (c.id = f.signed_by_id)"
            ") "
            "select 'binary' as target_type,"
            "count(*) as target_count,"
            "(select count(distinct t.id)"
            " from santa_target as t"
            " join collected_files as f on (t.type = 'BINARY' and t.sha256=f.sha_256)"
            " join santa_rule as r on (t.id = r.target_id)) as rule_count "
            "from collected_files "
            "union "
            "select 'certificate' as target_type,"
            "count(*) as target_count,"
            "(select count(distinct t.id)"
            " from santa_target as t"
            " join collected_certificates as c on (t.type = 'CERTIFICATE' and t.sha256=c.sha_256)"
            " join santa_rule as r on (t.id = r.target_id)) as rule_count "
            "from collected_certificates "
            "union "
            "select 'bundle' as target_type,"
            "count(*) as target_count,"
            "(select count(distinct b.id)"
            " from santa_bundle as b"
            " join santa_rule as r on (b.target_id = r.target_id)) as rule_count "
            "from santa_bundle"
        )
        cursor = connection.cursor()
        cursor.execute(query)
        summary = {"total": 0}
        for target_type, target_count, rule_count in cursor.fetchall():
            summary[target_type.lower()] = {"count": target_count, "rule_count": rule_count}
            summary["total"] += target_count
        return summary

    def search_query(self, q=None, target_type=None):
        if not target_type:
            target_type = None
        kwargs = {}
        if q:
            kwargs["q"] = "%{}%".format(connection.ops.prep_for_like_query(q))
            bi_where = "where upper(name) like upper(%(q)s) or upper(sha256) like upper(%(q)s)"
            ce_where = ("where upper(c.common_name) like upper(%(q)s) "
                        "or upper(c.organizational_unit) like upper(%(q)s) "
                        "or upper(c.sha_256) like upper(%(q)s)")
            bu_where = "where upper(name) like upper(%(q)s) or upper(sha256) like upper(%(q)s)"
        else:
            bi_where = ce_where = bu_where = ""
        targets_subqueries = {
            "BINARY":
                "select 'BINARY' as target_type,  f.sha256, f.name as sort_str,"
                "jsonb_build_object("
                " 'name', f.name,"
                " 'cert_cn', c.common_name,"
                " 'cert_sha256', c.sha_256,"
                " 'cert_ou', c.organizational_unit"
                ") as object "
                "from collected_files as f "
                "left join inventory_certificate as c on (f.signed_by_id = c.id) "
                f"{bi_where}"
                "group by target_type, f.sha256, f.name, c.common_name, c.sha_256, c.organizational_unit",
            "CERTIFICATE":
                "select 'CERTIFICATE' as target_type, c.sha_256 as sha256, c.common_name as sort_str,"
                "jsonb_build_object("
                " 'cn', c.common_name,"
                " 'ou', c.organizational_unit,"
                " 'valid_from', c.valid_from,"
                " 'valid_until', c.valid_until"
                ") as object "
                "from inventory_certificate as c "
                "join collected_files as f on (c.id = f.signed_by_id) "
                f"{ce_where}"
                "group by target_type, c.sha_256, c.common_name, c.organizational_unit, c.valid_from, c.valid_until",
            "BUNDLE":
                "select 'BUNDLE' as target_type, t.sha256, b.name as sort_str,"
                "jsonb_build_object("
                " 'name', b.name,"
                " 'version', b.version,"
                " 'version_str', b.version_str"
                ") as object "
                "from santa_bundle as b "
                "join santa_target as t on (b.target_id = t.id) "
                f"{bu_where}"
        }
        targets_query = " union ".join(v for k, v in targets_subqueries.items()
                                       if target_type is None or k == target_type)
        query = (
            "with collected_files as ("
            "  select f.sha_256 as sha256, f.signed_by_id, f.name"
            "  from inventory_file as f"
            "  join inventory_source as s on (f.source_id = s.id)"
            "  where s.module='zentral.contrib.santa' and s.name = 'Santa events'"
            "  group by f.sha_256, f.signed_by_id, f.name"
            f"), targets as ({targets_query}) "
            "select target_type, sha256, object, count(*) over() as full_count,"
            "(select count(*) from santa_rule as r"
            " join santa_target as t on (r.target_id = t.id)"
            " where t.type = ts.target_type and t.sha256 = ts.sha256) as rule_count "
            "from targets as ts "
            "order by sort_str, sha256 "
        )
        return query, kwargs

    def search(self, q=None, target_type=None, offset=0, limit=10):
        query, kwargs = self.search_query(q, target_type)
        kwargs.update({"offset": offset, "limit": limit})
        cursor = connection.cursor()
        cursor.execute(f"{query} offset %(offset)s limit %(limit)s", kwargs)
        columns = [col[0] for col in cursor.description]
        results = []
        for row in cursor.fetchall():
            result = dict(zip(columns, row))
            obj = result["object"]
            for attr in ("valid_from", "valid_until"):
                if attr in obj:
                    obj[attr] = parser.parse(obj[attr])
            url_name = result["target_type"].lower()
            result["url"] = reverse(f"santa:{url_name}", args=(result["sha256"],))
            results.append(result)
        return results


class Target(models.Model):
    BINARY = "BINARY"
    BUNDLE = "BUNDLE"
    CERTIFICATE = "CERTIFICATE"
    TYPE_CHOICES = (
        (BINARY, "Binary"),
        (BUNDLE, "Bundle"),
        (CERTIFICATE, "Certificate"),
    )
    type = models.CharField(choices=TYPE_CHOICES, max_length=16)
    sha256 = models.CharField(max_length=64)

    objects = TargetManager()

    class Meta:
        unique_together = (("type", "sha256"),)

    def get_absolute_url(self):
        return reverse(f"santa:{self.type.lower()}", args=(self.sha256,))

    @cached_property
    def files(self):
        if self.type == self.BINARY:
            return list(File.objects.select_related("bundle").filter(sha_256=self.sha256))
        else:
            return []

    @cached_property
    def certificates(self):
        if self.type == self.CERTIFICATE:
            return list(Certificate.objects.filter(sha_256=self.sha256))
        else:
            return []

    def serialize_for_event(self):
        return {"type": self.type, "sha256": self.sha256}


class BundleManager(models.Manager):
    def search(self, **kwargs):
        name = kwargs.get("name")
        if name:
            qs = self.filter(Q(name__icontains=name) | Q(bundle_id__icontains=name))
            return (
                qs.select_related("target")
                  .annotate(binary_target_count=Count("binary_targets"))
                  .order_by("name")
            )
        else:
            return []


class Bundle(models.Model):
    target = models.OneToOneField(Target, on_delete=models.PROTECT)

    path = models.TextField()
    executable_rel_path = models.TextField()
    bundle_id = models.TextField()
    name = models.TextField()
    version = models.TextField()
    version_str = models.TextField()

    binary_count = models.PositiveIntegerField()
    binary_targets = models.ManyToManyField(Target, related_name="parent_bundle")
    uploaded_at = models.DateTimeField(null=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    objects = BundleManager()

    def __str__(self):
        return f"{self.bundle_id} {self.version_str}"

    def get_absolute_url(self):
        return reverse("santa:bundle", args=(self.target.sha256,))


class RuleSet(models.Model):
    name = models.CharField(max_length=256, unique=True)

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    def __str__(self):
        return self.name

    def serialize_for_event(self):
        return {"pk": self.pk, "name": self.name}


def translate_rule_policy(policy):
    if not isinstance(policy, int):
        policy = int(policy)
    if policy == Rule.ALLOWLIST:
        return "ALLOWLIST"
    elif policy == Rule.ALLOWLIST_COMPILER:
        return "ALLOWLIST_COMPILER"
    elif policy == Rule.BLOCKLIST:
        return "BLOCKLIST"
    elif policy == Rule.SILENT_BLOCKLIST:
        return "SILENT_BLOCKLIST"
    elif policy == MachineRule.REMOVE:
        return "REMOVE"
    else:
        raise ValueError(f"Unknown santa policy: {policy}")


class Rule(models.Model):
    ALLOWLIST = 1
    BLOCKLIST = 2
    SILENT_BLOCKLIST = 3
    ALLOWLIST_COMPILER = 5
    POLICY_CHOICES = (
        (ALLOWLIST, "Allowlist"),
        (BLOCKLIST, "Blocklist"),
        (SILENT_BLOCKLIST, "Silent blocklist"),
        (ALLOWLIST_COMPILER, "Allowlist compiler"),
    )
    BUNDLE_POLICIES = (ALLOWLIST, ALLOWLIST_COMPILER)
    configuration = models.ForeignKey(Configuration, on_delete=models.CASCADE)
    ruleset = models.ForeignKey(RuleSet, on_delete=models.CASCADE, null=True)

    target = models.ForeignKey(Target, on_delete=models.PROTECT)
    policy = models.PositiveSmallIntegerField(choices=POLICY_CHOICES)
    custom_msg = models.TextField(blank=True)
    version = models.PositiveIntegerField(default=1)

    # scope
    serial_numbers = ArrayField(models.TextField(), blank=True, default=list)
    excluded_serial_numbers = ArrayField(models.TextField(), blank=True, default=list)
    primary_users = ArrayField(models.TextField(), blank=True, default=list)
    excluded_primary_users = ArrayField(models.TextField(), blank=True, default=list)
    tags = models.ManyToManyField(Tag, blank=True, related_name="+")
    excluded_tags = models.ManyToManyField(Tag, blank=True, related_name="+")

    created_at = models.DateTimeField(auto_now_add=True)
    updated_at = models.DateTimeField(auto_now=True)

    class Meta:
        unique_together = (("configuration", "target"),)

    def is_blocking_rule(self):
        return self.policy in (self.BLOCKLIST, self.SILENT_BLOCKLIST)

    def get_absolute_url(self):
        return reverse("santa:configuration_rules", args=(self.configuration_id,)) + f"#rule-{self.pk}"

    def serialize_for_event(self):
        d = {
            "configuration": self.configuration.serialize_for_event(basic=True),
            "target": self.target.serialize_for_event(),
            "policy": translate_rule_policy(self.policy),
        }
        if self.ruleset: