import torch
import torch.nn as nn
import torch.nn.functional as F
import dgl
import dgl.function as fn
import numpy as np
import scipy.sparse as ssp
from dgl.data import citation_graph as citegrh
import networkx as nx

##load data
##cora dataset have 2708 nodes, 1208 of them is used as train set 1000 of them is used as test set
data = citegrh.load_cora()
adj = nx.adjacency_matrix(data.graph)
# reorder
n_nodes = 2708
ids_shuffle = np.arange(n_nodes)
np.random.shuffle(ids_shuffle)
adj = adj[ids_shuffle, :][:, ids_shuffle]
data.features = data.features[ids_shuffle]
data.labels = data.labels[ids_shuffle]
##train-test split
train_nodes = np.arange(1208)
test_nodes = np.arange(1708, 2708)
train_adj = adj[train_nodes, :][:, train_nodes]
test_adj = adj[test_nodes, :][:, test_nodes]
trainG = dgl.DGLGraphStale(train_adj)
allG = dgl.DGLGraphStale(adj)
h = torch.tensor(data.features[train_nodes], dtype=torch.float32)
test_h = torch.tensor(data.features[test_nodes], dtype=torch.float32)
all_h = torch.tensor(data.features, dtype=torch.float32)
train_nodes = torch.tensor(train_nodes)
test_nodes = torch.tensor(test_nodes)
y_train = torch.tensor(data.labels[train_nodes])
y_test = torch.tensor(data.labels[test_nodes])
input_size = h.shape[1]
output_size = data.num_labels

##configuration
config = {
    'n_epoch': 300,
    'lamb': 0.5,
    'lr': 1e-3,
    'weight_decay': 5e-4,
    'hidden_size': 16,
    ##sample size for each layer during training
    'batch_size': 256,
    ##sample size for each layer during test
    'test_batch_size': 64,
    'test_layer_sizes': [64 * 8, 64 * 8],
}


class NodeSampler(object):
    """Minibatch sampler that samples batches of nodes uniformly from the given graph and list of seeds.
    """

    def __init__(self, graph, seeds, batch_size):
        self.seeds = seeds
        self.batch_size = batch_size
        self.graph = graph

    def __len__(self):
        return len(self.seeds) // self.batch_size

    def __iter__(self):
        """Returns
        (1) The seed node IDs, for NodeFlow generation,
        (2) Indices of the seeds in the original seed array, as auxiliary data.
        """
        batches = torch.randperm(len(self.seeds)).split(self.batch_size)
        for i in range(len(self)):
            if len(batches[i]) < self.batch_size:
                break
            else:
                yield self.seeds[batches[i]], batches[i]


def create_nodeflow(layer_mappings, block_mappings, block_aux_data,
                    data_dict, num_nodes_dict, seed_map):
    hg = dgl.heterograph(data_dict, num_nodes_dict=num_nodes_dict)
    hg.layer_mappings = layer_mappings
    hg.block_mappings = block_mappings
    hg.block_aux_data = block_aux_data
    hg.seed_map = seed_map
    return hg


def normalize_adj(adj):
    """Symmetrically normalize adjacency matrix."""
    adj = ssp.eye(adj.shape[0]) + adj
    adj = ssp.coo_matrix(adj)
    rowsum = np.array(adj.sum(1))
    d_inv_sqrt = np.power(rowsum, -0.5).flatten()
    d_inv_sqrt[np.isinf(d_inv_sqrt)] = 0.
    d_mat_inv_sqrt = ssp.diags(d_inv_sqrt)
    return adj.dot(d_mat_inv_sqrt).transpose().dot(d_mat_inv_sqrt)


