# Copyright 2011-2020 Akretion France (http://www.akretion.com)
# Copyright 2009-2022 Noviat (http://www.noviat.com)
# @author Alexis de Lattre <alexis.delattre@akretion.com>
# @author Luc de Meyer <info@noviat.com>

import logging
from collections import defaultdict
from datetime import date

from dateutil.relativedelta import relativedelta
from stdnum.vatin import is_valid

from odoo import Command, _, api, fields, models
from odoo.exceptions import RedirectWarning, UserError, ValidationError
from odoo.tools import float_is_zero

_logger = logging.getLogger(__name__)


class IntrastatProductDeclaration(models.Model):
    _name = "intrastat.product.declaration"
    _description = "Intrastat Product Declaration"
    _rec_name = "year_month"
    _inherit = ["mail.thread", "mail.activity.mixin"]
    _order = "year_month desc, declaration_type, revision"
    _sql_constraints = [
        (
            "date_uniq",
            "unique(year_month, company_id, declaration_type, revision)",
            "A declaration of the same type already exists for this month !"
            "\nYou should update the existing declaration "
            "or change the revision number of this one.",
        )
    ]

    @api.model
    def default_get(self, fields_list):
        res = super().default_get(fields_list)
        decl_date = fields.Date.context_today(self) - relativedelta(months=1)
        res.update(
            {"year": str(decl_date.year), "month": str(decl_date.month).zfill(2)}
        )
        return res

    company_id = fields.Many2one(
        comodel_name="res.company",
        string="Company",
        required=True,
        default=lambda self: self.env.company,
    )
    company_country_code = fields.Char(
        related="company_id.partner_id.country_id.code",
        string="Company Country Code",
        store=True,
    )
    state = fields.Selection(
        selection=[("draft", "Draft"), ("confirmed", "Confirmed"), ("done", "Done")],
        readonly=True,
        tracking=True,
        copy=False,
        default="draft",
    )
    note = fields.Html(
        string="Notes",
    )
    year = fields.Char(required=True)
    month = fields.Selection(
        selection=[
            ("01", "01"),
            ("02", "02"),
            ("03", "03"),
            ("04", "04"),
            ("05", "05"),
            ("06", "06"),
            ("07", "07"),
            ("08", "08"),
            ("09", "09"),
            ("10", "10"),
            ("11", "11"),
            ("12", "12"),
        ],
        required=True,
    )
    year_month = fields.Char(
        compute="_compute_year_month",
        string="Period",
        tracking=True,
        store=True,
        help="Year and month of the declaration.",
    )
    declaration_type = fields.Selection(
        selection="_get_declaration_type",
        string="Type",
        required=True,
        tracking=True,
    )
    action = fields.Selection(
        selection="_get_action",
        required=True,
        default="replace",
        tracking=True,
    )
    revision = fields.Integer(
        default=1,
        help="Used to keep track of changes",
    )
    computation_line_ids = fields.One2many(
        comodel_name="intrastat.product.computation.line",
        inverse_name="parent_id",
        string="Intrastat Product Computation Lines",
    )
    declaration_line_ids = fields.One2many(
        comodel_name="intrastat.product.declaration.line",
        inverse_name="parent_id",
        string="Intrastat Product Declaration Lines",
        readonly=True,
    )
    num_decl_lines = fields.Integer(
        compute="_compute_numbers",
        string="Number of Declaration Lines",
        store=True,
        tracking=True,
    )
    total_amount = fields.Integer(
        compute="_compute_numbers",
        string="Total Fiscal Amount",
        store=True,
        help="Total fiscal amount in company currency of the declaration.",
    )
    currency_id = fields.Many2one(
        "res.currency", related="company_id.currency_id", string="Currency"
    )
    reporting_level = fields.Selection(
        selection="_get_reporting_level",
        compute="_compute_reporting_level",
        readonly=False,
        store=True,
    )
    xml_attachment_id = fields.Many2one("ir.attachment", string="XML Attachment")
    xml_attachment_datas = fields.Binary(
        related="xml_attachment_id.datas", string="XML Export"
    )
    xml_attachment_name = fields.Char(
        related="xml_attachment_id.name", string="XML Filename"
    )

    @api.model
    def _get_declaration_type(self):
        res = []
        company = self.env.company
        arrivals = company.intrastat_arrivals
        dispatches = company.intrastat_dispatches
        if arrivals != "exempt":
            res.append(("arrivals", _("Arrivals")))
        if dispatches != "exempt":
            res.append(("dispatches", _("Dispatches")))
        return res

    @api.model
    def _get_reporting_level(self):
        return [("standard", _("Standard")), ("extended", _("Extended"))]

    @api.model
    def _get_action(self):
        return [
            ("replace", _("Replace")),
            ("append", _("Append")),
            ("nihil", _("Nihil")),
        ]

    @api.depends("year", "month")
    def _compute_year_month(self):
        for this in self:
            if this.year and this.month:
                this.year_month = "-".join([this.year, this.month])

    @api.constrains("company_id")
    def _check_company_country(self):
        for this in self:
            if not this.company_id.country_id:
                raise ValidationError(
                    _("You must set the country on company '%s'.")
                    % this.company_id.display_name
                )

    @api.depends("declaration_line_ids.amount_company_currency")
    def _compute_numbers(self):
        rg_res = self.env["intrastat.product.declaration.line"].read_group(
            [("parent_id", "in", self.ids)],
            ["parent_id", "amount_company_currency:sum"],
            ["parent_id"],
        )
        mapped_data = {
            x["parent_id"][0]: {
                "num_decl_lines": x["parent_id_count"],
                "total_amount": x["amount_company_currency"],
            }
            for x in rg_res
        }
        for this in self:
            this.num_decl_lines = mapped_data.get(this.id, {}).get("num_decl_lines", 0)
            this.total_amount = mapped_data.get(this.id, {}).get("total_amount", 0)

    @api.constrains("year")
    def _check_year(self):
        for this in self:
            if len(this.year) != 4 or this.year[0] != "2":
                raise ValidationError(_("Invalid Year!"))

    @api.depends("declaration_type", "company_id")
    def _compute_reporting_level(self):
        for this in self:
            reporting_level = False
            if this.declaration_type == "arrivals":
                reporting_level = (
                    this.company_id.intrastat_arrivals == "extended"
                    and "extended"
                    or "standard"
                )
            elif this.declaration_type == "dispatches":
                reporting_level = (
                    this.company_id.intrastat_dispatches == "extended"
                    and "extended"
                    or "standard"
                )
            this.reporting_level = reporting_level

    @api.depends("declaration_type", "year_month")
    def _compute_display_name(self):
        type2label = dict(
            self.fields_get("declaration_type", "selection")["declaration_type"][
                "selection"
            ]
        )
        for rec in self:
            name = _(
                "Intrastat Product Declaration %(declaration_type)s %(year_month)s",
                year_month=rec.year_month,
                declaration_type=type2label.get(rec.declaration_type),
            )
            rec.display_name = name

    def copy(self, default=None):
        self.ensure_one()
        default = default or {}
        default["revision"] = self.revision + 1
        return super().copy(default)

    def _account_config_warning(self, msg):
        action = self.env.ref("account.action_account_config")
        raise RedirectWarning(
            msg, action.id, _("Go to Accounting Configuration Settings screen")
        )

    def _attach_xml_file(self, xml_bytes, declaration_name):
        """Attach the XML file to the report_intrastat_product/service
        object"""
        self.ensure_one()
        filename = f"{self.year_month}_{declaration_name}.xml"
        attach = self.env["ir.attachment"].create(
            {
                "name": filename,
                "res_id": self.id,
                "res_model": self._name,
                "raw": xml_bytes,
            }
        )
        return attach.id

    def unlink(self):
        for this in self:
            if this.state == "done":
                raise UserError(
                    _("Cannot delete the declaration %s because it is in Done state.")
                    % this.display_name
                )
        return super().unlink()

    def _get_partner_country(self, inv_line, notedict, eu_countries):
        inv = inv_line.move_id
        country = inv.src_dest_country_id
        if not country:
            if not inv.partner_shipping_id.country_id:
                error_partner = inv.partner_shipping_id
            elif not inv.partner_id.country_id:
                error_partner = inv.partner_id
            else:
                error_partner = inv.company_id.partner_id
            msg = _("Missing <em>Country</em>")
            notedict["partner"][error_partner.display_name][msg].add(
                notedict["inv_origin"]
            )
        else:
            if country not in eu_countries and country.code != "GB":
                msg = (
                    _(
                        "The source/destination country "
                        "is <em>%s</em> which is not part of the European Union"
                    )
                    % country.name
                )
                notedict["invoice"][notedict["inv_origin"]].add(msg)
        return country

    def _get_intrastat_transaction(self, inv_line, notedict):
        invoice = inv_line.move_id
        transaction = invoice.intrastat_transaction_id
        fp2transaction = {
            "b2b": self.env.ref("intrastat_product.intrastat_transaction_11"),
            "b2c": self.env.ref("intrastat_product.intrastat_transaction_12"),
        }
        if not transaction:
            # as we have searched with intrastat_fiscal_position in ('b2b', 'b2c')
            # we should always have intrastat_fiscal_position in fp2transaction
            transaction = fp2transaction[invoice.intrastat_fiscal_position]
        return transaction

    def _get_weight_and_supplunits(self, inv_line, hs_code, notedict):
        line_qty = inv_line.quantity
        product = inv_line.product_id
        intrastat_unit_id = hs_code.intrastat_unit_id
        source_uom = inv_line.product_uom_id
        weight_uom_categ = self.env.ref("uom.product_uom_categ_kgm")
        kg_uom = self.env.ref("uom.product_uom_kgm")
        self.env["decimal.precision"].precision_get("Stock Weight")
        weight = suppl_unit_qty = 0.0

        if not source_uom:
            msg = _("Missing unit of measure")
            notedict["invoice"][notedict["invline_origin"]].add(msg)
            return weight, suppl_unit_qty

        if intrastat_unit_id:
            target_uom = intrastat_unit_id.uom_id
            if not target_uom:
                msg = _("Missing link to a <em>regular unit of measure</em>")
                notedict["intrastat_unit"][intrastat_unit_id.display_name][msg].add(
                    notedict["invline_origin"]
                )
                return weight, suppl_unit_qty
            if target_uom.category_id == source_uom.category_id:
                suppl_unit_qty = source_uom._compute_quantity(line_qty, target_uom)
            else:
                msg = _(
                    "Conversion from unit of measure <em>%(source_uom)s</em> to "
                    "<em>%(target_uom)s</em>, which is configured on the intrastat "
                    "supplementary unit <i>%(intrastat_unit)s</i> "
                    "of H.S. code <i>%(hs_code)s</i>, "
                    "is not implemented yet"
                ) % {
                    "source_uom": source_uom.name,
                    "target_uom": target_uom.name,
                    "intrastat_unit": intrastat_unit_id.display_name,
                    "hs_code": hs_code.display_name,
                }
                notedict["invoice"][notedict["invline_origin"]].add(msg)
                return weight, suppl_unit_qty

        if source_uom == kg_uom:
            weight = line_qty
        elif source_uom.category_id == weight_uom_categ:
            weight = source_uom._compute_quantity(line_qty, kg_uom)
        elif source_uom.category_id == product.uom_id.category_id:
            # We suppose that, on product.template,
            # the 'weight' field is per uom_id
            # Test if module product_net_weight from OCA/product-attribute is installed
            if hasattr(product, "net_weight"):
                product_weight = product.net_weight
            else:
                product_weight = product.weight
            weight = product_weight * source_uom._compute_quantity(
                line_qty, product.uom_id
            )
        else:
            msg = _(
                "Conversion from unit of measure <em>%(source_uom)s</em> to "
                "<em>Kg</em> cannot be done automatically. It is needed for product "
                "<i>%(product)s</i> whose unit of measure is "
                "<i>%(product_uom)s</i>"
            ) % {
                "source_uom": source_uom.name,
                "product": product.display_name,
                "product_uom": product.uom_id.display_name,
            }
            notedict["invoice"][notedict["inv_origin"]].add(msg)
        return weight, suppl_unit_qty

    def _get_region_code(self, inv_line, notedict):
        """May be inherited by localisation modules
        If set, Odoo will use the region code returned by this method
        and will not call _get_region() and leave region_id empty
        """
        return False

    def _get_region(self, inv_line, notedict):
        """
        For supplier invoices/refunds: if the invoice line is linked
        to a stock move, use the destination stock location ;
        otherwise, get the PO (which is linked to a stock location)
        and then get the warehouse.
        It is important to take into account the following scenario:
        I order a qty of 50 kg and my suppliers delivers and invoices 52 kg
        (quite common in some industries where the order qty cannot be exact
        due to some operational constraints) ; in this case, I have a qty of
        2 kg which is not linked to a PO, but it is linked to a stock move.

        For customer invoices/refunds: if the invoice line is linked to a
        stock move, use the source stock location ;
        otherwise, get the sale order, which is linked to the warehouse.

        If none found, get the company's default intrastat region.

        """
        region = self.env["intrastat.region"]
        move_type = inv_line.move_id.move_type
        if move_type in ("in_invoice", "in_refund"):
            po_line = self.env["purchase.order.line"].search(
                [("invoice_lines", "in", inv_line.id)], limit=1
            )
            if po_line:
                if po_line.move_ids:
                    region = po_line.move_ids[0].location_dest_id.get_intrastat_region()
        elif move_type in ("out_invoice", "out_refund"):
            so_line = self.env["sale.order.line"].search(
                [("invoice_lines", "in", inv_line.id)], limit=1
            )
            if so_line:
                so = so_line.order_id
                region = so.warehouse_id.region_id
        if not region:
            if self.company_id.intrastat_region_id:
                region = self.company_id.intrastat_region_id
        return region

    def _get_transport(self, inv_line, notedict):
        transport = (
            inv_line.move_id.intrastat_transport_id
            or self.company_id.intrastat_transport_id
        )
        if not transport:
            msg = _(
                "The default Intrastat Transport Mode of the Company is not set, "
                "please configure it first."
            )
            self._account_config_warning(msg)
        return transport

    def _get_incoterm(self, inv_line, notedict):
        incoterm = inv_line.move_id.invoice_incoterm_id or self.company_id.incoterm_id
        if not incoterm:
            msg = _(
                "The default Incoterm of the Company is not set, "
                "please configure it first."
            )
            self._account_config_warning(msg)
        return incoterm

    def _get_product_origin_country(self, inv_line, notedict):
        product = inv_line.product_id
        origin_country = product.origin_country_id
        if not origin_country:
            msg = _("Missing <em>Country of Origin</em>")
            notedict["product"][product.display_name][msg].add(
                notedict["invline_origin"]
            )
        return origin_country

    def _get_partner_and_warn_vat(self, inv_line, notedict):
        inv = inv_line.move_id
        partner = (
            inv.partner_shipping_id
            and inv.partner_shipping_id.commercial_partner_id
            or inv.commercial_partner_id
        )
        # Warnings about VAT
        vat = partner.vat
        if self.declaration_type == "dispatches":
            if vat:
                if vat.startswith("GB"):
                    msg = _(
                        "VAT number is <em>%(vat)s</em>. If this partner "
                        "is from Northern Ireland, his VAT number should be "
                        "updated to his new VAT number starting with <em>XI</em> "
                        "following Brexit. If this partner is from Great Britain, "
                        "maybe the fiscal position was wrong on the invoice "
                        "(the fiscal position was <i>%(fiscal_position)s</i>)."
                    ) % {
                        "vat": vat,
                        "fiscal_position": inv.fiscal_position_id.display_name,
                    }
                    notedict["partner"][partner.display_name][msg].add(
                        notedict["inv_origin"]
                    )
            elif inv.fiscal_position_id.intrastat != "b2c":
                msg = _("Missing <em>VAT Number</em>")
                notedict["partner"][partner.display_name][msg].add(
                    notedict["inv_origin"]
                )
        return partner

    def _update_computation_line_vals(self, inv_line, line_vals, notedict):
        """placeholder for localization modules"""

    def _handle_invoice_accessory_cost(
        self,
        invoice,
        lines_current_invoice,
        total_inv_accessory_costs_cc,
        total_inv_product_cc,
        total_inv_weight,
    ):
        """
        Affect accessory costs pro-rata of the value
        (or pro-rata of the weight if the goods of the invoice
        have no value)

        This method allows to implement a different logic
        in the localization modules.
        E.g. in Belgium accessory cost should not be added.
        """
        if total_inv_accessory_costs_cc:
            if total_inv_product_cc:
                # pro-rata of the value
                for ac_line_vals in lines_current_invoice:
                    ac_line_vals["amount_accessory_cost_company_currency"] = (
                        total_inv_accessory_costs_cc
                        * ac_line_vals["amount_company_currency"]
                        / total_inv_product_cc
                    )
            elif total_inv_weight:
                # pro-rata of the weight
                for ac_line_vals in lines_current_invoice:
                    ac_line_vals["amount_accessory_cost_company_currency"] = (
                        total_inv_accessory_costs_cc
                        * ac_line_vals["weight"]
                        / total_inv_weight
                    )
            else:
                for ac_line_vals in lines_current_invoice:
                    ac_line_vals["amount_accessory_cost_company_currency"] = (
                        total_inv_accessory_costs_cc / len(lines_current_invoice)
                    )

    def _prepare_invoice_domain(self):
        """
        Complete this method in the localization module
        with the country-specific logic for arrivals and dispatches.
        Cf. l10n_be_intrastat_product_declaration for an example
        The dates are based on account.move,date in stead of invoice_date
        to ensure consistency between intrastat and intracomm tax declaration.
        """
        start_date = date(int(self.year), int(self.month), 1)
        end_date = start_date + relativedelta(day=1, months=+1, days=-1)
        domain = [
            ("date", ">=", start_date),
            ("date", "<=", end_date),
            ("state", "=", "posted"),
            ("intrastat_fiscal_position", "in", ("b2b", "b2c")),
            ("company_id", "=", self.company_id.id),
        ]
        if self.declaration_type == "arrivals":
            domain.append(("move_type", "in", ("in_invoice", "in_refund")))
        elif self.declaration_type == "dispatches":
            domain.append(("move_type", "in", ("out_invoice", "out_refund")))
        return domain

    def _is_product(self, invoice_line):
        if invoice_line.product_id and invoice_line.product_id.type in (
            "product",
            "consu",
        ):
            return True
        else:
            return False

    def _gather_invoices_init(self, notedict):
        """placeholder for localization modules"""

    def _gather_invoices(self, notedict):
        lines = []
        qty_prec = self.env["decimal.precision"].precision_get(
            "Product Unit of Measure"
        )
        accessory_costs = self.company_id.intrastat_accessory_costs
        eu_countries = self.env.ref("base.europe").country_ids

        self._gather_invoices_init(notedict)
        domain = self._prepare_invoice_domain()
        order = "journal_id, name"
        invoices = self.env["account.move"].search(domain, order=order)

        for invoice in invoices:
            lines_current_invoice = []
            total_inv_accessory_costs_cc = 0.0  # in company currency
            total_inv_product_cc = 0.0  # in company currency
            total_inv_weight = 0.0
            notedict["inv_origin"] = invoice.name
            for line_nbr, inv_line in enumerate(
                invoice.invoice_line_ids.filtered(
                    lambda x: x.display_type == "product"
                ),
                start=1,
            ):
                notedict["invline_origin"] = _("%(invoice)s line %(line_nbr)s") % {
                    "invoice": invoice.name,
                    "line_nbr": line_nbr,
                }
                inv_intrastat_line = invoice.intrastat_line_ids.filtered(
                    lambda r, inv_line=inv_line: r.invoice_line_id == inv_line
                )

                if (
                    accessory_costs
                    and inv_line.product_id
                    and inv_line.product_id.is_accessory_cost
                ):
                    acost = invoice.currency_id._convert(
                        inv_line.price_subtotal,
                        self.company_id.currency_id,
                        self.company_id,
                        invoice.date,
                    )
                    total_inv_accessory_costs_cc += acost

                    continue

                if float_is_zero(inv_line.quantity, precision_digits=qty_prec):
                    _logger.info(
                        "Skipping invoice line %s qty %s "
                        "of invoice %s. Reason: qty = 0",
                        inv_line.name,
                        inv_line.quantity,
                        invoice.name,
                    )
                    continue

                partner_country = self._get_partner_country(
                    inv_line, notedict, eu_countries
                )
                # When the country is the same as the company's country must be skipped.
                if partner_country == self.company_id.country_id:
                    _logger.info(
                        "Skipping invoice line %s qty %s "
                        "of invoice %s. Reason: partner_country = "
                        "company country",
                        inv_line.name,
                        inv_line.quantity,
                        invoice.name,
                    )
                    continue

                if inv_intrastat_line:
                    hs_code = inv_intrastat_line.hs_code_id
                elif inv_line.product_id and self._is_product(inv_line):
                    hs_code = inv_line.product_id.get_hs_code_recursively()
                    if not hs_code:
                        msg = _("Missing <em>H.S. Code</em>")
                        notedict["product"][inv_line.product_id.display_name][msg].add(
                            notedict["invline_origin"]
                        )
                        continue
                else:
                    _logger.info(
                        "Skipping invoice line %s qty %s "
                        "of invoice %s. Reason: no product nor Intrastat Code",
                        inv_line.name,
                        inv_line.quantity,
                        invoice.name,
                    )
                    continue

                intrastat_transaction = self._get_intrastat_transaction(
                    inv_line, notedict
                )

                if inv_intrastat_line:
                    weight = inv_intrastat_line.transaction_weight
                    suppl_unit_qty = inv_intrastat_line.transaction_suppl_unit_qty
                else:
                    weight, suppl_unit_qty = self._get_weight_and_supplunits(
                        inv_line, hs_code, notedict
                    )
                total_inv_weight += weight

                sign = invoice.move_type in ("in_invoice", "out_refund") and 1 or -1
                amount_company_currency = sign * inv_line.balance
                total_inv_product_cc += amount_company_currency

                if inv_intrastat_line:
                    product_origin_country = (
                        inv_intrastat_line.product_origin_country_id
                    )
                else:
                    product_origin_country = self._get_product_origin_country(
                        inv_line, notedict
                    )

                region_code = self._get_region_code(inv_line, notedict)
                region = self.env["intrastat.region"]
                if not region_code:
                    region = self._get_region(inv_line, notedict)

                partner = self._get_partner_and_warn_vat(inv_line, notedict)

                line_vals = {
                    "parent_id": self.id,
                    "invoice_line_id": inv_line.id,
                    "src_dest_country_id": partner_country.id,
                    "product_id": inv_line.product_id.id,
                    "hs_code_id": hs_code.id,
                    "weight": weight,
                    "suppl_unit_qty": suppl_unit_qty,
                    "amount_company_currency": amount_company_currency,
                    "amount_accessory_cost_company_currency": 0.0,
                    "transaction_id": intrastat_transaction.id,
                    "product_origin_country_id": product_origin_country.id or False,
                    "region_code": region_code or region.code,
                    "region_id": region and region.id or False,
                    "partner_id": partner.id,
                }

                # extended declaration
                if self.reporting_level == "extended":
                    transport = self._get_transport(inv_line, notedict)
                    line_vals.update({"transport_id": transport.id})

                self._update_computation_line_vals(inv_line, line_vals, notedict)

                if line_vals:
                    lines_current_invoice.append(line_vals)

            self._handle_invoice_accessory_cost(
                invoice,
                lines_current_invoice,
                total_inv_accessory_costs_cc,
                total_inv_product_cc,
                total_inv_weight,
            )

            for line_vals in lines_current_invoice:
                if (
                    not line_vals["amount_company_currency"]
                    and not line_vals["amount_accessory_cost_company_currency"]
                ):
                    inv_line = self.env["account.move.line"].browse(
                        line_vals["invoice_line_id"]
                    )
                    _logger.info(
                        "Skipping invoice line %s qty %s "
                        "of invoice %s. Reason: price_subtotal = 0 "
                        "and accessory costs = 0",
                        inv_line.name,
                        inv_line.quantity,
                        inv_line.move_id.name,
                    )
                    continue
                lines.append(line_vals)

        return lines

    def _prepare_html_note(self, notedict, key2label):
        note = ""
        for key, entries in notedict.items():
            if not key.endswith("_origin") and entries:
                note += "<h3>%s</h3><ul>" % key2label[key]
                for obj_name, messages in entries.items():
                    note += "<li>%s<ul>" % obj_name
                    if isinstance(
                        messages, dict
                    ):  # 2 layers of dict (partner, product)
                        for message, origins in messages.items():
                            origin_str = ", ".join(origins)
                            note += f"<li>{message} <small>({origin_str})</small></li>"
                    else:  # 1st layer=dict, 2nd layer=set (invoice)
                        for message in messages:
                            note += "<li>%s</li>" % message
                    note += "</ul>"
                note += "</ul>"
        return note

    def _prepare_notedict(self):
        notedict = {
            "partner": defaultdict(lambda: defaultdict(set)),
            # key = partner.display_name
            # value = {message1: {origin1, origin2}, message2: {origin3, origin4}}
            "product": defaultdict(lambda: defaultdict(set)),
            "intrastat_unit": defaultdict(lambda: defaultdict(set)),
            "invoice": defaultdict(set),  # for invoice and invoice line
            # key = invoice.name
            # value (set) = {message1, message2}
            "inv_origin": "",
            "invline_origin": "",
        }
        key2label = {
            "partner": _("Partners"),
            "product": _("Products"),
            "intrastat_unit": _("Intrastat Supplementary Units"),
            "invoice": _("Invoices/Refunds"),
        }
        return notedict, key2label

    def action_gather(self):
        self.ensure_one()
        self.message_post(body=_("Generate Lines from Invoices"))
        notedict, key2label = self._prepare_notedict()
        self.computation_line_ids.unlink()
        self.declaration_line_ids.unlink()
        lines = self._gather_invoices(notedict)

        vals = {"note": self._prepare_html_note(notedict, key2label)}
        if not lines:
            vals["action"] = "nihil"
            nihil_title = _("No records found for the selected period !")
            nihil_note = _("The declaration Action has been set to <em>nihil</em>.")
            vals["note"] += f"<h3>{nihil_title}</h3><p>{nihil_note}</p>"
        else:
            vals["computation_line_ids"] = [Command.create(x) for x in lines]

        self.write(vals)
        if vals["note"]:
            result_view = self.env.ref("intrastat_product.intrastat_result_view_form")
            return {
                "name": _("Generate lines from invoices: results"),
                "view_mode": "form",
                "res_model": "intrastat.result.view",
                "view_id": result_view.id,
                "target": "new",
                "context": dict(self._context, note=vals["note"]),
                "type": "ir.actions.act_window",
            }

    def generate_declaration(self):
        """generate declaration lines from computation lines"""
        self.ensure_one()
        assert not self.declaration_line_ids
        dl_group = {}
        for cl in self.computation_line_ids:
            hashcode = cl.group_line_hashcode()
            if hashcode not in dl_group:
                dl_group[hashcode] = cl
            else:
                dl_group[hashcode] |= cl
        ipdl = self.declaration_line_ids
        line_number = 1
        for cl_lines in dl_group.values():
            vals = cl_lines._prepare_declaration_line(line_number)
            declaration_line = ipdl.create(vals)
            cl_lines.write({"declaration_line_id": declaration_line.id})
            line_number += 1

    def _check_generate_xml(self):
        self.ensure_one()
        if not self.company_id.partner_id.vat:
            raise UserError(
                _("The VAT number is not set for the partner '%s'.")
                % self.company_id.partner_id.display_name
            )

    def _generate_xml(self):
        """Designed to be inherited in localization modules"""
        return False

    def generate_xml(self):
        """generate the INTRASTAT Declaration XML file"""
        self.ensure_one()
        if self.xml_attachment_id:
            raise UserError(
                _(
                    "An XML Export already exists for %s. "
                    "To re-generate it, you must first delete it."
                )
                % self.display_name
            )
        self.message_post(body=_("Generate XML Declaration File"))
        self._check_generate_xml()
        xml_bytes = self._generate_xml()
        if xml_bytes:
            attach_id = self._attach_xml_file(
                xml_bytes, f"{self.declaration_type}_{self.revision}"
            )
            self.write({"xml_attachment_id": attach_id})

    def delete_xml(self):
        self.ensure_one()
        self.xml_attachment_id and self.xml_attachment_id.unlink()

    @api.model
    def _xls_computation_line_fields(self):
        """
        Update list in custom module to add/drop columns or change order
        """
        return [
            "product",
            "hs_code",
            "src_dest_country",
            "src_dest_country_code",
            "amount_company_currency",
            "accessory_cost",
            "transaction",
            "weight",
            "suppl_unit_qty",
            "suppl_unit",
            "transport",
            "region",
            "region_code",
            "product_origin_country",
            "product_origin_country_code",
            "vat",
            "partner_id",
            "invoice",
        ]

    @api.model
    def _xls_declaration_line_fields(self):
        """
        Update list in custom module to add/drop columns or change order
        Use same order as tree view by default
        """
        return [
            "hs_code",
            "src_dest_country_code",
            "amount_company_currency",
            "transaction_code",
            "transaction",
            "weight",
            "suppl_unit_qty",
            "suppl_unit",
            "transport_code",
            "transport",
            "region_code",
            "product_origin_country_code",
            "vat",
        ]

    @api.model
    def _xls_template(self):
        """
        Placeholder for excel report template updates

        """
        return {}

    @api.model
    def _get_xlsx_report_filename(self):
        self.ensure_one()
        declaration_type_label = dict(
            self.fields_get("declaration_type", "selection")["declaration_type"][
                "selection"
            ]
        )[self.declaration_type]
        draft_label = ""
        if self.state == "draft":
            draft_label = (
                "-%s"
                % dict(self.fields_get("state", "selection")["state"]["selection"])[
                    self.state
                ]
            )
        filename = _(
            "intrastat-%(year_month)s-%(declaration_type)s%(draft)s",
            year_month=self.year_month,
            declaration_type=declaration_type_label,
            draft=draft_label,
        )
        return filename

    def draft2confirmed(self):
        for decl in self:
            decl.generate_declaration()
        self.write({"state": "confirmed"})

    def confirmed2done(self):
        for decl in self:
            decl.generate_xml()
        self.write({"state": "done"})

    def back2draft(self):
        for decl in self:
            decl.delete_xml()
            decl.declaration_line_ids.unlink()
        self.write({"state": "draft"})


