# This file is part of Tryton.  The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
import datetime as dt
from collections import defaultdict
from itertools import groupby

from trytond.model import Index, Model, ModelSQL, ModelView, Workflow, fields
from trytond.modules.company.model import (
    employee_field, reset_employee, set_employee)
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Eval
from trytond.transaction import Transaction
from trytond.wizard import Button, StateAction, StateView, Wizard


class QuantityEarlyPlan(Workflow, ModelSQL, ModelView):
    __name__ = 'stock.quantity.early_plan'

    company = fields.Many2One(
        'company.company', "Company", required=True)
    origin = fields.Reference(
        "Origin", 'get_origins', required=True,
        domain={
            'stock.move': [
                ('company', '=', Eval('company', -1)),
                ],
            'stock.shipment.out': [
                ('company', '=', Eval('company', -1)),
                ],
            'stock.shipment.in.return': [
                ('company', '=', Eval('company', -1)),
                ],
            'stock.shipment.internal': [
                ('company', '=', Eval('company', -1)),
                ],
            })
    planned_date = fields.Function(
        fields.Date("Planned Date"),
        'on_change_with_planned_date')
    early_quantity = fields.Float(
        "Early Quantity", readonly=True,
        states={
            'invisible': True,
            })
    early_date = fields.Date(
        "Early Date", readonly=True,
        states={
            'invisible': True,
            })
    earlier_date = fields.Function(
        fields.Date("Earlier Date"), 'get_earlier_date')
    earliest_date = fields.Function(
        fields.Date("Earliest Date"), 'get_earliest_date')
    earliest_percentage = fields.Function(
        fields.Float(
            "Earliest Percentage", digits=(1, 4),
            states={
                'invisible': ~Eval('earliest_date'),
                }),
        'get_earliest_percentage')
    warehouse = fields.Function(
        fields.Many2One('stock.location', "Warehouse"),
        'on_change_with_warehouse')
    moves = fields.Function(
        fields.Many2Many(
            'stock.quantity.early_plan', None, None, "Moves",
            states={
                'invisible': ~Eval('moves'),
                }),
        'get_moves')

    processed_by = employee_field("Processed by", states=['processing'])
    closed_by = employee_field("Closed by", states=['closed'])
    ignored_by = employee_field("Ignored by", states=['ignored'])

    state = fields.Selection([
            ('open', "Open"),
            ('processing', "Processing"),
            ('closed', "Closed"),
            ('ignored', "Ignored"),
            ], "State", required=True, readonly=True, sort=False)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        t = cls.__table__()
        cls._sql_indexes.add(
            Index(
                t,
                (t.state, Index.Equality(cardinality='low')),
                where=t.state.in_(['open', 'processing'])))
        cls._transitions |= {
            ('open', 'processing'),
            ('open', 'ignored'),
            ('processing', 'closed'),
            ('processing', 'open'),
            ('processing', 'ignored'),
            ('ignored', 'open'),
            }
        cls._buttons.update({
                'open': {
                    'invisible': ~Eval('state').in_(['processing', 'ignored']),
                    'depends': ['state'],
                    },
                'process': {
                    'invisible': Eval('state') != 'open',
                    'depends': ['state'],
                    },
                'close': {
                    'invisible': Eval('state') != 'processing',
                    'depends': ['state'],
                    },
                'ignore': {
                    'invisible': ~Eval('state').in_(['open', 'processing']),
                    'depends': ['state'],
                    },
                })

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

    @classmethod
    def _get_origins(cls):
        "Return a list of Model names for origin Reference"
        return [
            'stock.move',
            'stock.shipment.out',
            'stock.shipment.in.return',
            'stock.shipment.internal',
            ]

    @fields.depends('origin')
    def on_change_with_planned_date(self, name=None):
        if isinstance(self.origin, Model) and self.origin.id >= 0:
            return self.origin.planned_date

    def get_earlier_date(self, name):
        return self._get_dates(max)

    def get_earliest_date(self, name):
        return self._get_dates(min)

    @property
    def _allow_partial_moves(self):
        "Allow to early planning without all moves"
        return True

    def _get_dates(self, aggregate):
        pool = Pool()
        Move = pool.get('stock.move')
        if isinstance(self.origin, Move) and self.origin.id >= 0:
            if (aggregate == max
                    and self.early_quantity != self.origin.internal_quantity):
                return self.origin.planned_date
            else:
                return self.early_date
        elif (not self._allow_partial_moves
                and any(not m.early_date for m in self.moves)):
            return self.planned_date
        else:
            return aggregate(
                filter(None, (m._get_dates(aggregate) for m in self.moves)),
                default=self.planned_date)

    @property
    def _early_quantity(self):
        if isinstance(self.origin, Move) and self.origin.id >= 0:
            if self.early_quantity is not None:
                return self.early_quantity
            else:
                return self.origin.internal_quantity

    def get_earliest_percentage(self, name):
        pool = Pool()
        Move = pool.get('stock.move')
        if isinstance(self.origin, Move) and self.origin.id >= 0:
            return round(
                self._early_quantity / self.origin.internal_quantity, 4)
        else:
            date = self._get_dates(min)
            total = sum(m.origin.internal_quantity for m in self.moves)
            quantity = sum(
                m._early_quantity for m in self.moves
                if (m.early_date or m.planned_date or dt.date.max) <= date)
            if total:
                return round(quantity / total, 4)
            else:
                return 1

    @classmethod
    def default_warehouse(cls):
        pool = Pool()
        Location = pool.get('stock.location')
        return Location.get_default_warehouse()

    @fields.depends('origin')
    def on_change_with_warehouse(self, name=None):
        pool = Pool()
        Move = pool.get('stock.move')
        if isinstance(self.origin, Move) and self.origin.id >= 0:
            return self.origin.from_location.warehouse
        elif (isinstance(self.origin, Model) and self.origin.id >= 0
                and hasattr(self.origin, 'warehouse')):
            return self.origin.warehouse

    def get_moves(self, name):
        pool = Pool()
        ShipmentOut = pool.get('stock.shipment.out')
        ShipmentInReturn = pool.get('stock.shipment.in.return')
        ShipmentInternal = pool.get('stock.shipment.internal')
        moves = []
        if isinstance(self.origin, (
                    ShipmentOut, ShipmentInReturn, ShipmentInternal)):
            for move in self.origin.moves:
                moves.extend([p.id for p in move.quantity_early_plans])
        return moves

    @classmethod
    def default_state(cls):
        return 'open'

    def get_rec_name(self, name):
        return (self.origin.rec_name if isinstance(self.origin, Model)
            else '(%s)' % self.id)

    @classmethod
    @ModelView.button
    @Workflow.transition('open')
    @reset_employee('processed_by', 'ignored_by')
    def open(cls, plans):
        pass

    @classmethod
    @ModelView.button
    @Workflow.transition('processing')
    @set_employee('processed_by')
    def process(cls, plans):
        pass

    @classmethod
    @ModelView.button
    @Workflow.transition('closed')
    @set_employee('closed_by')
    def close(cls, plans):
        pass

    @classmethod
    @ModelView.button
    @Workflow.transition('ignored')
    @set_employee('ignored_by')
    def ignore(cls, plans):
        pass

    @classmethod
    def generate_plans(cls, warehouses=None, company=None):
        """
        For each outgoing move it creates an early plan and a plan for its
        shipment.

        If warehouses is specified it searches only for moves from them.
        """
        pool = Pool()
        Date = pool.get('ir.date')
        Location = pool.get('stock.location')
        Move = pool.get('stock.move')
        User = pool.get('res.user')

        if warehouses is None:
            warehouses = Location.search([
                    ('type', '=', 'warehouse'),
                    ])
        if company is None:
            company = User(Transaction().user).company

        with Transaction().set_context(company=company.id):
            today = Date.today()

        # Do not keep former plan as the may no more be valid
        opens = cls.search([
                ('company', '=', company.id),
                ('state', '=', 'open'),
                ])
        opens = [
            p for p in opens if p.warehouse in warehouses or not p.warehouse]
        cls.delete(opens)

        plans = {}
        for plan in cls.search([
                    ('company', '=', company.id),
                    ('state', 'in', ['processing', 'ignored']),
                    ]):
            if plan.warehouse not in warehouses:
                continue
            plans[plan.origin] = plan

        for warehouse in warehouses:
            moves = Move.search([
                    ('company', '=', company.id),
                    ('from_location', 'child_of', [warehouse.id], 'parent'),
                    ('to_location', 'not child_of', [warehouse.id], 'parent'),
                    ('planned_date', '>', today),
                    ('state', '=', 'draft'),
                    ],
                order=[('product.id', 'ASC'), ('planned_date', 'ASC')])

            for product, moves in groupby(moves, lambda m: m.product):
                for move in moves:
                    earlier_date, quantity = cls._get_earlier_date(
                        move, warehouse)
                    plan = cls._add(move, plans)
                    if earlier_date < move.planned_date:
                        plan.early_date = earlier_date
                    else:
                        plan.early_date = None
                    plan.early_quantity = quantity

                    for parent in cls._parents(move):
                        cls._add(parent, plans)
        cls.save(plans.values())

        to_delete = []
        for plan in cls.browse(plans.values()):
            if (plan.state == 'open'
                    and not isinstance(plan.origin, Move)
                    and plan.earliest_date == plan.planned_date):
                to_delete.append(plan)
        cls.delete(to_delete)

        # Update early date based on internal incoming requests
        for warehouse in warehouses:
            product_plans = cls.search([
                    ('company', '=', company.id),
                    ('state', '=', 'open'),
                    ('origin', 'like', 'stock.move,%'),
                    ],
                order=[('early_date', 'ASC NULLS LAST')])

            in_plans = cls.search([
                    ('company', '=', company.id),
                    ('state', 'in', ['open', 'processing']),
                    cls._incoming_domain(),
                    ])
            product2in = defaultdict(lambda: defaultdict(list))
            for plan in in_plans:
                products = defaultdict(int)
                for product, quantity in plan._incoming_quantities(warehouse):
                    products[product] += quantity
                for product, quantity in products.items():
                    product2in[product][plan.planned_date].append(
                        (quantity, plan))

            to_save = []
            products = set()
            for product_plan in product_plans:
                if product_plan.warehouse != warehouse:
                    continue
                product = product_plan.origin.product
                quantity = product_plan.origin.internal_quantity
                plans = product2in[product][product_plan.early_date]
                plans = cls._pick_incoming(quantity, plans)
                if plans:
                    incoming_products = {p
                        for pl in plans
                        for p, q in pl._incoming_quantities(warehouse)}
                    if incoming_products & products:
                        cls.save(to_save)
                        del to_save[:]
                        products.clear()

                    earlier_date = max(p.earlier_date for p in plans)

                    if (not product_plan.early_date
                            or product_plan.early_date > earlier_date):
                        product_plan.early_date = earlier_date
                        to_save.append(product_plan)
                        products.add(product)
            cls.save(to_save)

    @classmethod
    def _get_earlier_date(cls, move, warehouse):
        pool = Pool()
        Date = pool.get('ir.date')
        ProductQuantitiesByWarehouse = pool.get(
            'stock.product_quantities_warehouse')
        product = move.product
        with Transaction().set_context(company=move.company.id):
            today = Date.today()

        quantity = move.internal_quantity
        if product.consumable:
            return today, quantity

        with Transaction().set_context(
                product=product.id,
                warehouse=warehouse.id,
                stock_skip_warehouse=False,
                ):
            product_quantities = (
                ProductQuantitiesByWarehouse.search([
                        ('date', '>=', today),
                        ('date', '<=', move.planned_date),
                        ],
                    order=[('date', 'DESC')]))
            future_product_quantities = (
                ProductQuantitiesByWarehouse.search([
                        ('date', '>=', move.planned_date),
                        ]))
            min_future_product_quantity = min(
                p.quantity for p in future_product_quantities)
        earlier_date = move.planned_date
        if product_quantities and product_quantities[0].quantity >= 0:
            assert product_quantities[0].date == move.planned_date
            if product_quantities[0].quantity > -quantity:
                # The new date must left the same available
                # quantity for other moves at the current date
                min_quantity = (
                    product_quantities[0].quantity
                    + move.internal_quantity)
                quantity = min(min_quantity, move.internal_quantity)
                if min_future_product_quantity > 0:
                    # The remaining quantities can be used
                    min_quantity -= min_future_product_quantity
                if quantity >= 0:
                    for product_quantity in product_quantities[1:]:
                        if product_quantity.quantity < min_quantity:
                            if earlier_date == move.planned_date:
                                # Not found earlier date,
                                # try with the first smaller quantity
                                quantity = min(
                                    quantity, product_quantity.quantity)
                                min_quantity = min(
                                    product_quantity.quantity,
                                    move.internal_quantity)
                            else:
                                break
                        earlier_date = product_quantity.date
        return earlier_date, quantity

    @classmethod
    def _add(cls, origin, plans):
        if origin in plans:
            plan = plans[origin]
        else:
            plan = plans[origin] = cls(
                company=origin.company,
                origin=origin)
        return plan

    @classmethod
    def _parents(cls, move):
        if move.shipment:
            yield move.shipment

    @classmethod
    def _incoming_domain(cls):
        return ['OR',
            ('origin.state', '=', 'request', 'stock.shipment.internal'),
            ]

    def _incoming_quantities(self, warehouse):
        pool = Pool()
        ShipmentInternal = pool.get('stock.shipment.internal')
        if isinstance(self.origin, ShipmentInternal):
            shipment = self.origin
            if (shipment.to_location.warehouse == warehouse
                    or shipment.from_location.warehouse != warehouse):
                for move in shipment.moves:
                    if move.to_location.warehouse == warehouse:
                        yield move.product, move.internal_quantity

    @classmethod
    def _pick_incoming(cls, quantity, plans):
        plans = [p for q, p in plans if q >= quantity]
        plans.sort(key=lambda p: p.earlier_date)
        return plans


