from datetime import datetime, timedelta
from psycopg2 import OperationalError
from pytz import timezone
from werkzeug.urls import url_quote_plus, url_encode

import hashlib
import logging
import math
import requests.exceptions
import json

from odoo import _, api, fields, models
from odoo.addons.certificate.tools import CertificateAdapter
from odoo.exceptions import UserError
from odoo.tools import float_repr, float_round, frozendict, zeep

import odoo.release

_logger = logging.getLogger(__name__)

# Custom patches to perform the WSDL requests.
# Avoid failure on servers where the DH key is too small
EUSKADI_CIPHERS = "DEFAULT:!DH"

VERIFACTU_VERSION = "1.0"

BATCH_LIMIT = 1000


def _sha256(string):
    hash_string = hashlib.sha256(string.encode('utf-8'))
    return hash_string.hexdigest().upper()


def _get_zeep_operation(company, operation):
    """The creation of the zeep client may raise (in case of networking issues)."""
    if operation not in ('registration', 'registration_xml'):
        raise NotImplementedError(_("Unsupported `operation` '%s'", operation))

    session = requests.Session()

    info = {}

    def response_hook(resp, *args, **kwargs):
        info['raw_response'] = resp.text

    session.hooks['response'] = response_hook

    settings = zeep.Settings(forbid_entities=False, strict=False)
    wsdl = company._l10n_es_edi_verifactu_get_endpoints()['wsdl']
    client = zeep.Client(
        wsdl['url'], session=session, settings=settings,
        operation_timeout=20, timeout=20,
    )

    if operation == 'registration':
        # Note: using the "certificate" before creating `client` causes an error during the `client` creation
        session.cert = company.sudo()._l10n_es_edi_verifactu_get_certificate()
        session.mount('https://', CertificateAdapter(ciphers=EUSKADI_CIPHERS))

        service = client.bind(wsdl['service'], wsdl['port'])
        function = service[wsdl[operation]]
    else:
        # operation == 'registration_xml'
        zeep_client = client._Client__obj  # get the "real" zeep client from the odoo specific wrapper
        service = zeep_client.bind(wsdl['service'], wsdl['port'])

        def function(*args, **kwargs):
            return zeep_client.create_message(service, wsdl['registration'], *args, **kwargs)

    return function, info


