# 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 collections
import json
import logging
import os
import selectors
import threading
import time
import uuid

import trytond.config as config
from trytond import backend
from trytond.protocols.jsonrpc import JSONDecoder, JSONEncoder
from trytond.protocols.wrappers import HTTPStatus, Response, abort, exceptions
from trytond.tools import resolve
from trytond.transaction import Transaction
from trytond.wsgi import app

logger = logging.getLogger(__name__)


class _MessageQueue:

    Message = collections.namedtuple('Message', 'channel content timestamp')

    def __init__(self, timeout):
        super().__init__()
        self._lock = collections.defaultdict(threading.Lock)
        self._timeout = timeout
        self._messages = []

    def append(self, channel, element):
        self._messages.append(
            self.Message(channel, element, time.time()))

    def get_next(self, channels, from_id=None):
        oldest = time.time() - self._timeout
        to_delete_index = 0
        found = False
        first_message = None
        message = self.Message(None, None, None)
        for idx, item in enumerate(self._messages):
            if item.timestamp < oldest:
                to_delete_index = idx
                continue
            if item.channel not in channels:
                continue
            if not first_message:
                first_message = item
            if from_id is None or found:
                message = item
                break
            found = item.content['message_id'] == from_id
        else:
            if first_message and not found:
                message = first_message

        with self._lock[os.getpid()]:
            del self._messages[:to_delete_index]

        return message.channel, message.content


class LongPollingBus:

    _channel = 'bus'
    _queues_lock = collections.defaultdict(threading.Lock)
    _queues = collections.defaultdict(
        lambda: {'timeout': None, 'events': collections.defaultdict(list)})
    _messages = {}

    @classmethod
    def subscribe(cls, database, channels, last_message=None):
        pid = os.getpid()
        with cls._queues_lock[pid]:
            start_listener = (pid, database) not in cls._queues
            cls._queues[pid, database]['timeout'] = (
                time.time() + config.getint('database', 'timeout'))
            if start_listener:
                listener = threading.Thread(
                    target=cls._listen, args=(database,), daemon=True)
                cls._queues[pid, database]['listener'] = listener
                listener.start()

        logger.info("subscribe to '%s' on '%s'", ','.join(channels), database)
        messages = cls._messages.get(database)
        if messages:
            channel, content = messages.get_next(channels, last_message)
            if content:
                return cls.create_response(channel, content)

        event = threading.Event()
        try:
            for channel in channels:
                with cls._queues_lock[pid]:
                    event_channel = cls._queues[pid, database][
                        'events'][channel]
                    event_channel.append(event)

            triggered = event.wait(
                config.getint('bus', 'long_polling_timeout'))
            if not triggered:
                response = cls.create_response(None, None)
            else:
                response = cls.create_response(
                    *cls._messages[database].get_next(channels, last_message))
        finally:
            with cls._queues_lock[pid]:
                queue_events = cls._queues[pid, database]['events']
                for channel in channels:
                    if channel not in queue_events:
                        continue
                    events = queue_events[channel]
                    try:
                        events.remove(event)
                    except ValueError:
                        pass
                    if not events:
                        # A user could query a lot of channels filling the
                        # events dictionnary with empty lists
                        del queue_events[channel]

        return response

    @classmethod
    def create_response(cls, channel, message):
        response_data = {
            'message': message,
            'channel': channel,
            }
        logger.debug('Bus: %r', response_data)
        return response_data

    @classmethod
    def _listen(cls, database):
        db = backend.Database(database)
        if not db.has_channel():
            raise exceptions.NotImplemented

        logger.info("start listener for '%s'", database)
        conn = db.get_connection(autocommit=True)
        pid = os.getpid()
        selector = selectors.DefaultSelector()
        try:
            cursor = conn.cursor()
            cursor.execute('LISTEN "%s"' % cls._channel)

            cls._messages[database] = messages = _MessageQueue(
                config.getint('bus', 'cache_timeout'))

            now = time.time()
            selector.register(conn, selectors.EVENT_READ)
            while cls._queues[pid, database]['timeout'] > now:
                selector.select(timeout=config.getint('bus', 'select_timeout'))
                notifications = db.get_notifications(conn)
                while notifications:
                    notification = notifications.pop()
                    payload = json.loads(
                        notification.payload,
                        object_hook=JSONDecoder())
                    channel = payload['channel']
                    message = payload['message']
                    messages.append(channel, message)

                    with cls._queues_lock[pid]:
                        events = cls._queues[pid, database][
                            'events'][channel].copy()
                        cls._queues[pid, database]['events'][channel].clear()
                    for event in events:
                        event.set()
                now = time.time()
                # Keep connected
                cursor.execute('SELECT 1')
        except Exception:
            logger.exception("bus listener on '%s' crashed", database)

            with cls._queues_lock[pid]:
                del cls._queues[pid, database]
            raise
        finally:
            selector.close()
            db.put_connection(conn)

        with cls._queues_lock[pid]:
            if cls._queues[pid, database]['timeout'] <= now:
                del cls._queues[pid, database]
            else:
                # A query arrived between the end of the while and here
                listener = threading.Thread(
                    target=cls._listen, args=(database,), daemon=True)
                cls._queues[pid, database]['listener'] = listener
                listener.start()

    @classmethod
    def publish(cls, channel, message):
        transaction = Transaction()
        if not transaction.database.has_channel():
            logger.info("database backend does not support channels")
            return

        message['message_id'] = str(uuid.uuid4())
        payload = json.dumps({
                'channel': channel,
                'message': message,
                }, cls=JSONEncoder, separators=(',', ':'))
        transaction.database.notify(
            transaction.connection, cls._channel, payload)
        logger.debug(
            "publish %r to '%s' on '%s'",
            message, channel, transaction.database.name)


if config.get('bus', 'class'):
    Bus = resolve(config.get('bus', 'class'))
else:
    Bus = LongPollingBus


@app.route('/<string:database_name>/bus', methods=['POST'])
@app.session_valid
def subscribe(request, database_name):
    if not config.getboolean('bus', 'allow_subscribe'):
        raise exceptions.NotImplemented
    url_host = config.get('bus', 'url_host')
    if url_host and url_host != request.host_url:
        abort(HTTPStatus.UNAUTHORIZED)
    if session := request.session:
        userid = session.userid
    else:
        abort(HTTPStatus.UNAUTHORIZED)
    channels = request.parsed_data.get('channels', [])
    if userid is None:
        raise exceptions.BadRequest

    channels = set(filter(lambda c: not c.startswith('user:'), channels))
    channels.add('user:%s' % userid)

    last_message = request.parsed_data.get('last_message')

    logger.info(
        "get bus messages for %s since %s from %s@%s%s",
        channels, last_message, session.username,
        request.remote_addr, request.path)
    bus_response = Bus.subscribe(database_name, channels, last_message)
    return Response(
        json.dumps(bus_response, cls=JSONEncoder, separators=(',', ':')),
        content_type='application/json')


def notify(title, body=None, priority=1, user=None, client=None):
    if user is None:
        if client is None:
            context_client = Transaction().context.get('client')
            if context_client:
                channel = 'client:%s' % context_client
            else:
                return
        else:
            channel = 'client:%s' % client
    elif client is None:
        channel = 'user:%s' % user
    else:
        channel = 'client:%s' % client

    return Bus.publish(channel, {
            'type': 'notification',
            'title': title,
            'body': body,
            'priority': priority,
            })
