# 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
from collections import defaultdict
from decimal import Decimal
from itertools import groupby

from sql import Cast, Null
from sql.aggregate import Sum
from sql.operators import Concat

from trytond import backend
from trytond.i18n import gettext
from trytond.model import ModelSQL, ModelView, fields
from trytond.model.exceptions import AccessError
from trytond.modules.currency.fields import Monetary
from trytond.pool import Pool, PoolMeta
from trytond.pyson import Bool, Eval, Id, If
from trytond.tools import grouped_slice, reduce_ids, sqlite_apply_types
from trytond.transaction import Transaction
from trytond.wizard import StateAction, Wizard

from .exceptions import InvoicingError


class Effort:
    __slots__ = ()

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.project_invoice_method.selection.append(
            ('effort', "On Effort"))

    @classmethod
    def _get_quantity_to_invoice_effort(cls, works):
        quantities = {}
        for work in works:
            if (work.progress == 1
                    and work.invoice_unit_price
                    and not work.invoice_line):
                if work.price_list_hour:
                    quantity = work.effort_hours
                else:
                    quantity = 1
                if work.unit_to_invoice:
                    quantity = work.unit_to_invoice.round(quantity)
                quantities[work.id] = quantity
        return quantities

    @classmethod
    def _get_invoiced_amount_effort(cls, works):
        pool = Pool()
        InvoiceLine = pool.get('account.invoice.line')
        Currency = pool.get('currency.currency')

        invoice_lines = InvoiceLine.browse([
                w.invoice_line.id for w in works
                if w.invoice_line])

        id2invoice_lines = dict((l.id, l) for l in invoice_lines)
        amounts = {}
        for work in works:
            currency = work.company.currency
            if work.invoice_line:
                invoice_line = id2invoice_lines[work.invoice_line.id]
                invoice_currency = (invoice_line.invoice.currency
                    if invoice_line.invoice else invoice_line.currency)
                if work.price_list_hour:
                    amount = (
                        Decimal(str(work.effort_hours))
                        * invoice_line.unit_price)
                else:
                    amount = invoice_line.unit_price
                amounts[work.id] = Currency.compute(
                    invoice_currency, amount, currency)
            else:
                amounts[work.id] = Decimal(0)
        return amounts

    def get_origins_to_invoice(self):
        try:
            origins = super().get_origins_to_invoice()
        except AttributeError:
            origins = []
        if self.invoice_method == 'effort':
            origins.append(self)
        return origins


