#!/usr/bin/perl -w
#
# Zymonic Business Process and Information Management System
# Copyright Zednax Limited 2008 -
# For Authors and Changelog see the subversion history
use strict;

package main;

# Library path
BEGIN
{
    use vars qw($location $script_name $script_location $utils);
    use Zymonic::Utils qw(clean debug death_handler get_array);
    $main::SIG{__DIE__} = \&death_handler;
}

# Modules
use Zymonic::Auth;
use Zymonic::Config;
use Zymonic::Process;
use Zymonic::Session;
use Zymonic;
$Zymonic::system = '';

use File::Path qw(make_path);

# until Utils::send_email is working
use MIME::Base64;
use Mail::Sendmail 0.75;
use XML::Simple;
use Storable qw(dclone);
use Sys::Hostname;

# Clean path
$ENV{PATH} = "/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/sbin:/bin:/usr/bin:/sbin:/usr/sbin";

# set binmode for STDOUT and STDERR to handle unicode
binmode STDOUT, ':utf8';
binmode STDERR, ':utf8';

# handler for handling stopping
$SIG{'INT'} = 'scheduler_death_handler';

# get args
my $system = $ARGV[0];
unless ($system)
{
    usage();
}

my $log_dir = $ARGV[1];
unless ($log_dir)
{
    my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday ) = localtime();
    ++$mon;
    $year += 1900;
    map { $_ = sprintf( '%02d', $_ ) } ( $mday, $mon );
    $log_dir = "/var/log/zymonic_scheduler/$year$mon$mday";
}
unless ( -d $log_dir )
{
    make_path($log_dir) or die "Unable to create log directory: $!\n";
}
my $log_file = "$log_dir/scheduler.log";
open my $log_fh, ">", $log_file or die "Unable to open logfile: $!\n";
$log_fh->autoflush(1);

# setup config for given system
$Zymonic::system = clean( $system, '_' );
scheduler_debug("Zymonic starting $Zymonic::system");
$Zymonic::ZCONFIG{$Zymonic::system} = Zymonic::Config->new(
    system_name => $Zymonic::system,
    config_dir  => "/etc/zymonic",
    ip_address  => 'SCHEDULER',
    protocol    => 'http'
);

# Load an auth object for securing records
my $auth = Zymonic::Auth->new(
    config     => $Zymonic::ZCONFIG{$Zymonic::system},
    DB         => $Zymonic::ZCONFIG{$Zymonic::system}->{DB},
    ip_address => $Zymonic::ZCONFIG{$Zymonic::system}->{ip_address}
);
my $db = $Zymonic::ZCONFIG{$Zymonic::system}->{DB};

# setup basic session for param handling
$Zymonic::session = Zymonic::Session->new(
    config         => $Zymonic::ZCONFIG{$Zymonic::system},
    auth           => $auth,
    DB             => $Zymonic::ZCONFIG{$Zymonic::system}->{DB},
    request_source => 'zymonic_scheduler',
);

# will now loop forever (until killed)
scheduler_debug('Started scheduler');
get_jobs();

## REST IS FUNCTIONS ##