class AdaptGenerator(object):
    """
    Nodeflow generator used for adaptive sampling

    """

    def __init__(self, graph, num_blocks, node_feature=None, sampler=None, num_workers=0, coalesce=False,
                 sampler_weights=None, layer_nodes=None):
        self.node_feature = node_feature
        adj = graph.adjacency_matrix_scipy(transpose=False)
        adj.data = np.ones(adj.nnz)
        self.norm_adj = normalize_adj(adj).tocsr()
        self.layer_nodes = layer_nodes
        if sampler_weights is not None:
            self.sample_weights = sampler_weights
        else:
            self.sample_weights = nn.Parameter(torch.randn((input_size, 2), dtype=torch.float32))
            nn.init.xavier_uniform_(self.sample_weights)
        self.graph = graph
        self.num_blocks = num_blocks
        self.coalesce = coalesce
        self.sampler = sampler

    def __iter__(self):
        for sampled in self.sampler:
            seeds = sampled[0]
            auxiliary = sampled[1:]
            hg = self(seeds, *auxiliary)
            yield (hg, auxiliary[0])

    def __call__(self, seeds, *auxiliary):
        """
           The __call__ function must take in an array of seeds, and any auxiliary data, and
           return a NodeFlow grown from the seeds and conditioned on the auxiliary data.
        """
        curr_frontier = seeds  # Current frontier to grow neighbors from
        layer_mappings = []  # Mapping from layer node ID to parent node ID
        block_mappings = []  # Mapping from block edge ID to parent edge ID, or -1 if nonexistent
        block_aux_data = []
        data_dict = dict()
        num_nodes_dict = dict()

        if self.coalesce:
            curr_frontier = torch.LongTensor(np.unique(seeds.numpy()))
            invmap = {x: i for i, x in enumerate(curr_frontier.numpy())}
            seed_map = [invmap[x] for x in seeds.numpy()]
        else:
            seed_map = list(range(len(seeds)))

        layer_mappings.append(curr_frontier.numpy())

        for i in reversed(range(self.num_blocks)):
            neighbor_nodes, neighbor_edges, num_neighbors, aux_result = self.stepback(curr_frontier, i, *auxiliary)
            prev_frontier_srcs = neighbor_nodes
            # The un-coalesced mapping from block edge ID to parent edge ID
            prev_frontier_edges = neighbor_edges.numpy()
            nodes_idx_map = dict({*zip(neighbor_nodes.numpy(), range(len(aux_result)))})
            # Coalesce nodes
            if self.coalesce:
                prev_frontier = np.unique(prev_frontier_srcs.numpy())
                prev_frontier_invmap = {x: j for j, x in enumerate(prev_frontier)}
                block_srcs = np.array([prev_frontier_invmap[s] for s in prev_frontier_srcs.numpy()])
            else:
                prev_frontier = prev_frontier_srcs.numpy()
                block_srcs = np.arange(len(prev_frontier_edges))
            aux_result = aux_result[[nodes_idx_map[i] for i in prev_frontier]]
            block_dsts = np.arange(len(curr_frontier)).repeat(num_neighbors)

            data_dict.update({
                ('layer%d' % i, 'block%d' % i, 'layer%d' % (i + 1)): (block_srcs, block_dsts)
            })
            num_nodes_dict.update({
                'layer%d' % i: len(prev_frontier),
                'layer%d' % (i + 1): len(curr_frontier)
            })

            layer_mappings.insert(0, prev_frontier)
            block_mappings.insert(0, prev_frontier_edges)
            block_aux_data.insert(0, aux_result)

            curr_frontier = torch.LongTensor(prev_frontier)

        return create_nodeflow(
            layer_mappings=layer_mappings,
            block_mappings=block_mappings,
            block_aux_data=block_aux_data,
            data_dict=data_dict,
            num_nodes_dict=num_nodes_dict,
            seed_map=seed_map)

    def stepback(self, curr_frontier, layer_index, *auxiliary):
        """Function that takes in the node set in the current layer, and returns the
        neighbors of each node.

        Parameters
        ----------
        curr_frontier : Tensor
        auxiliary : any auxiliary data yielded by the sampler

        Returns
        -------
        neighbor_nodes, incoming_edges, num_neighbors, auxiliary: Tensor, Tensor, Tensor, any
            num_neighbors[i] contains the number of neighbors generated for curr_frontier[i]

            neighbor_nodes[sum(num_neighbors[0:i]):sum(num_neighbors[0:i+1])] contains the actual
            neighbors as node IDs in the original graph for curr_frontier[i].

            incoming_edges[sum(num_neighbors[0:i]):sum(num_neighbors[0:i+1])] contains the actual
            incoming edges as edge IDs in the original graph for curr_frontier[i], or -1 if the
            edge does not exist, or if we don't care about the edge, in the original graph.

            auxiliary could be of any type containing block-specific additional data.
        """

        # Relies on that the same dst node of in_edges are contiguous, and the dst nodes
        # are ordered the same as curr_frontier.

        sample_weights = self.sample_weights
        layer_size = self.layer_nodes[layer_index]

        src, des, eid = self.graph.in_edges(curr_frontier, form='all')
        neighbor_nodes = torch.unique(torch.cat((curr_frontier, src), dim=0), sorted=False)
        sparse_adj = self.norm_adj[curr_frontier, :][:, neighbor_nodes]
        square_adj = sparse_adj.multiply(sparse_adj).sum(0)

        tensor_adj = torch.FloatTensor(square_adj.A[0])
        ##compute sampling probability for next layer which is decided by :
        # 1. attention part derived from node hidden feature
        # 2. adjacency part derived from graph structure
        hu = torch.matmul(self.node_feature[neighbor_nodes], sample_weights[:, 0])
        hv = torch.sum(torch.matmul(self.node_feature[curr_frontier], sample_weights[:, 1]))
        adj_part = torch.sqrt(tensor_adj)
        attention_part = F.relu(hv + hu) + 1
        gu = F.relu(hu) + 1
        probas = adj_part * attention_part * gu
        probas = probas / torch.sum(probas)
        ##build graph between candidates and curr_frontier
        candidates = neighbor_nodes[probas.multinomial(num_samples=layer_size, replacement=True)]
        ivmap = {x: i for i, x in enumerate(neighbor_nodes.numpy())}
        # use matrix operation in pytorch to avoid for-loop
        curr_padding = curr_frontier.repeat_interleave(len(candidates))
        cand_padding = candidates.repeat(len(curr_frontier))
        ##the edges between candidates and curr_frontier composed of
        # 1. edges in orginal graph
        # 2. edges between same node(self-loop)
        has_loops = curr_padding == cand_padding
        has_edges = self.graph.has_edges_between(cand_padding, curr_padding)
        loops_or_edges = (has_edges.bool() + has_loops).int()
        # get neighbor_nodes and corresponding sampling probability
        num_neighbors = loops_or_edges.reshape((len(curr_frontier), -1)).sum(1)
        sample_neighbor = cand_padding[loops_or_edges.bool()]
        q_prob = probas[[ivmap[i] for i in sample_neighbor.numpy()]]

        # get the edge mapping ,-1 if the edge dostn't exist
        eids = torch.zeros(torch.sum(num_neighbors), dtype=torch.int64) - 1
        has_edge_ids = torch.where(has_edges)[0]
        all_ids = torch.where(loops_or_edges)[0]
        edges_ids_map = torch.where(has_edge_ids[:, None] == all_ids[None, :])[1]
        u, v, e = self.graph.edge_ids(cand_padding, curr_padding, return_uv=True)
        eids[edges_ids_map] = e

        return sample_neighbor, eids, num_neighbors, q_prob


