# 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 dateutil.relativedelta import relativedelta
from sql import Column, Literal, Null, Window
from sql.aggregate import Min, Sum
from sql.conditionals import Case, Coalesce, Greatest, Least, NullIf
from sql.functions import Function, NthValue, Round
from sql.operators import Concat

from trytond import backend
from trytond.model import ModelSQL, ModelView, fields
from trytond.pool import Pool
from trytond.pyson import Eval
from trytond.transaction import Transaction


class _SQLite_JulianDay(Function):
    __slots__ = ()
    _function = 'JULIANDAY'


class _InventoryContextMixin(ModelView):

    company = fields.Many2One('company.company', "Company", required=True)
    location = fields.Many2One(
        'stock.location', "Location", required=True,
        domain=[
            ('type', 'in', ['warehouse', 'storage', 'view']),
            ])

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

    @classmethod
    def default_location(cls):
        pool = Pool()
        Location = pool.get('stock.location')
        return Transaction().context.get(
            'location', Location.get_default_warehouse())


class _InventoryMixin:
    __slots__ = ()

    company = fields.Many2One('company.company', "Company")
    product_reference = fields.Reference("Product Reference", [
            ('product.product', "Variant"),
            ('product.template', "Product"),
            ],
        context={
            'company': Eval('company', -1),
            },
        depends=['company'])
    product_template = fields.Many2One(
        'product.template', "Product",
        states={
            'invisible': (
                Eval('context', {}).get('product_type', 'product.template')
                != 'product.template'),
            },
        context={
            'company': Eval('company', -1),
            },
        depends=['company'])
    product = fields.Many2One(
        'product.product', "Variant",
        states={
            'invisible': (
                Eval('context', {}).get('product_type', 'product.template')
                != 'product.product'),
            },
        context={
            'company': Eval('company', -1),
            },
        depends=['company'])
    unit = fields.Function(
        fields.Many2One('product.uom', "Unit"),
        'on_change_with_unit')

    input_quantity = fields.Float("Input Quantity")
    output_quantity = fields.Float("Output Quantity")
    quantity = fields.Float("Quantity")

    @classmethod
    def table_query(cls):
        pool = Pool()
        Location = pool.get('stock.location')
        Move = pool.get('stock.move')
        Period = pool.get('stock.period')
        PeriodCache = pool.get('stock.period.cache')
        Product = pool.get('product.product')

        transaction = Transaction()
        context = transaction.context
        move = Move.__table__()
        from_location = Location.__table__()
        to_location = Location.__table__()
        period_cache = PeriodCache.__table__()
        product_table = Product.__table__()

        if location := context.get('location'):
            location = Location(location)
            left, right = location.left, location.right
        else:
            left = right = -1
        if date := context.get('date'):
            from_date = to_date = date
        else:
            from_date = context.get('from_date') or dt.date.min
            to_date = context.get('to_date') or dt.date.max
        company = context.get('company')

        if periods := Period.search([
                    ('company', '=', company),
                    ('date', '<=', from_date),
                    ('state', '=', 'closed'),
                    ],
                order=[('date', 'DESC')],
                limit=1):
            period, = periods
        else:
            period = None

        quantities = (
            move
            .join(from_location,
                condition=move.from_location == from_location.id)
            .join(to_location,
                condition=move.to_location == to_location.id)
            )

        if context.get('product_type') == 'product.product':
            product_reference = Concat('product.product,', move.product)
            product = move.product
            product_template = Literal(None)
        else:
            product_reference = Concat(
                'product.template,', product_table.template)
            product = Literal(None)
            product_template = product_table.template
            quantities = (
                quantities
                .join(product_table,
                    condition=move.product == product_table.id)
                )

        move_date = Coalesce(move.effective_date, move.planned_date)
        input_clause = (
            (to_location.left >= left) & (to_location.right <= right))
        output_clause = (
            (from_location.left >= left) & (from_location.right <= right))
        start_quantity = Sum(
            move.quantity * Case((output_clause, -1), else_=1),
            filter_=(move_date < from_date))
        input_quantity = Sum(
            move.quantity,
            filter_=input_clause & (move_date >= from_date))
        output_quantity = Sum(
            move.quantity,
            filter_=output_clause & (move_date >= from_date))

        date_column = Greatest(move_date, from_date)
        if context.get('product_type') == 'product.product':
            partition = move.product
        else:
            partition = product_table.template

        state_clause = cls._state_clause(move, to_date)

        quantities_columns = list(cls._quantities_columns(move, product_table))
        quantities = quantities.select(
            Min(move.id).as_('id'),
            product_reference.as_('product_reference'),
            product_template.as_('product_template'),
            product.as_('product'),
            date_column.as_('date'),
            partition.as_('partition'),
            start_quantity.as_('start_quantity'),
            input_quantity.as_('input_quantity'),
            output_quantity.as_('output_quantity'),
            *quantities_columns,
            where=(((input_clause & ~output_clause)
                    | (output_clause & ~input_clause))
                & (move_date <= to_date)
                & state_clause
                & (move.company == company)),
            group_by=[
                partition.as_('partition'),
                date_column.as_('date'),
                *cls._quantities_group_by(move, product_table),
                ],
            )

        quantity = Sum(
            Coalesce(quantities.start_quantity, 0)
            + Coalesce(quantities.input_quantity, 0)
            - Coalesce(quantities.output_quantity, 0),
            window=Window(
                [quantities.product_reference],
                order_by=[*cls._quantities_order_by(quantities)]))

        if period:
            quantities.where &= move_date > period.date
            if context.get('product_type') != 'product.product':
                period_cache_product = Product.__table__()
                cache = (
                    period_cache
                    .join(period_cache_product,
                        condition=period_cache.product
                        == period_cache_product.id)
                    .select(
                        period_cache_product.template.as_('product'),
                        Sum(period_cache.internal_quantity
                            ).as_('quantity'),
                        group_by=[
                            period_cache_product.template.as_('product'),
                            ]))
            else:
                cache = (
                    period_cache
                    .select(
                        period_cache.product.as_('product'),
                        Sum(period_cache.internal_quantity).as_('quantity'),
                        group_by=[
                            period_cache.product.as_('product'),
                            ]))
            location_cache = Location.__table__()
            cache.where = (
                (period_cache.period == period.id)
                & period_cache.location.in_(
                    location_cache.select(
                        location_cache.id,
                        where=(location_cache.left >= left)
                        & (location_cache.right <= right))))
            query = quantities.join(cache, 'LEFT',
                condition=(
                    cache.product
                    == cls.product_reference.sql_id(
                        quantities.product_reference, cls)))
            quantity += Coalesce(cache.quantity, 0)
        else:
            query = quantities

        query = query.select(
            quantities.id.as_('id'),
            Literal(company).as_('company'),
            quantities.product_reference.as_('product_reference'),
            quantities.product_template.as_('product_template'),
            quantities.product.as_('product'),
            quantities.date.as_('date'),
            # Use NthValue instead of LastValue to get NULL for the last row
            NthValue(
                quantities.date, 2, window=Window(
                    [quantities.partition],
                    order_by=[quantities.date.asc],
                    frame='ROWS', start=0, end=1)).as_('next_date'),
            quantities.input_quantity.as_('input_quantity'),
            quantities.output_quantity.as_('output_quantity'),
            quantity.as_('quantity'),
            *[Column(quantities, col.output_name).as_(col.output_name)
                for col in quantities_columns],
            )

        return (query
            .select(
                query.id.as_('id'),
                Literal(company).as_('company'),
                query.product_reference.as_('product_reference'),
                query.product_template.as_('product_template'),
                query.product.as_('product'),
                query.input_quantity.as_('input_quantity'),
                query.output_quantity.as_('output_quantity'),
                query.quantity.as_('quantity'),
                *cls._columns(query),
                where=(
                    (Coalesce(query.next_date, dt.date.max) >= from_date)
                    | (query.date >= from_date))))

    @classmethod
    def _quantities_columns(cls, move, product):
        yield from []

    @classmethod
    def _quantities_group_by(cls, move, product):
        yield from []

    @classmethod
    def _quantities_order_by(cls, quantities):
        yield quantities.date.asc

    @classmethod
    def _columns(cls, quantities):
        yield from []

    @classmethod
    def _where(cls, quantities):
        return Literal(True)

    @classmethod
    def _state_clause(cls, move, date):
        pool = Pool()
        Date = pool.get('ir.date')
        today = Date.today()
        forcast = date > today

        move_date = Coalesce(move.effective_date, move.planned_date)
        state_clause = (
            (move_date <= today) & (move.state == 'done'))
        state_clause |= (
            ((move_date >= today) if forcast else (move_date > today))
            & (move.state.in_(['done', 'assigned', 'draft'])))
        return state_clause

    @fields.depends('product_reference')
    def on_change_with_unit(self, name=None):
        if self.product_reference:
            return self.product_reference.default_uom

    @classmethod
    def view_attributes(cls):
        return super().view_attributes() + [
            ('/tree/field[@name="product_template"]', 'tree_invisible',
                Eval('product_type', 'product.template')
                != 'product.template'),
            ('/tree/field[@name="product"]', 'tree_invisible',
                Eval('product_type', 'product.template') != 'product.product'),
            ]


