#!/usr/bin/perl -w
#

use strict;
use Getopt::Long;
use File::Basename;

Getopt::Long::Configure("bundling");
my %LC;
my $BACKUP_MODE;    # 'Standard' or 'Auto-Grouped'

my $mrOpt = "";

sub getLocalConfig(;@) {
    my @vars = @_;
    my $dir  = dirname($0);
    my $cmd  = "$dir/zmlocalconfig -q -x";
    if ( scalar(@vars) > 0 ) {
        $cmd .= ' ' . join( ' ', @vars );
    }
    open( LCH, "$cmd |" ) or die "Unable to invoke $cmd: $!";
    my $line;
    while ( defined( $line = <LCH> ) ) {
        $line =~ s/[\r\n]*$//;    # Remove trailing CR/LFs.
        my @fields = split( /\s*=\s*/, $line, 2 );
        $LC{ $fields[0] } = $fields[1];
    }
    close(LCH);
}

sub getBackupMode() {
    my $dir = dirname($0);
    my $svr = $LC{'zimbra_server_hostname'};
    my $cmd = "$dir/zmprov -m -l gs $svr zimbraBackupMode 2> /dev/null";
    open( PROV, "$cmd |" ) or die "Unable to invoke $cmd: $!";
    my $line;
    $BACKUP_MODE = 'Standard';
    while ( defined( $line = <PROV> ) ) {
        chomp($line);
        if ( $line =~ /^zimbraBackupMode:\s+(.+)$/i ) {
            $BACKUP_MODE = $1;
            last;
        }
    }
    close(PROV);
}

sub isLdapOnly() {
    if ( -f "/opt/zimbra/lib/ext/zcsplus-backuprestore/zimbrabackup.jar" ) {
        return 0;
    }
    if ( -f "/opt/zimbra/common/sbin/slapcat" ) {
        return 1;
    }
}

sub backupCheck() {
    if (   -f "/opt/zimbra/lib/ext/zcsplus-backuprestore/zimbrabackup.jar"
        || -f "/opt/zimbra/common/sbin/slapcat" )
    {
        return 0;
    }
    else {
        print STDERR "Neither a store or LDAP server, no backups required.\n";
        exit(0);
    }
}

sub userCheck() {
    my $loggedIn = qx(id -un);
    chomp($loggedIn) if ( defined($loggedIn) );
    my $expected = $LC{zimbra_user};
    if ( $loggedIn ne $expected ) {
        print STDERR "Must be user $expected to run this command\n";
        exit(1);
    }
}

getLocalConfig( 'zimbra_user', 'zimbra_server_hostname',
    'zimbra_tmp_directory' );
userCheck();
backupCheck();

my @schedules = ();

my @cron = ();
my ( $cronstart, $cronstop );
my ($compress);

my @default;
my %commandmap;

my $zbOptions = "";

my ( $account, $target, $server );
my (
    $help, $query, $save,     $flush, $append, $replace,
    $def,  $zip,   $zipStore, $targ,  $acc,    $nozip
) = ( 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 );
my ( $sync, $includeSearchIndex, $includeHsmBlobs, $includeBlobs,
    $excludeSearchIndex, $excludeHsmBlobs, $excludeBlobs, $mailReport )
  = ( 0, 0, 0, 0, 0, 0, 0, 0 );
my $opts_good = GetOptions(
    'h|help'             => \$help,
    'q|query'            => \$query,
    's|save'             => \$save,
    'F|flush'            => \$flush,
    'A|append'           => \$append,
    'R|replace'          => \$replace,
    'D|default'          => \$def,
    'z|zip'              => \$zip,
    'zipStore'           => \$zipStore,
    'n|nozip'            => \$nozip,
    'a|account=s'        => \$acc,
    't|target=s'         => \$targ,
    'mail-report'        => \$mailReport,
    'server=s'           => \$server,
    'sync'               => \$sync,
    'excludeBlobs'       => \$excludeBlobs,
    'includeBlobs'       => \$includeBlobs,
    'excludeHsmBlobs'    => \$excludeHsmBlobs,
    'includeHsmBlobs'    => \$includeHsmBlobs,
    'excludeSearchIndex' => \$excludeSearchIndex,
    'includeSearchIndex' => \$includeSearchIndex,
);

