#!/usr/bin/env python
# -*- coding: UTF-8 -*-

"""
tcp_socket (TCP Socket)
=======================

**Author:**

* Dirk Alders <sudo-dirk@mount-mockery.de>

**Description:**

    This Module supports a client/ server tcp socket connection.

**Submodules:**

* :class:`tcp_socket.tcp_client`
* :class:`tcp_socket.tcp_client_stp`
* :class:`tcp_socket.tcp_server`
* :class:`tcp_socket.tcp_server_stp`

**Unittest:**

        See also the :download:`unittest <tcp_socket/_testresults_/unittest.pdf>` documentation.

**Module Documentation:**

"""
__DEPENDENCIES__ = ['stringtools', 'task', ]

import stringtools
import task

import logging
import socket
import time

try:
    from config import APP_NAME as ROOT_LOGGER_NAME
except ImportError:
    ROOT_LOGGER_NAME = 'root'

logger = logging.getLogger(ROOT_LOGGER_NAME).getChild(__name__)

__DESCRIPTION__ = """The Module {\\tt %s} is designed to help with client / server tcp socket connections.
For more Information read the documentation.""" % __name__.replace('_', '\_')
"""The Module Description"""
__INTERPRETER__ = (2, 3)
"""The Tested Interpreter-Versions"""


class tcp_base(object):
    """
    This is the base class for other classes in this module.

    :param host: The host IP for the TCP socket functionality
    :type host: str
    :param port: The port for the TCP socket functionality
    :type port: int
    :param channel_name: The name for the logging channel
    :type channel_name: str

    .. note:: This class is not designed for direct usage.
    """
    DEFAULT_CHANNEL_NAME = 'all_others'

    RX_LENGTH = 0xff
    COM_TIMEOUT = 0.5
    IS_CLIENT = False

    def __init__(self, host, port, channel_name=None, rx_tx_log_lvl=logging.INFO):
        self.host = host
        self.port = port
        self.init_channel_name(channel_name)
        self.__rx_tx_log_lvl__ = rx_tx_log_lvl
        self.__socket__ = None
        self.__data_available_callback__ = None
        self.__supress_data_available_callback__ = False
        self.__connect_callback__ = None
        self.__disconnect_callback__ = None
        self.__clean_receive_buffer__()
        self.__connection__ = None
        self.__listening_message_displayed__ = False
        self.__client_address__ = None
        self.__queue__ = task.threaded_queue()
        self.__queue__.enqueue(5, self.__receive_task__)
        self.__queue__.run()

    def __call_data_available_callback__(self):
        if len(self.__receive_buffer__) > 0 and not self.__supress_data_available_callback__ and self.__data_available_callback__ is not None:
            self.__supress_data_available_callback__ = True
            self.__data_available_callback__(self)
            self.__supress_data_available_callback__ = False

    def __clean_receive_buffer__(self):
        self.logger.debug("%s Cleaning up receive-buffer", self.__log_prefix__())
        self.__receive_buffer__ = b''

    def __connection_lost__(self):
        self.__listening_message_displayed__ = False
        self.__connection__.close()
        self.__connection__ = None
        self.__client_address__ = None
        self.logger.info('%s Connection lost...', self.__log_prefix__())
        if self.__disconnect_callback__ is not None:
            self.__disconnect_callback__()

    def __del__(self):
        self.close()

    def __log_prefix__(self):
        return 'comm-client:' if self.IS_CLIENT else 'comm-server:'

    def __receive_task__(self, queue_inst):
        if self.__connection__ is not None:
            try:
                data = self.__connection__.recv(self.RX_LENGTH)
            except socket.error as e:
                if e.errno != 11:
                    raise
                else:
                    time.sleep(.05)
            else:
                if len(data) > 0:
                    self.logger.log(self.__rx_tx_log_lvl__, '%s RX <- "%s"', self.__log_prefix__(), stringtools.hexlify(data))
                    self.__receive_buffer__ += data
                else:
                    self.__connection_lost__()
            self.__call_data_available_callback__()
        else:
            self.__connect__()
        queue_inst.enqueue(5, self.__receive_task__)

    def client_address(self):
        """
        This method returns the address of the connected client.

        :return: The client address.
        :rtype: str
        """
        return self.__client_address__[0]

    def close(self):
        """
        This method closes the connected communication channel, if exists.
        """
        self.__queue__.stop()
        self.__queue__.join()
        if self.__connection__ is not None:
            self.__connection_lost__()
        if self.__socket__ is not None:
            self.__socket__.close()

    def init_channel_name(self, channel_name=None):
        """
        With this Method, the channel name for logging can be changed.

        :param channel_name: The name for the logging channel
        :type channel_name: str
        """
        if channel_name is None:
            self.logger = logger.getChild(self.DEFAULT_CHANNEL_NAME)
        else:
            self.logger = logger.getChild(channel_name)

    def is_connected(self):
        """
        With this Method the connection status can be identified.

        :return: True, if a connection is established, otherwise False.
        :rtype: bool
        """
        return self.__connection__ is not None

    def receive(self, timeout=1, num=None):
        """
        This method returns received data.

        :param timeout: The timeout for receiving data (at least after the timeout the method returns data or None).
        :type timeout: float
        :param num: the number of bytes to receive (use None to get all available data).
        :type num: int
        :return: The received data.
        :rtype: bytes
        """
        rv = None
        if self.__connection__ is not None:
            tm = time.time()
            while (num is not None and len(self.__receive_buffer__) < num) or (num is None and len(self.__receive_buffer__) < 1):
                if self.__connection__ is None:
                    return None
                if time.time() > tm + timeout:
                    self.logger.warning('%s TIMEOUT (%ss): Not enough data in buffer. Requested %s and buffer size is %d.', self.__log_prefix__(), repr(timeout), repr(num or 'all'), len(self.__receive_buffer__))
                    return None
                time.sleep(0.05)
            if num is None:
                rv = self.__receive_buffer__
                self.__clean_receive_buffer__()
            else:
                rv = self.__receive_buffer__[:num]
                self.__receive_buffer__ = self.__receive_buffer__[num:]
        return rv

    def register_callback(self, callback):
        """
        This method stores the callback which is executed, if data is available. You need to execute :func:`receive` of this instance
        given as first argument.

        :param callback: The callback which will be executed, when data is available.
        :type callback:
        """
        self.__data_available_callback__ = callback

    def register_connect_callback(self, callback):
        """
        This method stores the callback which is executed, if a connection is established.

        :param callback: The callback which will be executed, when a connection is established.
        :type callback:
        """
        self.__connect_callback__ = callback

    def register_disconnect_callback(self, callback):
        """
        This method stores the callback which is executed, after the connection is lost.

        :param callback: The callback which will be executed, after the connection is lost.
        :type callback:
        """
        self.__disconnect_callback__ = callback

    def send(self, data, timeout=1):
        """
        This method sends data via the initiated communication channel.

        :param data: The data to be send over the communication channel.
        :type data: bytes
        :param timeout: The timeout for sending data (e.g. time to establish new connection).
        :type timeout: float
        :return: True if data had been sent, otherwise False.
        :rtype: bool
        """
        tm = time.time()
        while time.time() - tm < timeout:
            if self.__connection__ is not None:
                try:
                    self.__connection__.sendall(data)
                except BlockingIOError:
                    time.sleep(.1)  # try again till timeout exceeds
                except (BrokenPipeError, ConnectionResetError) as e:
                    self.logger.exception('%s Exception while sending data', self.__log_prefix__())
                    self.__connection_lost__()
                    return False
                else:
                    self.logger.log(self.__rx_tx_log_lvl__, '%s TX -> "%s"', self.__log_prefix__(), stringtools.hexlify(data))
                    return True
            else:
                time.sleep(.1)  # give some time to establish the connection
        self.logger.warning('%s Cound NOT send -> "%s"', self.__log_prefix__(), stringtools.hexlify(data))
        return False


