#!/usr/bin/env python
# -*- coding: UTF-8 -*-
# File: model_desc.py


from abc import ABCMeta, abstractmethod
from collections import namedtuple
import tensorflow as tf
import six

from ..utils.argtools import memoized
from ..utils.develop import log_deprecated
from ..tfutils.gradproc import FilterNoneGrad
from ..tfutils.tower import get_current_tower_context
from ..input_source import InputSource
from ..models.regularize import regularize_cost_from_collection

__all__ = ['InputDesc', 'ModelDesc', 'ModelDescBase']


class InputDesc(
        namedtuple('InputDescTuple', ['type', 'shape', 'name'])):
    """
    Metadata about an input entry point to the graph.
    This metadata can be later used to build placeholders or other types of
    input source.
    """

    def __new__(cls, type, shape, name):
        """
        Args:
            type (tf.DType):
            shape (tuple):
            name (str):
        """
        shape = tuple(shape)    # has to be tuple for "self" to be hashable
        assert isinstance(type, tf.DType), type
        if any(k in name for k in [':', '/', ' ']):
            raise ValueError("Invalid InputDesc name: '{}'".format(name))
        self = super(InputDesc, cls).__new__(cls, type, shape, name)
        self._cached_placeholder = None
        return self

    def build_placeholder(self):
        """
        Build a tf.placeholder from the metadata.

        Returns:
            tf.Tensor:
        """
        with tf.name_scope(None):   # clear any name scope it might get called in
            ret = tf.placeholder(
                self.type, shape=self.shape, name=self.name)
        if self._cached_placeholder is None:
            self._cached_placeholder = ret  # cached_placeholder only caches the prefix='' case
        return ret

    # cannot memoize here, because InputDesc is hashed by its fields.
    def build_placeholder_reuse(self):
        """
        Build a tf.placeholder from the metadata, or return an old one.

        Returns:
            tf.Tensor:
        """
        if self._cached_placeholder is not None:
            return self._cached_placeholder
        return self.build_placeholder()


@six.add_metaclass(ABCMeta)
class ModelDescBase(object):
    """ Base class for a model description.
    """
    @memoized
    def get_inputs_desc(self):
        """
        Returns:
            list[:class:`InputDesc`]: list of the underlying :class:`InputDesc`.
        """
        return self._get_inputs()

    @abstractmethod
    def _get_inputs(self):
        """
        :returns: a list of InputDesc
        """

    def build_graph(self, *args):
        """
        Build the whole symbolic graph.
        This is supposed to be the "tower function" when used with :class:`TowerTrainer`.
        By default it will call :meth:`_build_graph` with a list of input tensors, for backward-compatibility.

        Args:
            args ([tf.Tensor]): tensors that matches the list of
                :class:`InputDesc` defined by ``_get_inputs``.
        """
        if len(args) == 1:
            arg = args[0]
            if isinstance(arg, InputSource):
                inputs = arg.get_input_tensors()  # remove in the future?
                log_deprecated("build_graph(InputSource)",
                               "Call with tensors in positional args instead.", "2018-03-31")
            elif isinstance(arg, (list, tuple)):
                inputs = arg
                log_deprecated("build_graph([Tensor])", "Call with positional args instead.", "2018-03-31")
            else:
                inputs = [arg]
        else:
            inputs = args

        assert len(inputs) == len(self.get_inputs_desc()), \
            "Number of inputs passed to the graph != number of inputs defined " \
            "in ModelDesc! ({} != {})".format(len(inputs), len(self.get_inputs_desc()))
        self._build_graph(inputs)

    def _build_graph(self, inputs):
        """
        This is an old interface which takes a list of tensors, instead of positional arguments.
        """
        pass


class ModelDesc(ModelDescBase):
    """
    A ModelDesc with single cost and single optimizer.
    It contains information about InputDesc, how to get cost, and how to get optimizer.
    """

    def get_cost(self):
        """
        Return the cost tensor in the graph.

        It calls :meth:`ModelDesc._get_cost()` which by default returns
        ``self.cost``. You can override :meth:`_get_cost()` if needed.

        This function also applies the collection
        ``tf.GraphKeys.REGULARIZATION_LOSSES`` to the cost automatically.
        """
        cost = self._get_cost()
        reg_cost = regularize_cost_from_collection()
        if reg_cost is not None:
            return tf.add(cost, reg_cost, name='cost_with_regularizer')
        else:
            return cost

    def _get_cost(self, *args):
        return self.cost

    @memoized
    def get_optimizer(self):
        """
        Return the memoized optimizer returned by `_get_optimizer`.

        Users of :class:`ModelDesc` will need to implement `_get_optimizer()`,
        which will only be called once per each model.

        Returns:
            a :class:`tf.train.Optimizer` instance.
        """
        return self._get_optimizer()

    def _get_optimizer(self):
        raise NotImplementedError()

    def _build_graph_get_cost(self, *inputs):
        self.build_graph(*inputs)
        return self.get_cost()

    # TODO this is deprecated and only used for v1 trainers
    def _build_graph_get_grads(self, *inputs):
        """
        Build the graph from inputs and return the grads.

        Returns:
            [(grad, var)]
        """
        ctx = get_current_tower_context()
        cost = self._build_graph_get_cost(*inputs)

        if ctx.has_own_variables:
            varlist = ctx.get_collection_in_tower(tf.GraphKeys.TRAINABLE_VARIABLES)
        else:
            varlist = tf.trainable_variables()
        opt = self.get_optimizer()
        grads = opt.compute_gradients(
            cost, var_list=varlist,
            gate_gradients=False, colocate_gradients_with_ops=True)
        grads = FilterNoneGrad().process(grads)
        return grads
