# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# =============================================================================
"""
Nerual net class for constructing the nets using layers and providing access
functions for net info, e.g., parameters.


Example usages::

    from singa import net as ffnet
    from singa import metric
    from singa import loss
    from singa import layer
    from singa import device

    # create net and add layers
    net = ffnet.FeedForwardNet(loss.SoftmaxCrossEntropy(), metric.Accuracy())
    net.add(layer.Conv2D('conv1', 32, 5, 1, input_sample_shape=(3,32,32,)))
    net.add(layer.Activation('relu1'))
    net.add(layer.MaxPooling2D('pool1', 3, 2))
    net.add(layer.Flatten('flat'))
    net.add(layer.Dense('dense', 10))

    # init parameters
    for p in net.param_values():
        if len(p.shape) == 0:
            p.set_value(0)
        else:
            p.gaussian(0, 0.01)

    # move net onto gpu
    dev = device.create_cuda_gpu()
    net.to_device(dev)

    # training (skipped)

    # do prediction after training
    x = tensor.Tensor((2, 3, 32, 32), dev)
    x.uniform(-1, 1)
    y = net.predict(x)
    print tensor.to_numpy(y)
"""
from __future__ import print_function
from __future__ import absolute_import

from builtins import zip
from builtins import str
from builtins import object
import numpy as np
import os

from .proto.model_pb2 import kTrain, kEval
from .__init__ import __version__
from . import tensor
from . import layer
from . import snapshot

try:
    import pickle
except ImportError:
    import cPickle as pickle


'''For display training information, e.g L1 value of layer data'''
verbose = False


