"""
Copyright 2023, 2024 Jobin Rezai

This file is part of LVNAuth.

LVNAuth is free software: you can redistribute it and/or modify it under the terms of
the GNU General Public License as published by the Free Software Foundation,
either version 3 of the License, or (at your option) any later version.

LVNAuth is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.

You should have received a copy of the GNU General Public License along with
LVNAuth. If not, see <https://www.gnu.org/licenses/>. 
"""

import niquests as requests
import json
from io import BytesIO
from os import stat
from threading import Thread
from enum import Enum, auto
from queue import Queue
from response_code import ServerResponseReceipt, ServerResponseCode
# from loading_window import LoadingWindow



def get_file_size(file_path) -> int:
    return stat(file_path).st_size


class DataType(Enum):
    SIMPLE_MESSAGE = auto()
    ROW_DATA = auto()
    BINARY_INFO = auto()


class ReplyReaderProgressReceipt:
    """
    Used for getting the download percentage from ReplyReader and sending it
    to the GUI via a queue.
    
    The object that gets sent in the queue will be an object instantiated
    from this class.
    """
    def __init__(self, callback_method, percentage: int):
        self.callback_method = callback_method
        self.percentage = percentage


class ExpectedBinary:
    def __init__(self, binary_id, binary_hash, binary_size,
                 starts_at_bytes, ends_at_bytes):
        
        self.binary_id = binary_id
        self.binary_hash = binary_hash
        self.binary_size = binary_size
        
        # Start/End in relation to the *overall* data stream.
        # Same as 'from' bytes and 'to' bytes.
        self.starts_at_bytes = starts_at_bytes
        self.ends_at_bytes = ends_at_bytes


