# Copyright 3D Control Systems, Inc. All Rights Reserved 2017-2019.
# Built in San Francisco.

# This software is distributed under a commercial license for personal,
# educational, corporate or any other use.
# The software as a whole or any parts of it is prohibited for distribution or
# use without obtaining a license from 3D Control Systems, Inc.

# All software licenses are subject to the 3DPrinterOS terms of use
# (available at https://www.3dprinteros.com/terms-and-conditions/),
# and privacy policy (available at https://www.3dprinteros.com/privacy-policy/)

import asyncio
import collections
import concurrent.futures
import io
import logging
import os
import threading
import time
import pprint
import typing
import aiohttp
import aiohttp.client
import config
import base_sender


class BaseHTTPConnection:
    SERVICE_NAME = "BaseHTTPConnection"
    DEFAULT_HTTP_PORT = 80
    DEFAULT_HTTPS_PORT = 443
    DEFAULT_HOST_IP = "127.0.0.1"
    DEFAULT_WS_PORT = 80
    HOST_MASK = "http://%s:%d"
    WS_HOST_MASK = "ws://%s:%d/websocket"
    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"
    MAX_MESSAGE_SIZE = 0x2000
    MAX_MESSAGE_INDEX = 0xfffffff
    TIMEOUT = 4
    UPLOAD_TIMEOUT = 600
    COMMAND_TIMEOUT = 15
    LOOP_TIME = 0.5
    REQ_RETRY = 6
    WEBSOCKET_COMMUNICATION_MODE = False
    WS_HEARTBEAT = 30

    # if WEBSOCKET_COMMUNICATION_MODE enabled need to be implemented next methods:
    # ws_printer_info_task
    # parse_ws_text
    # parse_ws_binary
    # (look moonraker_sender for the example)

    # else for http connection loop should be implemented:
    # http_communication_loop
    # (look raise3d_sender for the example)

    def __init__(self, host=None, port=None, timeout=TIMEOUT, parent=None, logger=None):
        self.stop_flag = False
        if not host:
            host = self.DEFAULT_HOST_IP
        if not port:
            port = self.DEFAULT_HTTP_PORT
        self.host = self.HOST_MASK % (host, port)
        self.ws_host_plus_path = self.WS_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.command_future = None
        self.print_future = None
        self.operational_flag = False
        self.printing_flag = False
        self.paused_flag = False
        self.session = None
        self.message_index = 0
        self.send_now_buffer = collections.deque()
        self.ws_send_deque = collections.deque()
        self.ws_id_responses = {}
        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()
        self.material_names = []
        self.material_desc = []
        self.material_colors = []
        self.ams_ids = []
        self.ams_slot_ids = []
        self.ws_connection = self.WEBSOCKET_COMMUNICATION_MODE
        self.ws_headers = None
        #self.ws_client = None
        if self.parent:
            self.ws_connection = self.parent.profile.get("ws", self.WEBSOCKET_COMMUNICATION_MODE)
        self.get_server_info_task = 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("Starting 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)

    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 self.ws_connection:
                self.get_server_info_task = asyncio.create_task(self.ws_printer_info_task())
                await self.ws_communication_loop()
            else:
                await self.http_communication_loop()
        except Exception as e:
            # self.logger.exception("Unable to connect to " + str(self.host))
            msg = "Unable to connect to " + str(self.host) + ". Description: " + str(e)
            if self.parent and self.parent.verbose:
                self.logger.exception(msg)
            else:
                self.logger.error(msg)

        finally:
            self.stop_flag = True
            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 send_file_to_print(self, f):
        return "send_file_to_print not implemented."

    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 = "The upload timeout has been reached"
        except (asyncio.CancelledError, concurrent.futures.CancelledError):
            upload_error = f'Cancelled. The upload timeout has been reached'
        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)
        if self.parent:
            self.parent.next_print_options = None
        return not bool(upload_error)

    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

    async def wait_for_ws_response(self, request_id, timeout=3):
        too_late = time.time() + timeout
        while time.time() < too_late:
            if self.stop_flag:
                break
            if request_id in self.ws_id_responses:
                return self.ws_id_responses.pop(request_id)
            await asyncio.sleep(0.1)
        return None

    async def ws_communication_loop(self):
        self.logger.debug('Entering WS loop...')
        self.logger.info("Connecting to %s" % self.host)
        async with self.session.ws_connect(self.ws_host_plus_path, headers=self.ws_headers, heartbeat=self.WS_HEARTBEAT) as ws:
            while not ws.closed:
                message = ""
                if self.ws_send_deque:
                    message = self.ws_send_deque.popleft()
                # elif self.send_now_buffer:
                #     data = self.send_now_buffer.popleft()
                #     if data:
                #         # TODO: discuss
                #         # if isinstance(data, bytes):
                #         #     data = data.encode()
                #         message['gcodes'] = data
                if message:
                    if self.parent and self.parent.verbose:
                        self.logger.info(f'WS WRITING {message}')
                    if ws and not ws.closed:
                        try:
                            await ws.send_str(message)
                        except Exception as e:
                            # TODO: few retries
                            self.logger.error("Error in WS send. Desc: " + str(e))
                    else:
                        self.logger.info("Can't send message. WS not connected")
                try:
                    msg = await ws.receive(timeout=self.TIMEOUT)
                except asyncio.TimeoutError:
                    self.logger.info("websocket not received any message")
                except Exception as e:
                    self.logger.info("Error receive. Desc: " + str(e))
                    self.stop_flag = True
                    self.register_error(3333, "Error receive. Desc: " + str(e), disconnect=True)
                # self.logger.debug('Received on WS:' + str(msg.data))
                else:
                    if self.stop_flag:
                        await ws.close()
                        self.logger.info('Exiting WS loop due to stop flag...')
                        break
                    elif msg.type == aiohttp.WSMsgType.TEXT:
                        if self.parent and self.parent.verbose:
                            self.logger.info("WS TEXT:" + pprint.pformat(msg.data))
                        self.parse_ws_text(msg.data)
                    elif msg.type == aiohttp.WSMsgType.BINARY:
                        # self.logger.debug('Received on WS:' + pprint.pformat(msg.json()))
                        self.parse_ws_binary(msg.data)
                    elif msg.type in (aiohttp.WSMsgType.CLOSED,
                                      aiohttp.WSMsgType.CLOSE,
                                      aiohttp.WSMsgType.CLOSING,
                                      aiohttp.WSMsgType.ERROR):
                        self.logger.info(f"Exiting WS loop due connection closed or error encountered. msgType: {str(msg.type)}")
                        break
                finally:
                    if self.stop_flag:
                        await ws.close()
                        self.logger.info('Exiting WS loop due to stop flag...')
        self.logger.info('WS loop finish')

    async def http_communication_loop(self):
        self.logger.error("http_communication_loop not implemented. Exit loop")

    async def ws_printer_info_task(self):
        self.logger.error("ws_printer_info_task not implemented. Exit task")

    async def create_http_post_command(self, path, data=None):
        if data:
            return await self.session.post(self.host + path, data=data,
                                           timeout=aiohttp.ClientTimeout(total=self.COMMAND_TIMEOUT))
        else:
            return await self.session.post(self.host + path, timeout=aiohttp.ClientTimeout(total=self.COMMAND_TIMEOUT))

    def run_async_command(self, coro_to_run):
        self.logger.info("Creating command future...")
        while self.command_future:
            if self.stop_flag:
                return False
            time.sleep(0.1)
        result = None
        if self.loop:
            self.command_future = asyncio.run_coroutine_threadsafe(coro_to_run, self.loop)
            self.logger.info("Waiting for command future to finish...")
            try:
                result = self.command_future.result(self.TIMEOUT)
            except (asyncio.TimeoutError, concurrent.futures.TimeoutError):
                self.logger.error(f'Command future timeout')
                # self.command_future.cancel()
            except asyncio.CancelledError:
                self.logger.error(f'Command future cancelled')
            finally:
                self.command_future = None
                self.logger.info(f'Command execution result: {result}')
        else:
            self.logger.info(f'No loop to execute a command {coro_to_run}')
        return result

    def run_http_post_command(self, path_or_method, data=None):
        result = self.run_async_command(self.create_http_post_command(path_or_method, data))
        return result and getattr(result, "ok", False)

    def pause(self):
        self.logger.error("connection pause not implemented.")

    def resume(self):
        self.logger.error("connection resume not implemented.")

    def cancel(self):
        self.logger.error("connection cancel not implemented.")

    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 parse_ws_text(self, data):
        self.logger.error("parse_ws_text not implemented.")

    def parse_ws_binary(self, data):
        self.logger.debug("ws binary received")

    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()
        if self.get_server_info_task:
            self.get_server_info_task.cancel()
        self.status_thread.join(self.TIMEOUT + 0.1)
        if self.loop:
            try:
                self.loop.close()
            except RuntimeError:
                pass
        time.sleep(0.1)

