#!/usr/bin/env python3
#
# Monitor pScheduler's Schedule
#


import curses
import datetime
import optparse
import os
import pscheduler
import time

#
# Gargle the arguments
#

usage = "Usage: %prog [options]"
opt_parser = optparse.OptionParser(usage = usage)
opt_parser.disable_interspersed_args()

# GENERAL OPTIONS

opt_parser.add_option("--bind",
                      help="Make the request from the provided address",
                      default=None,
                      action="store", type="string",
                      dest="bind")

opt_parser.add_option("--color",
                      help="Use colors if the terminal supports it.",
                      default=True,
                      action="store_true",
                      dest="color")
opt_parser.add_option("--no-color",
                      help="Don't use colors even if the terminal supports it.",
                      action="store_false",
                      dest="color")

opt_parser.add_option("--delta",
                      help="Show times as deltas from now",
                      # New default behavior
                      default=True,
                      action="store_true",
                      dest="delta")

opt_parser.add_option("--absolute",
                      help="Show absolute times",
                      action="store_false",
                      dest="delta")

opt_parser.add_option("--past",
                      help="Percentage of available screen lines to occupy with past runs (0..100)",
                      action="store", type="int",
                      default=50,
                      dest="past")

opt_parser.add_option("--host",
                      help="pScheduler host",
                      action="store", type="string",
                      default=pscheduler.api_local_host(),
                      dest="host")

opt_parser.add_option("--refresh",
                      help="Seconds between refreshes of the schedule data",
                      action="store", type="int",
                      default=3,
                      dest="refresh")


(options, remaining_args) = opt_parser.parse_args()

if len(remaining_args) != 0:
    opt_parser.print_usage()
    pscheduler.fail()

if options.past < 0 or options.past > 100:
    pscheduler.fail("Invalid --past; must be in 0..100")
past_fraction = float(options.past) / 100.0

if options.refresh <= 0:
    pscheduler.fail("Invalid --refresh; must be > 0")
update_interval = datetime.timedelta(seconds=options.refresh)


# What the server thinks its hostname is
server_hostname = "Unknown Host"

#
# Some utilities
#


def center_on_line(screen, line, string):
    """Center text on a specified line on the screen."""
    height, width = screen.getmaxyx()
    screen.addstr(line, int((width - len(string))/2), string)



#
# Color Handling
#

COLOR_PAIRS = {
    "default": {
        "background": curses.COLOR_BLACK,
        "foreground": curses.COLOR_WHITE,
        "attrs": curses.A_NORMAL
    },
    "run": {
        "background": curses.COLOR_BLACK,
        "foreground": curses.COLOR_GREEN,
        "attrs": curses.A_NORMAL
    },
    "error": {
        "background": curses.COLOR_RED,
        "foreground": curses.COLOR_WHITE,
        "attrs": curses.A_BOLD
    },
    "soft-error": {
        "background": curses.COLOR_YELLOW,
        "foreground": curses.COLOR_BLACK,
        "attrs": curses.A_BOLD
    },
}


def init_color_pairs():
    curses.start_color()
    number = 0
    for key in COLOR_PAIRS:
        pair = COLOR_PAIRS[key]
        if curses.has_colors() and options.color:
            number += 1
            curses.init_pair(number,
                             pair["foreground"], pair["background"])
            COLOR_PAIRS[key]["final"] \
                = curses.color_pair(number) | pair["attrs"]
        else:
            COLOR_PAIRS[key]["final"] = pair["attrs"]

def color_pair(name):
    if name not in COLOR_PAIRS:
        name = "default"

    return COLOR_PAIRS[name]["final"]


#
# Screen Construction
#

