"""Run this file to train demo GRU-Tree."""

from __future__ import print_function
from __future__ import absolute_import

import pydotplus
from copy import deepcopy

import autograd.numpy as np
import autograd.numpy.random as npr
from autograd.optimizers import sgd, adam
from autograd import grad

from sklearn.tree import DecisionTreeClassifier
from sklearn.tree import export_graphviz
from datasets import gen_synthetic_dataset
from model import build_gru, build_mlp
from model import softplus
import collections
import time



class GRUTree(object):
    def __init__(self, in_count, state_count, hidden_sizes, out_count, strength):
        self.gru = GRU(in_count, state_count, out_count)
        self.mlp = MLP(self.gru.num_weights, hidden_sizes, 1)
        self.mlp.weights = self.mlp.init_weights(0.1)  # init for 1st run
        self.strength = strength

    def objective(self, W, X, F, y):
        path_length = self.mlp.pred_fun(self.mlp.weights, 
                                        W[:self.gru.num_weights][:, np.newaxis]).ravel()[0]
        print("loglike: "+ str(-self.gru.loglike_fun(W, X, F, y)))
        print("apl: "+ str(path_length))
        print("with strength: " + str(self.strength * path_length) )
        return -self.gru.loglike_fun(W, X, F, y) + self.strength * path_length


    def average_path_length_batch(self, weights_batch, X_train, F_train, y_train):
        apl_batch = np.zeros((1, weights_batch.shape[1]))
        for i in range(weights_batch.shape[1]):
            apl_batch[0, i] = self.gru.average_path_length(weights_batch[:, i], 
                                                           X_train, F_train, y_train)
        return apl_batch

    def train(self, X_train, F_train, y_train, iters_retrain=25, num_iters=1000,
              batch_size=32, lr=1e-3, param_scale=0.01, log_every=10):
        npr.seed(42)

        print('building synthetic dataset...')
        repo_size = 50

        start = time.time()
        #
        synthetic_W = np.array(map(lambda x:2*x-1,np.random.rand(6800,repo_size)))
        W_repo = collections.deque([],repo_size)
        apl_repo = collections.deque([],repo_size)

        for x in range(synthetic_W.shape[1]):
            W_repo.append(synthetic_W[:, x])
        W_train_list = np.array([x for x in W_repo]).T
        print('W_train_list shape:' + str(W_train_list.shape))
        a = time.time()
        print("creating dataset: " + str(a - start))
        APL_train = self.average_path_length_batch(W_train_list, X_train, F_train, y_train)
        for x in range(APL_train.shape[1]):
            apl_repo.append(APL_train[:, x])
        b = time.time()
        print("computing apl for dataset: " + str(b - a))
        apl_test =  np.array([x for x in apl_repo]).T
        print('initial training of MLP on synthetic set')
        # !!!! num_iters changed to 500 !!!
        self.mlp.train(W_train_list[:self.gru.num_weights, :], apl_test, num_iters=500,lr=1e-3, param_scale=0.1, log_every=25)
        c = time.time()
        print("initial training of mlp: " + str(c - b))

        #num_retrains = num_iters // iters_retrain
        num_retrains = 7
        for i in range(num_retrains):
            self.gru.objective = self.objective
            # carry over weights from last training
            init_weights = self.gru.weights if i > 0 else None
            start_gru = time.time()
            print('training deep net... [%d/%d], learning rate: %.4f' % (i + 1, num_retrains, lr))
            self.gru.train(X_train, F_train, y_train, num_iters=iters_retrain,
                           batch_size=batch_size, lr=lr, param_scale=param_scale, 
                           log_every=1, init_weights=init_weights)
            print('building surrogate dataset...')
            d = time.time()
            print("gru 1 iter: " + str(d - start_gru))
            W_train = deepcopy(self.gru.saved_weights.T)
            print(W_train.shape)
            print("W_train shape:" + str(W_train.shape))
            for x in range(W_train.shape[1]):
                W_repo.append(W_train[:,x])
            W_train_list = np.array([x for x in W_repo]).T
            print('W_train_list shape:' + str(W_train_list.shape))
            APL_train = self.average_path_length_batch(W_train_list, X_train, F_train, y_train)
            e = time.time()
            print("computing apl: " + str(e - d))
            print(APL_train)
            print('training surrogate net... [%d/%d]' % (i + 1, num_retrains))
            # !!!! num_iters changed to 500 !!!
            self.mlp.train(W_train_list[:self.gru.num_weights, :], APL_train, num_iters=num_iters,
                           lr=1e-3, param_scale=0.1, log_every=25)
            f = time.time()
            print("training mlp: " + str(f - e))

        self.pred_fun = self.gru.pred_fun
        self.weights = self.gru.weights
        # save final decision tree
        self.tree = self.gru.fit_tree(self.weights, X_train, F_train, y_train)

        return self.weights


