"""
Base classes for DFFML. All classes in DFFML should inherit from these so that
they follow a similar API for instantiation and usage.
"""
import abc
import ast
import copy
import inspect
import argparse
import functools
import contextlib
import collections
import dataclasses
from argparse import ArgumentParser
from typing import Dict, Any, Type, Optional

from .util.data import get_args, get_origin
from .util.cli.arg import Arg
from .util.data import (
    traverse_config_set,
    traverse_config_get,
    type_lookup,
    export_dict,
    parser_helper,
)

from .util.entrypoint import Entrypoint

from .log import LOGGER


ARGP = ArgumentParser()


class ParseExpandAction(argparse.Action):
    def __call__(self, parser, namespace, values, option_string=None):
        if not isinstance(values, list):
            values = [values]
        setattr(namespace, self.dest, self.LIST_CLS(*values))


# Maps classes to their ParseClassNameAction
LIST_ACTIONS: Dict[Type, Type] = {}


def list_action(list_cls):
    """
    Action to take a list of values and make them values in the list of type
    list_class. Which will be a class descendent from AsyncContextManagerList.
    """
    LIST_ACTIONS.setdefault(
        list_cls,
        type(
            f"Parse{list_cls.__qualname__}Action",
            (ParseExpandAction,),
            {"LIST_CLS": list_cls},
        ),
    )
    return LIST_ACTIONS[list_cls]


class MissingArg(Exception):
    """
    Raised when a BaseConfigurable is missing an argument from the args dict it
    created with args(). If this exception is raised then the config() method is
    attempting to retrive an argument which was not set in the args() method.
    """


class MissingConfig(Exception):
    """
    Raised when a BaseConfigurable is missing an argument from the config dict.
    Also raised if there was no default value set and the argument is missing.
    """


class MissingRequiredProperty(Exception):
    """
    Raised when a BaseDataFlowFacilitatorObject is missing some property which
    should have been defined in the class.
    """


class LoggingLogger(object):
    """
    Provide the logger property using Python's builtin logging module.
    """

    @property
    def logger(self):
        prop_name = "__%s_logger" % (self.__class__.__qualname__,)
        logger = getattr(self, prop_name, False)
        if logger is False:
            logger = LOGGER.getChild(self.__class__.__qualname__)
            setattr(self, prop_name, logger)
        return logger


def mkarg(field):
    if field.type != bool:
        arg = Arg(type=field.type)
    else:
        arg = Arg()
    arg.annotation = field.type
    # HACK For detecting dataclasses._MISSING_TYPE
    if "dataclasses._MISSING_TYPE" not in repr(field.default):
        arg["default"] = field.default
    if "dataclasses._MISSING_TYPE" not in repr(field.default_factory):
        arg["default"] = field.default_factory()
    if field.type == bool:
        arg["action"] = "store_true"
    elif inspect.isclass(field.type):
        if issubclass(field.type, (list, collections.UserList)):
            arg["nargs"] = "+"
            if not hasattr(field.type, "SINGLETON"):
                raise AttributeError(
                    f"{field.type.__qualname__} missing attribute SINGLETON"
                )
            arg["action"] = list_action(field.type)
            arg["type"] = field.type.SINGLETON
        if hasattr(arg["type"], "load_labeled") and field.metadata.get(
            "labeled", False
        ):
            arg["type"] = arg["type"].load_labeled
        if hasattr(arg["type"], "load"):
            # TODO (python3.8) Use Protocol
            arg["type"] = arg["type"].load
    elif get_origin(field.type) in (list, tuple):
        arg["type"] = get_args(field.type)[0]
        arg["nargs"] = "+"
    if "description" in field.metadata:
        arg["help"] = field.metadata["description"]
    if field.metadata.get("action"):
        arg["action"] = field.metadata["action"]
    if field.metadata.get("required"):
        arg["required"] = field.metadata["required"]
    return arg


