""" Code for the MAML algorithm and network definitions. """
from __future__ import print_function
import numpy as np
import sys
import pdb
import tensorflow as tf
import time
from tensorflow.python import debug as tfdbg
try:
    import special_grads
except KeyError as e:
    print('WARN: Cannot define MaxPoolGrad, likely already defined for this version of tensorflow: %s' % e,
          file=sys.stderr)

from tensorflow.python.platform import flags
from utils import mse, xent, conv_block, normalize

FLAGS = flags.FLAGS

class MAML:
    def __init__(self, dim_input=1, dim_output=1, test_num_updates=5):
        """ must call construct_model() after initializing MAML! """
        self.dim_input = dim_input
        self.dim_output = dim_output
        self.update_lr = FLAGS.update_lr
        self.meta_lr = tf.placeholder_with_default(FLAGS.meta_lr, ())
        self.classification = False
        self.test_num_updates = test_num_updates
        self.lambda_r = 1.0
        if FLAGS.datasource == 'sinusoid':
            self.dim_hidden = [40, 40]
            self.loss_func = mse
            self.forward = self.forward_fc
            self.construct_weights = self.construct_fc_weights
        elif FLAGS.datasource == 'omniglot' or FLAGS.datasource == 'miniimagenet':
            self.loss_func = xent
            self.classification = True
            if FLAGS.conv:
                self.dim_hidden = FLAGS.num_filters
                self.forward = self.forward_conv
                self.construct_weights = self.construct_conv_weights
            else:
                self.dim_hidden = [256, 128, 64, 64]
                self.forward=self.forward_fc
                self.construct_weights = self.construct_fc_weights
            if FLAGS.datasource == 'miniimagenet':
                self.channels = 3
            else:
                self.channels = 1
            self.img_size = int(np.sqrt(self.dim_input/self.channels))
        else:
            raise ValueError('Unrecognized data source.')


    def construct_model(self, input_tensors=None, prefix='metatrain_'):
        # a: training data for inner gradient, b: test data for meta gradient
        if input_tensors is None:
            self.inputa = tf.placeholder(tf.float32)
            self.inputb = tf.placeholder(tf.float32)
            self.labela = tf.placeholder(tf.float32)
            self.labelb = tf.placeholder(tf.float32)
        else:
            self.inputa = input_tensors['inputa']
            self.inputb = input_tensors['inputb']
            self.labela = input_tensors['labela']
            self.labelb = input_tensors['labelb']
        with tf.variable_scope('model', reuse=None) as training_scope:
            if 'weights' in dir(self):
                training_scope.reuse_variables()
                weights = self.weights
            else:
                # Define the weights
                self.weights = weights = self.construct_weights()

            # outputbs[i] and lossesb[i] is the output and loss after i+1 gradient updates
            lossesa, outputas, lossesb, outputbs = [], [], [], []
            accuraciesa, accuraciesb = [], []
            num_updates = max(self.test_num_updates, FLAGS.num_updates)
            outputbs = [[]]*num_updates
            lossesb = [[]]*num_updates
            accuraciesb = [[]]*num_updates
            loss_reduce_inner_loops = []

            def task_metalearn(inp, reuse=True):
                """ Perform gradient descent for one task in the meta-batch. """
                inputa_all, inputb, labela_all, labelb = inp  # changed the name of input tensors
                
                # ================================================================
                # if FLAGS.train == True:
                if 'train' in prefix:
                    # add further spilt in inputa ==================
                    # batch_num_a = tf.shape(inputa_all)[1] / num_updates  # slice along axis 0, as map_fn has already handled the 0 axis
                    batch_num_a = num_updates
                    print(inputa_all, 'The inputa shape') # (784,)
                    inputa = tf.split(inputa_all, batch_num_a, 0)
                    labela = tf.split(labela_all, batch_num_a, 0)
                    print('Train model data set')
                else:
                    assert FLAGS.update_batch_size == 1, 'data cannot be tiled properly'
                    inputa = [inputa_all for i in range(num_updates)]
                    labela = [labela_all for i in range(num_updates)]
                    print('shape of each batch in inputa', inputa[0])
                    print('Val model data set')
                    # assert tf.shape(inputa[0])[0] == FLAGS.num_classes, 'tile inputa error'


                task_outputbs, task_lossesb = [], []
                # reptile_grads = []   # =============================
                if self.classification:
                    task_accuraciesb = []
                loss_reduce_inner_loops = []
                task_outputa = self.forward(inputa[0], weights, reuse=reuse)  # only reuse on the first iter
                task_lossa = self.loss_func(task_outputa, labela[0])
                grads = tf.gradients(task_lossa, list(weights.values()))
                if FLAGS.stop_grad:
                    grads = [tf.stop_gradient(grad) for grad in grads]

                gradients = dict(zip(weights.keys(), grads))
                fast_weights = dict(zip(weights.keys(), [weights[key] - self.update_lr*gradients[key] for key in weights.keys()]))
                output = self.forward(inputb, fast_weights, reuse=True)
                task_outputbs.append(output)
                task_lossesb.append(self.loss_func(output, labelb))
                
                for j in range(num_updates - 1):
                    loss = self.loss_func(self.forward(inputa[j + 1], fast_weights,
                                                       reuse=True), labela[j + 1])
                    loss_reduce_inner_loops.append(tf.reduce_mean(task_lossa - loss))
                    grads = tf.gradients(loss, list(fast_weights.values()))
                    if FLAGS.stop_grad:
                        grads = [tf.stop_gradient(grad) for grad in grads]
                    gradients = dict(zip(fast_weights.keys(), grads))
                    fast_weights = dict(zip(fast_weights.keys(), [fast_weights[key] - self.update_lr*gradients[key] for key in fast_weights.keys()]))
                    output = self.forward(inputb, fast_weights, reuse=True)
                    task_outputbs.append(output)
                    task_lossesb.append(self.loss_func(output, labelb))
                # print('Compare fast_weights and weights:==============')
                # print('fast_weights keys:', fast_weights.keys())
                # print('weights keys:', weights.keys())
                # print('===============================================')
                reptile_grad_val_task = [(1. / FLAGS.update_lr) * (fast_weights[key] - weights[key]) for
                                         key in weights.keys()] # =================
                # print('Check the variables:', reptile_grad_val_task)
                task_output = [reptile_grad_val_task, loss_reduce_inner_loops, task_outputa, task_outputbs, task_lossa, task_lossesb]  # task_lossa gives the loss before any updating

                if self.classification:
                    task_accuracya = tf.contrib.metrics.accuracy(tf.argmax(tf.nn.softmax(task_outputa), 1), tf.argmax(labela, 1))
                    for j in range(num_updates):
                        task_accuraciesb.append(tf.contrib.metrics.accuracy(tf.argmax(tf.nn.softmax(task_outputbs[j]), 1), tf.argmax(labelb, 1)))
                    task_output.extend([task_accuracya, task_accuraciesb])
                return tuple(task_output)

            if FLAGS.norm is not 'None':
                # to initialize the batch norm vars, might want to combine this, and not run idx 0 twice.
                unused = task_metalearn((self.inputa[0], self.inputb[0], self.labela[0], self.labelb[0]), False)

            self.num_weights = len(self.weights.keys())  # 14 trainable variables but 10 weights when --conv=True
            # print('The number of weights', self.num_weights)
            out_dtype = [[tf.float32]*self.num_weights,[tf.float32]*(num_updates-1), tf.float32, [tf.float32]*num_updates, tf.float32, [tf.float32]*num_updates]  # 47 when num_update = 10
            if self.classification:
                out_dtype.extend([tf.float32, [tf.float32]*num_updates])
            out_dtype = tuple(out_dtype)
            # ==============================================
            # structure of dtype:([]*10, tf.float32, []*10, tf.float32, []*10, tf.float32, []*10, tf.float32)
            # structure of ouput:(should be the same, revisit the construction )
            result = tf.map_fn(task_metalearn, elems=(self.inputa, self.inputb, self.labela, self.labelb), dtype=out_dtype, parallel_iterations=FLAGS.meta_batch_size)
            # each output of tf.map_fn is made to a tensor, not list
            if self.classification:
                reptile_grad_values, loss_reduce_inner_loops_values, outputas, outputbs, lossesa, lossesb, accuraciesa, accuraciesb = result
            else:
                reptile_grad_values, loss_reduce_inner_loops_values, outputas, outputbs, lossesa, lossesb  = result
            reptile_grad_values = [tf.reduce_mean(reptile_grad_value, axis=0) for reptile_grad_value in list(reptile_grad_values)] # ==============
            reptile_grads = dict(zip(self.weights.keys(), reptile_grad_values))
        ## Performance & Optimization
        if 'train' in prefix:
            self.total_loss1 = total_loss1 = tf.reduce_sum(lossesa) / tf.to_float(FLAGS.meta_batch_size)
            self.total_losses2 = total_losses2 = [tf.reduce_sum(lossesb[j]) / tf.to_float(FLAGS.meta_batch_size) for j in range(num_updates)]
            # after the map_fn
            self.outputas, self.outputbs = outputas, outputbs
            if self.classification:
                self.total_accuracy1 = total_accuracy1 = tf.reduce_sum(accuraciesa) / tf.to_float(FLAGS.meta_batch_size)
                self.total_accuracies2 = total_accuracies2 = [tf.reduce_sum(accuraciesb[j]) / tf.to_float(FLAGS.meta_batch_size) for j in range(num_updates)]
            # self.pretrain_op = tf.train.AdamOptimizeri(self.meta_lr).minimize(total_loss1)
            self.loss_reduced_inner_loops = [tf.reduce_mean(loss_reduced_value) for loss_reduced_value in loss_reduce_inner_loops_values]
            if FLAGS.metatrain_iterations > 0:
                # optimizer = tf.train.AdamOptimizer(self.meta_lr)
                optimizer = tf.train.GradientDescentOptimizer(self.meta_lr)
                self.gvs = gvs = optimizer.compute_gradients(self.total_losses2[FLAGS.num_updates-1])  # list of (gradient, variable pairs)
                # if FLAGS.datasource == 'miniimagenet':
                # gvs = [(tf.clip_by_value(grad, -10, 10), var) for grad, var in gvs] # original codes
                # ===========================================
                self.maml_grad_mag = [tf.nn.l2_loss(grad) for (grad, var) in gvs if str(var.name)[6:-2] in reptile_grads.keys()]
                self.reptile_grad_mag = [tf.nn.l2_loss(reptile_grads[var]) for var in reptile_grads.keys()]
                print('Name of reptile_grads:', reptile_grads.keys())
                print('Name of gvs_variables:', [str(var.name)[6:-2] for grad, var in gvs])
                # gvs = [(tf.clip_by_value(grad + self.lambda_r * reptile_grads[str(var.name)[6:-2]], -10, 10), var) for grad, var in gvs if \
                #        str(var.name)[6:-2] in reptile_grads.keys()] # Add reptile step.
                # gvs = [(tf.clip_by_value(grad + self.lambda_r *
                #                          reptile_grads[str(var.name)[6:-2]],
                #                          -10, 10), var) if str(var.name)[6:-2]
                #         in reptile_grads.keys() else (tf.clip_by_value(grad, -10, 10), var) for grad, var in gvs]  # Add reptile step
                gvs = [(-1. * reptile_grads[str(var.name)[6:-2]], var) \
                    if str(var.name)[6:-2] in reptile_grads.keys() else (tf.clip_by_value(grad, -10, 10), var) \
                    for grad, var in gvs]
                # gvs = tf.Print(gvs, [maml_grad_mag, reptile_grad_mag], message='magnitudes of maml and reptile grads')
                # pp = [(tf.clip_by_value(reptile_grads[str(var.name)[6:-2]],
                # -10, 10), var) for grad, var in gvs if str(var.name)[6:-2] in
                # reptile_grads.keys()]  # 10
                # print('Length of set variables:', len(pp))
                self.metatrain_op = optimizer.apply_gradients(gvs)
        else:
            self.metaval_total_loss1 = total_loss1 = tf.reduce_sum(lossesa) / tf.to_float(FLAGS.meta_batch_size)
            self.metaval_total_losses2 = total_losses2 = [tf.reduce_sum(lossesb[j]) / tf.to_float(FLAGS.meta_batch_size) for j in range(num_updates)]
            if self.classification:
                self.metaval_total_accuracy1 = total_accuracy1 = tf.reduce_sum(accuraciesa) / tf.to_float(FLAGS.meta_batch_size)
                self.metaval_total_accuracies2 = total_accuracies2 =[tf.reduce_sum(accuraciesb[j]) / tf.to_float(FLAGS.meta_batch_size) for j in range(num_updates)]

        ## Summaries
        tf.summary.scalar(prefix+'Pre-update loss', total_loss1)
        if self.classification:
            tf.summary.scalar(prefix+'Pre-update accuracy', total_accuracy1)

        for j in range(num_updates):
            tf.summary.scalar(prefix+'Post-update loss, step ' + str(j+1), total_losses2[j])
            if self.classification:
                tf.summary.scalar(prefix+'Post-update accuracy, step ' + str(j+1), total_accuracies2[j])
        for j in range(num_updates - 1):
            tf.summary.scalar(prefix + 'loss-reduced-inner-step' + str(j + 1), self.loss_reduced_inner_loops[j])
        for var in weights.keys():
            tf.summary.histogram(str(var), weights[var])
    
    ### Network construction functions (fc networks and conv networks)
    def construct_fc_weights(self):
        weights = {}
        weights['w1'] = tf.Variable(tf.truncated_normal([self.dim_input, self.dim_hidden[0]], stddev=0.01))
        weights['b1'] = tf.Variable(tf.zeros([self.dim_hidden[0]]))
        for i in range(1,len(self.dim_hidden)):
            weights['w'+str(i+1)] = tf.Variable(tf.truncated_normal([self.dim_hidden[i-1], self.dim_hidden[i]], stddev=0.01))
            weights['b'+str(i+1)] = tf.Variable(tf.zeros([self.dim_hidden[i]]))
        weights['w'+str(len(self.dim_hidden)+1)] = tf.Variable(tf.truncated_normal([self.dim_hidden[-1], self.dim_output], stddev=0.01))
        weights['b'+str(len(self.dim_hidden)+1)] = tf.Variable(tf.zeros([self.dim_output]))
        return weights

    def forward_fc(self, inp, weights, reuse=False):
        hidden = normalize(tf.matmul(inp, weights['w1']) + weights['b1'], activation=tf.nn.relu, reuse=reuse, scope='0')
        for i in range(1,len(self.dim_hidden)):
            hidden = normalize(tf.matmul(hidden, weights['w'+str(i+1)]) + weights['b'+str(i+1)], activation=tf.nn.relu, reuse=reuse, scope=str(i+1))
        return tf.matmul(hidden, weights['w'+str(len(self.dim_hidden)+1)]) + weights['b'+str(len(self.dim_hidden)+1)]

    def construct_conv_weights(self):
        weights = {}

        dtype = tf.float32
        conv_initializer =  tf.contrib.layers.xavier_initializer_conv2d(dtype=dtype)
        fc_initializer =  tf.contrib.layers.xavier_initializer(dtype=dtype)
        # conv_initializer = tf.glorot_uniform_initializer(dtype=dtype)
        # fc_initializer = tf.glorot_uniform_initializer(dtype=dtype)
        k = 3

        weights['conv1'] = tf.get_variable('conv1', [k, k, self.channels,
                                                     self.dim_hidden], initializer=conv_initializer, dtype=dtype)
        weights['b1'] = tf.Variable(tf.zeros([self.dim_hidden]), name='b1')
        weights['conv2'] = tf.get_variable('conv2', [k, k, self.dim_hidden,
                                                     self.dim_hidden], initializer=conv_initializer, dtype=dtype)
        weights['b2'] = tf.Variable(tf.zeros([self.dim_hidden]), name='b2')
        weights['conv3'] = tf.get_variable('conv3', [k, k, self.dim_hidden,
                                                     self.dim_hidden], initializer=conv_initializer, dtype=dtype)
        weights['b3'] = tf.Variable(tf.zeros([self.dim_hidden]), name='b3')
        weights['conv4'] = tf.get_variable('conv4', [k, k, self.dim_hidden,
                                                     self.dim_hidden], initializer=conv_initializer, dtype=dtype)
        weights['b4'] = tf.Variable(tf.zeros([self.dim_hidden]), name='b4')
        if FLAGS.datasource == 'miniimagenet':
            # assumes max pooling
            weights['w5'] = tf.get_variable('w5', [(self.dim_hidden)*6*6, self.dim_output], initializer=fc_initializer)
            weights['b5'] = tf.Variable(tf.zeros([self.dim_output]), name='b5')
        else:
            weights['w5'] = tf.Variable(tf.random_normal([self.dim_hidden, self.dim_output]), name='w5')
            weights['b5'] = tf.Variable(tf.zeros([self.dim_output]), name='b5')
        return weights

    def forward_conv(self, inp, weights, reuse=False, scope=''):
        # reuse is for the normalization parameters.
        channels = self.channels
        inp = tf.reshape(inp, [-1, self.img_size, self.img_size, channels])

        hidden1 = conv_block(inp, weights['conv1'], weights['b1'], reuse, scope+'0')
        hidden2 = conv_block(hidden1, weights['conv2'], weights['b2'], reuse, scope+'1')
        hidden3 = conv_block(hidden2, weights['conv3'], weights['b3'], reuse, scope+'2')
        hidden4 = conv_block(hidden3, weights['conv4'], weights['b4'], reuse, scope+'3')
        if FLAGS.datasource == 'miniimagenet':
            # last hidden layer is 6x6x64-ish, reshape to a vector
            hidden4 = tf.reshape(hidden4, [-1, np.prod([int(dim) for dim in hidden4.get_shape()[1:]])])
        else:
            hidden4 = tf.reduce_mean(hidden4, [1, 2])

        return tf.matmul(hidden4, weights['w5']) + weights['b5']


