#!python
"""cPing concurrently checks if hosts are responding using ICMP echo or TCP SYN.

Todo:
    * Fix ICMP echo spawning (new process per ping). Currently causse 1 session
        to be created per ping due to the different ICMP signature of a new
        process."""

# pylint: disable=broad-except  # Don't care about which exception; cleaning up

import collections
import queue
import re
import signal
import socket
import statistics
import subprocess
import sys
import threading
import time

HOST_LEN_MAX = 64  # Max host length to be honored by padding
INTERVAL_MIN = 0.1  # Lower bound on test interval


class Host:
    """Represents a single host and its result"""

    ID = 0

    def __init__(self, host, port, interval, results_length):
        self.hid = Host.ID
        self.host = host
        self.port = port
        self.interval = interval
        self.lock = threading.Lock()
        self.results = collections.deque(maxlen=max(1, results_length))
        self.status = None

        Host.ID += 1

    def add_result(self, result):
        """Add a result to the deque making sure to lock it before hand."""
        self.lock.acquire()
        self.results.append(result)
        self.lock.release()

    def check_icmp(self, shutdown):
        """Run ping and add the reply times to the results buffer"""
        def read_fd(stdout, output):
            for line in iter(stdout.readline, b''):
                output.put(line)
            stdout.close()

        command = ['ping', self.host, '-i{}'.format(self.interval)]
        output = queue.Queue()

        ping = subprocess.Popen(command,
                                stdout=subprocess.PIPE,
                                stderr=subprocess.STDOUT)
        threading.Thread(target=read_fd, args=(ping.stdout, output)).start()

        while not shutdown.is_set():
            time.sleep(self.interval)  # Wait for reply first
            reply = False

            while not output.empty():
                line = output.get(False).decode()

                if 'ping: cannot resolve' in line:
                    raise Exception('Host resolution failed')

                match = re.search('bytes from.+time=([.0-9]+) ms', line)

                # Reply within interval range (i.e. not retrospective)
                if match and float(match.group(1)) < self.interval * 1000:
                    reply = True
                    self.add_result(float(match.group(1)))

            if not reply:  # Didn't find a reply within the interval
                self.add_result(-1)

        ping.send_signal(signal.SIGINT)

    def check_tcp(self):
        """Perform TCP test and add the result to the results buffer"""
        try:
            sock_addr = socket.getaddrinfo(self.host, self.port, 0, 0,
                                           socket.IPPROTO_TCP)
            sock = socket.socket(sock_addr[0][0], sock_addr[0][1])
            sock.settimeout(self.interval)

            time_start = time.time()
            sock.connect(sock_addr[0][4])
            self.add_result((time.time() - time_start) * 1000)
        except socket.timeout:
            self.add_result(-1)
        finally:
            if 'sock' in locals():
                sock.close()

    def get_status(self, host_padding=64, stats_padding=8):
        """Return a string that represents the status of the host"""
        status = (self.host).ljust(host_padding) + '    '

        if self.status:  # Status already set (maybe an error)
            return status + self.status

        # Prevent mutations to deque during iteration
        self.lock.acquire()

        # Remove downed pings
        results = list(filter(lambda x: x != -1, self.results))

        if self.results.maxlen > 1:  # Enough results for stats and history
            # Stats; successful pings only
            if results:
                status += '{:.3f}'.format(min(results)).ljust(stats_padding)

                if len(results) > 1:
                    for stat in statistics.mean, max, statistics.stdev:
                        status += '{:.3f}'.format(
                            stat(results)).ljust(stats_padding)

                    hits = [x for x in self.results if x != -1]
                    success_rate = int(len(hits) * 100 / len(self.results))
                    success_rate = '{}%'.format(success_rate).rjust(4)
                    status += success_rate.ljust(stats_padding)
                else:
                    status += '  -'.ljust(stats_padding) * 4
            else:
                status += '  -'.ljust(stats_padding) * 5

            # History; includes failed pings
            for result in self.results:
                if result == -1:
                    status += color('.', False)
                else:
                    status += color('!')
        else:
            status += '{:.3f}'.format(results[0]) if results else '-'

        self.lock.release()

        return status

    def test(self, delay, shutdown):
        """Start the continous ping tests"""
        time.sleep(self.hid * delay)  # Spread tests across interval

        try:
            if self.port == -1:  # ICMP
                self.check_icmp(shutdown)
            else:  # TCP
                while not shutdown.is_set():
                    self.check_tcp()
                    time.sleep(self.interval)
        except socket.gaierror:
            self.status = 'Host resolution failed'
        except Exception as exception:
            self.status = str(exception)


def args_init():
    """Initialzes arguments and returns the output of `parse_args`"""
    import argparse

    parser = argparse.ArgumentParser()

    parser.add_argument('host',
                        type=str,
                        nargs='+',
                        help='one or more hosts to ping')
    parser.add_argument('-i',
                        metavar='sec',
                        type=float,
                        help='ping interval (default: %(default)s)',
                        default=1)
    parser.add_argument('-l',
                        metavar='len',
                        type=int,
                        help='results history length (default: %(default)s)',
                        default=25)
    parser.add_argument('-p',
                        metavar='port',
                        type=int,
                        help='test using TCP SYN (default: ICMP echo)',
                        default=-1)

    args = parser.parse_args()
    args.i = max(INTERVAL_MIN, args.i)

    return args


def color(string, success=True):
    """Colors the string green if its a success, or red if its not."""
    return '\033[' + ('32m' if success else '31m') + string + '\033[0m'


def main():
    """Main entry point"""
    args = args_init()

    if args.p != -1 and not 0 < args.p < 65536:
        return 'Port outside of range 1-65535'

    delay = args.i / len(args.host)
    host_padding = min(HOST_LEN_MAX, max([len(x) for x in args.host]))
    hosts = [Host(x, args.p, args.i, args.l) for x in args.host]
    shutdown = threading.Event()

    for host in hosts:
        threading.Thread(target=host.test, args=(delay, shutdown)).start()

    try:
        while True:
            for host in hosts:
                print(host.get_status(host_padding))

            time.sleep(args.i)

            print('\033[{}A'.format(len(hosts)), end='')  # Move to beginning
            print('\033[J', end='')  # Clear 'til end of screen
    except KeyboardInterrupt:  # Break out of the application
        shutdown.set()
        print()


if __name__ == '__main__':
    sys.exit(main())
