# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.

from sql.functions import CharLength

from trytond.i18n import gettext
from trytond.model import DeactivableMixin, ModelSQL, ModelView, fields
from trytond.model.exceptions import RecursionError
from trytond.pool import Pool
from trytond.pyson import Bool, Eval, If
from trytond.tools import is_full_text, lstrip_wildcard
from trytond.wizard import Button, StateView, Wizard


class BOM(DeactivableMixin, ModelSQL, ModelView):
    __name__ = 'production.bom'

    name = fields.Char('Name', required=True, translate=True)
    code = fields.Char(
        "Code",
        states={
            'readonly': Eval('code_readonly', False),
            })
    code_readonly = fields.Function(
        fields.Boolean("Code Readonly"), 'get_code_readonly')
    phantom = fields.Boolean(
        "Phantom",
        help="If checked, the BoM can be used in another BoM.")
    phantom_unit = fields.Many2One(
        'product.uom', "Unit",
        states={
            'invisible': ~Eval('phantom', False),
            'required': Eval('phantom', False),
            },
        help="The Unit of Measure of the Phantom BoM")
    phantom_quantity = fields.Float(
        "Quantity", digits='phantom_unit',
        domain=['OR',
            ('phantom_quantity', '>=', 0),
            ('phantom_quantity', '=', None),
            ],
        states={
            'invisible': ~Eval('phantom', False),
            'required': Eval('phantom', False),
            },
        help="The quantity of the Phantom BoM")
    inputs = fields.One2Many(
        'production.bom.input', 'bom', "Input Materials",
        domain=[If(Eval('phantom') & Eval('outputs', None),
                ('id', '=', None),
                ()),
            ],
        states={
            'invisible': Eval('phantom') & Bool(Eval('outputs')),
            })
    input_products = fields.Many2Many(
        'production.bom.input', 'bom', 'product', "Input Products")
    outputs = fields.One2Many(
        'production.bom.output', 'bom', "Output Materials",
        domain=[If(Eval('phantom') & Eval('inputs', None),
                ('id', '=', None),
                ()),
            ],
        states={
            'invisible': Eval('phantom') & Bool(Eval('inputs')),
            })
    output_products = fields.Many2Many('production.bom.output',
        'bom', 'product', 'Output Products')

    @classmethod
    def order_code(cls, tables):
        table, _ = tables[None]
        if cls.default_code_readonly():
            return [CharLength(table.code), table.code]
        else:
            return [table.code]

    @classmethod
    def default_code_readonly(cls):
        pool = Pool()
        Configuration = pool.get('production.configuration')
        config = Configuration(1)
        return bool(config.bom_sequence)

    def get_code_readonly(self, name):
        return self.default_code_readonly()

    @classmethod
    def order_rec_name(cls, tables):
        table, _ = tables[None]
        return cls.order_code(tables) + [table.name]

    def get_rec_name(self, name):
        if self.code:
            return '[' + self.code + '] ' + self.name
        else:
            return self.name

    @classmethod
    def search_rec_name(cls, name, clause):
        _, operator, operand, *extra = clause
        if operator.startswith('!') or operator.startswith('not '):
            bool_op = 'AND'
        else:
            bool_op = 'OR'
        code_value = operand
        if operator.endswith('like') and is_full_text(operand):
            code_value = lstrip_wildcard(operand)
        return [bool_op,
            ('name', operator, operand, *extra),
            ('code', operator, code_value, *extra),
            ]

    def compute_factor(self, product, quantity, unit, type='outputs'):
        pool = Pool()
        Uom = pool.get('product.uom')
        assert type in {'inputs', 'outputs'}, f"Invalid {type}"
        total = 0
        if self.phantom:
            total = Uom.compute_qty(
                self.phantom_unit, self.phantom_quantity, unit, round=False)
        else:
            for line in getattr(self, type):
                if line.product == product:
                    total += Uom.compute_qty(
                        line.unit, line.quantity, unit, round=False)
        if total:
            return quantity / total
        else:
            return 0

    @classmethod
    def _code_sequence(cls):
        pool = Pool()
        Configuration = pool.get('production.configuration')
        config = Configuration(1)
        return config.bom_sequence

    @classmethod
    def preprocess_values(cls, mode, values):
        values = super().preprocess_values(mode, values)
        if mode == 'create' and not values.get('code'):
            if sequence := cls._code_sequence():
                values['code'] = sequence.get()
        return values

    @classmethod
    def copy(cls, records, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('code', None)
        default.setdefault('input_products', None)
        default.setdefault('output_products', None)
        return super().copy(records, default=default)


class BOMInput(ModelSQL, ModelView):
    __name__ = 'production.bom.input'

    bom = fields.Many2One(
        'production.bom', "BOM", required=True, ondelete='CASCADE')
    product = fields.Many2One(
        'product.product', "Product",
        domain=[If(Eval('phantom_bom', None),
                ('id', '=', None),
                ()),
            ],
        states={
            'invisible': Bool(Eval('phantom_bom')),
            'required': ~Bool(Eval('phantom_bom')),
            })
    phantom_bom = fields.Many2One(
        'production.bom', "Phantom BOM",
        states={
            'invisible': Bool(Eval('product')),
            'required': ~Bool(Eval('product')),
            })
    uom_category = fields.Function(fields.Many2One(
        'product.uom.category', 'Uom Category'), 'on_change_with_uom_category')
    unit = fields.Many2One(
        'product.uom', "Unit", required=True,
        domain=[
            ('category', '=', Eval('uom_category', -1)),
            ])
    quantity = fields.Float(
        "Quantity", digits='unit', required=True,
        domain=['OR',
            ('quantity', '>=', 0),
            ('quantity', '=', None),
            ])

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.phantom_bom.domain = [
            If(Eval('product', None),
                ('id', '=', None),
                ()),
            ('phantom', '=', True),
            ('inputs', '!=', None),
            ]
        cls.product.domain = [('type', 'in', cls.get_product_types())]
        cls.__access__.add('bom')

    @classmethod
    def __register__(cls, module):
        table_h = cls.__table_handler__(module)

        # Migration from 6.8: rename uom to unit
        if (table_h.column_exist('uom')
                and not table_h.column_exist('unit')):
            table_h.column_rename('uom', 'unit')

        super().__register__(module)
        table_h = cls.__table_handler__(module)

        # Migration from 6.0: remove unique constraint
        table_h.drop_constraint('product_bom_uniq')
        # Migration from 7.6: remove required on product
        table_h.not_null_action('product', 'remove')

    @classmethod
    def get_product_types(cls):
        return ['goods', 'assets']

    @fields.depends('phantom_bom', 'unit')
    def on_change_phantom_bom(self):
        if self.phantom_bom:
            category = self.phantom_bom.phantom_unit.category
            if not self.unit or self.unit.category != category:
                self.unit = self.phantom_bom.phantom_unit
        else:
            self.unit = None

    @fields.depends('product', 'unit')
    def on_change_product(self):
        if self.product:
            category = self.product.default_uom.category
            if not self.unit or self.unit.category != category:
                self.unit = self.product.default_uom
        else:
            self.unit = None

    @fields.depends('product', 'phantom_bom')
    def on_change_with_uom_category(self, name=None):
        uom_category = None
        if self.product:
            uom_category = self.product.default_uom.category
        elif self.phantom_bom:
            uom_category = self.phantom_bom.phantom_unit.category
        return uom_category

    def get_rec_name(self, name):
        if self.product:
            return self.product.rec_name
        elif self.phantom_bom:
            return self.phantom_bom.rec_name

    @classmethod
    def search_rec_name(cls, name, clause):
        _, operator, value = clause
        if operator.startswith('!') or operator.startswith('not '):
            bool_op = 'AND'
        else:
            bool_op = 'OR'

        return [bool_op,
            ('product.rec_name', operator, value),
            ('phantom_bom.rec_name', operator, value),
            ]

    @classmethod
    def validate(cls, boms):
        super().validate(boms)
        for bom in boms:
            bom.check_bom_recursion()

    def check_bom_recursion(self, bom=None):
        '''
        Check BOM recursion
        '''
        if bom is None:
            bom = self.bom
        if self.product:
            self.product.check_bom_recursion()
        else:
            for line_ in self._phantom_lines:
                if line_.phantom_bom and (line_.phantom_bom == bom
                        or line_.check_bom_recursion(bom=bom)):
                    raise RecursionError(gettext(
                            'production.msg_recursive_bom_bom',
                            bom=bom.rec_name))

    def compute_quantity(self, factor):
        return self.unit.ceil(self.quantity * factor)

    def prepare_move(self, production, move):
        "Update stock move for the production"
        return move

    @property
    def _phantom_lines(self):
        if self.phantom_bom:
            return self.phantom_bom.inputs

    def lines_for_quantity(self, quantity):
        if self.phantom_bom:
            factor = self.phantom_bom.compute_factor(
                None, quantity, self.unit)
            for line in self._phantom_lines:
                yield line, line.compute_quantity(factor)
        else:
            yield self, quantity


class BOMOutput(BOMInput):
    __name__ = 'production.bom.output'
    __string__ = None
    _table = None  # Needed to override BOMInput._table

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.phantom_bom.domain = [
            If(Eval('product', None),
                ('id', '=', None),
                ()),
            ('phantom', '=', True),
            ('outputs', '!=', None),
            ]

    def compute_quantity(self, factor):
        return self.unit.floor(self.quantity * factor)

    @property
    def _phantom_lines(self):
        if self.phantom_bom:
            return self.phantom_bom.outputs

    @classmethod
    def on_delete(cls, outputs):
        pool = Pool()
        ProductBOM = pool.get('product.product-production.bom')

        callback = super().on_delete(outputs)

        bom_products = [b for o in outputs for b in o.product.boms]
        # Validate that output_products domain on bom is still valid
        callback.append(lambda: ProductBOM._validate(bom_products, ['bom']))
        return callback

    @classmethod
    def on_write(cls, outputs, values):
        pool = Pool()
        ProductBOM = pool.get('product.product-production.bom')

        callback = super().on_write(outputs, values)

        bom_products = [b for o in outputs for b in o.product.boms]
        # Validate that output_products domain on bom is still valid
        callback.append(lambda: ProductBOM._validate(bom_products, ['bom']))
        return callback


class BOMTree(ModelView):
    __name__ = 'production.bom.tree'

    product = fields.Many2One('product.product', 'Product')
    quantity = fields.Float('Quantity', digits='unit')
    unit = fields.Many2One('product.uom', "Unit")
    childs = fields.One2Many('production.bom.tree', None, 'Childs')

    @classmethod
    def tree(cls, product, quantity, unit, bom=None):
        Input = Pool().get('production.bom.input')

        result = []
        if bom is None:
            pbom = product.get_bom()
            if pbom is None:
                return result
            bom = pbom.bom

        factor = bom.compute_factor(product, quantity, unit)
        for input_ in bom.inputs:
            quantity = Input.compute_quantity(input_, factor)
            childs = cls.tree(input_.product, quantity, input_.unit)
            values = {
                'product': input_.product.id,
                'product.': {
                    'rec_name': input_.product.rec_name,
                    },
                'quantity': quantity,
                'unit': input_.unit.id,
                'unit.': {
                    'rec_name': input_.unit.rec_name,
                    },
                'childs': childs,
            }
            result.append(values)
        return result


class OpenBOMTreeStart(ModelView):
    __name__ = 'production.bom.tree.open.start'

    quantity = fields.Float('Quantity', digits='unit', required=True)
    unit = fields.Many2One(
        'product.uom', "Unit", required=True,
        domain=[
            ('category', '=', Eval('category', -1)),
            ])
    category = fields.Many2One('product.uom.category', 'Category',
        readonly=True)
    bom = fields.Many2One('product.product-production.bom',
        'BOM', required=True, domain=[
            ('product', '=', Eval('product', -1)),
            ])
    product = fields.Many2One('product.product', 'Product', readonly=True)


class OpenBOMTreeTree(ModelView):
    __name__ = 'production.bom.tree.open.tree'

    bom_tree = fields.One2Many('production.bom.tree', None, 'BOM Tree',
        readonly=True)

    @classmethod
    def tree(cls, bom, product, quantity, unit):
        pool = Pool()
        Tree = pool.get('production.bom.tree')

        childs = Tree.tree(product, quantity, unit, bom=bom)
        bom_tree = [{
                'product': product.id,
                'product.': {
                    'rec_name': product.rec_name,
                    },
                'quantity': quantity,
                'unit': unit.id,
                'unit.': {
                    'rec_name': unit.rec_name,
                    },
                'childs': childs,
                }]
        return {
            'bom_tree': bom_tree,
            }


class OpenBOMTree(Wizard):
    __name__ = 'production.bom.tree.open'
    _readonly = True

    start = StateView('production.bom.tree.open.start',
        'production.bom_tree_open_start_view_form', [
            Button('Cancel', 'end', 'tryton-cancel'),
            Button('OK', 'tree', 'tryton-ok', True),
            ])
    tree = StateView('production.bom.tree.open.tree',
        'production.bom_tree_open_tree_view_form', [
            Button('Change', 'start', 'tryton-back'),
            Button('Close', 'end', 'tryton-close'),
            ])

    def default_start(self, fields):
        defaults = {}
        product = self.record
        defaults['category'] = product.default_uom.category.id
        if self.start.unit:
            defaults['unit'] = self.start.unit.id
        else:
            defaults['unit'] = product.default_uom.id
        defaults['product'] = product.id
        if self.start.bom:
            defaults['bom'] = self.start.bom.id
        else:
            bom = product.get_bom()
            if bom:
                defaults['bom'] = bom.id
        defaults['quantity'] = self.start.quantity
        return defaults

    def default_tree(self, fields):
        pool = Pool()
        BomTree = pool.get('production.bom.tree.open.tree')
        return BomTree.tree(self.start.bom.bom, self.start.product,
            self.start.quantity, self.start.unit)
