"""
Handles ethernet connections. This file specifically is
intended to listen to tcp and udb packets on the network.
A udp broadcast is used to find the connection, then
tcp packets are used to communicate.

| author     = "Seth Miers"
| copyright  = "Copyright, Xilinx Inc. All rights reserved."
| credits    = ["Seth Miers"]
| license    = "GPL"
| version    = "3.0"
| maintainer = "Seth Miers"
| email      = "sethm@xilinx.com"
| status     = "Development"

"""

##########################################
#               Imports                  #
##########################################

# --------------Packages---------------- #
import socket
try:
    import queue
except ImportError:
    import Queue as queue
import pickle
import base64
import threading
import logging
import time

# Just for testing
import sys

# --------------Local------------------- #


##########################################
#              Main Code                 #
##########################################

class EthernetComm():
    '''
    Handles connections to a single port.
    Sets up a UDP broadcast port so that
    the IP address can be discovered.
    Sets up a number of TCP connections
    and sends/receives to them.

    Args:
        logger (logging): The logger that should be used.
        max_number_of_connections (int): The maximum
            number of TCP connections that can be handled.
        broadcast_port (int): The port that the UDP
            broadcaster should be setup on.
        connection_port (int): The port hat will
            be used by clients to connect to over TCP

    Attributes:
        stop (boolean): Set to true when everything in
            the EthernetComm should stop.
        client_connections (list): A list of all the
            currently connected clients
        send_queue (queue): a queue of all the stuff
            that should be sent over the tcp port
        receive_queue (queue): a queue of all the stuff
            that has been received over the tcp port
        receive_size (int): the maximum length
            that a tcp packet can be.

    Todo:
        * Reduce the number of attributes used
    '''
    # pylint: disable=too-many-instance-attributes
    # 10 is a reasonable number for now
    BROADCAST_RESPONSE = 'bfw_ip_response'
    BROADCAST_MESSAGE = 'bfw_ip_broadcast'

    def __init__(self, logger, max_number_of_connections,
                 broadcast_port, connection_port):
        self.stop = False
        self.client_connections = []
        self.send_queue = queue.Queue()
        self.receive_queue = queue.Queue()
        self.receive_size = 8192

        self.main_thread = None

        self.logger = logger

        self.max_number_of_connections = max_number_of_connections
        self.broadcast_port = broadcast_port
        self.connection_port = connection_port

    def start_client(self):
        '''
        This method creates the necessary thread for a client
        and starts the thread as a daemon.
        Auxiliary functions might use this.

        Attributes:
            main_thread (threading.Thread): The client thread
        '''
        self.main_thread = threading.Thread(target=self.create_connections,
                                            name='client_create_connections')
        self.main_thread.setDaemon(True)
        self.main_thread.start()

    def start_server(self):
        '''
        This method creates the necessary thread for a server
        and starts the thread as a daemon.
        Typically this is only called by the BoardFramework.

        Attributes:
            main_thread (threading.Thread): The server thread
        '''
        self.main_thread = \
            threading.Thread(target=self.listen_for_connections,
                             name='server_listen_for_connections')
        self.main_thread.setDaemon(True)
        self.main_thread.start()

    def create_connections(self):
        '''
        This method is run as a separate thread.
        creating connections is what the user has
        the most control over. Data can be sent.
        This method searches for programs running the board
        framework by using a UDP broadcast.
        Once an IP is found, this function establishes a
        connection using TCP. The program will
        then loop over sending and receiving data from the
        executor and putting that data into the apropriate location.

        Attributes:
            sock (socket): the socket we are currently connecting to.
                At first the sock will be the UDP connection, once a
                executor is found then the sock will be replaced with
                a TCP connection
            server_address (tuple): The subnet range and the port
                to look for executors on.
            message (string): The message to send to the executor
                to confirm that this is a commander
            server (tuple): Stores the IP address of the executor
        '''
        # In case the commander gets disconnected we want to
        # run this in a while loop
        while not self.stop:
            # Create the broadcast socket
            sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
            sock.settimeout(1)

            # Assign the broadcast range and port
            server_address = ('255.255.255.255', self.broadcast_port)
            data, server = ('localhost', 16340)
            # Send out broadcasts until we receive a response
            while not self.stop:
                try:
                    sock.sendto(self.BROADCAST_MESSAGE.encode(),
                                server_address)
                    data, server = sock.recvfrom(self.receive_size)
                    if data.decode('UTF_8') == self.BROADCAST_RESPONSE:
                        break
                except socket.timeout:
                    self.logger.debug("Timed out looking for server, \
                                       trying again...")
            # We know what IP we are connecting to so the socket
            # is no longer needed
            sock.close()

            # Create a new socket for a TCP connection
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.connect((server[0], self.connection_port))
            sock.settimeout(0.1)
            # Add the conneciton to a list
            self.client_connections = [(sock, server[0])]

            # Send and receive commands for the rest of the program
            while not self.stop:
                self.send_all_data()
                self.receive_all_data()
                if not self.client_connections:
                    # Something unexpected happened, time to start over
                    break

            if self.client_connections:
                for (conn, _) in self.client_connections:
                    conn.close()

    def listen_for_connections(self):
        '''
        This method is run as a separate thread.
        First a thread is set up so that the
        IP address can be discovered, then it
        enteres a loop to accept TCP connections.

        Attributes:
            broadcast_listener (threading.Thread): The thread that
                will setup and listen for UDP broadcasts
            sock (socket): the tcp socket that the clients
                will connect to.
            client_handler (threading.thread): The thread that
                will handle each of the tcp connections.
            conn: A new tcp connection
            addr: The  address of a new tcp connection
        '''
        # Setup the thread that listens for UDP broadcasts for discovery
        broadcast_listener = threading.Thread(
            target=self.listen_for_broadcasts,
            name='listen_for_broadcasts')
        broadcast_listener.setDaemon(True)
        broadcast_listener.start()

        # Setup the socket to listen for TCP connections
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.bind(('', self.connection_port))
        sock.listen(self.max_number_of_connections)
        sock.settimeout(0.1)

        # Setup the thread that handles each of the TCP connections
        client_handler = threading.Thread(
            target=self.handle_existing_connections,
            name='client_handler')
        client_handler.setDaemon(True)
        client_handler.start()

        # Continously accept new TCP connections and add them
        # to the connections list
        while not self.stop:
            try:
                conn, addr = sock.accept()
                self.client_connections.append((conn, addr))
                conn.settimeout(0.1)
                self.logger.debug("Successfully establesed \
                                  a new connection")
            except socket.timeout:
                if len(self.client_connections) < 1:
                    self.logger.debug("Timed out while waiting \
                                      for a new connection")
            except socket.error:
                if len(self.client_connections) < 1:
                    self.logger.debug("Errored while waiting \
                                      for a new connection")

        # Stop has been called, program is over
        # join all the threads and close out
        client_handler.join()
        sock.close()
        broadcast_listener.join()

    def listen_for_broadcasts(self):
        '''
        This method is run in a separate thread.
        UDP listener to listen to UDP broadcasts
        and then respond to them acordingly.
        This allows others to discover the IP address
        of this device.

        Attributes:
            sock (socket): the udp socket that the clients
                broadcast to.
            address: The ip and port of the received UDP
                broadcast.
            data: the data received from the udp broadcast
            address: The address of the received udp broadcast
        '''
        sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        address = ('', self.broadcast_port)
        sock.bind(address)
        sock.settimeout(1)

        while not self.stop:
            try:
                data, address = sock.recvfrom(self.receive_size)
                data = str(data.decode('UTF-8'))
                if data == self.BROADCAST_MESSAGE:
                    sock.sendto(self.BROADCAST_RESPONSE.encode(), address)
            except socket.timeout:
                pass
        sock.close()

    def handle_existing_connections(self):
        '''
        This method is run as a separate thread.
        A TCP connection is handled here.
        All data in a queue is sent to the
        connection and all data that has been
        received by the connection is added to a queue.
        '''
        # Loop sending and receiving
        while not self.stop:
            self.send_all_data()
            self.receive_all_data()

        # Close out all of the connections
        # The program is over so we no longer need them
        for (conn, _) in self.client_connections:
            conn.close()

    def send_all_data(self):
        '''
        Takes all the data from the sender queue and sends
        it to all the connections. Sends all the data
        in the queue until the queue is empty or stop has
        been set to True.

        Attributes:
            encode_decode (EncodeDecode): Used to easily convert
                betwen data and raw when communicating between
                commander and executor.
        '''
        if self.client_connections:
            encode_decode = EncodeDecode(self.receive_size)
            # Loop while there is still stuff to send
            while (not self.send_queue.empty()) and (not self.stop):
                try:
                    encode_decode.data = self.send_queue.get(False)
                    self.logger.debug("Sending: " + str(encode_decode.data))
                    for (conn, _) in self.client_connections:
                        conn.sendall(encode_decode.raw)
                except queue.Empty:
                    self.logger.debug("Nothing to send in send_all_data")
        else:
            self.logger.debug("No connections to send to")

    def receive_all_data(self):
        '''
        Receives all the data from the specified connections
        (1 or more). Stores all the data into the specified
        queue. If a connection results in a ConnectionResetError
        then the connection is closed and removed from the
        list of connections.

        Attributes:
            encode_decode (EncodeDecode): Used to easily convert
                betwen data and raw when communicating between
                commander and executor.
            raws (string): The raw data received from one or
                more TCP packets
        '''
        if self.client_connections:
            encode_decode = EncodeDecode(self.receive_size)
            for (conn, addr) in self.client_connections:
                try:
                    # Run this while loop until a timeout occurs
                    # or the program has stopped (usually a timeout)
                    # A timeout indicates there is nothing more to receive
                    while not self.stop:
                        raws = conn.recv(self.receive_size)
                        for raw in raws.splitlines():
                            encode_decode.raw = raw
                            self.logger.debug("Received: " +
                                              str(encode_decode.data))
                            self.receive_queue.put(encode_decode.data)
                # The connection closed out unexpectedly, handle this
                except ConnectionResetError:
                    conn.close()
                    self.client_connections.remove((conn, addr))
                    self.logger.debug("Connection has ended...")
                except (socket.timeout, socket.error):
                    self.logger.debug("Nothing to receive in receive_all_data")
        else:
            self.logger.debug("No connections to receive from")


