import re
import email.utils
import inspect
import json
import logging

import django.db
import django.core.mail
import django.core.exceptions

import mini_buildd.config
import mini_buildd.misc
import mini_buildd.net
import mini_buildd.gnupg
import mini_buildd.ftpd

import mini_buildd.models.base
import mini_buildd.models.repository
import mini_buildd.models.chroot
import mini_buildd.models.gnupg

LOG = logging.getLogger(__name__)


class Daemon(mini_buildd.models.base.StatusModel):
    MBD_HELP_PICKLED_DATA = """\
Saved ``setup`` config as command line options. See ``mini-buildd-api setup --help`` for syntax.

You may set values manually here, or just use ``save=True`` in an any call to ``setup``, either from the command line or web application.

A setup command preconfigured with these values will be available from the ``setup`` page.

Note: In case you see a base64 dump, just remove it. It's a now unused leftover from 1.0.x usage of this field (and no good way to avoid it completely).
"""

    MBD_HELP_EXTRA_OPTIONS = """
X-Archive-Origin: <origin>
  Custom APT "Origin" field for repository indices.
"""

    MBD_HELP_NEEDS_RESTART = "[Daemon must be restarted to activate this setting.]\n\n"

    # Basics
    identity = django.db.models.CharField(max_length=50,
                                          default=mini_buildd.config.HOSTNAME,
                                          help_text="""\
Will be used to identify this mini-buildd instance in various places:

* 'Name-Real' part of the GnuPG key.
* name of the automated keyring packages.
* the 'Origin' tag of repository indices (but this may also be customized via 'X-Archive-Origin' extra option, see ``Extra options`` below).
* to identify remotes.
""")

    hostname = django.db.models.CharField(
        max_length=200,
        blank=True,
        help_text="Obsoleted: Use command line arg '--hostname' instead.")

    email_address = django.db.models.EmailField(max_length=255, default=f"mini-buildd@{mini_buildd.config.HOSTNAME_FQDN}",
                                                help_text="""\
Will be used in:

* 'Name-Email' part of the GnuPG key.
* Sender for notification email.
""")

    # GnuPG options
    gnupg_template = django.db.models.TextField(default=("Key-Type: RSA\n"
                                                         "Key-Length: 4096\n"
                                                         "Expire-Date: 0\n"),
                                                help_text="""\
Template as accepted by 'gpg --batch --gen-key' (see 'man gpg').

You should not give 'Name-Real' and 'Name-Email', as these are
automatically added.

Prepare/Remove actions will generate/remove the GnuPG key.
""")

    gnupg_keyserver = django.db.models.CharField(
        max_length=200,
        default="keyserver.ubuntu.com",
        help_text="GnuPG keyserver to use (as fill-in helper).")

    ftpd_bind = django.db.models.CharField(
        max_length=200,
        default="tcp:port=8067",
        help_text=MBD_HELP_NEEDS_RESTART + f"FTP Server Endpoint: {inspect.getdoc(mini_buildd.net.ServerEndpoint)}")

    ftpd_options = django.db.models.CharField(max_length=255,
                                              default="",
                                              blank=True,
                                              help_text=MBD_HELP_NEEDS_RESTART + mini_buildd.ftpd.FtpD.HANDLER_OPTIONS_USAGE)

    # Load options
    build_queue_size = django.db.models.IntegerField(
        default=mini_buildd.misc.get_cpus(),
        help_text=MBD_HELP_NEEDS_RESTART + "Maximum number of parallel builds.",
        verbose_name="Max Parallel Builds")

    sbuild_jobs = django.db.models.IntegerField(
        default=1,
        help_text=("DEPRECATED/UNUSED (since 1.99.17). See Developer Manual, 'Is package building parallel?'"))

    # EMail options
    # DEPRECATED/UNUSED: With the switch to django mail framework, this is now configured via the --smtp command line argument.
    smtp_server = django.db.models.CharField(
        max_length=254,
        default="localhost:25",
        help_text="DEPRECATED/UNUSED: Replaced by '--smtp' command line option.")

    notify = django.db.models.ManyToManyField(mini_buildd.models.repository.EmailAddress,
                                              blank=True,
                                              help_text="Addresses that get all notification emails unconditionally.")
    allow_emails_to = django.db.models.CharField(
        max_length=254,
        default=f".*@{mini_buildd.config.HOSTNAME_FQDN}",
        help_text="""\
Regex to allow sending emails to automatically computed
addresses (currently the 'Changed-By' or 'Maintainer' addresses
when this feature is enabled for the resp. repository).

Use '.*' to allow all -- it's however recommended to put this to
s.th. like '.*@myemail.domain', to avoid original package
maintainers to be accidentally spammed.
""")

    custom_hooks_directory = django.db.models.CharField(max_length=255, default="", blank=True, help_text="DEPRECATED, never used (there are now event hooks in predefined path ~/etc/event-hooks).")

    show_last_packages = django.db.models.IntegerField(
        default=500,
        help_text=MBD_HELP_NEEDS_RESTART + "Max number of items that can be stored.",
        verbose_name="Event Queue Size")
    show_last_builds = django.db.models.IntegerField(
        default=100,
        help_text="Max number of items visible on home page (this essentially pimps its load time).",
        verbose_name="Event Queue View Size")

    wait_for_build_results = django.db.models.IntegerField(
        default=5,
        help_text="Future use: How many days to wait for build results until finishing a package.")
    keep_build_results = django.db.models.IntegerField(
        default=5,
        help_text="Future use: How many days to keep build results that cannot be uploaded.")

    LETHAL_DEPENDENCIES = False

    class Meta(mini_buildd.models.base.StatusModel.Meta):
        verbose_name_plural = "Daemon"

    class Admin(mini_buildd.models.base.StatusModel.Admin):
        exclude = ("hostname", "custom_hooks_directory", "sbuild_jobs", "smtp_server", "wait_for_build_results", "keep_build_results")

        filter_horizontal = ("notify",)

        @classmethod
        def _mbd_on_activation(cls, obj):
            mini_buildd.get_daemon().mbd_start()

        @classmethod
        def _mbd_on_deactivation(cls, obj):
            mini_buildd.get_daemon().mbd_stop()

    def save(self, *args, **kwargs):
        """Save model && sync with Daemon()."""
        super().save(*args, **kwargs)
        mini_buildd.get_daemon().sync()

    def __str__(self):
        return (f"{self.identity}: Serving {len(mini_buildd.models.repository.Repository.mbd_get_active())} repositories, "
                f"{len(mini_buildd.models.chroot.Chroot.mbd_get_active())} chroots, "
                f"using {len(mini_buildd.models.gnupg.Remote.mbd_get_active())} remotes")

    def mbd_setup_v10x2v20x(self):
        """
        Fixup v10x->v20x 'pickled_data' is now 'setup' upgrade convenience.

        Arbitrary heuristic number to decide whether the data is actually still pickled data from 1.0.x, and should be automatically blanked:
         - Typical 1.0.x "last packages" pickled size: 200000
         - A rather lengthy (but still real life) setup may have about: 2000

         This practically limits the possible setup command line length for the convenience save.
        """
        max_setup_len = 4000
        if len(self.pickled_data) > max_setup_len:
            LOG.warning(f"Setup length ({len(self.pickled_data)}) exceeds max setup length ({max_setup_len}): Setting blank (assuming pickled data from 1.0.x)")
            self.mbd_setup_save("")

    def mbd_setup_save(self, command_line):
        """Save setup command line."""
        LOG.info(f"Saving setup: {command_line}")
        self.pickled_data = command_line
        self.save(update_fields=["pickled_data"])

    def mbd_setup_get(self):
        return self.pickled_data

    def mbd_get_ftp_endpoint(self):
        return mini_buildd.net.ServerEndpoint(self.ftpd_bind, protocol="ftp")

    def mbd_fullname(self):
        return f"mini-buildd archive {self.identity}"

    def mbd_gnupg(self):
        return mini_buildd.gnupg.GnuPG(self.gnupg_template, self.mbd_fullname(), self.email_address)

    def clean(self, *args, **kwargs):
        super().clean(*args, **kwargs)
        self.mbd_validate_regex(r"^[a-zA-Z0-9\-]+$", self.identity, "identity")

        try:
            mini_buildd.ftpd.FtpD.parse_handler_options(self.ftpd_options)
        except BaseException as e:
            raise django.core.exceptions.ValidationError({"ftpd_options": f"Can't parse '{self.ftpd_options}': {mini_buildd.e2http(e)}"})

        try:
            mini_buildd.net.ServerEndpoint(self.ftpd_bind, protocol="ftp")
        except BaseException as e:
            raise django.core.exceptions.ValidationError({"ftpd_bind": f"Can't parse '{self.ftpd_bind}': {mini_buildd.e2http(e)}"})

        if Daemon.objects.count() > 0 and self.id != Daemon.objects.get().id:
            raise django.core.exceptions.ValidationError("You can only create one Daemon instance!")

    def mbd_prepare(self):
        self.mbd_gnupg().prepare()
        LOG.info("Daemon GnuPG prepared.")

    @classmethod
    def mbd_sync(cls):
        LOG.warning("The GnuPG key will never be updated automatically. Explicitly run remove+prepare to achieve this.")

    def mbd_remove(self):
        self.mbd_gnupg().remove()
        LOG.info("Daemon GnuPG removed.")

    def mbd_get_dependencies(self):
        """All active or to-be active repositories, remotes and chroots."""
        result = []
        for model in [mini_buildd.models.repository.Repository, mini_buildd.models.chroot.Chroot, mini_buildd.models.gnupg.Remote]:
            for o in model.mbd_get_active_or_auto_reactivate():
                result.append(o)
        return result

    @classmethod
    def mbd_check(cls):
        """Just warn in case there are no repos and no chroots."""
        if not mini_buildd.models.repository.Repository.mbd_get_active() and not mini_buildd.models.chroot.Chroot.mbd_get_active():
            LOG.warning("No active chroot or repository.")

    def mbd_get_archive_origin(self):
        return self.mbd_get_extra_option("X-Archive-Origin", f"Mini-Buildd {self.identity}")

    def mbd_get_pub_key(self):
        return self.mbd_gnupg().get_pub_key()

    def mbd_get_dput_conf(self):
        ftp = self.mbd_get_ftp_endpoint()
        return f"""\
[mini-buildd-{self.identity}]
method   = {'ftps' if ftp.is_ssl() else 'ftp'}
fqdn     = {ftp.hopo()}
login    = anonymous
incoming = /incoming
"""

    def _mbd_notify_signature(self, typ):
        reason = {"daemon": "Your address is configured to get any notifications (contact administrators if you don't want this).",
                  "repository": "Your address is configured to get any notifications for this repository (contact administrators if you don't want this).",
                  "changed-by": "Your address is the uploader of the package ('Changed-By' in changes).",
                  "maintainer": "Your address is the maintainer of the package ('Maintainer' in changes).",
                  "subscriber": "Your user account has a matching subscription.",
                  }.get(typ, "Unknown")

        return (f"--\N{SPACE}\n"
                f"mini-buildd instance '{self.identity}' at {mini_buildd.config.HOSTNAME} <{self.email_address}>\n"
                f"Reason for this mail: {reason}\n"
                f"Visit mini-buildd   : {mini_buildd.http_endpoint().geturl()}\n")

    def mbd_notify_event(self, event):
        repository, distribution = None, None
        try:
            _dist, repository, distribution, _suite = mini_buildd.models.repository.parse_diststr(event.distribution)
        except BaseException:
            pass

        try:
            subject = f"{event}"
            body = json.dumps(event.to_json(), indent=2) + "\n"

            # Old mbd_notify() code:
            subject_prefix = f"[mini-buildd-{self.identity}] "
            m_to = []
            m_to_raw = []
            m_automatic_to_allow = re.compile(self.allow_emails_to)

            def add_to(address, typ, is_automatic):
                address_raw = email.utils.parseaddr(address)[1]
                if not is_automatic or m_automatic_to_allow.search(address_raw):
                    if address_raw in m_to_raw:
                        LOG.debug(f"Notify: Skipping {typ} address: {address}: Duplicate")
                    else:
                        m_to.append((subject_prefix + subject, body + self._mbd_notify_signature(typ), self.email_address, [address]))
                        m_to_raw.append(address_raw)
                        LOG.info(f"Notify: Adding {typ} address: {address}")
                else:
                    LOG.warning(f"Notify: Skipping {typ} address: {address}: Not allowed (only '{self.allow_emails_to}')")

            def get_subscriptions():
                source = "" if event.source is None else event.source
                real_distribution = distribution
                if real_distribution is None:
                    # If distribution was not given explicitly, try from changes, resolving meta dists if needed.
                    changes_dist = "" if event.distribution is None else event.distribution
                    real_distribution = mini_buildd.models.repository.map_distribution(changes_dist)
                return mini_buildd.models.subscription.Subscription.objects.filter(package__in=[source, ""], distribution__in=[real_distribution, ""])

            # Add hardcoded addresses from daemon
            for m in self.notify.all():
                add_to(m.__str__(), "daemon", is_automatic=False)

            # Add hardcoded addresses from repository
            if repository:
                for m in repository.notify.all():
                    add_to(m.__str__(), "repository", is_automatic=False)

            # Add package uploader (Changed-By): Add even if we do not have a repo, so uploader is informed on such error cases too.
            if event.extra.get("changed_by") and (repository is None or repository.notify_changed_by):
                add_to(event.extra.get("changed_by"), "changed-by", is_automatic=True)

            # Add package maintainer: Add only when we have a repo, and it's configured to do so
            if event.extra.get("maintainer") and repository and repository.notify_maintainer:
                add_to(event.extra.get("maintainer"), "maintainer", is_automatic=True)

            # Add user subscriptions
            for s in get_subscriptions():
                address = f"{s.subscriber.get_full_name()} <{s.subscriber.email}>"
                if s.subscriber.is_active:
                    add_to(address, "subscriber", is_automatic=False)
                else:
                    LOG.debug(f"Notify: Skipping subscription address: {address}: Account disabled")

            django.core.mail.send_mass_mail(m_to)
            LOG.info(f"EMail notification for event ({event}) sent.")
        except BaseException as e:
            mini_buildd.log_exception(LOG, f"EMail notification for event ({event}) FAILED", e)


def get():
    model, created = mini_buildd.models.daemon.Daemon.objects.get_or_create(id=1, defaults={"ftpd_bind": mini_buildd.config.default_ftp_endpoint()})
    if created:
        LOG.info("New Daemon model instance created")
    else:
        model.mbd_setup_v10x2v20x()
    return model