class InventoryContext(_InventoryContextMixin):
    __name__ = 'stock.reporting.inventory.context'

    date = fields.Date("Date", required=True)
    product_type = fields.Selection([
            ('product.product', "Variant"),
            ('product.template', "Product"),
            ], "Product Type", required=True)

    @classmethod
    def default_date(cls):
        return Pool().get('ir.date').today()

    @classmethod
    def default_product_type(cls):
        return Transaction().context.get('product_type') or 'product.template'


class Inventory(_InventoryMixin, ModelSQL, ModelView):
    __name__ = 'stock.reporting.inventory'


class _InventoryRangeContextMixin(_InventoryContextMixin):

    from_date = fields.Date(
        "From Date", required=True,
        domain=[
            ('from_date', '<=', Eval('to_date')),
            ])
    to_date = fields.Date(
        "To Date", required=True,
        domain=[
            ('to_date', '>=', Eval('from_date')),
            ])

    @classmethod
    def default_from_date(cls):
        pool = Pool()
        Date = pool.get('ir.date')
        context = Transaction().context
        if 'from_date' in context:
            return context['from_date']
        return Date.today() - relativedelta(day=1, months=6)

    @classmethod
    def default_to_date(cls):
        pool = Pool()
        Date = pool.get('ir.date')
        context = Transaction().context
        if 'to_date' in context:
            return context['to_date']
        return Date.today() + relativedelta(day=1, months=6)


