import tensorflow as tf

custom_shuffle_module = tf.load_op_library('src/shuffle_op.so')
shuffle = custom_shuffle_module.shuffle


# ###################
# TENSORBOARD HELPERS
# ###################

def comprehensive_variable_summaries(var):
    """
    Attach a lot of summaries to a Tensor (for TensorBoard visualization).
    """
    with tf.name_scope('summaries'):
        mean = tf.reduce_mean(var)
        tf.summary.scalar('mean', mean)
        with tf.name_scope('stddev'):
            stddev = tf.sqrt(tf.reduce_mean(tf.square(var - mean)))
        tf.summary.scalar('stddev', stddev)
        tf.summary.scalar('max', tf.reduce_max(var))
        tf.summary.scalar('min', tf.reduce_min(var))
        tf.summary.histogram('histogram', var)


def histogram_variable_summaries(var):
    """
    Attach a histogram summary to a Tensor (for TensorBoard visualization).
    """
    with tf.name_scope('summaries'):
        tf.summary.histogram('histogram', var)

# ###################
# ###################


# ######################
# LAYER HELPER FUNCTIONS
# ######################

def subpixel_reshuffle_1D_impl(X, m):
    """
    performs a 1-D subpixel reshuffle of the input 2-D tensor
    assumes the last dimension of X is the filter dimension
    ref: https://github.com/Tetrachrome/subpixel
    """
    return tf.transpose(tf.stack([tf.reshape(x, (-1,)) for x
                                  in tf.split(X, m, axis=1)]))


def subpixel_reshuffle_1D(X, m, name=None):
    """
    maps over the batch dimension
    """
    return tf.map_fn(lambda x: subpixel_reshuffle_1D_impl(x, m), X, name=name)


def subpixel_restack_impl(X, n_prime, m_prime, name=None):
    """
    performs a subpixel restacking such that it restacks columns of a 2-D
    tensor onto the rows
    """
    bsize = tf.shape(X)[0]
    r_n = n_prime - X.get_shape().as_list()[1]
    total_new_space = r_n*m_prime
    to_stack = tf.slice(X, [0, 0, m_prime], [-1, -1, -1])
    to_stack = tf.slice(tf.reshape(to_stack, (bsize, -1)),
                        [0, 0], [-1, total_new_space])
    to_stack = tf.reshape(to_stack, (bsize, -1, m_prime))
    to_stack = tf.slice(to_stack, [0, 0, 0], [-1, r_n, -1])
    return tf.concat((tf.slice(X, [0, 0, 0], [-1, -1, m_prime]), to_stack),
                     axis=1, name=name)


def subpixel_restack(X, n_prime, m_prime=None, name=None):
    n = X.get_shape().as_list()[1]
    m = X.get_shape().as_list()[2]
    r_n = n_prime - n
    if m_prime is None:
        for i in range(1, m):
            r_m = i
            m_prime = m - r_m
            if r_m*n >= m_prime*r_n:
                break
    return subpixel_restack_impl(X, n_prime, m_prime, name=name)


def batch_norm(T, is_training, scope):
    # tf.cond takes nullary functions as its first and second arguments
    return tf.cond(is_training,
                   lambda: tf.contrib.layers.batch_norm(T,
                                                decay=0.99,
                                                # zero_debias_moving_mean=True,
                                                is_training=is_training,
                                                center=True, scale=True,
                                                updates_collections=None,
                                                scope=scope,
                                                reuse=False),
                   lambda: tf.contrib.layers.batch_norm(T,
                                                decay=0.99,
                                                is_training=is_training,
                                                center=True, scale=True,
                                                updates_collections=None,
                                                scope=scope,
                                                reuse=True))


def weight_variable(shape, name=None):
    initial = tf.truncated_normal(shape, mean=0.0, stddev=0.1)
    return tf.Variable(initial, name=name)


def bias_variable(shape, name=None):
    initial = tf.constant(0.1, shape=shape)
    return tf.Variable(initial, name=name)


def conv1d(x, W, stride=1, padding='SAME', name=None):
    return tf.nn.conv1d(x, W, stride=stride, padding=padding, name=name)