class FeedForwardNet(object):

    def __init__(self, loss=None, metric=None):
        '''Representing a feed-forward neural net.

        Args:
            loss, a Loss instance. Necessary training
            metric, a Metric instance. Necessary for evaluation
        '''
        self.loss = loss
        self.metric = metric
        self.layers = []
        self.src_of_layer = {}
        self.dst_of_layer = None
        self.ordered_layers = None
        self.out_sample_shape_of_layer = {}

    def to_device(self, dev):
        '''Move the net onto the given device, including
        all parameters and intermediate data.
        '''
        for lyr in self.layers:
            lyr.to_device(dev)

    def add(self, lyr, src=None):
        """Append a layer into the layer list.

        This function will get the sample shape from the src layers to setup the
        newly added layer. For the first layer, it is setup outside. The calling
        function should ensure the correctness of the layer order. If src is
        None, the last layer is the src layer. If there are multiple src layers,
        the src is a list of the src layers.

        Args:
            lyr (Layer): the layer to be added
            src (Layer): the source layer of lyr
        """
        if src is not None:
            if isinstance(src, layer.Layer):
                assert src.has_setup is True, 'the source layer must be set up'
                self.src_of_layer[lyr.name] = [src]
            else:
                assert type(src) == list, 'the src must be a list of layers'
                self.src_of_layer[lyr.name] = src
                # print 'merge------', len(src)
        else:
            assert len(self.layers) > 0 or lyr.has_setup, \
                'Source layers are needed to set up this layer'
            if len(self.layers) > 0:
                self.src_of_layer[lyr.name] = [self.layers[-1]]
            else:
                self.src_of_layer[lyr.name] = []
        if lyr.has_setup is False:
            in_shape = []
            for src in self.src_of_layer[lyr.name]:
                shapes = self.out_sample_shape_of_layer[src.name]
                assert len(shapes) > 0, \
                    'Cannot get output shape of layer %s' % lyr.name
                in_shape.append(shapes[0])
                shapes.pop(0)
            if len(in_shape) == 1:
                lyr.setup(in_shape[0])
            else:
                lyr.setup(in_shape)
        out_shape = lyr.get_output_sample_shape()
        if type(out_shape[0]) is tuple:
            self.out_sample_shape_of_layer[lyr.name] = out_shape
        else:
            self.out_sample_shape_of_layer[lyr.name] = [out_shape]
        self.layers.append(lyr)
        print((lyr.name, out_shape))
        return lyr

    def param_values(self):
        '''Return a list of tensors for all parameters'''
        values = []
        layers = self.layers
        if self.ordered_layers is not None:
            layers = self.ordered_layers
        for lyr in layers:
            values.extend(lyr.param_values())
        return values

    def param_specs(self):
        '''Return a list of ParamSpec for all parameters'''
        specs = []
        layers = self.layers
        if self.ordered_layers is not None:
            layers = self.ordered_layers
        for lyr in layers:
            specs.extend(lyr.param_specs)
        return specs

    def param_names(self):
        '''Return a list for the names of all params'''
        return [spec.name for spec in self.param_specs()]

    def train(self, x, y):
        '''Run BP for one iteration.
        This method is deprecated. It is only kept for backward compatibility.
        The name of this method is confusing since it does not update parameters.
        Please use backprob() instead.
        The back progagation algorithm computes gradients but it does not train.
        '''
        return self.backprob(x, y)

    def backprob(self, x, y):
        '''Run BP for one iteration.

        Currently only support nets with a single output layer, and a single
        loss objective and metric.
        For multiple outputs (with multiple loss/metric), please manually
        call forward, compute loss/metric and call backward. backward() is also
        more memory efficient than this function.

        Args:
            x: input data, a single input Tensor or a dict: layer name -> Tensor
            y: label data, a single input Tensor.
        Returns:
            gradients of parameters and the loss and metric values.
        '''
        out = self.forward(kTrain, x)
        l = self.loss.forward(kTrain, out, y)
        g = self.loss.backward()
        g /= x.shape[0]
        m = None
        if self.metric is not None:
            m = self.metric.evaluate(out, y)
        grads = []  # store all gradient tensors; memory inefficient
        for _, _, grad, _ in self.backward(g):
            grads.extend(grad[::-1])
        return grads[::-1], (l.l1(), m)

    def evaluate(self, x, y):
        '''Evaluate the loss and metric of the given data.

        Currently only support nets with a single output layer, and a single
        loss objective and metric.
        TODO(wangwei) consider multiple loss objectives and metrics.

        Args:
            x: input data, a single input Tensor or a dict: layer name -> Tensor
            y: label data, a single input Tensor.
        '''
        out = self.forward(kEval, x)
        l = None
        m = None
        assert self.loss is not None or self.metric is not None,\
            'Cannot do evaluation, as neither loss nor metic is set'
        if self.loss is not None:
            l = self.loss.evaluate(kEval, out, y)
        if self.metric is not None:
            m = self.metric.evaluate(out, y)
        return l, m

    def predict(self, x):
        '''Forward the input data through each layer to get the values of the
        output layers.

        Currently only support nets with a single output layer
        TODO(yujian) to handle multiple outputs from the network

        Args:
            x: input data, a single input Tensor or a dict: layer name -> Tensor

        Returns:
            a single output tensor as the prediction result.

        '''

        xx = self.forward(kEval, x)
        if type(xx) is dict:
            return tensor.softmax(list(xx.values())[0])
        else:
            return tensor.softmax(xx)

    def topo_sort(self, layers, src_of_layer):
        '''Topology sort of layers.

        It would try to preserve the orders of the input layers.

        Args:
            layers: a list of layers; the layers from the output of the same
                layer (e.g., slice layer) should be added by users in correct
                order; This function would not change their order.
            src_of_layer: a dictionary: src layer name -> a list of src layers

        Returns:
            A list of ordered layer
        '''
        order = []
        while len(order) < len(layers):
            for lyr in self.layers:
                if lyr not in order:
                    for src in src_of_layer[lyr.name]:
                        if src not in order:
                            break
                    order.append(lyr)
        return order

    def forward(self, flag, x, output=[], freeze=None):
        '''Forward the input(s) through every layer.

        Args:
            flag: True for training; False for evaluation; could also be
                model_pb2.kTrain or model_pb2.kEval, or other values for future
                use.
            x: a single SINGA tensor if there is a single input; otherwise, a
                dictionary: layer name-> singa tensor, for each layer accepting
                input data. Do not associate a layer with input tensor if it is
                connected from another layer. For such case, use a Dummy() layer
                to accept the input data and connect the dummy layer to this
                layer.
            output(list): a list of layer names whose output would be returned
                in addition to the default output.
            freeze(str): layer name, freeze all layers before this layer; flag
                is set to false for these layers.

        Returns:
            if there is only one output layer and output arg is empty, return
                the result from the single output layer; otherwise, return a
                dictionary: layer name -> output tensor(s)
        '''
        if self.ordered_layers is None:
            self.ordered_layers = self.topo_sort(
                self.layers, self.src_of_layer)
        if type(x) is dict:
            input_of_layer = x
        else:
            assert isinstance(x, tensor.Tensor), \
                'The inputs of a net should be dict or a single tensor'
            input_of_layer = {self.ordered_layers[0].name: x}
        output_of_layer = {}  # outputs generated by each layer
        ret = {}  # outputs to return
        if freeze is not None:
            is_valid = False
            for lyr in self.ordered_layers:
                is_valid |= lyr.name == freeze
            assert is_valid, 'Invalid freeze layer name =%s' % freeze
            old_flag = flag
            flag = False
        for cur in self.ordered_layers:
            if cur.name == freeze:
                flag = old_flag
            inputs = []
            if cur.name in input_of_layer:
                if type(input_of_layer[cur.name]) is list:
                    inputs.extend(input_of_layer[cur.name])
                else:
                    inputs.append(input_of_layer[cur.name])
            srcs = self.src_of_layer[cur.name]
            disp_src = ''
            for src in srcs:
                outs = output_of_layer[src.name]
                if type(outs) == list:
                    assert len(outs) > 0, \
                        'the output from layer %s is empty' % src.name
                    inputs.append(outs[0])
                    outs.pop(0)
                    if len(outs) == 0:
                        output_of_layer.pop(src.name)
                else:
                    inputs.append(outs)
                    output_of_layer[cur.name] = []
                    output_of_layer.pop(src.name)
            if len(inputs) == 1:
                inputs = inputs[0]
            out = cur.forward(flag, inputs)
            if verbose:
                disp_src = '+'.join([src.name for src in srcs])
                disp_src += '-->' + cur.name
                if type(out) is list:
                    print('%s: %s' % (disp_src,
                                      ' '.join([str(o.l1()) for o in out])))
                else:
                    print('%s: %f' % (disp_src, out.l1()))
            output_of_layer[cur.name] = out
            if cur.name in output:
                ret[cur.name] = out
            # print lyr.name, x.l1()
        # print output_of_layer
        ret.update(output_of_layer)
        if len(ret) == 1:
            return list(ret.values())[0]
        else:
            return ret

    def backward(self, dy, output=[], freeze=None):
        '''Run back-propagation after forward-propagation.

        Args:
            dy: a single tensor if there is a single loss function; otherwise,
                a dictionary maps the name of the layer connecting to the loss
                function -> gradient from the loss function. Do not associate a
                layer with gradient tensor if it is connecting to another layer.
                For such case, connect this layer to a Dummy() layer and use the
                dummy layer to accept the gradient.
            output(list): a list of layer names whose output gradient would be
                returned in addition to the param gradient
            freeze(str): layer name, stop backward after this layer.

        Returns:
                a geneartor iterator that generates
                (param_names, param_values, param_grads, layer_grads) after
                processing each layer h, where the first three lists are for h
                and the last item is a dictionary which maps
                layer name -> its output gradient tensor(s). At the end of this
                function, the key set includes all layers in the output arg.
        '''
        if self.dst_of_layer is None:
            self.dst_of_layer = {}
            for cur in self.layers:
                self.dst_of_layer[cur.name] = []
            for cur in self.ordered_layers[1:]:
                srcs = self.src_of_layer[cur.name]
                for src in srcs:
                    self.dst_of_layer[src.name].append(cur)
        output_of_layer = {}  # outputs generated by each layer
        ret = {}  # outputs to return
        if type(dy) is dict:
            input_of_layer = dy
        else:
            assert isinstance(dy, tensor.Tensor), \
                'The inputs of a net should be dict or a single tensor'
            input_of_layer = {self.ordered_layers[-1].name: dy}
        for cur in reversed(self.ordered_layers):
            inputs = []
            if cur.name in input_of_layer:
                if type(input_of_layer[cur.name]) is list:
                    inputs.extend(input_of_layer[cur.name])
                else:
                    inputs.append(input_of_layer[cur.name])
            for dst in self.dst_of_layer[cur.name]:
                outputs = output_of_layer[dst.name]
                if type(outputs) == list:
                    assert len(outputs) > 0, \
                        'the gradient from layer %s is empty' % dst.name
                    inputs.append(outputs[0])
                    outputs.pop(0)
                else:
                    inputs.append(outputs)
                    output_of_layer[dst.name] = []
                # del output_of_layer[dst.name]
            if len(inputs) == 1:
                inputs = inputs[0]
            outs, pgrads = cur.backward(kTrain, inputs)
            if verbose:
                disp_src = '+'.join(
                    [dst.name for dst in self.dst_of_layer[cur.name]])
                disp_src += '-->' + cur.name
                if type(outs) is list:
                    print('%s: %s' % (disp_src,
                                      ' '.join([str(o.l1()) for o in outs])))
                else:
                    print('%s: %f' % (disp_src, outs.l1()))
            if type(outs) is list:
                output_of_layer[cur.name] = outs[::-1]
            else:
                output_of_layer[cur.name] = outs
            if cur.name in output:
                ret[cur.name] = outs
            # ret.update(output_of_layer)
            yield (cur.param_names(), cur.param_values(), pgrads, ret)
            if cur.name == freeze:
                break

    def save(self, f, buffer_size=10, use_pickle=False):
        '''Save model parameters using io/snapshot.

        Args:
            f: file name
            buffer_size: size (MB) of the IO, default setting is 10MB; Please
                make sure it is larger than any single parameter object.
            use_pickle(Boolean): if true, it would use pickle for dumping;
                otherwise, it would use protobuf for serialization, which uses
                less space.
        '''
        if use_pickle:
            params = {}
            # since SINGA>=1.1.1  (1101)
            params['SINGA_VERSION'] = __version__
            for (name, val) in zip(self.param_names(), self.param_values()):
                val.to_host()
                params[name] = tensor.to_numpy(val)
            if not f.endswith('.pickle'):
                f = f + '.pickle'
            with open(f, 'wb') as fd:
                pickle.dump(params, fd)
        else:
            if f.endswith('.bin'):
                f = f[0:-4]
            sp = snapshot.Snapshot(f, True, buffer_size)
            v = tensor.from_numpy(np.array([__version__]))
            sp.write('SINGA_VERSION', v)
            for (name, val) in zip(self.param_names(), self.param_values()):
                val.to_host()
                sp.write(name, val)

    def load(self, f, buffer_size=10, use_pickle=False):
        '''Load model parameters using io/snapshot.

        Please refer to the argument description in save().
        '''
        version = 0

        def get_name(name):
            if version < 1101:
                idx = name.rfind('/')
                assert idx > 0, '/ must be in the parameter name'
                name = name[:idx] + '_' + name[idx + 1:]
            return name

        if use_pickle:
            print('NOTE: If your model was saved using Snapshot, '
                  'then set use_pickle=False for loading it')
            if not os.path.exists(f):
                # guess the correct path
                if f.endswith('.pickle'):
                    f = f[0:-7]
                else:
                    f = f + '.pickle'
            assert os.path.exists(f), 'file not exists %s w/o .pickle' % f
            with open(f, 'rb') as fd:
                params = pickle.load(fd, encoding='iso-8859-1')
        else:
            print('NOTE: If your model was saved using pickle, '
                  'then set use_pickle=True for loading it')
            if f.endswith('.bin'):
                f = f[0:-4]
            sp = snapshot.Snapshot(f, False, buffer_size)
            params = sp.read()

        if 'SINGA_VERSION' in params:
            version = params['SINGA_VERSION']
            if isinstance(version, tensor.Tensor):
                version = tensor.to_numpy(version)[0]
        else:
            version = 1100
        for name, val in zip(self.param_names(), self.param_values()):
            name = get_name(name)
            if name not in params:
                print('Param: %s missing in the checkpoint file' % name)
                continue
            try:
                if isinstance(params[name], tensor.Tensor):
                    val.copy_data(params[name])
                else:
                    val.copy_from_numpy(params[name])
            except AssertionError as err:
                print('Error from copying values for param: %s' % name)
                print(('shape of param vs checkpoint',
                       val.shape, params[name].shape))
                raise err
