#!/usr/bin/env python3

import argparse
import email
import logging
import os
import re
import quopri
from base64 import decodebytes as d64
from pathlib import Path
from dateutil.parser import parser as date_parser


import html2text

# cd active_crm; pytest $PWD/bin/mail2lead.py
IS_PYTEST = os.getenv('PYTEST_RUN_CONFIG') or 'pytest' in os.getenv('_')
if IS_PYTEST:
    from .api_client import ApiClient, ApiClientUserError
else:
    from api_client import ApiClient, ApiClientUserError

NEW_MAIL_DIRECTORY = None
CUR_MAIL_DIRECTORY = None
BAD_MAIL_DIRECTORY = None
PREFERRED_PAYLOAD_TYPE = 'text/plain'


class CantParseMessageException(Exception):
    pass


def do_test_msg(filename):
    Message(Path(os.getcwd()) / 'tests/test_module/test_mail2lead_messages' / filename)


def test_thunderbird_basic():
    do_test_msg("thunderbird_basic.message")


def test_thunderbird_formatted():
    do_test_msg("thunderbird_formatted.message")


def test_access_link_evacrm():
    do_test_msg("access_link_evacrm.message")


def test_ios_mail_ru():
    do_test_msg("ios_mail.ru.message")


def test_non_cyrillic_subject():
    do_test_msg("non_cyrillic_subject.message")


def test_tiger_alert():
    do_test_msg("tiger_alert.message")


def test_html_only():
    do_test_msg("html_only.message")


# TODO: раскомментировать когда будем добавлять поддержку отправки сообщений. По идее здесь даже не надо парсить тело
# def test_not_delivered_from_gmail():
#     do_test_msg("not_delivered_from_gmail.message")