class IntrastatProductComputationLine(models.Model):
    _name = "intrastat.product.computation.line"
    _description = "Intrastat Product Computation Lines"

    parent_id = fields.Many2one(
        "intrastat.product.declaration",
        string="Intrastat Product Declaration",
        ondelete="cascade",
        readonly=True,
    )
    company_id = fields.Many2one(related="parent_id.company_id")
    company_currency_id = fields.Many2one(
        related="company_id.currency_id", string="Company currency"
    )
    company_country_code = fields.Char(
        related="parent_id.company_id.partner_id.country_id.code",
        string="Company Country Code",
    )
    declaration_type = fields.Selection(related="parent_id.declaration_type")
    reporting_level = fields.Selection(related="parent_id.reporting_level")
    invoice_line_id = fields.Many2one(
        "account.move.line", string="Invoice Line", readonly=True
    )
    invoice_id = fields.Many2one(
        "account.move", related="invoice_line_id.move_id", string="Invoice"
    )
    # partner_id is not a related field any more, to auto-fill the VAT
    # number (via onchange) when you create a computation line manually
    partner_id = fields.Many2one(
        "res.partner",
        string="Partner",
        domain=[("parent_id", "=", False)],
    )
    declaration_line_id = fields.Many2one(
        "intrastat.product.declaration.line", string="Declaration Line", readonly=True
    )
    src_dest_country_id = fields.Many2one(
        "res.country",
        string="Country",
        help="Country of Origin/Destination",
    )
    src_dest_country_code = fields.Char(
        compute="_compute_src_dest_country_code",
        string="Country Code",
        readonly=False,
        store=True,
        help="2 letters code of the country of origin/destination.\n"
        "Specify 'XI' for Northern Ireland.",
    )
    product_id = fields.Many2one(
        "product.product", related="invoice_line_id.product_id"
    )
    hs_code_id = fields.Many2one("hs.code", string="Intrastat Code")
    intrastat_unit_id = fields.Many2one(
        "intrastat.unit",
        related="hs_code_id.intrastat_unit_id",
        string="Suppl. Unit",
        help="Intrastat Supplementary Unit",
    )
    weight = fields.Float(digits="Stock Weight", help="Net weight in Kg")
    suppl_unit_qty = fields.Float(
        string="Suppl. Unit Qty",
        digits="Product Unit of Measure",
        help="Supplementary Units Quantity",
    )
    amount_company_currency = fields.Float(
        string="Fiscal Value",
        digits="Account",
        required=True,
        help="Amount in company currency to write in the declaration. "
        "Amount in company currency = amount in invoice currency "
        "converted to company currency with the rate of the invoice date.",
    )
    amount_accessory_cost_company_currency = fields.Float(
        string="Accessory Costs",
        digits="Account",
        help="Amount in company currency of the accessory costs related to "
        "this invoice line. By default, these accessory costs are computed "
        "at the pro-rata of the amount of each invoice line.",
    )
    transaction_id = fields.Many2one(
        "intrastat.transaction",
        string="Intrastat Transaction",
    )
    transaction_code = fields.Char(
        related="transaction_id.code", store=True, string="Transaction Code"
    )
    region_id = fields.Many2one("intrastat.region", string="Intrastat Region")
    # Note that, in l10n_fr_intrastat_product and maybe in other localization modules
    # region_id is left empty and Odoo writes directly in region_code
    region_code = fields.Char(
        compute="_compute_region_code", store=True, readonly=False
    )
    product_origin_country_id = fields.Many2one(
        "res.country",
        string="Country of Origin of the Product",
        help="Country of origin of the product i.e. product 'made in ____'",
    )
    product_origin_country_code = fields.Char(
        compute="_compute_product_origin_country_code",
        string="Country Code of Origin of the Product",
        size=2,
        readonly=False,
        store=True,
        help="2 letters ISO code of the country of origin of the product.\n"
        "Specify 'QU' when the country of origin is unknown.\n",
    )
    vat = fields.Char(
        compute="_compute_vat",
        store=True,
        readonly=False,
        string="VAT Number",
    )

    # extended declaration
    incoterm_id = fields.Many2one("account.incoterms", string="Incoterm")
    transport_id = fields.Many2one("intrastat.transport_mode", string="Transport Mode")

    @api.depends("region_id")
    def _compute_region_code(self):
        for this in self:
            this.region_code = this.region_id and this.region_id.code or False

    @api.depends("src_dest_country_id")
    def _compute_src_dest_country_code(self):
        for this in self:
            code = this.src_dest_country_id and this.src_dest_country_id.code or False
            if code == "GB":
                code = "XI"  # Northern Ireland
            this.src_dest_country_code = code

    @api.depends("product_origin_country_id")
    def _compute_product_origin_country_code(self):
        for this in self:
            code = (
                this.product_origin_country_id
                and this.product_origin_country_id.code
                or False
            )
            if code == "GB":
                code = "XU"
                # XU can be used when you don't know if the product
                # originate from Great-Britain or from Northern Ireland
            this.product_origin_country_code = code

    @api.constrains("vat")
    def _check_vat(self):
        for this in self:
            if this.vat and not is_valid(this.vat):
                raise ValidationError(_("The VAT number '%s' is invalid.") % this.vat)

    @api.depends("partner_id")
    def _compute_vat(self):
        for this in self:
            vat = False
            if (
                this.partner_id
                and this.partner_id.vat
                and this.parent_id.declaration_type == "dispatches"
            ):
                vat = this.partner_id.vat
            this.vat = vat

    def _group_line_hashcode_fields(self):
        self.ensure_one()
        return {
            "country": self.src_dest_country_code,
            "hs_code_id": self.hs_code_id.id or False,
            "intrastat_unit": self.intrastat_unit_id.id or False,
            "transaction": self.transaction_id.id or False,
            "transport": self.transport_id.id or False,
            "region": self.region_code or False,
            "product_origin_country": self.product_origin_country_code,
            "vat": self.vat or False,
        }

    def group_line_hashcode(self):
        self.ensure_one()
        hc_fields = self._group_line_hashcode_fields()
        hashcode = "-".join([str(f) for f in hc_fields.values()])
        return hashcode

    def _prepare_grouped_fields(self, fields_to_sum):
        self.ensure_one()
        vals = {
            "src_dest_country_code": self.src_dest_country_code,
            "intrastat_unit_id": self.intrastat_unit_id.id,
            "hs_code_id": self.hs_code_id.id,
            "transaction_id": self.transaction_id.id,
            "transport_id": self.transport_id.id,
            "region_code": self.region_code,
            "parent_id": self.parent_id.id,
            "product_origin_country_code": self.product_origin_country_code,
            "amount_company_currency": 0.0,
            "vat": self.vat,
        }
        for field in fields_to_sum:
            vals[field] = 0.0
        return vals

    def _fields_to_sum(self):
        fields_to_sum = ["weight", "suppl_unit_qty"]
        return fields_to_sum

    def _prepare_declaration_line(self, line_number):
        fields_to_sum = self._fields_to_sum()
        vals = self[0]._prepare_grouped_fields(fields_to_sum)
        for computation_line in self:
            for field in fields_to_sum:
                vals[field] += computation_line[field]
            vals["amount_company_currency"] += (
                computation_line["amount_company_currency"]
                + computation_line["amount_accessory_cost_company_currency"]
            )
        # on computation lines, weight and suppl_unit_qty are floats
        # on declaration lines, weight and suppl_unit_qty
        # are integer => so we must round()
        for field in fields_to_sum:
            vals[field] = int(round(vals[field]))
        # the intrastat specs say that, if the value is between 0 and 0.5,
        # it should be rounded to 1
        if not vals["weight"]:
            vals["weight"] = 1
        if vals["intrastat_unit_id"] and not vals["suppl_unit_qty"]:
            vals["suppl_unit_qty"] = 1
        vals["amount_company_currency"] = int(round(vals["amount_company_currency"]))
        vals["line_number"] = line_number
        return vals


