import socket
import struct
from typing import Any, Dict, List, Optional, Tuple
from typing_extensions import Final


class InvalidPacketException(Exception):
    """
    Exception thrown when a packet has invalid information in it that doesn't
    confirm to the spec.
    """


class UnknownPacketException(Exception):
    """
    Exception thrown when a packet is valid, but we don't include support for
    decoding it.
    """


class TCPStream:
    """
    A very rudimentary TCP stream reassembler. Assumes well-formed TCP streams with a
    SYN -> SYNACK -> ACK flow followed by some data followed by a FIN -> FINACK -> ACK
    flow. Luckily, this is exactly what most HTTP requests look like.
    """
    INBOUND: Final[str] = 'inbound'
    OUTBOUND: Final[str] = 'outbound'

    def __init__(self, packet: Dict[str, Any]) -> None:
        """
        Initialize a new stream with a packet that belongs in this stream. Expects a packet
        dictionary as returned by Sniffer.recv_raw().
        """
        self.source_address = packet['ip_header']['source_address']
        self.source_port = packet['tcp_header']['source_port']
        self.destination_address = packet['ip_header']['destination_address']
        self.destination_port = packet['tcp_header']['destination_port']

        self.packets = [(TCPStream.INBOUND, packet)]

    def add_packet(self, packet: Dict[str, Any]) -> bool:
        """
        Add a packet that potentially belongs to this stream. Expects a packet dictionary as
        returned by Sniffer.recv_raw().

        Returns:
            True - If this packet indeed belonged and was added.
            False - If this packet doesn't belong and should go elsewhere.
        """
        if (
            packet['tcp_header']['source_port'] == self.source_port and
            packet['tcp_header']['destination_port'] == self.destination_port and
            packet['ip_header']['source_address'] == self.source_address and
            packet['ip_header']['destination_address'] == self.destination_address
        ):
            self.packets.append((TCPStream.INBOUND, packet))
            return True

        if (
            packet['tcp_header']['source_port'] == self.destination_port and
            packet['tcp_header']['destination_port'] == self.source_port and
            packet['ip_header']['source_address'] == self.destination_address and
            packet['ip_header']['destination_address'] == self.source_address

        ):
            self.packets.append((TCPStream.OUTBOUND, packet))
            return True

        return False

    def reassemble(self) -> Optional[Dict[str, Any]]:
        """
        Attempt to reassemble this stream. If this was successful, you should receive a dictionary containing the
        following keys. If it was not successful, you should receive None. When there is overlapping data, packets
        that were received later will take precidence over packets received earlier.

        Returns:
            None - This stream can't be reassembled.
            Dictionary:
                - source_address - A string representing the IPv4 address of the source
                - destination_address - A string representing the IPv4 address of the destination
                - source_port - An integer representing the TCP source address
                - destination_port - An integer representing the TCP destination address
                - inbound - A binary blob representing inbound traffic, reassembled
                - outbound - A binary blob representing outbound traffic, reassembled
        """
        # This is really crude, just make sure that we get a SYN -> SYN/AC -> ACK, then a FIN -> FIN/ACK -> ACK
        state: Dict[str, Dict[str, Optional[str]]] = {
            TCPStream.INBOUND: {
                'syn': None,
                'fin': None,
            },
            TCPStream.OUTBOUND: {
                'syn': None,
                'fin': None,
            }
        }
        sequence = {
            TCPStream.INBOUND: 0,
            TCPStream.OUTBOUND: 0,
        }

        def other_direction(direction: str) -> str:
            if direction == TCPStream.INBOUND:
                return TCPStream.OUTBOUND
            else:
                return TCPStream.INBOUND

        # Crude state machine to ensure that every SYN was ack'd and every FIN was ack'd. Should probably
        # also check that SYNs are ack'd before FINs but whatever, it works well enough for now.
        for packet in self.packets:
            direction = packet[0]
            other = other_direction(direction)
            syn = packet[1]['tcp_header']['flags']['syn']
            fin = packet[1]['tcp_header']['flags']['fin']
            ack = packet[1]['tcp_header']['flags']['ack']
            seq = packet[1]['tcp_header']['sequence']

            if syn:
                if state[direction]['syn'] is None:
                    state[direction]['syn'] = 'sent'
                    sequence[direction] = seq
            if fin:
                if state[direction]['fin'] is None:
                    state[direction]['fin'] = 'sent'
            if ack:
                if state[other]['syn'] == 'sent':
                    state[other]['syn'] = 'ackd'
                if state[other]['fin'] == 'sent':
                    state[other]['fin'] = 'ackd'

        if (
            state[TCPStream.INBOUND]['syn'] == 'ackd' and
            state[TCPStream.INBOUND]['fin'] == 'ackd' and
            state[TCPStream.OUTBOUND]['syn'] == 'ackd' and
            state[TCPStream.OUTBOUND]['fin'] == 'ackd'
        ):
            # This stream is finished, can be reassembled
            data = {
                TCPStream.INBOUND: b'',
                TCPStream.OUTBOUND: b'',
            }

            def add_data(packet: bytes, data: bytes, offset: int) -> bytes:
                length = len(data)

                if len(packet) < offset:
                    # Pad out, then add
                    packet = packet + b'\0' * (offset - len(packet))
                    return packet + data
                if len(packet) == offset:
                    # Add to end
                    return packet + data
                if len(packet) > offset and len(packet) <= (offset + length):
                    # Truncate, then add
                    packet = packet[:offset]
                    return packet + data
                if len(packet) > (offset + length):
                    before = packet[:offset]
                    after = packet[offset + length:]
                    return before + data + after

                raise Exception('Logic error!')

            for packet in self.packets:
                dir = packet[0]
                syn = packet[1]['tcp_header']['flags']['syn']
                fin = packet[1]['tcp_header']['flags']['fin']
                ack = packet[1]['tcp_header']['flags']['ack']
                seq = packet[1]['tcp_header']['sequence']

                if syn:
                    continue

                # Figure out what this packet has
                length = len(packet[1]['data'])
                position = seq - sequence[dir] - 1

                if length > 0:
                    data[dir] = add_data(data[dir], packet[1]['data'], position)

            return {
                'source_address': self.source_address,
                'destination_address': self.destination_address,
                'source_port': self.source_port,
                'destination_port': self.destination_port,
                TCPStream.INBOUND: data[TCPStream.INBOUND],
                TCPStream.OUTBOUND: data[TCPStream.OUTBOUND],
            }

        return None


