#!/usr/bin/perl

## no critic (CodeLayout::ProhibitParensWithBuiltins);

use strict;
use warnings;
use autodie;

use Carp;
use Getopt::Long::Descriptive;
use Pod::Usage;
use POSIX qw(strftime);
use Try::Tiny;

use Stanford::Orange::Sendmail;
use Stanford::Orange::Util qw(
  mysql_time
);
use Stanford::WWWScheduler qw(
  db_connect
  read_config
  read_password
);

### # ##### # ##### # ##### # ##### # ##### # ##### # ##### # ##### # ##

my $VERBOSE = 0;

### CONFIGURATION
my $config_file = '/etc/www-scheduler/www-scheduler-config.yaml';
my %CONFIG      = read_config($config_file);

# Populate %PROTECTED_IDS hash which lists those job ids
# that should never be changed.
my %PROTECTED_IDS = ();
for my $id (@{ $CONFIG{'protected_ids'} }) {
    $PROTECTED_IDS{$id} = 1;
}


my $DRY_RUN = 1;

my $GENERIC_EXIT = 255;
### # ##### # ##### # ##### # ##### # ##### # ##### # ##### # ##### # ##

sub print_error {
    my ($msg) = @_;
    print {*STDERR} "ERROR: $msg\n";
    return;
}

sub progress {
    my ($msg) = @_;
    if ($VERBOSE) {
        my $now = time();
        my $date_string = strftime('%Y-%m-%dT%H:%M:%SZ', gmtime($now));
        print "[$date_string] progress: $msg\n";
    }
    return;
}

## #### #### #### #### #### #### #### #### #### #### #### ##

sub format_job_record {
    my ($db_ref) = @_;

    my $id        = $db_ref->{'cr_id'};
    my $requester = $db_ref->{'cr_requester'};
    my $modified  = $db_ref->{'cr_modified'};

    return "$id,$requester,$modified";
}

sub show_active_inactive {
    my ($type_to_show) = @_;

    my $msg;
    $msg = "about to show $type_to_show scheduler jobs";
    progress($msg);

    my $is_active;
    if ($type_to_show eq 'active') {
        $is_active = 'YES';
    } else {
        $is_active = 'NO';
    }

    my ($query, $sth);
    my $dbh = db_connect(\%CONFIG);
    $query = 'SELECT * from cronjobs WHERE cr_active=? ORDER BY cr_id';
    $sth   = $dbh->prepare($query);
    $sth->execute($is_active);

    while (my $ref = $sth->fetchrow_hashref()) {
        my $formatted_record = format_job_record($ref);
        print "$formatted_record\n";
    }

    return;
}

# Return a DB statement handle of all expiring jobs.
sub get_expiring {
    my ($query, $sth);

    my $dbh = db_connect(\%CONFIG);

    my $today_mysql_date = mysql_time();
    $query =
        q{SELECT * from cronjobs }
      . q{WHERE (DATEDIFF(?, cr_modified) > 365) }
      . q{AND (cr_active = 'Yes') }
      . q{ORDER BY cr_modified};
    $sth = $dbh->prepare($query);
    $sth->execute($today_mysql_date);
    return $sth;
}

sub show_expired {

    my $msg;
    $msg = 'about to show expiring jobs';
    progress($msg);

    my $sth = get_expiring();

    while (my $ref = $sth->fetchrow_hashref()) {
        my $formatted_record = format_job_record($ref);
        print "$formatted_record\n";
    }

    return;
}

sub expire {
    progress('about to expire jobs');

    if ($DRY_RUN) {
        progress('in dry-run mode');
    }

    my $sth = get_expiring();

    my $counter = 0;
    while (my $ref = $sth->fetchrow_hashref()) {
        my $id = $ref->{'cr_id'};

        if (is_protected_id($id)) {
            progress("skipping exipiring $id as it is a protected id");
            next;
        }


        # De-activate this job.
        if ($DRY_RUN) {
            progress("in dry-run so skipping deactivating $id");
        } else {
            set_active_id($id, 'No');
        }

        # Send email letting the user know the jobs is expired.
        send_expired_job_email($ref);

        ++$counter;
    }

    if ($counter == 0) {
        progress('expired no jobs');
    } else {
        progress("expired $counter job(s)");
    }

    return;
}

