import os
import sys
import shutil
import time
import logging
import torch
import torch.utils.data as data
import torch.nn as nn
import torchvision.transforms as transforms
from utils.metric import AverageMeter, Loss, constraints_loss
from test import test
from config import data_config, network_config, lr_scheduler, get_image_unique
from train_config import config
from tqdm import tqdm
import sys
from solver import WarmupMultiStepLR, RandomErasing

from test import test
import numpy as np
import re
import pickle
import random
import math

logger = logging.getLogger()
logger.setLevel(logging.INFO)

def save_checkpoint(state, epoch, dst, is_best):
    filename = os.path.join(dst, 'best_model') + '.pth.tar'
    torch.save(state, filename)
    if is_best:
        dst_best = os.path.join(dst, 'model_best', str(epoch)) + '.pth.tar'
        shutil.copyfile(filename, dst_best)

def train(epoch, train_loader, network, optimizer, compute_loss, args, co_location_loss=None):
    batch_time = AverageMeter()
    train_loss = AverageMeter()
    image_pre = AverageMeter()
    text_pre = AverageMeter()

    # switch to train mode
    network.train()

    end = time.time()

    for step, (images, captions, labels) in enumerate(train_loader):
        sep_captions = []

        n_sep = 2

        for i, c in enumerate(captions):
            c = re.split(r'[;,!?.]', c)
            if len(c) > n_sep or len(c) == n_sep:
                sep_captions = sep_captions + c[0:n_sep]
            else:
                pad_length = n_sep - len(c)
                padding = ["[PAD]" for j in range(pad_length)]
                sep_captions = sep_captions + c + padding

        tokens, segments, input_masks, caption_length = network.module.language_model.pre_process(captions)
        sep_tokens, sep_segments, sep_input_masks, sep_caption_length = network.module.language_model.pre_process(sep_captions)

        tokens = tokens.cuda()
        segments = segments.cuda()
        input_masks = input_masks.cuda()
        caption_length = caption_length.cuda()

        sep_tokens = sep_tokens.cuda()
        sep_segments = sep_segments.cuda()
        sep_input_masks = sep_input_masks.cuda()

        images = images.cuda()
        labels = labels.cuda()

        p2 = [i for i in range(args.part2)]
        p3 = [i for i in range(args.part3)]
        random.shuffle(p2)
        random.shuffle(p3)

        global_img_feat, global_text_feat, local_img_query, local_img_value, local_text_key, local_text_value = network(images, tokens, segments, input_masks, sep_tokens, sep_segments, sep_input_masks, n_sep, p2, p3,  stage='train')

        cmpm_loss, cmpc_loss, cont_loss, loss, image_precision, text_precision, pos_avg_sim, neg_arg_sim, local_pos_avg_sim, local_neg_avg_sim = compute_loss(
            global_img_feat, global_text_feat, local_img_query, local_img_value, local_text_key, local_text_value, caption_length, labels)

        if step % 10 == 0:
            print('epoch:{}, step:{}, cmpm_loss:{:.3f}, cmpc_loss:{:.3f}, cont_loss:{:.3f}, pos_sim_avg:{:.3f}, neg_sim_avg:{:.3f}, lpos_sim_avg:{:.3f}, lneg_sim_avg:{:.3f}'.
                  format(epoch, step, cmpm_loss, cmpc_loss, cont_loss, pos_avg_sim, neg_arg_sim, local_pos_avg_sim, local_neg_avg_sim))
        # constrain embedding with the same id at the end of one epoch
        if (args.constraints_images or args.constraints_text) and step == len(train_loader) - 1:
            con_images, con_text = constraints_loss(train_loader, network, args)
            loss += (con_images + con_text)

            print('epoch:{}, step:{}, con_images:{:.3f}, con_text:{:.3f}'.format(epoch, step, con_images.item(), con_text.item()))

        # compute gradient and do ADAM step
        optimizer.zero_grad()
        loss.backward()
        optimizer.step()

        # measure elapsed time
        batch_time.update(time.time() - end)
        end = time.time()

        train_loss.update(loss.item(), images.shape[0])
        image_pre.update(image_precision, images.shape[0])
        text_pre.update(text_precision, images.shape[0])
    return train_loss.avg, batch_time.avg, image_pre.avg, text_pre.avg

