from __future__ import annotations

from dataclasses import dataclass
from enum import Enum
from functools import wraps
from typing import Any
from typing import Callable

from dynaconf.base import Settings
from dynaconf.loaders.base import SourceMetadata

__all__ = [
    "hookable",
    "EMPTY_VALUE",
    "Hook",
    "EagerValue",
    "HookValue",
    "MethodValue",
    "Action",
    "HookableSettings",
    "post_hook",
]


class Empty: ...


EMPTY_VALUE = Empty()


def hookable(function=None, name=None):
    """Adds before and after hooks to any method.

    :param function: function to be decorated
    :param name: name of the method to be decorated (default to method name)
    :return: decorated function

    Usage:

        class MyHookableClass(Settings):
            @hookable
            def execute_loaders(....):
                # do whatever you want here
                return super().execute_loaders(....)

        settings = Dynaconf(_wrapper_class=MyHookableClass)

        def hook_function(temp_settings, value, ...):
            # do whatever you want here
            return value

        settings.add_hook("after_execute_loaders", Hook(function))

        settings.FOO
        # will trigger execute_loaders
        # -> will trigger the hookable method
        # -> will execute registered hooks

    see tests/test_hooking.py for more examples.
    """

    if function and not callable(function):
        raise TypeError("hookable must be applied with named arguments only")

    def dispatch(fun, self, *args, **kwargs):
        """calls the decorated function and its hooks"""

        # if object has no hooks, return the original
        if not (_registered_hooks := get_hooks(self)):
            return fun(self, *args, **kwargs)

        function_name = name or fun.__name__

        # function being called not in the list of hooks, return the original
        if not set(_registered_hooks).intersection(
            (f"before_{function_name}", f"after_{function_name}")
        ):
            return fun(self, *args, **kwargs)

        # Create an unhook-able (to avoid recursion)
        # temporary settings to pass to the hooked function
        temp_settings = TempSettingsHolder(self)

        def _hook(action: str, value: HookValue) -> HookValue:
            """executes the hooks for the given action"""
            hooks = _registered_hooks.get(f"{action}_{function_name}", [])
            for hook in hooks:
                value = hook.function(temp_settings, value, *args, **kwargs)
                value = HookValue.new(value)
            return value

        # Value starts as en empty value on the first before hook
        value = _hook("before", HookValue(EMPTY_VALUE))

        # If the value is EagerValue, it means main function should not be
        # executed and the value should go straight to the after hooks if any
        original_value = EMPTY_VALUE
        if not isinstance(value, EagerValue):
            value = MethodValue(fun(self, *args, **kwargs))
            original_value = value.value

        value = _hook("after", value)

        # track the loading history
        # adding inspect history like:
        # "identifier": "get_hook_(read_settings_from_cache_or_db)"
        if value.value != original_value and function_name == "get":
            hook_names = "_".join(
                [
                    hook.function.__name__
                    for list_of_hooks in _registered_hooks.values()
                    for hook in list_of_hooks
                ]
            )
            metadata = SourceMetadata(
                loader="hooking",
                identifier=f"{function_name}_hook_({hook_names})",
                merged=True,
            )
            history = self._loaded_by_loaders.setdefault(metadata, {})
            key = args[0] if args else kwargs.get("key")
            history[key] = value.value

        # unwrap the value from the HookValue so it can be returned
        # normally to the caller
        return value.value

    if function:
        # decorator applied without parameters e.g: @hookable
        @wraps(function)
        def wrapper(*args, **kwargs):
            return dispatch(function, *args, **kwargs)

        wrapper.original_function = function
        return wrapper

    def decorator(function):
        # decorator applied with parameters e.g: @hookable(before=False)
        @wraps(function)
        def wrapper(*args, **kwargs):
            return dispatch(function, *args, **kwargs)

        wrapper.original_function = function
        return wrapper

    return decorator


def get_hooks(obj):
    """get registered hooks from object
    must try different casing and accessors because of
    tests and casing mode set on dynaconf.
    """
    attr = "_registered_hooks"
    for key in [attr, attr.upper()]:
        if hasattr(obj, key):
            return getattr(obj, key)
        elif isinstance(obj, dict) and key in obj:
            return obj[key]
        elif hasattr(obj, "_store") and key in obj._store:
            return obj._store[key]
    return {}


@dataclass
class Hook:
    """Hook to wrap a callable on _registered_hooks list.

    :param callable: The callable to be wrapped

    The callable must accept the following arguments:

    - temp_settings: Settings or a Dict
    - value: The value to be processed wrapper in a HookValue
      (accumulated from previous hooks, last hook will receive the final value)
    - *args: The args passed to the original method
    - **kwargs: The kwargs passed to the original method

    The callable must return the value:

    - value: The processed value to be passed to the next hook
    """

    function: Callable