def build_1d_conv_layer(prev_tensor, prev_conv_depth,
                        conv_window, conv_depth,
                        act, layer_number,
                        stride=1,
                        padding='SAME',
                        tensorboard_output=False,
                        name=None):
    with tf.name_scope('{}_layer_weights'.format(layer_number)):
        W = weight_variable([conv_window,
                             prev_conv_depth,
                             conv_depth])
        if tensorboard_output:
            histogram_variable_summaries(W)
    with tf.name_scope('{}_layer_biases'.format(layer_number)):
        b = bias_variable([conv_depth])
        if tensorboard_output:
            histogram_variable_summaries(b)
    with tf.name_scope('{}_layer_conv_preactivation'.format(layer_number)):
        conv = conv1d(prev_tensor, W, stride=stride, padding=padding) + b
        if tensorboard_output:
            histogram_variable_summaries(conv)
    with tf.name_scope('{}_layer_conv_activation'.format(layer_number)):
        h = act(conv, name=name)
        if tensorboard_output:
            histogram_variable_summaries(h)
    return h


def build_1d_conv_layer_with_res(prev_tensor, prev_conv_depth,
                                 conv_window, conv_depth,
                                 res, act, layer_number,
                                 tensorboard_output=False,
                                 name=None):
    with tf.name_scope('{}_layer_weights'.format(layer_number)):
        W = weight_variable([conv_window,
                             prev_conv_depth,
                             conv_depth])
        if tensorboard_output:
            histogram_variable_summaries(W)
    with tf.name_scope('{}_layer_biases'.format(layer_number)):
        b = bias_variable([conv_depth])
        if tensorboard_output:
            histogram_variable_summaries(b)
    with tf.name_scope('{}_layer_conv_preactivation'.format(layer_number)):
        conv = conv1d(prev_tensor, W) + b
        if tensorboard_output:
            histogram_variable_summaries(conv)
    with tf.name_scope('{}_layer_conv_activation'.format(layer_number)):
        h = act(tf.add(conv, res), name=name)
        if tensorboard_output:
            histogram_variable_summaries(h)
    return h


def build_downsampling_block(input_tensor,
                             filter_size, stride,
                             layer_number,
                             act=tf.nn.relu,
                             is_training=True,
                             depth=None,
                             padding='VALID',
                             tensorboard_output=False,
                             name=None):

    # assume this layer is twice the depth of the previous layer if no depth
    # information is given
    if depth is None:
        depth = 2*input_tensor.get_shape().as_list()[-1]

    with tf.name_scope('{}_layer_weights'.format(layer_number)):
        W = weight_variable([filter_size,
                             input_tensor.get_shape().as_list()[-1],
                             depth])
        if tensorboard_output:
            histogram_variable_summaries(W)
    with tf.name_scope('{}_layer_biases'.format(layer_number)):
        b = bias_variable([depth])
        if tensorboard_output:
            histogram_variable_summaries(b)
    with tf.name_scope('{}_layer_conv_preactivation'.format(layer_number)):
        l = tf.nn.conv1d(input_tensor, W, stride=stride,
                         padding=padding, name=name) + b
        if tensorboard_output:
            histogram_variable_summaries(l)
    with tf.name_scope('{}_layer_batch_norm'.format(layer_number)) as scope:
        # l = tf.nn.dropout(l, keep_prob=0.25)
        l = batch_norm(l, is_training, scope)
    with tf.name_scope('{}_layer_conv_activation'.format(layer_number)):
        l = act(l, name=name)
        if tensorboard_output:
            histogram_variable_summaries(l)

    return l


def build_upsampling_block(input_tensor, residual_tensor,
                           filter_size,
                           layer_number,
                           act=tf.nn.relu,
                           is_training=True,
                           depth=None,
                           padding='VALID',
                           tensorboard_output=False,
                           name=None):

    # assume this layer is half the depth of the previous layer if no depth
    # information is given
    if depth is None:
        depth = int(input_tensor.get_shape().as_list()[-1]/2)

    with tf.name_scope('{}_layer_weights'.format(layer_number)):
        W = weight_variable([filter_size,
                             input_tensor.get_shape().as_list()[-1],
                             depth])
        if tensorboard_output:
            histogram_variable_summaries(W)
    with tf.name_scope('{}_layer_biases'.format(layer_number)):
        b = bias_variable([depth])
        if tensorboard_output:
            histogram_variable_summaries(b)
    with tf.name_scope('{}_layer_conv_preactivation'.format(layer_number)):
        l = tf.nn.conv1d(input_tensor, W, stride=1,
                         padding=padding, name=name) + b
        if tensorboard_output:
            histogram_variable_summaries(l)
    with tf.name_scope('{}_layer_batch_norm'.format(layer_number)) as scope:
        # l = tf.nn.dropout(l, keep_prob=0.25)
        l = batch_norm(l, is_training, scope)
        # l = tf.nn.l2_normalize(l, dim=2)
    with tf.name_scope('{}_layer_conv_activation'.format(layer_number)):
        l = act(l, name=name)
        if tensorboard_output:
            histogram_variable_summaries(l)
    with tf.name_scope('{}_layer_subpixel_reshuffle'.format(layer_number)):
        l = subpixel_reshuffle_1D(l,
                                  residual_tensor.get_shape().as_list()[-1],
                                  name=name)
        if tensorboard_output:
            histogram_variable_summaries(l)
    with tf.name_scope('{}_layer_stacking'.format(layer_number)):
        sliced = tf.slice(residual_tensor,
                          begin=[0, 0, 0],
                          size=[-1, l.get_shape().as_list()[1], -1])
        l = tf.concat((l, sliced), axis=2, name=name)
        if tensorboard_output:
            histogram_variable_summaries(l)

    return l