class Progress:
    __slots__ = ()
    invoiced_progress = fields.One2Many('project.work.invoiced_progress',
        'work', 'Invoiced Progress', readonly=True)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.project_invoice_method.selection.append(
            ('progress', 'On Progress'))

    @classmethod
    def _get_quantity_to_invoice_progress(cls, works):
        pool = Pool()
        Progress = pool.get('project.work.invoiced_progress')

        cursor = Transaction().connection.cursor()
        table = cls.__table__()
        progress = Progress.__table__()

        invoiced_progress = {}
        quantities = {}
        for sub_works in grouped_slice(works):
            sub_works = list(sub_works)
            where = reduce_ids(
                table.id, [x.id for x in sub_works if x.invoice_unit_price])
            cursor.execute(*table.join(progress,
                    condition=progress.work == table.id
                    ).select(table.id, Sum(progress.progress),
                    where=where,
                    group_by=table.id))
            invoiced_progress.update(dict(cursor))

            for work in sub_works:
                delta = (
                    (work.progress or 0)
                    - invoiced_progress.get(work.id, 0.0))
                if work.invoice_unit_price and delta > 0:
                    quantity = delta
                    if work.price_list_hour:
                        quantity *= work.effort_hours
                    if work.unit_to_invoice:
                        quantity = work.unit_to_invoice.round(quantity)
                    quantities[work.id] = quantity
        return quantities

    @property
    def progress_to_invoice(self):
        if self.quantity_to_invoice:
            if self.price_list_hour:
                return self.quantity_to_invoice / self.effort_hours
            else:
                return self.quantity_to_invoice

    @classmethod
    def _get_invoiced_amount_progress(cls, works):
        pool = Pool()
        Progress = pool.get('project.work.invoiced_progress')
        InvoiceLine = pool.get('account.invoice.line')
        Company = pool.get('company.company')
        Currency = pool.get('currency.currency')

        cursor = Transaction().connection.cursor()
        table = cls.__table__()
        progress = Progress.__table__()
        invoice_line = InvoiceLine.__table__()
        company = Company.__table__()

        amounts = defaultdict(Decimal)
        work2currency = {}
        ids2work = dict((w.id, w) for w in works)
        for sub_ids in grouped_slice(ids2work.keys()):
            where = reduce_ids(table.id, sub_ids)
            query = (table.join(progress,
                    condition=progress.work == table.id
                    ).join(invoice_line,
                    condition=progress.invoice_line == invoice_line.id
                    ).select(
                    table.id,
                    Sum(Cast(progress.progress, 'NUMERIC')
                        * invoice_line.unit_price).as_('amount'),
                    where=where,
                    group_by=table.id))
            if backend.name == 'sqlite':
                sqlite_apply_types(query, [None, 'NUMERIC'])
            cursor.execute(*query)
            for work_id, amount in cursor:
                work = ids2work[work_id]
                if work.price_list_hour:
                    amount *= Decimal(str(work.effort_hours))
                amounts[work_id] = amount

            cursor.execute(*table.join(company,
                    condition=table.company == company.id
                    ).select(table.id, company.currency,
                    where=where))
            work2currency.update(cursor)

        currencies = Currency.browse(set(work2currency.values()))
        id2currency = {c.id: c for c in currencies}

        for work in works:
            currency = id2currency[work2currency[work.id]]
            amounts[work.id] = currency.round(amounts[work.id])
        return amounts

    def get_origins_to_invoice(self):
        pool = Pool()
        InvoicedProgress = pool.get('project.work.invoiced_progress')
        try:
            origins = super().get_origins_to_invoice()
        except AttributeError:
            origins = []
        if self.invoice_method == 'progress':
            invoiced_progress = InvoicedProgress(
                work=self, progress=self.progress_to_invoice)
            origins.append(invoiced_progress)
        return origins

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