class InventoryRangeContext(_InventoryRangeContextMixin):
    __name__ = 'stock.reporting.inventory.range.context'


class InventoryMove(_InventoryMixin, ModelSQL, ModelView):
    __name__ = 'stock.reporting.inventory.move'

    date = fields.Date("Date")
    move = fields.Many2One('stock.move', "Move")
    origin = fields.Reference("Origin", selection='get_origin')
    document = fields.Function(
        fields.Reference("Document", selection='get_documents'),
        'get_document')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._order = [
            ('date', 'DESC'),
            ('move', 'DESC NULLS LAST'),
            ('id', 'DESC'),
            ]

    @classmethod
    def _quantities_columns(cls, move, product):
        from_date = Transaction().context.get('from_date') or dt.date.min
        move_date = Coalesce(move.effective_date, move.planned_date)
        yield from super()._quantities_columns(move, product)
        yield Case(
            (move_date < from_date, Null),
            else_=move.id).as_('move')
        yield Case(
            (move_date < from_date, Null),
            else_=move.origin).as_('origin')

    @classmethod
    def _quantities_order_by(cls, quantities):
        yield from super()._quantities_order_by(quantities)
        yield quantities.move.asc.nulls_first

    @classmethod
    def _quantities_group_by(cls, move, product):
        from_date = Transaction().context.get('from_date') or dt.date.min
        move_date = Coalesce(move.effective_date, move.planned_date)
        yield from super()._quantities_group_by(move, product)
        yield Case(
            (move_date < from_date, Null),
            else_=move.id).as_('move')
        yield Case(
            (move_date < from_date, Null),
            else_=move.origin).as_('origin')

    @classmethod
    def _columns(cls, quantities):
        yield from super()._columns(quantities)
        yield quantities.date.as_('date')
        yield quantities.move.as_('move')
        yield quantities.origin.as_('origin')

    @classmethod
    def get_origin(cls):
        pool = Pool()
        Move = pool.get('stock.move')
        return Move.get_origin()

    @classmethod
    def _get_document_models(cls):
        pool = Pool()
        Move = pool.get('stock.move')
        return [m for m, _ in Move.get_shipment() if m]

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

    def get_document(self, name):
        if self.move and self.move.shipment:
            return str(self.move.shipment)

    def get_rec_name(self, name):
        name = super().get_rec_name(name)
        if self.move:
            name = self.move.rec_name
        return name