sub send_expired_job_email {
    my ($db_ref) = @_;

    my ($msg);

    #### Get configurations
    my $config_email = $CONFIG{'send_email'};

    my $smtp_host          = $config_email->{'smtp_host'};
    my $smtp_port          = $config_email->{'smtp_port'};
    my $from               = $config_email->{'from'};
    my $auth_enabled       = $config_email->{'auth_enabled'};
    my $auth_username      = $config_email->{'auth_username'};
    my $auth_password_file = $config_email->{'auth_password_file'};

    my $to_override;
    if (exists($config_email->{'to_override'})) {
        $to_override = $config_email->{'to_override'};
    }

    #### Get job information
    my $id          = $db_ref->{'cr_id'};
    my $requester   = $db_ref->{'cr_requester'};
    my $email       = $db_ref->{'cr_email'};
    my $command     = $db_ref->{'cr_command'};
    my $description = $db_ref->{'cr_description'};
    my $principal   = $db_ref->{'cr_principal'};
    my $modified    = $db_ref->{'cr_modified'};

    if ($to_override) {
        $msg = "about to send expired job email to $requester "
             . "(overridden by $to_override)";
    } else {
        $msg = "about to send expired job email to $requester";
    }
    progress($msg);

    # mail body

    # If overriding the e-mail address put a notice at the start informing the
    # recipient of this.
    my $override_preface = q{};
    if ($to_override) {
        $override_preface = '(NOTE: This e-mail sent by the non-production '
            . 'instance of www-scheduler; the To: address '
            . "of $requester was overridden.)\n\n";
    }

    my $site_url = 'https://www-scheduler.stanford.edu';
    #<<< This layout is easier to read.
    my $content =
        qq{$override_preface}
      . qq{Your WWW Scheduler job $id has reached its expiration date }
      . qq{and been set to inactive. To re-activate your job for another }
      . qq{year go to $site_url, select your job, and click on the }
      . qq{'Renew Job' button.}
      . qq{\n}
      . qq{\n}
      . qq{If you no longer need this scheduled job ignore this e-mail. }
      . qq{\n}
      . qq{\n}
      . qq{Details about this job: }
      . qq{\n}
      . qq{\n}
      . qq{Job id:      $id}
      . qq{\n}
      . qq{Requester:   $requester}
      . qq{\n}
      . qq{Command:     $command}
      . qq{\n}
      . qq{Description: $description}
      . qq{\n}
      . qq{Email:       $email}
      . qq{\n}
      . qq{Principal:   $principal}
      . qq{\n}
      . qq{Updated:     $modified}
    ;
    #>>>

    my $subject = "WWW Scheduler Job $id has expired";
    if ($to_override) {
        $subject .= ' (non-production instance)';
    }

    my $recipient;
    if ($to_override) {
        $recipient = $to_override;
    } else {
        ## no critic (ValuesAndExpressions::RequireInterpolationOfMetachars);
        $recipient = $requester . '@stanford.edu';
    }

    # Prepare and send the email!
    my %mail_params = (
        'smtp_host' => $smtp_host,
        'smtp_port' => $smtp_port,
        'from'      => $from,
        'to'        => $recipient,
        'subject'   => $subject,
        'body'      => $content,
        'debug'     => 0,
    );

    # Adjust %mail_params if we are doing an authenticated send.
    if ($auth_enabled =~ m{^yes.*$}ixsm) {
        $mail_params{'authenticate'} = 1;
        $mail_params{'username'}     = $auth_username;

        my $pw_file = $auth_password_file;
        $mail_params{'password'} = read_password($pw_file);
    } else {
        $mail_params{'authenticate'} = 0;
    }

    if ($DRY_RUN) {
        $msg = <<~"EOM";
        In dry-run mode so _would_ have sent this e-mail:
        -------------------------------------------------
        To: $recipient
        From: $from
        Subject: $subject

        $content
        EOM

        print $msg;
    } else {
        try {
            my $email1 = Stanford::Orange::Sendmail->new(%mail_params);
            $email1->deliver();
        } catch {
            my $exception = $_;
            croak("Problem sending email to $recipient: $exception");
        };
    }

    return;
}