class Timesheet:
    __slots__ = ()
    project_invoice_timesheet_up_to = fields.Date(
        "Invoice up to",
        states={
            'invisible': Eval('project_invoice_method') != 'timesheet',
            },
        depends=['project_invoice_method'],
        help="Limits which timesheet lines get invoiced to "
        "only those before the date.")
    invoice_timesheet_up_to = fields.Function(fields.Date(
            "Invoice up to"), 'on_change_with_invoice_timesheet_up_to')

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.project_invoice_method.selection.append(
            ('timesheet', 'On Timesheet'))
        cls.product.domain = [
            cls.product.domain,
            If(Eval('invoice_method') == 'timesheet',
                ('default_uom_category', '=', Id('product', 'uom_cat_time')),
                ()),
            ]
        if 'invoice_method' not in cls.product.depends:
            cls.product.depends.add('invoice_method')

    @fields.depends('type', 'project_invoice_timesheet_up_to',
        'parent', '_parent_parent.invoice_timesheet_up_to')
    def on_change_with_invoice_timesheet_up_to(self, name=None):
        if self.type == 'project':
            return self.project_invoice_timesheet_up_to
        elif self.parent:
            return self.parent.invoice_timesheet_up_to

    @classmethod
    def _get_quantity_to_invoice_timesheet(cls, works):
        pool = Pool()
        TimesheetLine = pool.get('timesheet.line')
        cursor = Transaction().connection.cursor()
        line = TimesheetLine.__table__()

        upto2tworks = defaultdict(list)
        twork2work = {}
        for work in works:
            upto = work.invoice_timesheet_up_to
            for timesheet_work in work.timesheet_works:
                twork2work[timesheet_work.id] = work.id
                upto2tworks[upto].append(timesheet_work.id)

        durations = defaultdict(datetime.timedelta)
        query = line.select(
            line.work, Sum(line.duration),
            group_by=line.work)
        for upto, tworks in upto2tworks.items():
            for sub_ids in grouped_slice(tworks):
                query.where = (reduce_ids(line.work, sub_ids)
                    & (line.invoice_line == Null))
                if upto:
                    query.where &= (line.date <= upto)
                cursor.execute(*query)

                for twork_id, duration in cursor:
                    if duration:
                        # SQLite uses float for SUM
                        if not isinstance(duration, datetime.timedelta):
                            duration = datetime.timedelta(seconds=duration)
                        durations[twork2work[twork_id]] += duration

        quantities = {}
        for work in works:
            duration = durations[work.id]
            if work.invoice_unit_price:
                hours = duration.total_seconds() / 60 / 60
                if work.unit_to_invoice:
                    hours = work.unit_to_invoice.round(hours)
                quantities[work.id] = hours
        return quantities

    @classmethod
    def _get_invoiced_amount_timesheet(cls, works):
        pool = Pool()
        TimesheetWork = pool.get('timesheet.work')
        TimesheetLine = pool.get('timesheet.line')
        InvoiceLine = pool.get('account.invoice.line')
        Company = pool.get('company.company')
        Currency = pool.get('currency.currency')

        cursor = Transaction().connection.cursor()
        table = cls.__table__()
        timesheet_work = TimesheetWork.__table__()
        timesheet_line = TimesheetLine.__table__()
        invoice_line = InvoiceLine.__table__()
        company = Company.__table__()

        amounts = {}
        work2currency = {}
        work_ids = [w.id for w in works]
        for sub_ids in grouped_slice(work_ids):
            where = reduce_ids(table.id, sub_ids)
            cursor.execute(*table.join(timesheet_work,
                    condition=(
                        Concat(cls.__name__ + ',', table.id)
                        == timesheet_work.origin)
                    ).join(timesheet_line,
                    condition=timesheet_line.work == timesheet_work.id
                    ).join(invoice_line,
                    condition=timesheet_line.invoice_line == invoice_line.id
                    ).select(table.id,
                    Sum(timesheet_line.duration * invoice_line.unit_price),
                    where=where,
                    group_by=table.id))
            amounts.update(cursor)

            cursor.execute(*table.join(company,
                    condition=table.company == company.id
                    ).select(table.id, company.currency,
                    where=where))
            work2currency.update(cursor)

        currencies = Currency.browse(set(work2currency.values()))
        id2currency = {c.id: c for c in currencies}

        for work in works:
            currency = id2currency[work2currency[work.id]]
            amount = amounts.get(work.id, 0)
            if isinstance(amount, datetime.timedelta):
                amount = amount.total_seconds()
            amount = amount / 60 / 60
            amounts[work.id] = currency.round(Decimal(str(amount)))
        return amounts

    def get_origins_to_invoice(self):
        try:
            origins = super().get_origins_to_invoice()
        except AttributeError:
            origins = []
        if self.invoice_method == 'timesheet':
            up_to = self.invoice_timesheet_up_to or datetime.date.max
            origins.extend(
                l for tw in self.timesheet_works
                for l in tw.timesheet_lines
                if not l.invoice_line and l.date <= up_to)
        return origins


