# -*- coding: utf-8 -*-
#
# Copyright (C) 2014-2022 by the Free Software Foundation, Inc.
#
# This file is part of HyperKitty.
#
# HyperKitty is free software: you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option)
# any later version.
#
# HyperKitty is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
# FITNESS FOR A PARTICULAR PURPOSE.  See the GNU General Public License for
# more details.
#
# You should have received a copy of the GNU General Public License along with
# HyperKitty.  If not, see <http://www.gnu.org/licenses/>.
#
# Author: Aurelien Bompard <abompard@fedoraproject.org>
#

import logging
import os
import re
from email.generator import BytesGenerator
from email.message import EmailMessage
from email.utils import formataddr
from io import BytesIO

from django.conf import settings
from django.db import IntegrityError, models
from django.utils.timezone import get_fixed_timezone, now

from hyperkitty.lib.analysis import compute_thread_order_and_depth
from .common import VotesCachedValue
from .mailinglist import MailingList
from .thread import Thread
from .vote import Vote


logger = logging.getLogger(__name__)


class Email(models.Model):
    """
    An archived email, from a mailing-list. It is identified by both the list
    name and the message id.
    """
    mailinglist = models.ForeignKey(
        "MailingList", related_name="emails", on_delete=models.CASCADE)
    message_id = models.CharField(max_length=255, db_index=True)
    message_id_hash = models.CharField(max_length=255, db_index=True)
    sender = models.ForeignKey(
        "Sender", related_name="emails", on_delete=models.CASCADE)
    sender_name = models.CharField(max_length=255, null=True, blank=True)
    subject = models.CharField(max_length=512, db_index=True)
    content = models.TextField()
    date = models.DateTimeField(db_index=True)
    timezone = models.SmallIntegerField()
    in_reply_to = models.CharField(
        max_length=255, null=True, blank=True, db_index=True)
    # XXX(maxking): Delete behavior is handled by on_pre_delete(). Since we
    # manually make sure that we set and unset parents and handle the fact that
    # they can't be None for more than one Email in a thread, we don't want
    # database engine to enforce the relationship is valid. Hence, we set the
    # on_delete=DO_NOTHING and db_constraint=False.
    parent = models.ForeignKey(
        "self", blank=True, null=True, on_delete=models.DO_NOTHING,
        related_name="children", db_constraint=False)
    thread = models.ForeignKey(
        "Thread", related_name="emails", on_delete=models.CASCADE)
    archived_date = models.DateTimeField(default=now, db_index=True)
    thread_depth = models.IntegerField(default=0)
    thread_order = models.IntegerField(null=True, blank=True, db_index=True)

    ADDRESS_REPLACE_RE = re.compile(r"([\w.+-]+)@([\w.+-]+)")

    def __init__(self, *args, **kwargs):
        super(Email, self).__init__(*args, **kwargs)
        self.cached_values = {
            "votes": VotesCachedValue(self),
        }

    def __lt__(self, other):
        return self.date < other.date

    class Meta:
        unique_together = ("mailinglist", "message_id")

    def get_votes(self):
        return self.cached_values["votes"]()

    def vote(self, value, user):
        # Checks if the user has already voted for this message.
        existing = self.votes.filter(user=user).first()
        if existing is not None and existing.value == value:
            return  # Vote already recorded (should I raise an exception?)
        if value not in (0, 1, -1):
            raise ValueError("A vote can only be +1 or -1 (or 0 to cancel)")
        if existing is not None:
            # vote changed or cancelled
            if value == 0:
                existing.delete()
            else:
                existing.value = value
                existing.save()
        else:
            # new vote
            vote = Vote(email=self, user=user, value=value)
            vote.save()

    def set_parent(self, parent):
        if self.id == parent.id:
            raise ValueError("An email can't be its own parent")
        # Compute the subthread
        subthread = [self]

        def _collect_children(current_email):
            children = list(current_email.children.all())
            if not children:
                return
            subthread.extend(children)
            for child in children:
                _collect_children(child)
        _collect_children(self)
        # now set my new parent value
        old_parent_id = self.parent_id
        self.parent = parent
        self.save(update_fields=["parent_id"])
        # If my future parent is in my current subthread, I need to set its
        # parent to my current parent
        if parent in subthread:
            parent.parent_id = old_parent_id
            parent.save(update_fields=["parent_id"])
            # do it after setting the new parent_id to avoid having two
            # parent_ids set to None at the same time (IntegrityError)
        if self.thread_id != parent.thread_id:
            # we changed the thread, reattach the subthread
            former_thread = self.thread
            for child in subthread:
                child.thread = parent.thread
                child.save(update_fields=["thread_id"])
                if child.date > parent.thread.date_active:
                    parent.thread.date_active = child.date
            parent.thread.save()
            # if we were the starting email, or former thread may be empty
            if former_thread.emails.count() == 0:
                former_thread.delete()
        compute_thread_order_and_depth(parent.thread)

    def as_message(self, escape_addresses=True):
        # http://wordeology.com/computer/how-to-send-good-unicode-email-with-python.html
        # http://stackoverflow.com/questions/31714221/how-to-send-an-email-with-quoted
        # http://stackoverflow.com/questions/9403265/how-do-i-use-python/9509718#9509718
        msg = EmailMessage()

        # Headers
        def unfold(hdr):
            return re.sub('[\r\n]', '', hdr)
        unixfrom = "From %s %s" % (
            self.sender.address, self.archived_date.strftime("%c"))
        assert isinstance(self.sender.address, str)
        header_from = self.sender.address
        if self.sender_name and self.sender_name != self.sender.address:
            header_from = formataddr((self.sender_name, header_from))
        header_to = self.mailinglist.name
        msg.set_unixfrom(unixfrom)
        headers = (
            ("From", header_from),
            ("To", header_to),
            ("Subject", self.subject),
            )
        for header_name, header_value in headers:
            msg[header_name] = unfold(header_value)
        tz = get_fixed_timezone(self.timezone)
        header_date = self.date.astimezone(tz).replace(microsecond=0)
        # Date format: http://tools.ietf.org/html/rfc5322#section-3.3
        msg["Date"] = header_date.strftime("%a, %d %b %Y %H:%M:%S %z")
        # Clean bogus characters from Message-ID.
        msg["Message-ID"] = "<%s>" % re.sub(r'[\s<>()]', '', self.message_id)
        if self.in_reply_to:
            msg["In-Reply-To"] = "<%s>" % unfold(self.in_reply_to)

        # Body
        content = self.ADDRESS_REPLACE_RE.sub(r"\1(a)\2", self.content)

        # Enforce `multipart/mixed` even when there are no attachments.
        msg.set_content(content, subtype='plain')
        msg.make_mixed()

        # Attachments
        for attachment in self.attachments.order_by("counter"):
            mimetype = attachment.content_type.split('/', 1)
            msg.add_attachment(attachment.get_content(), maintype=mimetype[0],
                               subtype=mimetype[1], filename=attachment.name)

        return msg

    def as_bytes(self):
        """Similar to EmailMessage.as_bytes() but sets mangle_from_=True.

        Serialize the current email into bytes to be added into an mbox.
        """
        msg = self.as_message()
        # We have to call BytesGenerator here to set
        # mangle_from_=True, which is set to 'False' by default in
        # EmailMessage.as_bytes() method.
        fd = BytesIO()
        gen = BytesGenerator(fd, mangle_from_=True)
        gen.flatten(msg, unixfrom=True)
        return fd.getvalue()

    @property
    def display_fixed(self):
        return "@@" in self.content

    def _set_message_id_hash(self):
        from hyperkitty.lib.utils import get_message_id_hash  # circular import
        if not self.message_id_hash:
            self.message_id_hash = get_message_id_hash(self.message_id)

    def on_post_init(self):
        self._set_message_id_hash()

    def on_post_created(self):
        self.thread.on_email_added(self)
        self.mailinglist.on_email_added(self)
        if not getattr(settings, "HYPERKITTY_BATCH_MODE", False):
            # For batch imports, let the cron job do the work
            from hyperkitty.tasks import check_orphans
            check_orphans(self.id)

    def on_pre_save(self):
        self._set_message_id_hash()
        # Link to the thread
        if self.thread_id is None:
            # Create the thread if not found
            thread, _thread_created = Thread.objects.get_or_create(
                mailinglist=self.mailinglist,
                thread_id=self.message_id_hash)
            self.thread = thread
        # Make sure there is only one email with parent_id == None in a thread
        if self.parent_id is not None:
            return
        starters = Email.objects.filter(
                thread=self.thread, parent_id__isnull=True
            ).values_list("id", flat=True)
        if len(starters) > 0 and list(starters) != [self.id]:
            raise IntegrityError("There can be only one email with "
                                 "parent_id==None in the same thread")

    def on_post_save(self):
        pass

    def on_pre_delete(self):
        # Reset parent_id
        children = self.children.order_by("date")
        if not children:
            return
        if self.parent is None:
            #  Temporarily set the email's parent_id to not None, to allow the
            #  next email to be the starting email (there's a check on_save for
            #  duplicate thread starters)
            self.parent = self
            self.save(update_fields=["parent"])
            starter = children[0]
            starter.parent = None
            starter.save(update_fields=["parent"])
            children.all().update(parent=starter)
        else:
            children.update(parent=self.parent)

    def on_post_delete(self):
        try:
            thread = Thread.objects.get(id=self.thread_id)
        except Thread.DoesNotExist:
            pass
        else:
            thread.on_email_deleted(self)
        try:
            mlist = MailingList.objects.get(pk=self.mailinglist_id)
        except MailingList.DoesNotExist:
            pass
        else:
            mlist.on_email_deleted(self)

    def on_vote_added(self, vote):
        from hyperkitty.tasks import rebuild_email_cache_votes
        rebuild_email_cache_votes(self.id)

    on_vote_deleted = on_vote_added


