"""
Generating terraform with pyterranetes is, as with kubernetes, just a
question of writing a p10s script which creates a
``terraform.Context`` object and adds terraform blocks to it:

.. code-block:: python

    from p10s import tf

    c = tf.Context()

    c += tf.Variable("foo", dict(
        default=0
    ))

    c += tf.variables(key="${data.terraform_remote_state.key}"})

    c += tf.Resource("type", "name", dict(
        var = 'value'
    ))

    c += tf.from_hcl(""\"
        module "m" {
          source = "../m/"
        }
    ""\")

    c += tf.outputs(key="${module.m.key}"})

    ...


For each terraform configuration block there is a corresponding python
class. Depending on the block type and how it's used some of the
classes provid convenience methods and constructors. All of the
classes have a ``.body`` property representing the data inside the
block.

There are 3 different categories of terraform blocks:

Blocks that don't have any properties other than ``.body``:
    :py:class:`terraform <p10s.terraform.Terraform>`, and
    :py:class:`locals <p10s.terraform.Locals>`.
Blocks that also have a name property:
    :py:class:`provider <p10s.terraform.Provider>`,
    :py:class:`variable <p10s.terraform.Variable>`, and
    :py:class:`output <p10s.terraform.Output>`
Blocks that also have a type and a name property:
    :py:class:`resource <p10s.terraform.Resource>`, and
    :py:class:`data <p10s.terraform.Data>`

Sometimes it's more convenient to start with hcl code, we can use the
:py:func:`from_hcl <p10s.terraform.from_hcl>` function:

.. code-block:: python

    c += tf.from_hcl(\"""
        resource "type" "name" {
          var = "value"
          ...
        }
    \""")

Note the hcl has two, equivalent, ways to assign a nested map (dict in python):

.. code-block:: none

    key {
       v = k
    }

and

.. code-block:: none

    key = {
       v = k
    }

When parsing hcl pyterranetes will convert both of these to a single
entry with key ``key`` and value a dict with the values of the
corresponsding block. As pyterranetes only actually generates
``.tf.json`` and not ``.tf`` (hcl) files, there is also no ambiguity
in the output.
"""

import json
from copy import deepcopy
import collections.abc

from p10s.loads import hcl
from p10s.utils import merge_dicts
from p10s.base import BaseContext
from pprint import pformat


class Context(BaseContext):
    """Context for terraform files.

    As with the k8s.Context this instances of this class represent a
    single terraform file. Block of terraform can be added using the
    ``+=`` operator.

    By default adding the same terraform block multiple times to a context
    does not cause any errors, the values will be merged in as normal. If
    needed more strict verification can be added by passing `strict=True`
    to the Context constructor.

    """
    output_file_extension = '.tf.json'

    def __init__(self, *args, data=None, strict=False, **kwargs):
        if data is None:
            self.data = {}
        else:
            self.data = data

        self.strict = strict

        super().__init__(*args, **kwargs)

    def _merge_in(self, values):
        self.data = merge_dicts(self.data, values)
        return self

    def add(self, block):
        if not isinstance(block, (list, tuple)):
            block = [block]
        for b in block:
            if self.strict:
                existing = self.lookup(b._key())
                if existing is not None:
                    raise DuplicateBlockError(existing, block)
            self._merge_in(b.data)
        return self

    def lookup(self, key):
        def _rec(here, path):
            if len(path) == 0:
                return here
            if isinstance(here, collections.abc.Mapping):
                if path[0] in here:
                    first = path.pop(0)
                    return _rec(here[first], path)
            return None

        return _rec(self.data, key)

    def __iadd__(self, block):
        """Add ``block`` to the context's data, destructively modifies ``self``

        :param TerraformBlock block:
        """
        return self.add(block)

    def __add__(self, block):
        """Returns a new context containg all the data in ``self`` and ``block``

        :param TerraformBlock block:
        """
        return deepcopy(self).add(block)

    def _render_data(self):
        return self.data

    def render(self):
        with self._output_stream() as tf_json:
            tf_json.write(json.dumps(self._render_data(), indent=4, sort_keys=True))


class DuplicateBlockError(ValueError):
    def __init__(self, existing, new_block):
        self.existing = existing
        self.new_block = new_block


