# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import random
import time
from collections import defaultdict
from email.message import EmailMessage
from functools import lru_cache, partial
from urllib.parse import (
    parse_qs, parse_qsl, urlencode, urljoin, urlsplit, urlunsplit)

from genshi.core import END, START, Attrs, QName
from genshi.template import MarkupTemplate
from genshi.template import TemplateError as GenshiTemplateError
from sql.aggregate import Count

import trytond.config as config
from trytond.i18n import gettext
from trytond.ir.session import token_hex
from trytond.model import (
    DeactivableMixin, Index, ModelSQL, ModelView, Unique, Workflow, fields)
from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.report import Report, get_email, html_to_text, mjml_to_html
from trytond.sendmail import SMTPDataManager, send_message_transactional
from trytond.tools.email_ import (
    EmailNotValidError, format_address, normalize_email, set_from_header,
    validate_email)
from trytond.transaction import Transaction, inactive_records
from trytond.url import http_base
from trytond.wizard import Button, StateTransition, StateView, Wizard

from .exceptions import EMailValidationError, TemplateError


def _add_params(url, **params):
    parts = urlsplit(url)
    query = parse_qsl(parts.query)
    for key, value in sorted(params.items()):
        query.append((key, value))
    parts = list(parts)
    parts[3] = urlencode(query)
    return urlunsplit(parts)


def _extract_params(url):
    return parse_qsl(urlsplit(url).query)


class Email(DeactivableMixin, ModelSQL, ModelView):
    __name__ = 'marketing.email'
    _rec_name = 'email'

    email = fields.Char("Email", required=True)
    list_ = fields.Many2One('marketing.email.list', "List", required=True)
    email_token = fields.Char("Email Token", required=True)
    web_user = fields.Function(
        fields.Many2One('web.user', "Web User"), 'get_web_user')
    party = fields.Function(
        fields.Many2One('party.party', "Party"), 'get_web_user')

    @classmethod
    def __setup__(cls):
        super().__setup__()

        t = cls.__table__()
        cls._sql_constraints = [
            ('email_list_unique', Unique(t, t.email, t.list_),
                'marketing_email.msg_email_list_unique'),
            ]
        cls._sql_indexes.add(Index(
                t,
                (t.list_, Index.Range()),
                (t.email, Index.Equality(cardinality='high'))))

    @classmethod
    def default_email_token(cls, nbytes=None):
        return token_hex(nbytes)

    @classmethod
    def get_web_user(cls, records, names):
        pool = Pool()
        WebUser = pool.get('web.user')
        result = {}
        web_user = 'web_user' in names
        if web_user:
            web_users = defaultdict(lambda: None)
            result['web_user'] = web_users
        party = 'party' in names
        if party:
            parties = defaultdict(lambda: None)
            result['party'] = parties
        email2id = {r.email: r.id for r in records}
        users = WebUser.search([
                ('email', 'in', list(email2id.keys())),
                ])
        if web_user:
            web_users.update((email2id[u.email], u.id) for u in users)
        if party:
            parties.update(
                (email2id[u.email], u.party.id)
                for u in users if u.party)
        return result

    @classmethod
    def preprocess_values(cls, mode, values):
        values = super().preprocess_values(mode, values)
        if mode == 'create':
            # Ensure to get a different token for each record
            # default methods are called only once
            values.setdefault('email_token', cls.default_email_token())
        if values.get('email'):
            values['email'] = normalize_email(values['email']).lower()
        return values

    @classmethod
    def validate_fields(cls, records, fields_names):
        super().validate_fields(records, fields_names)
        cls.check_valid_email(records, fields_names)

    @classmethod
    def check_valid_email(cls, records, fields_names=None):
        if fields_names and 'email' not in fields_names:
            return
        for record in records:
            if record.email:
                try:
                    validate_email(record.email)
                except EmailNotValidError as e:
                    raise EMailValidationError(gettext(
                            'marketing_email.msg_email_invalid',
                            record=record.rec_name,
                            email=record.email),
                        str(e)) from e

    def get_email_subscribe(self, report_name='marketing.email.subscribe'):
        pool = Pool()
        ActionReport = pool.get('ir.action.report')
        report, = ActionReport.search([
            ('report_name', '=', report_name),
            ], limit=1)
        return get_email(report, self, [self.list_.language])

    def get_email_subscribe_url(self, url=None):
        if url is None:
            url = config.get('marketing', 'email_subscribe_url')
        if url is not None:
            return _add_params(url, token=self.email_token)
        else:
            return ''

    @classmethod
    def subscribe_url(cls, url):
        parts = urlsplit(url)
        tokens = filter(
            None, parse_qs(parts.query).get('token', [None]))
        return cls.subscribe(list(tokens))

    @classmethod
    def subscribe(cls, tokens):
        # Make it slow to prevent brute force attacks
        delay = config.getint('marketing', 'subscribe_delay', default=1)
        Transaction().atexit(time.sleep, delay)
        with inactive_records():
            records = cls.search([
                    ('email_token', 'in', tokens),
                    ])
        cls.write(records, {'active': True})
        return bool(records)

    def get_email_unsubscribe(self, report_name='marketing.email.unsubscribe'):
        pool = Pool()
        ActionReport = pool.get('ir.action.report')
        report, = ActionReport.search([
                ('report_name', '=', report_name),
                ], limit=1)
        return get_email(report, self, [self.list_.language])

    def get_email_unsubscribe_url(self, url=None):
        if url is None:
            url = config.get('marketing', 'email_unsubscribe_url')
        if url is not None:
            return _add_params(url, token=self.email_token)
        else:
            return ''

    @classmethod
    def unsubscribe_url(cls, url):
        parts = urlsplit(url)
        tokens = filter(
            None, parse_qs(parts.query).get('token', [None]))
        cls.unsubscribe(list(tokens))

    @classmethod
    def unsubscribe(cls, tokens):
        # Make it slow to prevent brute force attacks
        delay = config.getint('marketing', 'subscribe_delay', default=1)
        Transaction().atexit(time.sleep, delay)
        records = cls.search([
                ('email_token', 'in', tokens),
                ])
        cls.write(records, {'active': False})
        return bool(records)


