#!/usr/bin/perl
# Backup.pm - [description]
# Copyright (c) 2006 Jonathan T. Rockway
# $Id: $

package Chroniton::Backup;
use strict;
use warnings;
use Carp;

use Exporter;
use File::Copy qw(cp);
use File::Spec::Functions qw(abs2rel catfile splitpath catfile);
use Time::HiRes qw(time);
use DateTime;
use Digest::MD5;

use Chroniton::Messages;
use Chroniton::Message;
use Chroniton::Event;
use Chroniton::Config;
use Chroniton::BackupContents;

our @ISA = qw(Exporter);
our @EXPORT_OK = qw(backup);

=head1 NAME

Chroniton::Backup - implements full and incremental backups

=head1 EXPORT

None of these subroutines are exported by default.  Please load them
into your namespace by specifying them on the module load line:

    use Chroniton::Backup qw(backup);

=head2 backup($config, $log, \@sources, $destination, [$against])

Perfoms a backup of each file in @sources to a new subdirectory of
$destination.  If $against is specified, an incremental backup is
performed by comparing the files in each of @sources to the
identically named files in $against.  If the files are the same, the new
file in $destination is linked to the version in $against (instead of being 
copied from the $source).

Returns a BackupSet object.  Dies on a fatal error, otherwise logs
errors and warnings to $log, a C<Chroniton::Messages> object.

=cut

sub backup {
    my ($config, $log, $sources_ref, $destination, $against) = @_;
    my @sources = @{$sources_ref};
    
    my $mode = ($against) ? "incremental" : "full";
    my $sources = join(', ', @sources);
    $log->message($sources, "starting $mode backup of $sources to $destination");

    # create the destination directory
    mkdir $destination if !-e $destination;
    $log->fatal("cannot write to destination directory ($!)", $destination)
      if !-d $destination || !-w _;
    
    # if it didn't fail, log the creation event
    $log->add(Chroniton::Event->mkdir($destination));

    # create the storage directory
    my $date = DateTime->now;
    $date =~ s/\s//g; # whitespace in filenames is unnecessary

    my $storage_dir = "$destination/backup_$date";
    if(-e $storage_dir){
	# if you have more than one backup in one second, the second backup
	# will fail unless we make the dates unique
	$storage_dir .= time();
    }
    mkdir $storage_dir or 
      $log->fatal("cannot create storage directory: $!", $storage_dir);
    my $contents = Chroniton::BackupContents->new($storage_dir);
    
    # back up each source
    foreach my $src (@sources){
	if(!-e $src){
	    $log->error($src, "can't backup $src because it doesn't exist");
	    next;
	}

	my $dest = "$storage_dir/$src";
	my $this_against = "$against/$src" if $against;
	my @dirs = split m{/}, $src;
	my $adir = shift @dirs;
	unshift @dirs, $adir if $adir ne "/";
	
	# make original parent directories in the backup dir
	my $dir = $storage_dir;
	foreach(@dirs){
	    $dir .= "/$_";
	    mkdir $dir;
	    $log->add(Chroniton::Event->mkdir($dir))      if  -d $dir;
	    $log->fatal("couldn't create $dir: $!", $dir) if  !-e $dir;
	}
	$contents->add($src);
	# do it!
	_clone_dir($config, $log, $contents, $src, $dest, $this_against, \&_compare_files);
    }

    $log->message($sources, "$mode backup of $sources completed");
    return $contents;
}

=head2 _clone_dir($config, $log, $contents, $src, $dest, [$against, $compare_ref])

Clones $src into $dest, recursively decending into subdirectories,
depth first.  If $against is specified, each file in $src is compared
against the file of the same name in $against via the $compare_ref
subroutine.  This routine should return 1 if the file from $src needs
to be copied to $dest, or 0 if the file placed in $dest should be a
(relative) symbolic link to the file in $against.

Returns nothing.  Dies on a fatal error, otherwise logs errors and
warnings to $log, a C<Chroniton::Messages> object.

=cut