class QuantityEarlyPlanProduction(metaclass=PoolMeta):
    __name__ = 'stock.quantity.early_plan'

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.origin.domain['production'] = [
            ('company', '=', Eval('company', -1)),
            ]

    @classmethod
    def _get_origins(cls):
        return super()._get_origins() + ['production']

    @property
    def _allow_partial_moves(self):
        pool = Pool()
        Production = pool.get('production')
        allow = super()._allow_partial_moves
        if (isinstance(self.origin, Production)
                and any(not m.early_date for m in self.moves)):
            allow = False
        return allow

    def get_moves(self, name):
        pool = Pool()
        Production = pool.get('production')
        moves = super().get_moves(name)
        if isinstance(self.origin, Production):
            for move in self.origin.inputs + self.origin.outputs:
                moves.extend([p.id for p in move.quantity_early_plans])
        return moves

    @classmethod
    def _parents(cls, move):
        yield from super()._parents(move)
        if move.production_input:
            yield move.production_input
        if move.production_output:
            yield move.production_output

    @classmethod
    def _incoming_domain(cls):
        return super()._incoming_domain() + [
            ('origin.state', '=', 'request', 'production'),
            ]

    def _incoming_quantities(self, warehouse):
        pool = Pool()
        Production = pool.get('production')
        yield from super()._incoming_quantities(warehouse)
        if isinstance(self.origin, Production):
            production = self.origin
            if production.warehouse == warehouse:
                for output in production.outputs:
                    yield output.product, output.internal_quantity


