# Copyright 2019 Mobvoi Inc. All Rights Reserved.
# Author: binbinzhang@mobvoi.com (Binbin Zhang)

import logging
from contextlib import nullcontext
# if your python version < 3.7 use the below one
# from contextlib import suppress as nullcontext
import torch
from torch.nn.utils import clip_grad_norm_
from tqdm import tqdm


class Executor:
    def __init__(self):
        self.step = 0
        self.train_batch_cache = None
        self.cv_batch_cache = None

    def train(self, model, optimizer, scheduler, world_size, data_loader, device, writer,
              args, scaler):
        ''' Train one epoch
        '''
        # scheduler.step() # step one epoch and init lr at first epoch
        model.train()
        clip = args.get('grad_clip', 50.0)
        log_interval = args.get('log_interval', 10)
        rank = args.get('rank', 0)
        accum_grad = args.get('accum_grad', 1)
        is_distributed = args.get('is_distributed', True)
        use_amp = args.get('use_amp', False)
        logging.info('using accumulate grad, new batch size is {} times'
                     'larger than before'.format(accum_grad))
        if use_amp:
            assert scaler is not None
        num_seen_utts = 0
        num_total_batch = len(data_loader)

        if self.train_batch_cache is None:
            tepoch = tqdm(data_loader, unit="batch")
            self.train_batch_cache = []
        else:
            tepoch = tqdm(self.train_batch_cache, unit="batch")
        for batch_idx, batch in enumerate(tepoch):
            # for batch_idx, batch in enumerate(data_loader):
            key, feats, target, feats_lengths, target_lengths = batch
            # cache gpu tensors
            if batch_idx >= len(self.train_batch_cache):
                feats = feats.to(device)
                # ys_in_pad, ys_out_pad = target
                target = target.to(device)
                # target = tuple(t.to(device) for t in target)
                feats_lengths = feats_lengths.to(device)
                target_lengths = target_lengths.to(device)
                self.train_batch_cache.append(
                    [key, feats, target, feats_lengths, target_lengths])

            num_utts = target_lengths.size(0)
            if num_utts == 0:
                continue
            context = None
            # Disable gradient synchronizations across DDP processes.
            # Within this context, gradients will be accumulated on module
            # variables, which will later be synchronized.
            if is_distributed and batch_idx % accum_grad != 0:
                context = model.no_sync
            # Used for single gpu training and DDP gradient synchronization
            # processes.
            else:
                context = nullcontext
            with context():
                # autocast context
                # The more details about amp can be found in
                # https://pytorch.org/docs/stable/notes/amp_examples.html
                with torch.cuda.amp.autocast(scaler is not None):
                    loss, loss_att, loss_ctc, acc = model(feats, feats_lengths,
                                                          target, target_lengths)
                    loss = loss / accum_grad
                if use_amp:
                    scaler.scale(loss).backward()
                else:
                    loss.backward()

            # tepoch.set_postfix(loss=loss.item())

            num_seen_utts += num_utts
            if batch_idx % accum_grad == 0:
                if rank == 0 and writer is not None:
                    writer.add_scalar('train_loss', loss, self.step)
                # Use mixed precision training
                if use_amp:
                    scaler.unscale_(optimizer)
                    grad_norm = clip_grad_norm_(model.parameters(), clip)
                    # Must invoke scaler.update() if unscale_() is used in the
                    # iteration to avoid the following error:
                    #   RuntimeError: unscale_() has already been called
                    #   on this optimizer since the last update().
                    # We don't check grad here since that if the gradient has
                    # inf/nan values, scaler.step will skip optimizer.step().
                    scaler.step(optimizer)
                    scaler.update()
                else:
                    grad_norm = clip_grad_norm_(model.parameters(), clip)
                    if torch.isfinite(grad_norm):
                        optimizer.step()
                optimizer.zero_grad()
                # or just scheduler.step() \n self.step += 1?
                # [scheduler.step() for i in range(world_size)]
                # self.step += world_size
                scheduler.step()
                self.step += 1
            if batch_idx % log_interval == 0:
                lr = optimizer.param_groups[0]['lr']
                log_str = 'TRAIN Batch {}/{} loss {:.6f} '.format(
                    batch_idx, num_total_batch,
                    loss.item() * accum_grad)
                if loss_att is not None:
                    log_str += 'loss_att {:.6f} '.format(loss_att.item())
                if loss_ctc is not None:
                    log_str += 'loss_ctc {:.6f} '.format(loss_ctc.item())
                log_str += 'lr {:.8f} rank {}'.format(lr, rank)
                logging.debug(log_str)

    def cv(self, model, data_loader, device, args):
        ''' Cross validation on
        '''
        model.eval()
        log_interval = args.get('log_interval', 10)
        # in order to avoid division by 0
        num_seen_utts = 1
        total_loss = 0.0
        total_acc = 0.0
        total_ppl = 0.0
        num_total_batch = len(data_loader)

        if self.cv_batch_cache is None:
            tepoch = data_loader
            self.cv_batch_cache = []
        else:
            tepoch = self.cv_batch_cache
        with torch.no_grad():
            for batch_idx, batch in enumerate(tepoch):
                key, feats, target, feats_lengths, target_lengths = batch
                # cache gpu tensors
                if batch_idx >= len(self.cv_batch_cache):
                    feats = feats.to(device)
                    target = target.to(device)
                    # target = tuple(t.to(device) for t in target)
                    feats_lengths = feats_lengths.to(device)
                    target_lengths = target_lengths.to(device)
                    self.cv_batch_cache.append(
                        [key, feats, target, feats_lengths, target_lengths])

                num_utts = target_lengths.size(0)
                if num_utts == 0:
                    continue
                loss, loss_att, loss_ctc, acc = model(feats, feats_lengths, target,
                                                      target_lengths)
                if torch.isfinite(loss):
                    num_seen_utts += num_utts
                    total_loss += loss.item() * num_utts
                    total_acc += acc * num_utts
                    # total_ppl += model.module.perplexity.item() * num_utts
                if batch_idx % log_interval == 0:
                    log_str = 'CV Batch {}/{} loss {:.6f} '.format(
                        batch_idx, num_total_batch, loss.item())
                    if loss_att is not None:
                        log_str += 'loss_att {:.6f} '.format(loss_att.item())
                    if loss_ctc is not None:
                        log_str += 'loss_ctc {:.6f} '.format(loss_ctc.item())
                    log_str += 'history loss {:.6f}'.format(total_loss /
                                                            num_seen_utts)
                    log_str += 'history acc {:.6f}'.format(total_acc /
                                                           num_seen_utts)
                    # log_str += 'history ppl {:.6f}'.format(total_ppl /
                    #                                        num_seen_utts)
                    logging.debug(log_str)

        return total_loss, total_acc, total_ppl, num_seen_utts
