import asyncio
import copy
import json
from typing import Any, Awaitable, Callable, Dict, List

import redis
import redis.asyncio as aioredis
from redis.asyncio.client import PubSub

from .. import app_config, util
from ..backend import Backend, BackendConfig, BackendOptions
from ..logger import Logger
from ..request import Request
from ..response import Response

NAME = 'Redis'
"""
Name of the main class of the module. Used by the App class
to obtain the correct class via getattr
"""

Channel = str
TaskName = str


class RedisConfig(BackendConfig):
    """Redis backend config attributes.

    Used during backend initialisation and do
    not change during the runtime

    Attributes:
        host (str): Redis server host
        port (int): Redis server port
        db (int): Redis server db
        init_reconnect_timeout (float): reconnect timeout
            when the backend starts
        reconnect_timeout (float): reconnect timeout
            when the backend is running and the connection is lost
        health_check_interval (int): interval between performing
            health checks by the driver

    """
    host: str
    port: int
    db: int
    init_reconnect_timeout: float
    reconnect_timeout: float
    health_check_interval: int


class RedisOptions(BackendOptions):
    """Redis backend options.

    Used by tasks when they attach to the backend

    Attributes:
        control_channel (str): a Redis channel that the backend
            will subscribe to for incoming messages

    """
    control_channel: str