def convert_value(arg, value):
    if value is None:
        # Return default if not found and available
        if "default" in arg:
            return copy.deepcopy(arg["default"])
        raise MissingConfig

    if not "nargs" in arg and isinstance(value, list):
        value = value[0]
    if "type" in arg:
        type_cls = arg["type"]
        if type_cls == Type:
            type_cls = type_lookup
        # TODO This is a oversimplification of argparse's nargs
        if "nargs" in arg:
            value = list(map(type_cls, value))
        elif getattr(type_cls, "CONFIGLOADABLE", False):
            pass
        else:
            value = type_cls(value)
        # list -> tuple
        if arg.annotation is not None and get_origin(arg.annotation) is tuple:
            value = get_origin(arg.annotation)(value)
    if "action" in arg:
        if isinstance(arg["action"], str):
            # HACK This accesses _pop_action_class from ArgumentParser
            # which is prefaced with an underscore indicating it not an API
            # we can rely on
            arg["action"] = ARGP._pop_action_class(arg)
        namespace = ConfigurableParsingNamespace()
        action = arg["action"](dest="dest", option_strings="")
        action(None, namespace, value)
        value = namespace.dest
    return value


def is_config_dict(value):
    return bool(
        "plugin" in value
        and "config" in value
        and isinstance(value["config"], dict)
    )


def _fromdict(cls, **kwargs):
    for field in dataclasses.fields(cls):
        if field.name in kwargs:
            value = kwargs[field.name]
            config = {}
            if is_config_dict(value):
                value, config = value["plugin"], value["config"]
            value = convert_value(mkarg(field), value)
            if inspect.isclass(value) and issubclass(value, BaseConfigurable):
                # TODO This probably isn't 100% correct. Figure out what we need
                # to do with nested configs.
                value = value.withconfig(
                    {
                        value.ENTRY_POINT_NAME[-1]: {
                            "plugin": None,
                            "config": {
                                key: value
                                if is_config_dict(value)
                                else {"plugin": value, "config": {}}
                                for key, value in config.items()
                            },
                        }
                    }
                )
            kwargs[field.name] = value
    return cls(**kwargs)


def field(
    description: str,
    *args,
    action=None,
    required: bool = False,
    labeled: bool = False,
    metadata: Optional[dict] = None,
    **kwargs,
):
    """
    Creates an instance of :py:func:`dataclasses.field`. The first argument,
    ``description`` is the description of the field, and will be set as the
    ``"description"`` key in the metadata ``dict``.
    """
    if not metadata:
        metadata = {}
    metadata["description"] = description
    metadata["required"] = required
    metadata["labeled"] = labeled
    metadata["action"] = action
    return dataclasses.field(*args, metadata=metadata, **kwargs)


def config_asdict(self, *args, **kwargs):
    return export_dict(**dataclasses.asdict(self, *args, **kwargs))


def config(cls):
    """
    Decorator to create a dataclass
    """
    datacls = dataclasses.dataclass(eq=True, init=True)(cls)
    datacls._fromdict = classmethod(_fromdict)
    datacls._replace = lambda self, *args, **kwargs: dataclasses.replace(
        self, *args, **kwargs
    )
    datacls._asdict = config_asdict
    return datacls


def make_config(cls_name: str, fields, *args, namespace=None, **kwargs):
    """
    Function to create a dataclass
    """
    if namespace is None:
        namespace = {}
    namespace.setdefault("_fromdict", classmethod(_fromdict))
    namespace.setdefault(
        "_replace",
        lambda self, *args, **kwargs: dataclasses.replace(
            self, *args, **kwargs
        ),
    )
    namespace.setdefault("_asdict", config_asdict)
    kwargs["eq"] = True
    kwargs["init"] = True
    # Ensure non-default arguments always come before default arguments
    fields_non_default = []
    fields_default = []
    for name, cls, field in fields:
        if (
            field.default is not dataclasses.MISSING
            or field.default_factory is not dataclasses.MISSING
        ):
            fields_default.append((name, cls, field))
        else:
            fields_non_default.append((name, cls, field))
    fields = fields_non_default + fields_default
    # Create dataclass
    return dataclasses.make_dataclass(
        cls_name, fields, *args, namespace=namespace, **kwargs
    )


@config
class BaseConfig:
    """
    All DFFML Base Objects should take an object (likely a typing.NamedTuple) as
    as their config.
    """

    def __repr__(self):
        return "BaseConfig()"

    def __str__(self):
        return repr(self)


class ConfigurableParsingNamespace(object):
    def __init__(self):
        self.dest = None


class ConfigAndKWArgsMutuallyExclusive(Exception):
    """
    Raised when both kwargs and config are specified.
    """