class GRU(object):
    def __init__(self, in_count, state_count, out_count):
        super(GRU, self).__init__()
        pred_fun, loglike_fun, parser = build_gru(in_count, state_count, out_count)
        self.num_weights = parser.num_weights
        self.in_count = in_count
        self.out_count = out_count
        self.state_count = state_count
        self.pred_fun = pred_fun
        self.loglike_fun = loglike_fun
        self.saved_weights = None

    def objective(self, W, X, F, y):
        return -self.loglike_fun(W, X, F, y)


    def init_weights(self, param_scale):
        return npr.randn(self.num_weights) * param_scale

    def fit_tree(self, weights, X_train, F_train, y_train):
        """Train decision tree to track path length."""
        y_train_hat = self.pred_fun(weights, X_train, F_train)
        y_train_hat_int = np.rint(y_train_hat).astype(int)
        tree = DecisionTreeClassifier(min_samples_leaf=1000)
        tree.fit(X_train.T, y_train_hat_int.T)
        return tree

    def average_path_length(self, weights, X_train, F_train, y_train):
        tree = self.fit_tree(weights, X_train, F_train, y_train)
        path_length = average_path_length(tree, X_train.T)
        return path_length

    def train(self, X_train, F_train, y_train, batch_size=32, num_iters=1000, 
              lr=1e-3, param_scale=0.01, log_every=100, init_weights=None):
        grad_fun = build_batched_grad_fences(grad(self.objective), batch_size, 
                                             X_train, F_train, y_train)
        if init_weights is None:
            init_weights = self.init_weights(param_scale)
        saved_weights = np.zeros((num_iters, self.num_weights))

        def callback(weights, i, gradients):
            apl = self.average_path_length(weights, X_train, F_train, y_train)
            saved_weights[i, :] = weights
            loss_train = self.objective(weights, X_train, F_train, y_train)
            if i % log_every == 0: 
                print('model: gru | iter: {} | loss: {:.2f} | apl: {:.2f}'.format(i, loss_train, apl))

        optimized_weights = adam(grad_fun, init_weights, num_iters=num_iters, 
                                 step_size=lr, callback=callback)
        self.saved_weights = saved_weights
        self.weights = optimized_weights
        return optimized_weights


class MLP(object):
    def __init__(self, in_count, hidden_sizes, out_count):
        super(MLP, self).__init__()
        layer_specs = [in_count] + hidden_sizes + [out_count]
        pred_fun, loglike_fun, parser = build_mlp(layer_specs, output_activation=softplus)
        self.num_weights = parser.num_weights
        self.in_count = in_count
        self.out_count = out_count
        self.hidden_sizes = hidden_sizes
        self.pred_fun = pred_fun
        self.loglike_fun = loglike_fun

    def objective(self, W, X, y):
        return self.loglike_fun(W, X, y) + np.linalg.norm(W[:self.num_weights], ord=2)

    def init_weights(self, param_scale):
        return npr.randn(self.num_weights) * param_scale

    def train(self, X_train, y_train, batch_size=32, num_iters=1000, 
              lr=1e-3, param_scale=0.01, log_every=100, init_weights=None):
        grad_fun = build_batched_grad(grad(self.objective), batch_size, 
                                      X_train, y_train)
        if init_weights is None:
            init_weights = self.init_weights(param_scale)
    
        def callback(weights, i, gradients):
            loss_train = self.objective(weights, X_train, y_train)
            if i % log_every == 0: 
                print('model: mlp | iter: {} | loss: {:.2f}'.format(i, loss_train))

        optimized_weights = adam(grad_fun, init_weights, num_iters=num_iters,
                                 step_size=lr, callback=callback)
        self.weights = optimized_weights
        return optimized_weights


def average_path_length(tree, X):
    """Compute average path length: cost of simulating the average
    example; this is used in the objective function.

    @param tree: DecisionTreeClassifier instance
    @param X: NumPy array (D x N)
              D := number of dimensions
              N := number of examples
    @return path_length: float
                         average path length
    """

    #added for computing anomalies apl
    # if(X.shape[0]==1):
    #     intermediate = [0]*tree.tree_.node_count
    #     print(intermediate)
    #     intermediate[tree.apply(X)[0]] = 1
    #     print(intermediate)
    #     leaf_counts = intermediate
    #     print(leaf_counts)
    # else:
    leaf_indices = tree.apply(X)
    leaf_counts = np.bincount(leaf_indices)
    leaf_i = np.arange(tree.tree_.node_count)
    path_length = np.dot(leaf_i, leaf_counts) / float(X.shape[0])
    return path_length


def get_ith_minibatch_ixs(i, num_data, batch_size):
    """Split data into minibatches.
    
    @param i: integer
              iteration index
    @param num_data: integer
                     number of data points
    @param batch_size: integer
                       number of data points in a batch
    @return batch_slice: slice object
    """
    num_minibatches = num_data / batch_size + ((num_data % batch_size) > 0)
    i = i % num_minibatches
    start = i * batch_size
    stop = start + batch_size
    return slice(start, stop)


