# Copyright 2016-2022 Akretion France (http://www.akretion.com)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).

import logging

from lxml import etree

from odoo import _, api, fields, models
from odoo.exceptions import UserError
from odoo.tools import (
    float_compare,
    float_is_zero,
    float_round,
    html2plaintext,
    is_html_empty,
)
from odoo.tools.misc import format_date

logger = logging.getLogger(__name__)

try:
    from facturx import generate_from_file, xml_check_xsd
except ImportError:
    logger.debug("Cannot import facturx")


FACTURX_FILENAME = "factur-x.xml"
DIRECT_DEBIT_CODES = ("49", "59")
CREDIT_TRF_CODES = ("30", "31", "42")
PROFILES_EN_UP = ["en16931", "extended"]


class AccountMove(models.Model):
    _name = "account.move"
    _inherit = ["account.move", "base.facturx"]

    @api.model
    def _cii_add_address_block(self, partner, parent_node, ns):
        address = etree.SubElement(parent_node, ns["ram"] + "PostalTradeAddress")
        if ns["level"] != "minimum":
            if partner.zip:
                address_zip = etree.SubElement(address, ns["ram"] + "PostcodeCode")
                address_zip.text = partner.zip
            if partner.street:
                address_street = etree.SubElement(address, ns["ram"] + "LineOne")
                address_street.text = partner.street
                if partner.street2:
                    address_street2 = etree.SubElement(address, ns["ram"] + "LineTwo")
                    address_street2.text = partner.street2
                if hasattr(partner, "street3") and partner.street3:
                    address_street3 = etree.SubElement(address, ns["ram"] + "LineThree")
                    address_street3.text = partner.street3
            if partner.city:
                address_city = etree.SubElement(address, ns["ram"] + "CityName")
                address_city.text = partner.city
        if not partner.country_id:
            raise UserError(
                _(
                    "Country is not set on partner '%s'. In the Factur-X "
                    "standard, the country is required for buyer and seller."
                )
                % partner.display_name
            )
        address_country = etree.SubElement(address, ns["ram"] + "CountryID")
        address_country.text = partner.country_id.code
        if ns["level"] != "minimum" and partner.state_id:
            address_state = etree.SubElement(
                address, ns["ram"] + "CountrySubDivisionName"
            )
            address_state.text = partner.state_id.name

    def _cii_trade_contact_department_name(self, partner):
        return False

    @api.model
    def _cii_add_trade_contact_block(self, partner, parent_node, ns):
        trade_contact = etree.SubElement(parent_node, ns["ram"] + "DefinedTradeContact")
        contact_name = etree.SubElement(trade_contact, ns["ram"] + "PersonName")
        contact_name.text = partner.name
        department = self._cii_trade_contact_department_name(partner)
        if department:
            department_name = etree.SubElement(
                trade_contact, ns["ram"] + "DepartmentName"
            )
            department_name.text = department
        phone = partner.phone or partner.mobile
        if phone:
            phone_node = etree.SubElement(
                trade_contact, ns["ram"] + "TelephoneUniversalCommunication"
            )
            phone_number = etree.SubElement(phone_node, ns["ram"] + "CompleteNumber")
            phone_number.text = phone
        if partner.email:
            email_node = etree.SubElement(
                trade_contact, ns["ram"] + "EmailURIUniversalCommunication"
            )
            email_uriid = etree.SubElement(
                email_node, ns["ram"] + "URIID", schemeID="SMTP"
            )
            email_uriid.text = partner.email

    @api.model
    def _cii_add_date(
        self, node_name, date_datetime, parent_node, ns, date_ns_type="udt"
    ):
        date_node = etree.SubElement(parent_node, ns["ram"] + node_name)
        date_node_str = etree.SubElement(
            date_node, ns[date_ns_type] + "DateTimeString", format="102"
        )
        # 102 = format YYYYMMDD
        date_node_str.text = date_datetime.strftime("%Y%m%d")

    def _cii_add_document_context_block(self, root, ns):
        self.ensure_one()
        doc_ctx = etree.SubElement(root, ns["rsm"] + "ExchangedDocumentContext")
        ctx_param = etree.SubElement(
            doc_ctx, ns["ram"] + "GuidelineSpecifiedDocumentContextParameter"
        )
        ctx_param_id = etree.SubElement(ctx_param, ns["ram"] + "ID")
        if ns["level"] == "en16931":
            urn = "urn:cen.eu:en16931:2017"
        elif ns["level"] == "basic":
            urn = "urn:cen.eu:en16931:2017#compliant#urn:factur-x.eu:1p0:basic"
        elif ns["level"] == "extended":
            urn = "urn:cen.eu:en16931:2017#conformant#" "urn:factur-x.eu:1p0:extended"
        else:
            urn = "urn:factur-x.eu:1p0:%s" % ns["level"]
        ctx_param_id.text = urn

    def _cii_add_header_block(self, root, ns):
        self.ensure_one()
        header_doc = etree.SubElement(root, ns["rsm"] + "ExchangedDocument")
        header_doc_id = etree.SubElement(header_doc, ns["ram"] + "ID")
        if self.state == "posted":
            header_doc_id.text = self.name
        else:
            header_doc_id.text = self._fields["state"].convert_to_export(
                self.state, self
            )
        header_doc_typecode = etree.SubElement(header_doc, ns["ram"] + "TypeCode")
        if self.move_type == "out_invoice":
            header_doc_typecode.text = "380"
        elif self.move_type == "out_refund":
            header_doc_typecode.text = ns["refund_type"]
        # 2 options allowed in Factur-X :
        # a) invoice and refunds -> 380 ; negative amounts if refunds
        # b) invoice -> 380 refunds -> 381, with positive amounts
        # In ZUGFeRD samples, they use option a)
        # For Chorus, they impose option b)
        # Until August 2017, I was using option a), now I use option b)
        # Starting from November 2017, it's a config option !
        invoice_date_dt = self.invoice_date or fields.Date.context_today(self)
        self._cii_add_date("IssueDateTime", invoice_date_dt, header_doc, ns)
        if not is_html_empty(self.narration) and ns["level"] != "minimum":
            note = etree.SubElement(header_doc, ns["ram"] + "IncludedNote")
            content_note = etree.SubElement(note, ns["ram"] + "Content")
            content_note.text = html2plaintext(self.narration)

    @api.model
    def _cii_get_party_identification(self, commercial_partner):
        """This method is designed to be inherited in localisation modules
        Should return a dict with key=SchemeName, value=Identifier"""
        return {}

    @api.model
    def _cii_add_party_identification(self, commercial_partner, parent_node, ns):
        id_dict = self._cii_get_party_identification(commercial_partner)
        if id_dict:
            party_identification = etree.SubElement(
                parent_node, ns["ram"] + "SpecifiedLegalOrganization"
            )
            for scheme_name, party_id_text in id_dict.items():
                party_identification_id = etree.SubElement(
                    party_identification, ns["ram"] + "ID", schemeID=scheme_name
                )
                party_identification_id.text = party_id_text
        return

    def _cii_trade_agreement_buyer_ref(self, partner):
        return False

    def _cii_add_trade_agreement_block(self, trade_transaction, ns):
        self.ensure_one()
        company = self.company_id
        trade_agreement = etree.SubElement(
            trade_transaction, ns["ram"] + "ApplicableHeaderTradeAgreement"
        )
        buyer_ref = self._cii_trade_agreement_buyer_ref(self.partner_id)
        if buyer_ref:
            buyer_reference = etree.SubElement(
                trade_agreement, ns["ram"] + "BuyerReference"
            )
            buyer_reference.text = buyer_ref
        seller = etree.SubElement(trade_agreement, ns["ram"] + "SellerTradeParty")
        seller_name = etree.SubElement(seller, ns["ram"] + "Name")
        seller_name.text = company.name
        self._cii_add_party_identification(company.partner_id, seller, ns)
        if ns["level"] in PROFILES_EN_UP:
            self._cii_add_trade_contact_block(
                self.invoice_user_id.partner_id or company.partner_id, seller, ns
            )
        self._cii_add_address_block(company.partner_id, seller, ns)
        if company.vat:
            seller_tax_reg = etree.SubElement(
                seller, ns["ram"] + "SpecifiedTaxRegistration"
            )
            seller_tax_reg_id = etree.SubElement(
                seller_tax_reg, ns["ram"] + "ID", schemeID="VA"
            )
            seller_tax_reg_id.text = company.vat
        buyer = etree.SubElement(trade_agreement, ns["ram"] + "BuyerTradeParty")
        if ns["level"] != "minimum" and self.commercial_partner_id.ref:
            buyer_id = etree.SubElement(buyer, ns["ram"] + "ID")
            buyer_id.text = self.commercial_partner_id.ref
        buyer_name = etree.SubElement(buyer, ns["ram"] + "Name")
        buyer_name.text = self.commercial_partner_id.name
        self._cii_add_party_identification(self.commercial_partner_id, buyer, ns)
        if (
            ns["level"] in PROFILES_EN_UP
            and self.commercial_partner_id != self.partner_id
            and self.partner_id.name
        ):
            self._cii_add_trade_contact_block(self.partner_id, buyer, ns)
        self._cii_add_address_block(self.partner_id, buyer, ns)
        if self.commercial_partner_id.vat:
            buyer_tax_reg = etree.SubElement(
                buyer, ns["ram"] + "SpecifiedTaxRegistration"
            )
            buyer_tax_reg_id = etree.SubElement(
                buyer_tax_reg, ns["ram"] + "ID", schemeID="VA"
            )
            buyer_tax_reg_id.text = self.commercial_partner_id.vat
        if ns["level"] == "extended" and self.invoice_incoterm_id:
            delivery_terms = etree.SubElement(
                trade_agreement, ns["ram"] + "ApplicableTradeDeliveryTerms"
            )
            delivery_code = etree.SubElement(
                delivery_terms, ns["ram"] + "DeliveryTypeCode"
            )
            delivery_code.text = self.invoice_incoterm_id.code
        self._cii_add_buyer_order_reference(trade_agreement, ns)
        self._cii_add_contract_reference(trade_agreement, ns)

    def _cii_add_buyer_order_reference(self, trade_agreement, ns):
        self.ensure_one()
        if self.ref:
            buyer_order_ref = etree.SubElement(
                trade_agreement, ns["ram"] + "BuyerOrderReferencedDocument"
            )
            buyer_order_id = etree.SubElement(
                buyer_order_ref, ns["ram"] + "IssuerAssignedID"
            )
            buyer_order_id.text = self.ref

    def _cii_add_contract_reference(self, trade_agreement, ns):
        self.ensure_one()
        contract_code = self._get_contract_code()
        if ns["level"] != "minimum" and contract_code:
            contract_ref = etree.SubElement(
                trade_agreement, ns["ram"] + "ContractReferencedDocument"
            )
            contract_id = etree.SubElement(contract_ref, ns["ram"] + "IssuerAssignedID")
            contract_id.text = contract_code

    def _get_contract_code(self):
        """This method is designed to be inherited
        There are so many different ways to handle a contract in Odoo!
        So it's difficult to have a common datamodel for it"""
        return False

    def _cii_add_trade_delivery_block(self, trade_transaction, ns):
        self.ensure_one()
        trade_agreement = etree.SubElement(
            trade_transaction, ns["ram"] + "ApplicableHeaderTradeDelivery"
        )
        # partner_shipping_id is provided by the sale module
        if (
            ns["level"] in PROFILES_EN_UP
            and hasattr(self, "partner_shipping_id")
            and self.partner_shipping_id
        ):
            shipto_trade_party = etree.SubElement(
                trade_agreement, ns["ram"] + "ShipToTradeParty"
            )
            self._cii_add_address_block(
                self.partner_shipping_id, shipto_trade_party, ns
            )
        return trade_agreement

    def _cii_add_trade_settlement_payment_means_block(self, trade_settlement, ns):
        payment_means = etree.SubElement(
            trade_settlement, ns["ram"] + "SpecifiedTradeSettlementPaymentMeans"
        )
        payment_means_code = etree.SubElement(payment_means, ns["ram"] + "TypeCode")
        if ns["level"] in PROFILES_EN_UP:
            payment_means_info = etree.SubElement(
                payment_means, ns["ram"] + "Information"
            )
        if self.payment_mode_id:
            payment_means_code.text = self.payment_mode_id.payment_method_id.unece_code
            if ns["level"] in PROFILES_EN_UP:
                payment_means_info.text = (
                    self.payment_mode_id.note or self.payment_mode_id.name
                )
        else:
            payment_means_code.text = "30"  # use 30 and not 31,
            # for wire transfer, according to Factur-X CIUS
            if ns["level"] in PROFILES_EN_UP:
                payment_means_info.text = _("Wire transfer")
            logger.warning(
                "Missing payment mode on invoice ID %d. "
                "Using 30 (wire transfer) as UNECE code as fallback "
                "for payment mean",
                self.id,
            )
        if payment_means_code.text in CREDIT_TRF_CODES:
            partner_bank = self.partner_bank_id
            if (
                not partner_bank
                and self.payment_mode_id
                and self.payment_mode_id.bank_account_link == "fixed"
                and self.payment_mode_id.fixed_journal_id
            ):
                partner_bank = self.payment_mode_id.fixed_journal_id.bank_account_id
            if partner_bank and partner_bank.acc_type == "iban":
                payment_means_bank_account = etree.SubElement(
                    payment_means, ns["ram"] + "PayeePartyCreditorFinancialAccount"
                )
                iban = etree.SubElement(
                    payment_means_bank_account, ns["ram"] + "IBANID"
                )
                iban.text = partner_bank.sanitized_acc_number
                if ns["level"] in PROFILES_EN_UP and partner_bank.bank_bic:
                    payment_means_bank = etree.SubElement(
                        payment_means,
                        ns["ram"] + "PayeeSpecifiedCreditorFinancialInstitution",
                    )
                    payment_means_bic = etree.SubElement(
                        payment_means_bank, ns["ram"] + "BICID"
                    )
                    payment_means_bic.text = partner_bank.bank_bic
        # Field mandate_id provided by the OCA module account_banking_mandate
        elif (
            payment_means_code.text in DIRECT_DEBIT_CODES
            and hasattr(self, "mandate_id")
            and self.mandate_id.partner_bank_id
            and self.mandate_id.partner_bank_id.acc_type == "iban"
            and self.mandate_id.partner_bank_id.sanitized_acc_number
        ):
            debtor_acc = etree.SubElement(
                payment_means, ns["ram"] + "PayerPartyDebtorFinancialAccount"
            )
            debtor_acc_iban = etree.SubElement(debtor_acc, ns["ram"] + "IBANID")
            debtor_acc_iban.text = self.mandate_id.partner_bank_id.sanitized_acc_number

    def _cii_trade_payment_terms_block(self, trade_settlement, ns):
        trade_payment_term = etree.SubElement(
            trade_settlement, ns["ram"] + "SpecifiedTradePaymentTerms"
        )
        if ns["level"] in PROFILES_EN_UP:
            trade_payment_term_desc = etree.SubElement(
                trade_payment_term, ns["ram"] + "Description"
            )
            # The 'Description' field of SpecifiedTradePaymentTerms
            # is a required field, so we must always give a value
            if self.invoice_payment_term_id:
                trade_payment_term_desc.text = self.invoice_payment_term_id.name
            else:
                trade_payment_term_desc.text = _("No specific payment term selected")

        if self.invoice_date_due:
            self._cii_add_date(
                "DueDateDateTime", self.invoice_date_due, trade_payment_term, ns
            )

        # Direct debit Mandate
        if (
            self.payment_mode_id.payment_method_id.unece_code in DIRECT_DEBIT_CODES
            and hasattr(self, "mandate_id")
            and self.mandate_id.unique_mandate_reference
        ):
            mandate = etree.SubElement(
                trade_payment_term, ns["ram"] + "DirectDebitMandateID"
            )
            mandate.text = self.mandate_id.unique_mandate_reference

    def _cii_check_tax_required_info(self, tax_dict):
        if not tax_dict:
            # Hack when there is NO tax at all
            # ApplicableTradeTax is a required field, both on line and total
            tax_dict.update(
                {
                    "unece_type_code": "VAT",
                    "unece_categ_code": "E",
                    "amount": 0,
                    "amount_type": "percent",
                    "display_name": "Empty virtual tax",
                }
            )
        if not tax_dict["unece_type_code"]:
            raise UserError(
                _("Missing UNECE Tax Type on tax '%s'") % tax_dict["display_name"]
            )
        if not tax_dict["unece_categ_code"]:
            raise UserError(
                _("Missing UNECE Tax Category on tax '%s'") % tax_dict["display_name"]
            )

    def _cii_line_applicable_trade_tax_block(
        self, tax_recordset, parent_node, ns, allowance=False
    ):
        tax = {}
        if tax_recordset:
            tax = ns["tax_speeddict"][tax_recordset.id]
        self._cii_check_tax_required_info(tax)
        if allowance:
            node_name = "CategoryTradeTax"
        else:
            node_name = "ApplicableTradeTax"
        trade_tax = etree.SubElement(parent_node, ns["ram"] + node_name)
        trade_tax_typecode = etree.SubElement(trade_tax, ns["ram"] + "TypeCode")
        trade_tax_typecode.text = tax["unece_type_code"]
        trade_tax_categcode = etree.SubElement(trade_tax, ns["ram"] + "CategoryCode")
        trade_tax_categcode.text = tax["unece_categ_code"]
        # No 'DueDateTypeCode' on lines
        if tax.get("amount_type") == "percent":
            trade_tax_percent = etree.SubElement(
                trade_tax, ns["ram"] + "RateApplicablePercent"
            )
            trade_tax_percent.text = "%0.*f" % (2, tax["amount"])

    def _cii_total_applicable_trade_tax_block(
        self, tax_recordset, tax_amount, base_amount, parent_node, ns
    ):
        if ns["level"] == "minimum":
            return
        tax = {}
        if tax_recordset:
            tax = ns["tax_speeddict"][tax_recordset.id]
        self._cii_check_tax_required_info(tax)
        trade_tax = etree.SubElement(parent_node, ns["ram"] + "ApplicableTradeTax")
        amount = etree.SubElement(trade_tax, ns["ram"] + "CalculatedAmount")
        amount.text = "%0.*f" % (ns["cur_prec"], tax_amount * ns["sign"])
        tax_type = etree.SubElement(trade_tax, ns["ram"] + "TypeCode")
        tax_type.text = tax["unece_type_code"]

        if (
            tax["unece_categ_code"] != "S"
            and float_is_zero(tax_amount, precision_digits=ns["cur_prec"])
            and self.fiscal_position_id
            and ns["fp_speeddict"][self.fiscal_position_id.id]["note"]
        ):
            exemption_reason = etree.SubElement(
                trade_tax, ns["ram"] + "ExemptionReason"
            )
            exemption_reason.text = ns["fp_speeddict"][self.fiscal_position_id.id][
                "note"
            ]

        base = etree.SubElement(trade_tax, ns["ram"] + "BasisAmount")
        base.text = "%0.*f" % (ns["cur_prec"], base_amount * ns["sign"])
        tax_categ_code = etree.SubElement(trade_tax, ns["ram"] + "CategoryCode")
        tax_categ_code.text = tax["unece_categ_code"]
        due_date_type_code = self._get_unece_due_date_type_code() or tax.get(
            "unece_due_date_code"
        )
        if due_date_type_code:
            trade_tax_due_date = etree.SubElement(
                trade_tax, ns["ram"] + "DueDateTypeCode"
            )
            trade_tax_due_date.text = due_date_type_code
            # Field tax_exigibility is not required, so no error if missing
        if tax.get("amount_type") == "percent":
            percent = etree.SubElement(trade_tax, ns["ram"] + "RateApplicablePercent")
            percent.text = "%0.*f" % (2, tax["amount"])

    def _cii_add_trade_settlement_block(self, trade_transaction, allowance_ilines, ns):
        self.ensure_one()
        trade_settlement = etree.SubElement(
            trade_transaction, ns["ram"] + "ApplicableHeaderTradeSettlement"
        )
        # ICS, provided by the OCA module account_banking_sepa_direct_debit
        if (
            ns["level"] != "minimum"
            and self.payment_mode_id.payment_method_id.unece_code in DIRECT_DEBIT_CODES
            and hasattr(self.company_id, "sepa_creditor_identifier")
            and self.company_id.sepa_creditor_identifier
        ):
            ics = etree.SubElement(trade_settlement, ns["ram"] + "CreditorReferenceID")
            ics.text = self.company_id.sepa_creditor_identifier

        if ns["level"] != "minimum":
            payment_ref = etree.SubElement(
                trade_settlement, ns["ram"] + "PaymentReference"
            )
            payment_ref.text = self.name or self.state
        invoice_currency = etree.SubElement(
            trade_settlement, ns["ram"] + "InvoiceCurrencyCode"
        )
        invoice_currency.text = ns["currency"]
        if (
            self.payment_mode_id
            and not self.payment_mode_id.payment_method_id.unece_code
        ):
            raise UserError(
                _("Missing UNECE code on payment method '%s'")
                % self.payment_mode_id.payment_method_id.display_name
            )
        if ns["level"] != "minimum" and not (
            self.move_type == "out_refund"
            and self.payment_mode_id
            and self.payment_mode_id.payment_method_id.unece_code in CREDIT_TRF_CODES
        ):
            self._cii_add_trade_settlement_payment_means_block(trade_settlement, ns)

        at_least_one_tax = False
        # move_type == 'out_invoice': tline.amount_currency < 0
        # move_type == 'out_refund': tline.amount_currency > 0
        tax_amount_sign = self.move_type == "out_invoice" and -1 or 1
        for tline in self.line_ids.filtered(lambda x: x.tax_line_id):
            tax_base_amount = tline.tax_base_amount
            tax_amount = tline.amount_currency * tax_amount_sign
            self._cii_total_applicable_trade_tax_block(
                tline.tax_line_id,
                tax_amount,
                tax_base_amount,
                trade_settlement,
                ns,
            )
            at_least_one_tax = True
        tax_zero_amount = {}  # key = tax recordset, value = base
        for line in self.line_ids:
            for tax in line.tax_ids.filtered(
                lambda t: float_is_zero(t.amount, precision_digits=ns["cur_prec"])
            ):
                tax_zero_amount.setdefault(tax, 0.0)
                tax_zero_amount[tax] += line.price_subtotal
        for tax, tax_base_amount in tax_zero_amount.items():
            self._cii_total_applicable_trade_tax_block(
                tax, 0, tax_base_amount, trade_settlement, ns
            )
            at_least_one_tax = True

        if not at_least_one_tax:
            self._cii_total_applicable_trade_tax_block(None, 0, 0, trade_settlement, ns)

        # Global Allowance lines = invoice lines with negative price
        for allowance_iline in allowance_ilines:
            self._cii_allowance_line(allowance_iline, trade_settlement, ns)

        if ns["level"] != "minimum":
            self._cii_trade_payment_terms_block(trade_settlement, ns)

        self._cii_monetary_summation_block(trade_settlement, ns)
        # When you create a full refund from an invoice, Odoo will
        # set the field reversed_entry_id
        if self.reversed_entry_id and self.reversed_entry_id.state == "posted":
            inv_ref_doc = etree.SubElement(
                trade_settlement, ns["ram"] + "InvoiceReferencedDocument"
            )
            inv_ref_doc_num = etree.SubElement(
                inv_ref_doc, ns["ram"] + "IssuerAssignedID"
            )
            inv_ref_doc_num.text = self.reversed_entry_id.name
            self._cii_add_date(
                "FormattedIssueDateTime",
                self.reversed_entry_id.invoice_date,
                inv_ref_doc,
                ns,
                date_ns_type="qdt",
            )

    def _cii_allowance_line(self, iline, trade_settlement, ns):
        allowance_line = etree.SubElement(
            trade_settlement, ns["ram"] + "SpecifiedTradeAllowanceCharge"
        )
        charge_indic = etree.SubElement(allowance_line, ns["ram"] + "ChargeIndicator")
        indicator = etree.SubElement(charge_indic, ns["udt"] + "Indicator")
        indicator.text = "false"
        if not float_is_zero(iline.discount, ns["disc_prec"]):
            calculation_percent = etree.SubElement(
                allowance_line, ns["ram"] + "CalculationPercent"
            )
            calculation_percent.text = "%0.*f" % (ns["disc_prec"], iline.discount)
            basis_amount = etree.SubElement(allowance_line, ns["ram"] + "BasisAmount")
            basis_amount.text = "%0.*f" % (
                ns["price_prec"],
                iline.price_unit * iline.quantity * -1,
            )

        actual_amount = iline.price_subtotal * -1
        ns["allowance_total_amount"] += actual_amount
        actual_amount_node = etree.SubElement(
            allowance_line, ns["ram"] + "ActualAmount"
        )
        actual_amount_node.text = "%0.*f" % (ns["cur_prec"], actual_amount)

        reason = etree.SubElement(allowance_line, ns["ram"] + "Reason")
        reason.text = (
            iline.name
            or (iline.product_id and iline.product_id.display_name)
            or _("Discount")
        )
        self._cii_invoice_line_taxes(iline, allowance_line, ns, allowance=True)

    def _cii_monetary_summation_block(self, trade_settlement, ns):
        sums = etree.SubElement(
            trade_settlement,
            ns["ram"] + "SpecifiedTradeSettlementHeaderMonetarySummation",
        )
        if ns["level"] != "minimum":
            line_total = etree.SubElement(sums, ns["ram"] + "LineTotalAmount")
            line_total.text = "%0.*f" % (
                ns["cur_prec"],
                (self.amount_untaxed - ns["allowance_total_amount"]) * ns["sign"],
            )
            # We don't want to generate charge total, because we don't have the
            # notion of charge in Odoo. We only support allowance:
            # an allowance is an invoice line with a negative price
            # Warning: the allowance amount is positive (but has negative meaning)
            if not self.currency_id.is_zero(ns["allowance_total_amount"]):
                allowance_total = etree.SubElement(
                    sums, ns["ram"] + "AllowanceTotalAmount"
                )
                allowance_total.text = "%0.*f" % (
                    ns["cur_prec"],
                    ns["allowance_total_amount"],
                )
        tax_basis_total_amt = etree.SubElement(sums, ns["ram"] + "TaxBasisTotalAmount")
        tax_basis_total_amt.text = "%0.*f" % (
            ns["cur_prec"],
            self.amount_untaxed * ns["sign"],
        )
        tax_total = etree.SubElement(
            sums, ns["ram"] + "TaxTotalAmount", currencyID=ns["currency"]
        )
        tax_total.text = "%0.*f" % (ns["cur_prec"], self.amount_tax * ns["sign"])
        total = etree.SubElement(sums, ns["ram"] + "GrandTotalAmount")
        total.text = "%0.*f" % (ns["cur_prec"], self.amount_total * ns["sign"])
        if ns["level"] != "minimum":
            prepaid = etree.SubElement(sums, ns["ram"] + "TotalPrepaidAmount")
            prepaid.text = "%0.*f" % (
                ns["cur_prec"],
                (self.amount_total - self.amount_residual) * ns["sign"],
            )
        residual = etree.SubElement(sums, ns["ram"] + "DuePayableAmount")
        residual.text = "%0.*f" % (ns["cur_prec"], self.amount_residual * ns["sign"])

    def _set_iline_product_information(self, iline, trade_product, ns):
        if iline.product_id:
            if iline.product_id.barcode:
                barcode = etree.SubElement(
                    trade_product, ns["ram"] + "GlobalID", schemeID="0160"
                )
                # 0160 = GS1 Global Trade Item Number (GTIN, EAN)
                barcode.text = iline.product_id.barcode
            if ns["level"] in PROFILES_EN_UP and iline.product_id.default_code:
                product_code = etree.SubElement(
                    trade_product, ns["ram"] + "SellerAssignedID"
                )
                product_code.text = iline.product_id.default_code
        product_name = etree.SubElement(trade_product, ns["ram"] + "Name")
        product_name.text = iline.name or _("No invoice line label")
        if (
            ns["level"] in PROFILES_EN_UP
            and iline.product_id
            and iline.product_id.description_sale
        ):
            product_desc = etree.SubElement(trade_product, ns["ram"] + "Description")
            product_desc.text = iline.product_id.description_sale

    def _set_iline_product_attributes(self, iline, trade_product, ns):
        if iline.product_id and ns["level"] in PROFILES_EN_UP:
            product = iline.product_id
            for attrib_val in product.product_template_attribute_value_ids:
                attrib_value_rec = attrib_val.product_attribute_value_id
                attrib_value = attrib_value_rec.name
                attribute_name = attrib_value_rec.attribute_id.name
                product_charact = etree.SubElement(
                    trade_product, ns["ram"] + "ApplicableProductCharacteristic"
                )
                product_charact_desc = etree.SubElement(
                    product_charact, ns["ram"] + "Description"
                )
                product_charact_desc.text = attribute_name
                product_charact_value = etree.SubElement(
                    product_charact, ns["ram"] + "Value"
                )
                product_charact_value.text = attrib_value
            if hasattr(product, "hs_code_id") and product.type in ("product", "consu"):
                hs_code = product.get_hs_code_recursively()
                if hs_code:
                    product_classification = etree.SubElement(
                        trade_product, ns["ram"] + "DesignatedProductClassification"
                    )
                    product_classification_code = etree.SubElement(
                        product_classification, ns["ram"] + "ClassCode", listID="HS"
                    )
                    product_classification_code.text = hs_code.local_code
            # origin_country_id and hs_code_id are provided
            # by the OCA module product_harmonized_system
            if (
                hasattr(product, "origin_country_id")
                and product.type in ("product", "consu")
                and product.origin_country_id
            ):
                origin_trade_country = etree.SubElement(
                    trade_product, ns["ram"] + "OriginTradeCountry"
                )
                origin_trade_country_code = etree.SubElement(
                    origin_trade_country, ns["ram"] + "ID"
                )
                origin_trade_country_code.text = product.origin_country_id.code

    def _cii_add_invoice_line_block(self, trade_transaction, iline, line_number, ns):
        self.ensure_one()
        line_item = etree.SubElement(
            trade_transaction, ns["ram"] + "IncludedSupplyChainTradeLineItem"
        )
        line_doc = etree.SubElement(
            line_item, ns["ram"] + "AssociatedDocumentLineDocument"
        )
        etree.SubElement(line_doc, ns["ram"] + "LineID").text = str(line_number)

        trade_product = etree.SubElement(line_item, ns["ram"] + "SpecifiedTradeProduct")
        self._set_iline_product_information(iline, trade_product, ns)
        self._set_iline_product_attributes(iline, trade_product, ns)
        line_trade_agreement = etree.SubElement(
            line_item, ns["ram"] + "SpecifiedLineTradeAgreement"
        )
        # convert gross price_unit to tax_excluded value
        taxres = iline.tax_ids.compute_all(iline.price_unit)
        gross_price_val = float_round(
            taxres["total_excluded"], precision_digits=ns["price_prec"]
        )
        # Use oline.price_subtotal/qty to compute net unit price to be sure
        # to get a *tax_excluded* net unit price
        if float_is_zero(iline.quantity, precision_digits=ns["qty_prec"]):
            net_price_val = 0.0
        else:
            net_price_val = float_round(
                iline.price_subtotal / float(iline.quantity),
                precision_digits=ns["price_prec"],
            )
        if ns["level"] in PROFILES_EN_UP:
            gross_price = etree.SubElement(
                line_trade_agreement, ns["ram"] + "GrossPriceProductTradePrice"
            )
            gross_price_amount = etree.SubElement(
                gross_price, ns["ram"] + "ChargeAmount"
            )
            gross_price_amount.text = "%0.*f" % (ns["price_prec"], gross_price_val)
            fc_discount = float_compare(
                iline.discount, 0.0, precision_digits=ns["disc_prec"]
            )
            if fc_discount in [-1, 1]:
                trade_allowance = etree.SubElement(
                    gross_price, ns["ram"] + "AppliedTradeAllowanceCharge"
                )
                charge_indic = etree.SubElement(
                    trade_allowance, ns["ram"] + "ChargeIndicator"
                )
                indicator = etree.SubElement(charge_indic, ns["udt"] + "Indicator")
                if fc_discount == 1:
                    indicator.text = "false"
                    ac_sign = 1
                else:
                    indicator.text = "true"
                    ac_sign = -1
                calculation_percent = etree.SubElement(
                    trade_allowance, ns["ram"] + "CalculationPercent"
                )
                calculation_percent.text = "%0.*f" % (
                    ns["disc_prec"],
                    iline.discount * ac_sign,
                )
                basis_amount = etree.SubElement(
                    trade_allowance, ns["ram"] + "BasisAmount"
                )
                basis_amount.text = "%0.*f" % (
                    ns["price_prec"],
                    iline.price_unit * iline.quantity,
                )
                actual_amount = etree.SubElement(
                    trade_allowance, ns["ram"] + "ActualAmount"
                )
                actual_amount_val = float_round(
                    ac_sign
                    * ((iline.price_unit * iline.quantity) - iline.price_subtotal),
                    precision_digits=ns["price_prec"],
                )
                actual_amount.text = "%0.*f" % (
                    ns["price_prec"],
                    actual_amount_val * ns["sign"],
                )

        net_price = etree.SubElement(
            line_trade_agreement, ns["ram"] + "NetPriceProductTradePrice"
        )
        net_price_amount = etree.SubElement(net_price, ns["ram"] + "ChargeAmount")
        net_price_amount.text = "%0.*f" % (ns["price_prec"], net_price_val)
        line_trade_delivery = etree.SubElement(
            line_item, ns["ram"] + "SpecifiedLineTradeDelivery"
        )
        if iline.product_uom_id and iline.product_uom_id.unece_code:
            unitCode = iline.product_uom_id.unece_code
        else:
            unitCode = "C62"
            if not iline.product_uom_id:
                logger.warning(
                    "No unit of measure on invoice line '%s', "
                    "using C62 (piece) as fallback",
                    iline.name,
                )
            else:
                logger.warning(
                    "Missing UNECE Code on unit of measure %s, "
                    "using C62 (piece) as fallback",
                    iline.product_uom_id.name,
                )
        billed_qty = etree.SubElement(
            line_trade_delivery, ns["ram"] + "BilledQuantity", unitCode=unitCode
        )
        billed_qty.text = "%0.*f" % (ns["qty_prec"], iline.quantity * ns["sign"])
        line_trade_settlement = etree.SubElement(
            line_item, ns["ram"] + "SpecifiedLineTradeSettlement"
        )
        self._cii_invoice_line_taxes(iline, line_trade_settlement, ns)
        # Fields start_date and end_date are provided by the OCA
        # module account_invoice_start_end_dates
        if (
            ns["level"] in PROFILES_EN_UP
            and hasattr(iline, "start_date")
            and hasattr(iline, "end_date")
            and iline.start_date
            and iline.end_date
        ):
            bill_period = etree.SubElement(
                line_trade_settlement, ns["ram"] + "BillingSpecifiedPeriod"
            )
            self._cii_add_date("StartDateTime", iline.start_date, bill_period, ns)
            self._cii_add_date("EndDateTime", iline.end_date, bill_period, ns)

        subtotal = etree.SubElement(
            line_trade_settlement,
            ns["ram"] + "SpecifiedTradeSettlementLineMonetarySummation",
        )
        subtotal_amount = etree.SubElement(subtotal, ns["ram"] + "LineTotalAmount")
        subtotal_amount.text = "%0.*f" % (
            ns["cur_prec"],
            iline.price_subtotal * ns["sign"],
        )

    def _cii_invoice_line_taxes(self, iline, parent_node, ns, allowance=False):
        if iline.tax_ids:
            for tax in iline.tax_ids:
                self._cii_line_applicable_trade_tax_block(
                    tax, parent_node, ns, allowance=allowance
                )
        else:
            self._cii_line_applicable_trade_tax_block(
                None, parent_node, ns, allowance=allowance
            )

    def generate_facturx_xml(self):
        self.ensure_one()
        assert self.move_type in (
            "out_invoice",
            "out_refund",
        ), "only works for customer invoice and refunds"
        dpo = self.env["decimal.precision"]
        level = self.company_id.facturx_level or "en16931"
        refund_type = self.company_id.facturx_refund_type or "381"
        sign = 1
        if self.move_type == "out_refund" and refund_type == "380":
            sign = -1
        lang = self.partner_id.lang or self.env.user.lang or "en_US"
        tax_speeddict = self.company_id._get_tax_unece_speeddict()
        fp_speeddict = self.company_id._get_fiscal_position_speeddict(lang=lang)
        self = self.with_context(lang=lang)
        nsmap = {
            "xsi": "http://www.w3.org/2001/XMLSchema-instance",
            "rsm": "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100",
            "ram": "urn:un:unece:uncefact:data:standard:"
            "ReusableAggregateBusinessInformationEntity:100",
            "qdt": "urn:un:unece:uncefact:data:standard:QualifiedDataType:100",
            "udt": "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100",
        }
        ns = {
            "rsm": "{urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100}",
            "ram": "{urn:un:unece:uncefact:data:standard:"
            "ReusableAggregateBusinessInformationEntity:100}",
            "qdt": "{urn:un:unece:uncefact:data:standard:QualifiedDataType:100}",
            "udt": "{urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100}",
            "level": level,
            "refund_type": refund_type,
            "sign": sign,
            "currency": self.currency_id.name,
            "cur_prec": self.currency_id.decimal_places,
            "price_prec": dpo.precision_get("Product Price"),
            "disc_prec": dpo.precision_get("Discount"),
            "qty_prec": dpo.precision_get("Product Unit of Measure"),
            "lang": lang,
            "tax_speeddict": tax_speeddict,
            "fp_speeddict": fp_speeddict,
            "allowance_total_amount": 0.0,
        }

        root = etree.Element(ns["rsm"] + "CrossIndustryInvoice", nsmap=nsmap)
        self._cii_add_document_context_block(root, ns)
        self._cii_add_header_block(root, ns)

        trade_transaction = etree.SubElement(
            root, ns["rsm"] + "SupplyChainTradeTransaction"
        )

        allowance_ilines = self.env["account.move.line"]
        if ns["level"] in ("extended", "en16931", "basic"):
            line_number = 0
            for iline in self.invoice_line_ids.filtered(
                lambda x: x.display_type == "product"
            ):
                price_compare = float_compare(
                    iline.price_unit, 0, precision_digits=ns["price_prec"]
                )
                if price_compare >= 0:
                    line_number += 1
                    self._cii_add_invoice_line_block(
                        trade_transaction, iline, line_number, ns
                    )
                else:
                    # global allowance
                    allowance_ilines |= iline

        self._cii_add_trade_agreement_block(trade_transaction, ns)
        self._cii_add_trade_delivery_block(trade_transaction, ns)
        self._cii_add_trade_settlement_block(trade_transaction, allowance_ilines, ns)

        xml_byte = etree.tostring(
            root, pretty_print=True, encoding="UTF-8", xml_declaration=True
        )
        logger.debug("Factur-X XML file generated for invoice ID %d", self.id)
        logger.debug(xml_byte.decode("utf-8"))
        try:
            xml_check_xsd(xml_byte, flavor="factur-x", level=ns["level"])
        except Exception as e:
            raise UserError(str(e)) from e
        return (xml_byte, level)

    def _prepare_pdf_metadata(self):
        self.ensure_one()
        inv_type = self.move_type == "out_refund" and _("Refund") or _("Invoice")
        if self.invoice_date:
            invoice_date = format_date(
                self.env, self.invoice_date, lang_code=self.partner_id.lang
            )
        else:
            invoice_date = _("(no date)")
        if self.state == "posted":
            invoice_number = self.name
        else:
            invoice_number = self._fields["state"].convert_to_export(self.state, self)
        format_vals = {
            "company_name": self.company_id.name,
            "invoice_type": inv_type,
            "invoice_number": invoice_number,
            "invoice_date": invoice_date,
        }
        pdf_metadata = {
            "author": format_vals["company_name"],
            "keywords": ", ".join([inv_type, _("Factur-X")]),
            "title": _(
                "{company_name}: {invoice_type} {invoice_number} dated {invoice_date}"
            ).format(**format_vals),
            "subject": _(
                "Factur-X {invoice_type} {invoice_number} dated {invoice_date} "
                "issued by {company_name}"
            ).format(**format_vals),
        }
        return pdf_metadata

    def _prepare_facturx_attachments(self):
        # This method is designed to be inherited in other modules
        self.ensure_one()
        return {}

    def regular_pdf_invoice_to_facturx_invoice(self, pdf_bytesio):
        self.ensure_one()
        assert pdf_bytesio, "Missing pdf_bytesio"
        if self.move_type in ("out_invoice", "out_refund"):
            facturx_xml_bytes, level = self.generate_facturx_xml()
            pdf_metadata = self._prepare_pdf_metadata()
            lang = (
                self.partner_id.lang and self.partner_id.lang.replace("_", "-") or None
            )
            # Generate a new PDF with XML file as attachment
            attachments = self._prepare_facturx_attachments()
            generate_from_file(
                pdf_bytesio,
                facturx_xml_bytes,
                flavor="factur-x",
                level=level,
                check_xsd=False,
                pdf_metadata=pdf_metadata,
                lang=lang,
                attachments=attachments,
            )
            logger.info("%s file added to PDF invoice", FACTURX_FILENAME)