sub show_id {
    my ($id) = @_;

    my ($query, $sth);

    my $dbh = db_connect(\%CONFIG);

    my $today_mysql_date = mysql_time();
    $query = q{SELECT * from cronjobs WHERE cr_id = ?};
    $sth   = $dbh->prepare($query);
    $sth->execute($id);

    while (my $ref = $sth->fetchrow_hashref()) {
        my $requester   = $ref->{'cr_requester'};
        my $email       = $ref->{'cr_email'};
        my $description = $ref->{'cr_description'};
        my $command     = $ref->{'cr_command'};
        my $active      = $ref->{'cr_active'};
        my $modified    = $ref->{'cr_modified'};
        my $principal   = $ref->{'cr_principal'};
        my $type        = $ref->{'cr_type'};

        if (!$description) {
            $description = 'NO DESCRIPTION';
        }

        my $display = <<~"EOR";
        id:          $id
        requester:   $requester
        email:       $email
        principal:   $principal
        active:      $active
        command:     $command
        description: $description
        type:        $type
        modified:    $modified
        EOR

        print $display;

    }
    return $sth;

}

# Set the cr_active field to the value of the $state parameter (either
# 'Yes' or 'No'). We want to update this field WITHOUT changing the
# cr_modified time stamp (hence the strange cr_modified = cr_modified in
# the query). We want to avoind changing the cr_modified since that is
# really meant for when the requester changes the record, not when an
# admin changed the record.
#
# If you want to set the record to the current datetime use touch-id.
#
# We SKIP setting active/inactive for any ids in the protected_ids list.
sub set_active_id {
    my ($id, $state) = @_;

    if (is_protected_id($id)) {
        progress("skipping changing cr_active field for protected id '$id' "
               . "(see 'protected_ids' configuration item)");
        print "warning: skipping changing cr_active field for protected id '$id'\n";
    } else {
        my $dbh = db_connect(\%CONFIG);

        my $today_mysql_date = mysql_time();
        my $query =
            q{UPDATE cronjobs }
          . q{SET cr_active = ?, }
          . q{    cr_modified = cr_modified }
          . q{WHERE cr_id = ?};
        my $sth = $dbh->prepare($query);
        $sth->execute($state, $id);
    }

    return show_id($id);

}

sub is_protected_id {
    my ($id) = @_ ;
    if (exists($PROTECTED_IDS{$id})) {
        return 1;
    } else {
        return 0;
    }
}

# Update the cr_modified field of the record with cr_id $id
# to be the current date and time.
sub touch_id {
    my ($id, $state) = @_;

    my ($query, $sth);

    my $dbh = db_connect(\%CONFIG);

    my $today_mysql_date = mysql_time();
    $query =
        q{UPDATE cronjobs }
      . q{SET cr_modified = current_timestamp }
      . q{WHERE cr_id = ?};
    $sth = $dbh->prepare($query);
    $sth->execute($id);

    return show_id($id);

}

sub expire_id {
    my ($id) = @_;

    if (is_protected_id($id)) {
        print "cannot expire id $id as it is a protected id\n";
        return;
    }

    my ($query, $sth);

    my $dbh = db_connect(\%CONFIG);

    my $today_mysql_date = mysql_time();
    $query = q{SELECT * from cronjobs WHERE cr_id = ?};
    $sth   = $dbh->prepare($query);
    $sth->execute($id);

    while (my $ref = $sth->fetchrow_hashref()) {

        # If not in dry-run mode de-activate this job.
        if ($DRY_RUN) {
            progress("in dry-run mode so skipping job deactivation of $id");
        } else {
            set_active_id($id, 'No');
        }

        # Send email letting the user know the jobs is expired.
        send_expired_job_email($ref);
    }

    if (!$DRY_RUN) {
        return show_id($id);
    }

    return;
}

