import asyncio
import collections
import concurrent.futures
import io
import json
import logging
import threading
import time
import os
import pprint
import typing

import aiohttp
import aiohttp.client

import base_sender
import log


class HTTPConnection:

    SERVICE_NAME = "Moonraker"
    DEFAULT_HTTP_PORT = 7125
    DEFAULT_HTTPS_PORT = 7130
    DEFAULT_HOST_IP = "127.0.0.1"
    HOST_MASK = "http://%s:%d"
    #WS_HOST_MASK = "ws://%s:%d/socket"

    UPLOAD_FILENAME = "3dprinteros.gcode" # don't use dot in name and be very cautious with length and special characters

    APIKEY_HEADER_NAME = "X-Api-Key"

    #SERVER_INFO_PATH = "/server/info"
    PRINTER_STATUS_PATH = "/printer/objects/query?extruder=target,temperature&heater_bed=target,temperature&toolhead=position&print_stats&virtual_sdcard"
    PRINTER_INFO_PATH = "/printer/info"
    EXEC_GCODE_PATH = "/printer/gcode/script?script=" #POST #NOTE user url encode for spaces
    LOGIN_PATH = "/access/login" #POST
    FILES_PATH = "/server/files"
    UPLOAD_PATH = FILES_PATH + "/upload" #POST
    UPLOAD_ROOT = 'gcodes'
    METADATA_PATH = '/server/files/metadata?filename=' + UPLOAD_FILENAME

    MAX_MESSAGE_SIZE = 0x2000
    MAX_MESSAGE_INDEX = 0xfffffff
    TIMEOUT = 2
    UPLOAD_TIMEOUT = 60
    COMMAND_TIMEOUT = 15
    LOOP_TIME = 0.5
    REQ_RETRY = 6

    def __init__(self, host=None, port=None, timeout=TIMEOUT, parent=None, logger=None):
        self.stop_flag = False
        if logger:
            self.logger = logger
        else:
            self.logger = logging.getLogger()
        if not host:
            host = self.DEFAULT_HOST_IP
        if not port:
            port = self.DEFAULT_HTTP_PORT
        self.host = self.HOST_MASK % (host, port)
        if port == self.DEFAULT_HTTPS_PORT:
            self.host = self.host.replace('http:', 'https:')
        if not timeout:
            timeout = self.TIMEOUT
        self.timeout = timeout
        self.parent = parent
        self.loop = None
        self.send_now_buffer = collections.deque()
        self.command_future = None
        self.print_future = None
        self.apikey = None
        self.operational_flag = False
        self.printing_flag = False
        self.paused_flag = False
        self.session = None
        self.message_index = 0
        self.temps = [0.0, 0.0]
        self.ttemps = [0.0, 0.0]
        self.total_lines = 0
        self.current_line = 0
        self.percent = 0.0
        self.est_print_time = 0.0
        self.print_time_left = 0.0
        self.previous_state = ''
        self.previous_exact_progress = 0.0
        self.commands_to_process = collections.deque()
        if parent:
            self.upload_root = self.parent.settings.get('upload_root', self.UPLOAD_ROOT)
            self.moonraker_token = parent.settings.get('token')
            self.moonraker_username = parent.settings.get('login')
            self.moonraker_password = parent.settings.get('password')
        else:
            self.upload_root = self.UPLOAD_ROOT
            self.moonraker_token = None
            self.moonraker_username = None
            self.moonraker_password = None
        self.auth_headers = None
        if logger:
            self.logger = logger.getChild('connection')
        else:
            self.logger = logging.getLogger(self.__class__.__name__)
        self.status_thread = threading.Thread(target=self.init_loop)
        self.status_thread.start()

    def init_loop(self):
        try:
            self.loop = asyncio.get_event_loop()
        except RuntimeError:
            self.logger.info("Staring event loop...")
            self.loop = asyncio.new_event_loop()
            asyncio.set_event_loop(self.loop)
        while not self.stop_flag:
            self.loop.run_until_complete(self.connect())
            time.sleep(0.2)

    def upload(self, f):
        self.logger.info("Creating upload gcodes task...")
        self.print_future = asyncio.run_coroutine_threadsafe(self.send_file_to_print(f), self.loop)
        self.logger.info("Waiting for upload gcodes to finish...")
        try:
            upload_error = self.print_future.result(self.UPLOAD_TIMEOUT)
        except (asyncio.TimeoutError, concurrent.futures.TimeoutError):
            self.print_future.cancel()
            upload_error = None
        except asyncio.CancelledError:
            upload_error = 'Cancelled'
        finally:
            self.print_future = None
        if upload_error:
            self.logger.info("Upload error:" + str(upload_error))
            self.register_error(2009, 'File upload failed', job_fail=True)
        return not bool(upload_error)

    async def send_file_to_print(self, f):
        form = aiohttp.FormData()
        form.add_field('file', f, filename=self.UPLOAD_FILENAME)
        form.add_field('root', self.upload_root)
        form.add_field('print', 'true')
        self.logger.info('Uploading file to ' + self.SERVICE_NAME)
        error = ""
        if not self.session:
            error = 'Unable to send file due to lack of connection to host'
        else:
            try:
                await self.session.delete(self.host + self.FILES_PATH + "/" +  
                      self.upload_root + "/" + self.UPLOAD_FILENAME, \
                      headers=self.auth_headers, timeout=aiohttp.ClientTimeout(total=self.COMMAND_TIMEOUT))
                async with self.session.post(self.host + self.UPLOAD_PATH, headers=self.auth_headers, \
                        data=form, timeout=aiohttp.ClientTimeout(total=self.UPLOAD_TIMEOUT)) as resp:
                    if resp.ok:
                        self.percent = 0.0
                        self.est_print_time = 0.0
                        self.logger.info('Successful upload to %s',  self.host)
                        resp_dict = await resp.json()
                        if resp_dict.get("print_started"):
                            await self.get_and_parse_metadata()
                        else:
                            error = f"Print start error:" + pprint.pformat(resp_dict)
                    else:
                        error = f"Upload error: {resp.status}:\n" + pprint.pformat(await resp.text())
            except asyncio.TimeoutError:
                 error = "Upload timeout"
            except asyncio.CancelledError:
                 error = "Upload cancelled"
            except Exception as e:
                error = "Exception on upload: " + str(e)
            if error:
                self.register_error(2015, error, job_fail=True)

    async def get_and_parse_metadata(self):
        async with self.session.get(self.host + self.METADATA_PATH, headers=self.auth_headers, \
                timeout=aiohttp.ClientTimeout(total=self.UPLOAD_TIMEOUT)) as resp:
            if resp.ok:
                metata_dict = await resp.json()
                metadata_result = metata_dict.get('result', {})
                try:
                    est_print_time = float(metadata_result.get('estimated_time'))
                except:
                    est_print_time = None
                if est_print_time:
                    self.est_print_time = est_print_time

    #TODO implement this in future versions
    async def authorize(self):
        return True

    async def check_klippy_state(self):
        operational_flag = False
        resp = await self.session.get(self.host + self.PRINTER_INFO_PATH) 
        if resp.ok:
            resp_dict = await resp.json()
            if isinstance(resp_dict, dict):
                state = resp_dict.get('result', {}).get('state') 
                if state == 'ready':
                    operational_flag = True
                elif state == 'startup':
                    operational_flag = False
                    self.parent.register_error(2001, 'Klipper host starting up', info=True)
                elif state == 'error':
                    operational_flag = False
                    self.parent.register_error(2001, 'Klipper host software error')
                elif state == 'disconnected':
                    operational_flag = False
                    self.parent.register_error(2002, 'Klipper host software disconnected', disconnect=True)
                elif state == 'shutdown':
                    operational_flag = False
                    self.parent.register_error(20004, 'Klipper host software about to shutdown', disconnect=True)
        return operational_flag

    async def connect(self):
        self.logger.info(f"Connecting to {self.SERVICE_NAME} server at {self.host}")
        try:
            self.session = aiohttp.ClientSession(loop=self.loop, timeout=aiohttp.ClientTimeout(total=self.timeout))
            # if self.moonraker_username:
            #     if not await self.authorize():
            #         self.register_error(1000, 'Unable to connect to ' + str(self.host))
            if await self.check_klippy_state():
                await self.status_requesting()
        except:
            #self.logger.exception("Unable to connect to " + str(self.host))
            self.logger.warning("Unable to connect to " + str(self.host))
        finally:
            self.logger.info("Closing connection to " + str(self.host))
            if self.session:
                try:
                    await self.session.close()
                except:
                    pass
            self.session = None
            self.operational_flag = False

    async def status_requesting(self):
        self.logger.info('Entering status loop...')
        retries_left = self.REQ_RETRY
        while not self.stop_flag:
            loop_start_time = time.monotonic()
            if not self.session:
                break
            #if not self.moonraker_token:
                # auth = {'username': self.moonraker_username, "password": self.moonraker_password}
                # resp = await self.session.post(auth)
                # if resp.ok:
                #     resp_dict = await resp.json()
                #     self.moonraker_token = resp_dict.get('token')
                #     self.moonraker_refresh_token = resp_dict.get('token')
            if not self.command_future and not self.print_future:
                try:
                    resp = await self.session.get(self.host + self.PRINTER_STATUS_PATH, headers=self.auth_headers)
                    if resp.ok:
                        self.parse_printer_status(await resp.json())
                    self.operational_flag = await self.check_klippy_state()
                except Exception as e:
                    retries_left -= 1
                    if not retries_left:
                        self.operational_flag = False
                        self.register_error(2000, self.SERVICE_NAME + ' connection lost')
                    self.logger.exception('Exception in status loop: ' + str(e))
                else:
                    retries_left = self.REQ_RETRY
            delta = time.monotonic() + self.LOOP_TIME - loop_start_time
            if delta > 0:
                await asyncio.sleep(delta)
        self.is_operational = False
        self.logger.info('Status loop exit')

    def parse_printer_status(self, resp_dict):
        if self.parent.verbose:
            self.logger.info('Moonraker: ' + pprint.pformat(resp_dict))
        else:
            self.logger.debug('Moonraker: ' + pprint.pformat(resp_dict))
        status = resp_dict.get('result', {}).get('status', {})
        self.temps[0] = status.get('heater_bed', {}).get('temperature', 0.0)
        self.temps[1] = status.get('extruder', {}).get('temperature', 0.0)
        self.ttemps[0] = status.get('heater_bed', {}).get('target', 0.0)
        self.ttemps[1] = status.get('extruder', {}).get('target', 0.0)
        position = status.get('toolhead', {}).get('position')
        if isinstance(position, list) or isinstance(position, tuple) and len(position) == 4:
            self.position = list(position)
        print_stats = status.get('print_stats', {})
        state = print_stats.get('state')
        progress = None
        if state == 'complete':
            if self.previous_state != 'complete':
                progress = 1.0
                self.parent.register_print_finished_event()
        elif state == 'cancelled':
            if self.previous_state != 'cancelled':
                self.parent.register_print_cancelled_event()
        elif state == 'standby':
            progress = 0.0
        self.previous_state = state
        self.printing_flag = (state == 'printing' or state == 'paused')
        self.paused_flag = state == 'paused'
        print_duration = print_stats.get('print_duration', 0.0)
        total_duration = print_stats.get('total_duration', 0.0)
        if self.printing_flag and not self.est_print_time:
            self.est_print_time = total_duration
        if print_duration and self.est_print_time:
            self.print_time_left = max(self.est_print_time - print_duration, 0.0)
        virual_sdcard = status.get('virtual_sdcard', {})
        if progress is None:
            progress = virual_sdcard.get('progress')
        if not isinstance(progress, float) and self.est_print_time:
            progress = float(print_duration) / self.est_print_time
        if state == 'error':
            moonraker_error = print_stats.get('message', '')
            if self.previous_state != 'error':
                self.register_error(1509, "Moonraker error: " + str(moonraker_error))
            elif progress == self.previous_exact_progress:
                self.register_error(1510, "Moonraker error: " + str(moonraker_error), job_fail=True)
        self.previous_exact_progress = progress
        self.percent = round(max(100 * progress, 100.0), 2)

    # def get_message_index(self):
    #     if self.message_index > self.MAX_MESSAGE_INDEX:
    #         self.message_index = 0
    #     self.message_index += 1
    #     return self.message_index

    def run_async_command(self, path, method, data=None):
        self.logger.info("Creating command future...")
        while self.command_future:
            if self.stop_flag:
                return False
            time.sleep(0.1)
        if data:
            self.command_future = asyncio.run_coroutine_threadsafe(method(self.host + path, \
                    data=data, timeout=aiohttp.ClientTimeout(total=self.COMMAND_TIMEOUT)), self.loop)
        else:
            self.command_future = asyncio.run_coroutine_threadsafe(method(self.host + path, \
                    timeout=aiohttp.ClientTimeout(total=self.COMMAND_TIMEOUT)), self.loop)
        self.logger.info("Waiting for command future to finish...")
        success = False
        try:
            success = self.command_future.result(self.TIMEOUT).ok
        except (asyncio.TimeoutError, concurrent.futures.TimeoutError):
            self.logger.error('Command future timeout ' + path)
            #self.command_future.cancel()
        except asyncio.CancelledError:
            self.logger.error('Command future cancelled ' + path)
        finally:
            self.command_future = None
            self.logger.info(f'Command {path} execution result: {success}')
        return success

    def pause(self):
        return self.run_async_command('/printer/print/pause', self.session.post)

    def resume(self):
        return self.run_async_command('/printer/print/resume', self.session.post)

    def cancel(self):
        return self.run_async_command('/printer/print/cancel', self.session.post)

    def restart_firmware(self):
        return self.run_async_command('/printer/firmware_restart', self.session.post)

    def emergency_stop(self):
        return self.run_async_command('/printer/emergency_stop', self.session.post)

    def execute_gcode(self, gcode):
        return self.run_async_command(self.EXEC_GCODE_PATH + gcode, self.session.post)

    def register_error(self, code, message, job_fail=False, info=False, disconnect=False):
        if self.parent:
            self.parent.register_error(code, message, job_fail=job_fail, info=info, disconnect=disconnect)
        else:
            self.logger.error(f"Register error: {code} {message} blocking={job_fail} info={info} disconnect={disconnect}")

    def close(self):
        self.logger.info("Closing " + __class__.__name__)
        self.stop_flag = True
        if self.print_future:
            self.print_future.cancel()
        if self.command_future:
            self.command_future.cancel()
        self.status_thread.join(self.TIMEOUT + 0.1)
        if self.loop:
            try:
                self.loop.close()
            except RuntimeError:
                pass
        time.sleep(0.1)