class AdaptSAGEConv(nn.Module):
    def __init__(self,
                 in_feats,
                 out_feats,
                 aggregator_type='mean',
                 feat_drop=0.,
                 bias=True,
                 norm=None,
                 activation=None,
                 G=None):
        super(AdaptSAGEConv, self).__init__()
        self._in_feats = in_feats
        self._out_feats = out_feats
        self.norm = norm
        self.feat_drop = nn.Dropout(feat_drop)
        self.activation = activation
        # self.fc_self = nn.Linear(in_feats, out_feats, bias=bias).double()
        self.fc_neigh = nn.Linear(in_feats, out_feats, bias=bias)
        self.reset_parameters()
        self.G = G

    def reset_parameters(self):
        gain = nn.init.calculate_gain('relu')
        # nn.init.xavier_uniform_(self.fc_self.weight, gain=gain)
        nn.init.xavier_uniform_(self.fc_neigh.weight, gain=gain)

    def forward(self, graph, hidden_feat, node_feat, layer_id, sample_weights, norm_adj=None, var_loss=None,
                is_test=False):
        """
        graph: Bipartite.  Has two edge types.  The first one represents the connection to
        the desired nodes from neighbors.  The second one represents the computation
        dependence of the desired nodes themselves.
        :type graph: dgl.DGLHeteroGraph
        """
        # local_var is not implemented for heterograph
        # graph = graph.local_var()

        neighbor_etype_name = 'block%d' % layer_id
        src_name = 'layer%d' % layer_id
        dst_name = 'layer%d' % (layer_id + 1)

        graph.nodes[src_name].data['hidden_feat'] = hidden_feat
        graph.nodes[src_name].data['node_feat'] = node_feat[graph.layer_mappings[layer_id]]
        ##normalized degree for node_i is defined as  1/sqrt(d_i+1)
        ##use the training graph during training and whole graph during testing
        if not is_test:
            graph.nodes[src_name].data['norm_deg'] = 1 / torch.sqrt(
                trainG.in_degrees(graph.layer_mappings[layer_id]).float() + 1)
            graph.nodes[dst_name].data['norm_deg'] = 1 / torch.sqrt(
                trainG.in_degrees(graph.layer_mappings[layer_id + 1]).float() + 1)
        else:
            graph.nodes[src_name].data['norm_deg'] = 1 / torch.sqrt(
                allG.in_degrees(graph.layer_mappings[layer_id]).float() + 1)
            graph.nodes[dst_name].data['norm_deg'] = 1 / torch.sqrt(
                allG.in_degrees(graph.layer_mappings[layer_id + 1]).float() + 1)
        graph.nodes[dst_name].data['node_feat'] = node_feat[graph.layer_mappings[layer_id + 1]]
        graph.nodes[src_name].data['q_probs'] = graph.block_aux_data[layer_id]

        def send_func(edges):

            hu = torch.matmul(edges.src['node_feat'], sample_weights[:, 0])
            hv = torch.matmul(edges.dst['node_feat'], sample_weights[:, 1])
            ##attention coeffient is adjusted by normalized degree and sampling probability
            attentions = edges.src['norm_deg'] * edges.dst['norm_deg'] * (F.relu(hu + hv) + 0.1) / edges.src[
                'q_probs'] / len(hu)

            hidden = edges.src['hidden_feat'] * torch.reshape(attentions, [-1, 1])
            return {"hidden": hidden}

        recv_func = dgl.function.sum('hidden', 'neigh')
        # def receive_fuc(nodes):
        # aggregate from neighbors
        graph[neighbor_etype_name].update_all(message_func=send_func, reduce_func=recv_func)
        h_neigh = graph.nodes[dst_name].data['neigh']
        rst = self.fc_neigh(h_neigh)

        # activation
        if self.activation is not None:
            rst = self.activation(rst)
        # normalization
        if self.norm is not None:
            rst = self.norm(rst)
        # compute the variance loss according to the formula in orginal paper
        if var_loss and not is_test:
            pre_sup = self.fc_neigh(hidden_feat)  # u*h
            ##normalized adjacency matrix for nodeflow layer
            support = norm_adj[graph.layer_mappings[layer_id + 1], :][:, graph.layer_mappings[layer_id]]  ##v*u
            hu = torch.matmul(node_feat[graph.layer_mappings[layer_id]], sample_weights[:, 0])
            hv = torch.matmul(node_feat[graph.layer_mappings[layer_id + 1]], sample_weights[:, 1])
            attentions = (F.relu(torch.reshape(hu, [1, -1]) + torch.reshape(hv, [-1, 1])) + 1) / graph.block_aux_data[
                layer_id] / len(hu)
            adjust_support = torch.tensor(support.A, dtype=torch.float32) * attentions
            support_mean = adjust_support.sum(0)
            mu_v = torch.mean(rst, dim=0)  # h
            diff = torch.reshape(support_mean, [-1, 1]) * pre_sup - torch.reshape(mu_v, [1, -1])
            loss = torch.sum(diff * diff) / len(hu) / len(hv)
            return rst, loss
        return rst


