import collections
import contextlib
import json
import sys

from .exceptions import (
    DuplicateDeclarationException,
    IncompatibleProviderConfiguration,
    TwoAnonymousNames,
)
from .utils import BaggyDictionary, get_location_from_frame, infinite_defaultdict

INTERNAL_ATTR_NAMES = {
    "_blocks",
    "_provider",
    "_resource_name",
    "_kind",
    "_resource_type",
    "_declaration_location",
    "_name",
    "_as_object",
    "_make_ref",
    "_args",
    "mro",
}

ALLOWED_RESOURCE_BLOCK_KINDS = {"module", "data", "resource"}


class OurJSONEncoder(json.JSONEncoder):
    def default(self, value):
        if hasattr(value, "_as_object"):
            return value._as_object
        return super().default(value)


class Model:
    def __init__(self):
        self.reset()

    def reset(self):
        self.resource_store = collections.defaultdict(dict)
        self.top_level_store = collections.defaultdict(dict)
        self.module_stack = []

    def register_resource(self, item, name):
        known_items = self.resource_store[type(item).__name__]
        if name in known_items:
            resource_kind = item._kind
            if resource_kind == "resource":
                resource_kind = ""
            loc = known_items[name]._declaration_location
            if name is None:
                raise TwoAnonymousNames(
                    "The %sresource of type %r was declared twice, also here: %r"
                    % (resource_kind, item._resource_name, loc)
                )
            raise DuplicateDeclarationException(
                "The name %r for a %sresource of type %r was also declared here: %r"
                % (name, resource_kind, item._resource_name, loc)
            )
        known_items[name] = item

    def register_top_level_block(self, item, name):
        known_items = self.top_level_store[item.block_type]
        if name in known_items:
            loc = known_items[name]._declaration_location
            if name is None:
                raise DuplicateDeclarationException(
                    "The %s block was also declared here: %r" % (item.block_type, loc)
                )
            raise DuplicateDeclarationException(
                "The name %r for a %s block was also declared here: %r"
                % (name, item.block_type, loc)
            )
        known_items[name] = item

    @contextlib.contextmanager
    def module(self, name):
        self.module_stack.append(name)
        yield
        self.module_stack.pop()

    @property
    def module_path(self):
        return "--".join(self.module_stack + [""]) if self.module_stack else ""

    @property
    def as_object(self):
        obj = infinite_defaultdict()
        obj["//"] = "Generated by SnakeCraft"
        obj["terraform"]["required_providers"] = {}
        for items in self.top_level_store.values():
            for item in items.values():
                item.patch_into_object(obj)

        for items in self.resource_store.values():
            for item_name, item in items.items():
                section_root = obj[item._kind]
                if item._resource_type:
                    section_root = section_root[item._resource_type]
                section_root[item_name] = self.render(item, item)
        return obj

    @property
    def as_json(self):
        return json.dumps(self.as_object, indent=4, cls=OurJSONEncoder)

    def render(self, item, resource):
        obj = infinite_defaultdict()
        for block_name, blocks in getattr(item, "_blocks", {}).items():
            if len(blocks) == 1 and not blocks[0].__doc__:
                obj[block_name] = self.render(blocks[0](), resource)
            else:
                obj[block_name] = [
                    {block.__doc__: self.render(block(), resource)}
                    if block.__doc__
                    else self.render(block(), resource)
                    for block in blocks
                ]
        for attr_name in dir(type(item)):
            if attr_name in INTERNAL_ATTR_NAMES or attr_name.startswith("__"):
                continue
            obj[attr_name] = self.render_immediate(resource, getattr(item, attr_name))
        return obj

    def render_immediate(self, resource, obj):
        if isinstance(obj, list):
            return [self.render_immediate(resource, item) for item in obj]
        elif isinstance(obj, dict):
            return {
                self.render_immediate(resource, key): self.render_immediate(resource, value)
                for key, value in obj.items()
            }
        elif callable(obj):
            return obj(resource)
        return obj


