# Part of Odoo. See LICENSE file for full copyright and licensing details.

from __future__ import annotations

from datetime import timedelta
from typing import TYPE_CHECKING, Literal

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

if TYPE_CHECKING:
    from odoo.orm.types import Self
    from odoo.tools.float_utils import RoundingMethod


class UomUom(models.Model):
    _name = 'uom.uom'
    _description = 'Product Unit of Measure'
    _parent_name = 'relative_uom_id'
    _parent_store = True
    _order = 'sequence, relative_uom_id, id'

    def _unprotected_uom_xml_ids(self):
        """ Return a list of UoM XML IDs that are not protected by default.
        Note: Some of these may be protected via overrides in other modules.
        """
        return [
            "product_uom_hour",
            "product_uom_dozen",
            "product_uom_pack_6",
        ]

    name = fields.Char('Unit Name', required=True, translate=True)
    sequence = fields.Integer(compute="_compute_sequence", store=True, readonly=False, precompute=True)
    relative_factor = fields.Float(
        'Contains', default=1.0, digits=0, required=True,  # force NUMERIC with unlimited precision
        help='How much bigger or smaller this unit is compared to the reference UoM for this unit')
    rounding = fields.Float('Rounding Precision', compute="_compute_rounding")
    active = fields.Boolean('Active', default=True, help="Uncheck the active field to disable a unit of measure without deleting it.")
    relative_uom_id = fields.Many2one('uom.uom', 'Reference Unit', ondelete='cascade', index='btree_not_null')
    related_uom_ids = fields.One2many('uom.uom', 'relative_uom_id', 'Related UoMs')
    factor = fields.Float('Absolute Quantity', digits=0, compute='_compute_factor', recursive=True, store=True)
    parent_path = fields.Char(index=True)

    _factor_gt_zero = models.Constraint(
        'CHECK (relative_factor!=0)',
        'The conversion ratio for a unit of measure cannot be 0!',
    )

    # === COMPUTE METHODS === #

    @api.depends('relative_factor')
    def _compute_sequence(self):
        for uom in self:
            if uom.id and uom.sequence:
                # Only set a default sequence before the record creation, or on module update if
                # there is no value.
                continue
            uom.sequence = min(int(uom.relative_factor * 100.0), 1000)

    def _compute_rounding(self):
        """ All Units of Measure share the same rounding precision defined in 'Product Unit'.
            Set in a compute to ensure compatibility with previous calls to `uom.rounding`.
        """
        decimal_precision = self.env['decimal.precision'].precision_get('Product Unit')
        self.rounding = 10 ** -decimal_precision

    @api.depends('relative_factor', 'relative_uom_id', 'relative_uom_id.factor')
    def _compute_factor(self):
        for uom in self:
            if uom.relative_uom_id:
                uom.factor = uom.relative_factor * uom.relative_uom_id.factor
            else:
                uom.factor = uom.relative_factor

    # === ONCHANGE METHODS === #

    @api.onchange('relative_factor')
    def _onchange_critical_fields(self):
        if self._filter_protected_uoms() and self.create_date < (fields.Datetime.now() - timedelta(days=1)):
            return {
                'warning': {
                    'title': _("Warning for %s", self.name),
                    'message': _(
                        "Some critical fields have been modified on %s.\n"
                        "Note that existing data WON'T be updated by this change.\n\n"
                        "As units of measure impact the whole system, this may cause critical issues.\n"
                        "Therefore, changing core units of measure in a running database is not recommended.",
                        self.name,
                    )
                }
            }

    # === CONSTRAINT METHODS === #

    @api.constrains('relative_factor', 'relative_uom_id')
    def _check_factor(self):
        for uom in self:
            if not uom.relative_uom_id and uom.relative_factor != 1.0:
                raise UserError(_("Reference unit of measure is missing."))

    # === CRUD METHODS === #

    @api.ondelete(at_uninstall=False)
    def _unlink_except_master_data(self):
        locked_uoms = self._filter_protected_uoms()
        if locked_uoms:
            raise UserError(_(
                "The following units of measure are used by the system and cannot be deleted: %s\nYou can archive them instead.",
                ", ".join(locked_uoms.mapped('name')),
            ))

    # === BUSINESS METHODS === #

    def round(self, value: float, rounding_method: RoundingMethod = 'HALF-UP') -> float:
        """Round the value using the 'Product Unit' precision"""
        self.ensure_one()
        digits = self.env['decimal.precision'].precision_get('Product Unit')
        return tools.float_round(value, precision_digits=digits, rounding_method=rounding_method)

    def compare(self, value1: float, value2: float) -> Literal[-1, 0, 1]:
        """Compare two measures after rounding them with the 'Product Unit' precision

        :param value1: origin value to compare
        :param value2: value to compare to
        :return: -1, 0 or 1, if ``value1`` is lower than, equal to, or greater than ``value2``.
        """
        self.ensure_one()
        digits = self.env['decimal.precision'].precision_get('Product Unit')
        return tools.float_compare(value1, value2, precision_digits=digits)

    def is_zero(self, value: float) -> bool:
        """Check if the value is zero after rounding with the 'Product Unit' precision"""
        self.ensure_one()
        digits = self.env['decimal.precision'].precision_get('Product Unit')
        return tools.float_is_zero(value, precision_digits=digits)

    @api.depends('name', 'relative_factor', 'relative_uom_id')
    @api.depends_context('formatted_display_name')
    def _compute_display_name(self):
        super()._compute_display_name()
        for uom in self:
            if uom.env.context.get('formatted_display_name') and uom.relative_uom_id:
                uom.display_name = f"{uom.name}\t--{uom.relative_factor} {uom.relative_uom_id.name}--"

    def _compute_quantity(
        self,
        qty: float,
        to_unit: Self,
        round: bool = True,
        rounding_method: RoundingMethod = 'UP',
        raise_if_failure: bool = True,
    ) -> float:
        """ Convert the given quantity from the current UoM `self` into a given one
            :param qty: the quantity to convert
            :param to_unit: the destination UomUom record (uom.uom)
            :param raise_if_failure: only if the conversion is not possible
                - if true, raise an exception if the conversion is not possible (different UomUom category),
                - otherwise, return the initial quantity
        """
        if not self or not qty:
            return qty
        self.ensure_one()

        if self == to_unit:
            amount = qty
        else:
            amount = qty * self.factor
            if to_unit:
                amount = amount / to_unit.factor

        if to_unit and round:
            amount = tools.float_round(amount, precision_rounding=to_unit.rounding, rounding_method=rounding_method)

        return amount

    def _check_qty(self, product_qty, uom_id, rounding_method="HALF-UP"):
        """Check if product_qty in given uom is a multiple of the packaging qty.
        If not, rounding the product_qty to closest multiple of the packaging qty
        according to the rounding_method "UP", "HALF-UP or "DOWN".
        """
        self.ensure_one()
        packaging_qty = self._compute_quantity(1, uom_id)
        if self == uom_id:
            return product_qty
        # We do not use the modulo operator to check if qty is a mltiple of q. Indeed the quantity
        # per package might be a float, leading to incorrect results. For example:
        # 8 % 1.6 = 1.5999999999999996
        # 5.4 % 1.8 = 2.220446049250313e-16
        if product_qty and packaging_qty:
            product_qty = float_round(product_qty / packaging_qty, precision_rounding=1.0,
                                  rounding_method=rounding_method) * packaging_qty
        return product_qty

    def _compute_price(self, price: float, to_unit: Self) -> float:
        self.ensure_one()
        if not self or not price or not to_unit or self == to_unit:
            return price
        amount = price * to_unit.factor
        if to_unit:
            amount = amount / self.factor
        return amount

    def _filter_protected_uoms(self):
        """Verifies self does not contain protected uoms."""
        linked_model_data = self.env['ir.model.data'].sudo().search([
            ('model', '=', self._name),
            ('res_id', 'in', self.ids),
            ('module', '=', 'uom'),
            ('name', 'not in', self._unprotected_uom_xml_ids()),
        ])
        if not linked_model_data:
            return self.browse()
        else:
            return self.browse(set(linked_model_data.mapped('res_id')))

    def _has_common_reference(self, other_uom: Self) -> bool:
        """ Check if `self` and `other_uom` have a common reference unit """
        self.ensure_one()
        other_uom.ensure_one()
        self_path = self.parent_path.split('/')
        other_path = other_uom.parent_path.split('/')
        common_path = []
        for self_parent, other_parent in zip(self_path, other_path):
            if self_parent == other_parent:
                common_path.append(self_parent)
            else:
                break
        return bool(common_path)
