from io import UnsupportedOperation
from nesim.devices.ip_packet_sender import IPPacketSender
from nesim.devices.router import Route, Router
from nesim.ip import IP
from typing import Dict, List
from nesim.devices.switch import Switch
from nesim.devices.hub import Hub
from nesim.devices import Device, Duplex, Host
import nesim.utils as utils


class NetSimulation():
    """
    Clase principal encargada de ejecutar una simulación.

    Parameters
    ----------
    output_path : str
        Ruta donde se guardarán los logs de la simulación al finalizar.
        la misma. (Por defecto es ``output``).
    """

    def __init__(self, output_path: str = 'output'):
        utils.check_config()
        self.instructions = []
        self.signal_time = utils.CONFIG['signal_time']
        self.output_path = output_path
        self.inst_index = 0
        self.time = 0
        self.pending_devices = []
        self.port_to_device: Dict[str, Device] = {}
        self.devices: Dict[str, Device] = {}
        self.disconnected_devices: Dict[str, Device] = {}
        self.hosts: Dict[str, Host] = {}
        self.end_delay = self.signal_time

    @property
    def is_running(self):
        """
        bool : Indica si la simulación todavía está en ejecución.
        """

        device_sending = any([d.is_active for d in self.devices.values()])
        running = self.instructions or device_sending
        if not running:
            self.end_delay -= 1
        return self.end_delay > 0

    def add_device(self, device: Device):
        """
        Añade un dispositivo a la simulación.

        Parameters
        ----------
        device : Device
            Dispositivo a añadir.
        """

        if device.name in self.devices.keys():
            raise ValueError(
                f'The device name {device.name} is already taken.')

        self.devices[device.name] = device

        if isinstance(device, Host):
            self.hosts[device.name] = device

        for port in device.ports.keys():
            self.port_to_device[port] = device

    def connect(self, port1, port2):
        """
        Conecta dos puertos mediante un cable.

        Parameters
        ----------
        port1, port2 : str
            Nombres de los puertos a conectar.
        """

        if port1 not in self.port_to_device.keys():
            raise ValueError(f'Unknown port {port1}')

        if port2 not in self.port_to_device.keys():
            raise ValueError(f'Unknown port {port2}')

        dev1 = self.port_to_device[port1]
        dev2 = self.port_to_device[port2]

        if dev1.name in self.disconnected_devices.keys():
            self.disconnected_devices.pop(dev1.name)
            self.add_device(dev1)
        if dev2.name in self.disconnected_devices.keys():
            self.disconnected_devices.pop(dev2.name)
            self.add_device(dev2)

        is_simple = isinstance(dev1, Hub) or isinstance(dev2, Hub)
        cab = Duplex(simple=is_simple)
        dev1.sim_time = self.time
        dev2.sim_time = self.time
        self.port_to_device[port1].connect(cab.head_1, port1)
        self.port_to_device[port2].connect(cab.head_2, port2)

    def send(self, host_name: str, data: List[int],
             package_size: int = 8):
        """
        Ordena a un host a enviar una serie de datos determinada.

        Parameters
        ----------
        host_name : str
            Nombre del host que enviará la información.
        data : List[int]
            Datos a enviar.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send(data, package_size)

    def send_frame(self, host_name: str, mac: List[int], data: List[int]):
        """
        Ordena a un host a enviar un frame determinado a una dirección mac
        determinada.

        Parameters
        ----------
        host_name : str
            Nombre del host que envía la información.
        mac : List[int]
            Mac destino.
        data : List[int]
            Frame a enviar.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send_frame(mac, data)

    def send_ip_package(self, host_name: str, ip_dest: IP, data: List[int]):
        """
        Env'ia un paquete IP a una dirección determinada.

        Parameters
        ----------
        host_name : str
            Host que envía el paquete.
        ip_dest : IP
            Dirección IP destino.
        data : List[int]
            Datos a enviar.

        Raises
        ------
        ValueError
            Si el host no existe.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send_by_ip(ip_dest, data)

    def ping_to(self, host_name: str, ip_dest: IP):
        """
        Ejecuta la instrucción ``ping``.

        Parameters
        ----------
        host_name : str
            Host que ejecuta la acción.
        ip_dest : IP
            IP destino.

        Raises
        ------
        ValueError
            Si el host no existe.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send_ping_to(ip_dest)

    def route(self, device_name: str, action: str = 'reset',
              route: Route = None):
        """
        Ejecuta una de las acciones realcionadas con las rutas: ``add``,
        ``remove``, ``reset``

        Parameters
        ----------
        device_name : str
            Nombre del dispositivo al que se le ejecuta la acción.
        action : str, optional
            Acción a ejecutar.
        route : Route, optional
            Ruta a añadir o eliminar.
        """

        router: Router = self.devices[device_name]
        if action == 'add':
            router.add_route(route)
        elif action == 'remove':
            router.remove_route(route)
        else:
            router.reset_routes()

    def disconnect(self, port: str):
        """
        Desconecta un puerto.

        Parameters
        ----------
        port : str
            Puerto a desconectar.
        """

        if port not in self.port_to_device.keys():
            raise ValueError(f'Unknown port {port}')

        dev = self.port_to_device[port]
        dev.disconnect(port)

        if dev.name in self.hosts.keys():
            self.hosts.pop(dev.name)
            self.devices.pop(dev.name)
            self.disconnected_devices[dev.name] = dev
            return

        if isinstance(dev, Hub):
            for cable in dev.ports.values():
                if cable is not None:
                    break
            else:
                self.devices.pop(dev.name)
                self.disconnected_devices[dev.name] = dev

        if isinstance(dev, Switch):
            for send_receiver in dev.ports.values():
                if send_receiver.cable_head is not None:
                    break
            else:
                self.devices.pop(dev.name)
                self.disconnected_devices[dev.name] = dev

    def start(self, instructions):
        """
        Comienza la simulación dada una lista de instrucciones.

        Parameters
        ----------
        instructions : List[Instruction]
            Lista de instrucciones a ejecutar en la simulación.
        """

        self.instructions = instructions
        self.time = 0
        while self.is_running:
            self.update()
        for device in self.devices.values():
            device.save_log(self.output_path)

    def assign_mac_addres(self, device_name, mac, interface):
        """
        Asigna una dirección mac a un host.

        Parameters
        ----------
        device_name : str
            Nombre del dispositivo al cual se le asigna la dirección mac.
        mac : List[int]
            Dirección mac.
        """

        self.devices[device_name].mac_addrs[interface] = mac

    def assign_ip_addres(self, device_name, ip: IP, mask: IP, interface: int):
        """
        Asigna una dirección mac a un host.

        Parameters
        ----------
        device_name : str
            Nombre del dispositivo al cual se le asigna la dirección mac.
        mac : List[int]
            Dirección mac.
        """

        device: IPPacketSender = self.devices[device_name]
        if not isinstance(device, IPPacketSender):
            raise UnsupportedOperation(f'Can not set ip to {device_name}')

        device.ips[interface] = ip
        device.masks[interface] = mask

    def update(self):
        """
        Ejecuta un ciclo de la simulación actualizando el estado de la
        misma.

        Esta función se ejecuta una vez por cada milisegundo simulado.
        """

        current_insts = []
        while self.instructions and self.time == self.instructions[0].time:
            current_insts.append(self.instructions.pop(0))

        for instr in current_insts:
            instr.execute(self)

        for device in self.devices.values():
            device.reset()

        for host in self.hosts.values():
            host.update(self.time)

        for dev in self.devices.values():
            if isinstance(dev, Switch) or type(dev) == Router:
                dev.update(self.time)

        for _ in range(len(self.devices)):
            for device in self.devices.values():
                if isinstance(device, Hub):
                    device.update(self.time)

        for dev in self.devices.values():
            if isinstance(dev, Switch) or type(dev) == Router:
                dev.receive()

        for host in self.hosts.values():
            host.receive()

        self.time += 1