# one process will loop putting schedules into jobs list
sub get_jobs
{

    # vars needed
    my @times = (time);
    my $time;
    my @schedules;

    # loop indefinitely
    while (1)
    {
        # re-connect the DB
        $Zymonic::ZCONFIG{$Zymonic::system}->{DB}->connect('reconnect');

        # get schedule ids
        my @jobs = ();
        $time      = shift @times;
        @schedules = get_schedules($time);
        scheduler_debug( "Got schedules for " . localtime($time) . ": " . join( ', ', map { $_->{name} } @schedules ) )
          if @schedules;
        while (@schedules)
        {
            my $schedule = shift @schedules;
            $schedule->{run_time} = scalar localtime($time);

            # if process_id is set, then need to lookup the zname
            if ( $schedule->{scheduled_process_id} )
            {
                $schedule->{scheduled_process} =
                  $Zymonic::ZCONFIG{$Zymonic::system}->zname_from_id( 'Process', $schedule->{scheduled_process_id} );
            }

            # load field mapping
            my $field_mappings = get_field_mappings( $schedule->{id} );

            # add job to queue
            push( @jobs, { schedule => dclone($schedule), field_mappings => dclone($field_mappings) } );
        }

        # do the jobs
        do_jobs( \@jobs );

        # if missed any minutes, add to list
        my $mins_missed = ( localtime(time) )[1] - ( localtime($time) )[1];
        if ( $mins_missed > 1 )
        {
            # if minutes are missed, clear the times list as the below command will
            # fill all the times in between last time and now
            # if we don't empty the list we get duplicate times
            @times = map { $time + ( 60 * $_ ) } ( 1 .. $mins_missed );
            scheduler_debug(
                "Missed $mins_missed mins, times to check: " . join( ', ', map { scalar localtime($_) } @times ) );
        }
        elsif (@times)
        {
            # if there are times we are catching up on, no need to wait a minute, nor to add the current minute
        }
        else
        {
            # wait until next minute
            while ( ( localtime($time) )[1] == ( localtime(time) )[1] ) { sleep(1); }

            # add current time (do this here as the mins missed above goes up to current time)
            push( @times, time );
        }
    }
}

# pull jobs from jobs list ad perform them
sub do_jobs
{
    my $jobs = shift || [];
    foreach my $job ( @{$jobs} )
    {
        # ran into some issues with cleanup of field refs when running this in a single process
        # so try forking here to have a clean setup for each job
        my $child_pid = fork;
        if ( defined $child_pid )
        {
            if ( $child_pid == 0 )
            {
                # setup data after forking and let the child run the schedule
                $Zymonic::ZCONFIG{$Zymonic::system}->{DB}->child_mode();
                $Zymonic::ZCONFIG{$Zymonic::system}->field_factory()->clear_cache();
                run_schedule( $job->{schedule}, $job->{field_mappings} );
                exit 0;
            }
            else
            {
                # make parent wait for child and quit
                waitpid( $child_pid, 0 );
            }
        }
        else
        {
            scheduler_debug("Unable to fork!");
            die "Unable to fork!";
        }
    }
}

# looks up all schedules for the current (or given) time,
# maps them into simple hashrefs
sub get_schedules
{
    my $time = shift || time;

    my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) = localtime($time);
    ++$mon;    # to range 1-12;

    # this look was originally done using a filter to get the schedules
    # then lookup all the record fields in scheduler tables
    # this had a memory leak in it which could not be spotted
    # so just using direct SQL for the time being
    # this has disadvantage of not checking permissions, however no explciti permission on schedules yet
    my $schedules = $db->run_query(
        {
            string =>
              'SELECT id, name, sequence, username, scheduled_process, scheduled_process_id, scheduled_transition, '
              . 'success_email, success_email_cc, success_email_bcc, failure_email, failure_email_cc, failure_email_bcc '
              . 'FROM schedules WHERE active = ? AND (autocreated IS NULL OR autocreated != ?) AND (deleted IS NULL OR deleted != ?) '
              . 'AND (minutes = ? OR minutes LIKE CONCAT(?, ",%") OR minutes LIKE CONCAT("%,", ?) OR minutes LIKE CONCAT("%,", ?, ",%") OR minutes LIKE ?)'
              . 'AND (hours = ?   OR hours   LIKE CONCAT(?, ",%") OR hours   LIKE CONCAT("%,", ?) OR hours   LIKE CONCAT("%,", ?, ",%") OR hours   LIKE ?)'
              . 'AND (months = ?  OR months  LIKE CONCAT(?, ",%") OR months  LIKE CONCAT("%,", ?) OR months  LIKE CONCAT("%,", ?, ",%") OR months  LIKE ?)'
              . ' AND ( '
              . '    (days = ?      OR days      LIKE CONCAT(?, ",%") OR days      LIKE CONCAT("%,", ?) OR days      LIKE CONCAT("%,", ?, ",%") OR days      LIKE ?)'
              . ' OR (week_days = ? OR week_days LIKE CONCAT(?, ",%") OR week_days LIKE CONCAT("%,", ?) OR week_days LIKE CONCAT("%,", ?, ",%") OR week_days LIKE ?)'
              . ')',
            params => [
                'Y',  -1,   'Y',  '*',  $min, $min,  $min,  $min,  '*',   $hour, $hour, $hour, $hour, '*',
                $mon, $mon, $mon, $mon, '*',  $mday, $mday, $mday, $mday, '*',   $wday, $wday, $wday, $wday,
            ],
        }
    );

    # return sorted list
    my @schedules = sort { ( $a->{sequence} || 999 ) <=> ( $b->{sequence} || 999 ) } @{$schedules};

    return @schedules;
}

