import logging

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

_log = logging.getLogger(__name__)


class MrpBomLine(models.Model):
    _inherit = "mrp.bom.line"

    product_id = fields.Many2one("product.product", "Component", required=False)
    product_backup_id = fields.Many2one(
        "product.product", help="Technical field to store previous value of product_id"
    )
    component_template_id = fields.Many2one(
        "product.template",
        "Component (product template)",
        domain="[('id', '!=', parent_product_tmpl_id)]",
    )
    match_on_attribute_ids = fields.Many2many(
        "product.attribute",
        string="Match on Attributes",
        compute="_compute_match_on_attribute_ids",
        store=True,
    )
    product_uom_category_id = fields.Many2one(
        "uom.category",
        related=None,
        compute="_compute_product_uom_category_id",
    )

    @api.constrains("component_template_id")
    def _check_component_template_recursion(self):
        for line in self:
            if line.component_template_id == line.parent_product_tmpl_id:
                raise ValidationError(
                    _(
                        "Component template must be different from BOM "
                        "product template. Please check BOM: %s BOM Line: %s"
                    )
                    % (line, line.bom_id)
                )

    @api.depends("product_id", "component_template_id")
    def _compute_product_uom_category_id(self):
        """Compute the product_uom_category_id field.

        This is the product category that will be allowed to use on the product_uom_id
        field, already covered by core module:
        https://github.com/odoo/odoo/blob/331b9435c/addons/mrp/models/mrp_bom.py#L372

        In core, though, this field is related to "product_id.uom_id.category_id".
        Here we make it computed to choose between component_template_id and
        product_id, depending on which one is set
        """
        # pylint: disable=missing-return
        # NOTE: To play nice with other modules trying to do the same:
        #   1) Set the field value as if it were a related field (core behaviour)
        #   2) Call super (if it's there)
        #   3) Update only the records we want
        for rec in self:
            rec.product_uom_category_id = rec.product_id.uom_id.category_id
        if hasattr(super(), "_compute_product_uom_category_id"):
            super()._compute_product_uom_category_id()
        for rec in self:
            if rec.component_template_id:
                rec.product_uom_category_id = (
                    rec.component_template_id.uom_id.category_id
                )

    @api.onchange("component_template_id")
    def _onchange_component_template_id(self):
        if self.component_template_id:
            if self.product_id:
                self.product_backup_id = self.product_id
                self.product_id = False
            if (
                self.product_uom_id.category_id
                != self.component_template_id.uom_id.category_id
            ):
                self.product_uom_id = self.component_template_id.uom_id
        else:
            if self.product_backup_id:
                self.product_id = self.product_backup_id
                self.product_backup_id = False
            if self.product_uom_id.category_id != self.product_id.uom_id.category_id:
                self.product_uom_id = self.product_id.uom_id

    @api.depends("component_template_id")
    def _compute_match_on_attribute_ids(self):
        for rec in self:
            if rec.component_template_id:
                rec.match_on_attribute_ids = (
                    rec.component_template_id.attribute_line_ids.attribute_id.filtered(
                        lambda x: x.create_variant != "no_variant"
                    )
                )
            else:
                rec.match_on_attribute_ids = False

    @api.constrains("component_template_id")
    def _check_component_attributes(self):
        for rec in self:
            if not rec.component_template_id:
                continue
            comp_attrs = (
                rec.component_template_id.valid_product_template_attribute_line_ids.attribute_id
            )
            prod_attrs = (
                rec.bom_id.product_tmpl_id.valid_product_template_attribute_line_ids.attribute_id
            )
            if not comp_attrs:
                raise ValidationError(
                    _(
                        "No match on attribute has been detected for Component "
                        "(Product Template) %s",
                        rec.component_template_id.display_name,
                    )
                )
            if not all(attr in prod_attrs for attr in comp_attrs):
                raise ValidationError(
                    _(
                        "Some attributes of the dynamic component are not included into "
                        "production product attributes."
                    )
                )

    @api.constrains("component_template_id", "bom_product_template_attribute_value_ids")
    def _check_variants_validity(self):
        for rec in self:
            if (
                not rec.bom_product_template_attribute_value_ids
                or not rec.component_template_id
            ):
                continue
            variant_attrs = rec.bom_product_template_attribute_value_ids.attribute_id
            same_attr_ids = set(rec.match_on_attribute_ids.ids) & set(variant_attrs.ids)
            same_attrs = self.env["product.attribute"].browse(same_attr_ids)
            if same_attrs:
                raise ValidationError(
                    _(
                        "You cannot use an attribute value for attribute(s) %(attributes)s "
                        "in the field “Apply on Variants” as it's the same attribute used "
                        "in the field “Match on Attribute” related to the component "
                        "%(component)s.",
                        attributes=", ".join(same_attrs.mapped("name")),
                        component=rec.component_template_id.name,
                    )
                )

    @api.onchange("match_on_attribute_ids")
    def _onchange_match_on_attribute_ids_check_component_attributes(self):
        if self.match_on_attribute_ids:
            self._check_component_attributes()

    @api.onchange("bom_product_template_attribute_value_ids")
    def _onchange_bom_product_template_attribute_value_ids_check_variants(self):
        if self.bom_product_template_attribute_value_ids:
            self._check_variants_validity()