class Work(Effort, Progress, Timesheet, metaclass=PoolMeta):
    __name__ = 'project.work'
    project_invoice_method = fields.Selection([
            ('manual', "Manual"),
            ], "Invoice Method",
        states={
            'readonly': Bool(Eval('invoiced_amount')),
            'required': Eval('type') == 'project',
            'invisible': Eval('type') != 'project',
            },
        depends=['invoiced_amount', 'type'])
    invoice_method = fields.Function(fields.Selection(
            'get_invoice_methods', "Invoice Method"),
        'on_change_with_invoice_method')
    quantity_to_invoice = fields.Function(
        fields.Float("Quantity to Invoice"), '_get_invoice_values')
    amount_to_invoice = fields.Function(Monetary(
            "Amount to Invoice", currency='currency', digits='currency',
            states={
                'invisible': Eval('invoice_method') == 'manual',
                },
            depends=['invoice_method']),
        'get_total')
    invoiced_amount = fields.Function(Monetary(
            "Invoiced Amount", currency='currency', digits='currency',
            states={
                'invisible': Eval('invoice_method') == 'manual',
                },
            depends=['invoice_method']),
        'get_total')
    invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
        readonly=True)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._buttons.update({
                'invoice': {
                    'invisible': ((Eval('type') != 'project')
                        | (Eval('project_invoice_method', 'manual')
                            == 'manual')),
                    'readonly': ~Eval('amount_to_invoice'),
                    'depends': [
                        'type', 'project_invoice_method', 'amount_to_invoice'],
                    },
                })

    @staticmethod
    def default_project_invoice_method():
        return 'manual'

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

    @classmethod
    def check_modification(cls, mode, works, values=None, external=False):
        super().check_modification(
            mode, works, values=values, external=external)
        if mode == 'write':
            if ('effort_duration' in values
                    and any(w.invoice_line for w in works)):
                work = next((w for w in works if w.invoice_line))
                raise AccessError(gettext(
                        'project_invoice.msg_invoiced_work_modify_effort',
                        work=work.rec_name))
        elif mode == 'delete':
            if any(w.invoice_line for w in works):
                work = next((w for w in works if w.invoice_line))
                raise AccessError(gettext(
                        'project_invoice.msg_invoiced_work_delete',
                        work=work.rec_name))

    @classmethod
    def get_invoice_methods(cls):
        field = 'project_invoice_method'
        return cls.fields_get(field)[field]['selection']

    @fields.depends('type', 'project_invoice_method',
        'parent', '_parent_parent.invoice_method')
    def on_change_with_invoice_method(self, name=None):
        if self.type == 'project':
            return self.project_invoice_method
        elif self.parent:
            return self.parent.invoice_method
        else:
            return 'manual'

    @classmethod
    def default_quantity_to_invoice(cls):
        return 0

    @classmethod
    def _get_quantity_to_invoice_manual(cls, works):
        return {}

    @classmethod
    def _get_amount_to_invoice(cls, works):
        amounts = defaultdict(Decimal)
        for work in works:
            amounts[work.id] = work.company.currency.round(
                (work.invoice_unit_price or 0)
                * Decimal(str(work.quantity_to_invoice)))
        return amounts

    @classmethod
    def default_invoiced_amount(cls):
        return Decimal(0)

    @classmethod
    def _get_invoiced_amount_manual(cls, works):
        return {}

    @classmethod
    def _get_invoice_values(cls, works, name):
        default = getattr(cls, 'default_%s' % name)
        amounts = defaultdict(default)
        method2works = defaultdict(list)
        for work in works:
            method2works[work.invoice_method].append(work)
        for method, m_works in method2works.items():
            method = getattr(cls, '_get_%s_%s' % (name, method))
            # Re-browse for cache alignment
            amounts.update(method(cls.browse(m_works)))
        return amounts

    @classmethod
    def _get_invoiced_amount(cls, works):
        return cls._get_invoice_values(works, 'invoiced_amount')

    @classmethod
    @ModelView.button
    def invoice(cls, works):
        pool = Pool()
        Invoice = pool.get('account.invoice')

        invoices = []
        uninvoiced = works[:]
        while uninvoiced:
            work = uninvoiced.pop(0)
            invoice_lines, uninvoiced_children = (
                work._get_all_lines_to_invoice())
            uninvoiced.extend(uninvoiced_children)
            if not invoice_lines:
                continue
            invoice = work._get_invoice()
            invoice.save()
            invoices.append(invoice)
            for key, lines in groupby(invoice_lines,
                    key=work._group_lines_to_invoice_key):
                lines = list(lines)
                key = dict(key)
                invoice_line = work._get_invoice_line(key, invoice, lines)
                invoice_line.invoice = invoice.id
                invoice_line.save()
                origins = defaultdict(list)
                for line in lines:
                    for origin in line['origins']:
                        origins[origin.__class__].append(origin)
                for klass, records in origins.items():
                    klass.save(records)  # Store first new origins
                    klass.write(records, {
                            'invoice_line': invoice_line.id,
                            })
        Invoice.update_taxes(invoices)

    def _get_invoice(self):
        "Return invoice for the work"
        pool = Pool()
        Invoice = pool.get('account.invoice')
        Journal = pool.get('account.journal')

        journals = Journal.search([
                ('type', '=', 'revenue'),
                ], limit=1)
        if journals:
            journal, = journals
        else:
            journal = None

        if not self.party:
            raise InvoicingError(
                gettext('project_invoice.msg_missing_party',
                    work=self.rec_name))

        return Invoice(
            company=self.company,
            type='out',
            journal=journal,
            party=self.party,
            invoice_address=self.party.address_get(type='invoice'),
            currency=self.company.currency,
            account=self.party.account_receivable_used,
            payment_term=self.party.customer_payment_term,
            description=self.name,
            )

    def _group_lines_to_invoice_key(self, line):
        "The key to group lines"
        return (('product', line['product']),
            ('unit', line['unit']),
            ('unit_price', line['unit_price']),
            ('description', line['description'] or ''))

    def _get_invoice_line(self, key, invoice, lines):
        "Return a invoice line for the lines"
        pool = Pool()
        InvoiceLine = pool.get('account.invoice.line')

        quantity = sum(l['quantity'] for l in lines)
        product = key['product']

        invoice_line = InvoiceLine(invoice=invoice)
        invoice_line.on_change_invoice()
        invoice_line.type = 'line'
        invoice_line.description = key['description']
        invoice_line.unit_price = key['unit_price']
        invoice_line.quantity = quantity
        invoice_line.unit = key['unit']
        invoice_line.product = product
        invoice_line.on_change_product()
        if not getattr(invoice_line, 'account', None):
            if invoice_line.product:
                raise InvoicingError(
                    gettext(
                        'project_invoice.msg_product_missing_account_revenue',
                        work=self.rec_name,
                        product=invoice_line.product.rec_name))
            else:
                raise InvoicingError(
                    gettext('project_invoice.msg_missing_account_revenue',
                        work=self.rec_name))
        return invoice_line

    def _test_group_invoice(self):
        return (self.company, self.party)

    def _get_all_lines_to_invoice(self, test=None):
        "Return lines for work and children"
        lines = []
        if test is None:
            test = self._test_group_invoice()
        uninvoiced_children = []
        lines += self._get_lines_to_invoice()
        for children in self.children:
            if children.type == 'project':
                if test != children._test_group_invoice():
                    uninvoiced_children.append(children)
                    continue
            child_lines, uninvoiced = children._get_all_lines_to_invoice(
                test=test)
            lines.extend(child_lines)
            uninvoiced_children.extend(uninvoiced)
        return lines, uninvoiced_children

    def _get_lines_to_invoice(self):
        if self.quantity_to_invoice:
            if self.invoice_unit_price is None:
                raise InvoicingError(
                    gettext('project_invoice.msg_missing_list_price',
                        work=self.rec_name))
            return [{
                    'product': self.product,
                    'quantity': self.quantity_to_invoice,
                    'unit': self.unit_to_invoice,
                    'unit_price': self.invoice_unit_price,
                    'origins': self.get_origins_to_invoice(),
                    'description': self.name,
                    }]
        return []

    @property
    def invoice_unit_price(self):
        return self.list_price

    @property
    def unit_to_invoice(self):
        pool = Pool()
        ModelData = pool.get('ir.model.data')
        Uom = pool.get('product.uom')
        if self.price_list_hour:
            return Uom(ModelData.get_id('product', 'uom_hour'))
        elif self.product:
            return self.product.default_uom

    def get_origins_to_invoice(self):
        return super().get_origins_to_invoice()