# given a schedule id will return a hashref of field mappings
sub get_field_mappings
{
    my $schedule_id = shift;

    # get all mapppings that match this scheduler and add them to hash
    my $field_mappings = {};

    # this look was originally done using a table object
    # that potentially had a memory leak in it which could not be spotted
    # so just using direct SQL for the time being
    # this has disadvantage of not checking permissions, however no explciti permission on schedules yet
    my $mappings = $db->run_query(
        {
            string => 'SELECT field, value, record_id FROM schedule_mappings '
              . 'WHERE schedule_id = ? AND (autocreated IS NULL OR autocreated != ?) AND (deleted IS NULL OR deleted != ?)',
            params => [ $schedule_id, -1, 'Y' ],
        }
    );
    foreach my $mapping ( @{$mappings} )
    {
        if ( $mapping->{field} && $mapping->{value} )
        {
            $field_mappings->{ $mapping->{field} } = $mapping->{value};
        }
    }
    return $field_mappings;
}

# runs the given scheduler
sub run_schedule
{
    my $schedule          = shift || {};
    my $field_mappings_in = shift || {};

    my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday ) = localtime();
    ++$mon;
    $year += 1900;
    map { $_ = sprintf( '%02d', $_ ) } ( $mday, $mon, $hour, $min );
    my $schedule_debug_file = "$log_dir/$year$mon$mday$hour$min" . "_$schedule->{id}_" . time() . '.log';
    open my $schedule_fh, ">", $schedule_debug_file or die "Unable to open schedule debug file: $!\n";
    $Zymonic::Utils::debugfile = $schedule_fh;
    scheduler_debug("Running Schedule: $schedule->{name} for time $schedule->{run_time} ($schedule_debug_file)");

    # take copy of field mappings so can change it
    my $field_mappings = { %{$field_mappings_in} };

    # clear the session params
    $Zymonic::session->clear_cgi_params();

    # Load new auth object and 'log in' as schedule user
    # TODO: check if this is the correct way to do things
    #       or do we need special scheduler permissions
    my $auth = Zymonic::Auth->new(
        config     => $Zymonic::ZCONFIG{$Zymonic::system},
        DB         => $Zymonic::ZCONFIG{$Zymonic::system}->{DB},
        ip_address => $Zymonic::ZCONFIG{$Zymonic::system}->{ip_address}
    );
    $auth->{user} = $schedule->{username};
    $auth->get_user_record();

    # load the desired process
    my $process = Zymonic::Process->new(
        parent           => $Zymonic::session,
        zname            => $schedule->{scheduled_process},
        process_id       => $schedule->{scheduled_process_id} || '',
        config           => $Zymonic::ZCONFIG{$Zymonic::system},
        auth             => $auth,
        DB               => $Zymonic::ZCONFIG{$Zymonic::system}->{DB},
        ident            => "schedule_$schedule->{id}_at_" . time(),
        block_parameters => {}
    );

    # load the mapping for this schedule, can only do this if process has a form
    if ( $process->{current_state} && $process->{current_state}->{form} && keys %{$field_mappings} )
    {
        my $form = $process->{current_state}->{form};

        # map to first record
        my $form_record = $form->{records}->[0];
        if ($form_record)
        {
            foreach my $form_field (
                map  { $form->get_field_object($_) }
                grep { $field_mappings->{ $_->{zname} } }
                values %{ $form->get_fields( $form_record, 'all', 'refs_only' ) }
              )
            {
                $form_field->set_value_from_other( $field_mappings->{ $form_field->{zname} } );
                delete $field_mappings->{ $form_field->{zname} };
            }

            # set any remaining field mappings as cgi params params
            # these should be other values within a field, e.g. fetch values in glob/file fields
            map { $Zymonic::session->set_cgi_param( "$form_record->{ident}$_", $field_mappings->{$_} ); }
              keys %{$field_mappings};
        }
    }

    # set transition param for process and get it's output
    $process->{requested_transition} = $schedule->{scheduled_transition};
    my $process_output;
    eval {
        $process_output = $process->output();
        scheduler_debug("Success");
        send_email(
            $schedule->{success_email},
            $schedule->{success_email_cc},
            $schedule->{success_email_bcc},
            "Schedule Run: $schedule->{name} at $schedule->{run_time}",
            "Schedule $schedule->{name} ran successfully at $schedule->{run_time}. Attached is its output.",
            XMLout(
                $process_output,
                KeyAttr       => [],
                RootName      => 'Zymonic',
                NoEscape      => 1,
                NoIndent      => 1,
                SuppressEmpty => 1
            )
        );
        1;
    } or do
    {
        my $error_report = death_handler( $@, '', 'return_error' );
        scheduler_debug("An error occured, $error_report->{reference}");
        send_email(
            $schedule->{failure_email},
            $schedule->{failure_email_cc},
            $schedule->{failure_email_bcc},
            "Schedule Failure: $schedule->{name} at $schedule->{run_time}",
            "Running Schedule $schedule->{name} at $schedule->{run_time} caused an error.\n\n"
              . $error_report->{message}->{content}
        );
    };
    $Zymonic::Utils::debugfile = undef;
    close $schedule_fh;
}