# ######################
# ######################


# #################
# MODEL DEFINITIONS
# #################

def single_fully_connected_model(input_type, input_shape,
                                 n_inputs, n_weights,
                                 tensorboard_output=True,
                                 scope_name='single_fully_connected_layer'):

    with tf.name_scope(scope_name):
        # input of the model (examples)
        s = [None]
        shape_prod = 1
        for i in input_shape:
            s.append(i)
            shape_prod *= i
        x = tf.placeholder(input_type, shape=s)
        x_ = tf.reshape(x, [-1, shape_prod])

        # first conv layer
        with tf.name_scope('first_layer_weights'):
            s = []
            s.append(shape_prod)
            s.append(n_weights)
            W = weight_variable(s)
            if tensorboard_output:
                histogram_variable_summaries(W)
        with tf.name_scope('first_layer_biases'):
            b = bias_variable([n_weights])
            if tensorboard_output:
                histogram_variable_summaries(b)
        with tf.name_scope('first_layer_preactivation'):
            preact = tf.matmul(x_, W) + b
            if tensorboard_output:
                histogram_variable_summaries(preact)
        with tf.name_scope('first_layer_activation'):
            y = tf.identity(preact, name=scope_name)
            if tensorboard_output:
                histogram_variable_summaries(y)

        return x, y


def three_layer_conv_model(input_type, input_shape,
                           first_conv_window=30, first_conv_depth=128,
                           second_conv_window=10, second_conv_depth=64,
                           third_conv_window=15,
                           tensorboard_output=False,
                           scope_name='3-layer_conv'):

    with tf.name_scope(scope_name):
        # input of the model (examples)
        s = [None]
        for i in input_shape:
            s.append(i)
        x = tf.placeholder(input_type, shape=s)

        # first conv layer
        h1 = build_1d_conv_layer(x, 1,
                                 first_conv_window, first_conv_depth,
                                 tf.nn.elu, 1,
                                 tensorboard_output)

        # second conv layer
        h2 = build_1d_conv_layer(h1, first_conv_depth,
                                 second_conv_window, second_conv_depth,
                                 tf.nn.elu, 2,
                                 tensorboard_output)

        # third (last) conv layer
        y = build_1d_conv_layer(h2, second_conv_depth,
                                third_conv_window, 1,
                                tf.identity, 3,
                                tensorboard_output,
                                scope_name)

        return x, y


def five_layer_conv_model(input_type, input_shape,
                          first_conv_window=30, first_conv_depth=256,
                          second_conv_window=20, second_conv_depth=128,
                          third_conv_window=10, third_conv_depth=64,
                          fourth_conv_window=5, fourth_conv_depth=32,
                          fifth_conv_window=5,
                          tensorboard_output=False,
                          scope_name='5-layer_conv'):

    with tf.name_scope(scope_name):
        # input of the model (examples)
        s = [None]
        for i in input_shape:
            s.append(i)
        x = tf.placeholder(input_type, shape=s)

        # first conv layer
        h1 = build_1d_conv_layer(x, 1,
                                 first_conv_window, first_conv_depth,
                                 tf.nn.elu, 1,
                                 tensorboard_output)

        # second conv layer
        h2 = build_1d_conv_layer(h1, first_conv_depth,
                                 second_conv_window, second_conv_depth,
                                 tf.nn.elu, 2,
                                 tensorboard_output)

        # third conv layer
        h3 = build_1d_conv_layer(h2, second_conv_depth,
                                 third_conv_window, third_conv_depth,
                                 tf.nn.elu, 3,
                                 tensorboard_output)

        # fourth conv layer
        h4 = build_1d_conv_layer(h3, third_conv_depth,
                                 fourth_conv_window, fourth_conv_depth,
                                 tf.nn.elu, 4,
                                 tensorboard_output)

        # fifth (last) conv layer
        y = build_1d_conv_layer(h4, fourth_conv_depth,
                                fifth_conv_window, 1,
                                tf.identity, 5,
                                tensorboard_output,
                                scope_name)

        return x, y