sub _clone_dir {
    my ($config, $log, $contents, $src, $dest, $against, $compare_ref) = @_;

    my $mode = ($against) ? "incremental" : "full";
    $log->debug($src, "cloning $src to $dest (mode: $mode)".
		(($against) ? " against $against" : "")); 

    if($mode eq "incremental" && !-d $against){
	$log->message($against, "increment dir $against does not exist");
	undef $against; # do a full backup in this case
    }
    
    my $status = opendir(my $dir, $src);
    if(!$status){
	$log->error($src, "couldn't open $src for inspection");
	return;
    }
    
        
    # TODO: what happens when readdir fails midway thru?
    my @files = readdir $dir; 
    my @exclude_list = $config->exclude;
    @files = _filter_filelist($log, $src, [@files], [@exclude_list]);
    
    foreach my $file (@files){
	
	my $src_file      = "$src/$file";
	my $dest_file     = "$dest/$file";
	my $against_file  = "$against/$file" if $against;
	my $original;
	
	next if $file eq '.' || $file eq '..';
	
	if(-d $src_file && !-l $src_file){
	    my $d = $dest_file; # short alias to save typing :)

	    mkdir $d;
	    if(!-d $d){
		$log->error($d, "couldn't create $d: $!");
		# skip to next file
		next; # not really needed
	    }
	    else {
		$log->add(Chroniton::Event->mkdir($d)) if -d $d;
		
		# recurse
		_clone_dir($config, $log, $contents, $src_file, $d,
			   $against_file, $compare_ref);
	    }
	}
	else {
	    # it's a regular file, so decide whether to copy or link

	    my $what_to_do = 1; # copy by default
	    if($against && !-l $src_file){ 
		$what_to_do = eval { &$compare_ref($src_file, $against_file);};
		if($@){
		    $log->error($src_file, "could not compare $src_file ".
				           "and $against_file: $@");
		    $what_to_do = 1;
		}
	    }
	    

	    # decision has been made, act on it.
	    if(-l $src_file || $what_to_do == 1){
		my $error;
		my $start = time(); # provide timing information
		if(-l $src_file){
		    # if original file is a link, make the backup one, too
		    my $target = readlink($src_file);
		    symlink $target, $dest_file or $error = $!;
		}
		else {
		    cp($src_file, $dest_file) or $error = $!;
		}
		my $end = time();
		
		# so user can't write to these files without trying
		chmod 0400, $dest_file unless -l $src_file; 
		
		if($error){
		    $log->error($src_file,
				"couldn't copy $src_file to $dest_file: $!");
		    next;
		    
		}
		else {
		    my $bytes;
		    if(-l $src_file) {
			$bytes = (lstat $dest_file)[7];
		    }
		    else {
			$bytes = (stat $dest_file)[7];
		    }
		    $log->add(Chroniton::Event->copy($src_file,
						     $dest_file,
						     $end-$start,
						     $bytes));
		}
	    }
	    
	    else { # link
		my $from = _compute_relative_path($against_file, $dest_file);
		$original = $from;
		my $status = symlink $from, $dest_file;
		if($status){
		    $log->add(Chroniton::Event->link($against_file,
						     $dest_file));
		}
		else {
		    $log->error($against_file,
				"couldn't link $against_file to $dest_file ".
				"(source $src_file, mapping: $from)");
		    next;
		}
	    } # end link
	} # end dir/not dir if

	# make an entry in the file list, since this file backed up OK
	eval {
	    if($original){
		$original =~ s{^/?(?:[.][.]/)+}{}g; # strip leading ../../
	    }
	    $contents->add($src_file, $original);
	};
	if($@){
	    $log->error($src_file, "problem storing file metadata");
	}
	
    } # end readdir
    closedir $dir;
}

sub _filter_filelist {
    my ($log, $prefix, $files_ref, $filter_ref) = @_;
    my @okay_files;
  FILE: foreach my $file (@$files_ref){
	my $path = "$prefix/$file";
      FILTER: foreach my $filter (@$filter_ref){
	    if($path =~ $filter){
		$log->debug($path, "skipping $path due to exclude rules");
		next FILE;
	    }
	}
	push @okay_files, $file;
    }
    return @okay_files;
}

# computes a path suitable for relative links.
#
# imagine that you are in directory /foo/backup/current/bar and you want to
# create a link "baz" to the file in /foo/backup/old/bar/baz.  This function will
# return the correct first argument to symlink ("../../old/bar/baz" in this case).
sub _compute_relative_path {
    my $original    = shift; # what we're linking to
    my $destination = shift; # file name that the link will have
    
    # clean up destination to conform to abs2rel's expectation. namely that the file-
    # name is not specified already.
    my @dest = splitpath($destination);
    pop @dest;
    
    return abs2rel($original, catfile(@dest));
}

# arguments: 
# a: "original" file
# b: file that we're incrementing against

# not an argument, but c is the file that will be in the new backup set, 
# whether it's a link or a copy is based on the output of this function

# returns:
# 1 if a should be copied into the new direcory, creating "c"
# 0 if b should be linked into the new direcory, creating "c"
sub _compare_files {
    my $a = shift;
    my $b = shift;
    return 1 if !-e $b; # copy if there's nothing to diff against
    
    my $md_a = Digest::MD5->new;
    my $md_b = Digest::MD5->new;
    
    open my $afh, "<", $a or die "could not open $a: $!";
    open my $bfh, "<", $b or die "could not open $b: $!";
    
    $md_a->addfile($afh);
    $md_b->addfile($bfh);

    return ($md_a->hexdigest ne $md_b->hexdigest) ? "1" : "0";
    
    #confess "a $a does not exist" if !-e $a; # this shouldn't happen!!
    #
    #my $ma = (stat $a)[9];
    #my $mb = (stat $b)[9];
    #return ($ma > $mb) ? 1 : 0;
}

1;