def draw_static(screen):
    """Draw all of the static content on the screen."""

    def place_text(width):
        """Place all of the text"""

        columns_left = width

        # Put whatever will fit on the screen

        # Time (will be filled in by update_time)

        time_string = pscheduler.datetime_as_iso8601(pscheduler.time_now())
        time_len = len(time_string)
        columns_left -= time_len + 2
        if columns_left < 0:
            return


        # Host

        host_len = len(server_hostname)
        columns_left -= host_len + 2
        if columns_left < 0:
            return
        screen.addstr(0, width-host_len, server_hostname, color_pair("default"))


        # Title

        title_string = "pScheduler Monitor"
        title_len = len(title_string)

        # Not enough room for the title at all.
        if columns_left < title_len:
            return

        larger_side = max(time_len, host_len)
        if columns_left > title_len * 1.5:
            # Enough room to center
            center_on_line(screen, 0, title_string)
        else:
            # Squeeze it in between the two strings.
            screen.addstr(0,
                          time_len + 2 + int(((columns_left - title_len) / 2)),
                          title_string)



    height, width = screen.getmaxyx()
    screen.clear()
    place_text(width)
    if height > 1:
        screen.hline(1, 0, curses.ACS_HLINE, width)

    screen.refresh()



def update_time(screen):
    """Update the time in the status bar."""
    _, width = screen.getmaxyx()
    time_str = pscheduler.datetime_as_iso8601(pscheduler.time_now())
    if len(time_str) > width:
        time_str = "%s%s" % (time_str[:width-1], "+")
    if width > 2:
        screen.addstr(0, 0, time_str, color_pair("default"))
        screen.refresh()



def update_tasks(screen):
    """Update the list of displayed tasks."""

    height, width = screen.getmaxyx()

    # Put something in the corner to show we're busy
    loading_str = "  [ LOADING ]"
    screen.addstr(0, width-len(loading_str), loading_str)
    screen.refresh()

    # Ping it first.

    status, schedule = pscheduler.url_get(
        pscheduler.api_url(host=options.host, path="/monitor"),
        params={"window": height},
        bind=options.bind,
        throw=False)

    # Easy outs

    error_lines = []

    if status != 200:
        error_lines = ["Unable to read schedule from server.",
                       "",
                       "Error code %d" % status,
                       "",
                       schedule.replace("\n", "  ")]
    elif len(schedule) == 0:
        error_lines = ["Schedule is empty."]

    if error_lines:
        screen.clear()
        line = int((height - len(error_lines)) / 2)
        for text in error_lines:
            if len(text) > width:
                text = "%s%s" % (text[:width-3], "...")
            center_on_line(screen, line, text)
            line += 1
        screen.refresh()
        return


    # Sort the schedule into bins (what was, what is, what shall be)

    past = []
    present = []
    future = []

    for run in schedule:
        ppf = run["ppf"]
        if ppf < 0:
            past.append(run)
        elif ppf == 0:
            present.append(run)
        else:
            future.append(run)


    # Dole out screen real estate.

    # Curses uses zero-based geometry.
    rows_left = height
    last_row = height - 1

    # Things running take top priority
    if len(present) > rows_left:
        present = present[:rows_left]
    rows_left -= len(present)
    assert rows_left >= 0, "Left = %d" % rows_left

    # Past things take up to some fraction of what's left.
    if rows_left > 0 and past_fraction > 0:
        past_max = int(rows_left * past_fraction)
        past_len = min(past_max, rows_left)
        past = past[-past_len:]
        rows_left -= len(past)
    else:
        past = []

    # Things in the future get whatever's left.
    if rows_left > 0:
        if len(future) > rows_left:
            future = future[:rows_left]
        rows_left -= len(future)
    else:
        future = []

    assert rows_left >= 0, "Consumed %d rows too many (PPF=%d/%d/%d)" % (
        -rows_left, len(past), len(present), len(future))

    # Assemble the three arrays into what will be displayed

    if past:
        past[-1]["delimit"] = True
    show_schedule = past

    if present and future:
        present[-1]["delimit"] = True
    show_schedule.extend(present)

    show_schedule.extend(future)

    assert len(show_schedule) <= height

    screen.clear()

    row = 0
    time_now = pscheduler.time_now()
    for run in show_schedule:

        cli = [ run["task"]["test"]["type"] ]
        try:
            cli.extend(run["cli"])
        except KeyError:
            cli.append("(CLI not available)")

        run_time = run["end-time"] if run["ppf"] == -1 else run["start-time"]

        if options.delta:
            run_time_dt = pscheduler.iso8601_as_datetime(run_time)
            time_delta = abs(run_time_dt - time_now)
            sign = "+" if run_time_dt >= time_now else "-"
            display_time = "%s%s" \
                           % (sign, pscheduler.timedelta_format(time_delta))
        else:
            display_time = run_time

        # Priority, if there is one.
        pri_value = run.get("priority", None)
        if pri_value is not None:
            priority = " %+4d" % (pri_value)
        else:
            priority = "     "

        # Pad the string out to the width so inverse shows properly.
        line = "%s%s %-12s %s" % (display_time, priority,
                                run["state-display"], " ".join(cli))
        line = "%s%s" % (line, " " * (width-len(line)))

        if len(line) > width:
            line = "%s%s" % (line[:width-1], "+")

        state = run["state"]

        if state in ["pending", "on-deck"]:
            line_attr = color_pair("default")
        elif state in ["running", "cleanup"]:
            line_attr = color_pair("run")
        elif state in ["overdue", "missed", "failed"]:
            line_attr = color_pair("error")
        elif state in ["preempted", "nonstart"]:
            line_attr = color_pair("soft-error")
        else:
            line_attr = color_pair("default")

        try:
            if run["delimit"]:
                line_attr = line_attr | curses.A_UNDERLINE
        except KeyError:
            pass

        # The last row gets chopped by one character because curses
        # doesn't allow writing to the last character on the screen
        # with addstr.
        if row == last_row:
            line = line[:-1]

        # This is for debugging screen overflow issues
        #line = "Row %d  Height=%d  Left=%d" % (row, height, rows_left)

        screen.addstr(row, 0, line, line_attr)

        row += 1

    screen.refresh()