model = Model()
ModuleContext = model.module


class BlockTemplateMeta(type):
    def __prepare__(name, bases, **kwargs):
        return BaggyDictionary()

    def __new__(cls, cls_name, bases, attributes, provider=..., **kwargs):
        new_blocks = collections.defaultdict(list)
        for base in bases:
            if hasattr(base, "_blocks"):
                for block_name, blocks in base._blocks.items():
                    new_blocks[block_name].extend(blocks)
        new_attributes = collections.OrderedDict()
        for name, item in attributes.items():
            if hasattr(item, "__bases__") and not isinstance(item, _CallableType):
                new_blocks[name].append(item)
            else:
                new_attributes[name] = item
        new_attributes["_blocks"] = new_blocks
        if provider is not ...:
            new_attributes["_provider"] = provider
            if provider:
                provider_alias = provider._args.get("alias")
                if provider_alias:
                    new_attributes["provider"] = provider(...)
        tmpl = super().__new__(cls, cls_name, bases, new_attributes, **kwargs)
        return tmpl


class BlockTemplate(metaclass=BlockTemplateMeta):
    def __init_subclass__(cls, **kwargs):
        super().__init_subclass__(**kwargs)
        if BlockTemplate in cls.__bases__:
            cls._resource_name = cls.__name__

    def __init__(self, name=None, **kwargs):
        self._args = dict(kwargs)
        if isinstance(name, int):
            name = "_%i" % name
        name = f"{type(self).__name__}-anonymous" if name is None else name
        name = model.module_path + name
        self._name = name
        self._declaration_location = get_location_from_frame(sys._getframe(1))
        model.register_resource(self, name)

    def __getattr__(self, name):
        if name.startswith("__"):
            raise AttributeError
        return self._make_ref(name)

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

    @property
    def _resource_type(self):
        if self._provider is None:
            return
        return f"{self._provider._name}_{self._resource_name}"

    @property
    def _as_object(self):
        return self._make_ref()(...)

    def _make_ref(self, attr_name=None):
        kind = self._kind
        if kind == "resource":
            kind = ""
        else:
            kind += "."
        return RefString(
            lambda _, self=self, attr_name=attr_name: "${%s%s.%s%s}"
            % (
                kind,
                self._resource_type,
                self._name,
                "." + attr_name if attr_name else "",
            )
        )


class ModuleMeta(type):
    def __prepare__(name, bases, **kwargs):
        model.module_stack.append(name)
        return kwargs

    def __new__(cls, cls_name, bases, attributes, **kwargs):
        popped_module_name = model.module_stack.pop()
        assert popped_module_name == cls_name
        return super().__new__(cls, cls_name, bases, attributes, **kwargs)


class Module(metaclass=ModuleMeta):
    """Declares all resources instantiated inside to be in a snakecraft module."""


class _CallableType:
    pass


class TopLevelBlock:
    block_type = None
    has_name = False
    prefix_name = False

    def __init__(self, name=None, **kwargs):
        if not self.block_type:
            raise ValueError("This class is abstract")
        if name is None and self.has_name:
            raise ValueError("Need to pass a name to %s" % (type(self).__name__,))
        if self.prefix_name:
            name = model.module_path + name
        self._args = kwargs
        self._name = name
        self.register()

    def register(self):
        self._declaration_location = get_location_from_frame(sys._getframe(2))
        model.register_top_level_block(self, self._name)

    def patch_into_object(self, obj):
        root = obj[self.block_type]
        if self._name is not None:
            root = root[self._name]
        root.update({name: model.render_immediate(..., arg) for name, arg in self._args.items()})


class Output(TopLevelBlock):
    """Output block."""

    block_type = "output"
    has_name = True