class TerraformBlock():
    """Abstract base class for all terrform blocks.

    Exposes the :py:meth:`.body <p10s.terrform.TerraformBlock.body>`
    property for direct manipulation of the block's data.

    """
    def __init__(self, data=None):
        if data is not None:
            self.data = data

    def render(self):
        return self.data

    @property
    def body(self):
        """Returns the data contained in thie block. name, type, provider or
        other non-body values are exposed as properties on the
        corresponding object.

        """
        return self._body()

    def update(self, new_body_values):
        """Merges in ``new_body_values`` with this block's body. Does not
        change ``name`` or ``type`` or any of the block's
        properties.

        """
        merge_dicts(self.body, new_body_values)
        return self

    def _body(self):
        raise NotImplementedError()  # pragma: no cover

    def _key(self):
        raise NotImplementedError()  # pragma: no cover

    def __eq__(self, other):
        return self.__dict__ == other.__dict__


class NoArgsBlock(TerraformBlock):
    def __init__(self, body):
        super().__init__({
            self.KIND: body or {}
        })

    def _body(self):
        return self.data[self.KIND]

    def _key(self):
        return [self.KIND]

    def __repr__(self):
        return "#<%s %s>" % (self.KIND, len(self.body))


class NameBlock(TerraformBlock):
    def __init__(self, name, body=None):
        self._name = name
        super().__init__({
            self.KIND: {
                name: body or {}
            }
        })

    def _body(self):
        return self.data[self.KIND][self.name]

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        if name != self._name:
            self.data[self.KIND][name] = self.data[self.KIND][self._name]
            del self.data[self.KIND][self._name]
            self._name = name

    def _key(self):
        return [self.KIND, self.name]

    def __repr__(self):
        return "#<%s %s %s>" % (self.KIND, self.name, len(self.body))


class TypeNameBlock(TerraformBlock):
    def __init__(self, type, name, body=None):
        self._type = type
        self._name = name
        super().__init__({
            self.KIND: {
                type: {
                    name: body or {}
                }
            }
        })

    def _body(self):
        return self.data[self.KIND][self._type][self._name]

    @property
    def type(self):
        return self._type

    @type.setter
    def type(self, name):
        self.data[self.KIND][name] = self.data[self.KIND][self._type]
        del self.data[self.KIND][self._type]
        self._type = name

    @property
    def name(self):
        return self._name

    @name.setter
    def name(self, name):
        self.data[self.KIND][self._type][name] = self.data[self.KIND][self._type][self.name]
        del self.data[self.KIND][self._type][self._name]
        self._name = name

    def _key(self):
        return [self.KIND, self.type, self.name]

    def __repr__(self):
        return "#<%s %s %s %s>" % (self.KIND, self.type, self.name, len(self.body))


class Terraform(NoArgsBlock):
    """``terraform`` block. Doesn't expose any properties beyond ``body``."""
    KIND = 'terraform'


class Locals(NoArgsBlock):
    """``locals`` block. Doesn't expose any properties beyond ``body``."""
    KIND = 'locals'


class Variable(NameBlock):
    """``variable`` block. Exposes `.name` as a property.

    If you're defining a variable without a type or a description (90%
    of the cases in practice), it is probably easier to use
    :func:`variables <p10s.terraform.variables>`.

    """
    KIND = 'variable'

    def __repr__(self):
        return "#<%s %s %s>" % (self.KIND, self.name, self.body.get('default', None))


def variables(**kwargs):
    """Helper function for defining multiple :class:`variable
    <p10s.terraform.Variable` blocks which only specify their default
    value.

    Example:

    .. code-block:: python

      c += tf.variables(
          name='value,
          other='other value')

    is short hand for:

    .. code-block:: python

      c += tf.Variable("name", {'default': 'value'})
      c += tf.Variable("other", {'default': 'other value'})

    """
    return [Variable(name=name, body={'default': kwargs[name]}) for name in kwargs.keys()]


class Output(NameBlock):
    """``output`` block. Exposes `.name` as a property.

    The constructor is designed to be convenient to use in p10s
    scripts. Any (single) keyword argument passed to the constructor,
    other than ``name`` and ``body``, is assumed to be the name of an
    output with the given value.

    So this call:

    .. code-block:: python

        o = Output(ip="${aws_eip.ip.public_ip})

    is equivalent to this:

    .. code-block:: python

        o = Output(name="ip", body={
            'var_name': "${aws_eip.ip.public_ip}
        })

    The convenience constructor can be used to define exactly one output,
    for similar syntax for multiple outputs see :func:`outputs
    <p10s.terraform.outputs>`

    """
    KIND = 'output'

    def __init__(self, name=None, body=None, **kwargs):
        if name is None and body is None:
            keys = list(kwargs.keys())
            if len(keys) != 1:
                raise Exception("Output's dwim constructor expects exactly one arg, not %s", kwargs)
            name = keys[0]
            body = {
                'value': kwargs[name]
            }

        super().__init__(name=name, body=body)

    def __repr__(self):
        return "#<%s %s %s>" % (self.KIND, self.name, self.body.get('value', None))