def deep_residual_network(input_type, input_shape,
                          number_of_downsample_layers=8,
                          channel_multiple=8,
                          initial_filter_window=5,
                          initial_stride=2,
                          downsample_filter_window=3,
                          downsample_stride=2,
                          bottleneck_filter_window=4,
                          bottleneck_stride=2,
                          upsample_filter_window=3,
                          tensorboard_output=False,
                          scope_name='deep_residual'):

    print('layer summary for {} network'.format(scope_name))
    downsample_layers = []
    upsample_layers = []

    with tf.name_scope(scope_name):
        # training flag
        train_flag = tf.placeholder(tf.bool)

        # input of the model (examples)
        s = [None]
        for i in input_shape:
            s.append(i)
        x = tf.placeholder(input_type, shape=s)
        input_size = s[-2]
        num_of_channels = s[-1]
        print('input: {}'.format(x.get_shape().as_list()[1:]))

        d1 = build_downsampling_block(x,
                                      filter_size=initial_filter_window,
                                      stride=initial_stride,
                                      tensorboard_output=tensorboard_output,
                                      depth=channel_multiple*num_of_channels,
                                      is_training=train_flag,
                                      layer_number=1)
        print('downsample layer: {}'.format(d1.get_shape().as_list()[1:]))
        downsample_layers.append(d1)

        layer_count = 2
        for i in range(number_of_downsample_layers - 1):
            d = build_downsampling_block(
                downsample_layers[-1],
                filter_size=downsample_filter_window,
                stride=downsample_stride,
                tensorboard_output=tensorboard_output,
                is_training=train_flag,
                layer_number=layer_count)
            print('downsample layer: {}'.format(d.get_shape().as_list()[1:]))
            downsample_layers.append(d)
            layer_count += 1

        bn = build_downsampling_block(downsample_layers[-1],
                                      filter_size=bottleneck_filter_window,
                                      stride=bottleneck_stride,
                                      tensorboard_output=tensorboard_output,
                                      is_training=train_flag,
                                      layer_number=layer_count)
        print('bottleneck layer: {}'.format(bn.get_shape().as_list()[1:]))
        layer_count += 1

        u1 = build_upsampling_block(bn, downsample_layers[-1],
                                    depth=bn.get_shape().as_list()[-1],
                                    filter_size=upsample_filter_window,
                                    tensorboard_output=tensorboard_output,
                                    is_training=train_flag,
                                    layer_number=layer_count)
        print('upsample layer: {}'.format(u1.get_shape().as_list()[1:]))
        upsample_layers.append(u1)
        layer_count += 1

        for i in range(number_of_downsample_layers - 2, -1, -1):
            u = build_upsampling_block(upsample_layers[-1],
                                       downsample_layers[i],
                                       filter_size=upsample_filter_window,
                                       tensorboard_output=tensorboard_output,
                                       is_training=train_flag,
                                       layer_number=layer_count)
            print('upsample layer: {}'.format(u.get_shape().as_list()[1:]))
            upsample_layers.append(u)
            layer_count += 1

        target_size = int(input_size/initial_stride)
        restack = subpixel_restack(upsample_layers[-1],
                                   target_size + (upsample_filter_window - 1))
        print('restack layer: {}'.format(restack.get_shape().as_list()[1:]))

        conv = build_1d_conv_layer(restack, restack.get_shape().as_list()[-1],
                                   upsample_filter_window, initial_stride,
                                   tf.nn.elu, layer_count,
                                   padding='VALID',
                                   tensorboard_output=tensorboard_output)
        print('final conv layer: {}'.format(conv.get_shape().as_list()[1:]))

        # NOTE this effectively is a linear activation on the last conv layer
        y = subpixel_reshuffle_1D(conv,
                                  num_of_channels)
        y = tf.add(y, x, name=scope_name)
        print('output: {}'.format(y.get_shape().as_list()[1:]))

    return train_flag, x, y

# #################
# #################
