# Shared utility functions across monitors scripts.

import fcntl
import os
import re
import select
import signal
import subprocess
import time

TERM_MSG = 'Console connection unexpectedly lost. Terminating monitor.'


class Error(Exception):
    pass


class InvalidTimestampFormat(Error):
    pass


def prepend_timestamp(msg, format):
    """Prepend timestamp to a message in a standard way.

    Args:
      msg: str; Message to prepend timestamp to.
      format: str or callable; Either format string that
          can be passed to time.strftime or a callable
          that will generate the timestamp string.

    Returns: str; 'timestamp\tmsg'
    """
    if type(format) is str:
        timestamp = time.strftime(format, time.localtime())
    elif callable(format):
        timestamp = str(format())
    else:
        raise InvalidTimestampFormat

    return '%s\t%s' % (timestamp, msg)


def write_logline(logfile, msg, timestamp_format=None):
    """Write msg, possibly prepended with a timestamp, as a terminated line.

    Args:
      logfile: file; File object to .write() msg to.
      msg: str; Message to write.
      timestamp_format: str or callable; If specified will
          be passed into prepend_timestamp along with msg.
    """
    msg = msg.rstrip('\n')
    if timestamp_format:
        msg = prepend_timestamp(msg, timestamp_format)
    logfile.write(msg + '\n')


def make_alert(warnfile, msg_type, msg_template, timestamp_format=None):
    """Create an alert generation function that writes to warnfile.

    Args:
      warnfile: file; File object to write msg's to.
      msg_type: str; String describing the message type
      msg_template: str; String template that function params
          are passed through.
      timestamp_format: str or callable; If specified will
          be passed into prepend_timestamp along with msg.

    Returns: function with a signature of (*params);
        The format for a warning used here is:
            %(timestamp)d\t%(msg_type)s\t%(status)s\n
    """
    def t_format():
        return int(time.time())

    if timestamp_format is None:
        timestamp_format = t_format

    def alert(*params):
        formatted_msg = msg_type + "\t" + msg_template % params
        timestamped_msg = prepend_timestamp(formatted_msg, timestamp_format)
        print >> warnfile, timestamped_msg
    return alert


def _assert_is_all_blank_lines(lines, source_file):
    if sum(len(line.strip()) for line in lines) > 0:
        raise ValueError('warning patterns are not separated by blank lines '
                         'in %s' % source_file)


def _read_overrides(overrides_file):
    """
    Read pattern overrides from overrides_file, which may be None.  Overrides
    files are expected to have the format:
    <old regex> <newline> <new regex> <newline> <newline>
            old regex = a regex from the patterns file
            new regex = the regex to replace it
    Lines beginning with # are ignored.

    Returns a dict mapping old regexes to their replacements.
    """
    if not overrides_file:
        return {}
    overrides_lines = [line for line in overrides_file.readlines()
                       if not line.startswith('#')]
    overrides_pairs = zip(overrides_lines[0::3], overrides_lines[1::3])
    _assert_is_all_blank_lines(overrides_lines[2::3], overrides_file)
    return dict(overrides_pairs)


def build_alert_hooks(patterns_file, warnfile, overrides_file=None):
    """Parse data in patterns file and transform into alert_hook list.

    Args:
      patterns_file: file; File to read alert pattern definitions from.
      warnfile: file; File to configure alert function to write warning to.

    Returns:
      list; Regex to alert function mapping.
          [(regex, alert_function), ...]
    """
    pattern_lines = patterns_file.readlines()
    # expected pattern format:
    # <msgtype> <newline> <regex> <newline> <alert> <newline> <newline>
    #   msgtype = a string categorizing the type of the message - used for
    #             enabling/disabling specific categories of warnings
    #   regex   = a python regular expression
    #   alert   = a string describing the alert message
    #             if the regex matches the line, this displayed warning will
    #             be the result of (alert % match.groups())
    patterns = zip(pattern_lines[0::4], pattern_lines[1::4],
                   pattern_lines[2::4])
    _assert_is_all_blank_lines(pattern_lines[3::4], patterns_file)

    overrides_map = _read_overrides(overrides_file)

    hooks = []
    for msgtype, regex, alert in patterns:
        regex = overrides_map.get(regex, regex)
        regex = re.compile(regex.rstrip('\n'))
        alert_function = make_alert(warnfile, msgtype.rstrip('\n'),
                                    alert.rstrip('\n'))
        hooks.append((regex, alert_function))
    return hooks