# Exits with exit code 0 if the record with cr_id $id has
# a last modified time more than a year in the past,
# exits with code 1 otherwise.
sub is_expired_id {
    my ($id, $state) = @_;

    my ($query, $sth);

    my $dbh = db_connect(\%CONFIG);

    my $today_mysql_date = mysql_time();
    $query =
        q{SELECT * from cronjobs }
      . q{WHERE (DATEDIFF(?, cr_modified) > 365) }
      . q{AND (cr_id = ?) };
    $sth = $dbh->prepare($query);
    $sth->execute($today_mysql_date, $id);

    my $cr_modified;
    while (my $ref = $sth->fetchrow_hashref()) {
        $cr_modified = $ref->{'cr_modified'};
    }

    if (defined($cr_modified)) {
        exit(0);
    } else {
        exit(1);
    }
}

## #### #### #### #### #### #### #### #### #### #### #### ##

#<<< I like this layout.
my ($opt, $usage) = describe_options(
    'cron-maint %o ACTION <some-arg>',
    [ 'verbose|v', 'run in verbose mode' ],
    [ 'help|h',    'print usage message and exit', {'shortcircuit' => 1}],
    );
#>>>

sub extra_help {
    return <<~'EOS';

    ACTION must be one of:
      manual
      active
      inactive
      expired
      expire
      show-expire
      activate-id <job-id>
      deactivate-id <job-id>
      expire-id <job-id>
      is-expired-id <job-id>
      show-expire-id <job-id>
      show-id <job-id>
      touch-id <job-id>
    EOS
}

if ($opt->help()) {
    print $usage->text;
    print extra_help();
    exit(0);
}

if ($opt->verbose()) {
    $VERBOSE = 1;
}

my $ACTION = $ARGV[0];
if (!$ACTION) {
    print_error('missing ACTION');
    print {*STDERR} $usage->text;
    print {*STDERR} extra_help();
    exit($GENERIC_EXIT);
}

progress("action is '$ACTION'");

my %action_to_sub = (
    'manual'         => sub { pod2usage(-verbose => 2); },
    'active'         => sub { show_active_inactive('active'); },
    'inactive'       => sub { show_active_inactive('inactive'); },
    'expired'        => sub { show_expired(); },
    'expire'         => sub { $DRY_RUN = 0; expire(); },
    'show-expire'    => sub { $DRY_RUN = 1; expire(); },
    'show-id'        => sub { show_id(@_); },
    'activate-id'    => sub { set_active_id(@_, 'Yes'); },
    'deactivate-id'  => sub { set_active_id(@_, 'No'); },
    'expire-id'      => sub { $DRY_RUN = 0; expire_id(@_); },
    'show-expire-id' => sub { $DRY_RUN = 1; expire_id(@_); },
    'touch-id'       => sub { touch_id(@_); },
    'is-expired-id'  => sub { is_expired_id(@_); },
);

if (!exists($action_to_sub{$ACTION})) {
    my $msg = "'$ACTION' is not a valid action";
    print_error($msg);
    print {*STDERR} $usage->text;
    exit($GENERIC_EXIT);
} else {
    my $msg = "'$ACTION' is a valid action";
    progress($msg);
}

# If $ACTION is one of the "-id" actions ensure that there is a
# job id given on the command line.
my $id;
if ($ACTION =~ m{\-id$}xsm) {
    if (!$ARGV[1]) {
        my $msg = "action $ACTION requires a job id";
        print_error($msg);
        exit($GENERIC_EXIT);
    } else {
        $id = $ARGV[1];
        progress("job id is $id");
    }
}

# Carry out the action.
$action_to_sub{$ACTION}->($id);

exit(0);

__END__

=for stopwords Lewenberg

=head1 NAME

cron-maint - www-scheduler maintenance script

=head1 USAGE

cron-maint I<ACTION> [options]

=head1 DESCRIPTION

Use B<cron-maint> for www-scheduler maintenance tasks. ACTION should be one of

     manual
     active
     inactive
     expired
     expire
     show-expire
     show-id
     activate-id
     deactivate-id
     expire-id
     show-expire-id
     touch-id
     is-expired-id

=head1 ACTIONS

=head2 manual

Display this man page.

=head2 active

List all the records that have their C<cr_active> field set to "Yes". The results
will be a sequence of records each line of which is formatted as

    ID,REQUESTER,MODIFIED_DATETIME