def main(args):

    # transform
    normalize = transforms.Normalize(mean=[0.485, 0.456, 0.406],
                                     std=[0.229, 0.224, 0.225])

    train_transform = transforms.Compose([
        transforms.RandomHorizontalFlip(),
        transforms.ToTensor(),
        normalize,
    ])
    val_transform = transforms.Compose([
        transforms.ToTensor(),
        normalize
    ])

    test_transform = transforms.Compose([
        transforms.ToTensor(),
        normalize
    ])

    cap_transform = None

    # data
    train_loader = data_config(args.image_dir, args.anno_dir, args.batch_size, 'train', 100, train_transform, cap_transform=cap_transform)

    test_loader = data_config(args.image_dir, args.anno_dir, 64, 'test', 100, test_transform)
    unique_image = get_image_unique(args.image_dir, args.anno_dir, 64, 'test', 100, test_transform)

    # loss
    compute_loss = Loss(args)
    nn.DataParallel(compute_loss).cuda()

    # network
    network, optimizer = network_config(args, 'train', compute_loss.parameters(), args.resume, args.model_path)

    # lr_scheduler
    scheduler = WarmupMultiStepLR(optimizer, (20, 25, 35), 0.1, 0.01, 10, 'linear')

    ac_t2i_top1_best = 0.0
    best_epoch = 0
    for epoch in range(args.num_epoches - args.start_epoch):
        network.train()
        # train for one epoch
        train_loss, train_time, image_precision, text_precision = train(args.start_epoch + epoch, train_loader, network, optimizer, compute_loss, args)

        # evaluate on validation set
        is_best = False
        print('Train done for epoch-{}'.format(args.start_epoch + epoch))

        logging.info('Epoch:  [{}|{}], train_time: {:.3f}, train_loss: {:.3f}'.format(args.start_epoch + epoch, args.num_epoches, train_time, train_loss))
        logging.info('image_precision: {:.3f}, text_precision: {:.3f}'.format(image_precision, text_precision))
        scheduler.step()
        for param in optimizer.param_groups:
            print('lr:{}'.format(param['lr']))

        if epoch >= 0:
            ac_top1_i2t, ac_top5_i2t, ac_top10_i2t, ac_top1_t2i, ac_top5_t2i , ac_top10_t2i, test_time = test(test_loader, network, args, unique_image)

            state = {'network': network.state_dict(), 'optimizer': optimizer.state_dict(), 'W': compute_loss.W, 'epoch': args.start_epoch + epoch}

            if ac_top1_t2i > ac_t2i_top1_best:
                best_epoch = epoch
                ac_t2i_top1_best = ac_top1_t2i
                save_checkpoint(state, epoch, args.checkpoint_dir, is_best)

            logging.info('epoch:{}'.format(epoch))
            logging.info('top1_t2i: {:.3f}, top5_t2i: {:.3f}, top10_t2i: {:.3f}, top1_i2t: {:.3f}, top5_i2t: {:.3f}, top10_i2t: {:.3f}'.format(
            ac_top1_t2i, ac_top5_t2i, ac_top10_t2i, ac_top1_i2t, ac_top5_i2t, ac_top10_i2t))

    logging.info('Best epoch:{}'.format(best_epoch))
    logging.info('Train done')
    logging.info(args.checkpoint_dir)
    logging.info(args.log_dir)

if __name__ == "__main__":
    args = config()
    main(args)


#-------------------------------------------------------------------------
# Copyright (c) Microsoft Corporation.  All rights reserved.
# Licensed under the MIT License.
#--------------------------------------------------------------------------
import numpy as np
from logging import getLogger
from onnx import helper, numpy_helper, TensorProto
from onnx_model import OnnxModel
from fusion_base import Fusion
from fusion_utils import FusionUtils

logger = getLogger(__name__)