class tcp_server(tcp_base):
    """
    This class creates a tcp-server for transfering a serial stream of bytes (characters). See also parent :class:`tcp_base`.

    :param host: The host IP for the TCP socket functionality
    :type host: str
    :param port: The port for the TCP socket functionality
    :type port: int
    :param channel_name: The name for the logging channel
    :type channel_name: str

    .. note:: You need a :class:`tcp_client` to communicate with the server.

    **Example:**

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__tcp_server.py

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__tcp_server.log
    """
    def __connect__(self):
        if self.__socket__ is None:
            # Create a TCP/IP socket
            self.__socket__ = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            # Bind the socket to the port
            server_address = (self.host, self.port)
            self.__socket__.bind(server_address)
            # Listen for incoming connections
            self.__socket__.listen(1)
            self.__socket__.settimeout(self.COM_TIMEOUT)
            self.__socket__.setblocking(False)
        if not self.__listening_message_displayed__:
            self.logger.info('%s Server listening to %s:%d', self.__log_prefix__(), self.host, self.port)
            self.__listening_message_displayed__ = True
        try:
            self.__connection__, self.__client_address__ = self.__socket__.accept()
        except socket.error as e:
            if e.errno != 11:
                raise
            else:
                time.sleep(.05)
        else:
            self.logger.info('%s Connection established... (from %s:%s)', self.__log_prefix__(), self.client_address(), self.port)
            self.__clean_receive_buffer__()
            self.__connection__.setblocking(False)
            if self.__connect_callback__ is not None:
                self.__connect_callback__()