def build_alert_hooks_from_path(patterns_path, warnfile):
    """
    Same as build_alert_hooks, but accepts a path to a patterns file and
    automatically finds the corresponding site overrides file if one exists.
    """
    dirname, basename = os.path.split(patterns_path)
    site_overrides_basename = 'site_' + basename + '_overrides'
    site_overrides_path = os.path.join(dirname, site_overrides_basename)
    site_overrides_file = None
    patterns_file = open(patterns_path)
    try:
        if os.path.exists(site_overrides_path):
            site_overrides_file = open(site_overrides_path)
        try:
            return build_alert_hooks(patterns_file, warnfile,
                                     overrides_file=site_overrides_file)
        finally:
            if site_overrides_file:
                site_overrides_file.close()
    finally:
        patterns_file.close()


def process_input(
        input, logfile, log_timestamp_format=None, alert_hooks=()):
    """Continuously read lines from input stream and:

    - Write them to log, possibly prefixed by timestamp.
    - Watch for alert patterns.

    Args:
      input: file; Stream to read from.
      logfile: file; Log file to write to
      log_timestamp_format: str; Format to use for timestamping entries.
          No timestamp is added if None.
      alert_hooks: list; Generated from build_alert_hooks.
          [(regex, alert_function), ...]
    """
    while True:
        line = input.readline()
        if len(line) == 0:
            # this should only happen if the remote console unexpectedly
            # goes away. terminate this process so that we don't spin
            # forever doing 0-length reads off of input
            write_logline(logfile, TERM_MSG, log_timestamp_format)
            break

        if line == '\n':
            # If it's just an empty line we discard and continue.
            continue

        write_logline(logfile, line, log_timestamp_format)

        for regex, callback in alert_hooks:
            match = re.match(regex, line.strip())
            if match:
                callback(*match.groups())


def lookup_lastlines(lastlines_dirpath, path):
    """Retrieve last lines seen for path.

    Open corresponding lastline file for path
    If there isn't one or isn't a match return None

    Args:
      lastlines_dirpath: str; Dirpath to store lastlines files to.
      path: str; Filepath to source file that lastlines came from.

    Returns:
      str; Last lines seen if they exist
      - Or -
      None; Otherwise
    """
    underscored = path.replace('/', '_')
    try:
        lastlines_file = open(os.path.join(lastlines_dirpath, underscored))
    except (OSError, IOError):
        return

    lastlines = lastlines_file.read()
    lastlines_file.close()
    os.remove(lastlines_file.name)
    if not lastlines:
        return

    try:
        target_file = open(path)
    except (OSError, IOError):
        return

    # Load it all in for now
    target_data = target_file.read()
    target_file.close()
    # Get start loc in the target_data string, scanning from right
    loc = target_data.rfind(lastlines)
    if loc == -1:
        return

    # Then translate this into a reverse line number
    # (count newlines that occur afterward)
    reverse_lineno = target_data.count('\n', loc + len(lastlines))
    return reverse_lineno


def write_lastlines_file(lastlines_dirpath, path, data):
    """Write data to lastlines file for path.

    Args:
      lastlines_dirpath: str; Dirpath to store lastlines files to.
      path: str; Filepath to source file that data comes from.
      data: str;

    Returns:
      str; Filepath that lastline data was written to.
    """
    underscored = path.replace('/', '_')
    dest_path = os.path.join(lastlines_dirpath, underscored)
    open(dest_path, 'w').write(data)
    return dest_path


def nonblocking(pipe):
    """Set python file object to nonblocking mode.

    This allows us to take advantage of pipe.read()
    where we don't have to specify a buflen.
    Cuts down on a few lines we'd have to maintain.

    Args:
      pipe: file; File object to modify

    Returns: pipe
    """
    flags = fcntl.fcntl(pipe, fcntl.F_GETFL)
    fcntl.fcntl(pipe, fcntl.F_SETFL, flags | os.O_NONBLOCK)
    return pipe


def launch_tails(follow_paths, lastlines_dirpath=None):
    """Launch a tail process for each follow_path.

    Args:
      follow_paths: list;
      lastlines_dirpath: str;

    Returns:
      tuple; (procs, pipes) or
          ({path: subprocess.Popen, ...}, {file: path, ...})
    """
    if lastlines_dirpath and not os.path.exists(lastlines_dirpath):
        os.makedirs(lastlines_dirpath)

    tail_cmd = ('/usr/bin/tail', '--retry', '--follow=name')
    procs = {}  # path -> tail_proc
    pipes = {}  # tail_proc.stdout -> path
    for path in follow_paths:
        cmd = list(tail_cmd)
        if lastlines_dirpath:
            reverse_lineno = lookup_lastlines(lastlines_dirpath, path)
            if reverse_lineno is None:
                reverse_lineno = 1
            cmd.append('--lines=%d' % reverse_lineno)

        cmd.append(path)
        tail_proc = subprocess.Popen(cmd, stdout=subprocess.PIPE)
        procs[path] = tail_proc
        pipes[nonblocking(tail_proc.stdout)] = path

    return procs, pipes


def poll_tail_pipes(pipes, lastlines_dirpath=None, waitsecs=5):
    """Wait on tail pipes for new data for waitsecs, return any new lines.

    Args:
      pipes: dict; {subprocess.Popen: follow_path, ...}
      lastlines_dirpath: str; Path to write lastlines to.
      waitsecs: int; Timeout to pass to select

    Returns:
      tuple; (lines, bad_pipes) or ([line, ...], [subprocess.Popen, ...])
    """
    lines = []
    bad_pipes = []
    # Block until at least one is ready to read or waitsecs elapses
    ready, _, _ = select.select(pipes.keys(), (), (), waitsecs)
    for fi in ready:
        path = pipes[fi]
        data = fi.read()
        if len(data) == 0:
            # If no data, process is probably dead, add to bad_pipes
            bad_pipes.append(fi)
            continue

        if lastlines_dirpath:
            # Overwrite the lastlines file for this source path
            # Probably just want to write the last 1-3 lines.
            write_lastlines_file(lastlines_dirpath, path, data)

        for line in data.splitlines():
            lines.append('[%s]\t%s\n' % (path, line))

    return lines, bad_pipes


def snuff(subprocs):
    """Helper for killing off remaining live subprocesses.

    Args:
      subprocs: list; [subprocess.Popen, ...]
    """
    for proc in subprocs:
        if proc.poll() is None:
            os.kill(proc.pid, signal.SIGKILL)
            proc.wait()


def follow_files(follow_paths, outstream, lastlines_dirpath=None, waitsecs=5):
    """Launch tail on a set of files and merge their output into outstream.

    Args:
      follow_paths: list; Local paths to launch tail on.
      outstream: file; Output stream to write aggregated lines to.
      lastlines_dirpath: Local dirpath to record last lines seen in.
      waitsecs: int; Timeout for poll_tail_pipes.
    """
    procs, pipes = launch_tails(follow_paths, lastlines_dirpath)
    while pipes:
        lines, bad_pipes = poll_tail_pipes(pipes, lastlines_dirpath, waitsecs)
        for bad in bad_pipes:
            pipes.pop(bad)

        try:
            outstream.writelines(['\n'] + lines)
            outstream.flush()
        except (IOError, OSError), e:
            # Something is wrong. Stop looping.
            break

    snuff(procs.values())