class FusionGptAttention(Fusion):
    """
    Fuse GPT-2 Attention with past state subgraph into one Attention node.
    This does not support attention_mask graph input right now.
    """
    def __init__(self, model: OnnxModel, num_heads: int):
        super().__init__(model, "Attention", "LayerNormalization", "with past")
        self.num_heads = num_heads
        self.utils = FusionUtils(model)
        self.casted_attention_mask = {}  # map from name of attention mask to the name that casted to int32

    def create_attention_node(self, gemm, gemm_qkv, past, present, input, output, mask=''):
        attention_node_name = self.model.create_node_name('GptAttention')
        attention_node = helper.make_node('Attention',
                                          inputs=[input, gemm.input[1], gemm.input[2], mask, past],
                                          outputs=[attention_node_name + "_output", present],
                                          name=attention_node_name)
        attention_node.domain = "com.microsoft"
        attention_node.attribute.extend(
            [helper.make_attribute("num_heads", self.num_heads),
             helper.make_attribute("unidirectional", 1)])

        matmul_node = helper.make_node('MatMul',
                                       inputs=[attention_node_name + "_output", gemm_qkv.input[1]],
                                       outputs=[attention_node_name + "_matmul_output"],
                                       name=attention_node_name + "_matmul")

        add_node = helper.make_node('Add',
                                    inputs=[attention_node_name + "_matmul_output", gemm_qkv.input[2]],
                                    outputs=[output],
                                    name=attention_node_name + "_add")
        self.nodes_to_add.extend([attention_node, matmul_node, add_node])

    def fuse(self, normalize_node, input_name_to_nodes, output_name_to_node):
        past = None
        present = None
        return_indice = []
        qkv_nodes = self.model.match_parent_path(
            normalize_node,
            ['Add', 'Reshape', 'Gemm', 'Reshape', 'Reshape', 'Transpose', 'MatMul'],
            [0,      None,      0,     0,          0,         0,           0],
            output_name_to_node=output_name_to_node,
            return_indice=return_indice
            ) # yapf: disable
        if qkv_nodes is None:
            return
        (add_qkv, reshape_qkv, gemm_qkv, reshape_1, reshape_2, transpose_qkv, matmul_qkv) = qkv_nodes

        another_input = add_qkv.input[1 - return_indice[0]]

        v_nodes = self.model.match_parent_path(
            matmul_qkv,
            ['Concat', 'Transpose', 'Reshape', 'Split', 'Reshape', 'Gemm', 'Reshape'],
            [1,        1,            0,         0,       0,         0,      0]) # yapf: disable
        if v_nodes is None:
            logger.debug("fuse_attention: failed to match v path")
            return
        (concat_v, transpose_v, reshape_v, split_v, reshape_after_gemm, gemm, reshape_before_gemm) = v_nodes

        #      concat <-- Gather(indices=1) <-- past
        #        |
        #      unsqueeze
        #        |
        #    concat  -->  present
        gather_v = self.model.get_parent(concat_v, 0, output_name_to_node)
        if gather_v.op_type != 'Gather':
            logger.info("expect Gather for past")
            return
        if not self.model.find_constant_input(gather_v, 1) == 1:
            logger.info("expect indices=1 for Gather of past")
            return
        past = gather_v.input[0]
        if not self.model.find_graph_input(past):
            logger.info("expect past to be graph input")
            return
        unsqueeze_present_v = self.model.find_first_child_by_type(concat_v,
                                                                  'Unsqueeze',
                                                                  input_name_to_nodes,
                                                                  recursive=False)
        if not unsqueeze_present_v:
            logger.info("expect unsqueeze for present")
            return
        concat_present = self.model.find_first_child_by_type(unsqueeze_present_v,
                                                             'Concat',
                                                             input_name_to_nodes,
                                                             recursive=False)
        if not concat_present:
            logger.info("expect concat for present")
            return
        present = concat_present.output[0]
        if not self.model.find_graph_output(present):
            logger.info("expect present to be graph input")
            return

        layernorm_before_attention = self.model.get_parent(reshape_before_gemm, 0, output_name_to_node)
        if layernorm_before_attention is None or layernorm_before_attention.op_type != 'LayerNormalization':
            logger.debug(f"failed to get layernorm before gemm. Got {layernorm_before_attention.op_type}")
            return

        if not another_input in layernorm_before_attention.input:
            logger.debug("Add and LayerNormalization shall have one same input")
            return

        input_mask_nodes = None
        qk_nodes = self.model.match_parent_path(matmul_qkv, ['Softmax', 'Sub', 'Mul', 'Div', 'MatMul'], [0, 0, 0, 0, 0])
        if qk_nodes is not None:
            (softmax_qk, sub_qk, mul_qk, div_qk, matmul_qk) = qk_nodes
            mask_nodes = self.model.match_parent_path(
                sub_qk,
                ['Mul', 'Sub', 'Slice', 'Slice', 'Unsqueeze', 'Sub', 'Squeeze', 'Slice', 'Shape', 'Div'],
                [1,      0,     1,       0,       1,           0,     0,         0,       0,       0])  # yapf: disable
            if mask_nodes is None:
                logger.debug("fuse_attention: failed to match unidirectional mask path")
                return
            div_mask = mask_nodes[-1]

            if div_qk != div_mask:
                logger.debug("fuse_attention: skip since div_qk != div_mask")
                return
        else:
            # New pattern for gpt2 from PyTorch 1.5.0 and Transformers 2.9.0.
            i, qk_nodes, _ = self.model.match_parent_paths(
                matmul_qkv, [(['Softmax', 'Where', 'Div', 'MatMul'], [0, 0, 1, 0]),
                             (['Softmax', 'Add', 'Where', 'Div', 'MatMul'], [0, 0, 0, 1, 0])], output_name_to_node)
            if qk_nodes is None:
                logger.debug("fuse_attention: failed to match qk nodes")
                return

            where_qk = qk_nodes[-3]
            div_qk = qk_nodes[-2]
            matmul_qk = qk_nodes[-1]

            if i == 1:
                add_qk = qk_nodes[1]
                _, input_mask_nodes, _ = self.model.match_parent_paths(
                    add_qk, [(['Mul', 'Sub', 'Cast', 'Unsqueeze', 'Unsqueeze', 'Reshape'], [1, 0, 1, 0, 0, 0]),
                             (['Mul', 'Sub', 'Unsqueeze', 'Unsqueeze', 'Reshape'], [1, 0, 1, 0, 0])],
                    output_name_to_node)
                if input_mask_nodes is None:
                    logger.debug("fuse_attention: failed to match input attention mask path")
                    return

            mask_nodes = self.model.match_parent_path(
                where_qk,
                ['Cast', 'Slice', 'Slice', 'Unsqueeze', 'Sub', 'Squeeze', 'Slice', 'Shape', 'Div'],
                [ 0,     0,       0,       1,           0,     0,         0,       0,       0])  # yapf: disable
            if mask_nodes is None:
                logger.debug("fuse_attention: failed to match mask path")
                return
            div_mask = mask_nodes[-1]

            if div_qk != div_mask:
                logger.debug("fuse_attention: skip since div_qk != div_mask")
                return

        q_nodes = self.model.match_parent_path(matmul_qk, ['Transpose', 'Reshape', 'Split'], [0, 0, 0])
        if q_nodes is None:
            logger.debug("fuse_attention: failed to match q path")
            return
        (transpose_q, reshape_q, split_q) = q_nodes
        if split_v != split_q:
            logger.debug("fuse_attention: skip since split_v != split_q")
            return

        k_nodes = self.model.match_parent_path(matmul_qk, ['Concat', 'Transpose', 'Reshape', 'Split'], [1, 1, 0, 0])
        if k_nodes is None:
            logger.debug("fuse_attention: failed to match k path")
            return
        (concat_k, transpose_k, reshape_k, split_k) = k_nodes
        if split_v != split_k:
            logger.debug("fuse_attention: skip since split_v != split_k")
            return

        #     concat_k <-- Transpose (perm=0,1,3,2) <-- Gather(axes=0, indices=0) <-- past
        #        |
        #     Transpose (perm=0,1,3,2)
        #        |
        #      unsqueeze
        #        |
        #    concat  -->  present
        past_k_nodes = self.model.match_parent_path(concat_k, ['Transpose', 'Gather'], [0, 0])
        if past_k_nodes is None:
            logger.debug("fuse_attention: failed to match past_k_nodes path")
            return

        gather_past_k = past_k_nodes[-1]
        if not self.model.find_constant_input(gather_past_k, 0) == 1:
            logger.info("expect indices=0 for Gather k of past")
            return
        past_k = gather_past_k.input[0]
        if past != past_k:
            logger.info("expect past to be same")
            return

        attention_mask_input_name = ''
        if input_mask_nodes is not None:
            input_name = input_mask_nodes[-1].input[0]
            if input_name in self.casted_attention_mask:
                attention_mask_input_name = self.casted_attention_mask[input_name]
            elif self.model.find_graph_input(input_name):
                casted, attention_mask_input_name = self.utils.cast_graph_input_to_int32(input_name)
                self.casted_attention_mask[input_name] = attention_mask_input_name
            else:
                attention_mask_input_name, cast_node = self.utils.cast_input_to_int32(input_name)
                self.casted_attention_mask[input_name] = attention_mask_input_name

        self.create_attention_node(gemm, gemm_qkv, past, present, layernorm_before_attention.output[0],
                                   reshape_qkv.output[0], attention_mask_input_name)

        # we rely on prune_graph() to clean old subgraph nodes:
        # qk_nodes + q_nodes + k_nodes + v_nodes + mask_nodes + [reshape_qkv, transpose_qkv, matmul_qkv]
        self.prune_graph = True