class Attachment(models.Model):
    email = models.ForeignKey(
        "Email", related_name="attachments", on_delete=models.CASCADE)
    counter = models.SmallIntegerField()
    name = models.CharField(max_length=255)
    content_type = models.CharField(max_length=255)
    encoding = models.CharField(max_length=255, null=True)
    size = models.IntegerField(null=True)
    content = models.BinaryField(null=True)

    class Meta:
        unique_together = ("email", "counter")

    def on_pre_save(self):
        # set the size
        if not self.size and self.content is not None:
            self.size = len(self.content)

    def _get_folder(self):
        global_folder = getattr(
            settings, "HYPERKITTY_ATTACHMENT_FOLDER", None)
        if global_folder is None:
            return None
        mlist = self.email.mailinglist.name
        try:
            listname, domain = mlist.rsplit("@", 1)
        except ValueError:
            listname = "none"
            domain = mlist
        return os.path.join(
            global_folder, domain, listname,
            self.email.message_id_hash[0:2],
            self.email.message_id_hash[2:4],
            self.email.message_id_hash[4:6],
            str(self.email.id),
        )

    def get_content(self):
        folder = self._get_folder()
        if folder is None or self.content is not None:
            return bytes(self.content)
        filepath = os.path.join(folder, str(self.counter))
        if not os.path.exists(filepath):
            logger.error("Could not find local attachment %s for email %s",
                         self.counter, self.email.id)
            return ""
        with open(filepath, "rb") as f:
            content = f.read()
        return content

    def set_content(self, content):
        if isinstance(content, str):
            if self.encoding is not None:
                content = content.encode(self.encoding, errors='replace')
            else:
                content = content.encode('utf-8')
        self.size = len(content)
        folder = self._get_folder()
        if folder is None:
            self.content = content
            return
        if not os.path.exists(folder):
            os.makedirs(folder)
        with open(os.path.join(folder, str(self.counter)), "wb") as f:
            f.write(content)
        self.content = None
