import argparse
import torch
import torch.nn as nn
import torch.optim as optim
import copy
import time

from ogb.graphproppred import DglGraphPropPredDataset, collate_dgl
from torch.utils.data import DataLoader
from ogb.graphproppred import Evaluator
from models import DeeperGCN


def train(model, device, data_loader, opt, loss_fn):
    model.train()
    
    train_loss = []
    for g, labels in data_loader:
        g = g.to(device)
        labels = labels.to(torch.float32).to(device)
        logits = model(g, g.edata['feat'], g.ndata['feat'])
        loss = loss_fn(logits, labels)
        train_loss.append(loss.item())
        
        opt.zero_grad()
        loss.backward()
        opt.step()

    return sum(train_loss) / len(train_loss)


@torch.no_grad()
def test(model, device, data_loader, evaluator):
    model.eval()
    y_true, y_pred = [], []

    for g, labels in data_loader:
        g = g.to(device)
        logits = model(g, g.edata['feat'], g.ndata['feat'])
        y_true.append(labels.detach().cpu())
        y_pred.append(logits.detach().cpu())
    
    y_true = torch.cat(y_true, dim=0).numpy()
    y_pred = torch.cat(y_pred, dim=0).numpy()

    return evaluator.eval({
        'y_true': y_true,
        'y_pred': y_pred
    })['rocauc']


def main():
    # check cuda
    device = f'cuda:{args.gpu}' if args.gpu >= 0 and torch.cuda.is_available() else 'cpu'

    # load ogb dataset & evaluator
    dataset = DglGraphPropPredDataset(name='ogbg-molhiv')
    evaluator = Evaluator(name='ogbg-molhiv')

    g, _ = dataset[0]
    node_feat_dim = g.ndata['feat'].size()[-1]
    edge_feat_dim = g.edata['feat'].size()[-1]
    n_classes = dataset.num_tasks

    split_idx = dataset.get_idx_split()
    train_loader = DataLoader(dataset[split_idx["train"]],
                              batch_size=args.batch_size,
                              shuffle=True,
                              collate_fn=collate_dgl)
    valid_loader = DataLoader(dataset[split_idx["valid"]],
                              batch_size=args.batch_size,
                              shuffle=False,
                              collate_fn=collate_dgl)
    test_loader = DataLoader(dataset[split_idx["test"]],
                             batch_size=args.batch_size,
                             shuffle=False,
                             collate_fn=collate_dgl)

    # load model
    model = DeeperGCN(node_feat_dim=node_feat_dim,
                      edge_feat_dim=edge_feat_dim,
                      hid_dim=args.hid_dim,
                      out_dim=n_classes,
                      num_layers=args.num_layers,
                      dropout=args.dropout,
                      learn_beta=args.learn_beta).to(device)

    print(model)
    
    opt = optim.Adam(model.parameters(), lr=args.lr)
    loss_fn = nn.BCEWithLogitsLoss()

    # training & validation & testing
    best_auc = 0
    best_model = copy.deepcopy(model)
    times = []

    print('---------- Training ----------')
    for i in range(args.epochs):
        t1 = time.time()
        train_loss = train(model, device, train_loader, opt, loss_fn)
        t2 = time.time()

        if i >= 5:
            times.append(t2 - t1)

        train_auc = test(model, device, train_loader, evaluator)
        valid_auc = test(model, device, valid_loader, evaluator)

        print(f'Epoch {i} | Train Loss: {train_loss:.4f} | Train Auc: {train_auc:.4f} | Valid Auc: {valid_auc:.4f}')

        if valid_auc > best_auc:
            best_auc = valid_auc
            best_model = copy.deepcopy(model)
    
    print('---------- Testing ----------')
    test_auc = test(best_model, device, test_loader, evaluator)
    print(f'Test Auc: {test_auc}')
    if len(times) > 0:
        print('Times/epoch: ', sum(times) / len(times))


if __name__ == '__main__':
    """
    DeeperGCN Hyperparameters
    """
    parser = argparse.ArgumentParser(description='DeeperGCN')
    # training
    parser.add_argument('--gpu', type=int, default=-1, help='GPU index, -1 for CPU.')
    parser.add_argument('--epochs', type=int, default=300, help='Number of epochs to train.')
    parser.add_argument('--lr', type=float, default=0.01, help='Learning rate.')
    parser.add_argument('--dropout', type=float, default=0.2, help='Dropout rate.')
    parser.add_argument('--batch-size', type=int, default=2048, help='Batch size.')
    # model
    parser.add_argument('--num-layers', type=int, default=7, help='Number of GNN layers.')
    parser.add_argument('--hid-dim', type=int, default=256, help='Hidden channel size.')
    # learnable parameters in aggr
    parser.add_argument('--learn-beta', action='store_true')

    args = parser.parse_args()
    print(args)

    main()