@dataclass
class HookValue:
    """Base class for hook values.
    Hooks must return a HookValue instance.
    """

    value: Any

    @classmethod
    def new(cls, value: Any) -> HookValue:
        """Return a new HookValue instance with the given value."""
        if isinstance(value, HookValue):
            return value
        return cls(value)

    def __str__(self) -> str:
        return str(self.value)

    def __eq__(self, other) -> bool:
        return self.value == other

    def __ne__(self, other) -> bool:
        return self.value != other

    def __bool__(self) -> bool:
        return bool(self.value)

    def __len__(self) -> int:
        return len(self.value)

    def __iter__(self):
        return iter(self.value)

    def __getitem__(self, item):
        return self.value[item]

    def __setitem__(self, key, value):
        self.value[key] = value

    def __delitem__(self, key):
        del self.value[key]

    def __contains__(self, item):
        return item in self.value

    def __getattr__(self, item):
        return getattr(self.value, item)

    def __setattr__(self, key, value):
        if key == "value":
            super().__setattr__(key, value)
        else:
            setattr(self.value, key, value)

    def __add__(self, other):
        return self.value + other

    def __sub__(self, other):
        return self.value - other

    def __mul__(self, other):
        return self.value * other

    def __truediv__(self, other):
        return self.value / other

    def __floordiv__(self, other):
        return self.value // other

    def __mod__(self, other):
        return self.value % other

    def __divmod__(self, other):
        return divmod(self.value, other)

    def __pow__(self, power, modulo=None):
        return pow(self.value, power, modulo)

    def __delattr__(self, item):
        delattr(self.value, item)

    def __repr__(self) -> str:
        return repr(self.value)


class MethodValue(HookValue):
    """A value returned by a method
    The main decorated method have its value wrapped in this class
    """


class EagerValue(HookValue):
    """Use this wrapper to return earlier from a hook.
    Main function is bypassed and value is passed to after hooks."""


class Action(str, Enum):
    """All the hookable functions"""

    AFTER_GET = "after_get"
    BEFORE_GET = "before_get"


class HookableSettings(Settings):
    """Wrapper for dynaconf.base.Settings that adds hooks to get method."""

    _REGISTERED_HOOKS: dict[Action, list[Hook]] = {}
    # needed because django of Django admin see #1000

    @hookable
    def get(self, *args, **kwargs):
        return Settings.get(self, *args, **kwargs)


class TempSettingsHolder:
    """Holds settings to be passed down to hooks.
    To save runtime resources initialize a copy of it only if accessed.
    """

    _settings = None

    def __init__(self, settings):
        self._original_settings = settings

    def _initialize(self):
        if self._settings is None:
            self._settings = Settings(
                dynaconf_skip_loaders=True,
                dynaconf_skip_validators=True,
                _store=self._original_settings._store.copy(bypass_eval=True),
            )

    def __getattr__(self, attr):
        self._initialize()
        return getattr(self._settings, attr)

    def __getitem__(self, item):
        self._initialize()
        return self._settings[item]

    def __setitem__(self, item, value):
        self._initialize()
        self._settings[item] = value

    def __iter__(self):
        self._initialize()
        return iter(self._settings)

    def __contains__(self, item):
        self._initialize()
        return item in self._settings

    def __setattr__(self, attr, value):
        if attr in ["_original_settings", "_settings"]:
            super().__setattr__(attr, value)
        else:
            self._initialize()
            setattr(self._settings, attr, value)


def post_hook(function: Callable) -> Callable:
    """This decorator marks a function as a post hook.
    This works by adding the _dynaconf_hook attribute to the function,
    then the python loader, when reading the module, will look for
    this attribute and register the function as a post_hook for the settings.

    e.g: On a settings file with .py extension:

    from dynaconf import post_hook
    @post_hook
    def set_log_handlers(settings) -> dict:
        data = {}  # data to be merged into settings

        # conditionals
        if (logging := settings.get('LOGGING')) is not None:
            # do something with logging
            # add it back to data
            data['LOGGING'] = logging
        return data
    """
    try:
        function._dynaconf_hook = True  # type: ignore
        function._called = False  # type: ignore
        function._dynaconf_hook_source = function.__module__  # type: ignore
    except (AttributeError, TypeError):
        raise TypeError(
            "post_hook decorator must be applied to a function or method."
        )
    else:
        # On the same scope where the decorated function is defined we
        # add a variable with the same name as the function but prefixed
        # with _dynaconf_hook_ this variable will be used by the loader
        # to register the function as a post_hook
        function.__globals__[f"_dynaconf_hook_{function.__name__}"] = function

    return function