if ( !$opts_good ) {
    print STDERR "\n";
    usage();
}
if ($help) {
    usage(0);
}

$account = ( $acc    ? "-a $acc"  : "-a all" );
$target  = ( $targ   ? "-t $targ" : "" );
$server  = ( $server ? "$server"  : "" );

$compress = "";

if ($zip) {
    $compress = "--zip";
}
if ($zipStore) {
    $compress = "--zipStore";
}
if ($nozip) {
    $compress = "--noZip";
}

my $logopts =
" > $LC{zimbra_tmp_directory}/zmbackup.cron.log 2>&1 || cat $LC{zimbra_tmp_directory}/zmbackup.cron.log";

sub usage {

    my ($msg) = (@_);

    $msg && print STDERR "\nERROR: $msg\n";
    print STDERR <<USAGE;

Schedule regular backups

Usage: zmschedulebackup [-q|-D|-s|-F| [{-A|-R} [zmbackup options] { <backup-type> } { <backup-schedule> } ] ]
  -q: query (default command) - prints existing schedule
  -s: save schedule to text format for easy restoration
  -F: flush - remove current schedule (cancel all scheduled backups)
  -A: append - adds specified backup to current schedule
  -R: replace - replace current schedule with specified schedule
  -D: default - replace current schedule with DEFAULT schedule

  Options that will be passed to zmbackup:
  -n: no compression - Backs up blobs as individual files rather than in zip files.
  -z: compress - Backs up blobs in compressed zip files(default). If no argument is passed, zip is taken by default and  Ignored if --zipStore is specified.
  --zipStore: - Backup up blobs in zip files without compression.
  -t: target - backup target location (default is /opt/zimbra/backup)
  -a: account - account specific (default is all)
  --mail-report: Send an email report to the admin user.
  --server: server - Mail server hostname. Default is localhost.
  --sync: Runs full backup synchronously.
  --excludeBlobs: Exclude blobs from full backup; if unspecified, use server config
  --includeBlobs: Include blobs in full backup; if unspecified, use server config
  --excludeHsmBlobs: Exclude blobs on HSM volumes from full backup; if unspecified, use server config
  --includeHsmBlobs: Include blobs on HSM volumes in full backup; if unspecified, use server config
  --excludeSearchIndex: Exclude search index from full backup; if unspecified, use server config
  --includeSearchIndex: Include search index in full backup; if unspecified, use server config

  Backup Mode (Standard or Auto-Grouped) comes from global/server config zimbraBackupMode

  Cron schedule:
	backup-type: <i|f|d arg>
	i: incremental backup (not needed in Auto-Grouped backup mode)
	f: full backup
	d <arg>: delete backups. <arg> is n{d|m|y}
	backup-schedule: crontab style time specifier, QUOTED.  See crontab(5)
		Fields are:
			minute         0-59
			hour           0-23
			day of month   1-31
			month          1-12
			day of week    0-7 (0 or 7 is Sun, or use names)

	Default schedule in Standard backup mode is:
	f    0 1 * * 6
	i    0 1 * * 0-5
	d 1m 0 0 * * *

	Default schedule in Auto-Grouped backup mode is:
	f    0 1 * * 0-6
	d 8d 0 0 * * *

USAGE
    exit(1);
}

sub showRemainingArgs {
    foreach (@ARGV) {
        print "ARG: $_\n";
    }
}

