import ast
import asyncio
import os
import sys
from typing import Awaitable, Callable, Dict, List, Optional

from systemd import daemon

from . import app_config, backends, brokers, util
from .backend import Backend
from .base import Base
from .broker import Broker
from .logger import Logger
from .request import Request
from .task import Task, TaskConfig


class App(Base):
    """The main App class.

    Serves as the entry point to run the app, inherits Base

    Attributes:
        _backends (Dict[str, Backend]):
            Map <backend_name> -> Backend object
        _brokers (Dict[str, Brokers]):
            Map <broker_name> -> Broker object
        _tasks (Dict[str, Task]):
            Map <task_name> -> Task object
        _running (boold): true if the app is started, otherwise false

    """
    _backends: Dict[str, Backend] = {}
    _brokers: Dict[str, Broker] = {}
    _tasks: Dict[str, Task] = {}
    _running: bool

    def __init__(self) -> None:
        """App class constructor.

        Creates Logger object with own class name and stores
        it in self._logger, further accessible via self.log

        Args:
            None

        Returns:
            None

        """
        self._logger = Logger(util.short_class_name(self))
        super().__init__()

    def init_backends(self) -> None:
        """Backends initialization.

        Iterates over the imported backends modules list
        fetches the main class from the module and creates
        a dict entry 'name: backend object'

        Args:
            None

        Returns:
            None

        """
        if not self._backends:
            for backend in backends.backends_list:
                name = backend.__name__.split('.')[-1]
                self.log.debug(f'init backend={name}')
                self._backends[name] = getattr(backend, backend.NAME)()

    def backends(self) -> Dict[str, Backend]:
        """Fetches a dict containing backend name: backend object.

        Args:
            None

        Returns:
            Dict: backend name: backend object

        """
        return self._backends

    def backend(self, name: str) -> Backend:
        """Fetches the backend object.

        Args:
            name: (str): backend name

        Returns:
            Backend: backend object

        """
        return self._backends[name]

    def init_brokers(self,
                     send_req_cb: Callable[[Request],
                                           Awaitable[None]]) -> None:
        """Brokers initialization.

        Iterates over the imported brokers modules list,
        fetches the main class from the module and creates
        a dict entry 'name: broker object'

        Args:
            send_req_cb (callable): callback that can be used by a broker to
                trigger sending another request

        Returns:
            None

        """
        if not self._brokers:
            for broker in brokers.brokers_list:
                name = broker.__name__.split('.')[-1]
                self.log.debug(f'init broker={name}')
                self._brokers[name] = getattr(broker, broker.NAME)(send_req_cb)

    def brokers(self) -> Dict[str, Broker]:
        """Fetches a dict containing broker name: broker object.

        Args:
            None

        Returns:
            Dict: broker name: broker class

        """
        return self._brokers

    def broker(self, name: str) -> Broker:
        """Fetches the broker object.

        Args:
            name: (str): broker name

        Returns:
            Broker: broker object

        """
        return self._brokers[name]

    async def init_tasks(self) -> None:
        """Tasks initialization.

        Reads the tasks config dir and loads all the found tasks there.
        Creates a task with matching backend/broker instances and adds it
        to the dict as 'name: Task object'

        Args:
            None

        Returns:
            None

        """
        task_conf_dir = app_config.task_conf_dir
        for file in os.listdir(task_conf_dir):
            if file.endswith('.yml'):
                task_backend: Backend
                task_broker: Broker
                task_config: TaskConfig = TaskConfig()
                util.apply_dict(
                    await util.read_yaml(f'{task_conf_dir}/{file}'),
                    task_config
                )
                if task_config.name != file.removesuffix('.yml'):
                    raise ValueError(f"Task name {task_config.name} \
                        doesn't match file name {file}")

                if task_config.backend not in self.backends():
                    raise ValueError(f'Unknown backend: {task_config.backend}')
                task_backend = self.backend(task_config.backend)

                if task_config.broker not in self.brokers():
                    raise ValueError(f'Unknown broker: {task_config.broker}')
                task_broker = self.broker(task_config.broker)

                task_name = task_config.name
                task_backend_name = util.short_class_name(task_backend)
                task_broker_name = util.short_class_name(task_broker)
                self.log.debug(util.compact_str(
                    f'created task name={task_name}\
                      backend={task_backend_name}\
                      broker={task_broker_name}'
                ))
                if task_config.name in self._tasks:
                    raise NameError(
                        f'duplicate task with name={task_config.name}'
                    )
                self._tasks[task_config.name] = Task(
                    task_config, task_backend, task_broker
                )

    async def run(self) -> None:
        """Main client's entry point to the App.

        It causes to perform all the requires init steps and
        then runs the app in async loop while self._running is True

        Args:
            None

        Returns:
            None

        """
        try:
            await app_config.read_config()
            # app_config fixup initial types to the annotated ones
            util.apply_dict(app_config.__dict__, app_config)

            self.init_backends()
            self.init_brokers(self.send_request)
            await self.init_tasks()
            for name in self.backends().keys():
                await self.backend(name).start()
            for task in self._tasks.values():
                await task.start()
            self.log.info(f'{app_config.app_name} is running')
            self._running = True

            if not app_config.dev_mode:
                with open(app_config.pid_file, 'w', encoding='utf-8') as f:
                    f.write(str(os.getpid()))

                if app_config.systemd_controlled:
                    daemon.notify('READY=1')

            while self._running:
                await self._loop()
        except asyncio.CancelledError:
            pass
        # any exception must be caught here
        # to attempt to gracefully stop the app
        except Exception as err:  # noqa: B902
            self.log.fatal(str(err))
            await self.stop()

    async def stop(self) -> None:
        """Gracefully stops the backends, the tasks and the app.

        Args:
            None

        Returns:
            None

        """
        for backend in self._backends.values():
            await backend.stop()
        for task in self._tasks.values():
            await task.stop()
        self._running = False

        if not app_config.dev_mode:
            if app_config.systemd_controlled:
                daemon.notify('STOPPING=1')

            if os.path.exists(app_config.pid_file):
                os.remove(app_config.pid_file)

        self.log.info(f'{app_config.app_name} is stopped')

        # shutdown all remaining pending asyncio tasks,
        # if there are any
        for aiotask in asyncio.tasks.all_tasks():
            try:
                aiotask.cancel()
            except asyncio.CancelledError:
                pass

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

        Args:
            request: the request to send

        Returns:
            None
        """
        task = self._tasks.get(request.task)
        if not task:
            self.log.error(f'Unknown task {request.task}')
            return
        await task.backend().send_request(request)

    async def _loop(self) -> None:
        """App loop.

        If there is anything related to the app loop iteration,
        it should be defined here.

        Args:
            None

        Returns:
            None

        """
        await asyncio.sleep(1)