class BaseConfigurableMetaClass(type, abc.ABC):
    def __new__(cls, name, bases, props, module=None):
        # Create the class
        cls = super(BaseConfigurableMetaClass, cls).__new__(
            cls, name, bases, props
        )
        # Wrap __init__
        setattr(cls, "__init__", cls.wrap(cls.__init__))
        return cls

    @classmethod
    def wrap(cls, func):
        """
        If a subclass of BaseConfigurable is passed keyword arguments, convert
        them into the instance of the CONFIG class.
        """

        @functools.wraps(func)
        def wrapper(self, config: Optional[BaseConfig] = None, **kwargs):
            if config is not None and len(kwargs):
                raise ConfigAndKWArgsMutuallyExclusive
            elif config is None and hasattr(self, "CONFIG"):
                if kwargs:
                    try:
                        config = self.CONFIG(**kwargs)
                    except TypeError as error:
                        error.args = (
                            error.args[0].replace(
                                "__init__", f"{self.CONFIG.__qualname__}"
                            ),
                        )
                        raise
                else:
                    use_CONFIG = True
                    for field in dataclasses.fields(self.CONFIG):
                        if (
                            field.default is dataclasses.MISSING
                            and field.default_factory is dataclasses.MISSING
                        ):
                            use_CONFIG = False
                            break
                    if use_CONFIG:
                        config = self.CONFIG()
                    else:
                        raise TypeError(
                            "__init__() missing 1 required positional argument: 'config'"
                        )
            elif config is None:
                raise TypeError(
                    "__init__() missing 1 required positional argument: 'config'"
                )
            return func(self, config)

        return wrapper


class BaseConfigurable(metaclass=BaseConfigurableMetaClass):
    """
    Class which produces a config for itself by providing Args to a CMD (from
    dffml.util.cli.base) and then using a CMD after it contains parsed args to
    instantiate a config (deriving from BaseConfig) which will be used as the
    only parameter to the __init__ of a BaseDataFlowFacilitatorObject.
    """

    def __init__(self, config: Type[BaseConfig]) -> None:
        """
        BaseConfigurable takes only one argument to __init__,
        its config, which should inherit from BaseConfig. It shall be a object
        containing any information needed to configure the class and it's child
        context's.
        """
        self.config = config
        str_config = str(self.config)
        self.logger.debug(
            str_config if len(str_config) < 512 else (str_config[:512] + "...")
        )

    def __eq__(self, other: "BaseConfigurable") -> bool:
        if inspect.isclass(other) or not isinstance(other, self.__class__):
            return
        return self.config == other.config

    @classmethod
    def add_orig_label(cls, *above):
        return (
            list(above) + cls.ENTRY_POINT_NAME + [cls.ENTRY_POINT_ORIG_LABEL]
        )

    @classmethod
    def add_label(cls, *above):
        return list(above) + cls.ENTRY_POINT_NAME + [cls.ENTRY_POINT_LABEL]

    @classmethod
    def config_set(cls, args, above, *path) -> BaseConfig:
        return traverse_config_set(
            args, *(cls.add_orig_label(*above) + list(path))
        )

    @classmethod
    def type_for(cls, param: inspect.Parameter):
        """
        Guess the type based off the default value of the parameter, for when a
        parameter doesn't have a type annotation.
        """
        if param.annotation != inspect._empty:
            return param.annotation
        elif param.default is None:
            return parser_helper
        else:
            type_of = type(param.default)
            if type_of is bool:
                return lambda value: bool(parser_helper(value))
            return type_of

    @classmethod
    def config_get(cls, config, above, *path) -> BaseConfig:
        # unittest.mock.patch doesn't work if we cache args() output.
        args = cls.args({})
        args_above = cls.add_orig_label() + list(path)
        label_above = cls.add_label(*above) + list(path)
        no_label_above = cls.add_label(*above)[:-1] + list(path)

        arg = None
        try:
            arg = traverse_config_get(args, *args_above)
        except KeyError as error:
            pass

        if arg is None:
            raise MissingArg(
                "Arg %r missing from %s%s%s"
                % (
                    args_above[-1],
                    cls.__qualname__,
                    "." if args_above[:-1] else "",
                    ".".join(args_above[:-1]),
                )
            )

        value = None
        # Try to get the value specific to this label
        with contextlib.suppress(KeyError):
            value = traverse_config_get(config, *label_above)

        # Try to get the value specific to this plugin
        if value is None:
            with contextlib.suppress(KeyError):
                value = traverse_config_get(config, *no_label_above)

        try:
            return convert_value(arg, value)
        except MissingConfig as error:
            error.args = (
                (
                    "%s missing %r from %s"
                    % (
                        cls.__qualname__,
                        label_above[-1],
                        ".".join(label_above[:-1]),
                    )
                ),
            )
            raise

    @classmethod
    def args(cls, args, *above) -> Dict[str, Arg]:
        """
        Return a dict containing arguments required for this class
        """
        if getattr(cls, "CONFIG", None) is None:
            raise AttributeError(
                f"{cls.__qualname__} requires CONFIG property or implementation of args() classmethod"
            )
        for field in dataclasses.fields(cls.CONFIG):
            cls.config_set(args, above, field.name, mkarg(field))
        return args

    @classmethod
    def config(cls, config, *above):
        """
        Create the BaseConfig required to instantiate this class by parsing the
        config dict.
        """
        if getattr(cls, "CONFIG", None) is None:
            raise AttributeError(
                f"{cls.__qualname__} requires CONFIG property or implementation of config() classmethod"
            )
        # Build the arguments to the CONFIG class
        kwargs: Dict[str, Any] = {}
        for field in dataclasses.fields(cls.CONFIG):
            kwargs[field.name] = got = cls.config_get(
                config, above, field.name
            )
            if inspect.isclass(got) and issubclass(got, BaseConfigurable):
                try:
                    kwargs[field.name] = got.withconfig(
                        config, *above, *cls.add_label()
                    )
                except MissingConfig:
                    kwargs[field.name] = got.withconfig(
                        config, *above, *cls.add_label()[:-1]
                    )
        return cls.CONFIG(**kwargs)

    @classmethod
    def withconfig(cls, config, *above):
        return cls(cls.config(config, *above))