class EmailSubscribe(Report):
    __name__ = 'marketing.email.subscribe'

    @classmethod
    def get_context(cls, records, header, data):
        context = super().get_context(records, header, data)
        context['extract_params'] = _extract_params
        return context


class EmailUnsubscribe(Report):
    __name__ = 'marketing.email.unsubscribe'

    @classmethod
    def get_context(cls, records, header, data):
        context = super().get_context(records, header, data)
        context['extract_params'] = _extract_params
        return context


class EmailList(DeactivableMixin, ModelSQL, ModelView):
    __name__ = 'marketing.email.list'

    name = fields.Char("Name", required=True)
    language = fields.Many2One('ir.lang', "Language", required=True)
    emails = fields.One2Many('marketing.email', 'list_', "Emails")
    subscribed = fields.Function(
        fields.Integer("Subscribed"), 'get_subscribed')

    @staticmethod
    def default_language():
        Lang = Pool().get('ir.lang')
        code = Transaction().context.get(
            'language', config.get('database', 'language'))
        try:
            lang, = Lang.search([
                    ('code', '=', code),
                    ('translatable', '=', True),
                    ], limit=1)
            return lang.id
        except ValueError:
            return None

    @classmethod
    def get_subscribed(cls, lists, name):
        pool = Pool()
        Email = pool.get('marketing.email')
        email = Email.__table__()
        cursor = Transaction().connection.cursor()

        subscribed = defaultdict(int)
        query = email.select(
            email.list_, Count(),
            where=(
                fields.SQL_OPERATORS['in'](
                    email.list_, map(int, lists))
                & email.active),
            group_by=[email.list_])
        cursor.execute(*query)
        subscribed.update(cursor)
        return subscribed

    def request_subscribe(self, email, from_=None):
        pool = Pool()
        Email = pool.get('marketing.email')

        # Randomize processing time to prevent guessing whether the email
        # address is already subscribed to the list or not.
        Transaction().atexit(time.sleep, random.random())

        email = email.lower()
        with inactive_records():
            records = Email.search([
                    ('email', '=', email),
                    ('list_', '=', self.id),
                    ])
        if not records:
            record = Email(email=email, list_=self.id, active=False)
            record.save()
        else:
            record, = records
        if not record.active:
            from_cfg = (config.get('marketing', 'email_from')
                or config.get('email', 'from'))
            msg, title = record.get_email_subscribe()
            set_from_header(msg, from_cfg, from_ or from_cfg)
            msg['To'] = record.email
            msg['Subject'] = title
            send_message_transactional(msg)

    def request_unsubscribe(self, email, from_=None):
        pool = Pool()
        Email = pool.get('marketing.email')

        # Randomize processing time to prevent guessing whether the email
        # address was subscribed to the list or not.
        Transaction().atexit(time.sleep, random.random())

        email = email.lower()
        with inactive_records():
            records = Email.search([
                    ('email', '=', email),
                    ('list_', '=', self.id),
                    ])
        if records:
            record, = records
            if record.active:
                from_cfg = (config.get('marketing', 'email_from')
                    or config.get('email', 'from'))
                msg, title = record.get_email_unsubscribe()
                set_from_header(msg, from_cfg, from_ or from_cfg)
                msg['To'] = record.email
                msg['Subject'] = title
                send_message_transactional(msg)