#
# Main Program
#


# Make sure the destination is up and speaks pScheduler.

up, reason = pscheduler.api_ping(options.host, bind=options.bind)

if not up:
    pscheduler.fail("Cannot reach pScheduler on %s: %s"
                    % (options.host, reason))


# Get the server's idea of its name for use in the display.

hostname_url = pscheduler.api_url(options.host, path="/hostname")
status, result = pscheduler.url_get(hostname_url,
                                             bind=options.bind,
                                             throw=False,
                                             timeout=5)
if status != 200:
    pscheduler.fail("Unable to contact pScheduler on %s: %s"
                    % (options.host, result))
server_hostname = result



# Make sure the terminal is capable of everything we want.

curses.setupterm()

for cap in [
        (curses.tigetstr,  "clear"),
        (curses.tigetstr,  "cup"),
        ]:
    (check_fun, cap_name) = cap
    result = check_fun(cap_name)
    if result in [ None, -2, -1, 0 ]:
        pscheduler.fail("This terminal '%s' lacks features needed to run this program."
                        % os.environ.get("TERM", "Unknown"))

try:

    stdscr = curses.initscr()

    init_color_pairs()

    stdscr.nodelay(1)

    # Make the cursor invisible only if the terminal supports it.
    if curses.tigetstr("civis"):
        curses.curs_set(0)

    curses.cbreak()
    curses.noecho()
    task_window = stdscr.subwin(2, 0)

    draw_static(stdscr)
    update_tasks(task_window)

    last_update = pscheduler.time_now() - update_interval

    while True:

        input = stdscr.getch()

        if input == -1:
            pass
        elif input == curses.KEY_RESIZE:
            stdscr.clear()
            task_window = stdscr.subwin(2, 0)
            draw_static(stdscr)
            # TODO: This causes lots of fetches during resizes.
            update_tasks(task_window)
            continue
        elif chr(input) == " ":
            update_tasks(task_window)
        elif chr(input) in ["Q", "q", "\033"]:
            break

        update_time(stdscr)
        if pscheduler.time_now() > last_update + update_interval:
            update_tasks(task_window)
            last_update = pscheduler.time_now()

        time.sleep(0.1)

except KeyboardInterrupt:
    pass

finally:
    curses.nocbreak()
    stdscr.keypad(0)
    curses.echo()
    curses.endwin()

pscheduler.succeed()