=head2 inactive

List all the records that have their C<cr_active> field set to "No".
The results will be a sequence of records each line of which is formatted as

    ID,REQUESTER,MODIFIED_DATETIME

=head2 expired

List all the records whose C<cr_modified> field is at least 365 days in the
past and whose C<cr_active> field is set to "Yes".
The results will be a sequence of records each line of which is formatted as

    ID,REQUESTER,MODIFIED_DATETIME

=head2 expire

Find all records that are active and whose last modify date is at least
365 days in the past. For these records set them to be inactive and send
an e-mail to the e-mail address in the C<cr_email> field informing them
that this record has been deactivated. Note that this process
does I<not> change the value of the C<cr_modified> field.

=head2 show-expire

This action is a I<dry-run> version of the expire action.
Find all records that are active and whose last modify date is at least
365 days in the past. Do not change these records but instead
print to standard output the e-mail that I<would> be sent if
you had actually run the expire command.

=head2 show-id I<id>

Show the details for the record with C<cr_id> equal to I<id>.

=head2 activate-id I<id>

Activate record I<id>, i.e., set the C<cr_active> field for the record with
C<cr_id> equal to I<id> to "Yes". This action does I<NOT> change the last-modified time
for the record.

=head2 deactivate-id I<id>

Deactivate record I<id>, i.e., set the C<cr_active> field for record with
C<cr_id> equal to I<id> to "No". This action does I<NOT> change the last-modified time
for the record.

=head2 warn-id I<id>

If the record with C<cr_id> has been last modified too far in the past (as
defined by the directive ??? in
C</etc/www-scheduler/www-scheduler-config.yaml>) but has not yet expired,
send an e-mail warning that the requester that the record will soon be
deactivated.

=head2 expire-id I<id>

Deactivate the record with C<cr_id> field equal to I<id> to "No" and send
an e-mail informing the requester that the record has been deactivated.
Note that this action happens regardless of whether the record is already
deactivated or what the last-modified time is.

=head2 show-expire-id I<id>

This action is a I<dry-run> version of the expire-id action.
This will show the e-mail that would have been sent to the requester
if the record has actually been expired.

=head2 touch-id I<id>

Set the last-modified time of record I<id> to the currrent datetime.

=head2 is-expired-id I<id>

Exits with exit code 0 if the record I<id> has a last modified time more
than a year in the past; exits with code 1 otherwise. Whether the record
is active or not is ignored.


=head1 OPTIONS

=over 4

=item B<--verbose>

Show extra information when running.

=back

=head1 EXIT STATUS

The script will exit with 0 if the script completes and there were no
failures, 255 for any other reason. The I<is-expired-id> action exits with code 0 if
the supplied id is expired, and with exit code 1 if not expired.

=head1 EXAMPLES

List all records that are active:

    cron-main active

List all records that are not active:

    cron-main inactive

Deactivate all active records whose last-modified time is at least 365 days
in the past and send an e-mail to the requester informing them that their
job has been deactivated:

    cron-main expire

Do a dry-run of the above:

    cron-main show-expire

Display record 1234's details:

    cron-main show-id 1234


=head1 BUGS AND LIMITATIONS

None known.

=head1 CONFIGURATION

Configuration is managed via the the file
F</etc/www-scheduler/www-scheduler-config.yaml>.

=head1 SEE ALSO

????(1)

=head1 AUTHOR

Adam Lewenberg <adamhl@stanford.edu>

=head1 LICENSE AND COPYRIGHT

Copyright 2022 The Board of Trustees of the Leland Stanford Junior
University.  All rights reserved.

Permission to use, copy, modify, and distribute this software and its
documentation for any purpose and without fee is hereby granted, provided
that the above copyright notice appear in all copies and that both that
copyright notice and this permission notice appear in supporting
documentation, and that the name of Stanford University not be used in
advertising or publicity pertaining to distribution of the software
without specific, written prior permission.  Stanford University makes no
representations about the suitability of this software for any purpose.
It is provided "as is" without express or implied warranty.

THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR IMPLIED
WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE.

=cut

#############################################################################
#############################################################################