# function to send a scheduler email
sub send_email
{
    my $email   = shift;
    my $cc      = shift || '';
    my $bcc     = shift || '';
    my $subject = shift;
    my $body    = shift;
    my $xml     = shift || '';

    return unless $email;
    scheduler_debug("Sending email to $email");

    my $smtp_server = $Zymonic::ZCONFIG{$Zymonic::system}->sys_opt('email_smtp_server');
    unless ($smtp_server)
    {
        scheduler_debug("No email_smtp_server set, unable to send email");
        return;
    }

    my $scheduler_email = $Zymonic::ZCONFIG{$Zymonic::system}->sys_opt('scheduler_email_from_address') . "@"
      . ( $Zymonic::ZCONFIG{$Zymonic::system}->sys_opt('system_email_from_domain') || hostname() );

    # create mail object
    my %mail = (
        from    => $scheduler_email,
        to      => $email,
        cc      => $cc,
        bcc     => $bcc,
        subject => $subject,
        smtp    => $smtp_server,
    );
    if ($xml)
    {

        # encode XML and add as attachments as shown here:
        # http://alma.ch/perl/Mail-Sendmail-FAQ.html#attachments
        my $encoded_xml = encode_base64($xml);
        my $boundary    = "====" . time() . "====";

        $mail{'content-type'} = "multipart/mixed; boundary=\"$boundary\"";
        $mail{body} .= <<END_OF_BODY;
--$boundary
Content-Type: text/plain; charset="iso-8859-1"
Content-Transfer-Encoding: quoted-printable

$body
--$boundary
Content-Type: application/octet-stream; name="scheduled_process_output.xml"
Content-Transfer-Encoding: base64
Content-Disposition: attachment; filename="scheduled_process_output.xml"

$encoded_xml
--$boundary--
END_OF_BODY
    }
    else
    {
        $mail{'content-type'} = 'text/plain; charset="iso-8859-1"';
        $mail{body} = $body;
    }

    sendmail(%mail) or scheduler_debug("Unable to send mail because: $Mail::Sendmail::error");
}

sub scheduler_debug
{
    my $msg = shift;
    print $log_fh "[" . localtime() . "] ZS($$): $msg\n";
}

sub scheduler_death_handler
{
    print "Stoppping...\n";
    scheduler_debug("Stopping...\n");
    exit(0);
}

sub usage
{
    print STDERR "Usage: zymonic_scheduler.pl [system] [log directory (optional)] 2> [err log]\n";
    exit(0);
}
