"""
Fusion Accounting - SAF-T Export

Generates Standard Audit File for Tax (SAF-T) XML documents that
conform to the OECD SAF-T Schema v2.0. The export wizard collects a
date range from the user, queries general-ledger accounts, partners,
tax tables and journal entries for the selected period, and builds a
compliant XML tree.

The default namespace is the Portuguese SAF-T variant
(urn:OECD:StandardAuditFile-Tax:PT_1.04_01) but can be overridden per
company through the ``saft_namespace`` field so that the same generator
serves multiple country-specific schemas.

Reference: OECD Standard Audit File – Tax 2.0 (2010).

Original implementation by Nexa Systems Inc.
"""

import base64
import io
import logging
import xml.etree.ElementTree as ET

from datetime import date

from odoo import api, fields, models, _
from odoo.exceptions import UserError, ValidationError

_log = logging.getLogger(__name__)

# Default namespace when not overridden on the company
_DEFAULT_SAFT_NS = "urn:OECD:StandardAuditFile-Tax:PT_1.04_01"
_SAFT_VERSION = "2.00_01"


class FusionSAFTExport(models.TransientModel):
    """
    Wizard that produces an SAF-T XML file for a given date range.

    Workflow
    --------
    1. The user opens the wizard from *Accounting ▸ Reports ▸ Fiscal
       Compliance ▸ SAF-T Export*.
    2. A date range and optional filters are selected.
    3. Clicking **Generate** triggers ``action_generate_saft`` which
       delegates to ``generate_saft_xml()``.
    4. The resulting XML is stored as an ``ir.attachment`` and a download
       action is returned.

    The XML tree follows the OECD SAF-T structure:

    - ``<Header>``
    - ``<MasterFiles>`` (accounts, customers, suppliers, tax table)
    - ``<GeneralLedgerEntries>`` (journals → transactions → lines)
    """

    _name = "fusion.saft.export"
    _description = "SAF-T Export Wizard"

    # ------------------------------------------------------------------
    # Fields
    # ------------------------------------------------------------------
    date_from = fields.Date(
        string="Start Date",
        required=True,
        default=lambda self: date(date.today().year, 1, 1),
        help="First day of the reporting period (inclusive).",
    )
    date_to = fields.Date(
        string="End Date",
        required=True,
        default=fields.Date.context_today,
        help="Last day of the reporting period (inclusive).",
    )
    company_id = fields.Many2one(
        comodel_name="res.company",
        string="Company",
        required=True,
        default=lambda self: self.env.company,
    )
    state = fields.Selection(
        selection=[
            ("draft", "Draft"),
            ("done", "Generated"),
            ("error", "Error"),
        ],
        string="Status",
        default="draft",
        readonly=True,
    )
    attachment_id = fields.Many2one(
        comodel_name="ir.attachment",
        string="Generated File",
        readonly=True,
        ondelete="set null",
    )
    saft_namespace = fields.Char(
        string="SAF-T Namespace",
        default=_DEFAULT_SAFT_NS,
        help=(
            "XML namespace written into the root element. Change this "
            "to match your country's SAF-T variant."
        ),
    )
    error_message = fields.Text(
        string="Error Details",
        readonly=True,
    )

    # ------------------------------------------------------------------
    # Validation
    # ------------------------------------------------------------------
    @api.constrains("date_from", "date_to")
    def _check_date_range(self):
        for record in self:
            if record.date_from and record.date_to and record.date_to < record.date_from:
                raise ValidationError(
                    _("The end date must not precede the start date.")
                )

    # ------------------------------------------------------------------
    # Public action
    # ------------------------------------------------------------------
    def action_generate_saft(self):
        """Entry point called by the wizard button. Generates the XML and
        stores the result as a downloadable attachment."""
        self.ensure_one()
        try:
            xml_bytes = self.generate_saft_xml()
            filename = (
                f"SAF-T_{self.company_id.name}_{self.date_from}_{self.date_to}.xml"
            )
            attachment = self.env["ir.attachment"].create({
                "name": filename,
                "type": "binary",
                "datas": base64.b64encode(xml_bytes),
                "res_model": self._name,
                "res_id": self.id,
                "mimetype": "application/xml",
            })
            self.write({
                "state": "done",
                "attachment_id": attachment.id,
                "error_message": False,
            })
            return {
                "type": "ir.actions.act_url",
                "url": f"/web/content/{attachment.id}?download=true",
                "target": "new",
            }
        except Exception as exc:
            _log.exception("SAF-T generation failed for company %s", self.company_id.name)
            self.write({
                "state": "error",
                "error_message": str(exc),
            })
            raise UserError(
                _("SAF-T generation failed:\n%(error)s", error=exc)
            ) from exc

    # ------------------------------------------------------------------
    # XML generation
    # ------------------------------------------------------------------
    def generate_saft_xml(self):
        """Build the full SAF-T XML document and return it as ``bytes``.

        The tree mirrors the OECD SAF-T v2.0 specification:

        .. code-block:: xml

            <AuditFile xmlns="...">
              <Header>...</Header>
              <MasterFiles>
                <GeneralLedgerAccounts>...</GeneralLedgerAccounts>
                <Customers>...</Customers>
                <Suppliers>...</Suppliers>
                <TaxTable>...</TaxTable>
              </MasterFiles>
              <GeneralLedgerEntries>
                <Journal>
                  <Transaction>
                    <Line>...</Line>
                  </Transaction>
                </Journal>
              </GeneralLedgerEntries>
            </AuditFile>
        """
        self.ensure_one()
        ns = self.saft_namespace or _DEFAULT_SAFT_NS

        root = ET.Element("AuditFile", xmlns=ns)

        self._build_header(root)
        master_files = ET.SubElement(root, "MasterFiles")
        self._build_general_ledger_accounts(master_files)
        self._build_customers(master_files)
        self._build_suppliers(master_files)
        self._build_tax_table(master_files)
        self._build_general_ledger_entries(root)

        # Serialise to bytes with XML declaration
        buf = io.BytesIO()
        tree = ET.ElementTree(root)
        ET.indent(tree, space="  ")
        tree.write(buf, encoding="UTF-8", xml_declaration=True)
        return buf.getvalue()

    # ------------------------------------------------------------------
    # Header
    # ------------------------------------------------------------------
    def _build_header(self, root):
        """Populate the ``<Header>`` element with company metadata."""
        header = ET.SubElement(root, "Header")
        company = self.company_id

        _add_text(header, "AuditFileVersion", _SAFT_VERSION)
        _add_text(header, "CompanyID", company.company_registry or company.vat or str(company.id))
        _add_text(header, "TaxRegistrationNumber", company.vat or "")
        _add_text(header, "TaxAccountingBasis", "F")  # F = Facturação / Invoicing
        _add_text(header, "CompanyName", company.name or "")

        # Company address block
        address = ET.SubElement(header, "CompanyAddress")
        _add_text(address, "StreetName", company.street or "")
        _add_text(address, "City", company.city or "")
        _add_text(address, "PostalCode", company.zip or "")
        _add_text(address, "Country", (company.country_id.code or "").upper())

        _add_text(header, "FiscalYear", str(self.date_from.year))
        _add_text(header, "StartDate", str(self.date_from))
        _add_text(header, "EndDate", str(self.date_to))
        _add_text(header, "CurrencyCode", (company.currency_id.name or "EUR").upper())
        _add_text(header, "DateCreated", str(fields.Date.context_today(self)))
        _add_text(header, "TaxEntity", company.name or "")
        _add_text(header, "ProductCompanyTaxID", company.vat or "")
        _add_text(header, "SoftwareCertificateNumber", "0")
        _add_text(header, "ProductID", "Fusion Accounting/Nexa Systems Inc.")
        _add_text(header, "ProductVersion", "19.0")

    # ------------------------------------------------------------------
    # MasterFiles – General Ledger Accounts
    # ------------------------------------------------------------------
    def _build_general_ledger_accounts(self, master_files):
        """Add every active account for the company."""
        accounts = self.env["account.account"].search([
            ("company_id", "=", self.company_id.id),
        ])
        if not accounts:
            return

        gl_section = ET.SubElement(master_files, "GeneralLedgerAccounts")
        for account in accounts:
            acct_el = ET.SubElement(gl_section, "Account")
            _add_text(acct_el, "AccountID", account.code or "")
            _add_text(acct_el, "AccountDescription", account.name or "")

            # Map Odoo account types to SAF-T grouping categories
            saft_type = self._resolve_saft_account_type(account)
            _add_text(acct_el, "GroupingCategory", saft_type)
            _add_text(acct_el, "GroupingCode", account.code[:2] if account.code else "00")

            # Opening and closing balances for the period
            opening, closing = self._compute_account_balances(account)
            _add_text(acct_el, "OpeningDebitBalance", f"{max(opening, 0):.2f}")
            _add_text(acct_el, "OpeningCreditBalance", f"{max(-opening, 0):.2f}")
            _add_text(acct_el, "ClosingDebitBalance", f"{max(closing, 0):.2f}")
            _add_text(acct_el, "ClosingCreditBalance", f"{max(-closing, 0):.2f}")

    def _resolve_saft_account_type(self, account):
        """Map an ``account.account`` to an SAF-T grouping category string.

        Returns one of ``GR`` (Revenue/Income), ``GP`` (Expense),
        ``GA`` (Asset), ``GL`` (Liability), or ``GM`` (Mixed/Other).
        """
        account_type = account.account_type or ""
        if "receivable" in account_type or "income" in account_type:
            return "GR"
        if "payable" in account_type or "expense" in account_type:
            return "GP"
        if "asset" in account_type or "bank" in account_type or "cash" in account_type:
            return "GA"
        if "liability" in account_type:
            return "GL"
        return "GM"

    def _compute_account_balances(self, account):
        """Return ``(opening_balance, closing_balance)`` for *account*
        within the wizard's date range.

        * ``opening_balance`` – net balance of all posted move-lines
          dated **before** ``date_from``.
        * ``closing_balance`` – opening balance plus net movement
          between ``date_from`` and ``date_to``.
        """
        MoveLines = self.env["account.move.line"]
        domain_base = [
            ("account_id", "=", account.id),
            ("parent_state", "=", "posted"),
            ("company_id", "=", self.company_id.id),
        ]

        # Opening balance: everything before the period
        opening_lines = MoveLines.search(
            domain_base + [("date", "<", self.date_from)]
        )
        opening = sum(opening_lines.mapped("debit")) - sum(opening_lines.mapped("credit"))

        # Movement during the period
        period_lines = MoveLines.search(
            domain_base + [
                ("date", ">=", self.date_from),
                ("date", "<=", self.date_to),
            ]
        )
        movement = sum(period_lines.mapped("debit")) - sum(period_lines.mapped("credit"))

        return opening, opening + movement

    # ------------------------------------------------------------------
    # MasterFiles – Customers
    # ------------------------------------------------------------------
    def _build_customers(self, master_files):
        """Export customer (receivable) partners referenced in the period."""
        partners = self._get_partners_for_type("asset_receivable")
        if not partners:
            return

        customers_el = ET.SubElement(master_files, "Customers")
        for partner in partners:
            cust = ET.SubElement(customers_el, "Customer")
            _add_text(cust, "CustomerID", str(partner.id))
            _add_text(cust, "CustomerTaxID", partner.vat or "999999990")
            _add_text(cust, "CompanyName", partner.name or "")

            address = ET.SubElement(cust, "BillingAddress")
            _add_text(address, "StreetName", partner.street or "Desconhecido")
            _add_text(address, "City", partner.city or "Desconhecido")
            _add_text(address, "PostalCode", partner.zip or "0000-000")
            _add_text(address, "Country", (partner.country_id.code or "").upper() or "PT")

    # ------------------------------------------------------------------
    # MasterFiles – Suppliers
    # ------------------------------------------------------------------
    def _build_suppliers(self, master_files):
        """Export supplier (payable) partners referenced in the period."""
        partners = self._get_partners_for_type("liability_payable")
        if not partners:
            return

        suppliers_el = ET.SubElement(master_files, "Suppliers")
        for partner in partners:
            supp = ET.SubElement(suppliers_el, "Supplier")
            _add_text(supp, "SupplierID", str(partner.id))
            _add_text(supp, "SupplierTaxID", partner.vat or "999999990")
            _add_text(supp, "CompanyName", partner.name or "")

            address = ET.SubElement(supp, "SupplierAddress")
            _add_text(address, "StreetName", partner.street or "Desconhecido")
            _add_text(address, "City", partner.city or "Desconhecido")
            _add_text(address, "PostalCode", partner.zip or "0000-000")
            _add_text(address, "Country", (partner.country_id.code or "").upper() or "PT")

    def _get_partners_for_type(self, account_type):
        """Return distinct partners that have posted move-lines on
        accounts of the given ``account_type`` within the period.
        """
        lines = self.env["account.move.line"].search([
            ("company_id", "=", self.company_id.id),
            ("parent_state", "=", "posted"),
            ("date", ">=", self.date_from),
            ("date", "<=", self.date_to),
            ("account_id.account_type", "=", account_type),
            ("partner_id", "!=", False),
        ])
        return lines.mapped("partner_id")

    # ------------------------------------------------------------------
    # MasterFiles – Tax Table
    # ------------------------------------------------------------------
    def _build_tax_table(self, master_files):
        """Export the company's active taxes."""
        taxes = self.env["account.tax"].search([
            ("company_id", "=", self.company_id.id),
            ("active", "=", True),
        ])
        if not taxes:
            return

        table_el = ET.SubElement(master_files, "TaxTable")
        for tax in taxes:
            entry = ET.SubElement(table_el, "TaxTableEntry")
            _add_text(entry, "TaxType", self._resolve_saft_tax_type(tax))
            _add_text(entry, "TaxCountryRegion", (self.company_id.country_id.code or "PT").upper())
            _add_text(entry, "TaxCode", tax.name or str(tax.id))
            _add_text(entry, "Description", tax.description or tax.name or "")
            _add_text(entry, "TaxPercentage", f"{tax.amount:.2f}")

    @staticmethod
    def _resolve_saft_tax_type(tax):
        """Derive an SAF-T TaxType from the Odoo tax type_tax_use.

        Returns ``IVA`` for sales/purchase VAT and ``IS`` for
        withholding / other types.
        """
        if tax.type_tax_use in ("sale", "purchase"):
            return "IVA"
        return "IS"

    # ------------------------------------------------------------------
    # GeneralLedgerEntries
    # ------------------------------------------------------------------
    def _build_general_ledger_entries(self, root):
        """Write all posted journal entries for the period, grouped by
        journal (one ``<Journal>`` per Odoo ``account.journal``).
        """
        entries_el = ET.SubElement(root, "GeneralLedgerEntries")

        moves = self.env["account.move"].search([
            ("company_id", "=", self.company_id.id),
            ("state", "=", "posted"),
            ("date", ">=", self.date_from),
            ("date", "<=", self.date_to),
        ], order="journal_id, date, id")

        # Summary totals
        total_debit = 0.0
        total_credit = 0.0

        # Group moves by journal
        journals_map = {}
        for move in moves:
            journals_map.setdefault(move.journal_id, self.env["account.move"])
            journals_map[move.journal_id] |= move

        _add_text(entries_el, "NumberOfEntries", str(len(moves)))

        for journal, journal_moves in journals_map.items():
            journal_el = ET.SubElement(entries_el, "Journal")
            _add_text(journal_el, "JournalID", journal.code or str(journal.id))
            _add_text(journal_el, "Description", journal.name or "")

            for move in journal_moves:
                txn_el = ET.SubElement(journal_el, "Transaction")
                _add_text(txn_el, "TransactionID", move.name or str(move.id))
                _add_text(txn_el, "Period", str(move.date.month))
                _add_text(txn_el, "TransactionDate", str(move.date))
                _add_text(txn_el, "SourceID", (move.create_uid.login or "") if move.create_uid else "")
                _add_text(txn_el, "Description", move.ref or move.name or "")
                _add_text(txn_el, "GLPostingDate", str(move.date))

                for line in move.line_ids:
                    line_el = ET.SubElement(txn_el, "Line")
                    _add_text(line_el, "RecordID", str(line.id))
                    _add_text(line_el, "AccountID", line.account_id.code or "")
                    _add_text(line_el, "SourceDocumentID", move.name or "")

                    if line.debit:
                        debit_el = ET.SubElement(line_el, "DebitAmount")
                        _add_text(debit_el, "Amount", f"{line.debit:.2f}")
                        total_debit += line.debit
                    else:
                        credit_el = ET.SubElement(line_el, "CreditAmount")
                        _add_text(credit_el, "Amount", f"{line.credit:.2f}")
                        total_credit += line.credit

                    _add_text(line_el, "Description", line.name or "")

        _add_text(entries_el, "TotalDebit", f"{total_debit:.2f}")
        _add_text(entries_el, "TotalCredit", f"{total_credit:.2f}")


# ======================================================================
# Module-level helpers
# ======================================================================

def _add_text(parent, tag, text):
    """Create a child element with the given *tag* and *text* value."""
    el = ET.SubElement(parent, tag)
    el.text = str(text) if text is not None else ""
    return el
