#!/usr/bin/env python3
# Steapp - HTTP/Websocket API Server for Klipper
#
# Copyright (C) 2020 Eric Callahan <arksine.code@gmail.com>
#
# This file may be distributed under the terms of the GNU GPLv3 license

from __future__ import annotations
import argparse
from asyncio.events import new_event_loop
import sys
import importlib
import os
import io
import time
import socket
import logging
import json
import signal
import confighelper
import asyncio
from tornado import iostream
from tornado.netutil import Resolver
from .eventloop import EventLoop
from .app import StereotechCloud
from .utils import ServerError, SentinelClass, SteappLoggingHandler

# Annotation imports
from typing import (
    TYPE_CHECKING,
    Any,
    Optional,
    Callable,
    Coroutine,
    Tuple,
    Dict,
    List,
    Union,
    TypeVar,
)
if TYPE_CHECKING:
    from websockets import WebRequest, Subscribable
    from file_manager.file_manager import FileManager
    FlexCallback = Callable[..., Optional[Coroutine]]
    _T = TypeVar("_T")

INIT_TIME = .25
LOG_ATTEMPT_INTERVAL = int(2. / INIT_TIME + .5)
MAX_LOG_ATTEMPTS = 10 * LOG_ATTEMPT_INTERVAL

CORE_COMPONENTS = [
    'database', 'file_manager']

SENTINEL = SentinelClass.get_instance()