class Move(metaclass=PoolMeta):
    __name__ = 'stock.move'

    quantity_early_plans = fields.One2Many(
        'stock.quantity.early_plan', 'origin',
        "Quantity Early Plans", readonly=True,
        order=[('early_date', 'ASC NULLS LAST')])


class QuantityEarlyPlanGenerate(Wizard):
    __name__ = 'stock.quantity.early_plan.generate'
    start = StateView(
        'stock.quantity.early_plan.generate.start',
        'stock_quantity_early_planning'
        '.quantity_early_plan_generate_start_view_form', [
            Button("Cancel", 'end', 'tryton-cancel'),
            Button("Generate", 'generate', 'tryton-ok', default=True),
            ])
    generate = StateAction(
        'stock_quantity_early_planning.act_quantity_early_plan_form')

    def transition_generate(self):
        pool = Pool()
        QuantityEarlyPlan = pool.get('stock.quantity.early_plan')
        QuantityEarlyPlan.generate_plans(
            warehouses=self.start.warehouses or None)
        return 'end'


class QuantityEarlyPlanGenerateStart(ModelView):
    __name__ = 'stock.quantity.early_plan.generate.start'
    warehouses = fields.Many2Many(
        'stock.location', None, None, "Warehouses",
        domain=[
            ('type', '=', 'warehouse'),
            ],
        help="If empty all warehouses are used.")

    @classmethod
    def default_warehouses(cls):
        pool = Pool()
        Location = pool.get('stock.location')
        warehouse = Location.get_default_warehouse()
        if warehouse:
            return [warehouse]