class Redis(Backend):
    """Redis backend class, inherits Backend.

    Attributes:
        _config (RedisConfig): Redis config
        _connection (aioredis.Redis): Redis connection
        _pubsub (PubSub): Redis connection pub/sub object
        _connected (bool): True if connected to Redis, otherwise False
        _subs (List[Channel]): List of subscribed channels (names are unique)
        _subs_attached (Dict[Channel, List[TaskName]]):
            Map <channel_name> -> [<task_name>]
            to access all tasks that are subscribed to a channel with the name
        _attached (Dict[TaskName, RedisOptions]):
            Map <task_name> -> RedisOptions
            of tasks that are attached to the backend
        _callbacks (Dict[TaskName, Callable[[Any], Awaitable[None]]]):
            Map <task_name> -> <callback>
            of tasks and their callbacks
            e.g.: to fetch all callbacks for a channel it is used as
                for channel_name in _subs_attached:
                    for callback in _callbacks[_subs_attached[channel_name]]
                        callback()
        _running (bool): true when the backend is started, otherwise false
        _pubsub_poll_interval (float): poll interval
            for incoming pubsub messages
        _pending_subs (List[Channel]): a list of channels that are pending
            subscriptions when self._pubsub becomes available


    """
    _config: RedisConfig = RedisConfig()
    _connection: aioredis.Redis = None  # type: ignore
    _pubsub: PubSub = None  # type: ignore
    _connected: bool = False
    _subs: List[Channel] = []
    _subs_attached: Dict[Channel, List[TaskName]] = {}
    _attached: Dict[TaskName, RedisOptions] = {}
    _callbacks: Dict[TaskName, Callable[[Any], Awaitable[None]]] = {}
    _running = False
    _pubsub_poll_interval: float = 1.0
    _pending_subs: List[Channel] = []

    def __init__(self) -> None:
        """Constructor for Redis class.

        Args:
            None

        Returns:
            None

        """
        self._logger = Logger(f'backend.{util.short_class_name(self)}')
        super().__init__()

    async def read_config(self) -> None:
        """Reads config.

        Looks up the config file in the provided config dir
        that has the matching lowercase name to the module NAME and .yml
        extension and loads the yaml. Converts the serialized data into
        RedisConfig and stores it in self._config

        Args:
            None

        Returns
            None

        """
        yaml = await util.read_yaml(
            f'{app_config.backend_conf_dir}/{NAME.lower()}.yml'
        )
        util.apply_dict(yaml, self._config)

    async def start(self) -> None:
        """Starts the backend.

        Used by the Task class and expects a callback
        function. Upon startup reads and loads the config as well as connects
        to the Redis database

        Args:
            None

        Returns
            None

        """
        await self.read_config()
        asyncio.create_task(self.connect_and_read_messages())
        self._running = True

    async def stop(self) -> None:
        """Stops the backend.

        Disconnects from the Redis database, if there are any existing
        connections, all running "_read_from_channel" tasks are done

        Args:
            None

        Returns
            None

        """
        self._running = False
        if self._connection:
            await self._connection.aclose()

    def is_running(self) -> bool:
        """Returns true if the backend is running.

        Args:
            None

        Returns:
            bool: true if running

        """
        return self._running

    def config(self) -> RedisConfig:
        """Fetches backends's config.

        Args:
            None

        Returns:
            RedisConfig: backend config

        """
        return copy.deepcopy(self._config)

    async def connect(self) -> None:
        """Connects to Redis database.

        Connects to the Redis database using the configuration obtained
        from the config file.

        Args:
            None

        Returns
            None

        """
        while not self._connected:
            try:
                [host, port, use_db] = [getattr(self.config(), k)
                                        for k in ['host', 'port', 'db']]
                self._connection = await aioredis.Redis(
                    host=host,
                    port=port,
                    db=use_db,
                    health_check_interval=self.config().health_check_interval
                )
                await self._connection.ping()
                self._pubsub: PubSub = self._connection.pubsub()
                self._connected = True
                self.log.debug(
                    f'Connected to Redis host={host} port={port} db={use_db}'
                )
            except redis.exceptions.ConnectionError as err:
                self.log.debug(f'Could not connect to Redis error: {err}')
                await asyncio.sleep(self.config().init_reconnect_timeout)

    async def connect_and_read_messages(self) -> None:
        """Connects to Redis database.

        Invokes self.connect() and then self._read_messages() combo.

        Args:
            None

        Returns
            None

        """
        await self.connect()
        await self._read_messages()

    async def attach(self,
                     task_name: TaskName,
                     options: Dict[str, Any],
                     callback: Callable[[Any], Awaitable[None]]) -> None:
        """Attaches a task to the backend according to the provided options.

        When a request is received by the backend it dispatches the request
        to all attached tasks with matching name.

        Args:
            task_name (str): task name
            options (BackendOptions): backend options provided by the task
            callback (Callable[[Any], Awaitable[None]]): callback to dispatch
                the received requests to

        Returns:
            None

        """
        if task_name in self._attached:
            self.log.debug(
                util.compact_str(
                    f'task name={task_name} is already \
                        attached to the backend, skip.'
                )
            )
            return

        task_options = RedisOptions()
        util.apply_dict(options, task_options)

        if task_options and task_options.control_channel:
            ch = task_options.control_channel
            if ch in self._subs_attached:
                self._subs_attached[ch].append(task_name)
            else:
                self._subs.append(ch)
                self._subs_attached[ch] = []
                self._subs_attached[ch].append(task_name)
                self._pending_subs.append(ch)

        self._callbacks[task_name] = callback
        self._attached[task_name] = task_options

    async def detach(self, task_name: str) -> None:
        """Detaches a task from the backend.

        Args:
            task_name (str): task name

        Returns:
            None

        """
        if task_name not in self._attached:
            self.log.debug(
                util.compact_str(
                    f'task name={task_name} is not \
                        attached to the backend, skip.'
                )
            )
            return

        options = self._attached[task_name]
        if options and options.control_channel:
            ch = options.control_channel
            if ch in self._subs_attached:
                try:
                    self._subs_attached[ch].remove(task_name)
                except ValueError:
                    pass
                if len(self._subs_attached[ch]) == 0:
                    for i in range(len(self._subs)-1, -1, -1):
                        if self._subs[i] == ch:
                            del self._subs[i]
                            if not self._pubsub:
                                continue
                            try:
                                await self._pubsub.unsubscribe(ch)
                            except redis.exceptions.RedisError:
                                pass
                    del self._subs_attached[ch]
        del self._callbacks[task_name]
        del self._attached[task_name]

    async def send_response(self,
                            request: Request,
                            response: Response) -> None:
        """Sends response back to the client according to the options.

        Invoked by the Task class

        Args:
            request (Request): Request message object
            response (Response): Response message object

        Returns
            None

        """
        if request.options and 'feedback_channel' in request.options:
            fb_channel = request.options['feedback_channel']
            await self._connection.publish(str(fb_channel),
                                           json.dumps(vars(response)))

    async def send_request(self, request: Request) -> None:
        """Sends a new request.

        Invoked by the App class, triggered by the Task class when the "follow"
        option is set.

        Args:
            request (Request): Request message object

        Returns
            None

        """
        if request.task not in self._attached:
            self.log.error(
                f'task {request.task} is not known or not \
                        attached to this backend'
            )
            return

        task_options = self._attached[request.task]
        await self._connection.publish(task_options.control_channel,
                                       json.dumps(vars(request)))

    async def _read_messages(self) -> None:
        """Checks for incoming redis messages.

        This method runs as a separate asyncio task

        If connection is lost, it re-tries every 5 seconds
        (defined by the 'reconnect_interval' option)

        On successful reconnect the previous subscriptions are automatically
        restored (resubscribed to) by self._pubsub

        Args:
            None

        Returns
            None

        """
        while self.is_running():
            try:
                if not self._pubsub:
                    self.log.debug('Restore lost pubsub connection handler')
                    await self.connect()
                    for ch in self._subs:
                        self._pending_subs.append(ch)

                if not self._connected:
                    await self._pubsub.ping()
                    self.log.info('Reconnected to Redis')
                    self._connected = True

                if len(self._pending_subs):
                    for sub in self._pending_subs:
                        ch_cb = {sub: self._on_message}
                        try:
                            await self._pubsub.subscribe(**ch_cb)
                        except redis.exceptions.RedisError:
                            pass
                    self._pending_subs = []

                try:
                    await self._pubsub.get_message(
                        ignore_subscribe_messages=True,
                        timeout=self._pubsub_poll_interval,
                    )
                except asyncio.CancelledError:
                    break
            except redis.exceptions.ConnectionError as err:
                if not self._connected:
                    self.log.error(f'Reconnect error: ${err}')
                else:
                    self.log.error(repr(err))
                self._connected = False
                await asyncio.sleep(self.config().reconnect_timeout)

    async def _on_message(self, message: Dict[Any, Any]) -> None:
        """Reads and processes received channel message.

        Used as a callback in self.pubsub.subscribe

        If connection is lost, it re-tries every 5 seconds
        (defined by the 'reconnect_interval' option)

        On successful reconnect the previous subscriptions are automatically
        restored (resubscribed to) by self._pubsub

        Args:
            message (Dict): received message

        Returns
            None

        """
        ch = redis.utils.str_if_bytes(message['channel'])
        data = redis.utils.str_if_bytes(message['data'])

        if ch not in self._subs_attached:
            return

        try:
            request_data = json.loads(data)
            request = Request()
            request.__dict__.update(request_data)
            self.log.debug(
                f'request: {util.compact_str(str(vars(request)))}'
            )

            if ch not in self._subs_attached:
                return

            if not request.src or not request.dst:
                self.log.debug(util.compact_str(
                    f'Skip request uuid={request.uuid} \
                        with missing src or dst'
                ))
                return

            if not await util.check_dst_is_own(request.src,
                                               request.dst):
                own = app_config.ngcp.node_name
                state = await util.get_node_state()
                status = app_config.ngcp.status

                self.log.debug(util.compact_str(
                    f'Skip request uuid={request.uuid} as the own \
                        node={own}|status={status};state={state} \
                        does not match dst={request.dst}'
                ))
                return

            found_task = False
            for task_name in self._subs_attached[ch]:
                if task_name == request.task:
                    found_task = True
                    await asyncio.ensure_future(
                        self._callbacks[task_name](request)
                    )
            if not found_task:
                response = Response()
                response.src = app_config.ngcp.node_name
                response.dst = request.src
                response.ref = request.uuid
                response.status = 'error'
                response.reason = util.compact_str(
                    f'task name={request.task} does not exist'
                )
                await self.send_response(request, response)
        except (TypeError, json.JSONDecodeError) as err:
            self.log.error(
                f'skip message with malformed JSON: {err}'
            )
