from __future__ import print_function
from __future__ import division
import torch
import torch.nn as nn
import torch.optim as optim
import numpy as np
import torchvision
from torchvision import datasets, models, transforms
import matplotlib.pyplot as plt
import time
import os
import copy
from tqdm import tqdm
import torch
from onnx2torch import convert
# from stitchnet.finetuning.finetune import train_model,set_parameter_requires_grad,create_optimizer
from torch import nn
from stitchnet.stitchonnx.utils import load_cats_and_dogs_dset,load_dl

# Detect if we have a GPU available
device = torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
# device = 'cpu'
print("PyTorch Version: ",torch.__version__)
print("Torchvision Version: ",torchvision.__version__)
print("device", device)

def set_parameter_requires_grad(model, feature_extracting=False):
    if feature_extracting:
        for param in model.parameters():
            param.requires_grad = False
                        
def train_model(model, dataloaders, criterion, optimizer, num_epochs=25, num_iters=30, is_inception=False):
    model.to(device)
    since = time.time()

    numsamples = []
    iter_val_acc_history = []
    
    val_acc_history = []
    val_loss_history = []
    train_acc_history = []
    train_loss_history = []

    best_model_wts = copy.deepcopy(model.state_dict())
    best_acc = 0.0
    running_numsamples = 0
    iter_count = 0

    print("running for num_iters: ", num_iters);
    def evaluate_val(model):
        running_corrects = 0
        for inputs, labels in tqdm(dataloaders['val']):
            inputs = inputs.to(device)
            labels = labels.to(device)
            outputs = model(inputs)
            loss = criterion(outputs, labels)
            _, preds = torch.max(outputs, 1)
            running_corrects += torch.sum(preds == labels.data)
        return running_corrects.double() / len(dataloaders['val'].dataset)
            
    for epoch in range(num_epochs):
        print('Epoch {}/{}'.format(epoch, num_epochs - 1))
        print('-' * 10)

        # Each epoch has a training and validation phase
        for phase in ['train', 'val']:
            if phase == 'train':
                model.train()  # Set model to training mode
            else:
                model.eval()   # Set model to evaluate mode

            running_loss = 0.0
            running_corrects = 0

            # Iterate over data.
            for inputs, labels in tqdm(dataloaders[phase]):
                inputs = inputs.to(device)
                labels = labels.to(device)

                # zero the parameter gradients
                optimizer.zero_grad()

                # forward
                # track history if only in train
                with torch.set_grad_enabled(phase == 'train'):
                    # Get model outputs and calculate loss
                    # Special case for inception because in training it has an auxiliary output. In train
                    #   mode we calculate the loss by summing the final output and the auxiliary output
                    #   but in testing we only consider the final output.
                    if is_inception and phase == 'train':
                        # From https://discuss.pytorch.org/t/how-to-optimize-inception-model-with-auxiliary-classifiers/7958
                        outputs, aux_outputs = model(inputs)
                        loss1 = criterion(outputs, labels)
                        loss2 = criterion(aux_outputs, labels)
                        loss = loss1 + 0.4*loss2
                    else:
                        outputs = model(inputs)
                        loss = criterion(outputs, labels)

                    _, preds = torch.max(outputs, 1)

                    # backward + optimize only if in training phase
                    if phase == 'train':
                        loss.backward()
                        optimizer.step()

                # statistics
                running_loss += loss.item() * inputs.size(0)
                running_corrects += torch.sum(preds == labels.data)
                
                if phase == 'train':
                    running_numsamples += preds.shape[0]
                    numsamples.append(running_numsamples)
                    model.eval()
                    acc = evaluate_val(model)
                    acc = acc.item()
                    print(running_numsamples, acc)
                    iter_val_acc_history.append(acc)
                    model.train()
                    iter_count += 1
                    if iter_count >= num_iters:
                        break
                
                    
                    # print('HERE')
                    # break
                    
            epoch_loss = running_loss / len(dataloaders[phase].dataset)
            epoch_acc = running_corrects.double() / len(dataloaders[phase].dataset)

            print('{} Loss: {:.4f} Acc: {:.4f}'.format(phase, epoch_loss, epoch_acc))

            # deep copy the model
            if phase == 'val' and epoch_acc > best_acc:
                best_acc = epoch_acc
                best_model_wts = copy.deepcopy(model.state_dict())
            if phase == 'val':
                val_loss_history.append(epoch_loss)
                val_acc_history.append(epoch_acc)
            elif phase == 'train':
                train_loss_history.append(epoch_loss)
                train_acc_history.append(epoch_acc)
            
        if iter_count >= num_iters:
            break
        
    time_elapsed = time.time() - since
    print('Training complete in {:.0f}m {:.0f}s'.format(time_elapsed // 60, time_elapsed % 60))
    print('Best val Acc: {:4f}'.format(best_acc))
    
    # print(val_acc_history)
    # load best model weights
    # model.load_state_dict(best_model_wts)
    return numsamples, iter_val_acc_history, val_acc_history, val_loss_history, train_acc_history, train_loss_history


def create_optimizer(model_ft, feature_extract=False):
    # Gather the parameters to be optimized/updated in this run. If we are
    #  finetuning we will be updating all parameters. However, if we are
    #  doing feature extract method, we will only update the parameters
    #  that we have just initialized, i.e. the parameters with requires_grad
    #  is True.
    params_to_update = model_ft.parameters()
    print("Params to learn:")
    if feature_extract:
        params_to_update = []
        for name,param in model_ft.named_parameters():
            if param.requires_grad == True:
                params_to_update.append(param)
                print("\t",name)
    else:
        for name,param in model_ft.named_parameters():
            if param.requires_grad == True:
                print("\t",name)

    # Observe that all parameters are being optimized
    optimizer_ft = optim.SGD(params_to_update, lr=0.001, momentum=0.9)
    return optimizer_ft


def finetune(model_onnx1, data_score, num_classes = 2, num_epochs = 30, num_iters=30, batch_size = 64, val_batch_size=256, feature_extract = True):

    # model_onnx1 = load_onnx_model(modelname)
    torch_model_1 = convert(model_onnx1)

    set_parameter_requires_grad(torch_model_1, feature_extract)
    k,lastLayer = [(n,m) for n,m in torch_model_1.named_modules()][-1]
    setattr(torch_model_1, k, nn.Linear(lastLayer.in_features, num_classes, bias=lastLayer.bias is not None))
    
    dataloaders_dict = dict(
        train=load_dl(data_score, batch_size),
        val=load_dl(load_cats_and_dogs_dset("test"), val_batch_size)
    )
    
    optimizer_ft = create_optimizer(torch_model_1)
    # Setup the loss fxn
    criterion = nn.CrossEntropyLoss()
    # Train and evaluate
    return train_model(torch_model_1, dataloaders_dict, criterion, optimizer_ft, num_epochs=num_epochs, num_iters=num_iters, is_inception=False)