class EncodeDecode:
    '''
    A special class that automatically converts between base64-pickled
    data and the decrypted data. The getter and setter methods are
    set up in such a way so that all of this encryption and
    decryptin is done behind the scenes.

    Args:
        max_length: The maximum length that the raw encrypted
            data can be. This is generally the same number as the
            packet size in the Ethernet class.
    '''
    def __init__(self, max_length):
        self.max_length = max_length
        # Underscores exist to avoid conflict with the @property
        self._raw = b''
        self._data = None

    @property
    def data(self):
        '''
        When setting data, data is encrypted and that value
        is put into raw.

        Raises:
            ValueError: If the data ecrypted exceeds the max
            length, then an error is thrown
        '''
        return self._data

    @data.setter
    def data(self, new_data):
        if (not isinstance(new_data, tuple)) or (len(new_data) != 3):
            raise ValueError("data must be a tuple structured \
                              as '(ID, type, command)'")
        # Temporary variables to revert to incase the max_length
        # is exceeded.
        old_data = self._data
        self._data = new_data
        encoded_new_data = base64.b64encode(pickle.dumps(self._data))
        # Revert the variable here
        if len(encoded_new_data) > self.max_length:
            self._data = old_data
            raise ValueError("data exceeds max_length")
        # Assign the dummy variable
        self._raw = encoded_new_data + b'\n'

    @property
    def raw(self):
        '''
        When setting raw, raw is decrypted and that value
        is put into data.

        Raises:
            ValueError: If the raw input is not of type
            bytes
            ValueError: If the raw input exceeds the
            max_length
        '''
        return self._raw

    @raw.setter
    def raw(self, new_raw):
        # Make sure the data is acceptable
        if not isinstance(new_raw, bytes):
            raise ValueError("raw must be of type 'bytes'")
        if len(new_raw) > self.max_length:
            raise ValueError("raw exceeds max_length")
        # Assign the dummy variables
        self._raw = new_raw
        self._data = pickle.loads(base64.b64decode(new_raw))


if __name__ == "__main__":
    LOGGER = logging.getLogger()
    LOGGER.setLevel(logging.DEBUG)

    HANDLER = logging.StreamHandler(sys.stdout)
    HANDLER.setLevel(logging.DEBUG)
    FORMATTER = logging.Formatter('%(asctime)s - %(name)s -' +
                                  '%(levelname)s - %(message)s')
    HANDLER.setFormatter(FORMATTER)
    LOGGER.addHandler(HANDLER)

    X = EthernetComm(LOGGER, 5, 16339, 16340)

    if sys.argv[1] == "server":
        print("Server starting...")
        X_THREAD = threading.Thread(target=X.start_server,
                                    name='X_THREAD')
    elif sys.argv[1] == "client":
        print("Client starting...")
        X_THREAD = threading.Thread(target=X.start_client,
                                    name='X_THREAD')
    X_THREAD.setDaemon(True)
    X_THREAD.start()
    while True:
        if sys.argv[1] == "client":
############################################################ # noqa: E115, E266
#               THIS IS WHAT YOU WANT                      # # noqa: E115, E266
            X.send_queue.put(('1', 'sysc', 'IP4\r74\r\t'))
############################################################
        time.sleep(1)
    X_THREAD.join()