class ReplyReader(Thread):
    
    the_queue = Queue()
    
    def __init__(self,
                 request_uri,
                 callback_method,
                 chunk_size=64,
                 data_json=None):
        """
        
        Arguments:
        
        - request_uri: the download URI
        
        - callback_method: the method to run once the download has completed,
        successfully or not.
        
        - chunk_size: download chunk size in bytes
        
        - data_json: Used if we're sending json data to Flask
        """


        Thread.__init__(self, daemon=True)
        
        
        # Used if we're sending json data to Flask in our request.
        self.data_json = data_json
                   
        ## Create a new loading window for this request.
        #if self.loading_window:
            #self.loading_window =\
                #LoadingWindow(master=loading_window_parent,
                              #loading_text=loading_text, mode="determinate")
            
            ## This list will keep track of the percentage updates that we've 
            ## sent to the GUI so far.
            ## This is done to prevent sending multiple duplicate percentage 
            ## updates to the GUI unneccessarily.
            #self.progress_percentages_sent_to_gui_so_far = []
            
            ## Get the function to send the progress to.
            ## The progress will get sent to the GUI via a queue and then the
            ## call back method that's specified below will get called.
            #self.loading_progress_callback = self.loading_window.set_progress

            
        
        self.request_uri = request_uri
        self.chunk_size = chunk_size
        
        # 12 bytes per size header block
        self.size_header_block_size = 12
        
        # ok- beginning part is 3 bytes
        self.ok_size = 3
        
        # Get the total bytes of the header so we know where the actual data 
        # starts from (after the size headers)
        self.header_size_quantity = 6
        self.total_bytes_including_ok_header = \
            (self.size_header_block_size * self.header_size_quantity) + self.ok_size

        # The method to run once we have a success 
        # or failure message from the server.
        self.callback_method = callback_method
        
        # This is set if the 'ok-' part is not found in the stream.
        self.cancel_download = False
        
        # Initialize
        self.inner_chunks_read_so_far = 0
        self.header_read_size_counter = 0
        
        # Read 'so far' flags
        #self.read_cursor_position = 0
        self.read_ok_header = False
        self.read_entire_bytearray_size = None
        self.read_where_binary_starts = None
        self.read_simple_message_size = None
        self.read_row_data_binary_size = None
        self.read_binary_json_size = None
        self.read_binary_images_size = None
        
        self.has_downloaded_simple_message_binary = False
        self.has_downloaded_postgres_row_binary = False
        self.has_downloaded_binary_info_binary = False
        
        # After the simple message (json to dict) has been downloaded 
        # and converted to a dict, this dict will get populated.
        # This dictionary is passed as an argument in the method: 
        # on_received_download_finished
        self.final_simple_message = {}
        
        # A flag to indiciate whether we've downloaded app pre-image binaries
        # such as: simple message, postgres row data, binary info
        # Once this gets set to True, it means we are now dealing with 
        # downloading binary images, audio, etc. (if even available)
        self.all_before_image_binaries_downloaded = False
        
        # Will contain a list of ExpectedBinary objects used for downloading 
        # image/audio data, which will contain:
        # (binaryID, hash, size)
        # This list will get populated once the binary info (json) has 
        # been downloaded.
        self.binaries_expected = []
        
        # The image/audio binary that is awaiting to be fully downloaded.
        self.current_pending_binary_download = None
        
        """
        Keep track of how many bytes we've downloaded for images/audio files,
        so we know where to start reading from the stream, for the next
        downloaded image/audio binary.
        TODO: Rename variable to something that doesn't have the word image
        in it. with self.total_binary_image_bytes_downloaded,
        we're using this to download and read binary data one by one.
        Perhaps we should rename this variable to something that doesn't have
        the word 'image' in it, because this can be used for any binary data,
        not just images. Also, we still need to test out downloading images
        one by one, and making sure they all download successfully, even if
        the images are very small (ie: smaller than the chunk size).
        """
        self.total_binary_image_bytes_downloaded = 0
        
        
    def get_next_bytes_milestone(self):
        """
        Calculate and return how many bytes need to be downloaded to read the
        next size header of the stream.
        """

        # The ok- part of the download is special, it's only the first 3 
        # characters.
        if not self.read_ok_header:
            return self.ok_size
        else:
            return (self.size_header_block_size * self.header_read_size_counter) + self.ok_size
    
    def get_next_block_size(self, get_simple_message_binary=False,
                            get_postgres_row_data_binary=False,
                            get_binary_data_info=False) -> tuple:
        """
        Get the bytes range of the next block size we need to read.
        
        This function will return a tuple which will represent the
        _from and _to byte ranges that contains the byte ranges needed for
        the caller of this function.
        
        If all the arguments are set to False, then this function will attempt
        to read the next header size, if available.
        
        If one of the arguments has been set, it'll get the byte range that
        needs to be read to get the requested info type.
        It's important to note that this function doesn't check to see whether
        the binary data exists or not, it just attempts to get the byte range
        of the requested data type when it's asked. The caller of this function
        should check to see if the data type exists or not.
        
        Usage examples:
        To get the next block size for a header, just call it without any
        arguments: next_header = self.get_next_block_size()
        To get an actual binary range (once all the headers are downloaded),
        use: simple_binary_data = self.get_next_block_size(get_simple_message_binary=True)
        Only specify one argument, not more than one.
        
        In the second example usage, the existence of binary data should be
        done before calling this function.
        """

        # Has the ok- block been read yet?
        if not self.read_ok_header:
            
            # Return the size of the ok- header, which is 3 bytes.
            return (0, self.ok_size)
        
        # Each size header is 12 bytes, so it's easy to know where each header 
        # size will be.
        # If there are any header sizes that are not read yet, read them here.
        elif not self.header_sizes_downloaded():
        
            ## Make sure the ok- header has been downloaded first (which is why we check > 1 here)
            #if self.header_read_size_counter > 0:
                
            # Count one header back, so we can create the 'from' size.
            one_header_prior = self.header_read_size_counter - 1
            _from, _to = ( self.ok_size + (self.size_header_block_size * one_header_prior), self.ok_size + (self.size_header_block_size * self.header_read_size_counter) )
            
            #print(f"{_from=}, {_to=}")
            
            return _from, _to
            
            #else:
                #print("Does this ever run?")
                #return (self.ok_size, self.ok_size + self.size_header_block_size)
                
        
        # This is where we're getting the byte ranges for the actual 
        # binary data.
        
        # The caller of this function should make sure there is actual simple 
        # binary data before calling this function.
        elif get_simple_message_binary:

            return (self.get_bytes_position_of_data(DataType.SIMPLE_MESSAGE, include_up_to_end=False), self.get_bytes_position_of_data(DataType.SIMPLE_MESSAGE, include_up_to_end=True))
        
        # The caller of this function should make sure there is actual postgres 
        # binary data before calling this function.
        elif get_postgres_row_data_binary:
            
            return (self.get_bytes_position_of_data(DataType.ROW_DATA, include_up_to_end=False), self.get_bytes_position_of_data(DataType.ROW_DATA, include_up_to_end=True))
        
        # The caller of this function should make sure there is actual binary 
        # info before calling this function.
        elif get_binary_data_info:
            
            return (self.get_bytes_position_of_data(DataType.BINARY_INFO, include_up_to_end=False), self.get_bytes_position_of_data(DataType.BINARY_INFO, include_up_to_end=True))        
    
    def get_data_as_int(self, stream) -> int:
        reader = bytearray(stream.getvalue())
        
        _from, _to = self.get_next_block_size()
        
        #print(f"=======From is: {str(_from)} and to is: {str(_to)}")
        
        reader = reader[_from:_to].decode("utf-8")
        reader = reader.rstrip("X")
        
        
        
        #print("So far we have:", reader)
        
        if not reader:
            return 0
        else:
            return int(reader)
        
    def get_data_as_str(self, stream, read_range:tuple=None) -> str:
        reader = bytearray(stream.getvalue())
        
        # If an explicit byte range has been specified, use that.
        # An explicit range will be available only after the headers have 
        # been fully downloaded.
        if read_range:
            #print("Downloaded so far:", self.inner_chunks_read_so_far)
            _from, _to = read_range
            
        else:
            # There's no explicit range, so we should find out which range
            # to read from the function below. We won't have an explicit range
            # when the size headers haven't been fully downloaded yet.
            _from, _to = self.get_next_block_size()
        
        #print(f"=======From is: {str(_from)} and to is: {str(_to)}")
        

        reader = reader[_from:_to].decode("utf-8")        
        reader = reader.rstrip("X")
        
        if not reader:
            return
        else:
            return reader
        

    def download_pre_binary(self, stream):
        """
        Download all data before the actual image/audio data.
        
        Once all the data before the image data has been downloaded,
        then the flag, self.all_before_image_binaries_downloaded, will be
        set to True.
        
        This method will assume the caller of this method has checked to see
        that the prebinary data has not been fully downloded; it doesn't check
        here automatically.
        """

        # If not all the sizes have been read from the stream's header, attempt 
        # to finish that in this method.
        if not self.header_sizes_downloaded():
            self.read_headers(stream)
            
            # We haven't downloaded all the pre-binary image binaries yet.
            self.all_before_image_binaries_downloaded = False
            
            print("First check")
            
            
        
            if self.header_sizes_downloaded():
                print("Inner check")
                # Attempt to read binary data 
                # (simple message, postgres data, binary info) if the data 
                # chunks have been downloaded.
                self.all_before_image_binaries_downloaded =\
                    self.read_binary_data_before_actual_image_data(stream)                


        # We don't have an 'elif' here because we may now have the header sizes 
        # downloaded from the if statement above.
        if self.header_sizes_downloaded():
            
            # All the size headers have been downloaded.
            # Make sure Simple message, Postgres row Data, binary info have all 
            # been downloaded and if they haven't, run the method below,
            # which will check to see if they're ready to be read from 
            # the stream.        
            if False in (self.has_downloaded_simple_message_binary, self.has_downloaded_postgres_row_binary, self.has_downloaded_binary_info_binary):
    
    
                # Attempt to read binary data 
                # (simple message, postgres data, binary info) if the data 
                # chunks have been downloaded.
                self.all_before_image_binaries_downloaded = self.read_binary_data_before_actual_image_data(stream)
                
                
                #print("The three are:", self.has_downloaded_simple_message_binary, self.has_downloaded_postgres_row_binary, self.has_downloaded_binary_info_binary)
        
                #print("Downloaded chunks so far:", self.inner_chunks_read_so_far)
                
                
                print("Check here")
                
            else:
                self.all_before_image_binaries_downloaded = True
                print("Last check")
                
        
    def download_image_data(self, stream:BytesIO):
        """
        At this point, we've downloaded all prebinary data, such as simple
        message, postgres row data, binary info.
        Now we'll download the actual image/audio binary data, if there is any.
        
        This method will assume the caller of this method has checked to see
        that the prebinary data has been downloaded, it doesn't check here
        automatically.
        """
        
        #print(f"{self.read_where_binary_starts=}")
        
        # At this point, we already have all the following info downoaded:
        # - Simple message binary (json)
        # - PostgreSQL Row data (json)
        # - Binary info (json)
        
        # The only thing that's left is the actual binary data (images, audio, etc.)
        
        # There may or may not be binary data.
        # If self.read_where_binary_starts is not None, then that means that we know the bytes position of where image data starts.
        # If self.read_where_binary_starts is 0 (zero), it means there is no image data. If it's > 0, it means there is image data.
        
        
        #print(f"{self.binaries_expected=}")
        
        
        # There is no binary data? Check if we've downloaded the whole data (we should be done here.)
        if self.read_where_binary_starts is not None and self.read_where_binary_starts == 0:
            # There is no binary data.
            
            
            # Has the download finished? (we should be finished)
            if self.inner_chunks_read_so_far >= self.read_entire_bytearray_size:
                self.on_received_download_finished(with_binary_images=False,
                                                   final_simple_message=self.final_simple_message)
        
        
        # There is binary (image) data and we're still downloading it.
        elif self.read_where_binary_starts is not None and self.read_where_binary_starts > 0:
            # Yes, there is binary data and we haven't finished getting all the image binaries.
            
            # Is there an image that's been downloaded? Read the image.
            if self.inner_chunks_read_so_far >= self.current_pending_binary_download.ends_at_bytes:
                self.get_downloaded_binary(stream)
                
            # Has the download finished with binary images/audio? We may or may not be finished.
            if self.inner_chunks_read_so_far >= self.read_entire_bytearray_size:
                self.on_received_download_finished(with_binary_images=True, final_simple_message=self.final_simple_message)            
            
        
            #print("Downloading image data...")
                
    def get_downloaded_binary(self, stream:BytesIO):
        """
        One of the binary data has been fully downloaded (ie: an image or
        audio file).
        
        Put the binary into a variable and send it to the queue so the GUI
        thread can read it.
        """
        
        # Get the stream as a memoryview.
        mview = stream.getbuffer()
        
        # Get bytes data for the single downloaded-binary that we're wanting.
        downloaded_image = mview[self.current_pending_binary_download.starts_at_bytes:self.current_pending_binary_download.ends_at_bytes].tobytes()
        
        # This will be sent to the GUI thread.
        receipt = ServerResponseReceipt(self.callback_method, 
                              ServerResponseCode.SUCCESS, 
                              response_binary_data=downloaded_image, 
                              binary_id=self.current_pending_binary_download.binary_id)
        
 
        
        # Increment the total image/audio bytes that we've downloaded so far, 
        # so that we know where to start reading from the stream
        # for the next binary image/audio that gets downloaded from the stream, 
        # in case there's more to be downloaded.
        self.total_binary_image_bytes_downloaded += self.current_pending_binary_download.binary_size


        # TODO: Invoke finished-download on a separate method
        #  Once an image gets downloaded, it should invoke a separate 
        # 'on_received_binary_file' method.
        # We've now downloaded this binary, so get the info for the next 
        # expected binary (if any).
        try:
            self.current_pending_binary_download = self.binaries_expected.pop(0)
        except IndexError:
            # There are no more expected binaries.
            self.current_pending_binary_download = None
        
        # Send the result to the GUI thread using a queue.
        self.the_queue.put(receipt)     
        
    def analyze_chunk_so_far(self, stream:BytesIO):
        
        #self.inner_chunks_read_so_far += chunk_counter
        
        #print(f"{self.read_entire_bytearray_size=}, {self.read_where_binary_starts=}, {self.read_simple_message_size=}, {self.read_row_data_binary_size=}, {self.read_binary_json_size=}, {self.read_binary_images_size=}")
        
        
        
        #print("All before binaries downloaded:", self.all_before_image_binaries_downloaded)
        
        
        
        # Have we downloaded everything prior to the image/audio binary data?
        if not self.all_before_image_binaries_downloaded:
            self.download_pre_binary(stream)

        #else:

            #self.download_image_data(stream)
            
        if self.all_before_image_binaries_downloaded:
            # We've downloaded everything prior to the image/audio data.
            # There may not even be any image/audio data to download, but the
            # method below will check this.            
            self.download_image_data(stream)        

    def get_bytes_position_of_data(self, datatype_requested:DataType,
                                   include_up_to_end=True) -> int:
        """
        Return the starting or ending byte position of the given binary
        data type.
        
        This is not used to get the position of header sizes. It's used to get
        the starting or ending position of actual binary data,
        such as:
         - Simple message json binary starting or ending position
         - PostgreSQL row data binary starting or ending position
         - Binary info json data binary starting or ending position
         
        This function is used when we're about to read any of the above data
        types.
         
        But this is not used to get the starting/ending point of where image
        binary data starts, because that info is already available in its
        own header.
        
        Arguments:
        
        - datatype_requested: the part of the binary stream that we want to
        find the starting or ending position for.
        
        - include_up_to_end: if this is True, it will include up to the end of
        the given data type.
        For example: if datatype_requested is DataType.SIMPLE_MESSAGE, then it
        will return the byte position of the end of the json binary simple
        message data in the stream
        (length of header + size of simple message binary).
        
        Continuing the example above, if include_up_to_end is False, it will
        return the 'starting' point of the simple message binary data
        (length of header, which is right before where the
        simple message binary starts).
        """
        
        calc_size = 0
        
        if datatype_requested == DataType.SIMPLE_MESSAGE:
            if include_up_to_end:
                calc_size = self.total_bytes_including_ok_header + self.read_simple_message_size
            else:
                calc_size = self.total_bytes_including_ok_header
                
        
        elif datatype_requested == DataType.ROW_DATA:
            if include_up_to_end:
                calc_size = self.total_bytes_including_ok_header + self.read_simple_message_size + self.read_row_data_binary_size
            else:
                calc_size = self.total_bytes_including_ok_header + self.read_simple_message_size
                
            
        elif datatype_requested == DataType.BINARY_INFO:
            if include_up_to_end:
                calc_size = self.total_bytes_including_ok_header + self.read_simple_message_size + self.read_row_data_binary_size + self.read_binary_json_size
            else:
                calc_size = self.total_bytes_including_ok_header + self.read_simple_message_size + self.read_row_data_binary_size
                

        return calc_size
        
    def read_binary_data_before_actual_image_data(self, stream) -> bool:
        """
        This method will attempt to read the following:
            - Simple message binary (json)
            - PostgreSQL Row data (json)
            - Binary info (json)
            
        Those are the binary data before images have actually started to be
        downloaded.
        
        This function will return True if all the binary data before the image
        have been read.
        Otherwise, it will return False if there's one or more binary data
        still remaining.
        The only binaries this function deals with are:
            - Simple message binary (json)
            - PostgreSQL Row data (json)
            - Binary info (json)
        """

        #print("Simple message size is: ", self.read_simple_message_size, "inner chunks:", self.inner_chunks_read_so_far, "bytes position is:", self.get_bytes_position_of_data(DataType.SIMPLE_MESSAGE))


        # Have we downloaded the simple message binary?
        if not self.has_downloaded_simple_message_binary and self.read_simple_message_size > 0 and self.inner_chunks_read_so_far >= self.get_bytes_position_of_data(DataType.SIMPLE_MESSAGE):
            
            # Read the simple message binary
            self.get_json_to_dict_from_binary(stream, self.on_received_simple_message_dict, get_simple_message_binary=True)

            self.has_downloaded_simple_message_binary = True

        # No simple message data? Consider it downloaded.
        elif self.read_simple_message_size is None or self.read_simple_message_size == 0:
            self.has_downloaded_simple_message_binary = True



            
        # Have we finished downloading the postgres row data?    
        if not self.has_downloaded_postgres_row_binary and self.read_row_data_binary_size > 0 and self.inner_chunks_read_so_far >= self.get_bytes_position_of_data(DataType.ROW_DATA):
            # Read the postgres row binary data
            self.get_json_to_dict_from_binary(stream, self.on_received_postgres_data_dict, get_postgres_row_data_binary=True)
            
            self.has_downloaded_postgres_row_binary = True        
    
        # No row data available at all? Consider it downloaded.
        elif self.read_row_data_binary_size is None or self.read_row_data_binary_size == 0:
            self.has_downloaded_postgres_row_binary = True        
        

                
                    
        # Have we finished downloading the binary info data?
        if not self.has_downloaded_binary_info_binary and self.read_binary_json_size > 0 and self.inner_chunks_read_so_far >= self.get_bytes_position_of_data(DataType.BINARY_INFO):
            # Read the binary info data (has info about the images, audio, etc.)
            self.get_json_to_dict_from_binary(stream, self.on_received_binary_info_dict, get_binary_data_info=True)

            self.has_downloaded_binary_info_binary = True
                
        # No binary info data available at all? Consider it downloaded.
        elif self.read_binary_json_size is None or self.read_binary_json_size == 0:
            self.has_downloaded_binary_info_binary = True           

            

            
            

        # Return True if all 3 binaries have been downloaded (the caller of 
        # this function will need it for the next step)
        return (self.has_downloaded_simple_message_binary and self.has_downloaded_postgres_row_binary and self.has_downloaded_binary_info_binary)

    def get_json_to_dict_from_binary(self, stream, call_back_function, get_simple_message_binary=False, get_postgres_row_data_binary=False, get_binary_data_info=False):
        """
        Get the binary data and read it as a Python dictionary. This method
        doesn't return anything.
        
        This is used for reading:
         - Simple Message binary json
         - Row data binary json
         - Binary info json
        
        Before calling this method, the caller must be sure that the data type
        is available and already downloaded.
        This method just reads it without checking for its existence.
        """
        
        # Get the byte range that we want to read.
        tuple_from_to_range = self.get_next_block_size(get_simple_message_binary=get_simple_message_binary, get_postgres_row_data_binary=get_postgres_row_data_binary, get_binary_data_info=get_binary_data_info)
        
        # Make sure the tuple is not (0,0)
        if tuple_from_to_range[0] > 0 and tuple_from_to_range[1] > 0:
            
            
            # Get the binary data as a string, because the data is a json string.
            read_data = self.get_data_as_str(stream, read_range=tuple_from_to_range)
            if read_data:
                
                # Convert the json string to a Python dictionary.
                read_data = json.loads(read_data)
                
                
                
                
                # Invoke the callback method.
                call_back_function(read_data)
                

            

        
    def read_headers(self, stream: BytesIO):
        """
        Attempt to read the size headers from the stream based on what's been
        downloaded so far.
        """
        
        
        #reader1 = self.get_data_as_str(stream)
        #print("-------->>>> Decoded as:", reader1, "and we've downloaded", self.inner_chunks_read_so_far)        
        
        # ok- part
        if not self.read_ok_header and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            # Read the first 3 bytes.
            #reader = bytearray(stream.getvalue())
            #reader = reader[0:self.get_next_block_size()].decode()
            reader = self.get_data_as_str(stream)
            
            
            print("--------Decoded as:", reader, "and we've downloaded", self.inner_chunks_read_so_far)
            if reader == "ok-":
                
                
                self.read_ok_header = True
                
                self.on_ok_header_received()
                
                self.header_read_size_counter += 1
                
                
            else:
                self.on_ok_header_not_received(non_header_data=reader, stream=stream)
                
            
            
        # Make sure we've already received read the ok- header before attempting to
        # read the header sizes below.
        if not self.read_ok_header:
            return
        
        
        # Read the length of the entire array
        if self.read_entire_bytearray_size is None and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            
            # Get the size as an int
            reader = self.get_data_as_int(stream)

            #print("Read entire bytearray size as:", str(reader))
            self.read_entire_bytearray_size = reader
            self.on_received_entire_size()
        
            self.header_read_size_counter += 1
            
        
        # Read where the binary data starts (images, audio, etc.)
        if self.read_where_binary_starts is None and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            # Get the size as an int
            reader = self.get_data_as_int(stream)
            
            print("Read binary starts at:", str(reader))
            self.read_where_binary_starts = reader
            self.on_received_where_binary_starts()
        
            self.header_read_size_counter += 1          
            
        
        # Read where the simple message json data starts
        if self.read_simple_message_size is None and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            # Get the size as an int
            reader = self.get_data_as_int(stream)            

            #print("Read simple message length:", str(reader))
            self.read_simple_message_size = reader
            self.on_received_simple_message_size()
        
            self.header_read_size_counter += 1             
            

        # Read where length of the binary row data (PostgreSQL data in binary format)
        if self.read_row_data_binary_size is None and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            # Get the size as an int
            reader = self.get_data_as_int(stream)
        
            #print("Read binary data length:", str(reader))
            self.read_row_data_binary_size = reader
            self.on_received_postgres_data_size()
        
            self.header_read_size_counter += 1  
            
            
        # Read where length of the binary json data (which has the hash info / size of the images, etc.)
        if self.read_binary_json_size is None and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            # Get the size as an int
            reader = self.get_data_as_int(stream)
        
            #print("Read json data length:", str(reader))
            self.read_binary_json_size = reader
            self.on_received_binary_info_size()
        
            self.header_read_size_counter += 1  

            
        # Read the length of the actual binary data (images, audio, etc.)
        if self.read_binary_images_size is None and self.inner_chunks_read_so_far >= self.get_next_bytes_milestone():
            # Get the size as an int
            reader = self.get_data_as_int(stream)
        
            #print("Read binary data:", str(reader))
            self.read_binary_images_size = reader
            self.on_received_binary_images_size()
        
            self.header_read_size_counter += 1          
        
            
    def header_sizes_downloaded(self) -> bool:
        """
        Return whether all the header sizes have been downloaded from the
        stream or not.
        
        This refers to all the 12 byte header sizes, not the actual binary
        data, such as images.
        """
        #print("----")
        #print(f"{self.header_read_size_counter=}")
        #print(f"{self.read_entire_bytearray_size=}\n{self.read_where_binary_starts=}\n{self.read_simple_message_size=}\n{self.read_row_data_binary_size=}\n{self.read_binary_json_size=}\n{self.read_binary_images_size=}")
        #print("----")
        
        if None in (self.read_entire_bytearray_size, self.read_where_binary_starts, self.read_simple_message_size, self.read_row_data_binary_size, self.read_binary_json_size, self.read_binary_images_size):
            return False
        else:
            return True

    
    def on_received_download_finished(self, with_binary_images:bool, final_simple_message:dict):
        #pass
        print("The entire download has been finished.")
        
        
        #self.loading_window.destroy()
        
        # Get the response code as a string, and default to UKNOWN
        message_response = final_simple_message.get("ResponseCode", "UNKNOWN")
        
        # Get the text message from the reply, if any.
        message_text = final_simple_message.get("ResponseText")
        
        # Convert the string response code to an enum
        response_code = ServerResponseCode.to_enum(message_response)
        
        # ServerResponseReceipt will have both the response code and the message text, if any.
        msg_to_gui = ServerResponseReceipt(self.callback_method, response_code, message_text)
        
        # Send the message to the GUI
        self.the_queue.put(msg_to_gui)      
        
    def on_received_binary_images_size(self):
        pass
        #print("The size of the images and audio, etc. are", self.read_binary_images_size, "bytes")
        
    def on_received_postgres_data_size(self):
        pass
        #print("Postgres binary data size is:", self.read_row_data_binary_size, "bytes")
            
    def on_received_simple_message_size(self):
        pass
        #print("The simple message binary data length (in bytes) is:", self.read_simple_message_size)
            
    def on_received_where_binary_starts(self):
        pass
        #print("The binary data starts at:", self.read_where_binary_starts)
            
    def on_received_entire_size(self):
        #pass
        print("The entire byte array size has been downloaded.", self.read_entire_bytearray_size)
        
    def on_received_binary_info_size(self):
        #pass
        print("The binary info data length (in bytes) is", self.read_binary_json_size)
        
    def on_received_binary_info_dict(self, binary_info:dict):
        """
        The info for all the images/audio has been downloaded and is available as a Python dictionary.
        """
        print("The binary info dict looks like this:", binary_info)
        
        # Example of what a binary dict can look like:
        # {'binary_info': {'captcha_image': {'Size': '10623', 'Hash': '407be152e204fa22cee5c81d3aea7cf0'}}}
        
        # Get the main part of the dict.
        inner_dict = binary_info.get("binary_info")
        
        if not inner_dict:
            return
        
        
        sizes_considered_before = 0
        
        for k,v in inner_dict.items():
            
            binary_key = k      # binary_id
            dict_info = v
            
            image_size = int(dict_info.get("Size"))
            image_hash = dict_info.get("Hash")
            
            
            
            # Calculate how many total bytes will need to be downloaded to consider the single binary
            # as being fully available to read.
            #total_goal = self.read_where_binary_starts + self.total_binary_image_bytes_downloaded + image_size
            
            current_start_from = self.read_where_binary_starts + sizes_considered_before
            current_end_at = self.read_where_binary_starts + sizes_considered_before + image_size
            
            sizes_considered_before += image_size
            
            #self.binaries_expected.append(("captcha_image", image_hash, image_size))
            waiting_binary = ExpectedBinary(binary_id=binary_key, 
                                                                  binary_hash=image_hash, 
                                                                  binary_size=image_size,
                                                                  starts_at_bytes=current_start_from,
                                                                  ends_at_bytes=current_end_at)
            
            self.binaries_expected.append(waiting_binary)
            
        try:
            self.current_pending_binary_download = self.binaries_expected.pop(0)
        except IndexError as e:
            self.current_pending_binary_download = None
            
    def on_received_postgres_data_dict(self, row_data):
        """
        PostgreSQL row data has been received.
        """
        pass
            
    def on_received_simple_message_dict(self, simple_message:dict):
        """
        A simple message has been downloaded and is available to be read as
        a Python dictionary.
        """
        print("The simple message is:", simple_message)
        
        # Record the simple message so that once the entire download has been finished,
        # on_received_download_finished will read the dictionary.
        self.final_simple_message = simple_message


    
            
    def on_ok_header_not_received(self, non_header_data: str, stream: BytesIO):
        """
        The ok- part of the header is not there.
        
        Arguments:
        
        - non_header_data: the string data we received from Flask that is not
        related to header-data. In other words, this string contains something
        else aside from the expected 'ok-' string.
        
        We use non_header_data to look for this string: {"S
        because if that string exists, it means that the server (Flask) sent us
        a ServerResponseCode as a JSON. It would have sent it like this if
        flask responded with ReplyPostWriter instead of ReplyWriter.
        
        We should still be able to read replies from ReplyPostWriter,
        because they are simple JSON replies.
        
        - stream: the full data stream that we have. We will extract the
        complete ServerResponseCode from here, if it's there. The first
        argument, non_header_data, will only have the first 3 characters
        (such as {"S ) but stream will have the full server response code
        (as a BytesIO).
        """
        print("ok- part of the header is not there")
        self.cancel_download = True    
        
        # Was the response from Flask a simple JSON resposne? If so, 
        # read the response and send it to the GUI thread as a ServerReceipt 
        # (handled by the method call below).
        if self._get_post_server_response(non_header_data=non_header_data,
                                          stream=stream):
            
            # The response from Flask was a simple JSON response,
            # so consider it handled.
            return
        
        else:
            # The response from Flask was not a simple JSON response;
            # some kind of error occurred.
            # Let the GUI thread know about it.

            receipt = ServerResponseReceipt(self.callback_method, 
                                  ServerResponseCode.OK_HEADER_NOT_RECEIVED,
                                  "Expected header data not found.")
            ReplyReader.the_queue.put(receipt)
        
    def _get_post_server_response(self, non_header_data: str,
                                  stream: BytesIO) -> bool:
        """
        Check if the data we've downloaded is a response code from Flask.
        If it is, convert it to a ServerReceipt and return it.
        
        If the downloaded data is not a response code, return None so the
        caller knows that it's not a server response code.
        
        The purpose of this method is to read the server response
        from ReplyPostWriter, which is a simple json response.
        
        Reason this was made: some Flask responses will *always* be in
        ReplyPostWriter format (which is a simple json response).
        For example, if the JWT has expired, the reply
        from flask will be a simple string, even if the client request was
        sent from ReplyReader. We use this method here to detect if a simple
        JSON response was sent to us or not.
        """
        
        return_value = False
        
        if non_header_data == '{"S':
            # Convert the bytesIO stream to a bytearray
            reader = bytearray(stream.getvalue())

            # convert the bytearray to a utf8 string
            reader = reader.decode("utf-8")        
            reader = reader.strip()            
            
            # Now we'll have a string like this (in JSON format)
            # {"ServerResponseCode":"JWT_EXPIRED","ServerResponseText":null}

            # Attempt to convert it to a dict.
            try:
                response = json.loads(reader)
            except json.decoder.JSONDecodeError:
                response = None
            else:
                # At this point, the response code will be a string inside a 
                # dict, like this:
                # {"ServerResponseCode":"JWT_EXPIRED","ServerResponseText":null}
                response_code = response.get("ServerResponseCode")
                response_text = response.get("ServerResponseText")
                
                # Convert the string response code to an enum
                response_code = ServerResponseCode.to_enum(response_code)
                
                # Send the response to the GUI thread.
                receipt = ServerResponseReceipt(self.callback_method, 
                                      response_code,
                                      response_text)     
                ReplyReader.the_queue.put(receipt)           
                
                # So the caller of this method will know that it was a 
                # simple JSON response.
                return_value = True
        
        return return_value
        
    def on_ok_header_received(self):
        print("ok- part was read successfully")
        
    def run(self):
        
        
        try:
        
            
            r:requests.models.Response =\
                requests.get(url=self.request_uri,
                             data=self.data_json, stream=True)
            
            mystream = BytesIO()
            #chunk_counter = 0
            
            # Chunk download
            for chunk in r.iter_content(chunk_size=self.chunk_size):
                
                #time.sleep(1)
                
                # Cancel download if the flag is set.
                # This will be set if the ok- part of the stream was not found.
                if self.cancel_download:
                    print("Download has been cancelled.")
                    
                    break
                
                # Write the downloaded chunk to the stream variable.
                mystream.write(chunk)
                
                # Increment what we've downloaded so far
                self.inner_chunks_read_so_far += self.chunk_size     
                
                # Read/analyze what's been downloaded so far.
                self.analyze_chunk_so_far(mystream)

        except requests.exceptions.ConnectionError:
            
            # Tell the part of the GUI that is waiting for this ReplyReader 
            # response that a connection error occurred. That way, it can show 
            # an error message to the user (on the GUI thread).
            receipt = ServerResponseReceipt(self.callback_method,
                                            ServerResponseCode.CONNECTION_ERROR)
            ReplyReader.the_queue.put(receipt)     
