# 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
import logging

from decimal import Decimal, ROUND_HALF_EVEN, localcontext

from dateutil.relativedelta import relativedelta

from sql import Window
from sql.functions import NthValue

from trytond.i18n import gettext
from trytond.model import (
    ModelView, ModelSQL, DeactivableMixin, fields, Unique, Check, SymbolMixin)
from trytond.transaction import Transaction
from trytond.pool import Pool
from trytond.rpc import RPC
from trytond.pyson import Eval, If

from .ecb import RatesNotAvailableError, get_rates
from .exceptions import RateError
from .ir import rate_decimal

logger = logging.getLogger(__name__)


class Currency(SymbolMixin, DeactivableMixin, ModelSQL, ModelView):
    'Currency'
    __name__ = 'currency.currency'
    name = fields.Char('Name', required=True, translate=True,
        help="The main identifier of the currency.")
    symbol = fields.Char('Symbol', size=10, required=True,
        help="The symbol used for currency formating.")
    code = fields.Char('Code', size=3, required=True,
        help="The 3 chars ISO currency code.")
    numeric_code = fields.Char('Numeric Code', size=3,
        help="The 3 digits ISO currency code.")
    rate = fields.Function(fields.Numeric(
            "Current rate", digits=(rate_decimal * 2, rate_decimal)),
        'get_rate')
    rates = fields.One2Many('currency.currency.rate', 'currency', 'Rates',
        help="Add floating exchange rates for the currency.")
    rounding = fields.Numeric('Rounding factor', required=True,
        digits=(12, Eval('digits', 6)), depends=['digits'],
        help="The minimum amount which can be represented in this currency.")
    digits = fields.Integer("Digits", required=True,
        help="The number of digits to display after the decimal separator.")

    @classmethod
    def __setup__(cls):
        super(Currency, cls).__setup__()
        cls._order.insert(0, ('code', 'ASC'))
        cls.__rpc__.update({
                'compute': RPC(instantiate=slice(0, 3, 2)),
                })

    @classmethod
    def __register__(cls, module_name):
        pool = Pool()
        Data = pool.get('ir.model.data')
        data = Data.__table__()
        cursor = Transaction().connection.cursor()

        super(Currency, cls).__register__(module_name)

        table_h = cls.__table_handler__(module_name)

        # Migration from 4.6: removal of monetary
        for col in [
                'mon_grouping', 'mon_decimal_point',
                'p_sign_posn', 'n_sign_posn']:
            table_h.not_null_action(col, 'remove')

        # Migration from 5.2: remove country data
        cursor.execute(*data.delete(where=(data.module == 'currency')
                & (data.model == cls.__name__)))

    @staticmethod
    def default_rounding():
        return Decimal('0.01')

    @staticmethod
    def default_digits():
        return 2

    @classmethod
    def check_xml_record(cls, records, values):
        return True

    @classmethod
    def search_global(cls, text):
        for record, rec_name, icon in super(Currency, cls).search_global(text):
            icon = icon or 'tryton-currency'
            yield record, rec_name, icon

    @classmethod
    def search_rec_name(cls, name, clause):
        currencies = None
        field = None
        for field in ('code', 'numeric_code'):
            currencies = cls.search([(field,) + tuple(clause[1:])], limit=1)
            if currencies:
                break
        if currencies:
            return [(field,) + tuple(clause[1:])]
        return [(cls._rec_name,) + tuple(clause[1:])]

    @fields.depends('rates')
    def on_change_with_rate(self):
        now = datetime.date.today()
        closer = datetime.date.min
        res = Decimal('0.0')
        for rate in self.rates or []:
            date = getattr(rate, 'date', None) or now
            if date <= now and date > closer:
                res = rate.rate
                closer = date
        return res

    @staticmethod
    def get_rate(currencies, name):
        '''
        Return the rate at the date from the context or the current date
        '''
        Rate = Pool().get('currency.currency.rate')
        Date = Pool().get('ir.date')

        res = {}
        date = Transaction().context.get('date', Date.today())
        for currency in currencies:
            rates = Rate.search([
                    ('currency', '=', currency.id),
                    ('date', '<=', date),
                    ], limit=1, order=[('date', 'DESC')])
            if rates:
                res[currency.id] = rates[0].id
            else:
                res[currency.id] = 0
        rate_ids = [x for x in res.values() if x]
        rates = Rate.browse(rate_ids)
        id2rate = {}
        for rate in rates:
            id2rate[rate.id] = rate
        for currency_id in res.keys():
            if res[currency_id]:
                res[currency_id] = id2rate[res[currency_id]].rate
        return res

    def round(self, amount, rounding=ROUND_HALF_EVEN):
        'Round the amount depending of the currency'
        return self._round(amount, self.rounding, rounding)

    @classmethod
    def _round(cls, amount, factor, rounding):
        if not factor:
            return amount
        with localcontext() as ctx:
            ctx.prec = max(ctx.prec, (amount / factor).adjusted() + 1)
            # Divide and multiple by factor for case factor is not 10En
            result = (amount / factor).quantize(Decimal('1.'),
                    rounding=rounding) * factor
        return Decimal(result)

    def is_zero(self, amount):
        'Return True if the amount can be considered as zero for the currency'
        if not self.rounding:
            return not amount
        return abs(self.round(amount)) < abs(self.rounding)

    @classmethod
    def compute(cls, from_currency, amount, to_currency, round=True):
        '''
        Take a currency and an amount
        Return the amount to the new currency
        Use the rate of the date of the context or the current date
        '''
        Date = Pool().get('ir.date')
        Lang = Pool().get('ir.lang')
        from_currency = cls(int(from_currency))
        to_currency = cls(int(to_currency))

        if to_currency == from_currency:
            if round:
                return to_currency.round(amount)
            else:
                return amount
        if (not from_currency.rate) or (not to_currency.rate):
            date = Transaction().context.get('date', Date.today())
            if not from_currency.rate:
                name = from_currency.name
            else:
                name = to_currency.name

            lang = Lang.get()
            raise RateError(gettext('currency.msg_no_rate',
                    currency=name,
                    date=lang.strftime(date)))
        if round:
            return to_currency.round(
                amount * to_currency.rate / from_currency.rate)
        else:
            return amount * to_currency.rate / from_currency.rate

    @classmethod
    def currency_rate_sql(cls):
        "Return a SQL query with currency, rate, start_date and end_date"
        pool = Pool()
        Rate = pool.get('currency.currency.rate')
        transaction = Transaction()
        database = transaction.database

        rate = Rate.__table__()
        if database.has_window_functions():
            window = Window(
                [rate.currency],
                order_by=[rate.date.asc],
                frame='ROWS', start=0, end=1)
            # Use NthValue instead of LastValue to get NULL for the last row
            end_date = NthValue(rate.date, 2, window=window)
        else:
            next_rate = Rate.__table__()
            end_date = next_rate.select(
                next_rate.date,
                where=(next_rate.currency == rate.currency)
                & (next_rate.date > rate.date),
                order_by=[next_rate.date.asc],
                limit=1)

        query = (rate
            .select(
                rate.currency.as_('currency'),
                rate.rate.as_('rate'),
                rate.date.as_('start_date'),
                end_date.as_('end_date'),
                ))
        return query

    def get_symbol(self, sign, symbol=None):
        pool = Pool()
        Lang = pool.get('ir.lang')
        lang = Lang.get()
        symbol, position = super().get_symbol(sign, symbol=symbol)
        if ((sign < 0 and lang.n_cs_precedes)
                or (sign >= 0 and lang.p_cs_precedes)):
            position = 0
        return symbol, position