from io import UnsupportedOperation
from nesim.devices.ip_packet_sender import IPPacketSender
from nesim.devices.router import Route, Router
from nesim.ip import IP
from typing import Dict, List
from nesim.devices.switch import Switch
from nesim.devices.hub import Hub
from nesim.devices import Device, Duplex, Host
import nesim.utils as utils


class NetSimulation():
    """
    Clase principal encargada de ejecutar una simulación.

    Parameters
    ----------
    output_path : str
        Ruta donde se guardarán los logs de la simulación al finalizar.
        la misma. (Por defecto es ``output``).
    """

    def __init__(self, output_path: str = 'output'):
        utils.check_config()
        self.instructions = []
        self.signal_time = utils.CONFIG['signal_time']
        self.output_path = output_path
        self.inst_index = 0
        self.time = 0
        self.pending_devices = []
        self.port_to_device: Dict[str, Device] = {}
        self.devices: Dict[str, Device] = {}
        self.disconnected_devices: Dict[str, Device] = {}
        self.hosts: Dict[str, Host] = {}
        self.end_delay = self.signal_time

    @property
    def is_running(self):
        """
        bool : Indica si la simulación todavía está en ejecución.
        """

        device_sending = any([d.is_active for d in self.devices.values()])
        running = self.instructions or device_sending
        if not running:
            self.end_delay -= 1
        return self.end_delay > 0

    def add_device(self, device: Device):
        """
        Añade un dispositivo a la simulación.

        Parameters
        ----------
        device : Device
            Dispositivo a añadir.
        """

        if device.name in self.devices.keys():
            raise ValueError(
                f'The device name {device.name} is already taken.')

        self.devices[device.name] = device

        if isinstance(device, Host):
            self.hosts[device.name] = device

        for port in device.ports.keys():
            self.port_to_device[port] = device

    def connect(self, port1, port2):
        """
        Conecta dos puertos mediante un cable.

        Parameters
        ----------
        port1, port2 : str
            Nombres de los puertos a conectar.
        """

        if port1 not in self.port_to_device.keys():
            raise ValueError(f'Unknown port {port1}')

        if port2 not in self.port_to_device.keys():
            raise ValueError(f'Unknown port {port2}')

        dev1 = self.port_to_device[port1]
        dev2 = self.port_to_device[port2]

        if dev1.name in self.disconnected_devices.keys():
            self.disconnected_devices.pop(dev1.name)
            self.add_device(dev1)
        if dev2.name in self.disconnected_devices.keys():
            self.disconnected_devices.pop(dev2.name)
            self.add_device(dev2)

        is_simple = isinstance(dev1, Hub) or isinstance(dev2, Hub)
        cab = Duplex(simple=is_simple)
        dev1.sim_time = self.time
        dev2.sim_time = self.time
        self.port_to_device[port1].connect(cab.head_1, port1)
        self.port_to_device[port2].connect(cab.head_2, port2)

    def send(self, host_name: str, data: List[int],
             package_size: int = 8):
        """
        Ordena a un host a enviar una serie de datos determinada.

        Parameters
        ----------
        host_name : str
            Nombre del host que enviará la información.
        data : List[int]
            Datos a enviar.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send(data, package_size)

    def send_frame(self, host_name: str, mac: List[int], data: List[int]):
        """
        Ordena a un host a enviar un frame determinado a una dirección mac
        determinada.

        Parameters
        ----------
        host_name : str
            Nombre del host que envía la información.
        mac : List[int]
            Mac destino.
        data : List[int]
            Frame a enviar.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send_frame(mac, data)

    def send_ip_package(self, host_name: str, ip_dest: IP, data: List[int]):
        """
        Env'ia un paquete IP a una dirección determinada.

        Parameters
        ----------
        host_name : str
            Host que envía el paquete.
        ip_dest : IP
            Dirección IP destino.
        data : List[int]
            Datos a enviar.

        Raises
        ------
        ValueError
            Si el host no existe.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send_by_ip(ip_dest, data)

    def ping_to(self, host_name: str, ip_dest: IP):
        """
        Ejecuta la instrucción ``ping``.

        Parameters
        ----------
        host_name : str
            Host que ejecuta la acción.
        ip_dest : IP
            IP destino.

        Raises
        ------
        ValueError
            Si el host no existe.
        """

        if host_name not in self.hosts.keys():
            raise ValueError(f'Unknown host {host_name}')

        self.hosts[host_name].send_ping_to(ip_dest)

    def route(self, device_name: str, action: str = 'reset',
              route: Route = None):
        """
        Ejecuta una de las acciones realcionadas con las rutas: ``add``,
        ``remove``, ``reset``

        Parameters
        ----------
        device_name : str
            Nombre del dispositivo al que se le ejecuta la acción.
        action : str, optional
            Acción a ejecutar.
        route : Route, optional
            Ruta a añadir o eliminar.
        """

        router: Router = self.devices[device_name]
        if action == 'add':
            router.add_route(route)
        elif action == 'remove':
            router.remove_route(route)
        else:
            router.reset_routes()

    def disconnect(self, port: str):
        """
        Desconecta un puerto.

        Parameters
        ----------
        port : str
            Puerto a desconectar.
        """

        if port not in self.port_to_device.keys():
            raise ValueError(f'Unknown port {port}')

        dev = self.port_to_device[port]
        dev.disconnect(port)

        if dev.name in self.hosts.keys():
            self.hosts.pop(dev.name)
            self.devices.pop(dev.name)
            self.disconnected_devices[dev.name] = dev
            return

        if isinstance(dev, Hub):
            for cable in dev.ports.values():
                if cable is not None:
                    break
            else:
                self.devices.pop(dev.name)
                self.disconnected_devices[dev.name] = dev

        if isinstance(dev, Switch):
            for send_receiver in dev.ports.values():
                if send_receiver.cable_head is not None:
                    break
            else:
                self.devices.pop(dev.name)
                self.disconnected_devices[dev.name] = dev

    def start(self, instructions):
        """
        Comienza la simulación dada una lista de instrucciones.

        Parameters
        ----------
        instructions : List[Instruction]
            Lista de instrucciones a ejecutar en la simulación.
        """

        self.instructions = instructions
        self.time = 0
        while self.is_running:
            self.update()
        for device in self.devices.values():
            device.save_log(self.output_path)

    def assign_mac_addres(self, device_name, mac, interface):
        """
        Asigna una dirección mac a un host.

        Parameters
        ----------
        device_name : str
            Nombre del dispositivo al cual se le asigna la dirección mac.
        mac : List[int]
            Dirección mac.
        """

        self.devices[device_name].mac_addrs[interface] = mac

    def assign_ip_addres(self, device_name, ip: IP, mask: IP, interface: int):
        """
        Asigna una dirección mac a un host.

        Parameters
        ----------
        device_name : str
            Nombre del dispositivo al cual se le asigna la dirección mac.
        mac : List[int]
            Dirección mac.
        """

        device: IPPacketSender = self.devices[device_name]
        if not isinstance(device, IPPacketSender):
            raise UnsupportedOperation(f'Can not set ip to {device_name}')

        device.ips[interface] = ip
        device.masks[interface] = mask

    def update(self):
        """
        Ejecuta un ciclo de la simulación actualizando el estado de la
        misma.

        Esta función se ejecuta una vez por cada milisegundo simulado.
        """

        current_insts = []
        while self.instructions and self.time == self.instructions[0].time:
            current_insts.append(self.instructions.pop(0))

        for instr in current_insts:
            instr.execute(self)

        for device in self.devices.values():
            device.reset()

        for host in self.hosts.values():
            host.update(self.time)

        for dev in self.devices.values():
            if isinstance(dev, Switch) or type(dev) == Router:
                dev.update(self.time)

        for _ in range(len(self.devices)):
            for device in self.devices.values():
                if isinstance(device, Hub):
                    device.update(self.time)

        for dev in self.devices.values():
            if isinstance(dev, Switch) or type(dev) == Router:
                dev.receive()

        for host in self.hosts.values():
            host.receive()

        self.time += 1

Clase principal encargada de ejecutar una simulación.      Parameters     ----------     output_path : str         Ruta donde se guardarán los logs de la simulación al finalizar.         la misma. (Por defecto es ``output``). bool : Indica si la simulación todavía está en ejecución. Añade un dispositivo a la simulación.          Parameters         ----------         device : Device             Dispositivo a añadir. Conecta dos puertos mediante un cable.          Parameters         ----------         port1, port2 : str             Nombres de los puertos a conectar. Ordena a un host a enviar una serie de datos determinada.          Parameters         ----------         host_name : str             Nombre del host que enviará la información.         data : List[int]             Datos a enviar. Ordena a un host a enviar un frame determinado a una dirección mac         determinada.          Parameters         ----------         host_name : str             Nombre del host que envía la información.         mac : List[int]             Mac destino.         data : List[int]             Frame a enviar. Env'ia un paquete IP a una dirección determinada.          Parameters         ----------         host_name : str             Host que envía el paquete.         ip_dest : IP             Dirección IP destino.         data : List[int]             Datos a enviar.          Raises         ------         ValueError             Si el host no existe. Ejecuta la instrucción ``ping``.          Parameters         ----------         host_name : str             Host que ejecuta la acción.         ip_dest : IP             IP destino.          Raises         ------         ValueError             Si el host no existe. Ejecuta una de las acciones realcionadas con las rutas: ``add``,         ``remove``, ``reset``          Parameters         ----------         device_name : str             Nombre del dispositivo al que se le ejecuta la acción.         action : str, optional             Acción a ejecutar.         route : Route, optional             Ruta a añadir o eliminar. Desconecta un puerto.          Parameters         ----------         port : str             Puerto a desconectar. Comienza la simulación dada una lista de instrucciones.          Parameters         ----------         instructions : List[Instruction]             Lista de instrucciones a ejecutar en la simulación. Asigna una dirección mac a un host.          Parameters         ----------         device_name : str             Nombre del dispositivo al cual se le asigna la dirección mac.         mac : List[int]             Dirección mac. Asigna una dirección mac a un host.          Parameters         ----------         device_name : str             Nombre del dispositivo al cual se le asigna la dirección mac.         mac : List[int]             Dirección mac. Ejecuta un ciclo de la simulación actualizando el estado de la         misma.          Esta función se ejecuta una vez por cada milisegundo simulado.