sub checkOpts {

    # Some options are exclusive, look for problems
    if (
        $query
        && (   $save
            || $flush
            || $append
            || $replace
            || $def
            || $zip
            || $zipStore
            || $targ
            || $acc
            || $nozip
            || $server
            || $sync
            || $includeSearchIndex
            || $includeHsmBlobs
            || $includeBlobs
            || $excludeSearchIndex
            || $excludeHsmBlobs
            || $excludeBlobs )
      )
    {
        usage("Invalid options: query is a solo option");
    }
    elsif ( $append && ( $save || $flush || $replace || $def ) ) {
        usage(
"Invalid options: append, save, flush, replace, default cannot be mixed"
        );
    }
    elsif ( $save && ( $flush || $replace || $def ) ) {
        usage(
"Invalid options: append, save, flush, replace, default cannot be mixed"
        );
    }
    elsif ( $flush && ( $replace || $def ) ) {
        usage(
"Invalid options: append, save, flush, replace, default cannot be mixed"
        );
    }
    elsif ( $replace && $def ) {
        usage(
"Invalid options: append, save, flush, replace, default cannot be mixed"
        );
    }
    elsif ( $nozip && $zip ) {
        usage("Can't specify no compression and compression");
    }
    elsif ( $nozip && $zipStore ) {
        usage("Can't specify no compression and uncompressed blobs");
    }
    elsif ( $excludeBlobs && $includeBlobs ) {
        usage("Can't specify to both include and exclude blobs");
    }
    elsif ( $includeHsmBlobs && $excludeHsmBlobs ) {
        usage("Can't specify to both include and exclude HSM blobs");
    }
    elsif ( $includeSearchIndex && $excludeSearchIndex ) {
        usage("Can't specify to both include and exclude search index");
    }
    elsif ( $targ && $ARGV[0] eq "i" ) {
        usage("Custom backup target is not allowed for incremental backup");
    }
    if (   !$query
        && !$save
        && !$flush
        && !$append
        && !$replace
        && !$def
        && !$zip
        && !$zipStore
        && !$targ
        && !$acc
        && !$nozip
        && !$server
        && !$sync
        && !$includeSearchIndex
        && !$includeHsmBlobs
        && !$includeBlobs
        && !$excludeSearchIndex
        && !$excludeHsmBlobs
        && !$excludeBlobs )
    {
        $query = 1;
    }
    return;
}

sub isValidSchedule {
    my @fields = split( ' ', $_[0] );

    if ( scalar(@fields) != 5 ) {
        return 0;
    }
    else {
        #
        # I'm looking for legal values - not sane values...
        #
        # minute         0-59
        # hour           0-23
        # day of month   1-31
        # month          1-12
        # day of week    0-7 (0 or 7 is Sun, or use names)

        if ( $fields[0] ne "*" ) {
            if ( $fields[0] =~ m|(\d+)(/\d+)?| ) {
                if ( $1 < 0 || $1 > 59 ) { return 0; }
            }
            else { return 0; }
        }
        if ( $fields[1] ne "*" ) {
            if ( $fields[1] =~ m|(\d+)(/\d+)?| ) {
                if ( $1 < 0 || $1 > 23 ) { return 0; }
            }
            else { return 0; }
        }
        if ( $fields[2] ne "*" ) {
            if ( $fields[2] =~ m|(\d+)(/\d+)?| ) {
                if ( $1 < 1 || $1 > 31 ) { return 0; }
            }
            else { return 0; }
        }
        if ( $fields[3] ne "*" ) {
            if ( $fields[3] =~ m|(\d+)(/\d+)?| ) {
                if ( $1 < 1 || $1 > 12 ) { return 0; }
            }
            else { return 0; }
        }
        if ( $fields[4] ne "*" ) {
            if ( $fields[4] =~ m|(\d+)(/\d+)?| ) {
                if ( $1 < 0 || $1 > 7 ) { return 0; }
            }
            elsif ( $fields[4] !~ /^(?:(?:mon|tue|wed|thu|fri|sat|sun),?)+$/i )
            {
                return 0;
            }
        }
        return 1;
    }
}

sub parseSchedules {
    my $type = "";
    my $darg = "";
    for ( my $i = 0 ; $i <= $#ARGV ; $i++ ) {
        if ( $type eq "" ) {
            next if ( " $ARGV[$i]" eq $logopts );
            if ( $i == 0 ) {
                next if ( $ARGV[$i] eq "" );
            }
            $ARGV[$i] =~ s/$logopts//;
            if ( $ARGV[$i] eq "i" || $ARGV[$i] eq "f" || $ARGV[$i] eq "d" ) {
                $type = $ARGV[$i];
            }
            else {
                usage("Invalid schedule: $ARGV[$i]");
            }
        }
        elsif ( $type eq "d" && $darg eq "" ) {
            $darg = $ARGV[$i];
            if ( $darg !~ /^\d+(d|m|y)$/ ) {
                usage("Invalid arg in deletion schedule: $darg");
            }
            if ( $mrOpt ne "" ) {
                $darg = $darg . " $mrOpt";
            }
        }
        elsif ( isValidSchedule( $ARGV[$i] ) ) {
            push( @schedules, "$ARGV[$i] $commandmap{$type} $darg\n" );
            $type = "";
            $darg = "";
        }
        else {
            usage("Invalid schedule: @ARGV");
        }

        #print "ARG: $ARGV[$i]\n";
    }
    if ( $type ne "" ) { usage("Invalid schedule: @ARGV"); }
}