class BaseDataFlowFacilitatorObjectContext(LoggingLogger):
    """
    Base class for all Data Flow Facilitator object's contexts. These are
    classes which support async context management. Classes ending with
    ...Context are the most inner context's which are used in DFFML.

    See the :class:BaseDataFlowFacilitatorObject for example usage.
    """

    async def __aenter__(self) -> "BaseDataFlowFacilitatorObjectContext":
        return self

    async def __aexit__(self, exc_type, exc_value, traceback):
        pass


class BaseDataFlowFacilitatorObject(
    BaseDataFlowFacilitatorObjectContext, BaseConfigurable, Entrypoint
):
    """
    Base class for all Data Flow Facilitator objects conforming to the
    instantiate -> enter context -> return context via __call__ -> enter
    returned context's context pattern. Therefore they must contain a CONTEXT
    property, set to the BaseDataFlowFacilitatorObjectContext which will be
    returned from a __call__ to this class.

    DFFML is plugin based using Python's setuptool's entrypoint API. All
    classes inheriting from BaseDataFlowFacilitatorObject must have a property
    named ENTRYPOINT. In the form of `dffml.load_point` which will be used to
    load all classes registered to that entry point.

    >>> import asyncio
    >>> from dffml import *
    >>>
    >>> # Create the base object. Then enter it's context to preform any initial
    >>> # setup. Call obj to get an instance of obj.CONTEXT, which is a subclass
    >>> # of BaseDataFlowFacilitatorObjectContext. ctx, the inner context, does
    >>> # all the heavy lifting.
    >>>
    >>> class Context(BaseDataFlowFacilitatorObjectContext):
    ...     async def method(self):
    ...         return
    >>>
    >>> class Object(BaseDataFlowFacilitatorObject):
    ...     CONTEXT = Context
    ...     def __call__(self):
    ...         return Context()
    >>>
    >>> async def main():
    ...     async with Object(BaseConfig()) as obj:
    ...         async with obj() as ctx:
    ...             await ctx.method()
    >>>
    >>> asyncio.run(main())
    """

    def __init__(self, config: Type[BaseConfig]) -> None:
        BaseConfigurable.__init__(self, config)
        # TODO figure out how to call these in __new__
        self.__ensure_property("CONTEXT")
        self.__ensure_property("ENTRYPOINT")

    def __repr__(self):
        return "%s(%r)" % (self.__class__.__qualname__, self.config)

    @abc.abstractmethod
    def __call__(self) -> "BaseDataFlowFacilitatorObjectContext":
        pass

    @classmethod
    def __ensure_property(cls, property_name):
        if getattr(cls, property_name, None) is None:
            raise MissingRequiredProperty(
                "BaseDataFlowFacilitatorObjects may not be "
                "created without a `%s`. Missing %s.%s"
                % (property_name, cls.__qualname__, property_name)
            )