class Sender(base_sender.BaseSender):

    CONNECTION_CLASS = HTTPConnection
    CONNECTION_TIMEOUT = 3
    DEFAULT_TEMPS_LIST = [0.0, 0.0]
    DEFAULT_POSITION_LIST = [0.0, 0.0, 0.0, 0.0]
    # SUPPORT_JOBS = True TODO reconnect without job cancel on the cloud

    def __init__(self, parent, usb_info, profile):
        super().__init__(parent, usb_info, profile)
        self.connect()

    def connect(self):
        self.connection = self.CONNECTION_CLASS(self.usb_info['IP'],\
                                                self.usb_info.get('PORT', self.profile.get('port')),\
                                                self.profile.get('timeout'),\
                                                self, self.logger)
        timeleft = self.CONNECTION_TIMEOUT
        while not self.is_operational():
            if timeleft <= 0:
                self.connection.close()
                self.connection = None
                raise RuntimeError("No connection to host: " + str(self.usb_info['IP']))
            timeleft -= 0.1
            time.sleep(0.1)

    def gcodes(self, filepath: typing.Union[typing.AnyStr, io.BytesIO], keep_file: bool = False) -> bool:
        success = False
        if self.connection:
            with open(filepath, 'rb') as f:
                self.logger.info("Uploading print...")
                success = self.connection.upload(f)
                if success:
                    self.logger.info("Upload success")
                    self.gcode_filepath = filepath
                else:
                    self.logger.info("Upload fail")
        else:
            self.logger.info('Error: no connection')
        if not keep_file:
            try:
                os.remove(filepath)
            except:
                pass
        return success

    def unbuffered_gcodes(self, gcodes: typing.Any) -> bool:
        self.logger.info("Gcodes to send now: " + str(gcodes))
        if not self.connection:
            return False
        if isinstance(gcodes, (list, collections.deque)):
            if not gcodes:
                return False
            else:
                for index, gcode in enumerate(gcodes):
                    if isinstance(gcode, bytes):
                        gcodes[index] = gcode.decode('utf-8')
        else:
            if isinstance(gcodes, bytes):
                gcodes = gcodes.decode('utf-8')
            gcodes = gcodes.split("\n")
        for gcode in gcodes:
            try:
                if not self.connection.execute_gcode(gcode):
                    raise AttributeError
            except AttributeError:
                return False
        return True

    def is_printing(self):
        try:
            return self.connection.printing_flag
        except AttributeError:
            return False

    def is_paused(self):
        try:
            return self.connection.paused_flag
        except AttributeError:
            return False

    def is_operational(self):
        try:
            return self.connection.operational_flag
        except AttributeError:
            return False

    def pause(self):
        if self.is_printing():
            try:
                self.connection.pause()
                return True
            except AttributeError:
                pass
        return False

    def resume(self):
        try:
            self.connection.resume()
            return True
        except AttributeError:
            pass
        return False

    def unpause(self):
        return self.resume()

    def cancel(self):
        if self.is_printing():
            try:
                self.connection.cancel()
                return True
            except AttributeError:
                pass
        return False

    def get_percent(self):
        try:
            return self.connection.percent
        except AttributeError:
            return 0

    def get_position(self):
        try:
            return self.round_temps_list(self.connection.position)
        except AttributeError:
            return self.DEFAULT_POSITION_LIST

    #TODO
    def get_current_line_number(self):
        # try:
        #     return self.connection.current_line
        # except AttributeError:
            return 0

    #TODO
    def get_total_gcodes(self):
        # try:
        #     return self.connection.total_lines
        # except AttributeError:
            return 0

    def get_temps(self):
        if self.connection:
            try:
                return self.round_temps_list(self.connection.temps)
            except:
                return  self.DEFAULT_TEMPS_LIST
        else:
            return self.DEFAULT_TEMPS_LIST

    def get_target_temps(self):
        if self.connection:
            try:
                return self.round_temps_list(self.connection.ttemps)
            except:
                return self.DEFAULT_TEMPS_LIST
        else:
            return self.DEFAULT_TEMPS_LIST

    def get_remaining_print_time(self, ignore_state: bool = False) -> int:
        if self.is_printing() or self.connection and ignore_state:
            try:
                return self.connection.print_time_left
            except:
                pass
        return 0.0

    def reset(self):
        if self.connection:
            self.connection.restart_firmware()
            return True
        self.register_error(2010, 'No connection. Unable to send reset')
        return False

    def emergency_stop(self):
        if self.connection:
            self.connection.emergency_stop()
            return True
        self.register_error(2010, 'No connection. Unable to send emergency stop')
        return False

    def close(self):
        if self.connection:
            self.connection.close()