def Parameter(name, default=...):
    """Parameter that can be set on resource instantiation."""

    class FetchParameter(_CallableType):
        def __init__(self, path=None):
            self.path = path or []

        def __getattr__(self, name):
            return FetchParameter(self.path + [name])

        def __call__(self, resource):
            try:
                value = resource._args[name]
            except KeyError:
                if default is ...:
                    raise
                value = default
            for element in self.path:
                value = getattr(value, element)
            if hasattr(value, "__code__") or isinstance(value, _CallableType):
                value = value(resource)
            return value

    return FetchParameter()


class Deferred(_CallableType):
    """Wraps a function to avoid it being converted into a bound method."""

    def __init__(self, callable):
        self.callable = callable

    def __call__(self, resource):
        return self.callable(resource)


ResourceName = Deferred(lambda resource: resource._name)


class RefString(_CallableType):
    """Wraps a ref obtained e.g. by doing resource.something."""

    def __init__(self, callable):
        self.callable = callable

    def __call__(self, resource):
        return self.callable(resource)

    def __str__(self):
        return self.callable(...)

    __repr__ = __str__

    @property
    def _as_object(self):
        return str(self)


class ResourceNamespace:
    """Namespace of all resources of the provider."""

    def __init__(self, provider, kind):
        self.provider = provider
        self.kind = kind.lower()
        if self.kind not in ALLOWED_RESOURCE_BLOCK_KINDS:
            raise ValueError(
                "Unknown resource type %r, needs to be one of %s!"
                % (kind, ", ".join(sorted(ALLOWED_RESOURCE_BLOCK_KINDS)))
            )

    def __getattr__(self, name):
        return BlockTemplateMeta.__new__(
            BlockTemplateMeta,
            name,
            (BlockTemplate,),
            dict(_kind=self.kind.lower()),
            provider=self.provider,
        )


class Provider(_CallableType):
    """Declares a provider to be used."""

    tf_provider_fields = ("source", "version")
    block_type = "provider"

    def __init__(self, name, **kwargs):
        """name is the lower case name that can be found in the docs."""
        self._args = kwargs
        self._name = name
        self.register()

    def register(self):
        self._declaration_location = get_location_from_frame(sys._getframe(2))
        model.register_top_level_block(self, (self._name, self._args.get("alias")))

    def patch_into_object(self, obj):
        if self._name not in obj["provider"]:
            obj["provider"][self._name] = []
        obj["provider"][self._name].append(
            {
                name: arg(...) if callable(arg) else arg
                for name, arg in self._args.items()
                if name not in self.tf_provider_fields
            }
        )
        args = {k: v for k, v in self._args.items() if k in self.tf_provider_fields}
        alias = self._args.get("alias")
        old_args = obj["terraform"]["required_providers"].get(self._name) or {}
        aliases = old_args.pop("configuration_aliases", [])
        if alias:
            aliases.append(self(...))
        if old_args and old_args != args:
            raise IncompatibleProviderConfiguration(
                "The provider %s has two different configurations, please align them or configure "
                "only one alias: %r vs. %r"
                % (
                    self._name,
                    old_args,
                    args,
                )
            )
        args["configuration_aliases"] = aliases
        obj["terraform"]["required_providers"][self._name] = args

    def __getattr__(self, name):
        if name.startswith("__"):
            raise AttributeError(name)
        return ResourceNamespace(self, name)

    def resource(self, name):
        return self.__getattr__(name)

    def __call__(self, _):
        alias = self._args.get("alias")
        assert alias, "Alias of Provider declaration may not be None for using as references!"
        return ".".join([self._name, alias])


class TerraformBlock(TopLevelBlock):
    """Declares the toplevel terraform block."""

    block_type = "terraform"
    has_name = False


class Variable(TopLevelBlock, _CallableType):
    """Declares an input variable."""

    block_type = "variable"
    has_name = True

    def __call__(self, _):
        return "${var.%s}" % (self._name,)


# Allows to use/instantiate Terraform modules
TFModule = ResourceNamespace(None, "module").module


def write_json(file_obj):
    """Writes the current state into a .tf.json file."""
    print(model.as_json, file=file_obj)


def reset_model():
    """Resets the current state."""
    model.reset()