sub loadCron {
    open CRON, "crontab -l |";
    @cron = <CRON>;
    close CRON;

    $cronstart = -1;
    $cronstop  = -1;

    my $comments_good = 0;

    my $found = 0;
    for ( my $i = 0 ; $i <= $#cron ; $i++ ) {
        $_ = $cron[$i];
        if (m/BACKUP END/) {
            if ($found) {
                $comments_good = 1;
            }
            last;
        }

        if ($found) {
            $cronstop = $i;
            next;
        }
        if (m/BACKUP BEGIN/) {
            $found     = 1;
            $cronstart = $i;
            $cronstop  = $i;
            next;
        }
    }

    if ( !$comments_good ) {
        print STDERR "Rebuilding backup cron\n\n";

        # One or both backup comments not found.
        # Clean up the array, and add them to the end
        if ( $cronstart == -1 && $cronstop == -1 ) {

            # No comments at all
            push @cron, "# BACKUP BEGIN\n";
            $cronstart = $#cron;
            $cronstop  = $#cron;
            push @cron, "# BACKUP END\n";
        }
        else {
           # It's not possible to find an end and no start.
           # No end comment - add the end comment right after the start comment.
            splice( @cron, $cronstart + 1, 0, "# BACKUP END\n" );
        }
    }
}

sub saveCron {
    open CRON, ">/tmp/cron.$$";
    foreach (@cron) {

        # print;
        print CRON;
    }
    close CRON;
    qx(crontab /tmp/cron.$$);
    unlink "/tmp/cron.$$";
}

sub saveCurrentSchedule {
    for ( my $i = $cronstart + 1 ; $i <= $cronstop ; $i++ ) {
        $_ = $cron[$i];
        my @fields = split;
        if (/-f/) {
            print( scalar @fields >= 7 ? " @fields[7..$#fields]" : "" );
            print
              " f \"$fields[0] $fields[1] $fields[2] $fields[3] $fields[4]\"\n";
        }
        elsif (/-i/) {
            print( scalar @fields >= 7 ? " @fields[7..$#fields]" : "" );
            print
              " i \"$fields[0] $fields[1] $fields[2] $fields[3] $fields[4]\"\n";
        }
        elsif (/-del (\d+[dmy])/) {
            print( scalar @fields >= 8 ? " @fields[8..$#fields]" : "" );
            print
" d $1 \"$fields[0] $fields[1] $fields[2] $fields[3] $fields[4]\"\n";
        }
    }
    exit(0);
}

sub displayCurrentSchedule {
    print "Current Schedule:\n\n";

    for ( my $i = $cronstart + 1 ; $i <= $cronstop ; $i++ ) {
        $_ = $cron[$i];
        my @fields = split;
        if (/-f/) {
            print "\tf $fields[0] $fields[1] $fields[2] $fields[3] $fields[4]";
            print( scalar @fields >= 7 ? " @fields[7..$#fields]\n" : "\n" );
        }
        elsif (/-i/) {
            print "\ti $fields[0] $fields[1] $fields[2] $fields[3] $fields[4]";
            print( scalar @fields >= 7 ? " @fields[7..$#fields]\n" : "\n" );
        }
        elsif (/-del (\d+[dmy])/) {
            print
              "\td $1 $fields[0] $fields[1] $fields[2] $fields[3] $fields[4]";
            print( scalar @fields >= 8 ? " @fields[8..$#fields]\n" : "\n" );
        }
    }
    exit(0);
}

sub flushCron {
    splice( @cron, $cronstart + 1, $cronstop - $cronstart );
    saveCron();
    print "Schedule flushed\n\n";
    exit(0);
}

sub replaceCron {
    print "Schedule replaced\n\n";
    splice( @cron, $cronstart + 1, $cronstop - $cronstart, @schedules );
    saveCron();
    loadCron();
    displayCurrentSchedule();
    exit(0);
}

