"""
mitop.py is a version of the UNIX top command that knows how to display process
lists from multiple machines in a single listing.

This is a basic, initial version showing overall program layout. A future
version will extend it to:

    * Only notify the master of changed processes, rather than all processes.
    * Runtime-reconfigurable filters and aggregations handled on the remote
      machines rather than forcing a bottleneck in the master.

"""

import curses
import subprocess
import sys
import time

import mitogen.core
import mitogen.master
import mitogen.select
import mitogen.utils


class Host(object):
    """
    A target host from the perspective of the master process.
    """
    #: String hostname.
    name = None

    #: mitogen.parent.Context used to call functions on the host.
    context = None

    #: mitogen.core.Receiver the target delivers state updates to.
    recv = None

    def __init__(self):
        #: Mapping of pid -> Process() for each process described
        #: in the host's previous status update.
        self.procs = {}


class Process(object):
    """
    A single process running on a target host.
    """
    host = None
    user = None
    pid = None
    ppid = None
    pgid = None
    command = None
    rss = None
    pcpu = None
    rss = None


def child_main(sender, delay):
    """
    Executed on the main thread of the Python interpreter running on each
    target machine, Context.call() from the master. It simply sends the output
    of the UNIX 'ps' command at regular intervals toward a Receiver on master.

    :param mitogen.core.Sender sender:
        The Sender to use for delivering our result. This could target
        anywhere, but the sender supplied by the master simply causes results
        to be delivered to the master's associated per-host Receiver.
    """
    args = ['ps', '-axwwo', 'user,pid,ppid,pgid,%cpu,rss,command']
    while True:
        sender.send(subprocess.check_output(args).decode())
        time.sleep(delay)


def parse_output(host, s):
    prev_pids = set(host.procs)

    for line in s.splitlines()[1:]:
        bits = line.split(None, 6)
        pid = int(bits[1])
        new = pid not in prev_pids
        prev_pids.discard(pid)

        try:
            proc = host.procs[pid]
        except KeyError:
            host.procs[pid] = proc = Process()
            proc.hostname = host.name

        proc.new = new
        proc.user = bits[0]
        proc.pid = pid
        proc.ppid = int(bits[2])
        proc.pgid = int(bits[3])
        proc.pcpu = float(bits[4])
        proc.rss = int(bits[5]) / 1024
        proc.command = bits[6]

    # These PIDs had no update, so probably they are dead now.
    for pid in prev_pids:
        del host.procs[pid]


class Painter(object):
    """
    This is ncurses (screen drawing) magic, you can ignore it. :)
    """
    def __init__(self, hosts):
        self.stdscr = curses.initscr()
        curses.start_color()
        self.height, self.width = self.stdscr.getmaxyx()
        curses.cbreak()
        curses.noecho()
        self.stdscr.keypad(1)
        self.hosts = hosts
        self.format = (
            '%(hostname)10.10s '
            '%(pid)7.7s '
            '%(ppid)7.7s '
            '%(pcpu)6.6s '
            '%(rss)5.5s '
            '%(command)20s'
        )

    def close(self):
        curses.endwin()

    def paint(self):
        self.stdscr.erase()
        self.stdscr.addstr(0, 0, time.ctime())

        all_procs = []
        for host in self.hosts:
            all_procs.extend(host.procs.values())

        all_procs.sort(key=(lambda proc: -proc.pcpu))

        self.stdscr.addstr(1, 0, self.format % {
            'hostname': 'HOST',
            'pid': 'PID',
            'ppid': 'PPID',
            'pcpu': '%CPU',
            'rss': 'RSS',
            'command': 'COMMAND',
        })
        for i, proc in enumerate(all_procs):
            if (i+3) >= self.height:
                break
            if proc.new:
                self.stdscr.attron(curses.A_BOLD)
            else:
                self.stdscr.attroff(curses.A_BOLD)
            self.stdscr.addstr(2+i, 0, self.format % dict(
                vars(proc),
                command=proc.command[:self.width-36]
            ))

        self.stdscr.refresh()


def master_main(painter, router, select, delay):
    """
    Loop until CTRL+C is pressed, waiting for the next result delivered by the
    Select. Use parse_output() to turn that result ('ps' command output) into
    rich data, and finally repaint the screen if the repaint delay has passed.
    """
    next_paint = 0
    while True:
        msg = select.get()
        parse_output(msg.receiver.host, msg.unpickle())
        if next_paint < time.time():
            next_paint = time.time() + delay
            painter.paint()


@mitogen.main()
def main(router):
    """
    Main program entry point. @mitogen.main() is just a helper to handle
    reliable setup/destruction of Broker, Router and the logging package.
    """
    argv = sys.argv[1:]
    if not len(argv):
        print('mitop: Need a list of SSH hosts to connect to.')
        sys.exit(1)

    delay = 2.0
    select = mitogen.select.Select(oneshot=False)
    hosts = []

    # For each hostname on the command line, create a Host instance, a Mitogen
    # connection, a Receiver to accept messages from the host, and finally
    # start child_main() on the host to pump messages into the receiver.
    for hostname in argv:
        print('Starting on', hostname)
        host = Host()
        host.name = hostname

        if host.name == 'localhost':
            host.context = router.local()
        else:
            host.context = router.ssh(hostname=host.name)

        # A receiver wires up a handle (via Router.add_handler()) to an
        # internal thread-safe queue object, which can be drained through calls
        # to recv.get().
        host.recv = mitogen.core.Receiver(router)
        host.recv.host = host

        # But we don't want to receive data from just one receiver, we want to
        # receive data from many. In this case we can use a Select(). It knows
        # how to efficiently sleep while waiting for the first message sent to
        # many receivers.
        select.add(host.recv)

        # The inverse of a Receiver is a Sender. Unlike receivers, senders are
        # serializable, so we can call the .to_sender() helper method to create
        # one equivalent to our host's receiver, and pass it directly to the
        # host as a function parameter.
        sender = host.recv.to_sender()

        # Finally invoke the function in the remote target. Since child_main()
        # is an infinite loop, using .call() would block the parent, since
        # child_main() never returns. Instead use .call_async(), which returns
        # another Receiver. We also want to wait for results from it --
        # although child_main() never returns, if it crashes the exception will
        # be delivered instead.
        call_recv = host.context.call_async(child_main, sender, delay)
        call_recv.host = host

        # Adding call_recv to the select will cause mitogen.core.CallError to
        # be thrown by .get() if startup of any context fails, causing halt of
        # master_main(), and the exception to be printed.
        select.add(call_recv)
        hosts.append(host)

    # Painter just wraps up all the prehistory ncurses code and keeps it out of
    # master_main().
    painter = Painter(hosts)
    try:
        try:
            master_main(painter, router, select, delay)
        except KeyboardInterrupt:
            # Shut down gracefully when the user presses CTRL+C.
            pass
    finally:
        painter.close()
