import collections
import io
import logging
import os
import pathlib
import threading
import time
import zipfile

import config


class GcodesBuffer:

    READ_CHUNK_SIZE = 64 * 1024
    DEFAULT_BUFFER_SIZE = 16 * 1024 # first 1/4 of it used for possible gcodes resend requests
    READ_EVENT_TIMEOUT = 0.01
    READ_RETRIES = 5
    END_OF_LINE_CHAR = b"\n"

    def __init__(self, filename=None, parent_printer_interface=None, max_send_offset=0, blocking_count=True, keep_file=False):
        self.parent = parent_printer_interface
        if self.parent:
            self.logger = self.parent.logger.getChild(self.__class__.__name__)
        else:
            self.logger = logging.getLogger(self.__class__.__name__)
        if max_send_offset:
            # there are 4 blocks in the buffer with gcodes: already_sent, buffer_block1, buffer_block2, buffer_block3
            self.block_size = max_send_offset
            self.buffer_size = max_send_offset * 4
        else:
            self.buffer_size = self.DEFAULT_BUFFER_SIZE
            self.block_size = self.buffer_size // 4
        self.blocking_count = blocking_count
        self.stop_flag = True
        self.zf = None
        self.f = None
        self.fill_thread = None
        self.count_thread = None
        self.index_offset = 0
        self.lines_counter = 0
        self.lines_in_file = 0
        self.file_size = 0
        self.to_add_to_lines_in_file = 0
        self.remove_file_on_clear = not keep_file
        self.access_lock = threading.Lock()
        self.file_read_lock = threading.Lock()
        self.read_ready = threading.Event()
        self.read_done = threading.Event()
        self.deque = collections.deque(maxlen=self.buffer_size)
        if filename:
            self.open(filename)

    def __getitem__(self, index):
        if index < 0:
            raise ValueError("Negative indexes are not supported")
        elif self.lines_in_file and index >= self.lines_in_file:
            raise IndexError()
        elif index >= self.lines_counter - 2 * self.block_size:
            self.read_ready.set()
        while index >= self.lines_counter:
            self.read_ready.set()
            while not self.read_done.wait(self.READ_EVENT_TIMEOUT):
                if self.stop_flag:
                    return b""
                self.read_ready.set()
            #self.logger.debug("Waiting for new items")
        with self.access_lock:
            return self.deque[index - self.index_offset]

    def __len__(self):
        # while not self.lines_in_file:
        #     if self.stop_flag or (self.parent and self.parent.stop_flag):
        #         #self.logger.debug("Waiting for get request")
        #         break
        #     time.sleep(0.1)
        return self.lines_in_file

    def __bool__(self):
        return bool(self.lines_in_file) and not self.stop_flag

    def __setitem__(self, index, value):
        raise NotImplementedError("Buffer items should not be set from outside of buffer class")

    def __str__(self):
        return f"Dynamically loading codes buffer. Size:{self.file_size}. Lines:{self.lines_counter}. Cursor:{self.lines_counter}"

    def popleft(self):
        raise NotImplementedError

    def pop(self):
        raise NotImplementedError

    def insert(self, index, value):
        with self.access_lock:
            if len(self.deque) == self.buffer_size:
                self.index_offset += 1
            self.lines_counter += 1
            self.deque.insert(index, value)
            if self.lines_in_file:
                self.lines_in_file += 1
            else:
                self.to_add_to_lines_in_file += 1

    def open(self, f):
        start_time = time.monotonic()
        self.logger.info("Start opening gcodes file...")
        try:
            if isinstance(f, (str, bytes, pathlib.Path)):
                self.filepath = f
                f = open(self.filepath, "rb")
            elif isinstance(f, io.IOBase):
                self.filepath = f.name
            else:
                raise TypeError("Unsupported argument type for GcodesBuffer.open(): should be file obj or file path")
            if self.filepath.endswith(".zip"):
                self.zf = zipfile.ZipFile(f)
                # binary mode is the only and default for zipfile, so r is correct
                self.f = self.zf.open(self.zf.namelist()[0], 'r')
            else:
                self.zf = None
                self.f = f
        except (OSError, ValueError, AttributeError):
            if self.parent:
                self.parent.register_error(87, "Temporary file error. Cancelling...", job_fail=True)
        except zipfile.error:
            if self.parent:
                self.parent.register_error(89, "Bad zip file. Cancelling...", job_fail=True)
        else:
            self.logger.info(f"Opening gcodes file had taken:{time.monotonic() - start_time}s")
            self.stop_flag = False
            self.fill_thread = threading.Thread(target=self.fill_buffer_from_file)
            self.fill_thread.start()
            if self.blocking_count:
                self.count_lines_and_size()
            else:
                self.count_thread = threading.Thread(target=self.count_lines_and_size)
                self.count_thread.start()

    def fill_buffer_from_file(self):
        chunk = b""
        deque_rotation = False
        retries = self.READ_RETRIES
        while not self.stop_flag:
            if len(chunk) < self.READ_CHUNK_SIZE * 4:
                with self.file_read_lock:
                    try:
                        chunk += self.f.read(self.READ_CHUNK_SIZE)
                        retries = self.READ_RETRIES
                    except (ValueError, OSError) as e:
                        retries -= 1
                        if retries:
                            continue
                        self.logger.error("Error while reading gcode to buffer: %s" % e)
                        break
                    if not chunk:
                        self.logger.info("Buffer reader had reached the end of file " + self.filepath)
                        break
            lines = chunk.split(self.END_OF_LINE_CHAR, maxsplit=self.block_size)
            if len(lines) > 1:
                chunk = lines[-1]
                del lines[-1]
            number_of_lines = len(lines)
            while not self.read_ready.wait(self.READ_EVENT_TIMEOUT):
                if self.stop_flag:
                    #self.logger.debug("Waiting for get request")
                    return
            with self.access_lock:
                self.deque.extend(lines)
                if deque_rotation:
                    self.index_offset += number_of_lines
                else:
                    index_offset = number_of_lines + self.lines_counter - self.buffer_size
                    if index_offset > 0:
                        self.index_offset += index_offset
                        deque_rotation = True
                self.lines_counter += number_of_lines
                self.read_ready.clear()
                self.read_done.set()

    def count_lines_and_size(self):
        lines_counter = 0
        size_counter = 0
        start_time = time.monotonic()
        self.logger.info("Line counting started...")
        try:
            with self.file_read_lock:
                pos = self.f.tell()
                self.f.seek(0)
                while not self.stop_flag:
                    chunk = self.f.read(self.READ_CHUNK_SIZE)
                    if not chunk:
                        break
                    lines_counter += chunk.count(self.END_OF_LINE_CHAR)
                    size_counter += len(chunk)
                self.f.seek(pos)
        except (OSError, ValueError):
            if self.parent:
                self.parent.register_error(89, "Unable to count lines in file", job_fail=True)
            else:
                self.logger.error("Unable to count lines in file")
            self.clear()
        if not self.stop_flag:
            with self.access_lock:
                self.file_size = size_counter
                self.lines_in_file = lines_counter + self.to_add_to_lines_in_file
                try:
                    if self.parent:
                        self.parent.set_total_gcodes(lines_counter)
                except AttributeError:
                    self.logger.error("Buffer was unable to set total gcodes for sender")
        self.logger.info(f"Buffer had finished counting.\nLines:{lines_counter}\nSize:{size_counter}\n" \
                         f"Duration: {time.monotonic() - start_time}s")

    def clear(self):
        if not self.stop_flag:
            self.stop_flag = True
            if self.filepath:
                self.logger.info("Closing buffer for file " + self.filepath)
                for thread_to_join in (self.fill_thread, self.count_thread):
                    try:
                        thread_to_join.join()
                    except (AttributeError, RuntimeError): # clear could be called before Thread.start()
                        pass
                self.parent = None # gc will be faster without cyclic references
                self.deque.clear()
                for f in (self.f, self.zf):
                    if f and not getattr(f, "closed", False):
                        try:
                            f.close()
                        except OSError:
                            self.logger.error("Error closing and removing gcodes file: " + str(self.filepath))
                if self.remove_file_on_clear:
                    try:
                        if os.path.isfile(self.filepath):
                            os.remove(self.filepath)
                    except OSError:
                        self.logger.warning("Unable to remove file: " + str(self.filepath))
                self.f = None
                self.zf = None