class Message:
    def __init__(self, filename):
        self.filename = filename
        # Теоретически можно прямо так закинуть в CRM, тогда все модификации будут проводиться
        # прямо в модуле CmfMailbox, туда притащим всю специфику email итд, а эта утилита будет
        # работать с почтой только на уровне файлов. От этого класса можно будет избавиться.
        try:
            self.raw = email.message_from_bytes(filename.read_bytes())
            self.email, self.name = self._parse_sender()
            self.id = self.raw.get('Message-ID')
            self.date = self.raw.get('Date')
            self.subject = self._parse_subject()
            self.body = self._parse_body()
        except Exception:
            err = f"{self.filename}, с разбором возникли проблемы, отбрасываем"
            logging.exception(err)
            if not IS_PYTEST:
                self.mark_as_bad("возникли проблемы с разбором")
            raise CantParseMessageException(err)

    def _parse_sender(self):
        if not self.raw.get('Return-Path') or not self.raw.get('From'):
            return
        # Если письмо от нескольких человек (такое возможно), ответим первому
        sender = self.raw['Return-Path'].split(' ', 1)[0]
        # Может ли быть иначе? А фиг знает
        if sender[0] == '<' and sender[-1] == '>':
            sender = sender[1:-1]  # sender = <mail>, sender[1:-1] = mail
        sender_name = sender
        # Если реальный отправитель совпадает с подписью, имеет смысл попробовать распарсить подпись
        if sender_name in self.raw['From']:
            _from = email.header.decode_header(self.raw['From'])
            if len(_from) == 2:
                raw_name, raw_email = _from
                sender_name = raw_name[0].decode(raw_name[1] or 'utf-8')
                email_addr = raw_email[0].decode(raw_email[1] or 'utf-8')
                if email_addr.strip().strip('<').strip('>') != sender.strip().strip('<').strip('>'):
                    print("WARNING: подозрительное письмо, разные значения отправителя в Return-Path и From")
                    print("WARNING: Return-Path", sender, 'From:', email_addr)
        return sender, sender_name

    @staticmethod
    def _decode_subject_part(subject, encoding):
        if encoding:
            return subject.decode(encoding or 'utf-8').strip()
        if isinstance(subject, str):
            return subject.strip()
        return subject.decode().strip()

    def _parse_subject(self):
        if not self.raw.get('Subject'):
            logging.warning('В письме %s нет поля subject вообще', self.filename)
            return
        header = email.header.decode_header(self.raw['Subject'])
        decoded = []
        for subject, encoding in header:
            if encoding == 'unknown-8bit':
                encoding = 'utf-8'
            decoded.append(Message._decode_subject_part(subject, encoding))
        return " ".join(decoded)

    def _parse_body(self):
        message_object = self.raw
        cte = None
        d_type = self.raw.get_default_type()
        if d_type and PREFERRED_PAYLOAD_TYPE in d_type:
            logging.debug('%s Удачно, есть %s', self.filename, PREFERRED_PAYLOAD_TYPE)
        payload = self.raw.get_payload()
        # Если несколько payload'ов - HTML и text/plain, выберем text/plain
        if isinstance(payload, list):
            for ct in PREFERRED_PAYLOAD_TYPE, 'text/html':
                new_payload = self._pick_text_plain_payload(payload, ct)
                if new_payload:
                    payload = new_payload
                    break
            if not new_payload:
                raise IndexError(payload)
        # Отправленное из thunderbird с кучей форматирования
        if isinstance(payload, email.message.Message):
            cte = payload.get('Content-Transfer-Encoding')  # кэшируем для обработки дальше!
            # Сохраняем message_object, чтобы в случае если мы дёрнули один из списка, .raw.get_content_type достать
            message_object, payload = payload, payload.get_payload()
        # Отправленное самопальным почтовым клиентом сообщение
        if isinstance(payload, str):
            # для дебаггера, pycharm почему-то не может залезть внутрь self.raw
            cte = message_object.get('Content-Transfer-Encoding') or cte
            if cte == 'base64':
                payload = d64(payload.encode('utf-8')).decode('utf-8')
            elif cte == '8bit':  # thunderbird без разметки
                pass  # не return для логирования ниже
            elif cte is None:  # сообщение из вебки gmail на английском языке
                pass
            elif cte == 'quoted-printable':
                payload = quopri.decodestring(payload).decode('utf-8')
            else:
                raise CantParseMessageException(f"Неподдерживаемый CTE {cte}")
        # Отправленное с вебки гуглопочты сообщение
        else:
            logging.warning("Payload type is unknown: %s", type(payload))
            cte = payload.get('Content-Transfer-Encoding')
            if cte == 'base64':
                payload = d64(payload.get_payload().encode('utf-8')).decode('utf-8')
            else:
                raise CantParseMessageException(f"Неподдерживаемый CTE (2) {cte}")
        ct = message_object.get_content_type()
        if ct == 'text/html' and '<' in payload:
            logging.warning("Пытаемся преобразовать HTML в текст...")
            h = html2text.HTML2Text()
            h.ignore_images = h.ignore_links = True
            payload = h.handle(payload)
        payload = payload.strip()
        logging.debug("%s Текст сообщения: %s", self.filename, payload)
        return payload

    def _pick_text_plain_payload(self, payload, ct):
        if len(payload) == 0:
            print(self.filename, "Payload есть, но это пустой список")
            raise IndexError(payload)
        if len(payload) == 1:
            print(self.filename, "Payload это список, но из одного элемента, работаем с ним (надеясь на plain utf)")
            return payload[0]
        for p in payload:
            if 'Content-Type' not in p:
                print(self.filename, "Один из payload не имеет Content-Type заголовка")
                continue
            if ct in p['Content-Type']:
                logging.debug("%s Ура, %s нашёлся", self.filename, ct)
                return p
            else:
                print(self.filename, f"Один из payload имеет неподходящий Content-Type заголовок: {p['Content-Type']}")
        print(self.filename, f"Среди нескольких payload не нашлось {ct} версии")
        return None

    def mark_as_read(self):
        self.filename = self.filename.rename(CUR_MAIL_DIRECTORY / self.filename.name)

    def mark_as_bad(self, reason=None):
        new_path = BAD_MAIL_DIRECTORY / self.filename.name
        if not BAD_MAIL_DIRECTORY.is_dir():
            BAD_MAIL_DIRECTORY.mkdir(parents=True)
        self.filename = self.filename.rename(new_path)
        if reason:
            print(self, "Помечено как повреждённое, т.к.", reason)

    def __str__(self) -> str:
        representation = [f"'{self.filename}'"]
        if getattr(self, 'email'):
            representation.append(f"<{self.email}>")
        if getattr(self, 'subject'):
            representation.append(f'"{self.subject}"')
        return " ".join(representation)