class InventoryDaily(_InventoryMixin, ModelSQL, ModelView):
    __name__ = 'stock.reporting.inventory.daily'

    from_date = fields.Date("From Date")
    to_date = fields.Date("To Date")

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._order = [
            ('from_date', 'DESC'),
            ('id', 'DESC'),
            ]

    @classmethod
    def _quantities_order_by(cls, quantities):
        yield from []
        yield quantities.date.asc

    @classmethod
    def _columns(cls, quantities):
        transaction = Transaction()
        context = transaction.context
        to_date = context.get('to_date') or dt.date.max

        yield from super()._columns(quantities)

        yield quantities.date.as_('from_date')
        yield Least(quantities.next_date, to_date).as_('to_date')


class InventoryTurnoverContext(_InventoryRangeContextMixin):
    __name__ = 'stock.reporting.inventory.turnover.context'

    product_type = fields.Selection([
            ('product.product', "Variant"),
            ('product.template', "Product"),
            ], "Product Type", required=True)

    @classmethod
    def default_product_type(cls):
        return Transaction().context.get('product_type') or 'product.template'


class InventoryTurnover(ModelSQL, ModelView):
    __name__ = 'stock.reporting.inventory.turnover'

    company = fields.Many2One('company.company', "Company")
    product_reference = fields.Reference("Product", [
            ('product.product', "Variant"),
            ('product.template', "Product"),
            ],
        context={
            'company': Eval('company', -1),
            },
        depends=['company'])
    product_template = fields.Many2One(
        'product.template', "Product",
        states={
            'invisible': (
                Eval('context', {}).get('product_type', 'product.template')
                != 'product.template'),
            },
        context={
            'company': Eval('company', -1),
            },
        depends=['company'])
    product = fields.Many2One(
        'product.product', "Variant",
        states={
            'invisible': (
                Eval('context', {}).get('product_type', 'product.template')
                != 'product.product'),
            },
        context={
            'company': Eval('company', -1),
            },
        depends=['company'])
    unit = fields.Function(
        fields.Many2One('product.uom', "Unit"),
        'on_change_with_unit')

    output_quantity = fields.Float("Output Quantity", digits=(None, 3))
    average_quantity = fields.Float("Average Quantity", digits=(None, 3))
    turnover = fields.Float("Turnover", digits=(None, 3))

    @classmethod
    def table_query(cls):
        pool = Pool()
        Inventory = pool.get('stock.reporting.inventory.daily')

        transaction = Transaction()
        context = transaction.context
        inventory = Inventory.__table__()

        from_date = context.get('from_date') or dt.date.min
        to_date = context.get('to_date') or dt.date.max
        company = context.get('company')

        days = (to_date - from_date).days + 1

        output_quantity = Sum(inventory.output_quantity) / days
        inventory_from_date = inventory.from_date
        inventory_to_date = inventory.to_date
        if backend.name == 'sqlite':
            inventory_from_date = _SQLite_JulianDay(inventory_from_date)
            inventory_to_date = _SQLite_JulianDay(inventory_to_date)
        average_quantity = (
            Sum(Case(
                    (inventory.quantity >= 0, inventory.quantity),
                    else_=0)
                * (inventory_to_date - inventory_from_date
                    + Case((inventory.to_date == to_date, 1), else_=0)))
            / days)

        def round_sql(expression, digits=2):
            factor = 10 ** digits
            return Round(expression * factor) / factor

        return (inventory
            .select(
                cls.product_reference.sql_id(
                    inventory.product_reference, cls).as_('id'),
                Literal(company).as_('company'),
                inventory.product_reference.as_('product_reference'),
                inventory.product_template.as_('product_template'),
                inventory.product.as_('product'),
                round_sql(
                    output_quantity,
                    cls.output_quantity.digits[1]).as_('output_quantity'),
                round_sql(
                    average_quantity,
                    cls.average_quantity.digits[1]).as_('average_quantity'),
                round_sql(
                    output_quantity / NullIf(average_quantity, 0),
                    cls.turnover.digits[1]).as_('turnover'),
                group_by=[
                    inventory.product_reference.as_('product_reference'),
                    inventory.product_template.as_('product_template'),
                    inventory.product.as_('product'),
                    ]))

    @fields.depends('product_reference')
    def on_change_with_unit(self, name=None):
        if self.product_reference:
            return self.product_reference.default_uom

    @classmethod
    def view_attributes(cls):
        return super().view_attributes() + [
            ('/tree/field[@name="product_template"]', 'tree_invisible',
                Eval('product_type', 'product.template')
                != 'product.template'),
            ('/tree/field[@name="product"]', 'tree_invisible',
                Eval('product_type', 'product.template') != 'product.product'),
            ]