class MrpBom(models.Model):
    _inherit = "mrp.bom"

    # flake8: noqa: C901
    def explode(self, product, quantity, picking_type=False):
        # Had to replace this method
        """
        Explodes the BoM and creates two lists with all the information you need:
        bom_done and line_done
        Quantity describes the number of times you need the BoM: so the quantity
        divided by the number created by the BoM
        and converted into its UoM
        """
        from collections import defaultdict

        graph = defaultdict(list)
        V = set()

        def check_cycle(v, visited, recStack, graph):
            visited[v] = True
            recStack[v] = True
            for neighbour in graph[v]:
                if visited[neighbour] is False:
                    if check_cycle(neighbour, visited, recStack, graph) is True:
                        return True
                elif recStack[neighbour] is True:
                    return True
            recStack[v] = False
            return False

        product_ids = set()
        product_boms = {}

        def update_product_boms():
            products = self.env["product.product"].browse(product_ids)
            product_boms.update(
                self._get_product2bom(
                    products,
                    bom_type="phantom",
                    picking_type=picking_type or self.picking_type_id,
                    company_id=self.company_id.id,
                )
            )
            # Set missing keys to default value
            for product in products:
                product_boms.setdefault(product, self.env["mrp.bom"])

        boms_done = [
            (
                self,
                {
                    "qty": quantity,
                    "product": product,
                    "original_qty": quantity,
                    "parent_line": False,
                },
            )
        ]
        lines_done = []
        V |= {product.product_tmpl_id.id}

        bom_lines = []
        for bom_line in self.bom_line_ids:
            product_id = bom_line.product_id
            V |= {product_id.product_tmpl_id.id}
            graph[product.product_tmpl_id.id].append(product_id.product_tmpl_id.id)
            bom_lines.append((bom_line, product, quantity, False))
            product_ids.add(product_id.id)
        update_product_boms()
        product_ids.clear()
        while bom_lines:
            current_line, current_product, current_qty, parent_line = bom_lines[0]
            bom_lines = bom_lines[1:]

            if current_line._skip_bom_line(current_product):
                continue

            line_quantity = current_qty * current_line.product_qty
            if current_line.product_id not in product_boms:
                update_product_boms()
                product_ids.clear()
            # upd start
            component_template_product = self._get_component_template_product(
                current_line, product, current_line.product_id
            )
            if component_template_product:
                # need to set product_id temporary
                current_line.product_id = component_template_product
            else:
                # component_template_id is set, but no attribute value match.
                continue
            # upd end
            bom = product_boms.get(current_line.product_id)
            if bom:
                converted_line_quantity = current_line.product_uom_id._compute_quantity(
                    line_quantity / bom.product_qty, bom.product_uom_id
                )
                bom_lines += [
                    (
                        line,
                        current_line.product_id,
                        converted_line_quantity,
                        current_line,
                    )
                    for line in bom.bom_line_ids
                ]
                for bom_line in bom.bom_line_ids:
                    graph[current_line.product_id.product_tmpl_id.id].append(
                        bom_line.product_id.product_tmpl_id.id
                    )
                    if bom_line.product_id.product_tmpl_id.id in V and check_cycle(
                        bom_line.product_id.product_tmpl_id.id,
                        {key: False for key in V},
                        {key: False for key in V},
                        graph,
                    ):
                        raise UserError(
                            _(
                                "Recursion error!  A product with a Bill of Material "
                                "should not have itself in its BoM or child BoMs!"
                            )
                        )
                    V |= {bom_line.product_id.product_tmpl_id.id}
                    if bom_line.product_id not in product_boms:
                        product_ids.add(bom_line.product_id.id)
                boms_done.append(
                    (
                        bom,
                        {
                            "qty": converted_line_quantity,
                            "product": current_product,
                            "original_qty": quantity,
                            "parent_line": current_line,
                        },
                    )
                )
            else:
                # We round up here because the user expects
                # that if he has to consume a little more, the whole UOM unit
                # should be consumed.
                rounding = current_line.product_uom_id.rounding
                line_quantity = float_round(
                    line_quantity, precision_rounding=rounding, rounding_method="UP"
                )
                lines_done.append(
                    (
                        current_line,
                        {
                            "qty": line_quantity,
                            "product": current_product,
                            "original_qty": quantity,
                            "parent_line": parent_line,
                        },
                    )
                )
        return boms_done, lines_done

    def _get_component_template_product(
        self, bom_line, bom_product_id, line_product_id
    ):
        if bom_line.component_template_id:
            comp = bom_line.component_template_id
            comp_attr_ids = (
                comp.valid_product_template_attribute_line_ids.attribute_id.ids
            )
            prod_attr_ids = (
                bom_product_id.valid_product_template_attribute_line_ids.attribute_id.ids
            )
            # check attributes
            if not all(item in prod_attr_ids for item in comp_attr_ids):
                _log.info(
                    "Component skipped. Component attributes must be included into "
                    "product attributes to use component_template_id."
                )
                return False
            # find matching combination
            combination = self.env["product.template.attribute.value"]
            for ptav in bom_product_id.product_template_attribute_value_ids:
                combination |= self.env["product.template.attribute.value"].search(
                    [
                        ("product_tmpl_id", "=", comp.id),
                        ("attribute_id", "=", ptav.attribute_id.id),
                        (
                            "product_attribute_value_id",
                            "=",
                            ptav.product_attribute_value_id.id,
                        ),
                    ]
                )
            if len(combination) == 0:
                return False
            product_id = comp._get_variant_for_combination(combination)
            if product_id and product_id.active:
                return product_id
            return False
        else:
            return line_product_id

    @api.constrains("product_tmpl_id", "product_id")
    def _check_component_attributes(self):
        return self.bom_line_ids._check_component_attributes()

    @api.constrains("product_tmpl_id", "product_id")
    def _check_variants_validity(self):
        return self.bom_line_ids._check_variants_validity()
