import ast
import logging
import logging.handlers
import os
import sys
import warnings
from pathlib import Path
from typing import Any, Dict, Generic, List, Optional, Type, TypeVar

import ruamel.yaml

from nornir.core.exceptions import ConflictingConfigurationWarning

DEFAULT_SSH_CONFIG = str(Path("~/.ssh/config").expanduser())

T = TypeVar("T")


class Parameter(Generic[T]):
    def __init__(
        self,
        envvar: str,
        typ: Optional[Type[T]] = None,
        help: str = "",
        default: Optional[T] = None,
    ) -> None:
        if typ is not None:
            self.type: Type[T] = typ
        elif default is not None:
            self.type = default.__class__
        else:
            raise TypeError("either typ or default needs to be specified")
        self.envvar = envvar
        self.help = help
        self.default = default or self.type()

    def resolve(self, value: Optional[T]) -> T:
        v: Optional[Any] = value
        if value is None:
            t = os.environ.get(self.envvar)
            if self.type is bool and t:
                v = t in ["true", "True", "1", "yes"]
            elif self.type is str and t:
                v = t
            elif t:
                v = ast.literal_eval(t) if t is not None else None

        if v is None:
            v = self.default

        if not isinstance(v, self.type):
            raise TypeError(f"Expected type {self.type}, got {type(v)}")

        return v


class SSHConfig:
    __slots__ = ("config_file",)

    class Parameters:
        config_file = Parameter[str](default=DEFAULT_SSH_CONFIG, envvar="NORNIR_SSH_CONFIG_FILE")

    def __init__(self, config_file: Optional[str] = None) -> None:
        self.config_file = self.Parameters.config_file.resolve(config_file)

    def dict(self) -> Dict[str, Any]:
        return {"config_file": self.config_file}


