# Copyright 2016-2017 Akretion (http://www.akretion.com)
# @author: Alexis de Lattre <alexis.delattre@akretion.com>
# Copyright 2019 Onestein (<https://www.onestein.eu>)
# Copyright 2023 Jacques-Etienne Baudoux (BCIM) <je@bcim.be>
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl).

import base64
import io
import logging

from lxml import etree

from odoo import fields, models
from odoo.tools import float_is_zero, float_round

logger = logging.getLogger(__name__)


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

    def _ubl_add_header(self, parent_node, ns, version="2.1"):
        self.ensure_one()
        ubl_version = etree.SubElement(parent_node, ns["cbc"] + "UBLVersionID")
        ubl_version.text = version
        doc_id = etree.SubElement(parent_node, ns["cbc"] + "ID")
        doc_id.text = self.name
        issue_date = etree.SubElement(parent_node, ns["cbc"] + "IssueDate")
        issue_date.text = self.invoice_date.strftime("%Y-%m-%d")
        if (
            self.invoice_date_due
            and version >= "2.1"
            and self.move_type == "out_invoice"
        ):
            due_date = etree.SubElement(parent_node, ns["cbc"] + "DueDate")
            due_date.text = fields.Date.to_string(self.invoice_date_due)
        if self.move_type == "out_invoice":
            type_code = etree.SubElement(parent_node, ns["cbc"] + "InvoiceTypeCode")
        elif self.move_type == "out_refund":
            type_code = etree.SubElement(parent_node, ns["cbc"] + "CreditNoteTypeCode")
        type_code.text = self._ubl_get_invoice_type_code()
        if self.narration:
            note = etree.SubElement(parent_node, ns["cbc"] + "Note")
            note.text = self.narration
        doc_currency = etree.SubElement(parent_node, ns["cbc"] + "DocumentCurrencyCode")
        doc_currency.text = self.currency_id.name
        # TODO: enable when below commit of 15.0 is back ported to 14.0
        # [IMP] account_edi(_*): Standalone UBL format + edi.format inheritance
        # https://github.com/odoo/odoo/commit/b58810a77bb4c432a6aef18413659b1ea7b25c71
        # or when migrating to 15.0
        # buyer_reference = etree.SubElement(parent_node, ns["cbc"] + "BuyerReference")
        # buyer_reference.text = self.ref or ""

    def _ubl_get_invoice_type_code(self):
        if self.move_type == "out_invoice":
            return "380"
        elif self.move_type == "out_refund":
            return "381"

    def _ubl_get_order_reference(self):
        """An identifier of a referenced purchase order, issued by the Buyer"""
        return self.ref or "/"

    def _ubl_get_salesorder_reference(self):
        """An identifier of a referenced sales order, issued by the Seller"""
        return self.invoice_origin

    def _ubl_add_order_reference(self, parent_node, ns, version="2.1"):
        self.ensure_one()
        buyer_ref = self._ubl_get_order_reference()
        seller_ref = self._ubl_get_salesorder_reference()
        if buyer_ref or seller_ref:
            node = etree.SubElement(parent_node, ns["cac"] + "OrderReference")
            if buyer_ref:
                node_id = etree.SubElement(node, ns["cbc"] + "ID")
                node_id.text = buyer_ref
            if seller_ref:
                node_salesorderid = etree.SubElement(node, ns["cbc"] + "SalesOrderID")
                node_salesorderid.text = seller_ref

    def _ubl_get_buyer_reference(self):
        return self.partner_id.name

    def _ubl_add_buyer_reference(self, parent_node, ns, version="2.1"):
        self.ensure_one()
        buyer_ref = self._ubl_get_buyer_reference()
        if buyer_ref:
            buyer_order_ref = etree.SubElement(
                parent_node, ns["cbc"] + "BuyerReference"
            )
            buyer_order_ref.text = buyer_ref

    def _ubl_get_contract_document_reference_dict(self):
        """Result: dict with key = Doc Type Code, value = ID"""
        self.ensure_one()
        return {}

    def _ubl_add_contract_document_reference(self, parent_node, ns, version="2.1"):
        self.ensure_one()
        cdr_dict = self._ubl_get_contract_document_reference_dict()
        for doc_type_code, doc_id in cdr_dict.items():
            cdr = etree.SubElement(parent_node, ns["cac"] + "ContractDocumentReference")
            cdr_id = etree.SubElement(cdr, ns["cbc"] + "ID")
            cdr_id.text = doc_id
            cdr_type_code = etree.SubElement(cdr, ns["cbc"] + "DocumentTypeCode")
            cdr_type_code.text = doc_type_code

    def _ubl_add_attachments(self, parent_node, ns, version="2.1"):
        self.ensure_one()
        if self.company_id.embed_pdf_in_ubl_xml_invoice and not self.env.context.get(
            "no_embedded_pdf"
        ):
            filename = "Invoice-" + self.name + ".pdf"
            docu_reference = etree.SubElement(
                parent_node, ns["cac"] + "AdditionalDocumentReference"
            )
            docu_reference_id = etree.SubElement(docu_reference, ns["cbc"] + "ID")
            docu_reference_id.text = filename
            attach_node = etree.SubElement(docu_reference, ns["cac"] + "Attachment")
            binary_node = etree.SubElement(
                attach_node,
                ns["cbc"] + "EmbeddedDocumentBinaryObject",
                mimeCode="application/pdf",
                filename=filename,
            )
            ctx = dict()
            ctx["no_embedded_ubl_xml"] = True
            ctx["force_report_rendering"] = True
            pdf_inv = (
                self.env["ir.actions.report"]
                .with_context(**ctx)
                ._render_qweb_pdf("account.account_invoices", [self.id])[0]
            )
            binary_node.text = base64.b64encode(pdf_inv)

    def _ubl_get_invoice_vat_exclusive_amount(self):
        amount = self.amount_untaxed
        # Add also non-VAT taxes that are not subjected to VAT
        for tline in self.line_ids:
            if not tline.tax_line_id:
                continue
            if tline.tax_line_id.unece_type_id.code != "VAT":
                sign = 1 if tline.is_refund else -1
                amount += sign * tline.balance
        return amount

    def _ubl_get_invoice_vat_amount(self):
        amount = self.amount_tax
        # Remove non-VAT taxes that are not subjected to VAT
        for tline in self.line_ids:
            if not tline.tax_line_id:
                continue
            if tline.tax_line_id.unece_type_id.code != "VAT":
                sign = 1 if tline.is_refund else -1
                amount -= sign * tline.balance
        return amount

    def _ubl_get_charge_total_amount(self):
        amount = 0.0
        for tline in self.line_ids:
            if not tline.tax_line_id:
                continue
            if tline.tax_line_id.unece_type_id.code != "VAT":
                if not tline.tax_line_id.include_base_amount:
                    # For non-VAT taxes, not subject to VAT, they are declared
                    # as AllowanceCharge
                    sign = 1 if tline.is_refund else -1
                    amount += sign * tline.balance
        return amount

    def _ubl_add_legal_monetary_total(self, parent_node, ns, version="2.1"):
        self.ensure_one()
        monetary_total = etree.SubElement(parent_node, ns["cac"] + "LegalMonetaryTotal")
        cur_name = self.currency_id.name
        prec = self.currency_id.decimal_places
        line_total = etree.SubElement(
            monetary_total, ns["cbc"] + "LineExtensionAmount", currencyID=cur_name
        )
        line_total.text = "%0.*f" % (prec, self.amount_untaxed)
        tax_excl_total = etree.SubElement(
            monetary_total, ns["cbc"] + "TaxExclusiveAmount", currencyID=cur_name
        )
        tax_excl_total.text = "%0.*f" % (
            prec,
            self._ubl_get_invoice_vat_exclusive_amount(),
        )
        tax_incl_total = etree.SubElement(
            monetary_total, ns["cbc"] + "TaxInclusiveAmount", currencyID=cur_name
        )
        tax_incl_total.text = "%0.*f" % (prec, self.amount_total)
        charge_total_amount = self._ubl_get_charge_total_amount()
        if charge_total_amount:
            el_charge_total_amount = etree.SubElement(
                monetary_total, ns["cbc"] + "ChargeTotalAmount", currencyID=cur_name
            )
            el_charge_total_amount.text = "%0.*f" % (prec, charge_total_amount)
        prepaid_amount = etree.SubElement(
            monetary_total, ns["cbc"] + "PrepaidAmount", currencyID=cur_name
        )
        prepaid_value = self.amount_total - self.amount_residual
        prepaid_amount.text = "%0.*f" % (prec, prepaid_value)
        payable_amount = etree.SubElement(
            monetary_total, ns["cbc"] + "PayableAmount", currencyID=cur_name
        )
        payable_amount.text = "%0.*f" % (prec, self.amount_residual)

    def _ubl_get_invoice_line_price_unit(self, iline):
        """Compute the base unit price without taxes"""
        price = iline.price_unit
        qty = 1.0
        if iline.tax_ids:
            tax_incl = any(t.price_include for t in iline.tax_ids)
            if tax_incl:
                # To prevent rounding issue, we must declare tax excluded price
                # for the total quantity
                qty = iline.quantity
            taxes = iline.tax_ids.compute_all(
                price,
                self.currency_id,
                qty,
                product=iline.product_id,
                partner=self.partner_id,
            )
            if taxes:
                price = taxes["total_excluded"]
        dpo = self.env["decimal.precision"]
        price_precision = dpo.precision_get("Product Price")
        return price, price_precision, qty

    def _ubl_get_invoice_line_discount(self, iline, base_price, base_qty):
        # Formula: Net amount = Invoiced quantity * (Item net price/item price
        #   base quantity) + Sum of invoice line charge amount - sum of invoice
        #   line allowance amount
        discount = iline.quantity / base_qty * base_price - iline.price_subtotal
        dpo = self.env["decimal.precision"]
        price_precision = dpo.precision_get("Product Price")
        discount = float_round(discount, precision_digits=price_precision)
        return discount, price_precision

    def _ubl_add_invoice_line_discount(
        self, xml_root, iline, base_price, base_qty, ns, version="2.1"
    ):
        discount, prec = self._ubl_get_invoice_line_discount(
            iline, base_price, base_qty
        )
        if float_is_zero(discount, precision_digits=prec):
            return
        charge_node = etree.SubElement(xml_root, ns["cac"] + "AllowanceCharge")
        charge_indicator_node = etree.SubElement(
            charge_node, ns["cbc"] + "ChargeIndicator"
        )
        charge_indicator_node.text = "false"
        charge_reason_code_node = etree.SubElement(
            charge_node, ns["cbc"] + "AllowanceChargeReasonCode"
        )
        charge_reason_code_node.text = "95"
        charge_reason_node = etree.SubElement(
            charge_node, ns["cbc"] + "AllowanceChargeReason"
        )
        charge_reason_node.text = "Discount"
        charge_amount_node = etree.SubElement(
            charge_node, ns["cbc"] + "Amount", currencyID=self.currency_id.name
        )
        charge_amount_node.text = "%0.*f" % (prec, discount)

    def _ubl_add_invoice_line(self, parent_node, iline, line_number, ns, version="2.1"):
        self.ensure_one()
        cur_name = self.currency_id.name
        if self.move_type == "out_invoice":
            line_root = etree.SubElement(parent_node, ns["cac"] + "InvoiceLine")
        elif self.move_type == "out_refund":
            line_root = etree.SubElement(parent_node, ns["cac"] + "CreditNoteLine")
        dpo = self.env["decimal.precision"]
        qty_precision = dpo.precision_get("Product Unit of Measure")
        account_precision = self.currency_id.decimal_places
        line_id = etree.SubElement(line_root, ns["cbc"] + "ID")
        line_id.text = str(line_number)
        uom_unece_code = False
        # product_uom_id is not a required field on account.move.line
        if self.move_type == "out_invoice":
            qty_element_name = "InvoicedQuantity"
        elif self.move_type == "out_refund":
            qty_element_name = "CreditedQuantity"
        if iline.product_uom_id.unece_code:
            uom_unece_code = iline.product_uom_id.unece_code
            quantity = etree.SubElement(
                line_root, ns["cbc"] + qty_element_name, unitCode=uom_unece_code
            )
        else:
            quantity = etree.SubElement(line_root, ns["cbc"] + qty_element_name)
        qty = iline.quantity
        quantity.text = "%0.*f" % (qty_precision, qty)
        base_price, price_precision, base_qty = self._ubl_get_invoice_line_price_unit(
            iline
        )
        line_amount = etree.SubElement(
            line_root, ns["cbc"] + "LineExtensionAmount", currencyID=cur_name
        )
        line_amount.text = "%0.*f" % (account_precision, iline.price_subtotal)
        self._ubl_add_invoice_line_discount(
            line_root, iline, base_price, base_qty, ns, version=version
        )
        self._ubl_add_item(
            iline.name,
            iline.product_id,
            line_root,
            ns,
            type_="sale",
            version=version,
        )
        price_node = etree.SubElement(line_root, ns["cac"] + "Price")
        price_amount = etree.SubElement(
            price_node, ns["cbc"] + "PriceAmount", currencyID=cur_name
        )
        price_amount.text = "%0.*f" % (price_precision, base_price)
        if uom_unece_code:
            base_qty_node = etree.SubElement(
                price_node, ns["cbc"] + "BaseQuantity", unitCode=uom_unece_code
            )
        else:
            base_qty_node = etree.SubElement(price_node, ns["cbc"] + "BaseQuantity")
        base_qty_node.text = "%0.*f" % (qty_precision, base_qty)

    def _ubl_add_tax_total(self, xml_root, ns, version="2.1"):
        self.ensure_one()
        cur_name = self.currency_id.name
        prec = self.currency_id.decimal_places

        tax_lines = {}
        for tline in self.line_ids:
            sign = 1 if tline.is_refund else -1
            if tline.tax_line_id:
                # There are as many tax line as there are repartition lines
                tax_lines.setdefault(
                    tline.tax_line_id,
                    {"base": 0.0, "amount": 0.0},
                )
                tax_lines[tline.tax_line_id]["base"] += tline.tax_base_amount
                tax_lines[tline.tax_line_id]["amount"] += sign * tline.balance
            elif tline.tax_ids:
                # In case there are no repartition lines
                for tax in tline.tax_ids:
                    if not tline.is_refund and tax.invoice_repartition_line_ids:
                        continue
                    if tline.is_refund and tax.refund_repartition_line_ids:
                        continue
                    tax_lines.setdefault(
                        tax,
                        {"base": 0.0, "amount": 0.0},
                    )
                    tax_lines[tax]["base"] += sign * tline.balance

        exempt = 0.0
        exempt_taxes = self.line_ids.tax_line_id.browse()
        for tax, amounts in tax_lines.items():
            if tax.unece_type_id.code != "VAT":
                if tax.include_base_amount:
                    continue
                exempt += amounts["amount"]
                exempt_taxes |= tax
                # For non-VAT taxes, not subject to VAT, declare as AllowanceCharge
                charge_node = etree.SubElement(xml_root, ns["cac"] + "AllowanceCharge")
                charge_indicator_node = etree.SubElement(
                    charge_node, ns["cbc"] + "ChargeIndicator"
                )
                charge_indicator_node.text = "true"
                charge_reason_code_node = etree.SubElement(
                    charge_node, ns["cbc"] + "AllowanceChargeReasonCode"
                )
                charge_reason_code_node.text = "ABK"
                charge_reason_node = etree.SubElement(
                    charge_node, ns["cbc"] + "AllowanceChargeReason"
                )
                charge_reason_node.text = "Miscellaneous"
                charge_amount_node = etree.SubElement(
                    charge_node, ns["cbc"] + "Amount", currencyID=cur_name
                )
                charge_amount_node.text = "%0.*f" % (prec, amounts["amount"])
                self._ubl_add_tax_category(tax, charge_node, ns, version=version)

        tax_total_node = etree.SubElement(xml_root, ns["cac"] + "TaxTotal")
        tax_amount_node = etree.SubElement(
            tax_total_node, ns["cbc"] + "TaxAmount", currencyID=cur_name
        )
        tax_amount_node.text = "%0.*f" % (prec, self._ubl_get_invoice_vat_amount())
        for tax, amounts in tax_lines.items():
            if tax.unece_type_id.code == "VAT":
                self._ubl_add_tax_subtotal(
                    amounts["base"],
                    amounts["amount"],
                    tax,
                    cur_name,
                    tax_total_node,
                    ns,
                    version=version,
                )

        if not float_is_zero(exempt, precision_digits=prec):
            self._ubl_add_tax_subtotal(
                exempt,
                0,
                exempt_taxes[0],
                cur_name,
                tax_total_node,
                ns,
                version=version,
            )
            if len(exempt_taxes) > 1:
                # xpath cac:TaxCategory/cbc:Name
                exempt_node = tax_total_node[-1]
                exempt_node = [
                    e for e in list(exempt_node) if e.tag == ns["cac"] + "TaxCategory"
                ][0]
                exempt_node = [
                    e for e in list(exempt_node) if e.tag == ns["cbc"] + "Name"
                ][0]
                exempt_node.text = " + ".join([e.name for e in exempt_taxes])

    def generate_invoice_ubl_xml_etree(self, version="2.1"):
        self.ensure_one()
        if self.move_type == "out_invoice":
            nsmap, ns = self._ubl_get_nsmap_namespace("Invoice-2", version=version)
            xml_root = etree.Element("Invoice", nsmap=nsmap)
        elif self.move_type == "out_refund":
            nsmap, ns = self._ubl_get_nsmap_namespace("CreditNote-2", version=version)
            xml_root = etree.Element("CreditNote", nsmap=nsmap)
        self._ubl_add_header(xml_root, ns, version=version)
        if version == "2.1":
            self._ubl_add_buyer_reference(xml_root, ns, version=version)
        self._ubl_add_order_reference(xml_root, ns, version=version)
        self._ubl_add_contract_document_reference(xml_root, ns, version=version)
        self._ubl_add_attachments(xml_root, ns, version=version)
        self._ubl_add_supplier_party(
            False,
            self.company_id,
            "AccountingSupplierParty",
            xml_root,
            ns,
            version=version,
        )
        self._ubl_add_customer_party(
            self.partner_id,
            False,
            "AccountingCustomerParty",
            xml_root,
            ns,
            version=version,
        )
        # the field 'partner_shipping_id' is defined in the 'sale' module
        if hasattr(self, "partner_shipping_id") and self.partner_shipping_id:
            self._ubl_add_delivery(self.partner_shipping_id, xml_root, ns)
        if self.move_type == "out_invoice":
            # Put paymentmeans block even when invoice is paid ?
            payment_identifier = self.get_payment_identifier()
            self._ubl_add_payment_means(
                self.partner_bank_id,
                self.payment_mode_id,
                self.invoice_date_due,
                xml_root,
                ns,
                payment_identifier=payment_identifier,
                version=version,
            )
            if self.invoice_payment_term_id:
                self._ubl_add_payment_terms(
                    self.invoice_payment_term_id, xml_root, ns, version=version
                )
        self._ubl_add_tax_total(xml_root, ns, version=version)
        self._ubl_add_legal_monetary_total(xml_root, ns, version=version)

        line_number = 0
        invoice_lines = self.invoice_line_ids.filtered(
            lambda line: line.display_type not in ("line_note", "line_section")
        )
        for iline in invoice_lines:
            line_number += 1
            self._ubl_add_invoice_line(
                xml_root, iline, line_number, ns, version=version
            )
        return xml_root

    def generate_ubl_xml_string(self, version="2.1"):
        self.ensure_one()
        assert self.state == "posted"
        assert self.move_type in ("out_invoice", "out_refund")
        logger.debug("Starting to generate UBL XML Invoice file")
        lang = self.get_ubl_lang()
        # The aim of injecting lang in context
        # is to have the content of the XML in the partner's lang
        # but the problem is that the error messages will also be in
        # that lang. But the error messages should almost never
        # happen except the first days of use, so it's probably
        # not worth the additional code to handle the 2 langs
        xml_root = self.with_context(lang=lang).generate_invoice_ubl_xml_etree(
            version=version
        )
        xml_string = etree.tostring(
            xml_root, pretty_print=True, encoding="UTF-8", xml_declaration=True
        )
        if self.move_type == "out_invoice":
            self._ubl_check_xml_schema(xml_string, "Invoice", version=version)
        elif self.move_type == "out_refund":
            self._ubl_check_xml_schema(xml_string, "CreditNote", version=version)
        logger.debug(
            "Invoice UBL XML file generated for account invoice ID %d " "(state %s)",
            self.id,
            self.state,
        )
        logger.debug(xml_string.decode("utf-8"))
        return xml_string

    def get_ubl_filename(self, version="2.1"):
        """This method is designed to be inherited"""
        if self.move_type == "out_invoice":
            return "UBL-Invoice-%s.xml" % version
        elif self.move_type == "out_refund":
            return "UBL-CreditNote-%s.xml" % version

    def get_ubl_version(self):
        return self.env.context.get("ubl_version") or "2.1"

    def get_ubl_lang(self):
        self.ensure_one()
        return self.partner_id.lang or "en_US"

    def _embed_ubl_xml_in_pdf(self, pdf_stream):
        self.ensure_one()
        if self.is_ubl_sale_invoice_posted():
            version = self.get_ubl_version()
            xml_filename = self.get_ubl_filename(version=version)
            xml_string = self.generate_ubl_xml_string(version=version)
            pdf_content = pdf_stream["stream"].getvalue()
            new_content = self.env["pdf.helper"].pdf_embed_xml(
                pdf_content,
                xml_filename,
                xml_string,
            )
            # Replace the current content.
            pdf_stream["stream"].close()
            pdf_stream["stream"] = io.BytesIO(new_content)

    def attach_ubl_xml_file_button(self):
        self.ensure_one()
        assert self.move_type in ("out_invoice", "out_refund")
        assert self.state == "posted"
        version = self.get_ubl_version()
        xml_string = self.generate_ubl_xml_string(version=version)
        filename = self.get_ubl_filename(version=version)
        attach = (
            self.env["ir.attachment"]
            .with_context(**{})
            .create(
                {
                    "name": filename,
                    "res_id": self.id,
                    "res_model": self._name,
                    "datas": base64.b64encode(xml_string),
                    # If default_type = 'out_invoice' in context, 'type'
                    # would take 'out_invoice' value by default !
                    "type": "binary",
                }
            )
        )
        action = self.env["ir.attachment"].action_get()
        action.update({"res_id": attach.id, "views": False, "view_mode": "form,tree"})
        return action

    def is_ubl_sale_invoice_posted(self):
        self.ensure_one()
        is_ubl = self.company_id.xml_format_in_pdf_invoice == "ubl"
        if is_ubl and self.is_sale_document() and self.state == "posted":
            return True
        return False