class tcp_client(tcp_base):
    """
    This class creates a tcp-client for transfering a serial stream of bytes (characters). See also parent :class:`tcp_base`.

    :param host: The host IP for the TCP socket functionality
    :type host: str
    :param port: The port for the TCP socket functionality
    :type port: int
    :param channel_name: The name for the logging channel
    :type channel_name: str

    .. note:: You need a running :class:`tcp_server` listening at the given IP and Port to be able to communicate.

    **Example:**

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__tcp_client.py

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__tcp_client.log
    """
    IS_CLIENT = True

    def __connect__(self):
        if self.__socket__ is None:
            # Create a TCP/IP socket
            self.__socket__ = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            self.__socket__.setblocking(False)
        # Connect the socket to the port where the server is listening
        try:
            self.__socket__.connect((self.host, self.port))
        except socket.error as e:
            if e.errno == 9:
                self.__socket__.close()
            elif e.errno != 115 and e.errno != 111 and e.errno != 114:
                raise
            else:
                self.__connection__ = None
                time.sleep(.05)
        else:
            self.logger.info('%s Connection established... (to %s:%s)', self.__log_prefix__(), self.host, self.port)
            self.__clean_receive_buffer__()
            self.__connection__ = self.__socket__
            if self.__connect_callback__ is not None:
                self.__connect_callback__()

    def __connection_lost__(self):
        self.__socket__ = None
        tcp_base.__connection_lost__(self)

    def reconnect(self):
        self.__connect__()


class tcp_base_stp(tcp_base):
    """
    This is the base class for other classes in this module.  See also parent :class:`tcp_base`.

    :param host: The host IP for the TCP socket functionality
    :type host: str
    :param port: The port for the TCP socket functionality
    :type port: int
    :param channel_name: The name for the logging channel
    :type channel_name: str

    .. note:: This class is not designed for direct usage.
    """

    def __init__(self, host, port, channel_name=None):
        tcp_base.__init__(self, host, port, channel_name=channel_name, rx_tx_log_lvl=logging.DEBUG)
        self.__stp__ = stringtools.stp.stp()

    def __clean_receive_buffer__(self):
        self.logger.debug("%s Cleaning up receive-buffer", self.__log_prefix__())
        self.__receive_buffer__ = []

    def __receive_task__(self, queue_inst):
        if self.__connection__ is not None:
            try:
                data = self.__connection__.recv(self.RX_LENGTH)
            except socket.error as e:
                if e.errno == 104:
                    self.__connection_lost__()
                elif e.errno != 11:
                    raise
                else:
                    time.sleep(.05)
            else:
                if len(data) > 0:
                    self.logger.log(self.__rx_tx_log_lvl__, '%s RX <- "%s"', self.__log_prefix__(), stringtools.hexlify(data))
                    content = self.__stp__.process(data)
                    for msg in content:
                        self.logger.info('%s RX  <- "%s"', self.__log_prefix__(), stringtools.hexlify(msg))
                        self.__receive_buffer__.append(msg)
                else:
                    self.__connection_lost__()
            self.__call_data_available_callback__()
        else:
            self.__connect__()
        queue_inst.enqueue(5, self.__receive_task__)

    def receive(self, timeout=1):
        """
        This method returns one received messages via the initiated communication channel.

        :param timeout: The timeout for receiving data (at least after the timeout the method returns data or None).
        :type timeout: float
        :return: The received data.
        :rtype: bytes
        """
        try:
            return tcp_base.receive(self, timeout=timeout, num=1)[0]
        except TypeError:
            return None

    def send(self, data, timeout=1):
        """
        This method sends one stp message via the initiated communication channel.

        :param data: The message to be send over the communication channel.
        :type data: bytes
        :param timeout: The timeout for sending data (e.g. time to establish new connection).
        :type timeout: float
        :return: True if data had been sent, otherwise False.
        :rtype: bool
        """
        if tcp_base.send(self, stringtools.stp.build_frame(data), timeout=timeout):
            self.logger.info('%s TX -> "%s"', self.__log_prefix__(), stringtools.hexlify(data))
            return True
        else:
            return False


class tcp_server_stp(tcp_server, tcp_base_stp):
    """
    This class creates a tcp-server for transfering a message. The bytes will be packed on send and unpacked on receive. See also parents :class:`tcp_server` and :class:`tcp_base_stp`.
    See :mod:`stringtools.stp` for more information on packing and unpacking.

    :param host: The host IP for the TCP socket functionality
    :type host: str
    :param port: The port for the TCP socket functionality
    :type port: int
    :param channel_name: The name for the logging channel
    :type channel_name: str

    .. note:: You need a :class:`tcp_client_stp` to communicate with the server.

    **Example:**

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__stp_server.py

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__stp_server.log
    """
    pass


class tcp_client_stp(tcp_client, tcp_base_stp):
    """
    This class creates a tcp-client for transfering a message. The bytes will be packed on send and unpacked on receive. See also parents :class:`tcp_client` and :class:`tcp_base_stp`.
    See :mod:`stringtools.stp` for more information on packing and unpacking.

    :param host: The host IP for the TCP socket functionality
    :type host: str
    :param port: The port for the TCP socket functionality
    :type port: int
    :param channel_name: The name for the logging channel
    :type channel_name: str

    .. note:: You need a running :class:`tcp_server_stp` listening at the given IP and Port to be able to communicate.

    **Example:**

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__stp_client.py

    .. literalinclude:: tcp_socket/_examples_/tcp_socket__stp_client.log
    """
    pass