def outputs(**kwargs):
    """Helper function for defining multiple :class:`output <p10s.terraform.Output>`  blocks.

    Example:

    .. code-block:: python

      c += tf.outputs(
          name='${module.foo.id},
          other='${module.foo.key}')

    is short hand for:

    .. code-block:: python

      c += tf.Output("name", {'value': '${module.foo.id}'})
      c += tf.Output("other", {'value': '${module.foo.key}'})


    """
    return [Output(name=name, body={'value': kwargs[name]}) for name in kwargs.keys()]


class Module(NameBlock):
    """``module`` block. Exposes `.name` as a property."""
    KIND = 'module'


class Provider(NameBlock):
    """``provider`` block. Exposes `.name` as a property."""
    KIND = 'provider'


class Resource(TypeNameBlock):
    """``resource`` block. Exposes `.name` and `.type` as properties."""
    KIND = 'resource'


class Data(TypeNameBlock):
    """``data`` block. Exposes `.name` and `.type` as properties."""
    KIND = 'data'


class HCLParseError(Exception):
    def __init__(self, data, error=None):
        self.data = data
        self.error = error

    def __str__(self):
        return "Unable to parse HCL text %s: %s" % (pformat(self.data), self.error)


class HCLUnknownBlockError(HCLParseError):
    def __init__(self, kind):
        self.kind = kind

    def __str__(self):
        return "Unknown terraform block type %s" % self.kind


def many_from_hcl(hcl_string):
    """Build TerraformBlock objects from hcl text. Always returns a list of blocks.

    See :func:`p10s.terraform.from_hcl` for examples and
    :func:`p10s.loads.hcl` for details on the unerlying hcl parser.

    """

    try:
        data = hcl(hcl_string)
    except Exception as e:
        raise HCLParseError(data=hcl_string, error=e) from e

    blocks = []

    for kind in data.keys():
        if kind == 'terraform':
            blocks.append(Terraform(body=data[kind]))
        elif kind == 'locals':
            blocks.append(Locals(body=data[kind]))
        elif kind in ('variable', 'output', 'locals', 'module', 'provider'):
            for name in data[kind].keys():
                if kind == 'variable':
                    cls = Variable
                if kind == 'output':
                    cls = Output
                if kind == 'module':
                    cls = Module
                if kind == 'provider':
                    cls = Provider
                blocks.append(cls(name=name, body=data[kind][name]))
        elif kind in ('resource', 'data'):
            for type in data[kind].keys():
                for name in data[kind][type]:
                    if kind == 'resource':
                        cls = Resource
                    if kind == 'data':
                        cls = Data
                    blocks.append(cls(type=type, name=name, body=data[kind][type][name]))
        else:
            raise HCLUnknownBlockError(kind=kind)

    return sorted(blocks, key=lambda b: repr(b))


def from_hcl(hcl_string):
    """Build a TerraformBlock from hcl text.

    :param hcl_string: hcl text to parse
    :type hcl_string: a string, a pathlib.Path, or an io.Base object

    .. code-block:: python

        c += tf.from_hcl(\"\"\"
          foo { }
        "\"\")

    Note that the return value is an instance of tf.TerraformBlock, and
    has all the corresponding methods:

    .. code-block:: python

        resource = tf.from_hcl('''
          resource "type" "name" {
            key = 'value'
          }
        ''')

        resource.body['key'] = 'other value'

    It can be convenient to keep most of the terraform code in a seperate
    file and only use pyterranetes for certain operations. In this case
    it's often best to leave the terraform code seperate and just read it
    into the p10s script:

    .. code-block:: python

        for name in (name1, name2, ...):
            resource = tf.from_hcl(Path('./base.tf'))
            resource.name = name
            c += resource

    See :func:`p10s.loads.hcl` for details on the unerlying hcl parser.
    """
    blocks = many_from_hcl(hcl_string)

    if len(blocks) == 1:
        return blocks[0]
    else:
        raise Exception("Expected exactly one block when using `from_hcl` but got %s blocks from %s" %
                        (len(blocks), hcl_string))