class Sniffer:
    """
    A generic python sniffer. Listens to all raw traffic on the machine and parses packets
    down to TCP chunks to be reassembled.
    """
    RECEIVE_SIZE: Final[int] = 1048576
    ETH_HEADER_LENGTH: Final[int] = 14
    IP_HEADER_LENGTH: Final[int] = 20
    TCP_HEADER_LENGTH: Final[int] = 20

    def __init__(self, address: Optional[str]=None, port: Optional[int]=None) -> None:
        """
        Initialize the sniffer. Can be told to filter by address, port or both. If address or
        port is not provided, it defaults to all addresses or ports.

        Parameters:
            address - A string representing an IPv4 address to filter on.
            port - An integer representing a port to filter on.
        """
        self.address = address
        self.port = port
        self.streams: List[TCPStream] = []

        self.sock = socket.socket(
            socket.AF_PACKET,
            socket.SOCK_RAW,
            socket.ntohs(0x0003),
        )

    def __process_ethframe(self, eth_header: bytes) -> Dict[str, Any]:
        """
        Given a raw binary packet, extract the ethernet frame header and return as a dictionary.

        Parameters:
            eth_header - Raw bytes to be parsed, should include at least ETH_HEADER_LENGTH bytes.

        Returns:
            Dictionary:
                - header_length - The actual length in bytes of this header as an integer
                - protocol - An integer representing the protocol encapsulated in this header
        """
        eth = struct.unpack('!6s6sH', eth_header)
        eth_protocol = socket.ntohs(eth[2])

        return {
            'header_length': Sniffer.ETH_HEADER_LENGTH,
            'protocol': eth_protocol,
        }

    def __process_ipframe(self, ip_header: bytes) -> Dict[str, Any]:
        """
        Given a raw binary packet, extract the IP header and return as a dictionary.

        Parameters:
            ip_header - Raw bytes to be parsed, should include at least IP_HEADER_LENGTH bytes.

        Returns:
            Dictionary:
                - header_length - The actual length in bytes of this header as an integer
                - version - Integer IP version, should always be 4
                - length - Integer length of packet including this header
                - ttl - Integer time to live for packet
                - protocol - An integer representing the protocol encapsulated in this header
                - source_address - A string representing the source IPv4 address
                - destination_address - A string representing the destination IPv4 address
        """
        # Extract the 20 bytes IP header, ignoring the IP options
        iph = struct.unpack('!BBHHHBBH4s4s', ip_header)
        version = (iph[0] >> 4) & 0xF
        length = iph[2]
        ihl = (iph[0] & 0xF) * 4
        ttl = iph[5]
        proto = iph[6]

        if ihl < Sniffer.IP_HEADER_LENGTH:
            raise InvalidPacketException(
                f'Invalid IP length {ihl}',
            )

        if version != 4:
            raise UnknownPacketException(
                f'Unknown IP version {version}',
            )

        s_addr = socket.inet_ntoa(iph[8])
        d_addr = socket.inet_ntoa(iph[9])

        return {
            'header_length': ihl,
            'version': version,
            'length': length,
            'ttl': ttl,
            'protocol': proto,
            'source_address': s_addr,
            'destination_address': d_addr,
        }

    def __process_flags(self, flags: int) -> Dict[str, bool]:
        """
        Given an integer bitmask, parse as a TCP flag set, returning a dictionary keyed
        by the flag name who's value is True if set and False otherwise.
        """
        return {
            'ns': True if flags & 0x100 else False,
            'cwr': True if flags & 0x080 else False,
            'ece': True if flags & 0x040 else False,
            'urg': True if flags & 0x020 else False,
            'ack': True if flags & 0x010 else False,
            'psh': True if flags & 0x008 else False,
            'rst': True if flags & 0x004 else False,
            'syn': True if flags & 0x002 else False,
            'fin': True if flags & 0x001 else False,
        }

    def __process_address(self, address: Tuple[int, int, int, int, int]) -> Dict[str, int]:
        """
        Given an address tuple from Linux's recvfrom syscall, return a dict which represents this
        address.

        Parameters:
            address - A tuple as returned by recvfrom()

        Returns:
            Dictionary:
                - interface - The eth interface that this packet was received on
                - protocol - The protocol used to receive this packet
                - type - The packet type, as defined by linux kernel headers
                - hardware_Type - The hardware that received this as an integer
                - address - The hardware address that received this
        """
        return {
            'interface': address[0],
            'protocol': address[1],
            'type': address[2],
            'hardware_type': address[3],
            'address': address[4],
        }

    def __process_tcpframe(self, tcp_header: bytes) -> Dict[str, Any]:
        """
        Given a raw binary packet, extract the TCP header and return as a dictionary.

        Parameters:
            tcp_header - Raw bytes to be parsed, should include at least TCP_HEADER_LENGTH bytes.

        Returns:
            Dictionary:
                - header_length - The actual length in bytes of this header as an integer
                - source_port - An integer representing the source port of this packet
                - destination_port - An integer respresenting the destination port of this packet
                - sequence - An integer representing the current sequence number of this packet
                - acknowledgement - An integer representing the current acknowledgement of this packet
                - flags - A dictionary as defined in Sniffer.__process_flags()
        """
        tcph = struct.unpack('!HHLLBBHHH', tcp_header)

        # Normal stuff
        source_port = tcph[0]
        dest_port = tcph[1]
        sequence = tcph[2]
        acknowledgement = tcph[3]
        tcphl = (tcph[4] >> 4) * 4

        # TCP flags
        flags = ((tcph[4] & 1) << 8) | tcph[5]

        return {
            'header_length': tcphl,
            'source_port': source_port,
            'destination_port': dest_port,
            'sequence': sequence,
            'acknowledgement': acknowledgement,
            'flags': self.__process_flags(flags),
        }

    def __recv_frame(self) -> Dict[str, Any]:
        """
        Grab a packet from the kernel, parse it and return a dictionary representing the parsed
        packet.

        Returns:
            Dictionary:
                - ip_header - A dictionary defined by Sniffer.__process_ipframe()
                - tcp_header - A dictionary defined by Sniffer.__process_tcipframe()
                - data - Raw bytes representing payload of this packet
                - address - A dictionary defined by Sniffer.__process_address()
        """
        # Grab a packet
        packets = self.sock.recvfrom(Sniffer.RECEIVE_SIZE)
        address = self.__process_address(packets[1])
        packet = packets[0]
        offset = 0

        # Make sure its a valid packet
        eth_header = self.__process_ethframe(packet[offset:(offset + Sniffer.ETH_HEADER_LENGTH)])
        offset = offset + eth_header['header_length']

        if eth_header['protocol'] != 8:
            # Not IP
            raise UnknownPacketException(
                f'Unknown frame {eth_header["protocol"]}'
            )

        # Get the IP header
        ip_header = self.__process_ipframe(packet[offset:(offset + Sniffer.IP_HEADER_LENGTH)])
        offset = offset + ip_header['header_length']

        if ip_header['protocol'] != 6:
            # Not TCP
            raise UnknownPacketException(
                f'Unknown protocol {ip_header["protocol"]}',
            )

        # Get TCP header
        tcp_header = self.__process_tcpframe(packet[offset:(offset + Sniffer.TCP_HEADER_LENGTH)])
        offset = offset + tcp_header['header_length']

        # Get payload length
        payload_length = ip_header['length'] - ip_header['header_length'] - tcp_header['header_length']

        # Get payload
        data = packet[offset:offset + payload_length]

        return {
            'ip_header': ip_header,
            'tcp_header': tcp_header,
            'data': data,
            'address': address,
        }

    def recv_raw(self) -> Dict[str, Any]:
        """
        Receive the next packet that fits the filter criteria defined by the Sniffer constructor.

        Returns:
            Dictionary defined by Sniffer.__recv_frame()
        """
        while True:
            try:
                packet = self.__recv_frame()
            except UnknownPacketException:
                continue

            # Hack for sniffing on localhost
            if packet['address']['interface'] == 'lo' and packet['address']['type'] != 4:
                continue

            if self.address and self.port:
                if (
                    packet['ip_header']['source_address'] == self.address and
                    packet['tcp_header']['source_port'] == self.port
                ):
                    return packet
                if (
                    packet['ip_header']['destination_address'] == self.address and
                    packet['tcp_header']['destination_port'] == self.port
                ):
                    return packet
            elif self.address:
                if (
                    packet['ip_header']['source_address'] == self.address or
                    packet['ip_header']['destination_address'] == self.address
                ):
                    return packet
            elif self.port:
                if (
                    packet['tcp_header']['source_port'] == self.port or
                    packet['tcp_header']['destination_port'] == self.port
                ):
                    return packet
            else:
                return packet

    def recv_stream(self) -> Dict[str, Any]:
        """
        Receive the next TCP stream that fits the filter criteria defined by the Sniffer constructor.

        Returns:
            Dictionary defined by TCPStream.reassemble()
        """
        while True:
            # Try to reassemble and return a stream
            for i in range(len(self.streams)):
                tcp = self.streams[i].reassemble()

                if tcp:
                    del self.streams[i]
                    return tcp

            # Receive the next packet
            packet = self.recv_raw()

            # Add to the correct stream
            new = True
            for stream in self.streams:
                if stream.add_packet(packet):
                    new = False
                    break

            # See if this is a new TCP stream
            if new:
                self.streams.append(TCPStream(packet))
