# 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, Workflow, fields, tree)
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id, If
from trytond.transaction import Transaction
from trytond.wizard import (
    Button, StateAction, StateTransition, StateView, Wizard)

from .exceptions import PackageError, PackageValidationError


class Configuration(metaclass=PoolMeta):
    __name__ = 'stock.configuration'
    package_sequence = fields.MultiValue(fields.Many2One(
            'ir.sequence', "Package Sequence", required=True,
            domain=[
                ('company', 'in', [
                        Eval('context', {}).get('company', -1), None]),
                ('sequence_type', '=',
                    Id('stock_package', 'sequence_type_package')),
                ]))

    @classmethod
    def multivalue_model(cls, field):
        pool = Pool()
        if field == 'package_sequence':
            return pool.get('stock.configuration.sequence')
        return super().multivalue_model(field)

    @classmethod
    def default_package_sequence(cls, **pattern):
        return cls.multivalue_model(
            'package_sequence').default_package_sequence()


class ConfigurationSequence(metaclass=PoolMeta):
    __name__ = 'stock.configuration.sequence'
    package_sequence = fields.Many2One(
        'ir.sequence', "Package Sequence", required=True,
        domain=[
            ('company', 'in', [Eval('company', -1), None]),
            ('sequence_type', '=',
                Id('stock_package', 'sequence_type_package')),
            ])

    @classmethod
    def default_package_sequence(cls):
        pool = Pool()
        ModelData = pool.get('ir.model.data')
        try:
            return ModelData.get_id('stock_package', 'sequence_package')
        except KeyError:
            return None


class MeasurementsMixin:
    __slots__ = ()

    length = fields.Float(
        "Length", digits='length_uom',
        help="The length of the package.")
    length_uom = fields.Many2One(
        'product.uom', "Length UoM",
        domain=[('category', '=', Id('product', 'uom_cat_length'))],
        states={
            'required': Bool(Eval('length')),
            },
        help="The Unit of Measure for the package length.")
    height = fields.Float(
        "Height", digits='height_uom',
        help="The height of the package.")
    height_uom = fields.Many2One(
        'product.uom', "Height UoM",
        domain=[('category', '=', Id('product', 'uom_cat_length'))],
        states={
            'required': Bool(Eval('height')),
            },
        help="The Unit of Measure for the package height.")
    width = fields.Float(
        "Width", digits='width_uom',
        help="The width of the package.")
    width_uom = fields.Many2One(
        'product.uom', "Width UoM",
        domain=[('category', '=', Id('product', 'uom_cat_length'))],
        states={
            'required': Bool(Eval('width')),
            },
        help="The Unit of Measure for the package width.")

    packaging_volume = fields.Float(
        "Packaging Volume", digits='packaging_volume_uom',
        states={
            'readonly': (
                Bool(Eval('length'))
                & Bool(Eval('height'))
                & Bool(Eval('width'))),
            },
        help="The volume of the package.")
    packaging_volume_uom = fields.Many2One(
        'product.uom', "Packaging Volume UoM",
        domain=[('category', '=', Id('product', 'uom_cat_volume'))],
        states={
            'required': Bool(Eval('packaging_volume')),
            },
        help="The Unit of Measure for the packaging volume.")

    packaging_weight = fields.Float(
        "Packaging Weight", digits='packaging_weight_uom',
        help="The weight of the package when empty.")
    packaging_weight_uom = fields.Many2One(
        'product.uom', "Packaging Weight UoM",
        domain=[('category', '=', Id('product', 'uom_cat_weight'))],
        states={
            'required': Bool(Eval('packaging_weight')),
            },
        help="The Unit of Measure for the packaging weight.")

    @fields.depends(
        'packaging_volume', 'packaging_volume_uom',
        'length', 'length_uom',
        'height', 'height_uom',
        'width', 'width_uom')
    def on_change_with_packaging_volume(self):
        pool = Pool()
        ModelData = pool.get('ir.model.data')
        Uom = pool.get('product.uom')

        if not all([self.packaging_volume_uom,
                    self.length, self.length_uom,
                    self.height, self.height_uom,
                    self.width, self.width_uom]):
            if all([
                        self.length,
                        self.height,
                        self.width]):
                return
            return self.packaging_volume

        meter = Uom(ModelData.get_id('product', 'uom_meter'))
        cubic_meter = Uom(ModelData.get_id('product', 'uom_cubic_meter'))

        length = Uom.compute_qty(
            self.length_uom, self.length, meter, round=False)
        height = Uom.compute_qty(
            self.height_uom, self.height, meter, round=False)
        width = Uom.compute_qty(
            self.width_uom, self.width, meter, round=False)

        return Uom.compute_qty(
            cubic_meter, length * height * width, self.packaging_volume_uom)


