"""
Fusion Accounting - Cross-Industry Invoice (CII) / Factur-X Generator & Parser

Generates UN/CEFACT Cross-Industry Invoice (CII) compliant XML documents
and supports embedding the XML inside a PDF/A-3 container to produce
Factur-X / ZUGFeRD hybrid invoices.

References
----------
* UN/CEFACT XML Schemas (D16B)
  https://unece.org/trade/uncefact/xml-schemas
* Factur-X / ZUGFeRD specification
  https://fnfe-mpe.org/factur-x/
* EN 16931-1:2017 – European e-Invoicing semantic data model

Namespace URIs used below are taken directly from the published
UN/CEFACT schemas.

Original implementation by Nexa Systems Inc.
"""

import io
import logging
from datetime import date
from lxml import etree

from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.tools import float_round

_log = logging.getLogger(__name__)

# ======================================================================
# XML Namespace constants (UN/CEFACT CII D16B)
# ======================================================================
NS_RSM = (
    "urn:un:unece:uncefact:data:standard:"
    "CrossIndustryInvoice:100"
)
NS_RAM = (
    "urn:un:unece:uncefact:data:standard:"
    "ReusableAggregateBusinessInformationEntity:100"
)
NS_QDT = (
    "urn:un:unece:uncefact:data:standard:"
    "QualifiedDataType:100"
)
NS_UDT = (
    "urn:un:unece:uncefact:data:standard:"
    "UnqualifiedDataType:100"
)

NSMAP_CII = {
    "rsm": NS_RSM,
    "ram": NS_RAM,
    "qdt": NS_QDT,
    "udt": NS_UDT,
}

# Factur-X profile URNs
FACTURX_PROFILES = {
    "minimum": (
        "urn:factur-x.eu:1p0:minimum"
    ),
    "basicwl": (
        "urn:factur-x.eu:1p0:basicwl"
    ),
    "basic": (
        "urn:factur-x.eu:1p0:basic"
    ),
    "en16931": (
        "urn:cen.eu:en16931:2017#compliant#"
        "urn:factur-x.eu:1p0:en16931"
    ),
    "extended": (
        "urn:factur-x.eu:1p0:extended"
    ),
}

# CII type code mapping (UNTDID 1001)
CII_TYPE_CODE_MAP = {
    "out_invoice": "380",   # Commercial Invoice
    "out_refund": "381",    # Credit Note
    "in_invoice": "380",
    "in_refund": "381",
}