class InventoryConfig:
    __slots__ = "options", "plugin", "transform_function", "transform_function_options"

    class Parameters:
        plugin = Parameter[str](
            typ=str, default="SimpleInventory", envvar="NORNIR_INVENTORY_PLUGIN"
        )
        options = Parameter[Dict[str, Any]](default={}, envvar="NORNIR_INVENTORY_OPTIONS")
        transform_function = Parameter[str](typ=str, envvar="NORNIR_INVENTORY_TRANSFORM_FUNCTION")
        transform_function_options = Parameter[Dict[str, Any]](
            default={}, envvar="NORNIR_INVENTORY_TRANSFORM_FUNCTION_OPTIONS"
        )

    def __init__(
        self,
        plugin: Optional[str] = None,
        options: Optional[Dict[str, Any]] = None,
        transform_function: Optional[str] = None,
        transform_function_options: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.plugin = self.Parameters.plugin.resolve(plugin)
        self.options = self.Parameters.options.resolve(options) or {}
        self.transform_function = self.Parameters.transform_function.resolve(transform_function)
        self.transform_function_options = self.Parameters.transform_function_options.resolve(
            transform_function_options
        )

    def dict(self) -> Dict[str, Any]:
        return {
            "plugin": self.plugin,
            "options": self.options,
            "transform_function": self.transform_function,
            "transform_function_options": self.transform_function_options,
        }


class LoggingConfig:
    __slots__ = "enabled", "format", "level", "log_file", "loggers", "to_console"

    class Parameters:
        enabled = Parameter[bool](default=True, envvar="NORNIR_LOGGING_ENABLED")
        level = Parameter[str](default="INFO", envvar="NORNIR_LOGGING_LEVEL")
        log_file = Parameter[str](default="nornir.log", envvar="NORNIR_LOGGING_LOG_FILE")
        format = Parameter[str](
            default="%(asctime)s - %(name)12s - %(levelname)8s - %(funcName)10s() - %(message)s",
            envvar="NORNIR_LOGGING_FORMAT",
        )
        to_console = Parameter[bool](default=False, envvar="NORNIR_LOGGING_TO_CONSOLE")
        loggers = Parameter[List[str]](default=["nornir"], envvar="NORNIR_LOGGING_LOGGERS")

    def __init__(
        self,
        enabled: Optional[bool] = None,
        level: Optional[str] = None,
        log_file: Optional[str] = None,
        format: Optional[str] = None,
        to_console: Optional[bool] = None,
        loggers: Optional[List[str]] = None,
    ) -> None:
        self.enabled = self.Parameters.enabled.resolve(enabled)
        self.level = self.Parameters.level.resolve(level)
        self.log_file = self.Parameters.log_file.resolve(log_file)
        self.format = self.Parameters.format.resolve(format)
        self.to_console = self.Parameters.to_console.resolve(to_console)
        self.loggers = self.Parameters.loggers.resolve(loggers)

    def dict(self) -> Dict[str, Any]:
        return {
            "enabled": self.enabled,
            "level": self.level,
            "log_file": self.log_file,
            "format": self.format,
            "to_console": self.to_console,
            "loggers": self.loggers,
        }

    def configure(self) -> None:
        if not self.enabled:
            return

        root_logger = logging.getLogger()
        if root_logger.hasHandlers() or root_logger.level != logging.WARNING:
            msg = (
                "Native Python logging configuration has been detected, but Nornir "
                "logging is enabled too. "
                "This can lead to unexpected logging results. "
                "Please set logging.enabled config to False "
                "to disable automatic Nornir logging configuration. Refer to "
                "https://nornir.readthedocs.io/en/stable/configuration/index.html#logging"
            )
            warnings.warn(msg, ConflictingConfigurationWarning)

        formatter = logging.Formatter(self.format)
        # log INFO and DEBUG to stdout
        stdout_handler = logging.StreamHandler(sys.stdout)
        stdout_handler.setFormatter(formatter)
        stdout_handler.setLevel(logging.DEBUG)
        stdout_handler.addFilter(lambda record: record.levelno <= logging.INFO)
        # log WARNING, ERROR and CRITICAL to stderr
        stderr_handler = logging.StreamHandler(sys.stderr)
        stderr_handler.setFormatter(formatter)
        stderr_handler.setLevel(logging.WARNING)

        for logger_name in self.loggers:
            logger_ = logging.getLogger(logger_name)
            logger_.propagate = False
            logger_.setLevel(self.level)
            if logger_.hasHandlers():
                # Don't add handlers if some handlers are already attached to the logger
                # This is crucial to avoid duplicate handlers
                # Alternative would be to clear all handlers and reconfigure them
                # with Nornir
                # There are several situations this branch can be executed:
                # multiple calls to InitNornir,
                # logging.config.dictConfig configuring 'nornir' logger, etc.
                # The warning is not emitted in this scenario
                continue
            if self.log_file:
                handler = logging.handlers.RotatingFileHandler(
                    str(Path(self.log_file)), maxBytes=1024 * 1024 * 10, backupCount=20
                )
                handler.setFormatter(formatter)
                logger_.addHandler(handler)

            if self.to_console:
                logger_.addHandler(stdout_handler)
                logger_.addHandler(stderr_handler)


class RunnerConfig:
    __slots__ = ("options", "plugin")

    class Parameters:
        plugin = Parameter[str](default="threaded", envvar="NORNIR_RUNNER_PLUGIN")
        options = Parameter[Dict[str, Any]](default={}, envvar="NORNIR_RUNNER_OPTIONS")

    def __init__(
        self, plugin: Optional[str] = None, options: Optional[Dict[str, Any]] = None
    ) -> None:
        self.plugin = self.Parameters.plugin.resolve(plugin)
        self.options = self.Parameters.options.resolve(options)

    def dict(self) -> Dict[str, Any]:
        return {
            "plugin": self.plugin,
            "options": self.options,
        }


class CoreConfig:
    __slots__ = ("raise_on_error",)

    class Parameters:
        raise_on_error = Parameter[bool](default=False, envvar="NORNIR_CORE_RAISE_ON_ERROR")

    def __init__(self, raise_on_error: Optional[bool] = None) -> None:
        self.raise_on_error = self.Parameters.raise_on_error.resolve(raise_on_error)

    def dict(self) -> Dict[str, Any]:
        return {
            "raise_on_error": self.raise_on_error,
        }


class Config:
    __slots__ = (
        "core",
        "inventory",
        "logging",
        "runner",
        "ssh",
        "user_defined",
    )

    def __init__(
        self,
        inventory: Optional[InventoryConfig] = None,
        ssh: Optional[SSHConfig] = None,
        logging: Optional[LoggingConfig] = None,
        core: Optional[CoreConfig] = None,
        runner: Optional[RunnerConfig] = None,
        user_defined: Optional[Dict[str, Any]] = None,
    ) -> None:
        self.inventory = inventory or InventoryConfig()
        self.ssh = ssh or SSHConfig()
        self.logging = logging or LoggingConfig()
        self.core = core or CoreConfig()
        self.runner = runner or RunnerConfig()
        self.user_defined = user_defined or {}

    @classmethod
    def from_dict(
        cls,
        inventory: Optional[Dict[str, Any]] = None,
        ssh: Optional[Dict[str, Any]] = None,
        logging: Optional[Dict[str, Any]] = None,
        core: Optional[Dict[str, Any]] = None,
        runner: Optional[Dict[str, Any]] = None,
        user_defined: Optional[Dict[str, Any]] = None,
    ) -> "Config":
        return cls(
            inventory=InventoryConfig(**inventory or {}),
            ssh=SSHConfig(**ssh or {}),
            logging=LoggingConfig(**logging or {}),
            core=CoreConfig(**core or {}),
            runner=RunnerConfig(**runner or {}),
            user_defined=user_defined or {},
        )

    @classmethod
    def from_file(
        cls,
        config_file: str,
        inventory: Optional[Dict[str, Any]] = None,
        ssh: Optional[Dict[str, Any]] = None,
        logging: Optional[Dict[str, Any]] = None,
        core: Optional[Dict[str, Any]] = None,
        runner: Optional[Dict[str, Any]] = None,
        user_defined: Optional[Dict[str, Any]] = None,
    ) -> "Config":
        inventory = inventory or {}
        ssh = ssh or {}
        logging = logging or {}
        core = core or {}
        runner = runner or {}
        user_defined = user_defined or {}
        with open(config_file, "r") as f:
            yml = ruamel.yaml.YAML(typ="safe")
            data = yml.load(f)
        return cls(
            inventory=InventoryConfig(**{**data.get("inventory", {}), **inventory}),
            ssh=SSHConfig(**{**data.get("ssh", {}), **ssh}),
            logging=LoggingConfig(**{**data.get("logging", {}), **logging}),
            core=CoreConfig(**{**data.get("core", {}), **core}),
            runner=RunnerConfig(**{**data.get("runner", {}), **runner}),
            user_defined={**data.get("user_defined", {}), **user_defined},
        )

    def dict(self) -> Dict[str, Any]:
        return {
            "inventory": self.inventory.dict(),
            "ssh": self.ssh.dict(),
            "logging": self.logging.dict(),
            "core": self.core.dict(),
            "runner": self.runner.dict(),
            "user_defined": self.user_defined,
        }