class Server:
    error = ServerError

    def __init__(self,
                 args: Dict[str, Any],
                 file_logger: Optional[SteappLoggingHandler],
                 event_loop: EventLoop
                 ) -> None:
        self.event_loop = event_loop
        self.file_logger = file_logger
        self.app_args = args
        self.config = config = confighelper.get_configuration(self, args)
        # log config file
        strio = io.StringIO()
        config.write_config(strio)
        cfg_item = f"\n{'#'*20} Steapp Configuration {'#'*20}\n\n"
        cfg_item += strio.getvalue()
        cfg_item += "#"*65
        strio.close()
        self.add_log_rollover_item('config', cfg_item)
        self.host: str = config.get('host', "0.0.0.0")
        self.port: int = config.getint('port', 7125)
        self.domain: str = config.get('domain', self.host)
        self.ssl_port: int = config.getint('ssl_port', 7130)
        self.exit_reason: str = ""

        # Event initialization
        self.events: Dict[str, List[FlexCallback]] = {}

        # Tornado Application/Server
        self.server_running: bool = False
        self.stereotech_cloud_app = app = StereotechCloud(config)
        self.register_endpoint = app.register_local_handler
        self.register_static_file_handler = app.register_static_file_handler
        self.register_upload_handler = app.register_upload_handler
        self.get_websocket_manager = app.get_websocket_manager
        self.register_api_transport = app.register_api_transport
        self.register_notification_transport = app.register_notification_transport
        self.register_notification = app.register_notification

        self.failed_components: List[str] = []
        self.warnings: List[str] = []

        self.register_endpoint(
            "/server/info", ['GET'], self._handle_info_request)
        self.register_endpoint(
            "/server/config", ['GET'], self._handle_config_request)
        self.register_endpoint(
            "/server/restart", ['POST'], self._handle_server_restart)

        self.register_notification(
            'server:websocket_removed', transports=[])

        # Component initialization
        self.components: Dict[str, Any] = {}
        self._load_components(config)
        config.validate_config()

    def get_app_args(self) -> Dict[str, Any]:
        return dict(self.app_args)

    def get_event_loop(self) -> EventLoop:
        return self.event_loop

    def start(self) -> None:
        hostname, hostport = self.get_host_info()
        logging.info(
            f"Starting Steapp on ({self.host}, {hostport}), "
            f"Hostname: {hostname}")
        self.stereotech_cloud_app.listen(self.host, self.port, self.ssl_port)
        self.server_running = True
        self.event_loop.add_signal_handler(
            signal.SIGTERM, self._handle_term_signal)

    def add_log_rollover_item(self, name: str, item: str,
                              log: bool = True) -> None:
        if self.file_logger is not None:
            self.file_logger.set_rollover_info(name, item)
        if log and item is not None:
            logging.info(item)

    def add_warning(self, warning: str, log: bool = True) -> None:
        if log:
            logging.warning(warning)

    # ***** Component Management *****
    def _load_components(self, config: confighelper.ConfigHelper) -> None:
        # load core components
        for component in CORE_COMPONENTS:
            self.load_component(config, component)

        # check for optional components
        opt_sections = set([s.split()[0] for s in config.sections()])
        opt_sections.remove('server')
        for section in opt_sections:
            self.load_component(config, section, None)

    def load_component(self,
                       config: confighelper.ConfigHelper,
                       component_name: str,
                       default: Union[SentinelClass, _T] = SENTINEL
                       ) -> Union[_T, Any]:
        if component_name in self.components:
            return self.components[component_name]
        try:
            module = importlib.import_module("components." + component_name)
            func_name = "load_component"
            if hasattr(module, "load_component_multi"):
                func_name = "load_component_multi"
            if component_name not in CORE_COMPONENTS and \
                    func_name == "load_component":
                config = config[component_name]
            load_func = getattr(module, func_name)
            component = load_func(config)
        except Exception:
            msg = f"Unable to load component: ({component_name})"
            logging.exception(msg)
            self.failed_components.append(component_name)
            if isinstance(default, SentinelClass):
                raise ServerError(msg)
            return default
        self.components[component_name] = component
        logging.info(f"Component ({component_name}) loaded")
        return component

    def lookup_component(self,
                         component_name: str,
                         default: Union[SentinelClass, _T] = SENTINEL
                         ) -> Union[_T, Any]:
        component = self.components.get(component_name, default)
        if isinstance(component, SentinelClass):
            raise ServerError(f"Component ({component_name}) not found")
        return component

    def set_failed_component(self, component_name: str) -> None:
        if component_name not in self.failed_components:
            self.failed_components.append(component_name)

    def register_event_handler(self,
                               event: str,
                               callback: FlexCallback
                               ) -> None:
        self.events.setdefault(event, []).append(callback)

    def send_event(self, event: str, *args) -> None:
        events = self.events.get(event, [])
        for evt in events:
            self.event_loop.register_callback(evt, *args)

    def get_host_info(self) -> Tuple[str, int]:
        hostname = socket.gethostname()
        return hostname, self.port

    def _handle_term_signal(self) -> None:
        logging.info(f"Exiting with signal SIGTERM")
        self.event_loop.register_callback(self._stop_server, "terminate")

    async def _stop_server(self, exit_reason: str = "restart") -> None:
        self.server_running = False
        # Call each component's "on_exit" method
        for name, component in self.components.items():
            if hasattr(component, "on_exit"):
                func: FlexCallback = getattr(component, "on_exit")
                try:
                    ret = func()
                    if ret is not None:
                        await ret
                except Exception:
                    logging.exception(
                        f"Error executing 'on_exit()' for component: {name}")

        # Sleep for 100ms to allow connected websockets to write out
        # remaining data
        await asyncio.sleep(.1)
        try:
            await self.stereotech_cloud_app.close()
        except Exception:
            logging.exception("Error Closing App")

        # Close all components
        for name, component in self.components.items():
            if hasattr(component, "close"):
                func = getattr(component, "close")
                try:
                    ret = func()
                    if ret is not None:
                        await ret
                except Exception:
                    logging.exception(
                        f"Error executing 'close()' for component: {name}")

        self.exit_reason = exit_reason
        self.event_loop.remove_signal_handler(signal.SIGTERM)
        self.event_loop.stop()

    async def _handle_server_restart(self, web_request: WebRequest) -> str:
        self.event_loop.register_callback(self._stop_server)
        return "ok"

    async def _handle_info_request(self,
                                   web_request: WebRequest
                                   ) -> Dict[str, Any]:
        file_manager: Optional[FileManager] = self.lookup_component(
            'file_manager', None)
        reg_dirs = []
        if file_manager is not None:
            reg_dirs = file_manager.get_registered_dirs()

        resolver = Resolver()
        hostname, hostport = self.get_host_info()
        address = await resolver.resolve(hostname, hostport)

        return {
            'address': address,
            'components': list(self.components.keys()),
            'failed_components': self.failed_components,
            'plugins': list(self.components.keys()),
            'failed_plugins': self.failed_components,
            'registered_directories': reg_dirs,
            'warnings': self.warnings
        }

    async def _handle_config_request(self,
                                     web_request: WebRequest
                                     ) -> Dict[str, Any]:
        return {
            'config': self.config.get_parsed_config()
        }

# Basic WebRequest class, easily converted to dict for json encoding


class BaseRequest:
    def __init__(self, rpc_method: str, params: Dict[str, Any]) -> None:
        self.id = id(self)
        self.rpc_method = rpc_method
        self.params = params
        self._event = asyncio.Event()
        self.response: Any = None

    async def wait(self) -> Any:
        # Log pending requests every 60 seconds
        start_time = time.time()
        while True:
            try:
                await asyncio.wait_for(self._event.wait(), 60.)
            except asyncio.TimeoutError:
                pending_time = time.time() - start_time
                logging.info(
                    f"Request '{self.rpc_method}' pending: "
                    f"{pending_time:.2f} seconds")
                self._event.clear()
                continue
            break
        if isinstance(self.response, ServerError):
            raise self.response
        return self.response

    def notify(self, response: Any) -> None:
        self.response = response
        self._event.set()

    def to_dict(self) -> Dict[str, Any]:
        return {'id': self.id, 'method': self.rpc_method,
                'params': self.params}
