# 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 sql import Literal, Null, With
from sql.aggregate import Min
from sql.functions import CurrentTimestamp, Extract

import trytond.config as config
from trytond.model import Index, ModelSQL, fields
from trytond.pool import Pool
from trytond.tools import grouped_slice
from trytond.transaction import (
    Transaction, inactive_records, without_check_access)


class Queue(ModelSQL):
    __name__ = 'ir.queue'
    name = fields.Char("Name", required=True)

    data = fields.Dict(None, "Data")

    enqueued_at = fields.Timestamp("Enqueued at", required=True)
    dequeued_at = fields.Timestamp("Dequeued at")
    finished_at = fields.Timestamp("Finished at")

    scheduled_at = fields.Timestamp("Scheduled at",
        help="When the task can start.")
    expected_at = fields.Timestamp("Expected at",
        help="When the task should be done.")

    @classmethod
    def __setup__(cls):
        super().__setup__()
        table = cls.__table__()

        cls._sql_indexes.add(
            Index(
                table,
                (table.scheduled_at, Index.Range(nulls_first=True)),
                (table.expected_at, Index.Range(nulls_first=True)),
                (table.dequeued_at, Index.Equality()),
                (table.name, Index.Equality())))

    @classmethod
    def default_enqueued_at(cls):
        return datetime.datetime.now()

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

    @classmethod
    def push(cls, name, data, scheduled_at=None, expected_at=None):
        transaction = Transaction()
        database = transaction.database
        with without_check_access():
            record, = cls.create([{
                        'name': name,
                        'data': data,
                        'scheduled_at': scheduled_at,
                        'expected_at': expected_at,
                        }])
        if database.has_channel():
            database.notify(transaction.connection, cls.__name__, '')
        if not config.getboolean('queue', 'worker', default=False):
            transaction.tasks.append(record.id)
        return record.id

    @classmethod
    def pull(cls, database, connection, name=None):
        cursor = connection.cursor()
        queue = cls.__table__()
        queue_c = cls.__table__()
        queue_s = cls.__table__()

        candidates = With('id', 'scheduled_at', 'expected_at',
            query=queue_c.select(
                queue_c.id,
                queue_c.scheduled_at,
                queue_c.expected_at,
                where=((queue_c.name == name) if name else Literal(True))
                & (queue_c.dequeued_at == Null),
                order_by=[
                    queue_c.scheduled_at.nulls_first,
                    queue_c.expected_at.nulls_first]))
        selected = queue_s.select(
            queue_s.id,
            where=((queue_s.name == name) if name else Literal(True))
            & (queue_s.dequeued_at == Null)
            & ((queue_s.scheduled_at <= CurrentTimestamp())
                | (queue_s.scheduled_at == Null)),
            order_by=[
                queue_s.scheduled_at.nulls_first,
                queue_s.expected_at.nulls_first],
            limit=1)
        if database.has_select_for():
            For = database.get_select_for_skip_locked()
            selected.for_ = For('UPDATE')

        next_timeout = With('seconds', query=candidates.select(
                Min(Extract('EPOCH',
                        candidates.scheduled_at - CurrentTimestamp())
                    ),
                where=candidates.scheduled_at >= CurrentTimestamp()))

        task_id, seconds = None, None
        if database.has_returning():
            query = queue.update([queue.dequeued_at], [CurrentTimestamp()],
                where=queue.id.in_(selected),
                with_=[candidates, next_timeout],
                returning=[
                    queue.id, next_timeout.select(next_timeout.seconds)])
            cursor.execute(*query)
            row = cursor.fetchone()
            if row:
                task_id, seconds = row
        else:
            query = queue.select(queue.id,
                where=queue.id.in_(selected),
                with_=[candidates])
            cursor.execute(*query)
            row = cursor.fetchone()
            if row:
                task_id, = row
                query = queue.update([queue.dequeued_at], [CurrentTimestamp()],
                    where=queue.id == task_id)
                cursor.execute(*query)
            query = next_timeout.select(next_timeout.seconds)
            cursor.execute(*query)
            row = cursor.fetchone()
            if row:
                seconds, = row

        if not task_id and database.has_channel():
            cursor.execute('LISTEN "%s"' % cls.__name__)
        return task_id, seconds

    def run(self):
        transaction = Transaction()
        Model = Pool().get(self.data['model'])
        with transaction.set_user(self.data['user']), \
                transaction.set_context(
                    self.data['context'], _skip_warnings=True):
            instances = self.data['instances']
            # Ensure record ids still exist
            if isinstance(instances, int):
                with inactive_records():
                    if Model.search([('id', '=', instances)]):
                        instances = Model(instances)
                    else:
                        instances = None
            else:
                with inactive_records():
                    records = Model.search([('id', 'in', instances)])
                if ids := set(map(int, records)):
                    instances = Model.browse(
                        [i for i in instances if i in ids])
                else:
                    instances = None
            if instances is not None:
                getattr(Model, self.data['method'])(
                    instances, *self.data['args'], **self.data['kwargs'])
        if not self.dequeued_at:
            self.dequeued_at = datetime.datetime.now()
        self.finished_at = datetime.datetime.now()
        self.save()

    @classmethod
    def clean(cls, date=None):
        if date is None:
            date = (
                datetime.datetime.now() - datetime.timedelta(
                    days=config.getint('queue', 'clean_days', default=30)))
        tasks = cls.search(['OR',
                ('dequeued_at', '<', date),
                ('finished_at', '<', date),
                ])
        cls.delete(tasks)

    @classmethod
    def caller(cls, model):
        return _Model(cls, model)


class _Model(object):
    def __init__(self, queue, model):
        self.__queue = queue
        self.__model = model

    def __getattr__(self, name):
        return _Method(self.__queue, self.__model, name)


class _Method(object):
    def __init__(self, queue, model, name):
        self.__queue = queue
        self.__model = model
        self.__name = name

    def __call__(self, instances, *args, **kwargs):
        transaction = Transaction()
        context = transaction.context.copy()
        has_worker = config.getboolean('queue', 'worker', default=False)
        name = context.pop('queue_name', 'default')
        now = datetime.datetime.now()
        scheduled_at = context.pop('queue_scheduled_at', None)
        if scheduled_at is not None:
            scheduled_at = now + scheduled_at
        expected_at = context.pop('queue_expected_at', None)
        queue_batch = context.pop('queue_batch', None)
        context.pop('_check_access', None)
        context.pop('language', None)
        if expected_at is not None:
            expected_at = now + expected_at
        try:
            instances = list(map(int, instances))
        except TypeError:
            instances = int(instances)

        def _push(instances):
            data = {
                'model': self.__model.__name__,
                'method': self.__name,
                'user': transaction.user,
                'context': context,
                'instances': instances,
                'args': args,
                'kwargs': kwargs,
                }
            return self.__queue.push(
                name, data,
                scheduled_at=scheduled_at, expected_at=expected_at)

        if isinstance(instances, list):
            if has_worker and queue_batch:
                if isinstance(queue_batch, bool):
                    count = config.getint('queue', 'batch_size', default=20)
                else:
                    count = int(queue_batch)
            else:
                count = len(instances)
            task_ids = []
            for sub_instances in grouped_slice(instances, count=count):
                task_ids.append(_push(list(sub_instances)))
            return task_ids
        else:
            return _push(instances)