class BaseHTTPSender(base_sender.BaseSender):

    CONNECTION_TIMEOUT = 3
    CONNECTION_CLASS = BaseHTTPConnection
    DEFAULT_TEMPS_LIST = [0.0, 0.0]
    DEFAULT_POSITION_LIST = [0.0, 0.0, 0.0, 0.0]

    def __init__(self, parent, usb_info, profile):
        super().__init__(parent, usb_info, profile)
        self.connection = None
        self.verbose = config.get_settings().get('verbose', False)
        self.next_print_options = None
        # self.connect() # TODO: need to discuss

    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 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:
                return self.connection.pause()
            except AttributeError:
                pass
        return False

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

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

    def cancel(self):
        if self.is_printing():
            try:
                return self.connection.cancel()
            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 get_material_names(self) -> typing.List[str]:
        if self.connection:
            try:
                return self.connection.material_names
            except:
                pass


    def get_material_colors_hex(self) -> typing.List[str]:
        if self.connection:
            try:
                return self.connection.material_colors
            except:
                pass

    def get_material_desc(self) -> typing.List[str]:
        if self.connection:
            try:
                return self.connection.material_desc
            except:
                pass

    def get_ams_ids(self) -> typing.List[str]:
        if self.connection:
            try:
                return self.connection.ams_ids
            except:
                pass

    def get_ams_slot_ids(self) -> typing.List[str]:
        if self.connection:
            try:
                return self.connection.ams_slot_ids
            except:
                pass

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