# 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 decimal
import logging
from decimal import Decimal, localcontext

from dateutil.relativedelta import relativedelta
from sql import Window
from sql.functions import NthValue

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

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

logger = logging.getLogger(__name__)


ROUNDING_OPPOSITES = {
    decimal.ROUND_HALF_EVEN: decimal.ROUND_HALF_EVEN,
    decimal.ROUND_HALF_UP: decimal.ROUND_HALF_DOWN,
    decimal.ROUND_HALF_DOWN: decimal.ROUND_HALF_UP,
    decimal.ROUND_UP: decimal.ROUND_DOWN,
    decimal.ROUND_DOWN: decimal.ROUND_UP,
    decimal.ROUND_CEILING: decimal.ROUND_FLOOR,
    decimal.ROUND_FLOOR: decimal.ROUND_CEILING,
    }


class Currency(
        SymbolMixin, DigitsMixin, 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, strip=False,
        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)),
        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 5.2: remove country data
        cursor.execute(*data.delete(where=(data.module == 'currency')
                & (data.model == cls.__name__)))

        # Migration from 6.6: remove required on symbol
        table_h.not_null_action('symbol', 'remove')

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

    @staticmethod
    def default_digits():
        return 2

    @classmethod
    def check_xml_record(cls, records, values):
        pass

    @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)
        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=decimal.ROUND_HALF_EVEN, opposite=False):
        'Round the amount depending of the currency'
        if opposite:
            rounding = ROUNDING_OPPOSITES[rounding]
        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 not symbol:
            symbol = self.code
        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,
        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._sql_indexes.add(
            Index(
                t,
                (t.currency, Index.Equality()),
                (t.date, Index.Range()),
                order='DESC'))
        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):
        pass

    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',
            })
    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',
            })
    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, ondelete='CASCADE')
    currency = fields.Many2One(
        'currency.currency', "Currency",
        required=True, ondelete='CASCADE')