def build_batched_grad(grad, batch_size, inputs, targets):
    """Return grad on batched gradient. 

    @param grad: gradient function.
    @param batch_size: integer
                       batch size
    @param inputs: NumPy Array
                   size D x N
    @param targets: NumPy Array
                    size O x N
    @return batched_grad: function
                          function to compute gradients on inputs.
    """
    def batched_grad(weights, i):
        cur_idxs = get_ith_minibatch_ixs(i, targets.shape[1], batch_size)
        return grad(weights, inputs[:, cur_idxs], targets[:, cur_idxs])
    return batched_grad


def get_ith_minibatch_ixs_fences(b_i, batch_size, fences):
    """Split timeseries data of uneven sequence lengths into batches.
    This is how we handle different sized sequences.
    
    @param b_i: integer
                iteration index
    @param batch_size: integer
                       size of batch
    @param fences: list of integers
                   sequence of cutoff array
    @return idx: integer
    @return batch_slice: slice object
    """
    num_data = len(fences) - 1
    num_minibatches = num_data / batch_size + ((num_data % batch_size) > 0)
    b_i = b_i % num_minibatches
    idx = slice(b_i * batch_size, (b_i+1) * batch_size)
    batch_i = np.arange(num_data)[idx]
    batch_slice = np.concatenate([range(i, j) for i, j in 
                                  zip(fences[batch_i], fences[batch_i+1])])
    return idx, batch_slice


def build_batched_grad_fences(grad, batch_size, inputs, fences, targets):
    """Return grad on batched gradient. 

    @param grad: gradient function.
    @param batch_size: integer
                       batch size
    @param inputs: NumPy Array
                   size D x N
    @param fences: NumPy Array
    @param targets: NumPy Array
                    size O x N
    @return batched_grad: function
                          function to compute gradients on inputs with fenceposts.
    """
    def batched_grad(weights, i):
        cur_idxs, cur_slice = get_ith_minibatch_ixs_fences(i, batch_size, fences)
        batched_inputs = inputs[:, cur_slice]
        batched_targets = None if targets is None else targets[:, cur_slice]
        batched_fences = fences[cur_idxs.start:cur_idxs.stop+1] - fences[cur_idxs.start]
        return grad(weights, batched_inputs, batched_fences, batched_targets)
    return batched_grad


def visualize(tree, save_path,fs):
    """Generate PDF of a decision tree.

    @param tree: DecisionTreeClassifier instance
    @param save_path: string 
                      where to save tree PDF
    """
    dot_data = export_graphviz(tree, out_file=None,
                               filled=True, rounded=True,class_names=["normal","abnormal"])
    graph = pydotplus.graph_from_dot_data(dot_data)
    graph = make_graph_minimal(graph,fs)  # remove extra text

    if not save_path is None:
        graph.write_pdf(save_path)


def make_graph_minimal(graph,fs):
    nodes = graph.get_nodes()
    print("number of nodes: " + str(len(nodes)))
    for node in nodes:
        old_label = node.get_label()
        label = prune_label(old_label,fs)
        if label is not None:
            node.set_label(label)
    return graph


def prune_label(label,fs):
    if label is None:
        return None
    if len(label) == 0:
        return None
    label = label[1:-1]
    splitted_label = label.split('\\n')
    if 'X' in splitted_label[0] or 'x' in splitted_label[0]:
        feature = splitted_label[0]
        index = feature.split("[")[1].split("]")[0]
        import pickle
        if(fs):
            with open('./feature_name/list_feature_selection.pkl', 'rb') as fp:
                feature_list = pickle.load(fp)
        else:
            with open('./feature_name/list.pkl', 'rb') as fp:
                feature_list = pickle.load(fp)
        feature_name = feature_list[int(index)].split('(t')[0]
        splitted_label[0] = feature_name+ " " + splitted_label[0].split(" ")[1] + splitted_label[0].split(" ")[2]

    parts = [part for part in splitted_label
             if 'gini =' not in part]
    return '"' + '\\n'.join(parts) + '"'



if __name__ == "__main__":
    import os
    import pickle

    import argparse
    parser = argparse.ArgumentParser()
    parser.add_argument('--strength', type=float, default=1000.0,
                        help='how much to weigh tree-regularization term.')
    args = parser.parse_args()

    with open('./data/train.pkl', 'rb') as fp:
        data_train = pickle.load(fp)
        X_train = data_train['X']
        F_train = data_train['F']
        y_train = data_train['y']


    gru = GRUTree(14, 100, [25], 1, strength=args.strength)
    gru.train(X_train, F_train, y_train, iters_retrain=25, num_iters=300,
              batch_size=10, lr=1e-3, param_scale=0.1, log_every=10)

    if not os.path.isdir('./trained_models'):
        os.mkdir('./trained_models')

    with open('./trained_models/trained_weights.pkl', 'wb') as fp:
        pickle.dump({'gru': gru.gru.weights, 'mlp': gru.mlp.weights}, fp)
        print('saved trained model to ./trained_models')

    visualize(gru.tree, './trained_models/tree.pdf',False)
    print('saved final decision tree to ./trained_models')


