from contextlib import contextmanager
import logging
import os
import signal
import subprocess
import sys
from typing import Optional, Sequence

from platypush.bus import Bus
from platypush.bus.redis import RedisBus
from platypush.cli import parse_cmdline
from platypush.commands import CommandStream
from platypush.config import Config
from platypush.context import register_backends, register_plugins
from platypush.cron.scheduler import CronScheduler
from platypush.entities import init_entities_engine, EntitiesEngine
from platypush.event.processor import EventProcessor
from platypush.logger import Logger
from platypush.message.event import Event
from platypush.message.event.application import ApplicationStartedEvent
from platypush.message.request import Request
from platypush.message.response import Response
from platypush.utils import get_enabled_plugins, get_redis_conf

log = logging.getLogger('platypush')


class Application:
    """Main class for the Platypush application."""

    # Default Redis port
    _default_redis_port = 6379

    # backend_name => backend_obj map
    backends = None

    # number of executions retries before a request fails
    n_tries = 2

    def __init__(
        self,
        config_file: Optional[str] = None,
        workdir: Optional[str] = None,
        logsdir: Optional[str] = None,
        device_id: Optional[str] = None,
        pidfile: Optional[str] = None,
        requests_to_process: Optional[int] = None,
        no_capture_stdout: bool = False,
        no_capture_stderr: bool = False,
        redis_queue: Optional[str] = None,
        verbose: bool = False,
        start_redis: bool = False,
        redis_host: Optional[str] = None,
        redis_port: Optional[int] = None,
        ctrl_sock: Optional[str] = None,
    ):
        """
        :param config_file: Configuration file override (default: None).
        :param workdir: Overrides the ``workdir`` setting in the configuration
            file (default: None).
        :param logsdir: Set logging directory. If not specified, the
            ``filename`` setting under the ``logging`` section of the
            configuration file is used. If not set, logging will be sent to
            stdout and stderr.
        :param device_id: Override the device ID used to identify this
            instance. If not passed here, it is inferred from the configuration
            (device_id field). If not present there either, it is inferred from
            the hostname.
        :param pidfile: File where platypush will store its PID upon launch,
           useful if you're planning to integrate the application within a
           service or a launcher script (default: None).
        :param requests_to_process: Exit after processing the specified
            number of requests (default: None, loop forever).
        :param no_capture_stdout: Set to true if you want to disable the
            stdout capture by the logging system (default: False).
        :param no_capture_stderr: Set to true if you want to disable the
            stderr capture by the logging system (default: False).
        :param redis_queue: Name of the (Redis) queue used for dispatching
            messages (default: platypush/bus).
        :param verbose: Enable debug/verbose logging, overriding the stored
            configuration (default: False).
        :param start_redis: If set, it starts a managed Redis instance upon
            boot (it requires the ``redis-server`` executable installed on the
            server). This is particularly useful when running the application
            inside of Docker containers, without relying on ``docker-compose``
            to start multiple containers, and in tests (default: False).
        :param redis_host: Host of the Redis server to be used. It overrides
            the settings in the ``redis`` section of the configuration file.
        :param redis_port: Port of the local Redis server. It overrides the
            settings in the ``redis`` section of the configuration file.
        :param ctrl_sock: If set, it identifies a path to a UNIX domain socket
            that the application can use to send control messages (e.g. STOP
            and RESTART) to its parent.
        """

        self.pidfile = pidfile
        self.bus: Optional[Bus] = None
        self.redis_queue = redis_queue or RedisBus.DEFAULT_REDIS_QUEUE
        self.config_file = config_file
        self._verbose = verbose
        self._logsdir = (
            os.path.abspath(os.path.expanduser(logsdir)) if logsdir else None
        )

        Config.init(
            self.config_file,
            device_id=device_id,
            workdir=os.path.abspath(os.path.expanduser(workdir)) if workdir else None,
            ctrl_sock=os.path.abspath(os.path.expanduser(ctrl_sock))
            if ctrl_sock
            else None,
        )

        self.no_capture_stdout = no_capture_stdout
        self.no_capture_stderr = no_capture_stderr
        self.event_processor = EventProcessor()
        self.entities_engine: Optional[EntitiesEngine] = None
        self.requests_to_process = requests_to_process
        self.processed_requests = 0
        self.cron_scheduler = None
        self.start_redis = start_redis
        self.redis_host = redis_host
        self.redis_port = redis_port
        self.redis_conf = {}
        self._redis_proc: Optional[subprocess.Popen] = None
        self.cmd_stream = CommandStream(ctrl_sock)

        self._init_bus()
        self._init_logging()

    def _init_bus(self):
        self._redis_conf = get_redis_conf()
        self._redis_conf['port'] = self.redis_port or self._redis_conf.get(
            'port', self._default_redis_port
        )

        if self.redis_host:
            self._redis_conf['host'] = self.redis_host

        Config.set('redis', self._redis_conf)
        self.bus = RedisBus(
            redis_queue=self.redis_queue,
            on_message=self.on_message(),
            **self._redis_conf,
        )

    def _init_logging(self):
        logging_conf = Config.get('logging') or {}
        if self._verbose:
            logging_conf['level'] = logging.DEBUG
        if self._logsdir:
            logging_conf['filename'] = os.path.join(self._logsdir, 'platypush.log')
            logging_conf.pop('stream', None)

        Config.set('logging', logging_conf)
        logging.basicConfig(**logging_conf)

    def _start_redis(self):
        if self._redis_proc and self._redis_proc.poll() is None:
            log.warning(
                'A local Redis instance is already running, refusing to start it again'
            )
            return

        port = self._redis_conf['port']
        log.info('Starting local Redis instance on %s', port)
        redis_cmd_args = [
            'redis-server',
            '--bind',
            'localhost',
            '--port',
            str(port),
        ]

        try:
            self._redis_proc = subprocess.Popen(  # pylint: disable=consider-using-with
                redis_cmd_args,
                stdout=subprocess.PIPE,
            )
        except Exception as e:
            log.error(
                'Failed to start local Redis instance: "%s": %s',
                ' '.join(redis_cmd_args),
                e,
            )

            sys.exit(1)

        log.info('Waiting for Redis to start')
        for line in self._redis_proc.stdout:  # type: ignore
            if b'Ready to accept connections' in line:
                break

    def _stop_redis(self):
        if self._redis_proc and self._redis_proc.poll() is None:
            log.info('Stopping local Redis instance')
            self._redis_proc.kill()
            self._redis_proc = None

    @classmethod
    def from_cmdline(cls, args: Sequence[str]) -> "Application":
        """
        Build the app from command line arguments.
        """
        opts = parse_cmdline(args)
        return cls(
            config_file=opts.config,
            workdir=opts.workdir,
            logsdir=opts.logsdir,
            device_id=opts.device_id,
            pidfile=opts.pidfile,
            no_capture_stdout=opts.no_capture_stdout,
            no_capture_stderr=opts.no_capture_stderr,
            redis_queue=opts.redis_queue,
            verbose=opts.verbose,
            start_redis=opts.start_redis,
            redis_host=opts.redis_host,
            redis_port=opts.redis_port,
            ctrl_sock=opts.ctrl_sock,
        )

    def on_message(self):
        """
        Default message handler.
        """

        def _f(msg):
            """
            on_message closure
            Params:
                msg -- platypush.message.Message instance
            """

            if isinstance(msg, Request):
                try:
                    msg.execute(n_tries=self.n_tries)
                except PermissionError:
                    log.info('Dropped unauthorized request: %s', msg)

                self.processed_requests += 1
                if (
                    self.requests_to_process
                    and self.processed_requests >= self.requests_to_process
                ):
                    self.stop()
            elif isinstance(msg, Response):
                msg.log()
            elif isinstance(msg, Event):
                msg.log()
                self.event_processor.process_event(msg)

        return _f

    def stop(self):
        """Stops the backends and the bus."""
        from platypush.plugins import RunnablePlugin

        log.info('Stopping the application')
        backends = (self.backends or {}).copy().values()
        runnable_plugins = [
            plugin
            for plugin in get_enabled_plugins().values()
            if isinstance(plugin, RunnablePlugin)
        ]

        for backend in backends:
            backend.stop()

        for plugin in runnable_plugins:
            plugin.stop()

        for backend in backends:
            backend.wait_stop()

        for plugin in runnable_plugins:
            plugin.wait_stop()

        if self.entities_engine:
            self.entities_engine.stop()
            self.entities_engine.wait_stop()
            self.entities_engine = None

        if self.cron_scheduler:
            self.cron_scheduler.stop()
            self.cron_scheduler.wait_stop()
            self.cron_scheduler = None

        if self.bus:
            self.bus.stop()
            self.bus = None

        if self.start_redis:
            self._stop_redis()

        log.info('Exiting application')

    @contextmanager
    def _open_pidfile(self):
        if self.pidfile:
            try:
                with open(self.pidfile, 'w') as f:
                    f.write(str(os.getpid()))
            except OSError as e:
                log.warning('Failed to create PID file %s: %s', self.pidfile, e)

        yield

        if self.pidfile:
            try:
                os.remove(self.pidfile)
            except OSError as e:
                log.warning('Failed to remove PID file %s: %s', self.pidfile, e)

    def _run(self):
        from platypush import __version__

        if not self.no_capture_stdout:
            sys.stdout = Logger(log.info)
        if not self.no_capture_stderr:
            sys.stderr = Logger(log.warning)

        log.info('---- Starting platypush v.%s', __version__)

        # Start the local Redis service if required
        if self.start_redis:
            self._start_redis()

        # Initialize the backends and link them to the bus
        self.backends = register_backends(bus=self.bus, global_scope=True)

        # Start the backend threads
        for backend in self.backends.values():
            backend.start()

        # Initialize the plugins
        register_plugins(bus=self.bus)

        # Initialize the entities engine
        self.entities_engine = init_entities_engine()

        # Start the cron scheduler
        if Config.get_cronjobs():
            self.cron_scheduler = CronScheduler(jobs=Config.get_cronjobs())
            self.cron_scheduler.start()

        assert self.bus, 'The bus is not running'
        self.bus.post(ApplicationStartedEvent())

        # Poll for messages on the bus
        try:
            self.bus.poll()
        except KeyboardInterrupt:
            log.info('SIGINT received, terminating application')
            # Ignore other SIGINT signals
            signal.signal(signal.SIGINT, signal.SIG_IGN)
        finally:
            self.stop()

    def run(self):
        """Run the application."""

        with self._open_pidfile():
            self._run()


def main(*args: str):
    """
    Application entry point.
    """
    app = Application.from_cmdline(args)

    try:
        app.run()
    except KeyboardInterrupt:
        pass

    return 0


# vim:sw=4:ts=4:et:
