#!perl
# ABSTRACT: an interactive shell for monitoring JSON log files
# PODNAME: jshell

use strict;
use warnings;
use Getopt::Long;
use Pod::Usage;
use JSON::XS qw(decode_json encode_json);
use List::Util qw(max);
use Term::SimpleColor;
use Term::SimpleColor qw(:background);
use Term::ReadLine;
use Iterator::Simple qw(iter iterator);
use Time::HiRes qw(sleep);
use Fcntl qw(:seek);

#-------------------------------------------------------------------------------
# Parse command line options
#-------------------------------------------------------------------------------
my $help = 0;

GetOptions(
  'help' => \$help,
) or pod2usage(2);

if ($help) {
  pod2usage(1);
  exit 0;
}

#-------------------------------------------------------------------------------
# Set up global environment
#-------------------------------------------------------------------------------
my $default_prompt = green . '$ ' . default ;
my $prompt = $default_prompt;
my $term = Term::ReadLine->new('jshell');

my @fields;
my %match;
my %skip;

#-------------------------------------------------------------------------------
# Utilities
#-------------------------------------------------------------------------------
sub out {
  print { $term->OUT } @_, default, "\n";
}

sub set_prompt {
  $prompt = shift || $default_prompt;
}

#-------------------------------------------------------------------------------
# Iterators
#-------------------------------------------------------------------------------
sub lines {
  my $path = shift;

  open my $fh, '<', $path or do{
    out red, "$!\n";
    return;
  };

  iterator{
    while (defined(my $line = <$fh>)) {
      chomp $line;
      return $line;
    }

    return;
  }
}

sub tail {
  my $path   = shift;
  my $pos    = 0;
  my $notify = 0;
  my $stop   = 0;

  open my $fh, '<', $path or do{
    out red, "$!\n";
    return;
  };

  seek $fh, 0, SEEK_END;
  $pos = tell $fh;

  $SIG{INT} = sub{
    out yellow, 'Stopped';
    $stop = 1;
  };

  iterator{
    LINE:do{
      # Check for control-c
      if ($stop) {
        undef $SIG{INT};
        return;
      }

      # Check for file truncation
      my $eof = eof $fh;
      my $cur = tell $fh;

      seek $fh, 0, SEEK_END;
      my $end = tell $fh;

      if ($end < $cur) {
        out yellow, 'File truncated';
        $pos = $end;
      }
      else {
        $pos = $cur;
      }

      seek $fh, $pos, SEEK_SET;
      <$fh> if $eof;

      # Return next line
      if (defined(my $line = <$fh>)) {
        undef $notify;
        chomp $line;
        return $line;
      }

      # At EOF; notify user with how to break loop
      unless ($notify) {
        out yellow, 'Use control-c to break';
        $notify = 1;
      }

      # Reset position
      seek $fh, $pos, SEEK_SET;

      # Reset EOF condition on handle and wait for new input
      seek $fh, 0, SEEK_CUR;
      sleep 1;

      # Try again
      goto LINE;
    };
  };
}

sub parsed {
  my $lines = shift;

  iterator{
    while (defined(my $line = <$lines>)) {
      return (undef, "empty line") unless $line;
      my $obj = eval{ decode_json($line) };
      return (undef, "invalid JSON: $line") if $@;
      return ($obj, undef);
    }

    return;
  }
}

sub filtered {
  my $parsed = shift;
  my $line   = 0;

  iterator{
    ITEM:do{
      my ($obj, $err) = <$parsed>;

      return if !defined $obj
             && !defined $err;

      ++$line;

      if (defined $err) {
        return ($line, undef, $err);
      }

      foreach my $key (keys %match) {
        foreach my $pattern (@{ $match{$key} }) {
          goto ITEM unless $obj->{$key} =~ /$pattern/;
        }
      }

      foreach my $key (keys %skip) {
        foreach my $pattern (@{ $skip{$key} }) {
          goto ITEM unless $obj->{$key} !~ /$pattern/;
        }
      }

      return ($line, $obj, undef);
    };

    return;
  };
}

sub formatted {
  my $filtered = shift;

  iterator{
    LOOP:do{
      my ($line, $obj, $err) = <$filtered>;

      return if !defined $obj
             && !defined $err;

      if (defined $err) {
        return sprintf '%s[line:%d] %s%s', red, $line, $err, default;
      }

      my @keys = @fields ? @fields : keys %$obj;
      my $len  = max map{ length $_ } @keys;
      my @out;

      foreach my $key (@keys) {
        push @out, sprintf("%s%s[%${len}s:%d]%s%s %s", bg_green, black, $key, $line, bg_default, default, $obj->{$key} || '');
      }

      return join "\n", @out;
    };
  };
}

#-------------------------------------------------------------------------------
# Commands
#-------------------------------------------------------------------------------
sub cmd_help {
  out green, 'h[elp]                        Display commands';
  out green, 'q[uit]                        Exits the program';
  out green, 'f[ields] field1 [field2 ...]  Set the JSON object fields to display';
  out green, 'g[rep] field pattern          Set a required match pattern for a field';
  out green, '[grep]v field pattern         Set a forbidden match pattern for a field';
  out green, '[i]nfo                        Display selected fields and patterns';
  out green, '[r]eset                       Interactively reset fields and patterns';
  out green, '[c]at                         Prints all matched lines to the terminal';
  out green, '[t]ail                        Prints all matched lines to the terminal, continuing as the file is appended';
  out;
  out green, 'All patterns are case sensitive, except when using embedded match modifiers: (?i)..(?-i).';
  out;
}

sub cmd_quit {
  exit 0;
}

sub cmd_fields {
  @fields = @_ if @_;
  out green, 'Display fields:';
  out green, "  $_" foreach @fields;
  out;
}

sub cmd_grep {
  if (my $field = shift) {
    $match{$field} ||= [];
    push @{$match{$field}},  "@_";
  }

  out green, 'grep:';

  foreach my $field (keys %match) {
    foreach my $pattern (@{ $match{$field} }) {
      out green, "  $field: /$pattern/";
    }
  }

  out;
}

sub cmd_grepv {
  if (my $field = shift) {
    $skip{$field} ||= [];
    push @{$skip{$field}},  "@_";
  }

  out green, 'grep -v:';

  foreach my $field (keys %skip) {
    foreach my $pattern (@{ $skip{$field} }) {
      out green, "  $field: /$pattern/";
    }
  }

  out;
}

sub cmd_info {
  cmd_fields;
  cmd_grep;
  cmd_grepv;
}

sub cmd_reset {
  my $reset_fields = $term->readline("Clear fields? [y/n] ");
  my $reset_match  = $term->readline("Clear grep?   [y/n] ");
  my $reset_skip   = $term->readline("Clear grepv?  [y/n] ");

  undef @fields if $reset_fields =~ /^y(es)?$/i;
  undef %match  if $reset_match  =~ /^y(es)?$/i;
  undef %skip   if $reset_skip   =~ /^y(es)?$/i;

  cmd_info;
}

sub cmd_cat {
  my $path = shift;
  my $entries = formatted filtered parsed lines $path;

  while (my $entry = <$entries>) {
    out $entry;
    out;
  }
}

sub cmd_tail {
  my $path = shift;
  my $entries = formatted filtered parsed tail $path;

  while (my $entry = <$entries>) {
    out $entry;
    out;
  }
}

#-------------------------------------------------------------------------------
# Aliases
#-------------------------------------------------------------------------------
sub cmd_c { goto \&cmd_cat    }
sub cmd_f { goto \&cmd_fields }
sub cmd_g { goto \&cmd_grep   }
sub cmd_h { goto \&cmd_help   }
sub cmd_i { goto \&cmd_info   }
sub cmd_q { goto \&cmd_quit   }
sub cmd_r { goto \&cmd_reset  }
sub cmd_t { goto \&cmd_tail   }
sub cmd_v { goto \&cmd_grepv  }

#-------------------------------------------------------------------------------
# Main loop
#-------------------------------------------------------------------------------
while (defined(my $input = $term->readline($prompt))) {
  next unless $input;
  chomp $input;

  if ($input eq '!!') {
    $input = $term->previous_history;
    out $input;
  }

  my ($cmd, @args) = split /\s+/, $input;

  if (__PACKAGE__->can("cmd_$cmd")) {
    $term->addhistory($input);
    __PACKAGE__->can("cmd_$cmd")->(@args);
  }
  else {
    out red, "Invalid command; type help for a list of commands.";
  }
}

__END__

=pod

=encoding UTF-8

=head1 NAME

jshell - an interactive shell for monitoring JSON log files

=head1 VERSION

version 0.01

=head1 SYNOPSIS

  jshell

=head1 DESCRIPTION

Opens a shell to interact with JSON-formatted log files.

=head1 COMMANDS

=head2 help | h

Displays commands and their descriptions.

=head2 quit | q

Exits the program.

=head2 fields | f

Selects the fields to display from each line's JSON log object. If no fields are
selected, all fields will be shown.

  > fields timestamp priority message

=head2 grep | g

Adds a pattern which must be matched before a log entry is displayed.

  > grep field somepattern

Despite the name, patterns are perl regexes and matched against the string value
of the field. Embedded modifiers are supported, so a case insensitive search is
accomplished thusly:

  > grep field (?i)somepattern

=head2 grepv | v

Adds a pattern which excludes entries whose field value matches.

  > grepv field logswedonotwanttosee

=head2 info | i

Displays the current configuration of displayed fields and patterns.

=head2 reset | r

Interactively resets fields and patterns.

=head2 cat | c

Displays each entry in a file, showing only those fields selected. If no fields
are selected, all fields are shown.

  > cat /path/to/json.log

=head2 tail | t

Tails a log file, displaying new entries as they are appended to the file. Use
control-c to stop output.

  > tail /path/to/json.log

=head1 AUTHOR

Jeff Ober <sysread@fastmail.fm>

=head1 COPYRIGHT AND LICENSE

This software is copyright (c) 2018 by Jeff Ober.

This is free software; you can redistribute it and/or modify it under
the same terms as the Perl 5 programming language system itself.

=cut