class Message(Workflow, ModelSQL, ModelView):
    __name__ = 'marketing.email.message'
    _rec_name = 'title'

    _states = {
        'readonly': Eval('state') != 'draft',
        }
    from_ = fields.Char(
        "From", states=_states,
        help="Leave empty for the value defined in the configuration file.")
    list_ = fields.Many2One(
        'marketing.email.list', "List",
        required=True, states=_states)
    title = fields.Char(
        "Title", required=True, states=_states)
    content = fields.Text(
        "Content",
        states={
            'required': Eval('state') != 'draft',
            'readonly': _states['readonly'],
            })
    urls = fields.One2Many(
        'web.shortened_url', 'record', "URLs", readonly=True)
    state = fields.Selection([
            ('draft', "Draft"),
            ('sending', "Sending"),
            ('sent', "Sent"),
            ], "State", readonly=True, sort=False)
    del _states

    @classmethod
    def __setup__(cls):
        super().__setup__()
        t = cls.__table__()
        cls._sql_indexes.add(
            Index(
                t, (t.state, Index.Equality(cardinality='low')),
                where=t.state.in_(['draft', 'sending'])))
        cls._transitions |= set([
                ('draft', 'sending'),
                ('sending', 'sent'),
                ('sending', 'draft'),
                ])
        cls._buttons.update({
                'draft': {
                    'invisible': Eval('state') != 'sending',
                    'depends': ['state'],
                    },
                'send': {
                    'invisible': Eval('state') != 'draft',
                    'depends': ['state'],
                    },
                'send_test': {
                    'invisible': Eval('state') != 'draft',
                    'depends': ['state'],
                    },
                })

    @classmethod
    def default_state(cls):
        return 'draft'

    @classmethod
    def validate_fields(cls, messages, field_names):
        super().validate_fields(messages, field_names)
        cls.check_content(messages, field_names)

    @classmethod
    def check_content(cls, messages, field_names=None):
        if field_names and 'content' not in field_names:
            return
        for message in messages:
            if not message.content:
                continue
            try:
                MarkupTemplate(message.content)
            except GenshiTemplateError as exception:
                raise TemplateError(
                    gettext('marketing_email'
                        '.msg_message_invalid_content',
                        message=message.rec_name,
                        exception=exception)) from exception

    @classmethod
    @ModelView.button
    @Workflow.transition('draft')
    def draft(cls, messages):
        pass

    @classmethod
    @ModelView.button_action('marketing_email.wizard_send_test')
    def send_test(cls, messages):
        pass

    @classmethod
    @ModelView.button
    @Workflow.transition('sending')
    def send(cls, messages):
        pass

    @classmethod
    @Workflow.transition('sent')
    def sent(cls, messages):
        pass

    @classmethod
    def process(cls, messages=None, emails=None, smtpd_datamanager=None):
        pool = Pool()
        WebShortener = pool.get('web.shortened_url')
        spy_pixel = config.getboolean(
            'marketing', 'email_spy_pixel', default=False)

        url_base = config.get('marketing', 'email_base', default=http_base())
        url_open = urljoin(url_base, '/m/empty.gif')

        @lru_cache(None)
        def short(url, record):
            url = WebShortener(
                record=record,
                redirect_url=url)
            url.save()
            return url.shortened_url

        def convert_href(message):
            def filter_(stream):
                for kind, data, pos in stream:
                    if kind is START:
                        tag, attrs = data
                        if tag == 'a' and attrs.get('href'):
                            href = attrs.get('href')
                            attrs -= 'href'
                            href = short(href, str(message))
                            attrs |= [(QName('href'), href)]
                            data = tag, attrs
                    elif kind is END and data == 'body' and spy_pixel:
                        yield START, (QName('img'), Attrs([
                                    (QName('src'), short(
                                            url_open, str(message))),
                                    (QName('height'), '1'),
                                    (QName('width'), '1'),
                                    ])), pos
                        yield END, QName('img'), pos
                    yield kind, data, pos
            return filter_

        if not smtpd_datamanager:
            smtpd_datamanager = SMTPDataManager()
        if messages is None:
            messages = cls.search([
                    ('state', '=', 'sending'),
                    ])

        for message in messages:
            try:
                template = MarkupTemplate(message.content)
            except GenshiTemplateError as exception:
                raise TemplateError(
                    gettext('marketing_email'
                        '.msg_message_invalid_content',
                        message=message.rec_name,
                        exception=exception)) from exception
            for email in (emails or message.list_.emails):
                content = (template
                    .generate(
                        email=email,
                        short=partial(short, record=message))
                    .filter(convert_href(message))
                    .render())

                if content.startswith('<mjml'):
                    content = mjml_to_html(content)

                name = email.party.rec_name if email.party else ''
                from_cfg = (config.get('marketing', 'email_from')
                    or config.get('email', 'from'))
                to = format_address(email.email, name)

                msg = EmailMessage()
                set_from_header(msg, from_cfg, message.from_ or from_cfg)
                msg['To'] = to
                msg['Subject'] = message.title
                msg['List-Unsubscribe'] = (
                    f'<{email.get_email_unsubscribe_url()}>')
                msg['List-Unsubscribe-Post'] = 'List-Unsubscribe=One-Click'
                if content_text := html_to_text(content):
                    msg.add_alternative(content_text, subtype='plain')
                if msg.is_multipart():
                    msg.add_alternative(content, subtype='html')
                else:
                    msg.set_content(content, subtype='html')

                send_message_transactional(msg, datamanager=smtpd_datamanager)
        if not emails:
            cls.sent(messages)


class SendTest(Wizard):
    __name__ = 'marketing.email.send_test'
    start = StateView(
        'marketing.email.send_test',
        'marketing_email.send_test_view_form', [
            Button("Cancel", 'end', 'tryton-cancel'),
            Button("Send", 'send', 'tryton-ok', default=True),
            ])
    send = StateTransition()

    def default_start(self, fields):
        pool = Pool()
        Message = pool.get('marketing.email.message')

        message = Message(Transaction().context.get('active_id'))
        return {
            'list_': message.list_.id,
            'message': message.id,
            }

    def transition_send(self):
        pool = Pool()
        Message = pool.get('marketing.email.message')
        Message.process([self.start.message], [self.start.email])
        return 'end'


class SendTestView(ModelView):
    __name__ = 'marketing.email.send_test'

    list_ = fields.Many2One(
        'marketing.email.list', "List", readonly=True)
    message = fields.Many2One(
        'marketing.email.message', "Message", readonly=True)
    email = fields.Many2One(
        'marketing.email', "Email", required=True,
        domain=[
            ('list_', '=', Eval('list_', -1)),
            ])