class Package(tree(), MeasurementsMixin, ModelSQL, ModelView):
    __name__ = 'stock.package'
    _rec_name = 'number'
    number = fields.Char("Number", readonly=True, required=True)
    company = fields.Many2One('company.company', "Company", required=True)
    type = fields.Many2One(
        'stock.package.type', "Type", required=True,
        states={
            'readonly': Eval('state') == 'closed',
            })
    shipment = fields.Reference(
        "Shipment", selection='get_shipment',
        states={
            'readonly': Eval('state') == 'closed',
            },
        domain={
            'stock.shipment.out': [
                ('company', '=', Eval('company', -1)),
                ],
            'stock.shipment.in.return': [
                ('company', '=', Eval('company', -1)),
                ],
            })
    moves = fields.One2Many('stock.move', 'package', 'Moves',
        domain=[
            ('id', 'in', Eval('allowed_moves', [])),
            ],
        filter=[
            ('quantity', '!=', 0),
            ],
        add_remove=[
            ('package', '=', None),
            ],
        states={
            'readonly': Eval('state') == 'closed',
            })
    allowed_moves = fields.Function(
        fields.Many2Many('stock.move', None, None, "Allowed Moves"),
        'on_change_with_allowed_moves')
    parent = fields.Many2One(
        'stock.package', "Parent", ondelete='CASCADE',
        domain=[
            ('company', '=', Eval('company', -1)),
            ('shipment', '=', Eval('shipment')),
            ],
        states={
            'readonly': Eval('state') == 'closed',
            },
        help="The package that contains this package.")
    children = fields.One2Many(
        'stock.package', 'parent', 'Children',
        domain=[
            ('company', '=', Eval('company', -1)),
            ('shipment', '=', Eval('shipment')),
            ],
        add_remove=[
            ('parent', '=', None),
            ],
        states={
            'readonly': Eval('state') == 'closed',
            },
        help="The packages contained in this package.")
    state = fields.Function(fields.Selection([
                ('open', "Open"),
                ('closed', "Closed"),
                ], "State"), 'on_change_with_state')

    @classmethod
    def __setup__(cls):
        cls.number.search_unaccented = False
        super().__setup__()
        for field in [
                cls.length, cls.length_uom,
                cls.height, cls.height_uom,
                cls.width, cls.width_uom,
                cls.packaging_volume, cls.packaging_volume_uom,
                cls.packaging_weight, cls.packaging_weight_uom,
                ]:
            field.states = {
                'readonly': Eval('state') == 'closed',
                }
        cls._buttons.update(
            fill={
                'invisible': Eval('state') != 'open',
                'icon': 'tryton-launch',
                'depends': ['state'],
                })

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

        # Migration from 6.8: rename code to number
        if table_h.column_exist('code'):
            table_h.column_rename('code', 'number')

        super().__register__(module)

    @classmethod
    def order_number(cls, tables):
        table, _ = tables[None]
        return [CharLength(table.number), table.number]

    @classmethod
    def default_company(cls):
        return Transaction().context.get('company')

    @staticmethod
    def _get_shipment():
        'Return list of Model names for shipment Reference'
        return [
            'stock.shipment.out',
            'stock.shipment.in.return',
            'stock.shipment.internal',
            ]

    @classmethod
    def get_shipment(cls):
        pool = Pool()
        Model = pool.get('ir.model')
        get_name = Model.get_name
        models = cls._get_shipment()
        return [(None, '')] + [(m, get_name(m)) for m in models]

    @fields.depends('shipment')
    def on_change_with_allowed_moves(self, name=None):
        if self.shipment:
            return self.shipment.packages_moves

    @fields.depends('shipment')
    def on_change_with_state(self, name=None):
        if (self.shipment
                and self.shipment.state in {
                    'packed', 'shipped', 'done', 'cancelled'}):
            return 'closed'
        return 'open'

    @fields.depends('type')
    def on_change_type(self):
        if self.type:
            for name in dir(MeasurementsMixin):
                if isinstance(getattr(MeasurementsMixin, name), fields.Field):
                    setattr(self, name, getattr(self.type, name))

    @classmethod
    @ModelView.button_action('stock_package.wizard_package_pack')
    def fill(cls, packages):
        pass

    @classmethod
    def validate(cls, packages):
        super().validate(packages)
        for package in packages:
            package.check_volume()

    def check_volume(self):
        pool = Pool()
        Uom = pool.get('product.uom')
        Lang = pool.get('ir.lang')
        lang = Lang.get()
        if not self.packaging_volume:
            return
        children_volume = 0
        for child in self.children:
            if child.packaging_volume:
                children_volume += Uom.compute_qty(
                    child.packaging_volume_uom, child.packaging_volume,
                    self.packaging_volume_uom, round=False)
        if self.packaging_volume < children_volume:
            raise PackageValidationError(
                gettext('stock_package.msg_package_volume_too_small',
                    package=self.rec_name,
                    volume=lang.format_number_symbol(
                        self.packaging_volume, self.packaging_volume_uom),
                    children_volume=lang.format_number_symbol(
                        children_volume, self.packaging_volume_uom)))

    @classmethod
    def preprocess_values(cls, mode, values):
        pool = Pool()
        Configuration = pool.get('stock.configuration')
        values = super().preprocess_values(mode, values)
        if mode == 'create' and not values.get('number'):
            company_id = values.get('company', cls.default_company())
            if company_id is not None:
                configuration = Configuration(1)
                if sequence := configuration.get_multivalue(
                        'package_sequence', company=company_id):
                    values['number'] = sequence.get()
        return values

    @classmethod
    def copy(cls, packages, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('moves')
        return super().copy(packages, default=default)


class Type(MeasurementsMixin, DeactivableMixin, ModelSQL, ModelView):
    __name__ = 'stock.package.type'
    name = fields.Char('Name', required=True)


class Move(metaclass=PoolMeta):
    __name__ = 'stock.move'
    package = fields.Many2One(
        'stock.package', "Package", readonly=True,
        domain=[
            ('company', '=', Eval('company', -1)),
            ])

    @property
    def package_path(self):
        path = []
        package = self.package
        while package:
            path.append(package)
            package = package.parent
        path.reverse()
        return path

    @classmethod
    def copy(cls, moves, default=None):
        if default is None:
            default = {}
        else:
            default = default.copy()
        default.setdefault('package')
        return super().copy(moves, default=default)

    @classmethod
    @ModelView.button
    @Workflow.transition('cancelled')
    def cancel(cls, moves):
        cls.write([m for m in moves if m.package], {'package': None})
        super().cancel(moves)


class PackageMixin(object):
    __slots__ = ()
    packages = fields.One2Many('stock.package', 'shipment', 'Packages',
        domain=[
            ('company', '=', Eval('company', -1)),
            ])
    root_packages = fields.One2Many('stock.package',
        'shipment', 'Packages',
        domain=[
            ('company', '=', Eval('company', -1)),
            ],
        filter=[
            ('parent', '=', None),
            ])

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._buttons.update(pack_wizard={
                'icon': 'tryton-launch',
                })

    @classmethod
    def check_packages(cls, shipments):
        for shipment in shipments:
            if not shipment.packages:
                continue
            length = sum(len(p.moves) for p in shipment.packages)
            if len(shipment.packages_moves) != length:
                raise PackageError(
                    gettext('stock_package.msg_package_mismatch',
                        shipment=shipment.rec_name))

    @property
    def packages_moves(self):
        raise NotImplementedError

    @classmethod
    @ModelView.button_action('stock_package.wizard_shipment_pack')
    def pack_wizard(cls, shipments):
        pass

    @classmethod
    def copy(cls, shipments, default=None):
        default = default.copy() if default is not None else {}
        default.setdefault('packages')
        default.setdefault('root_packages')
        return super().copy(shipments, default=default)


class ShipmentOut(PackageMixin, object, metaclass=PoolMeta):
    __name__ = 'stock.shipment.out'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        packages_readonly = If(
            Eval('warehouse_storage') == Eval('warehouse_output'),
            Eval('state') != 'waiting',
            Eval('state') != 'picked')
        for field in [cls.packages, cls.root_packages]:
            field.states['readonly'] = packages_readonly
        cls._buttons['pack_wizard']['invisible'] = packages_readonly
        cls._buttons['pack_wizard']['depends'] = [
            'warehouse_storage', 'warehouse_output', 'state']

    @classmethod
    @ModelView.button
    @Workflow.transition('packed')
    def pack(cls, shipments):
        super().pack(shipments)
        cls.check_packages(shipments)

    @classmethod
    @ModelView.button
    @Workflow.transition('done')
    def do(cls, shipments):
        super().do(shipments)
        cls.check_packages(shipments)

    @property
    def packages_moves(self):
        return [
            m for m in self.outgoing_moves
            if m.state != 'cancelled' and m.quantity]

    def _group_parcel_key(self, lines, line):
        try:
            root_package = line.package_path[0]
        except IndexError:
            root_package = None
        return super()._group_parcel_key(lines, line) + (
            ('root_package', root_package),)

    @fields.depends('carrier')
    def _parcel_weight(self, parcel):
        pool = Pool()
        Uom = pool.get('product.uom')
        weight = super()._parcel_weight(parcel)
        if self.carrier:
            carrier_uom = self.carrier.weight_uom
            packages = {p for l in parcel for p in l.package_path}
            for package in packages:
                if package.packaging_weight:
                    weight += Uom.compute_qty(
                        package.packaging_weight_uom, package.packaging_weight,
                        carrier_uom, round=False)
        return weight


class ShipmentInReturn(PackageMixin, object, metaclass=PoolMeta):
    __name__ = 'stock.shipment.in.return'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        packages_readonly = ~Eval('state').in_(['waiting', 'assigned'])
        for field in [cls.packages, cls.root_packages]:
            field.states['readonly'] = packages_readonly
        cls._buttons['pack_wizard']['invisible'] = packages_readonly
        cls._buttons['pack_wizard']['depends'] = ['state']

    @classmethod
    @ModelView.button
    @Workflow.transition('done')
    def do(cls, shipments):
        super().do(shipments)
        cls.check_packages(shipments)

    @property
    def packages_moves(self):
        return [
            m for m in self.moves
            if m.state != 'cancelled' and m.quantity]


class ShipmentInternal(PackageMixin, object, metaclass=PoolMeta):
    __name__ = 'stock.shipment.internal'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        packages_readonly = Eval('state') != 'waiting'
        packages_invisible = ~Eval('transit_location')
        for field in [cls.packages, cls.root_packages]:
            field.states['readonly'] = packages_readonly
            field.states['invisible'] = packages_invisible
        cls._buttons['pack_wizard']['invisible'] = packages_invisible
        cls._buttons['pack_wizard']['depends'] = ['state', 'transit_location']

    @classmethod
    @ModelView.button
    @Workflow.transition('packed')
    def pack(cls, shipments):
        super().pack(shipments)
        cls.check_packages(shipments)

    @property
    def packages_moves(self):
        return [
            m for m in self.outgoing_moves
            if m.state != 'cancelled' and m.quantity]


class ShipmentPack(Wizard):
    __name__ = 'stock.shipment.pack'
    start_state = 'package'
    package = StateView(
        'stock.package',
        'stock_package.package_view_form_pack', [
            Button("End", 'end', 'tryton-cancel'),
            Button("Add Package", 'add_package', 'tryton-ok'),
            Button(
                "Add Package and Fill", 'add_fill_package', 'tryton-add',
                default=True),
            ])
    add_package = StateTransition()
    add_fill_package = StateAction('stock_package.wizard_package_pack')

    def default_package(self, fields):
        return {
            'company': self.record.company,
            'shipment': self.record,
            }

    def transition_add_package(self):
        self.package.save()
        return 'package'

    def do_add_fill_package(self, action):
        self.package.save()
        return action, {
            'id': self.package.id,
            'ids': [self.package.id],
            'model': self.package.__name__,
            }

    def transition_add_fill_package(self):
        return 'package'


class PackagePack(Wizard):
    __name__ = 'stock.package.pack'
    start_state = 'next_'
    next_ = StateTransition()
    move = StateView(
        'stock.package.pack.move',
        'stock_package.package_pack_move_view_form', [
            Button("End", 'end', 'tryton-ok'),
            Button("Add", 'add_move', 'tryton-forward', default=True),
            ])
    add_move = StateTransition()

    def transition_next_(self):
        if any(not m.package for m in self.record.allowed_moves):
            return 'move'
        else:
            return 'end'

    def default_move(self, fields):
        return {
            'allowed_moves': [
                m for m in self.record.allowed_moves if not m.package],
            }

    def transition_add_move(self):
        pool = Pool()
        Move = pool.get('stock.move')
        move = self.move.source
        if self.move.quantity and self.move.quantity != move.quantity:
            with Transaction().set_context(_stock_move_split=True):
                Move.copy([move], {
                        'quantity': move.quantity - self.move.quantity,
                        })
            move.quantity = self.move.quantity
        move.package = self.record
        move.save()
        return 'next_'


class PackagePackMove(ModelView):
    __name__ = 'stock.package.pack.move'

    source = fields.Many2One(
        'stock.move', "Source", required=True,
        domain=[
            ('id', 'in', Eval('allowed_moves', [])),
            ('package', '=', None),
            ])
    quantity = fields.Float(
        "Quantity", digits='unit',
        domain=['OR',
            ('quantity', '=', None),
            [
                ('quantity', '>', 0),
                ('quantity', '<=', Eval('move_quantity', 0)),
                ],
            ],
        help="The quantity to pack from the move.\n"
        "Leave empty for the full quantity of the move.")
    unit = fields.Function(
        fields.Many2One('product.uom', "Unit"),
        'on_change_with_unit')
    move_quantity = fields.Function(
        fields.Float("Move Quantity"),
        'on_change_with_move_quantity')
    allowed_moves = fields.Many2Many(
        'stock.move', None, None, "Allowed Moves", readonly=True)

    @fields.depends('source')
    def on_change_with_unit(self, name=None):
        return self.source.unit if self.source else None

    @fields.depends('source')
    def on_change_with_move_quantity(self, name=None):
        return self.source.quantity if self.source else None