class IntrastatProductDeclarationLine(models.Model):
    _name = "intrastat.product.declaration.line"
    _description = "Intrastat Product Declaration Lines"

    parent_id = fields.Many2one(
        "intrastat.product.declaration",
        string="Intrastat Product Declaration",
        ondelete="cascade",
        readonly=True,
    )
    # line_number is used by localization modules to point the user to a specific
    # declaration line in an error messages when generation the XML file
    line_number = fields.Integer(readonly=True)
    company_id = fields.Many2one(related="parent_id.company_id")
    company_currency_id = fields.Many2one(
        related="company_id.currency_id", string="Company currency"
    )
    company_country_code = fields.Char(
        related="parent_id.company_id.partner_id.country_id.code",
        string="Company Country Code",
    )
    declaration_type = fields.Selection(related="parent_id.declaration_type")
    reporting_level = fields.Selection(related="parent_id.reporting_level")
    computation_line_ids = fields.One2many(
        "intrastat.product.computation.line",
        "declaration_line_id",
        string="Computation Lines",
        readonly=True,
    )
    src_dest_country_code = fields.Char(
        string="Country Code",
        help="2 letters ISO code of the country of origin/destination.\n"
        "Specify 'XI' for Northern Ireland and 'XU' for Great Britain.",
    )
    hs_code_id = fields.Many2one("hs.code", string="Intrastat Code")
    intrastat_unit_id = fields.Many2one(
        "intrastat.unit",
        related="hs_code_id.intrastat_unit_id",
        string="Suppl. Unit",
        help="Intrastat Supplementary Unit",
    )
    weight = fields.Integer(help="Net weight in Kg")
    suppl_unit_qty = fields.Integer(
        string="Suppl. Unit Qty", help="Supplementary Units Quantity"
    )
    amount_company_currency = fields.Integer(
        string="Fiscal Value",
        help="Amount in company currency to write in the declaration. "
        "Amount in company currency = amount in invoice currency "
        "converted to company currency with the rate of the invoice date.",
    )
    transaction_id = fields.Many2one(
        "intrastat.transaction", string="Intrastat Transaction"
    )
    transaction_code = fields.Char(
        related="transaction_id.code", store=True, string="Transaction Code"
    )
    region_code = fields.Char()
    product_origin_country_code = fields.Char(
        string="Country of Origin of the Product",
        size=2,
        help="2 letters ISO code of the country of origin of the product"
        " except for the UK.\n"
        "Specify 'XI' for Northern Ireland and 'XU' for Great Britain.\n"
        "Specify 'QU' when the country is unknown.\n",
    )
    vat = fields.Char(string="VAT Number")
    # extended declaration
    incoterm_id = fields.Many2one("account.incoterms", string="Incoterm")
    transport_id = fields.Many2one("intrastat.transport_mode", string="Transport Mode")
