import logging
from collections import defaultdict

from markupsafe import Markup

from odoo import Command, _, api, models, modules, tools
from odoo.exceptions import UserError, ValidationError


_logger = logging.getLogger(__name__)


class AccountMoveSend(models.AbstractModel):
    """ Shared class between the two sending wizards.
    See 'account.move.send.batch.wizard' for multiple invoices sending wizard (async)
    and 'account.move.send.wizard' for single invoice sending wizard (sync).
    """
    _name = 'account.move.send'
    _description = "Account Move Send"

    # -------------------------------------------------------------------------
    # DEFAULTS
    # -------------------------------------------------------------------------

    @api.model
    def _get_default_sending_methods(self, move) -> set:
        """ By default, we use the sending method set on the partner or email. """
        return {move.commercial_partner_id.with_company(move.company_id).invoice_sending_method or 'email'}

    @api.model
    def _get_all_extra_edis(self) -> dict:
        """ Returns a dict representing EDI data such as:
        { 'edi_key': {'label': 'EDI label', 'is_applicable': function, 'help': 'optional help'} }
        """
        return {}

    @api.model
    def _get_default_extra_edis(self, move) -> set:
        """ By default, we use all applicable extra EDIs. """
        extra_edis = self._get_all_extra_edis()
        return {edi_key for edi_key, edi_vals in extra_edis.items() if edi_vals['is_applicable'](move)}

    @api.model
    def _get_default_invoice_edi_format(self, move, **kwargs) -> str:
        """ By default, we generate the EDI format set on partner. """
        return move.commercial_partner_id.with_company(move.company_id).invoice_edi_format

    @api.model
    def _get_default_pdf_report_id(self, move):
        if partner_default_template := move.commercial_partner_id.with_company(move.company_id).invoice_template_pdf_report_id:
            return partner_default_template

        if journal_default_template := move.journal_id.with_company(move.company_id).invoice_template_pdf_report_id:
            return journal_default_template

        action_report = self.env.ref('account.account_invoices')

        if move._is_action_report_available(action_report):
            return action_report

        raise UserError(_("There is no template that applies to this move type."))

    @api.model
    def _get_default_mail_template_id(self, move):
        return move._get_mail_template()

    @api.model
    def _get_default_sending_settings(self, move, from_cron=False, **custom_settings):
        """ Returns a dict with all the necessary data to generate and send invoices.
        Either takes the provided custom_settings, or the default value.
        """
        def get_setting(key, from_cron=False, default_value=None):
            return custom_settings.get(key) if key in custom_settings else move.sending_data.get(key) if from_cron else default_value

        vals = {
            'sending_methods': get_setting('sending_methods', default_value=self._get_default_sending_methods(move)) or {},
            'extra_edis': get_setting('extra_edis', default_value=self._get_default_extra_edis(move)) or {},
            'pdf_report': get_setting('pdf_report') or self._get_default_pdf_report_id(move),
            'author_user_id': get_setting('author_user_id', from_cron=from_cron) or self.env.user.id,
            'author_partner_id': get_setting('author_partner_id', from_cron=from_cron) or self.env.user.partner_id.id,
        }
        vals['invoice_edi_format'] = get_setting('invoice_edi_format', default_value=self._get_default_invoice_edi_format(move, sending_methods=vals['sending_methods']))
        mail_template = get_setting('mail_template') or self._get_default_mail_template_id(move)
        if 'email' in vals['sending_methods']:
            mail_lang = get_setting('mail_lang') or self._get_default_mail_lang(move, mail_template)
            vals.update({
                'mail_template': mail_template,
                'mail_lang': mail_lang,
                'mail_body': get_setting('mail_body', default_value=self._get_default_mail_body(move, mail_template, mail_lang)),
                'mail_subject': get_setting('mail_subject', default_value=self._get_default_mail_subject(move, mail_template, mail_lang)),
                'mail_partner_ids': get_setting('mail_partner_ids', default_value=self._get_default_mail_partner_ids(move, mail_template, mail_lang).ids),
                'reply_to': get_setting('reply_to') or self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'reply_to'),
            })
        # Add mail attachments if sending methods support them
        if self._display_attachments_widget(vals['invoice_edi_format'], vals['sending_methods']):
            mail_attachments_widget = self._get_default_mail_attachments_widget(
                move,
                mail_template,
                invoice_edi_format=vals['invoice_edi_format'],
                extra_edis=vals['extra_edis'],
                pdf_report=vals['pdf_report'],
            )
            vals['mail_attachments_widget'] = get_setting('mail_attachments_widget', default_value=mail_attachments_widget)
        return vals

    # -------------------------------------------------------------------------
    # ALERTS
    # -------------------------------------------------------------------------

    @api.model
    def _get_alerts(self, moves, moves_data):
        """ Returns a dict of all alerts corresponding to moves with the given context (sending method,
        edi format to generate, extra_edi to generate).
        An alert can have some information:
        - level (danger, info, warning, ...)  (! danger alerts are considered blocking and will be raised)
        - message to display
        - action_text for the text to show on the clickable link
        - action the action to run when the link is clicked
        """
        alerts = {}
        send_cron = self.env.ref('account.ir_cron_account_move_send', raise_if_not_found=False)
        if len(moves) > 1 and send_cron and not send_cron.sudo().active:
            has_cron_access = send_cron.has_access('write')
            has_access_message = _("The scheduled action 'Send Invoices automatically' is archived. You won't be able to send invoices in batch.")
            no_access_addendum = _("\nPlease contact your administrator.")
            alerts['account_send_cron_archived'] = {
                'level': 'warning',
                'message': has_access_message if has_cron_access else has_access_message + no_access_addendum,
                'action_text': _("Check") if has_cron_access else None,
                'action': send_cron._get_records_action() if has_cron_access else None,
            }
        # Filter moves that are trying to send via email
        email_moves = moves.filtered(lambda m: 'email' in moves_data[m]['sending_methods'])
        if email_moves:
            # Identify partners without email depending on batch/single send
            if is_batch := len(moves) > 1:
                # Batch sending
                partners_without_mail = email_moves.filtered(lambda m: not m.partner_id.email).mapped('partner_id')
            else:
                # Single sending
                partners_without_mail = moves_data[email_moves]['mail_partner_ids'].filtered(lambda p: not p.email)

            # If there are partners without email, add an alert
            if partners_without_mail:
                alerts['account_missing_email'] = {
                    'level': 'warning' if is_batch else 'danger',
                    'message': _("Partner(s) should have an email address."),
                    'action_text': _("View Partner(s)") if is_batch else False,
                    'action': (
                        partners_without_mail._get_records_action(name=_("Check Partner(s) Email(s)"))
                        if is_batch else False
                    ),
                }

        return alerts

    # -------------------------------------------------------------------------
    # MAIL
    # -------------------------------------------------------------------------

    @api.model
    def _get_mail_default_field_value_from_template(self, mail_template, lang, move, field, **kwargs):
        if not mail_template:
            return
        return mail_template.sudo()\
            .with_context(lang=lang)\
            ._render_field(field, move.ids, **kwargs)[move._origin.id]

    @api.model
    def _get_default_mail_lang(self, move, mail_template):
        return mail_template._render_lang([move.id]).get(move.id)

    @api.model
    def _get_default_mail_body(self, move, mail_template, mail_lang):
        return self._get_mail_default_field_value_from_template(
            mail_template,
            mail_lang,
            move,
            'body_html',
            options={'post_process': True},
        )

    @api.model
    def _get_default_mail_subject(self, move, mail_template, mail_lang):
        return self._get_mail_default_field_value_from_template(
            mail_template,
            mail_lang,
            move,
            'subject',
        )

    @api.model
    def _get_default_mail_partner_ids(self, move, mail_template, mail_lang):
        # TDE FIXME: this should use standard composer / template code to be sure
        # it is aligned with standard recipients management. Todo later
        partners = self.env['res.partner'].with_company(move.company_id)
        if mail_template.use_default_to:
            defaults = move._message_get_default_recipients()[move.id]
            email_cc = defaults['email_to']
            email_to = defaults['email_to']
            partners |= partners.browse(defaults['partner_ids'])
        else:
            if mail_template.email_cc:
                email_cc = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_cc')
            else:
                email_cc = ''
            if mail_template.email_to:
                email_to = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_to')
            else:
                email_to = ''

        partners |= move._partner_find_from_emails_single(
            tools.email_split(email_cc or '') + tools.email_split(email_to or ''),
            no_create=False,
        )

        if not mail_template.use_default_to and mail_template.partner_to:
            partner_to = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'partner_to')
            partner_ids = mail_template._parse_partner_to(partner_to)
            partners |= self.env['res.partner'].sudo().browse(partner_ids).exists()
        return partners if self.env.context.get('allow_partners_without_mail') else partners.filtered('email')

    # -------------------------------------------------------------------------
    # ATTACHMENTS
    # -------------------------------------------------------------------------

    @api.model
    def _get_default_mail_attachments_widget(self, move, mail_template, invoice_edi_format=None, extra_edis=None, pdf_report=None):
        return self._get_placeholder_mail_attachments_data(move, invoice_edi_format=invoice_edi_format, extra_edis=extra_edis, pdf_report=pdf_report) \
            + self._get_placeholder_mail_template_dynamic_attachments_data(move, mail_template, pdf_report=pdf_report) \
            + self._get_invoice_extra_attachments_data(move) \
            + self._get_mail_template_attachments_data(mail_template)

    @api.model
    def _get_placeholder_mail_attachments_data(self, move, invoice_edi_format=None, extra_edis=None, pdf_report=None):
        """ Returns all the placeholder data.
        Should be extended to add placeholder based on the sending method.
        :param: move:       The current move.
        :returns: A list of dictionary for each placeholder.
        * id:               str: The (fake) id of the attachment, this is needed in rendering in t-key.
        * name:             str: The name of the attachment.
        * mimetype:         str: The mimetype of the attachment.
        * placeholder       bool: Should be true to prevent download / deletion.
        """
        if move.invoice_pdf_report_id:
            return []
        filename = move._get_invoice_report_filename(report=pdf_report)
        return [{
            'id': f'placeholder_{filename}',
            'name': filename,
            'mimetype': 'application/pdf',
            'placeholder': True,
        }]

    @api.model
    def _get_placeholder_mail_template_dynamic_attachments_data(self, move, mail_template, pdf_report=None):
        """
        This method returns the placeholder data for the dynamic attachments.
        :param move:            The current move we are generating documents for.
        :param mail_template:   The mail template used to get dynamic attachments for the move.
        :param pdf_report:      The 'ir.actions.report' used for the move.
                                Usually it will be the generic 'account.account_invoices' but the user can customize it
                                from the Send Wizard interface.
        :return:                A list of dictionary, one for each placeholder.
        """
        # The Send wizard will generate a legal PDF based on a specific ir.actions.report.
        # In case the report selected to do so is also added in dynamic attachments of the mail template, we need to
        # filter them out to avoid duplicated placeholders, since they are already added in the
        # _get_placeholder_mail_attachments_data method.
        pdf_report = pdf_report or self._get_default_pdf_report_id(move)
        invoice_template = pdf_report | self.env.ref('account.account_invoices')
        extra_mail_templates = mail_template.report_template_ids - invoice_template
        filename = move._get_invoice_report_filename(report=pdf_report)
        return [
            {
                'id': f'placeholder_{extra_mail_template.name.lower()}_{filename}',
                'name': f'{extra_mail_template.name.lower()}_{filename}',
                'mimetype': 'application/pdf',
                'placeholder': True,
                'dynamic_report': extra_mail_template.report_name,
            } for extra_mail_template in extra_mail_templates
        ]

    @api.model
    def _get_invoice_extra_attachments(self, move):
        return move.invoice_pdf_report_id

    @api.model
    def _get_invoice_extra_attachments_data(self, move):
        return [
            {
                'id': attachment.id,
                'name': attachment.name,
                'mimetype': attachment.mimetype,
                'placeholder': False,
                'protect_from_deletion': True,
            }
            for attachment in self._get_invoice_extra_attachments(move)
        ]

    @api.model
    def _get_mail_template_attachments_data(self, mail_template):
        """ Returns all mail template data. """
        return [
            {
                'id': attachment.id,
                'name': attachment.name,
                'mimetype': attachment.mimetype,
                'placeholder': False,
                'mail_template_id': mail_template.id,
                'protect_from_deletion': True,
            }
            for attachment in mail_template.attachment_ids
        ]

    # -------------------------------------------------------------------------
    # HELPERS
    # -------------------------------------------------------------------------

    @api.model
    def _raise_danger_alerts(self, alerts):
        danger_alert_messages = [alert['message'] for _key, alert in alerts.items() if alert.get('level') == 'danger']
        if danger_alert_messages:
            raise UserError('\n'.join(danger_alert_messages))

    @api.model
    def _check_move_constraints(self, moves):
        for move in moves:
            if move_constraints := self._get_move_constraints(move):
                raise UserError(next(iter(move_constraints.values()), None))

    @api.model
    def _get_move_constraints(self, move):
        constraints = {}
        if move.state != 'posted':
            constraints['not_posted'] = _("You can't generate invoices that are not posted.")
        if not move.is_sale_document(include_receipts=True):
            constraints['not_sale_document'] = _("You can only generate sales documents.")
        return constraints

    @api.model
    def _check_invoice_report(self, moves, **custom_settings):
        if ((
                custom_settings.get('pdf_report')
                and any(not move._is_action_report_available(custom_settings['pdf_report']) for move in moves)
            )
            or any(not self._get_default_pdf_report_id(move).is_invoice_report for move in moves)
        ):
            raise UserError(_("The sending of invoices is not set up properly, make sure the report used is set for invoices."))

    @api.model
    def _format_error_text(self, error):
        """ Format the error that can be a dict (complex format needed)

        :param error: the error to format.
        :return: a text formatted error.
        """
        errors = '\n- '.join(error.get('errors', ''))
        return f"{error['error_title']}\n- {errors}" if errors else error['error_title']

    @api.model
    def _format_error_html(self, error):
        """ Format the error that can be a dict (complex format needed)

        :param error: the error to format.
        :return: a html formatted error.
        """
        if 'errors' not in error:
            return error['error_title']
        errors = Markup().join(Markup("<li>%s</li>") % error for error in error['errors'])
        return Markup("%s<ul>%s</ul>") % (error['error_title'], errors)

    @api.model
    def _display_attachments_widget(self, edi_format, sending_methods):
        return 'email' in sending_methods

    # -------------------------------------------------------------------------
    # SENDING METHODS
    # -------------------------------------------------------------------------

    @api.model
    def _is_applicable_to_company(self, method, company):
        """ TO OVERRIDE - used to determine if we should display the sending method in the selection."""
        return True

    @api.model
    def _is_applicable_to_move(self, method, move, **move_data):
        """ TO OVERRIDE - """
        if method == 'email' and 'mail_partner_ids' in move_data:
            return bool(move_data['mail_partner_ids'])
        return True

    @api.model
    def _hook_invoice_document_before_pdf_report_render(self, invoice, invoice_data):
        """ Hook allowing to add some extra data for the invoice passed as parameter before the rendering of the pdf
        report.
        :param invoice:         An account.move record.
        :param invoice_data:    The collected data for the invoice so far.
        """
        return

    @api.model
    def _prepare_invoice_pdf_report(self, invoices_data):
        """ Prepare the pdf report for the invoice passed as parameter.
        :param invoice:         An account.move record.
        :param invoice_data:    The collected data for the invoice so far.
        """

        company_id = next(iter(invoices_data)).company_id
        grouped_invoices_by_report = defaultdict(dict)
        for invoice, invoice_data in invoices_data.items():
            grouped_invoices_by_report[invoice_data['pdf_report']][invoice] = invoice_data

        for pdf_report, group_invoices_data in grouped_invoices_by_report.items():
            ids = [inv.id for inv in group_invoices_data]

            content, report_type = self.env['ir.actions.report'].with_company(company_id)._pre_render_qweb_pdf(pdf_report.report_name, res_ids=ids)
            content_by_id = self.env['ir.actions.report']._get_splitted_report(pdf_report.report_name, content, report_type)
            if len(content_by_id) == 1 and False in content_by_id:
                raise ValidationError(_("Cannot identify the invoices in the generated PDF: %s", ids))

            for invoice, invoice_data in group_invoices_data.items():
                invoice_data['pdf_attachment_values'] = {
                    'name': invoice._get_invoice_report_filename(report=pdf_report),
                    'raw': content_by_id[invoice.id],
                    'mimetype': 'application/pdf',
                    'res_model': invoice._name,
                    'res_id': invoice.id,
                    'res_field': 'invoice_pdf_report_file',  # Binary field
                }

    @api.model
    def _prepare_invoice_proforma_pdf_report(self, invoice, invoice_data):
        """ Prepare the proforma pdf report for the invoice passed as parameter.
        :param invoice:         An account.move record.
        :param invoice_data:    The collected data for the invoice so far.
        """
        pdf_report = invoice_data['pdf_report']
        content, report_type = self.env['ir.actions.report'].with_company(invoice.company_id)._pre_render_qweb_pdf(pdf_report.report_name, invoice.ids, data={'proforma': True})
        content_by_id = self.env['ir.actions.report']._get_splitted_report(pdf_report.report_name, content, report_type)

        invoice_data['proforma_pdf_attachment_values'] = {
            'raw': content_by_id[invoice.id],
            'name': invoice._get_invoice_proforma_pdf_report_filename(),
            'mimetype': 'application/pdf',
            'res_model': invoice._name,
            'res_id': invoice.id,
        }

    @api.model
    def _hook_invoice_document_after_pdf_report_render(self, invoice, invoice_data):
        """ Hook allowing to add some extra data for the invoice passed as parameter after the rendering of the
        (proforma) pdf report.
        :param invoice:         An account.move record.
        :param invoice_data:    The collected data for the invoice so far.
        """
        return

    @api.model
    def _link_invoice_documents(self, invoices_data):
        """ Create the attachments containing the pdf/electronic documents for the invoice passed as parameter.
        :param invoice:         An account.move record.
        :param invoice_data:    The collected data for the invoice so far.
        """
        # create an attachment that will become 'invoice_pdf_report_file'
        # note: Binary is used for security reason
        attachment_to_create = [invoice_data['pdf_attachment_values'] for invoice_data in invoices_data.values() if invoice_data.get('pdf_attachment_values')]
        if not attachment_to_create:
            return

        attachments = self.sudo().env['ir.attachment'].create(attachment_to_create)
        res_id_to_attachment = {attachment.res_id: attachment for attachment in attachments}

        for invoice, invoice_data in invoices_data.items():
            if attachment := res_id_to_attachment.get(invoice.id):
                invoice.message_main_attachment_id = attachment
                invoice.invalidate_recordset(fnames=['invoice_pdf_report_id', 'invoice_pdf_report_file'])
                invoice.is_move_sent = True

    @api.model
    def _hook_if_errors(self, moves_data, allow_raising=True):
        """ Process errors found so far when generating the documents. """
        group_by_partner = defaultdict(list)
        for move, move_data in moves_data.items():
            error = move_data['error']
            if allow_raising:
                raise UserError(self._format_error_text(error))
            group_by_partner[move_data['author_partner_id']].append(move.id)
            move.message_post(body=self._format_error_html(error))
        self._send_notifications_to_partners(group_by_partner, is_success=False)

    @api.model
    def _hook_if_success(self, moves_data, from_cron=False):
        """ Process (typically send) successful documents."""
        group_by_partner = defaultdict(list)
        to_send_mail = {}
        for move, move_data in moves_data.items():
            if from_cron:
                group_by_partner[move_data['author_partner_id']].append(move.id)
            if 'email' in move_data['sending_methods'] and self._is_applicable_to_move('email', move, **move_data):
                to_send_mail[move] = move_data
        self._send_mails(to_send_mail)
        self._send_notifications_to_partners(group_by_partner)

        # Notify subscribers.
        for move, move_data in moves_data.items():
            if not move.is_invoice(include_receipts=True):
                continue

            try:
                move.journal_id._notify_invoice_subscribers(
                    invoice=move,
                    mail_params={
                        'attachment_ids': [
                            Command.create({'name': attachment.name, 'raw': attachment.raw, 'mimetype': attachment.mimetype})
                            for attachment in self._get_invoice_extra_attachments(move)
                        ]
                    },
                )
            except Exception:
                _logger.exception("Failed notifying subscribers for move %s", move.id)

    @api.model
    def _send_notifications_to_partners(self, moves_grouped_by_author_partner_id, is_success=True):
        if not moves_grouped_by_author_partner_id:
            return

        def get_account_notification(move_ids, is_success: bool):
            _ = self.env._
            return [
                'account_notification',
                {
                    'type': 'success' if is_success else 'warning',
                    'title': _("Invoices sent") if is_success else _("Invoices in error"),
                    'message': _("Invoices sent successfully.") if is_success else _(
                        "One or more invoices couldn't be processed."),
                    'action_button': {
                        'name': _('Open'),
                        'action_name': _("Sent invoices") if is_success else _("Invoices in error"),
                        'model': 'account.move',
                        'res_ids': move_ids,
                    },
                },
            ]
        ResPartner = self.env['res.partner']
        for partner_id, move_ids in moves_grouped_by_author_partner_id.items():
            partner = ResPartner.browse(partner_id)
            partner._bus_send(*get_account_notification(move_ids, is_success))

    @api.model
    def _send_mail(self, move, mail_template, **kwargs):
        """ Send the journal entry passed as parameter by mail. """
        new_message = move.with_context(
            email_notification_allow_footer=True,
            disable_attachment_import=True,
            no_document=True,
        ).message_post(
            message_type='comment',
            **kwargs,
            **{  # noqa: PIE804
                'email_layout_xmlid': self._get_mail_layout(),
                'email_add_signature': not mail_template,
                'mail_auto_delete': mail_template.auto_delete,
                'mail_server_id': mail_template.mail_server_id.id,
                'reply_to_force_new': False,
            }
        )

        # Prevent duplicated attachments linked to the invoice.
        new_message.attachment_ids.invalidate_recordset(['res_id', 'res_model'], flush=False)
        if new_message.attachment_ids.ids:
            self.env.cr.execute("UPDATE ir_attachment SET res_id = NULL WHERE id IN %s", [tuple(new_message.attachment_ids.ids)])
        new_message.attachment_ids.write({
            'res_model': new_message._name,
            'res_id': new_message.id,
        })

    @api.model
    def _get_mail_layout(self):
        return 'mail.mail_notification_layout_with_responsible_signature'

    @api.model
    def _get_mail_params(self, move, move_data):
        # We must ensure the newly created PDF are added. At this point, the PDF has been generated but not added
        # to 'mail_attachments_widget'.
        mail_attachments_widget = move_data.get('mail_attachments_widget')
        seen_attachment_ids = set()
        to_exclude = {x['name'] for x in mail_attachments_widget if x.get('skip')}
        for attachment_data in self._get_invoice_extra_attachments_data(move) + mail_attachments_widget:
            if attachment_data['name'] in to_exclude and not attachment_data.get('manual'):
                continue

            try:
                attachment_id = int(attachment_data['id'])
            except ValueError:
                continue

            seen_attachment_ids.add(attachment_id)

        mail_attachments = [
            (attachment.name, attachment.raw)
            for attachment in self.env['ir.attachment'].browse(list(seen_attachment_ids)).exists()
        ]

        params = {
            'author_id': move_data['author_partner_id'],
            'body': move_data['mail_body'],
            'subject': move_data['mail_subject'],
            'partner_ids': move_data['mail_partner_ids'],
            'attachments': mail_attachments,
        }
        if move_data.get('reply_to'):
            params['reply_to'] = move_data['reply_to']
        return params

    @api.model
    def _generate_dynamic_reports(self, moves_data):
        for move, move_data in moves_data.items():
            mail_attachments_widget = move_data.get('mail_attachments_widget', [])

            dynamic_reports = [
                attachment_widget
                for attachment_widget in mail_attachments_widget
                if attachment_widget.get('dynamic_report')
                and not attachment_widget.get('skip')
            ]

            attachments_to_create = []
            for dynamic_report in dynamic_reports:
                content, _report_format = self.env['ir.actions.report']\
                .with_company(move.company_id)\
                .with_context(from_account_move_send=True)\
                ._render(dynamic_report['dynamic_report'], move.ids)

                attachments_to_create.append({
                    'raw': content,
                    'name': dynamic_report['name'],
                    'mimetype': 'application/pdf',
                    'res_model': move._name,
                    'res_id': move.id,
                })

            attachments = self.env['ir.attachment'].create(attachments_to_create)
            mail_attachments_widget += [{
                'id': attachment.id,
                'name': attachment.name,
                'mimetype': 'application/pdf',
                'placeholder': False,
                'protect_from_deletion': True,
            } for attachment in attachments]

    @api.model
    def _send_mails(self, moves_data):
        subtype = self.env.ref('mail.mt_comment')

        self._generate_dynamic_reports(moves_data)

        for move, move_data in [
            (move, move_data)
            for move, move_data in moves_data.items()
            if move.partner_id.email or move_data.get('mail_partner_ids')
        ]:
            mail_template = move_data['mail_template']
            mail_lang = move_data['mail_lang']
            mail_params = self._get_mail_params(move, move_data)
            if not mail_params:
                continue

            if move_data.get('proforma_pdf_attachment'):
                attachment = move_data['proforma_pdf_attachment']
                mail_params['attachments'].append((attachment.name, attachment.raw))

            # synchronize author / email_from, as account.move.send wizard computes
            # a bit too much stuff
            author_id = mail_params.pop('author_id', False)
            email_from = self._get_mail_default_field_value_from_template(mail_template, mail_lang, move, 'email_from')
            if email_from or not author_id:
                author_id, email_from = move._message_compute_author(email_from=email_from)
            model_description = move.with_context(lang=mail_lang).type_name

            self._send_mail(
                move,
                mail_template,
                author_id=author_id,
                subtype_id=subtype.id,
                model_description=model_description,
                notify_author_mention=True,
                email_from=email_from,
                **mail_params,
            )

    @api.model
    def _can_commit(self):
        """ Helper to know if we can commit the current transaction or not.
        :return: True if commit is accepted, False otherwise.
        """
        return not (tools.config['test_enable'] or modules.module.current_test)

    @api.model
    def _call_web_service_before_invoice_pdf_render(self, invoices_data):
        # TO OVERRIDE
        # call a web service before the pdfs are rendered
        return

    @api.model
    def _call_web_service_after_invoice_pdf_render(self, invoices_data):
        # TO OVERRIDE
        # call a web service after the pdfs are rendered
        return

    @api.model
    def _generate_invoice_documents(self, invoices_data, allow_fallback_pdf=False):
        """ Generate the invoice PDF and electronic documents.
        :param invoices_data:   The collected data for invoices so far.
        :param allow_fallback_pdf:  In case of error when generating the documents for invoices, generate a
                                    proforma PDF report instead.
        """
        for invoice, invoice_data in invoices_data.items():
            self._hook_invoice_document_before_pdf_report_render(invoice, invoice_data)
            invoice_data['blocking_error'] = invoice_data.get('error') \
                                             and not (allow_fallback_pdf and invoice_data.get('error_but_continue'))
            invoice_data['error_but_continue'] = allow_fallback_pdf and invoice_data.get('error_but_continue')

        invoices_data_web_service = {
            invoice: invoice_data
            for invoice, invoice_data in invoices_data.items()
            if not invoice_data.get('error')
        }
        if invoices_data_web_service:
            self._call_web_service_before_invoice_pdf_render(invoices_data_web_service)

        invoices_data_pdf = {
            invoice: invoice_data
            for invoice, invoice_data in invoices_data.items()
            if not invoice_data.get('error') or invoice_data.get('error_but_continue')
        }

        # Use batch to avoid memory error
        batch_size = self.env['ir.config_parameter'].sudo().get_param('account.pdf_generation_batch', '80')
        batches = []
        pdf_to_generate = {}
        for invoice, invoice_data in invoices_data_pdf.items():
            if not invoice_data.get('error') and not invoice.invoice_pdf_report_id:  # we don't regenerate pdf if it already exists
                pdf_to_generate[invoice] = invoice_data

                if (len(pdf_to_generate) > int(batch_size)):
                    batches.append(pdf_to_generate)
                    pdf_to_generate = {}

        if pdf_to_generate:
            batches.append(pdf_to_generate)

        for batch in batches:
            self._prepare_invoice_pdf_report(batch)

        for invoice, invoice_data in invoices_data_pdf.items():
            if not invoice_data.get('error') and not invoice.invoice_pdf_report_id:
                self._hook_invoice_document_after_pdf_report_render(invoice, invoice_data)

        # Cleanup the error if we don't want to block the regular pdf generation.
        if allow_fallback_pdf:
            invoices_data_pdf_error = {
                invoice: invoice_data
                for invoice, invoice_data in invoices_data.items()
                if invoice_data.get('pdf_attachment_values') and invoice_data.get('error')
            }
            if invoices_data_pdf_error:
                self._hook_if_errors(invoices_data_pdf_error, allow_raising=not allow_fallback_pdf)

        # Web-service after the PDF generation.
        invoices_data_web_service = {
            invoice: invoice_data
            for invoice, invoice_data in invoices_data.items()
            if not invoice_data.get('error')
        }
        if invoices_data_web_service:
            self._call_web_service_after_invoice_pdf_render(invoices_data_web_service)

        # Create and link the generated documents to the invoice if the web-service didn't failed.
        invoices_to_link = {
            invoice: invoice_data
            for invoice, invoice_data in invoices_data_web_service.items()
            if not invoice_data.get('error') or allow_fallback_pdf
        }
        self._link_invoice_documents(invoices_to_link)

    @api.model
    def _generate_invoice_fallback_documents(self, invoices_data):
        """ Generate the invoice PDF and electronic documents.
        :param invoices_data:   The collected data for invoices so far.
        """
        for invoice, invoice_data in invoices_data.items():
            if not invoice.invoice_pdf_report_id and invoice_data.get('error'):
                invoice_data.pop('error')
                self._prepare_invoice_proforma_pdf_report(invoice, invoice_data)
                self._hook_invoice_document_after_pdf_report_render(invoice, invoice_data)
                invoice_data['proforma_pdf_attachment'] = self.env['ir.attachment']\
                    .create(invoice_data.pop('proforma_pdf_attachment_values'))

    def _check_sending_data(self, moves, **custom_settings):
        """Assert the data provided to _generate_and_send_invoices are correct.
        This is a security in case the method is called directly without going through the wizards.
        """
        self._check_move_constraints(moves)
        self._check_invoice_report(moves, **custom_settings)
        assert all(
            sending_method in dict(self.env['res.partner']._fields['invoice_sending_method'].selection)
            for sending_method in custom_settings.get('sending_methods', [])
        ) if 'sending_methods' in custom_settings else True

    @api.model
    def _generate_and_send_invoices(self, moves, from_cron=False, allow_raising=True, allow_fallback_pdf=False, **custom_settings):
        """ Generate and send the moves given custom_settings if provided, else their default configuration set on related partner/company.
        :param moves: account.move to process
        :param from_cron: whether the processing comes from a cron.
        :param allow_raising: whether the process can raise errors, or should log them on the move's chatter.
        :param allow_fallback_pdf:  In case of error when generating the documents for invoices, generate a proforma PDF report instead.
        :param custom_settings: settings to apply instead of related partner's defaults settings.
        """
        self._check_sending_data(moves, **custom_settings)
        moves_data = {
            move.sudo(): {
                **self._get_default_sending_settings(move, from_cron=from_cron, **custom_settings),
            }
            for move in moves
        }

        # Generate all invoice documents (PDF and electronic documents if relevant).
        self._generate_invoice_documents(moves_data, allow_fallback_pdf=allow_fallback_pdf)

        # Manage errors.
        errors = {move: move_data for move, move_data in moves_data.items() if move_data.get('error')}
        if errors:
            self._hook_if_errors(errors, allow_raising=not from_cron and not allow_fallback_pdf and allow_raising)

        # Fallback in case of error.
        errors = {move: move_data for move, move_data in moves_data.items() if move_data.get('error')}
        if allow_fallback_pdf and errors:
            self._generate_invoice_fallback_documents(errors)

        # Successfully generated a PDF - Process sending.
        success = {move: move_data for move, move_data in moves_data.items() if not move_data.get('error')}
        if success:
            self._hook_if_success(success, from_cron=from_cron)

        # Update sending data of moves
        for move, move_data in moves_data.items():
            # We keep the sending_data, so it will be retried
            if from_cron and move_data.get('error', {}).get('retry'):
                continue
            move.sending_data = False

        # Return generated attachments.
        attachments = self.env['ir.attachment']
        for move, move_data in success.items():
            attachments += self._get_invoice_extra_attachments(move) or move_data['proforma_pdf_attachment']

        return attachments