class WorkInvoicedProgress(ModelView, ModelSQL):
    __name__ = 'project.work.invoiced_progress'
    work = fields.Many2One('project.work', "Work", ondelete='RESTRICT')
    progress = fields.Float('Progress', required=True,
        domain=[
            ('progress', '>=', 0),
            ])
    invoice_line = fields.Many2One('account.invoice.line', 'Invoice Line',
        ondelete='CASCADE')


class OpenInvoice(Wizard):
    __name__ = 'project.open_invoice'
    start_state = 'open_'
    _readonly = True
    open_ = StateAction('account_invoice.act_invoice_form')

    def do_open_(self, action):
        works = self.model.search([
                ('parent', 'child_of', list(map(int, self.records))),
                ])
        invoice_ids = set()
        for work in works:
            if work.invoice_line and work.invoice_line.invoice:
                invoice_ids.add(work.invoice_line.invoice.id)
            for twork in work.timesheet_works:
                for timesheet_line in twork.timesheet_lines:
                    if (timesheet_line.invoice_line
                            and timesheet_line.invoice_line.invoice):
                        invoice_ids.add(timesheet_line.invoice_line.invoice.id)
            if work.invoiced_progress:
                for progress in work.invoiced_progress:
                    invoice_ids.add(progress.invoice_line.invoice.id)
        return action, {'res_id': list(invoice_ids)}