class L10nEsEdiVerifactuDocument(models.Model):
    """Veri*Factu Document
    It represents a billing record with the necessary data specified by the AEAT.
    It i.e. ...
      * stores the data as JSON
      * handles the sending of the data as XML to the AEAT
      * stores information extracted from the received response

    The main functions are
      1. `_create_for_record` to generate Veri*Factu Documents (submission or cancellation):
         * The documents form a chain in generation order by including a reference to the preceding document.
         * The function handles the correct chaining.
      2. `trigger_next_batch` to send all waiting documents (now if possible or via a cron as soon as possible)

    We can not necessarily send the documents directly after generation.
    This is because the AEAT requires a waiting time between shipments (or reaching 1000 new records to send).
    The waiting time is usually 60 seconds.
    In case we cannot send the records directly a cron will be triggered at the next possible time.

    Note that (succesfully generated) Documents can not be deleted.
    This is since the Documents form a chain (in generation order) by including a reference to the preceding document.
    The chain also includes documents that are (/ possibly will be) rejected by the AEAT.
    """
    _name = 'l10n_es_edi_verifactu.document'
    _description = "Veri*Factu Document"
    _order = 'create_date DESC, id DESC'

    company_id = fields.Many2one(
        string="Company",
        comodel_name='res.company',
        required=True,
        readonly=True,
    )
    move_id = fields.Many2one(
        string="Journal Entry",
        comodel_name='account.move',
        readonly=True,
    )
    chain_index = fields.Integer(
        string="Chain Index",
        copy=False,
        readonly=True,
        help="Index in the chain of Veri*Factu Documents. It is only set if the generation was succesful.",
    )
    document_type = fields.Selection(
        string="Document Type",
        selection=[
            ('submission', "Submission"),
            ('cancellation', "Cancellation"),
        ],
        readonly=True,
        required=True,
    )
    # Note: Noone has write access of any kind to the model 'verifactu.document' (see ir.model.access.csv)
    json_attachment_id = fields.Many2one(
        string="JSON Attachment",
        comodel_name='ir.attachment',
        readonly=True,
        copy=False,
    )
    # To use the binary widget in the form view to download the attachment
    json_attachment_base64 = fields.Binary(
        string="JSON",
        related='json_attachment_id.datas',
    )
    json_attachment_filename = fields.Char(
        string="JSON Filename",
        compute='_compute_json_attachment_filename',
    )
    errors = fields.Html(
        string="Errors",
        copy=False,
        readonly=True,
    )
    response_csv = fields.Char(
        string="Response CSV",
        copy=False,
        readonly=True,
        help="The CSV of the response from the tax agency. There may not be one in case all documents of the batch were rejected.",
    )
    state = fields.Selection(
        string="Status",
        selection=[
            ('rejected', "Rejected"),
            ('registered_with_errors', "Registered with Errors"),
            ('accepted', "Accepted"),
        ],
        copy=False,
        readonly=True,
        help="""- Rejected: Successfully sent to the AEAT, but it was rejected during validation
                - Registered with Errors: Registered at the AEAT, but the AEAT has some issues with the sent record
                - Accepted: Registered by the AEAT without errors""",
    )

    @api.depends('document_type')
    def _compute_display_name(self):
        for document in self:
            document.display_name = _("Veri*Factu Document %s", document.id)

    @api.depends('chain_index', 'document_type')
    def _compute_json_attachment_filename(self):
        for document in self:
            document_type = 'anulacion' if document.document_type == 'cancellation' else 'alta'
            name = f"verifactu_registro_{document.chain_index}_{document_type}.json"
            document.json_attachment_filename = name

    @api.ondelete(at_uninstall=False)
    def _never_unlink_chained_documents(self):
        for document in self:
            if document.chain_index:
                raise UserError(_("You cannot delete Veri*Factu Documents that are part of the chain of all Veri*Factu Documents."))

    def _get_document_dict(self):
        self.ensure_one()
        if not self.json_attachment_id:
            return {}
        json_data = self.json_attachment_id.raw
        return json.loads(json_data)

    def _get_record_identifier(self):
        if not self:
            return False
        return self._extract_record_identifiers(self._get_document_dict())

    @api.model
    def _extract_record_identifiers(self, document_dict):
        """Return a dictionary that includes:
          * the IDFactura fields
          * the fields used for the fingerprint generation of this document and the next one
            (The fingerprint of this record is part of the fingerprint generation of the next record)
          * the fields used for QR code generation
          * the fields used for ImporteRectificacion (in case of rectification by substitutuion)
        """
        cancellation = 'RegistroAnulacion' in document_dict
        record_type = 'RegistroAnulacion' if cancellation else 'RegistroAlta'
        record_type_vals = document_dict[record_type]
        id_factura = record_type_vals['IDFactura']

        identifiers = {
            'FechaHoraHusoGenRegistro': record_type_vals['FechaHoraHusoGenRegistro'],
            'Huella': record_type_vals['Huella'],
        }
        if cancellation:
            identifiers.update({
                'IDEmisorFactura': id_factura['IDEmisorFacturaAnulada'],
                'NumSerieFactura': id_factura['NumSerieFacturaAnulada'],
                'FechaExpedicionFactura': id_factura['FechaExpedicionFacturaAnulada'],
            })
        else:
            identifiers.update({
                **{key: id_factura[key] for key in ['IDEmisorFactura', 'NumSerieFactura', 'FechaExpedicionFactura']},
                **{key: record_type_vals[key] for key in ['TipoFactura', 'CuotaTotal', 'ImporteTotal']},
                'FechaOperacion': record_type_vals.get('FechaOperacion'),  # optional
            })
        return identifiers

    @api.model
    def _format_errors(self, title, errors):
        error = {
            'error_title': title,
            'errors': errors,
        }
        return self.env['account.move.send']._format_error_html(error)

    ####################################################################
    # Helpers to be used on the records ('account.move' / 'pos.order') #
    ####################################################################

    @api.model
    def _get_tax_details(self, base_lines, company, tax_lines=None):
        AccountTax = self.env['account.tax']
        tax_details_functions = AccountTax._l10n_es_edi_verifactu_get_tax_details_functions(company)
        base_line_filter = tax_details_functions['base_line_filter']
        total_grouping_function = tax_details_functions['total_grouping_function']
        tax_details_grouping_function = tax_details_functions['tax_details_grouping_function']

        base_lines = [base_line for base_line in base_lines if base_line_filter(base_line)]

        AccountTax._add_tax_details_in_base_lines(base_lines, company)
        AccountTax._round_base_lines_tax_details(base_lines, company, tax_lines=tax_lines)

        # Totals
        base_lines_aggregated_values_for_totals = AccountTax._aggregate_base_lines_tax_details(base_lines, total_grouping_function)
        totals = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values_for_totals)[True]

        # Tax details
        base_lines_aggregated_values_for_tax_details = AccountTax._aggregate_base_lines_tax_details(base_lines, tax_details_grouping_function)
        tax_details = AccountTax._aggregate_base_lines_aggregated_values(base_lines_aggregated_values_for_tax_details)

        return {
            'base_amount': totals['base_amount'],
            'tax_amount': totals['tax_amount'],
            'tax_details': {key: tax_detail for key, tax_detail in tax_details.items() if key},
            'tax_details_per_record': {
                frozendict(base_line): {key: tax_detail for key, tax_detail in tax_details.items() if key}
                for base_line, tax_details in base_lines_aggregated_values_for_tax_details
            },
        }

    def _filter_waiting(self):
        return self.filtered(lambda doc: not doc.state and doc.json_attachment_id)

    def _get_last(self, document_type):
        return self.filtered(lambda doc: doc.document_type == document_type and doc.json_attachment_id).sorted()[:1]

    def _get_state(self):
        # Helper method to get the most recent state from a set of documents.
        # It should only be used on all the documents associated with a move or pos order.
        last_registered_document = self.filtered(lambda doc: doc.state in ('registered_with_errors', 'accepted')).sorted()[:1]
        if last_registered_document:
            cancellation = last_registered_document.document_type == 'cancellation'
            return 'cancelled' if cancellation else last_registered_document.state

        rejected_document = self.filtered(lambda doc: doc.state == 'rejected')[:1]
        if rejected_document:
            return 'rejected'

        return False

    def _get_qr_code_img_url(self):
        self.ensure_one()
        record_identifier = self._get_record_identifier()
        if not record_identifier or self.document_type != 'submission':
            # We take the values from the record identifier.
            # And only the 'submission' has all the necessary values ('ImporteTotal').
            return False
        # Documentation: "Detalle de las especificaciones técnicas del código «QR» de la factura y de la «URL» del
        # servicio de cotejo o remisión de información por parte del receptor de la factura"
        # https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/DetalleEspecificacTecnCodigoQRfactura.pdf
        endpoint_url = self.company_id._l10n_es_edi_verifactu_get_endpoints()['QR']
        url_params = url_encode({
            'nif': record_identifier['IDEmisorFactura'],
            'numserie': record_identifier['NumSerieFactura'],
            'fecha': record_identifier['FechaExpedicionFactura'],
            'importe': record_identifier['ImporteTotal'],
        })
        url = url_quote_plus(f"{endpoint_url}?{url_params}")
        return f'/report/barcode/?barcode_type=QR&value={url}&barLevel=M&width=180&height=180'

    @api.model
    def _check_record_values(self, vals):
        errors = []

        company_NIF = vals['company'].partner_id._l10n_es_edi_verifactu_get_values().get('NIF')
        if not company_NIF or len(company_NIF) != 9:  # NIFType
            errors.append(_("The NIF '%(company_NIF)s' of the company is not exactly 9 characters long.",
                            company_NIF=company_NIF))

        if not vals['name'] or len(vals['name']) > 60:
            errors.append(_("The name of the record is not between 1 and 60 characters long: %(name)s.",
                            name=vals['name']))

        if vals['documents'] and vals['documents']._filter_waiting():
            errors.append(_("We are waiting to send a Veri*Factu record to the AEAT already."))

        verifactu_registered = vals['verifactu_state'] in ('registered_with_errors', 'accepted')
        # We currently do not support updating registered records (resending).
        if not vals['cancellation'] and verifactu_registered:
            errors.append(_("The record is Veri*Factu registered already."))
        # We currently do not support cancelling records that are not registered or were registered outside odoo.
        if vals['cancellation'] and not verifactu_registered:
            errors.append(_("The cancelled record is not Veri*Factu registered (inside Odoo)."))

        certificate = vals['company'].sudo()._l10n_es_edi_verifactu_get_certificate()
        if not certificate:
            errors.append(_("There is no certificate configured for Veri*Factu on the company."))

        if not vals['invoice_date']:
            errors.append(_("The invoice date is missing."))

        if vals['verifactu_move_type'] == 'correction_substitution' and not vals['substituted_document']:
            errors.append(_("There is no Veri*Factu document for the substituted record."))

        if vals['verifactu_move_type'] == 'correction_substitution' and not vals['substituted_document_reversal_document']:
            errors.append(_("There is no Veri*Factu document for the reversal of the substituted record."))

        if vals['verifactu_move_type'] in ('correction_incremental', 'reversal_for_substitution') and not vals['refunded_document']:
            errors.append(_("There is no Veri*Factu document for the refunded record."))

        need_refund_reason = vals['verifactu_move_type'] in ('correction_incremental', 'correction_substitution')
        if need_refund_reason and not vals['refund_reason']:
            errors.append(_("The refund reason is not specified."))

        simplified_partner = self.env.ref('l10n_es.partner_simplified', raise_if_not_found=False)
        partner_specified = vals['partner'] and vals['partner'] != simplified_partner
        if need_refund_reason and vals['refund_reason'] != 'R5' and vals['is_simplified']:
            errors.append(_("A refund with Refund Reason %(refund_reason)s is not simplified (it needs a partner).",
                            refund_reason=vals['refund_reason']))

        if vals['verifactu_move_type'] == 'invoice' and not partner_specified and not vals['is_simplified']:
            errors.append(_("A non-simplified invoice needs a partner."))

        if not vals['l10n_es_applicability']:
            errors.append(_("Missing Veri*Factu Tax Applicability (Impuesto)."))

        if vals['l10n_es_applicability'] in ('01', '03') and not vals['clave_regimen']:
            errors.append(_("Missing Veri*Factu Regime Key (ClaveRegimen)."))

        sujeto_tax_types = self.env['account.tax']._l10n_es_get_sujeto_tax_types()
        ignored_tax_types = ['ignore', 'retencion']
        supported_tax_types = sujeto_tax_types + ignored_tax_types + ['no_sujeto', 'no_sujeto_loc', 'recargo', 'exento']
        tax_type_description = self.env['account.tax']._fields['l10n_es_type'].get_description(self.env)
        if not vals['tax_details']['tax_details']:
            errors.append(_("There are no taxes set on the invoice"))
        for key, tax_detail in vals['tax_details']['tax_details'].items():
            tax_type = key['l10n_es_type']
            if tax_type not in supported_tax_types:
                # tax_type in ('no_deducible', 'dua')
                # The remaining tax types are purchase taxes (for vendor bills).
                errors.append(_("A tax with value '%(tax_type)s' as %(field)s is not supported.",
                                field=tax_type_description['string'],
                                tax_type=dict(tax_type_description['selection'])[tax_type]))
            elif tax_type in ('no_sujeto', 'no_sujeto_loc'):
                tax_percentage = key['amount']
                tax_amount = tax_detail['tax_amount']
                if float_round(tax_percentage, precision_digits=2) or float_round(tax_amount, precision_digits=2):
                    errors.append(_("No Sujeto VAT taxes must have 0 amount."))
            if len(key['recargo_taxes']) > 1:
                errors.append(_("Only a single recargo tax may be used per \"main\" tax."))

        main_tax_types = self.env['account.tax']._l10n_es_get_main_tax_types()
        tax_applicabilities = {
            grouping_key['l10n_es_applicability']
            for grouping_key in vals['tax_details']['tax_details']
            if grouping_key['l10n_es_type'] in main_tax_types
        }
        if len(tax_applicabilities) > 1:
            name_map = self.env['account.tax']._l10n_es_edi_verifactu_get_applicability_name_map()
            errors.append(_("We only allow a single Veri*Factu Tax Applicability (Impuesto) per document: %(applicabilities)s.",
                            applicabilities=', '.join([name_map[t] for t in tax_applicabilities])))

        for record_detail in vals['tax_details']['tax_details_per_record'].values():
            main_tax_details = [
                tax_detail for key, tax_detail in record_detail.items()
                if key['l10n_es_type'] in main_tax_types
            ]
            if len(main_tax_details) > 1:
                errors.append(_("We only allow a single \"main\" tax per line."))
                break  # Giving the errors once should be enough

        return errors

    #####################
    # Document Creation #
    #####################

    def _create_for_record(self, record_values):
        """Create Veri*Factu documents for input `record_values`.
        Return the created document.
        The documents are also created in case the JSON generation fails; to inspect the errors.
        Such documents are deleted in case the JSON generation succeeds for a record at a later time.
        (In case we succesfully create a JSON we delete all linked documents that failed the JSON creation.)
        :param list record_values: record values dictionary
        """
        document_vals = record_values['document_vals']
        error_title = _("The Veri*Factu document could not be created")

        if not record_values['errors']:
            record_values['errors'] = self._check_record_values(record_values)

        if record_values['errors']:
            document_vals['errors'] = self._format_errors(error_title, record_values['errors'])
        else:
            previous_document = record_values['company']._l10n_es_edi_verifactu_get_last_document()
            render_vals = self._render_vals(
                record_values, previous_record_identifier=previous_document._get_record_identifier(),
            )
            document_dict = {render_vals['record_type']: render_vals[render_vals['record_type']]}

            # We check whether zeep can generate a valid XML (according to the information from the WSDL / XSD)
            # from the `document_dict`.
            # Otherwise this may happen at sending. But then the issue may be hard to resolve:
            # - Each generated document should also be sent to the AEAT.
            # - Documents must not be altered after generation.
            # The created XML (`create_message`) is just discarded.
            create_message = None
            try:
                # We only generate the XML; nothing is sent at this point.
                create_message, _zeep_info = _get_zeep_operation(record_values['company'], 'registration_xml')
            except (zeep.exceptions.Error, requests.exceptions.RequestException) as error:
                # The zeep client creation may cause a networking error
                errors = [_("Networking error: %s", error)]
                document_vals['errors'] = self._format_errors(error_title, errors)
                _logger.error("%s\n%s\n%s", error_title, errors[0], json.dumps(document_dict, indent=4))

            if create_message:
                batch_dict = self.with_company(record_values['company'])._get_batch_dict([document_dict])
                try:
                    _xml_node = create_message(batch_dict['Cabecera'], batch_dict['RegistroFactura'])
                except zeep.exceptions.ValidationError as error:
                    errors = [_("Validation error: %s", error)]
                    document_vals['errors'] = self._format_errors(error_title, errors)
                    _logger.error("%s\n%s\n%s", error_title, errors[0], json.dumps(batch_dict, indent=4))
                except zeep.exceptions.Error as error:
                    errors = [error]
                    document_vals['errors'] = self._format_errors(error_title, errors)
                    _logger.error("%s\n%s\n%s", error_title, errors[0], json.dumps(batch_dict, indent=4))

            if not document_vals.get('errors'):
                chain_sequence = record_values['company'].sudo()._l10n_es_edi_verifactu_get_chain_sequence()
                try:
                    document_vals['chain_index'] = chain_sequence.next_by_id()
                except OperationalError as e:
                    # We chain all the created documents per company in generation order.
                    # (indexed by `chain_index`).
                    # Thus we can not generate multiple documents for the same company at the same time.
                    # Function `next_by_id` effectively locks `company.l10n_es_edi_verifactu_chain_sequence_id`
                    # to prevent different transactions from chaining documents at the same time.
                    errors = [_("Error while chaining the document: %s", e)]
                    document_vals['errors'] = self._format_errors(error_title, errors)
                    _logger.error("%s\n%s\n%s", error_title, errors[0], json.dumps(batch_dict, indent=4))

        document = self.sudo().create(document_vals)

        if document.chain_index:
            attachment = self.env['ir.attachment'].sudo().create({
                'raw': json.dumps(document_dict, indent=4).encode(),
                'name': document.json_attachment_filename,
                'res_id': document.id,
                'res_model': document._name,
                'mimetype': 'application/json',
            })
            document.sudo().json_attachment_id = attachment
            # Remove (previously generated) documents that failed to generate a (valid) JSON
            record_values['documents'].filtered(lambda rd: not rd.json_attachment_id).sudo().unlink()

        return document

    #################
    # JSON Creation #
    #################

    @api.model
    def _format_date_type(self, date):
        if not date:
            return None
        # Format as 'fecha' type from xsd
        return date.strftime('%d-%m-%Y')

    @api.model
    def _round_format_number_2(self, number):
        # Round and format as number with 2 precision digits
        # I.e. used for 'ImporteSgn12.2Type' and 'Tipo2.2Type' XSD types.
        # We do not check / fix the number of digits in front of the decimal separator
        if number is None:
            return None
        rounded = float_round(number, precision_digits=2)
        return float_repr(rounded, precision_digits=2)

    @api.model
    def _render_vals(self, vals, previous_record_identifier=None):
        def remove_None_and_False(value):
            # Remove `None` and `False` from dictionaries
            if isinstance(value, dict):
                return {
                    key: remove_None_and_False(value)
                    for key, value in value.items()
                    if value is not None and value is not False
                }
            elif isinstance(value, list):
                return [remove_None_and_False(v) for v in value]
            else:
                return value

        record_type = 'RegistroAnulacion' if vals['cancellation'] else 'RegistroAlta'
        render_vals = {
            'company': vals['company'],
            'record_type': record_type,
            'record': vals['record'],
            'cancellation': vals['cancellation'],
            'vals': vals,
            'previous_record_identifier': previous_record_identifier,
        }

        generation_time_string = fields.Datetime.now(timezone('Europe/Madrid')).astimezone(timezone('Europe/Madrid')).isoformat()

        record_type_vals = {
            'IDVersion': VERIFACTU_VERSION,
            'FechaHoraHusoGenRegistro': generation_time_string,
            **self._render_vals_operation(vals),
            **self._render_vals_previous_submissions(vals),
            **self._render_vals_monetary_amounts(vals),
            **self._render_vals_SistemaInformatico(vals),
        }
        render_vals[record_type] = remove_None_and_False(record_type_vals)

        self._update_render_vals_with_chaining_info(render_vals)

        return render_vals

    @api.model
    def _render_vals_operation(self, vals):
        company_values = vals['company'].partner_id._l10n_es_edi_verifactu_get_values()
        invoice_date = self._format_date_type(vals['invoice_date'])

        if vals['cancellation']:
            render_vals = {
                'IDFactura': {
                    'IDEmisorFacturaAnulada': company_values['NIF'],
                    'NumSerieFacturaAnulada': vals['name'],
                    'FechaExpedicionFacturaAnulada': invoice_date,
                }
            }
            return render_vals

        render_vals = {
            'NombreRazonEmisor': company_values['NombreRazon'],
            'IDFactura': {
                'IDEmisorFactura': company_values['NIF'],
                'NumSerieFactura': vals['name'],
                'FechaExpedicionFactura': invoice_date,
            }
        }

        rectified_document = vals['refunded_document'] or vals['substituted_document']
        if vals['verifactu_move_type'] == 'invoice':
            tipo_rectificativa = None
            tipo_factura = 'F2' if vals['is_simplified'] else 'F1'
            delivery_date = self._format_date_type(vals['delivery_date'])
            fecha_operacion = delivery_date if delivery_date and delivery_date != invoice_date else None
        elif vals['verifactu_move_type'] == 'reversal_for_substitution':
            tipo_rectificativa = None
            tipo_factura = 'F2' if vals['is_simplified'] else 'F1'
            fecha_operacion = None
        elif vals['verifactu_move_type'] == 'correction_substitution':
            tipo_rectificativa = 'S'
            tipo_factura = vals['refund_reason']
            rectified = rectified_document._get_record_identifier()
            fecha_operacion = rectified['FechaOperacion'] or rectified['FechaExpedicionFactura']
        else:
            # vals['verifactu_move_type'] == 'correction_incremental':
            tipo_rectificativa = 'I'
            tipo_factura = vals['refund_reason']
            rectified = rectified_document._get_record_identifier()
            fecha_operacion = rectified['FechaOperacion'] or rectified['FechaExpedicionFactura']

        # Note: Error [1189]
        # Si TipoFactura es F1 o F3 o R1 o R2 o R3 o R4 el bloque Destinatarios tiene que estar cumplimentado.

        if not vals['is_simplified']:
            render_vals['Destinatarios'] = {
                'IDDestinatario': [vals['partner']._l10n_es_edi_verifactu_get_values()]
            }

        render_vals.update({
            'TipoFactura': tipo_factura,
            'TipoRectificativa': tipo_rectificativa,  # may be None
            'FechaOperacion': fecha_operacion,
            'DescripcionOperacion': vals['description'] or 'manual',
        })

        if vals['verifactu_move_type'] in ('correction_incremental', 'correction_substitution'):
            rectified_record_identifier = rectified_document._get_record_identifier()
            render_vals.update({
                'FacturasRectificadas': [{
                    'IDFacturaRectificada': {
                        key: rectified_record_identifier[key]
                        for key in ['IDEmisorFactura', 'NumSerieFactura', 'FechaExpedicionFactura']
                    }
                }],
            })
        # [1118] Si la factura es de tipo rectificativa por sustitución el bloque ImporteRectificacion es obligatorio.
        if vals['verifactu_move_type'] == 'correction_substitution':
            # We only support substitution if we also send an invoice that cancels out the amounts of the original invoice.
            # ('Opción 2' in the FAQ under '¿Cómo registra el emisor una factura rectificativa por sustitución “S”?')
            render_vals.update({
                'ImporteRectificacion': {
                    'BaseRectificada': self._round_format_number_2(0),
                    'CuotaRectificada': self._round_format_number_2(0),
                },
            })

        return render_vals

    @api.model
    def _render_vals_previous_submissions(self, vals):
        """
        See "Sistemas Informáticos de Facturación y Sistemas VERI*FACTU" Version 1.1.1 - "Validaciones" p. 22 f.
        https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/Validaciones_Errores_Veri-Factu.pdf
        For submissions (ALTA) we do not support any subsanación cases (update of a previously sent invoice).
        (Instead the user can issue a credit note and possibly a new substituting invoice).
        With the nomenclature from the Validations document above the following cases are supported (✓) / unsupported (✗):
          ✓ ALTA (new record)
          ✓ ALTA POR RECHAZO (new record, previously rejected)
          ✗ ALTA DE SUBSANACIÓN (update, previously rejected)
          ✗ ALTA POR RECHAZO DE SUBSANACIÓN (update, previously rejected)
          ✗ ALTA DE SUBSANACIÓN SIN REGISTRO PREVIO (update, record not known to the AEAT)
          ✗ ALTA POR RECHAZO DE SUBSANACIÓN SIN REGISTRO PREVIO (update, record not known to the AEAT, previously rejected)
          ✓ ANULACIÓN (cancellation)
          ✓ ANULACIÓN POR RECHAZO (cancellation, previously rejected)
          ✓ ANULACIÓN SIN REGISTRO PREVIO (cancellation, record not known to the AEAT)
          ✓ ANULACIÓN POR RECHAZO SIN REGISTRO PREVIO  (cancellation, record not known to the AEAT, previously rejected)
        """
        render_vals = {}
        verifactu_registered = vals['verifactu_state'] in ('registered_with_errors', 'accepted')

        if vals['cancellation']:
            render_vals = {
                # A cancelled record can e.g. not exist at the AEAT when we switch to Veri*Factu after the original invoice was created
                'SinRegistroPrevio': 'S' if not verifactu_registered else 'N',
                'RechazoPrevio': 'S' if vals['rejected_before'] else 'N',
            }
        else:
            render_vals = {
                'Subsanacion': 'S' if vals['rejected_before'] else 'N',
                'RechazoPrevio': 'X' if vals['rejected_before'] else None,
            }

        return render_vals

    @api.model
    def _render_vals_monetary_amounts(self, vals):
        # Note: We only support a single verifactu tax applicabilty, clave regimen pair per record.
        # For moves the clave regime is stored on each move in field `l10n_es_edi_verifactu_clave_regimen`
        if vals['cancellation']:
            return {}

        sign = vals['sign']
        sujeto_tax_types = self.env['account.tax']._l10n_es_get_sujeto_tax_types()

        recargo_tax_details_key = {}  # dict (tax_key -> recargo_tax_key)
        for record_tax_details in vals['tax_details']['tax_details_per_record'].values():
            main_key = None
            recargo_key = None
            # Note: There is only a single (main tax, recargo tax) pair on a single invoice line
            #       (if any; see `_check_record_values`)
            for key in record_tax_details:
                if key['recargo_taxes']:
                    main_key = key
                if key['l10n_es_type'] == 'recargo':
                    recargo_key = key
                if main_key and recargo_key:
                    break
            recargo_tax_details_key[main_key] = recargo_key

        detalles = []
        for key, tax_detail in vals['tax_details']['tax_details'].items():
            tax_type = key['l10n_es_type']
            # Tax types 'ignore' and 'retencion' are ignored when generating the `tax_details`
            # See `filter_to_apply` in function `_l10n_es_edi_verifactu_get_tax_details_functions` on 'account.tax'
            if tax_type == 'recargo':
                # Recargo taxes are only used in combination with another tax (a sujeto tax)
                # They will be handled when processing the remaining taxes
                continue

            exempt_reason = key['l10n_es_exempt_reason']  # only set if exempt

            tax_percentage = key['amount']
            base_amount = sign * tax_detail['base_amount']
            tax_amount = math.copysign(tax_detail['tax_amount'], base_amount)

            calificacion_operacion = None  # Reported if not tax-exempt;
            recargo_equivalencia = {}
            if tax_type in sujeto_tax_types:
                calificacion_operacion = 'S2' if tax_type == 'sujeto_isp' else 'S1'
                if key['recargo_taxes']:
                    recargo_key = recargo_tax_details_key.get(key)
                    recargo_tax_detail = vals['tax_details']['tax_details'][recargo_key]
                    recargo_tax_percentage = recargo_key['amount']
                    recargo_tax_amount = math.copysign(recargo_tax_detail['tax_amount'], base_amount)
                    recargo_equivalencia.update({
                        'tax_percentage': recargo_tax_percentage,
                        'tax_amount': recargo_tax_amount,
                    })
            elif tax_type in ('no_sujeto', 'no_sujeto_loc'):
                calificacion_operacion = 'N2' if tax_type == 'no_sujeto_loc' else 'N1'
            else:
                # tax_type == 'exento' (see `_check_record_values`)
                # exempt_reason set already
                # [1238]
                #     Si la operacion es exenta no se puede informar ninguno de los campos
                #     TipoImpositivo, CuotaRepercutida, TipoRecargoEquivalencia y CuotaRecargoEquivalencia.
                tax_percentage = None
                tax_amount = None
                recargo_percentage = None
                recargo_amount = None

            recargo_percentage = recargo_equivalencia.get('tax_percentage')
            recargo_amount = recargo_equivalencia.get('tax_amount')

            # Note on the TipoImpositivo and CuotaRepercutida tags.
            # In some cases it makes a difference for the validation whether the tags are output with 0
            # or not at all:
            # - In the no sujeto cases (calification_operacion in ('N1', 'N2')) we may not include them.
            # - In the (calification_operacion == S2) case the tags have to be included with value 0.
            #
            # See the following errors:
            # [1198]
            #     Si CalificacionOperacion es S2 TipoImpositivo y CuotaRepercutida deberan tener valor 0.
            # [1237]
            #     El valor del campo CalificacionOperacion está informado como N1 o N2 y el impuesto es IVA.
            #     No se puede informar de los campos TipoImpositivo, CuotaRepercutida, TipoRecargoEquivalencia y CuotaRecargoEquivalencia.
            if calificacion_operacion in ('N1', 'N2') and vals['l10n_es_applicability'] == '01':
                tax_percentage = None
                tax_amount = None

            detalle = {
                'Impuesto': vals['l10n_es_applicability'],
                'ClaveRegimen': vals['clave_regimen'],
                'CalificacionOperacion': calificacion_operacion,
                'OperacionExenta': exempt_reason,
                'TipoImpositivo': self._round_format_number_2(tax_percentage),
                'BaseImponibleOimporteNoSujeto': self._round_format_number_2(base_amount),
                'CuotaRepercutida': self._round_format_number_2(tax_amount),
                'TipoRecargoEquivalencia': self._round_format_number_2(recargo_percentage),
                'CuotaRecargoEquivalencia': self._round_format_number_2(recargo_amount),
            }

            detalles.append(detalle)

        total_amount = sign * (vals['tax_details']['base_amount'] + vals['tax_details']['tax_amount'])
        tax_amount = sign * (vals['tax_details']['tax_amount'])

        render_vals = {
            'Macrodato': 'S' if abs(total_amount) >= 100000000 else None,
            'Desglose': {
                'DetalleDesglose': detalles
            },
            'CuotaTotal': self._round_format_number_2(tax_amount),
            'ImporteTotal': self._round_format_number_2(total_amount),
        }

        return render_vals

    @api.model
    def _get_db_identifier(self):
        database_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
        return _sha256(database_uuid)

    @api.model
    def _render_vals_SistemaInformatico(self, vals):
        spanish_companies_on_db_count = self.env['res.company'].sudo().search_count([
            ('account_fiscal_country_id.code', '=', 'ES'),
        ], limit=2)

        # Note: We have to declare (self-certify) that we meet the Veri*Factu spec.
        # (DECLARACIÓN RESPONSABLE DE SISTEMAS INFORMÁTICOS DE FACTURACIÓN)
        # The values should match the values given in the declaration.
        render_vals = {
            'SistemaInformatico': {
                'NombreRazon': 'Odoo SA',
                'IDOtro': {
                    'CodigoPais': 'BE',
                    'IDType': '02',  # NIF-IVA
                    'ID': 'BE0477472701',
                },
                'NombreSistemaInformatico': 'Odoo',
                'IdSistemaInformatico': '00',  # identifies Odoo the software as product of Odoo the company
                'Version': odoo.release.version,
                'NumeroInstalacion':  self._get_db_identifier(),
                'TipoUsoPosibleSoloVerifactu': 'S',
                'TipoUsoPosibleMultiOT': 'S',
                'IndicadorMultiplesOT': 'S' if spanish_companies_on_db_count > 1 else 'N',
            },
        }

        return render_vals

    @api.model
    def _update_render_vals_with_chaining_info(self, render_vals):
        record_type_vals = render_vals[render_vals['record_type']]
        predecessor = (render_vals['previous_record_identifier'] or {})
        first_registration = not bool(predecessor)

        if first_registration:
            encadenamiento = {
                'PrimerRegistro': 'S',
            }
        else:
            encadenamiento = {
                'RegistroAnterior': {
                    'IDEmisorFactura': predecessor['IDEmisorFactura'],
                    'NumSerieFactura': predecessor['NumSerieFactura'],
                    'FechaExpedicionFactura': predecessor['FechaExpedicionFactura'],
                    'Huella': predecessor['Huella'],
                }
            }
        # The 'Encadenamiento' info needs to be set already during the `_fingerprint` computation
        record_type_vals['Encadenamiento'] = encadenamiento

        record_type_vals.update({
            'TipoHuella': "01",  # "01" means SHA-256
            'Huella': self._fingerprint(render_vals),
        })

        return render_vals

    @api.model
    def _fingerprint(self, render_vals):
        """
        Documentation: "Detalle de las especificaciones técnicas para generación de la huella o hash de los registros de facturación"
        https://www.agenciatributaria.es/static_files/AEAT_Desarrolladores/EEDD/IVA/VERI-FACTU/Veri-Factu_especificaciones_huella_hash_registros.pdf
        """
        record_type_vals = render_vals[render_vals['record_type']]
        id_factura = record_type_vals['IDFactura']
        registro_anterior = record_type_vals['Encadenamiento'].get('RegistroAnterior')  # does not exist for the first document
        record_type_vals_keys = [] if render_vals['cancellation'] else ['TipoFactura', 'CuotaTotal', 'ImporteTotal']
        fingerprint_values = [
            *list(id_factura.items()),
            *[(key, record_type_vals[key]) for key in record_type_vals_keys],
            ('Huella', registro_anterior['Huella'] if registro_anterior else ''),
            ('FechaHoraHusoGenRegistro', record_type_vals['FechaHoraHusoGenRegistro']),
        ]
        string = "&".join([f"{field}={value.strip()}" for (field, value) in fingerprint_values])
        return _sha256(string)

    ###########
    # Sending #
    ###########

    @api.model
    def trigger_next_batch(self):
        """
        1. Send all waiting documents that we can send
        2. Trigger the cron again at a later date to send the documents we could not send
        """
        unsent_domain = [
            ('json_attachment_id', '!=', False),
            ('state', '=', False),
        ]
        documents_per_company = self.sudo()._read_group(
            unsent_domain,
            groupby=['company_id'],
            aggregates=['id:recordset'],
        )

        if not documents_per_company:
            return

        next_trigger_time = None
        for company, documents in documents_per_company:
            # Avoid sending a document twice due to concurrent calls to `trigger_next_batch`.
            # This should also avoid concurrently sending in general since the set of documents
            # in both calls should overlap. (Since we always include all previously unsent documents.)
            try:
                self.env['res.company']._with_locked_records(documents)
            except UserError:
                # We will later make sure that we trigger the cron again
                continue

            # We sort the `documents` to batch them in the order they were chained
            documents = documents.sorted('chain_index')

            # Send batches with size BATCH_LIMIT; they are not restricted by the waiting time
            next_batch = documents[:BATCH_LIMIT]
            start_index = 0
            while len(next_batch) == BATCH_LIMIT:
                next_batch.with_company(company)._send_as_batch()
                start_index += BATCH_LIMIT
                next_batch = documents[start_index:start_index + BATCH_LIMIT]
            # Now: len(next_batch) < BATCH_LIMIT ; we need to respect the waiting time

            if not next_batch:
                continue

            next_batch_time = company.l10n_es_edi_verifactu_next_batch_time
            if not next_batch_time or fields.Datetime.now() >= next_batch_time:
                next_batch.with_company(company)._send_as_batch()
            else:
                # Since we have a `next_batch_time` the `next_trigger_time` will be set to a datetime
                # We set it to the minimum of all the already encountered `next_batch_time`
                next_trigger_time = min(next_trigger_time or datetime.max, next_batch_time)

        # In case any of the documents were not successfully sent we trigger the cron again in 60s
        # (or at the next batch time if the 60s is earlier)
        for company, documents in documents_per_company:
            unsent_documents = documents.filtered_domain(unsent_domain)
            next_batch_time = company.l10n_es_edi_verifactu_next_batch_time
            if unsent_documents:
                # Trigger in 60s or at the next batch time (except if there is an earlier trigger already)
                in_60_seconds = fields.Datetime.now() + timedelta(seconds=60)
                company_next_trigger_time = max(in_60_seconds, next_batch_time or datetime.min)
                # Set `next_trigger_time` to the minimum of all the already encountered trigger times
                next_trigger_time = min(next_trigger_time or datetime.max, company_next_trigger_time)

        if next_trigger_time:
            cron = self.env.ref('l10n_es_edi_verifactu.cron_verifactu_batch', raise_if_not_found=False)
            if cron:
                cron._trigger(at=next_trigger_time)

    @api.model
    def _send_batch(self, batch_dict):
        info = {
            'errors': [],
            'record_info': {},
            'soap_fault': False,
        }
        errors = info['errors']
        record_info = info['record_info']

        try:
            register, zeep_info = _get_zeep_operation(self.env.company, 'registration')
        except (zeep.exceptions.Error, requests.exceptions.RequestException) as error:
            errors.append(_("Networking error:\n%s", error))
            return info

        try:
            res = register(batch_dict['Cabecera'], batch_dict['RegistroFactura'])
            # `res` is of type 'zeep.client.SerialProxy'
        except requests.exceptions.SSLError:
            errors.append(_("The SSL certificate could not be validated."))
        except zeep.exceptions.TransportError as error:
            certificate_error = "No autorizado. Se ha producido un error al verificar el certificado presentado"
            if certificate_error in error.message:
                errors.append(_("The document could not be sent; the access was denied due to a problem with the certificate."))
            else:
                errors.append(_("Networking error while sending the document:\n%s", error))
        except requests.exceptions.ReadTimeout as error:
            # The error is only partially translated since we check for this message for the timeout duplicate handling.
            # (See `_send_as_batch`)
            error_description = _("Timeout while waiting for the response from the server:\n%s", error)
            errors.append(f"[Read-Timeout] {error_description}")
        except requests.exceptions.RequestException as error:
            errors.append(_("Networking error while sending the document:\n%s", error))
        except zeep.exceptions.Fault as soapfault:
            info['soap_fault'] = True
            errors.append(f"[{soapfault.code}] {soapfault.message}")
        except zeep.exceptions.XMLSyntaxError as error:
            _logger.error("raw zeep response:\n%s", zeep_info.get('raw_response'))
            certificate_error = "The root element found is html"
            if certificate_error in error.message:
                errors.append(_("The response of the server had the wrong format (HTML instead of XML). It is most likely a problem with the certificate."))
            else:
                errors.append(_("Error while sending the batch document:\n%s", error))
        except zeep.exceptions.Error as error:
            _logger.error("raw zeep response:\n%s", zeep_info.get('raw_response'))
            errors.append(_("Error while sending the batch document:\n%s", error))

        if errors:
            return info

        info.update({
            'response_csv': res['CSV'] if 'CSV' in res else None,  # noqa: SIM401 - `res` is of type 'zeep.client.SerialProxy'
            'waiting_time_seconds': int(res['TiempoEsperaEnvio']),
        })

        # EstadoRegistroType
        state_map = {
            'Incorrecto': 'rejected',
            'AceptadoConErrores': 'registered_with_errors',
            'Correcto': 'accepted',
        }
        # EstadoRegistroSFType
        duplicate_state_map = {
            'AceptadaConErrores': 'registered_with_errors',
            'Correcta': 'accepted',
            'Anulada': 'cancelled',
        }

        for response_line in res['RespuestaLinea']:
            record_id = response_line['IDFactura']
            invoice_issuer = record_id['IDEmisorFactura'].strip()
            invoice_name = record_id['NumSerieFactura'].strip()
            record_key = str((invoice_issuer, invoice_name))

            operation_type = response_line['Operacion']['TipoOperacion']
            received_state = response_line['EstadoRegistro']
            # In case of a duplicate the response supplies information about the original invoice.
            duplicate_info = response_line['RegistroDuplicado']
            duplicate = {}
            if duplicate_info:
                duplicate_state = duplicate_state_map[duplicate_info['EstadoRegistroDuplicado']]
                duplicate = {
                    'state': duplicate_state,
                    'errors': [],
                }
                if duplicate_state in ('rejected', 'registered_with_errors'):
                    error_code = duplicate_info['CodigoErrorRegistro']
                    error_description = duplicate_info['DescripcionErrorRegistro']
                    duplicate['errors'].append(f"[{error_code}] {error_description}")

            state = state_map[received_state]
            errors = []
            if state in ('rejected', 'registered_with_errors'):
                error_code = response_line['CodigoErrorRegistro']
                error_description = response_line['DescripcionErrorRegistro']
                errors.append(f"[{error_code}] {error_description}")

            record_info[record_key] = {
                'state': state,
                'cancellation': operation_type == 'Anulacion',
                'errors': errors,
                'duplicate': duplicate,
            }

        return info

    def _send_as_batch(self):
        # Documents in `self` should all belong to `self.env.company`.
        # For the cron we specifically set the `self.env.company` on some functions we call.
        sender_company = self.env.company

        batch_errors = self.with_company(sender_company)._send_as_batch_check()
        if batch_errors:
            error_title = _("The batch document could not be created")
            self.errors = self._format_errors(error_title, batch_errors)
            info = {'errors': batch_errors}
            return None, info

        # When the document is sent more than 240s after its creation the AEAT registers the document only with an error
        # See error with code 2004:
        #   El valor del campo FechaHoraHusoGenRegistro debe ser la fecha actual del sistema de la AEAT,
        #   admitiéndose un margen de error de: 240 segundos.
        incident = any(document.create_date > fields.Datetime.now() + timedelta(seconds=240) for document in self)

        document_dict_list = [document._get_document_dict() for document in self]
        batch_dict = self.with_company(sender_company)._get_batch_dict(document_dict_list, incident=incident)

        info = self.with_company(sender_company)._send_batch(batch_dict)

        batch_failure_info = {}
        if info['soap_fault'] or info['errors']:
            # Handle SOAP fault or the case that something went wrong while sending or parsing the respone.
            batch_failure_info = {
                'errors': info['errors'],
                'state': 'rejected' if info['soap_fault'] else False
            }

        # Store the information from the response split over the individual documents
        for document, document_dict in zip(self, document_dict_list):
            response_info = (
                batch_failure_info
                or info['record_info'].get(self._extract_record_key(document_dict), None)
                or {'errors': [_("We could not find any information about the record in the linked batch document.")]}
            )

            # In case of a timeout the document may have reached the AEAT but
            # we have not received the response. That is why we take the
            # duplicate information in case of timeout.
            duplicate_info = response_info.get('duplicate', {})
            if (not document.state
                and duplicate_info
                and document.errors
                and "[Read-Timeout] " in document.errors):
                if document.document_type == 'submission' and duplicate_info['state'] in ('accepted', 'registered_with_errors'):
                    response_info.update({
                        'state': duplicate_info['state'],
                        'errors': duplicate_info['errors'],
                    })
                elif document.document_type == 'cancellation' and duplicate_info['state'] == 'cancelled':
                    response_info.update({
                        'state': 'accepted',
                        'errors': duplicate_info['errors'],
                    })

            # Add some information from the batch level in any case.
            response_info.update({
                'waiting_time_seconds': info.get('waiting_time_seconds', False),
                'response_csv': info.get('response_csv', False),
            })

            # The errors have to be formatted (as HTML) before storing them on the document
            errors_html = False
            error_list = response_info.get('errors', [])
            if error_list:
                error_title = _("Error")
                if response_info.get('state', False):
                    error_title = _("The Veri*Factu document contains the following errors according to the AEAT")
                errors_html = self._format_errors(error_title, error_list)
            document.errors = errors_html

            # All other values can be stored directly on the document
            keys = ['response_csv', 'state']
            for key in keys:
                new_value = response_info.get(key, False)
                if new_value or document[key]:
                    document[key] = new_value

            # To avoid losing data we commit after every document
            if self.env['account.move']._can_commit():
                self.env.cr.commit()

        waiting_time_seconds = info.get('waiting_time_seconds')
        if waiting_time_seconds:
            now = fields.Datetime.to_datetime(fields.Datetime.now())
            next_batch_time = now + timedelta(seconds=waiting_time_seconds)
            self.env.company.l10n_es_edi_verifactu_next_batch_time = next_batch_time

        self._cancel_after_sending(info)

        if self.env['account.move']._can_commit():
            self.env.cr.commit()

        return batch_dict, info

    @api.model
    def _send_as_batch_check(self):
        # The batching / sending may happen after the initial check
        errors = []
        company = self.env.company  # sending company

        company_NIF = company.partner_id._l10n_es_edi_verifactu_get_values().get('NIF')
        if not company_NIF or len(company_NIF) != 9:  # NIFType
            errors.append(_("The NIF '%(company_NIF)s' of the company is not exactly 9 characters long.",
                            company_NIF=company_NIF))

        certificate = company.sudo()._l10n_es_edi_verifactu_get_certificate()
        if not certificate:
            errors.append(_("There is no certificate configured for Veri*Factu on the company."))

        if len(self) != len(self._filter_waiting()):
            errors.append(_("Some of the documents can not be sent. They were sent already or could not be generated correctly."))

        return errors

    @api.model
    def _get_batch_dict(self, document_dict_list, incident=False):
        company = self.env.company
        company_values = company.partner_id._l10n_es_edi_verifactu_get_values()

        batch_dict = {
          "Cabecera": {
              "ObligadoEmision": {
                  "NombreRazon": company_values['NombreRazon'],
                  "NIF": company_values['NIF'],
              },
              "RemisionVoluntaria": {
                  "Incidencia": 'S' if incident else 'N',
              },
          },
            "RegistroFactura": document_dict_list,
        }

        return batch_dict

    @api.model
    def _extract_record_key(self, document_dict):
        record_identifier = self._extract_record_identifiers(document_dict)
        return str((record_identifier['IDEmisorFactura'], record_identifier['NumSerieFactura']))

    def _cancel_after_sending(self, info):
        # This function should not raise since it may be called "in the middle" of the sending process
        for document in self:
            invoice = document.move_id
            if invoice.l10n_es_edi_verifactu_state == 'cancelled' and invoice.state != 'cancel':
                try:
                    invoice.button_cancel()
                except UserError as error:
                    _logger.error("Error while canceling journal entry %(name)s (id %(record_id)s) after Veri*Factu cancellation:\n%(error)s",
                                  record_id=invoice.id, name=invoice.name, error=error)