class MessageReader:
    def __init__(self, mailbox):
        self.messages = []
        self.api_client = ApiClient()
        mail_addr = MessageReader._get_mail_addr(mailbox)
        mailbox = self.api_client.call(
            'CmfMailbox.get', ['result'], None, email=mail_addr, fields=['id', 'load_from_date'])
        self.mailbox_id = mailbox['id']
        self.load_from_date = mailbox['load_from_date']
        self.date_parser = date_parser()

    def parse_date(self, message):
        # key = known buggy format, value - regex substitution to fix it
        bug_fixes = {
            r'\+[0-9] + \(\+[0-9]+\)$': (r'(\+[0-9]+)( \(\+[0-9]+\)$)', r'\g<1>', 'old postfix duplicates timezone')
        }
        try:
            return self.date_parser.parse(message.date)
        except ValueError:
            for bug, fix in bug_fixes.items():
                if re.match(bug, message.date):
                    pattern, replacement, description = fix
                    logging.exception("Теоретически проблема решаемая: %s", description)
                    try:
                        new_date = re.sub(pattern, replacement, message.date)
                        return self.date_parser.parse(new_date)
                    except ValueError:
                        logging.exception("Не удалось её решить таким способом :(")
                        continue
        logging.warning("Не удалось разобрать дату письма")

    def read_mail(self, filename) -> Message or None:
        """ Добавляет лида в формате email=subject в self.messages """
        message = Message(filename)
        if not isinstance(message.body, str):
            print("Попалось подозрительное сообщение, тип .body:", type(message.body), "текст:", message.body)
            if not message.body._payload.strip():
                message.body = "[пустое сообщение]"
            else:
                raise TypeError("Не получится закодировать это в JSON")
        if message.date and self.load_from_date:
            message_time = self.parse_date(message)
            if message_time:
                # делаем это на каждое письмо, ничего страшного.
                load_from_time = self.date_parser.parse(self.load_from_date)
                if message_time.date() < load_from_time.date():
                    message.mark_as_read()
                    # лог после отброса чтобы имя файла было удобно копировать
                    print(message, f"слишком старое ({message_time.date()}) - отбрасываем.")
                    return
        if not message.email:
            message.mark_as_bad("Отсутствует email отправителя")
            return
        if not message.subject and not message.body:
            message.mark_as_bad("В письме нет ни заголовка ни тела")
            return
        if not self.for_pipeline(message):
            return  # перенос в bad внутри for_pipeline() с указанием точной причины
        return message

    def for_pipeline(self, message):
        """ Определяем, что письмо является нужным, а не спамом или служебным """
        if message.subject and "Delivery Status Notification (Failure)" in message.subject:
            message.mark_as_bad("Служебное письмо о недоставке.")
            return False
        if message.email.split('@')[0] in ('no-reply', 'robot'):
            message.mark_as_bad("Служебное письмо.")
            return False
        for header in ('List-Subscribe', 'List-Unsubscribe', 'List-Unsubscribe-Post'):
            if message.raw.get(header):
                message.mark_as_bad(f"Письмо - рассылка (есть {header})")
                return False
        if message.raw.get('Auto-Submitted') == 'auto-replied':
            message.mark_as_bad("Игнорируем автоматические ответы.")
            return False
        return True

    def read_mail_directory(self):
        for filename in NEW_MAIL_DIRECTORY.iterdir():
            try:
                message = self.read_mail(filename)
                if message is not None:
                    self.put_to_crm(message)
            except CantParseMessageException as err:
                print(err)

    @staticmethod
    def _get_mail_addr(mailbox):
        global NEW_MAIL_DIRECTORY
        global CUR_MAIL_DIRECTORY
        global BAD_MAIL_DIRECTORY
        with open(f'{mailbox}/.offlineimaprc') as fd:
            for line in fd.readlines():
                if line.startswith('localfolders'):
                    local_folder = Path(line.split('=')[-1].strip())
                    _email = f"{local_folder.parts[-1]}@{local_folder.parts[-2]}"
                    local_folder /= 'INBOX'
                    NEW_MAIL_DIRECTORY = local_folder / 'new'
                    CUR_MAIL_DIRECTORY = local_folder / 'cur'
                    BAD_MAIL_DIRECTORY = local_folder / 'bad'
                    return _email
        raise ValueError("Не нашёл указанного почтового ящика в настройках")

    def put_to_crm(self, message):
        # К сожалению здесь не получится перейти на использование cmf/app_context потому что
        # он сожрёт +110мб оперативки и в итоге почта не будет работать на урезанных инстансах SLA0.
        # Вообще было бы прикольно иметь тонкий API-клиент который не жрёт оперативку за счёт инициализации всего и вся
        # но, при этом даёт разработчику автокомплит на основе сгенерированных моделей.
        try:
            self.api_client.call("CmfMailbox.receive_email", [], self.mailbox_id,
                                 message_raw={
                                     'email': message.email,
                                     'name': message.name,
                                     'subject': message.subject,
                                     'body': message.body,
                                     'message_id': message.id
                                 })
            print(message, "Успешно обработано и помечается прочитанным")
            message.mark_as_read()
        except ApiClientUserError:
            logging.exception('Ошибка обработки сообщения')
            message.mark_as_bad()


def parse_args():
    """ Разбор аргументов"""
    parser = argparse.ArgumentParser()
    parser.description = "Почтовый клиент, преобразующий письма из копии в формате Maildir в лиды CRM"
    parser.add_argument('-m', '--mailbox', type=str, required=True, help='Путь до папки почтового ящика')
    return parser.parse_args()


def main():
    args = parse_args()
    reader = MessageReader(args.mailbox)
    reader.read_mail_directory()


if __name__ == '__main__':
    main()