"""
Really bad video player made to play on the terminal.
Can also print images.

"""

from PIL import Image, UnidentifiedImageError
import numpy as np
from tqdm import tqdm
import time

from pynput import keyboard

import asyncio
import aiofiles
import argparse
import mmap
import threading
import concurrent.futures

import cv2
import filetype
import sys

class TerminalPlayer:
    def __init__(self, filename, mode, char, console, fps_cap):
        self.filename = filename
        self.filetype = filetype.guess(self.filename)
        self.console = console  # if True, opens a new console to show the gif
        self.fps_cap = fps_cap # if False, disables fps caps and plays as fast as it can
        self.char_array = [" ", ".", "-", "*", "/", "=", "#"]
        self.char = char
        self.supported_filetypes = ["gif", "mp4"]
        self.modes = ["ascii", "color", "color216"]
        assert mode in self.modes
        self.mode = mode  # either ascii-characters or colored
        self.image_frames_array = []
        self.screen_array = (
            []
        )  # dimensions are width by height so width * height elements

    def open_pillow_image(self, gif_object):
        """
        maybe here the mmaps should already be used to read and write faster
        TODO: idk, test this stupid idea later, maybe it is faster
        """
        self.is_animated = getattr(gif_object, "is_animated", False)
        self.width = gif_object.width
        self.height = gif_object.height
        self.frame_count = int(getattr(gif_object, "n_frames", 1))
        self.duration = gif_object.info.get("duration", 41.6) / 1000  # default to 24fps
        self.sound = False

        for frame in range(self.frame_count):
            gif_object.seek(frame)
            if self.mode == "ascii":
                frame = gif_object.convert("L").resize(self.frame_size).getdata()
                frame = np.reshape(frame, (*self.frame_size, 1))
            elif self.mode.startswith("color"):
                frame = gif_object.convert("RGB").resize(self.frame_size).getdata()
                frame = np.reshape(frame, (*self.frame_size, 3))
            self.image_frames_array.append(frame)

    def open_opencv_image(self, cap):
        """
        Opens the video using opencv-python
        """
        fps = cap.get(cv2.CAP_PROP_FPS)
        self.duration = 1 / fps
        self.width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
        self.height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
        self.frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        self.is_animated = True
        while cap.isOpened():
            ret, frame = cap.read()
            if not ret:
                break
            frame = cv2.resize(frame, self.frame_size)
            if self.mode == "ascii":
                frame = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
                frame = np.reshape(frame, (*self.frame_size, 1))
            elif self.mode.startswith("color"):
                frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
                frame = np.reshape(frame, (*self.frame_size, 3))
            self.image_frames_array.append(frame)
        cap.release()

    def handle_file_types(self):
        """
        handles multiple filetype
        sets the following aspect for the self object:

        image_frames_array -> holds the arrays of the pixel values
                              each frame is a numpy array
        frame_count -> number of frames in video
        filetype -> filetype
        width
        height
        duration -> time to sleep between frames
        TODO: play sound lol
        """
        try:
            self.open_pillow_image(Image.open(self.filename)) # tries to open with pillow
        except UnidentifiedImageError:
            cap = cv2.VideoCapture(self.filename) # if didnt open, try it with cv2
            if cap.isOpened():
                self.open_opencv_image(cap)
            else:
                print("Sua imagem/video não pôde ser aberta.")
                raise TypeError # TODO: create error type, this is just a place-holder

    def create_terminal_window(self):
        """
        creates the console screen buffer that can be printed to
        this only works on windows now, but should be easier to implement on unix cuz windows KEKW
        also, only worked for fixed console widths and heights, I still wasnt able to change it to
        arbitrary size, so all the gifs will be stretched to this ratio for now

        TODO: change the fucking window size
        """
        if sys.platform == "win32":
            import pywintypes
            import win32gui, win32ui, win32api, win32console

            if self.console:
                win32console.FreeConsole()  # detaches the process from the one that was used
                win32console.AllocConsole()  # and creates a new one
            self.window = (
                win32console.GetConsoleWindow()
            )  # gets the new window, just for grabs, not actually being used
            self.console_handle = win32console.CreateConsoleScreenBuffer()
            console_mode = self.console_handle.GetConsoleMode()
            console_info = self.console_handle.GetConsoleScreenBufferInfo()
            self.console_handle.SetConsoleMode(
                int(console_mode | 0x004)
            )  # sets the Enable Virtual Terminal Console Mode
            self.frame_size = (console_info["Size"].X, console_info["Size"].Y)

            """
        elif sys.platform.startswith('linux'):
            import curses

            screen_curses_object = curses.initscr()
            self.frame_size =  self.screen_curses_object.getmaxyx()[:-1]
            screen_curses_object.endwin()
            """

    def map_video_buffer_to_threads(self):
        """
        maps the video frames to the processing threads, to generate the frames
        """
        with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
            self.screen_array = list(
                tqdm(
                    executor.map(
                        self.create_gif_buffer,
                        range(self.frame_count),
                        self.image_frames_array,
                    ),
                    total=self.frame_count,
                )
            )

    def create_gif_buffer(self, frame_index, pixel_array):
        """
        creates the array that hold the actual pixel values (the colors of the chars)
        this function only need the pixel_data, so that it is not dependent to any format
        as long as it is properly formatted (correct width and height) it will work
        """
        screen_array = ""
        if self.mode == "color":
            last_pixel = 0, 0, 0
        elif self.mode == "color216":
            last_pixel = 0

        descriptor = len(f"{self.filename} -- 000 -- fps: 000")  # descriptor of the gif
        for line_index, line in enumerate(pixel_array):
            if line_index == 0 and self.is_animated:
                # write descriptor at first line
                screen_array += (
                    f"\033[7m{self.filename} -- {frame_index:03d} -- fps: 000\033[0m"
                )
                line = line[descriptor:]

            for pixel in line:
                if self.mode == "color":

                    r, g, b = pixel
                    if np.any(last_pixel != pixel):
                        pixel_string = f"\033[48;2;{r};{g};{b}m"  # color the background with the coloring
                        screen_array += pixel_string
                        last_pixel = pixel
                    screen_array += self.char

                elif self.mode == "color216":
                    r, g, b = pixel
                    r_index = int(6 * r / 256)
                    g_index = int(6 * g / 256)
                    b_index = int(6 * b / 256)
                    number_code = 16 + (r_index * 36 + g_index * 6 + b_index)
                    if last_pixel != number_code:
                        pixel_string = f"\033[48;5;{number_code}m"
                        screen_array += pixel_string
                        last_pixel = number_code
                    screen_array += self.char

                elif self.mode == "ascii":
                    pixel = pixel[0]
                    char = self.char_array[int(len(self.char_array) * pixel / 256)]
                    screen_array += char
            #screen_array += "\n"
        screen_array += "\033[H"
        return screen_array

    def create_frame_bytes(self):
        """
        uses mmaps to read/write faster
        @o_santi -> actually i wanted to create a virtual memory mapping of the stdout, so that the maximum read/write speed could be reached
        but i think this would put me in the gray area of the programmers because it seems like a really shitty idea
        either way, I will find a way to do this >:(
        """
        self.frames_bytes = []
        for index, string in enumerate(self.screen_array):
            # bytes_array.write(bytes(self.screen_array[index], encoding='utf-8'))
            mmap_buffer = mmap.mmap(
                -1,
                length=len(bytes(string, encoding="utf-8")),
                access=mmap.ACCESS_WRITE,
            )  # create the map
            mmap_buffer[:] = bytes(string, encoding="utf-8")  # write the frame to it
            mmap_buffer.flush()  # flush it so that it is written to memory
            self.frames_bytes.append(mmap_buffer)

    async def blit_screen(self, frame_index):
        """
        prints the index-th frame of the screen_array to the screen
        """
        t1 = time.perf_counter_ns()
        frame_mmap = self.frames_bytes[frame_index]  # get current frame
        frame_mmap.seek(0)  # seek to the start of the frame
        await self.file_object.write(frame_mmap.read())  # print
        if (delta := (time.perf_counter_ns() - t1) / 10 ** 9) < self.duration and self.fps_cap:
            await asyncio.sleep(
                self.duration - delta
            )
            delta += self.duration - delta
            # make sure that it only sleeps when it actually is printing faster than it should
            # >>>perf_counter_ns is post python 3.7<<<
        if self.is_animated:
            fps = f"{int(1/delta):03d}"
            self.frames_bytes[(frame_index + 1) % self.frame_count][
                self.descriptor_len : self.descriptor_len + 3
            ] = bytes(
                fps, encoding="utf-8"
            )  # write fps to position on the next frame

    async def play_animated(self):

        def on_key_press_stop(key):
            if key == keyboard.Key.esc:
                self.is_playing = False
                listener.stop()

        async with aiofiles.open("CONOUT$", "wb") as self.file_object:
            self.descriptor_len = len(f"{self.filename} -- 000 -- fps: 000") + 1
            # for some reason, the actual file-position is one char further than the actual len
            # maybe because of all the ansi-code that is being written, honestly idk
            if self.is_animated:
                listener = keyboard.Listener(on_press=on_key_press_stop)
                listener.start()

                frame_index = 0
                self.is_playing = True
                while self.is_playing:
                    await self.blit_screen(frame_index)
                    frame_index += 1
                    frame_index %= self.frame_count
            else:
                await self.blit_screen(0)  # print the first frame so that still images can also be shown
                with keyboard.Listener(on_press=on_key_press_stop) as listener:
                    listener.join() # waits for the key to be pressed so that you can see the image

    async def draw_to_screen_main(self):
        self.create_terminal_window()
        self.handle_file_types()
        self.map_video_buffer_to_threads()
        self.create_frame_bytes()
        self.console_handle.SetConsoleActiveScreenBuffer()
        await self.play_animated()

    def close(self):
        """
        stops everything that needs stopin'
        and closes everything that needs closin'
        """
        [mmap_obj.close() for mmap_obj in self.frames_bytes]
        self.console_handle.Close()
        print("Terminado com sucesso")

    def play(self):
        """
        runs the main loop to draw the gif and show it in the console
        pretty simple
        no biggies
        """
        try:
            asyncio.run(self.draw_to_screen_main())
        except KeyboardInterrupt:
            print(
                "Interrupted by user. The intended way of closing is with the ESC key"
            )
        finally:
            self.close()

def main():
    parser = argparse.ArgumentParser(
        description=__doc__,
        epilog="written by @o_santi, follow me on twitter @o_santi_",
    )
    parser.add_argument(
        "-c",
        "-C",
        "--console",
        help="whether or not a new console is created to show the gif",
        action="store_true",
    )
    parser.add_argument("filename", help="filename to the video or image to be shown")
    parser.add_argument(
        "mode",
        help="'color' for 24-bit colors (best to display images); \
              'color216' for 6-bit coloring (best to play videos);  \
              'ascii' for black and white text (best for aesthetics)",
    )
    parser.add_argument(
        "--char", help="char to print when in colored mode", default=" "
    )
    parser.add_argument(
        "--fps-cap",
        "-f",
        help="whether or not the video's normal fps should be respected. defaults to false",
        action="store_false",
    )

    args = parser.parse_args()
    if args.filename:
        terminal_player = TerminalPlayer(args.filename, args.mode, args.char, args.console, args.fps_cap)
        terminal_player.play()

if __name__ == "__main__":
    main()