# Copyright 2020 Camptocamp SA
# @author: Simone Orsi <simone.orsi@camptocamp.com>
# License LGPL-3.0 or later (https://www.gnu.org/licenses/lgpl)

import unicodedata
from collections import namedtuple

from odoo import api, models
from odoo.tools import float_compare

from odoo.addons.base_sparse_field.models.fields import Serialized

# Unify records as we mix up w/ UoM
Packaging = namedtuple("Packaging", "id name qty barcode is_unit")

NO_BREAK_SPACE_CHAR = unicodedata.lookup("NO-BREAK SPACE")


class Product(models.Model):
    _inherit = "product.product"

    packaging_contained_mapping = Serialized(
        compute="_compute_packaging_contained_mapping",
        help="Technical field to store contained packaging. ",
    )

    @api.depends_context("lang")
    @api.depends("packaging_ids.qty")
    def _compute_packaging_contained_mapping(self):
        for rec in self:
            rec.packaging_contained_mapping = rec._packaging_contained_mapping()

    def _packaging_contained_mapping(self):
        """Produce a mapping of packaging and contained packagings.

        Used mainly for `product_qty_by_packaging` but can be used
        to display info as you prefer.

        :returns: a dictionary in the form {pkg.id: [contained packages]}
        """
        res = {}
        packaging = self._ordered_packaging()
        for i, pkg in enumerate(packaging):
            if pkg.is_unit:
                # skip minimal unit
                continue
            res[pkg.id] = self._product_qty_by_packaging(packaging[i + 1 :], pkg.qty)
        return res

    def product_qty_by_packaging(self, prod_qty, with_contained=False):
        """Calculate quantity by packaging.

        The minimal quantity is always represented by the UoM of the product.

        Limitation: fractional quantities are lost.

        :prod_qty: total qty to satisfy.
        :with_contained: include calculation of contained packagings.

            eg: 1 pallet contains 4 big boxes and 6 little boxes.

        :returns: list of dict in the form

            [{id: 1, qty: qty_per_package, name: package_name}]

            If `with_contained` is passed, each element will include
            the quantity of smaller packaging, like:

            {contained: [{id: 1, qty: 4, name: "Big box"}]}
        """
        self.ensure_one()
        return self._product_qty_by_packaging(
            self._ordered_packaging(),
            prod_qty,
            with_contained=with_contained,
        )

    def _ordered_packaging(self):
        """Prepare packaging ordered by qty and exclude empty ones.

        Use ctx key `_packaging_filter` to pass a function to filter packaging
        to be considered.

        Use ctx key `_packaging_name_getter` to pass a function to change
        the display name of the packaging.
        """
        custom_filter = self.env.context.get("_packaging_filter", lambda x: x)
        name_getter = self.env.context.get(
            "_packaging_name_getter", self._packaging_name_getter
        )
        packagings = sorted(
            (
                Packaging(x.id, name_getter(x), x.qty, x.barcode, False)
                for x in self.packaging_ids.filtered(custom_filter)
                # Exclude the ones w/ zero qty as they are useless for the math
                if x.qty
            ),
            reverse=True,
            key=lambda x: x.qty,
        )
        # Add minimal unit
        packagings.append(
            # NOTE: the ID here could clash w/ one of the packaging's.
            # If you create a mapping based on IDs, keep this in mind.
            # You can use `is_unit` to check this.
            Packaging(self.uom_id.id, self.uom_id.name, self.uom_id.factor, None, True)
        )
        return packagings

    def _packaging_name_getter(self, packaging):
        return packaging.name

    def _product_qty_by_packaging(self, pkg_by_qty, qty, with_contained=False):
        """Produce a list of dictionaries of packaging info."""
        # TODO: refactor to handle fractional quantities (eg: 0.5 Kg)
        res = []
        prepare_values = self.env.context.get(
            "_packaging_values_handler", self._prepare_qty_by_packaging_values
        )
        for pkg in pkg_by_qty:
            qty_per_pkg, qty = self._qty_by_pkg(pkg.qty, qty)
            if qty_per_pkg:
                value = prepare_values(pkg, qty_per_pkg)
                if with_contained:
                    contained = None
                    if not pkg.is_unit:
                        mapping = self.packaging_contained_mapping
                        # integer keys are serialized as strings :/
                        contained = mapping.get(str(pkg.id))
                    value["contained"] = contained
                res.append(value)
            if not qty:
                break
        return res

    def _qty_by_pkg(self, pkg_qty, qty):
        """Calculate qty needed for given package qty."""
        qty_per_pkg = 0
        while (
            float_compare(qty - pkg_qty, 0.0, precision_digits=self.uom_id.rounding)
            >= 0.0
        ):
            qty -= pkg_qty
            qty_per_pkg += 1
        return qty_per_pkg, qty

    def _prepare_qty_by_packaging_values(self, packaging, qty_per_pkg):
        return {
            "id": packaging.id,
            "qty": qty_per_pkg,
            "name": packaging.name,
            "is_unit": packaging.is_unit,
            "barcode": packaging.barcode,
        }

    def product_qty_by_packaging_as_str(
        self, prod_qty, include_total_units=False, only_packaging=False
    ):
        """Return a string representing the qty of each packaging.

        :param prod_qty: the qty of current product to translate to pkg qty
        :param include_total_units: includes total qty required initially
        :param only_packaging: exclude units if you have only units.
            IOW: if the qty does not match any packaging and this flag is true
            you'll get an empty string instead of `N units`.
        """
        self.ensure_one()
        if not prod_qty:
            return ""

        qty_by_packaging = self.product_qty_by_packaging(prod_qty)
        if not qty_by_packaging:
            return ""

        # Exclude unit qty and reuse it later
        unit_qty = None
        has_only_units = True
        _qty_by_packaging = []
        for pkg_qty in qty_by_packaging:
            if pkg_qty["is_unit"]:
                unit_qty = pkg_qty["qty"]
                continue
            has_only_units = False
            _qty_by_packaging.append(pkg_qty)
        # Browse them all at once
        records = self.env["product.packaging"].browse(
            [x["id"] for x in _qty_by_packaging]
        )
        _qty_by_packaging_as_str = self.env.context.get(
            "_qty_by_packaging_as_str", self._qty_by_packaging_as_str
        )
        # Collect all strings representations
        as_string = []
        for record, info in zip(records, _qty_by_packaging):
            bit = _qty_by_packaging_as_str(record, info["qty"])
            if bit:
                as_string.append(bit)
        # Restore unit information if any.
        include_units = (has_only_units and not only_packaging) or not has_only_units
        if unit_qty and include_units:
            as_string.append(f"{unit_qty} {self.uom_id.name}")
        # We want to avoid line break here as this string
        # can be used by reports
        res = f",{NO_BREAK_SPACE_CHAR}".join(as_string)
        if include_total_units and not has_only_units:
            res += " " + self._qty_by_packaging_total_units(prod_qty)
        return res

    def _qty_by_packaging_as_str(self, packaging, qty):
        return f"{qty} {packaging.name}"

    def _qty_by_packaging_total_units(self, prod_qty):
        return f"({prod_qty} {self.uom_id.name})"