class AdaptGraphSAGENet(nn.Module):
    def __init__(self, sample_weights, node_feature, hidden_size):
        super().__init__()
        self.layers = nn.ModuleList([
            AdaptSAGEConv(input_size, hidden_size, 'mean', bias=False, activation=F.relu),
            AdaptSAGEConv(hidden_size, output_size, 'mean', bias=False, activation=F.relu),
        ])
        self.sample_weights = sample_weights
        self.node_feature = node_feature
        self.norm_adj = normalize_adj(trainG.adjacency_matrix_scipy(transpose=False))

    def forward(self, nf, h, is_test=False):
        for i, layer in enumerate(self.layers):
            if i == len(self.layers) - 1 and not is_test:
                h, loss = layer(nf, h, self.node_feature, i, self.sample_weights, norm_adj=self.norm_adj, var_loss=True,
                                is_test=is_test)
            else:
                h = layer(nf, h, self.node_feature, i, self.sample_weights, is_test=is_test)
        if is_test:
            return h
        return h, loss


def main(args):
    config.update(args)
    config['layer_sizes'] = [int(config['node_per_layer']) for _ in range(2)]
    # Create a sampler for training nodes and testing nodes
    train_sampler = NodeSampler(graph=trainG, seeds=train_nodes, batch_size=config['batch_size'])
    test_sampler = NodeSampler(graph=allG, seeds=test_nodes, batch_size=config['test_batch_size'])
    ##Generator for training
    train_generator = AdaptGenerator(graph=trainG, node_feature=all_h, layer_nodes=config['layer_sizes'],
                                     sampler=train_sampler,
                                     num_blocks=len(config['layer_sizes']), coalesce=True)
    # Generator for testing
    test_sample_generator = AdaptGenerator(graph=allG, node_feature=all_h, sampler=test_sampler,
                                           num_blocks=len(config['test_layer_sizes']),
                                           sampler_weights=train_generator.sample_weights,
                                           layer_nodes=config['test_layer_sizes'],
                                           coalesce=True)
    model = AdaptGraphSAGENet(train_generator.sample_weights, all_h, config['hidden_size'])
    params = list(model.parameters())
    params.append(train_generator.sample_weights)
    opt = torch.optim.Adam(params=params, lr=config['lr'])
    # model.train()
    lamb, weight_decay = config['lamb'], config['weight_decay']
    for epoch in range(config['n_epoch']):
        train_accs = []
        for nf, sample_indices in train_generator:
            seed_map = nf.seed_map
            train_y_hat, varloss = model(nf, h[nf.layer_mappings[0]])
            train_y_hat = train_y_hat[seed_map]
            y_train_batch = y_train[sample_indices]
            y_pred = torch.argmax(train_y_hat, dim=1)
            train_acc = torch.sum(torch.eq(y_pred, y_train_batch)).item() / config['batch_size']
            train_accs.append(train_acc)
            loss = F.cross_entropy(train_y_hat.squeeze(), y_train_batch)
            l2_loss = torch.norm(params[0])
            total_loss = varloss * lamb + loss + l2_loss * weight_decay
            opt.zero_grad()
            total_loss.backward()
            opt.step()
            # print(train_sampler.sample_weight)
        test_accs = []
        for test_nf, test_sample_indices in test_sample_generator:
            seed_map = test_nf.seed_map
            # print(test_sample_indices)
            test_y_hat = model(test_nf, all_h[test_nf.layer_mappings[0]], is_test=True)
            # print("test",test_y_hat)
            test_y_hat = test_y_hat[seed_map]
            y_test_batch = y_test[test_sample_indices]
            y_pred = torch.argmax(test_y_hat, dim=1)
            test_acc = torch.sum(torch.eq(y_pred, y_test_batch)).item() / len(y_pred)
            test_accs.append(test_acc)
        print("eqoch{} train accuracy {}, regloss {}, loss {} ,test accuracy {}".format(epoch, np.mean(train_acc),
                                                                                        varloss.item() * lamb,
                                                                                        total_loss.item(),
                                                                                        np.mean(test_accs)))


if __name__ == "__main__":
    import argparse

    parser = argparse.ArgumentParser('Adaptive Sampling for CCN')
    parser.add_argument('-b', '--batch_size', type=int, default=256,
                        help='batch size')
    parser.add_argument('-l', '--node_per_layer', type=float, default=256,
                        help='sampling size for each layer')

    args = parser.parse_args().__dict__

    main(args)