class FusionCIIGenerator(models.AbstractModel):
    """
    Generates and parses UN/CEFACT Cross-Industry Invoice documents and
    optionally embeds the XML within a PDF/A-3 container for Factur-X
    compliance.

    Implemented as an Odoo abstract model for ORM registry access.
    """

    _name = "fusion.cii.generator"
    _description = "Fusion CII / Factur-X Generator"

    # ==================================================================
    # Public API
    # ==================================================================
    def generate_cii_invoice(self, move, profile="en16931"):
        """Build a CII XML document for a single ``account.move``.

        Args:
            move: An ``account.move`` singleton.
            profile (str): Factur-X conformance profile.  One of
                ``'minimum'``, ``'basic'``, ``'en16931'`` (default),
                ``'extended'``.

        Returns:
            bytes: UTF-8 encoded CII XML.
        """
        move.ensure_one()
        self._validate_move(move)

        root = etree.Element(
            f"{{{NS_RSM}}}CrossIndustryInvoice", nsmap=NSMAP_CII
        )

        self._add_exchange_context(root, profile)
        header = self._add_header_trade(root, move)
        agreement = self._add_agreement_trade(root, move)
        delivery = self._add_delivery_trade(root, move)
        settlement = self._add_settlement_trade(root, move)
        self._add_line_items(root, move)

        return etree.tostring(
            root,
            xml_declaration=True,
            encoding="UTF-8",
            pretty_print=True,
        )

    def parse_cii_invoice(self, xml_bytes):
        """Parse a CII XML document into an invoice values dictionary.

        Args:
            xml_bytes (bytes): Raw CII XML content.

        Returns:
            dict: Invoice values suitable for ``account.move.create()``.
        """
        root = etree.fromstring(xml_bytes)
        ns = {
            "rsm": NS_RSM,
            "ram": NS_RAM,
            "udt": NS_UDT,
        }

        # Header
        header_path = (
            "rsm:SupplyChainTradeTransaction/"
            "ram:ApplicableHeaderTradeSettlement"
        )
        doc_path = (
            "rsm:ExchangedDocument"
        )

        ref = self._xpath_text(root, f"{doc_path}/ram:ID", ns)
        type_code = self._xpath_text(root, f"{doc_path}/ram:TypeCode", ns)
        issue_date = self._xpath_text(
            root,
            f"{doc_path}/ram:IssueDateTime/udt:DateTimeString",
            ns,
        )
        currency = self._xpath_text(
            root, f"{header_path}/ram:InvoiceCurrencyCode", ns
        )
        due_date = self._xpath_text(
            root,
            f"{header_path}/ram:SpecifiedTradePaymentTerms/"
            "ram:DueDateDateTime/udt:DateTimeString",
            ns,
        )

        # Parties
        agreement_path = (
            "rsm:SupplyChainTradeTransaction/"
            "ram:ApplicableHeaderTradeAgreement"
        )
        supplier_name = self._xpath_text(
            root,
            f"{agreement_path}/ram:SellerTradeParty/ram:Name",
            ns,
        )
        supplier_vat = self._xpath_text(
            root,
            f"{agreement_path}/ram:SellerTradeParty/"
            "ram:SpecifiedTaxRegistration/ram:ID",
            ns,
        )
        customer_name = self._xpath_text(
            root,
            f"{agreement_path}/ram:BuyerTradeParty/ram:Name",
            ns,
        )
        customer_vat = self._xpath_text(
            root,
            f"{agreement_path}/ram:BuyerTradeParty/"
            "ram:SpecifiedTaxRegistration/ram:ID",
            ns,
        )

        # Lines
        line_path = (
            "rsm:SupplyChainTradeTransaction/"
            "ram:IncludedSupplyChainTradeLineItem"
        )
        line_nodes = root.findall(line_path, ns)
        lines = []
        for ln in line_nodes:
            name = self._xpath_text(
                ln,
                "ram:SpecifiedTradeProduct/ram:Name",
                ns,
            ) or ""
            qty = float(
                self._xpath_text(
                    ln,
                    "ram:SpecifiedLineTradeDelivery/"
                    "ram:BilledQuantity",
                    ns,
                ) or "1"
            )
            price = float(
                self._xpath_text(
                    ln,
                    "ram:SpecifiedLineTradeAgreement/"
                    "ram:NetPriceProductTradePrice/"
                    "ram:ChargeAmount",
                    ns,
                ) or "0"
            )
            lines.append({
                "name": name,
                "quantity": qty,
                "price_unit": price,
            })

        is_credit_note = type_code == "381"
        move_type = "out_refund" if is_credit_note else "out_invoice"

        # Normalise dates from CII format (YYYYMMDD) to ISO
        if issue_date and len(issue_date) == 8:
            issue_date = f"{issue_date[:4]}-{issue_date[4:6]}-{issue_date[6:]}"
        if due_date and len(due_date) == 8:
            due_date = f"{due_date[:4]}-{due_date[4:6]}-{due_date[6:]}"

        return {
            "move_type": move_type,
            "ref": ref,
            "invoice_date": issue_date,
            "invoice_date_due": due_date,
            "currency_id": currency,
            "supplier_name": supplier_name,
            "supplier_vat": supplier_vat,
            "customer_name": customer_name,
            "customer_vat": customer_vat,
            "invoice_line_ids": lines,
        }

    def embed_in_pdf(self, pdf_bytes, xml_bytes, profile="en16931"):
        """Embed CII XML into a PDF to produce a Factur-X / ZUGFeRD file.

        This creates a PDF/A-3 compliant document with the XML attached
        as an Associated File (AF) according to the Factur-X specification.

        Args:
            pdf_bytes (bytes): The original invoice PDF content.
            xml_bytes (bytes): The CII XML to embed.
            profile (str): Factur-X profile name for metadata.

        Returns:
            bytes: The resulting PDF/A-3 with embedded XML.

        Note:
            This method requires the ``pypdf`` library.  If it is not
            installed the original PDF is returned unchanged with a
            warning logged.
        """
        try:
            from pypdf import PdfReader, PdfWriter
        except ImportError:
            _log.warning(
                "pypdf is not installed; returning PDF without embedded XML. "
                "Install pypdf to enable Factur-X PDF/A-3 embedding."
            )
            return pdf_bytes

        reader = PdfReader(io.BytesIO(pdf_bytes))
        writer = PdfWriter()

        # Copy all pages from the source PDF
        for page in reader.pages:
            writer.add_page(page)

        # Copy metadata
        if reader.metadata:
            writer.add_metadata(reader.metadata)

        # Attach the XML as an embedded file
        writer.add_attachment(
            fname="factur-x.xml",
            data=xml_bytes,
        )

        # Update document info with Factur-X conformance level
        profile_label = profile.upper() if profile != "en16931" else "EN 16931"
        writer.add_metadata({
            "/Subject": f"Factur-X {profile_label}",
        })

        output = io.BytesIO()
        writer.write(output)
        return output.getvalue()

    # ==================================================================
    # Internal – XML construction helpers
    # ==================================================================
    def _validate_move(self, move):
        """Ensure the move has the minimum data needed for CII export."""
        if not move.partner_id:
            raise UserError(
                _("Cannot generate CII: invoice '%s' has no partner.",
                  move.name or _("Draft"))
            )

    def _add_exchange_context(self, root, profile):
        """Add ``ExchangedDocumentContext`` with the Factur-X profile."""
        ram = NS_RAM
        rsm = NS_RSM

        ctx = self._sub(root, f"{{{rsm}}}ExchangedDocumentContext")
        guide = self._sub(ctx, f"{{{ram}}}GuidelineSpecifiedDocumentContextParameter")
        profile_urn = FACTURX_PROFILES.get(profile, FACTURX_PROFILES["en16931"])
        self._sub(guide, f"{{{ram}}}ID", profile_urn)

    def _add_header_trade(self, root, move):
        """Add ``ExchangedDocument`` with ID, type code, and issue date."""
        rsm = NS_RSM
        ram = NS_RAM
        udt = NS_UDT

        doc = self._sub(root, f"{{{rsm}}}ExchangedDocument")
        self._sub(doc, f"{{{ram}}}ID", move.name or "DRAFT")

        type_code = CII_TYPE_CODE_MAP.get(move.move_type, "380")
        self._sub(doc, f"{{{ram}}}TypeCode", type_code)

        issue_dt = self._sub(doc, f"{{{ram}}}IssueDateTime")
        issue_date = move.invoice_date or fields.Date.context_today(move)
        date_str_el = self._sub(
            issue_dt, f"{{{udt}}}DateTimeString",
            issue_date.strftime("%Y%m%d"),
        )
        date_str_el.set("format", "102")

        if move.narration:
            import re
            plain = re.sub(r"<[^>]+>", "", move.narration)
            note = self._sub(doc, f"{{{ram}}}IncludedNote")
            self._sub(note, f"{{{ram}}}Content", plain)

        return doc

    def _add_agreement_trade(self, root, move):
        """Add ``ApplicableHeaderTradeAgreement`` with seller/buyer parties."""
        rsm = NS_RSM
        ram = NS_RAM

        txn = root.find(f"{{{rsm}}}SupplyChainTradeTransaction")
        if txn is None:
            txn = self._sub(root, f"{{{rsm}}}SupplyChainTradeTransaction")

        agreement = self._sub(txn, f"{{{ram}}}ApplicableHeaderTradeAgreement")

        # Seller
        seller = self._sub(agreement, f"{{{ram}}}SellerTradeParty")
        self._add_trade_party(seller, move.company_id.partner_id, move.company_id)

        # Buyer
        buyer = self._sub(agreement, f"{{{ram}}}BuyerTradeParty")
        self._add_trade_party(buyer, move.partner_id)

        return agreement

    def _add_delivery_trade(self, root, move):
        """Add ``ApplicableHeaderTradeDelivery``."""
        rsm = NS_RSM
        ram = NS_RAM
        udt = NS_UDT

        txn = root.find(f"{{{rsm}}}SupplyChainTradeTransaction")
        delivery = self._sub(txn, f"{{{ram}}}ApplicableHeaderTradeDelivery")

        # Actual delivery date (use invoice date as fallback)
        event = self._sub(delivery, f"{{{ram}}}ActualDeliverySupplyChainEvent")
        occ = self._sub(event, f"{{{ram}}}OccurrenceDateTime")
        del_date = move.invoice_date or fields.Date.context_today(move)
        date_el = self._sub(
            occ, f"{{{udt}}}DateTimeString", del_date.strftime("%Y%m%d")
        )
        date_el.set("format", "102")

        return delivery

    def _add_settlement_trade(self, root, move):
        """Add ``ApplicableHeaderTradeSettlement`` with tax, totals, and terms."""
        rsm = NS_RSM
        ram = NS_RAM
        udt = NS_UDT

        txn = root.find(f"{{{rsm}}}SupplyChainTradeTransaction")
        settlement = self._sub(
            txn, f"{{{ram}}}ApplicableHeaderTradeSettlement"
        )

        currency = move.currency_id.name or "USD"
        self._sub(settlement, f"{{{ram}}}InvoiceCurrencyCode", currency)

        # Payment means
        pm = self._sub(
            settlement, f"{{{ram}}}SpecifiedTradeSettlementPaymentMeans"
        )
        self._sub(pm, f"{{{ram}}}TypeCode", "30")  # Credit transfer

        if move.partner_bank_id:
            account = self._sub(
                pm, f"{{{ram}}}PayeePartyCreditorFinancialAccount"
            )
            self._sub(
                account, f"{{{ram}}}IBANID",
                move.partner_bank_id.acc_number or "",
            )

        # Tax breakdown
        self._add_cii_tax(settlement, move, currency)

        # Payment terms
        if move.invoice_date_due:
            terms = self._sub(
                settlement, f"{{{ram}}}SpecifiedTradePaymentTerms"
            )
            due_dt = self._sub(terms, f"{{{ram}}}DueDateDateTime")
            due_el = self._sub(
                due_dt, f"{{{udt}}}DateTimeString",
                move.invoice_date_due.strftime("%Y%m%d"),
            )
            due_el.set("format", "102")

        # Monetary summation
        summation = self._sub(
            settlement,
            f"{{{ram}}}SpecifiedTradeSettlementHeaderMonetarySummation",
        )
        self._monetary_sub(
            summation, f"{{{ram}}}LineTotalAmount",
            move.amount_untaxed, currency,
        )
        self._monetary_sub(
            summation, f"{{{ram}}}TaxBasisTotalAmount",
            move.amount_untaxed, currency,
        )
        self._monetary_sub(
            summation, f"{{{ram}}}TaxTotalAmount",
            move.amount_tax, currency,
        )
        self._monetary_sub(
            summation, f"{{{ram}}}GrandTotalAmount",
            move.amount_total, currency,
        )
        self._monetary_sub(
            summation, f"{{{ram}}}DuePayableAmount",
            move.amount_residual, currency,
        )

        return settlement

    def _add_cii_tax(self, settlement, move, currency):
        """Add per-tax ``ApplicableTradeTax`` elements."""
        ram = NS_RAM

        # Group tax lines
        tax_groups = {}
        for line in move.line_ids.filtered(
            lambda l: l.tax_line_id and l.tax_line_id.amount_type != "group"
        ):
            tax = line.tax_line_id
            key = (tax.id, tax.name, tax.amount)
            if key not in tax_groups:
                tax_groups[key] = {
                    "tax": tax,
                    "tax_amount": 0.0,
                    "base_amount": 0.0,
                }
            tax_groups[key]["tax_amount"] += abs(line.balance)

        for inv_line in move.invoice_line_ids:
            for tax in inv_line.tax_ids:
                key = (tax.id, tax.name, tax.amount)
                if key in tax_groups:
                    tax_groups[key]["base_amount"] += abs(inv_line.balance)

        for _key, data in tax_groups.items():
            tax_el = self._sub(settlement, f"{{{ram}}}ApplicableTradeTax")
            self._monetary_sub(
                tax_el, f"{{{ram}}}CalculatedAmount",
                data["tax_amount"], currency,
            )
            self._sub(tax_el, f"{{{ram}}}TypeCode", "VAT")
            self._monetary_sub(
                tax_el, f"{{{ram}}}BasisAmount",
                data["base_amount"], currency,
            )
            self._sub(
                tax_el, f"{{{ram}}}CategoryCode",
                self._tax_category(data["tax"]),
            )
            self._sub(
                tax_el, f"{{{ram}}}RateApplicablePercent",
                self._fmt(abs(data["tax"].amount)),
            )

    def _add_line_items(self, root, move):
        """Append ``IncludedSupplyChainTradeLineItem`` elements."""
        rsm = NS_RSM
        ram = NS_RAM

        txn = root.find(f"{{{rsm}}}SupplyChainTradeTransaction")
        currency = move.currency_id.name or "USD"

        for idx, line in enumerate(move.invoice_line_ids, start=1):
            if line.display_type in ("line_section", "line_note"):
                continue

            item_el = self._sub(
                txn, f"{{{ram}}}IncludedSupplyChainTradeLineItem"
            )

            # Line document
            line_doc = self._sub(
                item_el,
                f"{{{ram}}}AssociatedDocumentLineDocument",
            )
            self._sub(line_doc, f"{{{ram}}}LineID", str(idx))

            # Product
            product = self._sub(
                item_el, f"{{{ram}}}SpecifiedTradeProduct"
            )
            if line.product_id and line.product_id.default_code:
                self._sub(
                    product, f"{{{ram}}}SellerAssignedID",
                    line.product_id.default_code,
                )
            self._sub(
                product, f"{{{ram}}}Name",
                line.name or line.product_id.name or _("(Unnamed)"),
            )

            # Line trade agreement (price)
            line_agreement = self._sub(
                item_el, f"{{{ram}}}SpecifiedLineTradeAgreement"
            )
            net_price = self._sub(
                line_agreement,
                f"{{{ram}}}NetPriceProductTradePrice",
            )
            self._monetary_sub(
                net_price, f"{{{ram}}}ChargeAmount",
                line.price_unit, currency,
            )

            # Line trade delivery (quantity)
            line_delivery = self._sub(
                item_el, f"{{{ram}}}SpecifiedLineTradeDelivery"
            )
            qty_el = self._sub(
                line_delivery, f"{{{ram}}}BilledQuantity",
                self._fmt(line.quantity),
            )
            qty_el.set("unitCode", self._uom_unece(line))

            # Line trade settlement (tax, total)
            line_settlement = self._sub(
                item_el, f"{{{ram}}}SpecifiedLineTradeSettlement"
            )

            for tax in line.tax_ids:
                trade_tax = self._sub(
                    line_settlement, f"{{{ram}}}ApplicableTradeTax"
                )
                self._sub(trade_tax, f"{{{ram}}}TypeCode", "VAT")
                self._sub(
                    trade_tax, f"{{{ram}}}CategoryCode",
                    self._tax_category(tax),
                )
                self._sub(
                    trade_tax, f"{{{ram}}}RateApplicablePercent",
                    self._fmt(abs(tax.amount)),
                )

            line_summation = self._sub(
                line_settlement,
                f"{{{ram}}}SpecifiedTradeSettlementLineMonetarySummation",
            )
            self._monetary_sub(
                line_summation, f"{{{ram}}}LineTotalAmount",
                line.price_subtotal, currency,
            )

    # ------------------------------------------------------------------
    # Trade party helper
    # ------------------------------------------------------------------
    def _add_trade_party(self, parent, partner, company=None):
        """Populate a trade party element with name, address, and tax ID."""
        ram = NS_RAM

        self._sub(
            parent, f"{{{ram}}}Name",
            company.name if company else partner.name,
        )

        # Postal address
        address = self._sub(parent, f"{{{ram}}}PostalTradeAddress")
        if partner.zip:
            self._sub(address, f"{{{ram}}}PostcodeCode", partner.zip)
        if partner.street:
            self._sub(address, f"{{{ram}}}LineOne", partner.street)
        if partner.street2:
            self._sub(address, f"{{{ram}}}LineTwo", partner.street2)
        if partner.city:
            self._sub(address, f"{{{ram}}}CityName", partner.city)
        if partner.country_id:
            self._sub(
                address, f"{{{ram}}}CountryID", partner.country_id.code
            )

        # Tax registration
        vat = company.vat if company else partner.vat
        if vat:
            tax_reg = self._sub(
                parent, f"{{{ram}}}SpecifiedTaxRegistration"
            )
            tax_id = self._sub(tax_reg, f"{{{ram}}}ID", vat)
            tax_id.set("schemeID", "VA")

    # ==================================================================
    # Utility helpers
    # ==================================================================
    @staticmethod
    def _sub(parent, tag, text=None):
        """Create a sub-element, optionally setting its text content."""
        el = etree.SubElement(parent, tag)
        if text is not None:
            el.text = str(text)
        return el

    @staticmethod
    def _monetary_sub(parent, tag, value, currency):
        """Create a monetary amount sub-element with ``currencyID``."""
        formatted = f"{float_round(float(value or 0), precision_digits=2):.2f}"
        el = etree.SubElement(parent, tag)
        el.text = formatted
        el.set("currencyID", currency)
        return el

    @staticmethod
    def _fmt(value, precision=2):
        """Format a numeric value with the given decimal precision."""
        return f"{float_round(float(value or 0), precision_digits=precision):.{precision}f}"

    @staticmethod
    def _tax_category(tax):
        """Map an Odoo tax to a CII/UBL tax category code (UNCL 5305)."""
        amount = abs(tax.amount)
        if amount == 0:
            return "Z"
        tax_name_lower = (tax.name or "").lower()
        if "exempt" in tax_name_lower:
            return "E"
        if "reverse" in tax_name_lower:
            return "AE"
        return "S"

    @staticmethod
    def _uom_unece(line):
        """Return the UN/ECE Rec 20 unit code for the invoice line."""
        uom = line.product_uom_id
        if not uom:
            return "C62"
        unece_code = getattr(uom, "unece_code", None)
        if unece_code:
            return unece_code
        name = (uom.name or "").lower()
        mapping = {
            "unit": "C62", "units": "C62", "piece": "C62",
            "pieces": "C62", "pce": "C62",
            "kg": "KGM", "kilogram": "KGM",
            "g": "GRM", "gram": "GRM",
            "l": "LTR", "liter": "LTR", "litre": "LTR",
            "m": "MTR", "meter": "MTR", "metre": "MTR",
            "hour": "HUR", "hours": "HUR",
            "day": "DAY", "days": "DAY",
        }
        return mapping.get(name, "C62")

    @staticmethod
    def _xpath_text(node, xpath, ns):
        """Return the text of the first matching element, or ``None``."""
        found = node.find(xpath, ns)
        return found.text if found is not None else None