sub defaultCron {
    print "Default schedule set\n\n";
    splice( @cron, $cronstart + 1, $cronstop - $cronstart, @default );
    saveCron();
    loadCron();
    displayCurrentSchedule();
    exit(0);
}

sub appendCron {
    print "Schedule modified\n\n";
    splice( @cron, $cronstop + 1, 0, @schedules );
    saveCron();
    loadCron();
    displayCurrentSchedule();
    exit(0);
}

$help && usage();

checkOpts();

getBackupMode();
$BACKUP_MODE = "Standard" if ( !defined($BACKUP_MODE) || $BACKUP_MODE eq "" );

my @zbOps;
if ($mailReport) {
    push( @zbOps, "--mail-report" );
}
if ($server) {
    push( @zbOps, "--server $server" );
}
if ($sync) {
    push( @zbOps, "--sync" );
}
if ($excludeBlobs) {
    push( @zbOps, "--excludeBlobs" );
}
if ($includeBlobs) {
    push( @zbOps, "--includeBlobs" );
}
if ($excludeHsmBlobs) {
    push( @zbOps, "--excludeHsmBlobs" );
}
if ($includeHsmBlobs) {
    push( @zbOps, "--includeHsmBlobs" );
}
if ($excludeSearchIndex) {
    push( @zbOps, "--excludeSearchIndex" );
}
if ($includeSearchIndex) {
    push( @zbOps, "--includeSearchIndex" );
}

foreach my $zbOp (@zbOps) {
    $zbOptions = $zbOptions . " " . $zbOp;
}

if ($mailReport) {
    $mrOpt = "--mail-report";
}

if ( $BACKUP_MODE eq 'Standard' ) {

 # default schedule: full backup 1am every sunday, incr backup 1am every weekday
 # deletes backups older than a month at 12am everyday
    if ( isLdapOnly() ) {
        @default = (
            "0 1 * * * /opt/zimbra/bin/zmbackup -f $target --mail-report\n",
            "0 0 * * * /opt/zimbra/bin/zmbackup -del 1m --mail-report\n"
        );
    }
    else {
        @default = (
"0 1 * * 6 /opt/zimbra/bin/zmbackup -f $account $target $compress --mail-report\n",
            "0 1 * * 0-5 /opt/zimbra/bin/zmbackup -i $compress --mail-report\n",
            "0 0 * * * /opt/zimbra/bin/zmbackup -del 1m --mail-report\n"
        );
    }
    %commandmap = (
        "f",
        "/opt/zimbra/bin/zmbackup -f $account $target $compress $zbOptions",
        "i",
        "/opt/zimbra/bin/zmbackup -i $compress $mrOpt",
        "d",
        "/opt/zimbra/bin/zmbackup -del"
    );
}
else {    # Auto-Grouped mode is default
          # default schedule: full backup 1am everyday, no incr backup
          # deletes backups older than a 8 days at 12am everyday
    @default = (
"0 1 * * 0-6 /opt/zimbra/bin/zmbackup -f $target $compress --mail-report\n",
        "0 0 * * * /opt/zimbra/bin/zmbackup -del 8d --mail-report\n"
    );
    %commandmap = (
        "f", "/opt/zimbra/bin/zmbackup -f $target $compress $mrOpt",
        "i", "/opt/zimbra/bin/zmbackup -i $compress $mrOpt",
        "d", "/opt/zimbra/bin/zmbackup -del"
    );
}

loadCron();

parseSchedules();

( $query || $flush || $def )
  && scalar(@schedules)
  && usage("No schedule specification allowed");

( $append || $replace )
  && !scalar(@schedules)
  && usage("Schedule specification required");

$query && displayCurrentSchedule();

if ( scalar(@schedules) ) {
    foreach (@schedules) {
        s/\h+/ /g;
        s/ $//;
    }
}

$save && saveCurrentSchedule();

if ($def) {
    foreach (@default) {
        s/\h+/ /g;
        s/ $//;
    }
}

defaultCron() if ( $def || ( isLdapOnly() && !$flush ) );

$flush && flushCron();

$replace && replaceCron();

$append && appendCron();

# saveCron();
showRemainingArgs();