class CurrencyRate(ModelSQL, ModelView):
    "Currency Rate"
    __name__ = 'currency.currency.rate'
    date = fields.Date('Date', required=True, select=True,
        help="From when the rate applies.")
    rate = fields.Numeric(
        "Rate", digits=(rate_decimal * 2, rate_decimal), required=1,
        help="The floating exchange rate used to convert the currency.")
    currency = fields.Many2One('currency.currency', 'Currency',
            ondelete='CASCADE',
        help="The currency on which the rate applies.")

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls.__access__.add('currency')
        t = cls.__table__()
        cls._sql_constraints = [
            ('date_currency_uniq', Unique(t, t.date, t.currency),
                'currency.msg_currency_unique_rate_date'),
            ('check_currency_rate', Check(t, t.rate >= 0),
                'currency.msg_currency_rate_positive'),
            ]
        cls._order.insert(0, ('date', 'DESC'))

    @staticmethod
    def default_date():
        Date = Pool().get('ir.date')
        return Date.today()

    @classmethod
    def check_xml_record(cls, records, values):
        return True

    def get_rec_name(self, name):
        return str(self.date)


class CronFetchError(Exception):
    pass


class Cron(ModelSQL, ModelView):
    "Currency Cron"
    __name__ = 'currency.cron'

    source = fields.Selection(
        [('ecb', "European Central Bank")],
        "Source", required=True,
        help="The external source for rates.")
    frequency = fields.Selection([
            ('daily', "Daily"),
            ('weekly', "Weekly"),
            ('monthly', "Monthly"),
            ], "Frequency", required=True,
        help="How frequently rates must be updated.")
    weekday = fields.Many2One(
        'ir.calendar.day', "Day of Week",
        states={
            'required': Eval('frequency') == 'weekly',
            'invisible': Eval('frequency') != 'weekly',
            },
        depends=['frequency'])
    day = fields.Integer(
        "Day of Month",
        domain=[If(Eval('frequency') == 'monthly',
                [('day', '>=', 1), ('day', '<=', 31)],
                [('day', '=', None)]),
            ],
        states={
            'required': Eval('frequency') == 'monthly',
            'invisible': Eval('frequency') != 'monthly',
            },
        depends=['frequency'])
    currency = fields.Many2One(
        'currency.currency', "Currency", required=True,
        help="The base currency to fetch rate.")
    currencies = fields.Many2Many(
        'currency.cron-currency.currency', 'cron', 'currency', "Currencies",
        help="The currencies to update the rate.")
    last_update = fields.Date("Last Update", required=True)

    @classmethod
    def __setup__(cls):
        super().__setup__()
        cls._buttons.update({
                'run': {},
                })

    @classmethod
    def default_frequency(cls):
        return 'monthly'

    @classmethod
    def default_day(cls):
        return 1

    @classmethod
    def default_last_update(cls):
        pool = Pool()
        Date = pool.get('ir.date')
        return Date.today()

    @classmethod
    @ModelView.button
    def run(cls, crons):
        cls.update(crons)

    @classmethod
    def update(cls, crons=None):
        pool = Pool()
        Rate = pool.get('currency.currency.rate')
        if crons is None:
            crons = cls.search([])
        rates = []
        for cron in crons:
            rates.extend(cron._update())
        Rate.save(rates)
        cls.save(crons)

    def _update(self):
        limit = self.limit_update()
        date = self.next_update()
        while date <= limit:
            try:
                yield from self._rates(date)
            except CronFetchError:
                logger.warning("Could not fetch rates temporary")
                if date >= datetime.date.today():
                    break
            except Exception:
                logger.error("Fail to fetch rates", exc_info=True)
                break
            self.last_update = date
            date = self.next_update()

    def next_update(self):
        return self.last_update + self.delta()

    def limit_update(self):
        pool = Pool()
        Date = pool.get('ir.date')
        return Date.today()

    def delta(self):
        if self.frequency == 'daily':
            delta = relativedelta(days=1)
        elif self.frequency == 'weekly':
            delta = relativedelta(weeks=1, weekday=int(self.weekday.index))
        elif self.frequency == 'monthly':
            delta = relativedelta(months=1, day=self.day)
        else:
            delta = relativedelta()
        return delta

    def _rates(self, date, rounding=None):
        pool = Pool()
        Rate = pool.get('currency.currency.rate')
        values = getattr(self, 'fetch_%s' % self.source)(date)

        exp = Decimal(Decimal(1) / 10 ** Rate.rate.digits[1])
        rates = Rate.search([
                ('date', '=', date),
                ])
        code2rates = {r.currency.code: r for r in rates}

        def get_rate(currency):
            if currency.code in code2rates:
                rate = code2rates[currency.code]
            else:
                rate = Rate(date=date, currency=currency)
            return rate

        rate = get_rate(self.currency)
        rate.rate = Decimal(1).quantize(exp, rounding=rounding)
        yield rate

        for currency in self.currencies:
            if currency.code not in values:
                continue
            value = values[currency.code]
            if not isinstance(value, Decimal):
                value = Decimal(value)
            rate = get_rate(currency)
            rate.rate = value.quantize(exp, rounding=rounding)
            yield rate

    def fetch_ecb(self, date):
        try:
            return get_rates(self.currency.code, date)
        except RatesNotAvailableError as e:
            raise CronFetchError() from e


class Cron_Currency(ModelSQL):
    "Currency Cron - Currency"
    __name__ = 'currency.cron-currency.currency'

    cron = fields.Many2One(
        'currency.cron', "Cron",
        required=True, select=True, ondelete='CASCADE')
    currency = fields.Many2One(
        'currency.currency', "Currency",
        required=True, ondelete='CASCADE')
