package DateTime::BusinessHours;

use strict;
use warnings;

use DateTime;

use Class::MethodMaker [
    scalar => [
        qw( datetime1 datetime2 worktiming weekends holidayfile holidays )
    ],
];

our $VERSION = '2.01';

sub new {
    my ( $class, %args ) = @_;

    die 'datetime1 parameter required' if !$args{ datetime1 };
    die 'datetime2 parameter required' if !$args{ datetime2 };

    $args{ worktiming } ||= [ [ 9, 17 ] ];
    $args{ weekends }   ||= [ 6, 7 ];
    $args{ holidays }   ||= [ ];

    if( !ref @{ $args{ worktiming } }[ 0 ] ) {
        $args{ worktiming } = [ [ @{ $args{ worktiming } } ] ];
    }

    return bless \%args, $class;
}

sub calculate {
    my $self = shift;
    $self->{ _result } = undef;
    $self->_calculate;
}

sub _calculate {
    my $self = shift;

    return $self->{ _result } if defined $self->{ _result };
    $self->{ _result } = { days => 0, hours => 0 };

    # number of hours in a work day
    my $length = $self->_calculate_day_length;
    my @holidays = $self->_get_holidays;
    my $weekend_re = join( '|', @{ $self->weekends } );

    my $d1 = $self->datetime1->clone;
    my $d2 = $self->datetime2->clone;

    # swap if "start" is more recent than "end"
    ( $d1, $d2 ) = ( $d2, $d1 ) if $d1 > $d2;

    my $start = $d1->clone->truncate( to => 'day' );
    my $end   = $d2->clone->truncate( to => 'day' );

    my $s_ymd = $start->ymd;
    my $e_ymd = $end->ymd;

    my %holiday_map = map { $_ => 1 } grep { $_ ge $s_ymd && $_ le $e_ymd } @holidays;

    # deal with everything non-inclusive to the start/end
    $start->add( days => 1 );
    $end->subtract( days => 1 );

    while( $start <= $end ) {
        if( $start->day_of_week !~ m{$weekend_re} && !exists $holiday_map{ $start->ymd } ) {
            $self->{ _result }->{ hours } += $length;
        }

        $start->add( days => 1 );
    }

    # handle start and end days
    for( reverse @{ $self->{ _timing_norms } } ) {
        last if $d1 >= $d1->clone->set( %{ $_->[ -1 ] } );

        my $r1 = $d1->clone->set( %{ $_->[ 0 ] } );
        my $r2 = $d1->clone->set( %{ $_->[ 1 ] } );

        # full or partial range
        $r1 = $d1 if $d1 > $r1;
        $r2 = $d2 if $d2 < $r2; # only happens when $d1 and $d2 are on the same day

        my $dur = $r2 - $r1;
        $self->{ _result }->{ hours } += $dur->in_units( 'minutes' ) / 60;
    }

    # if start and end aren't on the same day
    if( $d1->truncate( to => 'day' ) != $d2->truncate( to => 'day' ) ) {
        for( @{ $self->{ _timing_norms } } ) {
            last if $d2 <= $d2->clone->set( %{ $_->[ 0 ] } );

            my $r1 = $d2->clone->set( %{ $_->[ 0 ] } );
            my $r2 = $d2->clone->set( %{ $_->[ 1 ] } );

            # full or partial range
            $r2 = $d2 if $d2 < $r2;

            my $dur = $r2 - $r1;
            $self->{ _result }->{ hours } += $dur->in_units( 'minutes' ) / 60;
        }
    }

    $self->{ _result }->{ days } = $self->{ _result }->{ hours } / $length;
    return $self->{ _result };
}

# determine how many hours are in a business day
sub _calculate_day_length {
    my $self = shift;

    $self->{ _day_length } = 0;
    $self->{ _timing_norms } = [];

    for my $i ( @{ $self->worktiming } ) {
        push @{ $self->{ _timing_norms } }, [];
        for( @$i ) {
            # normalize input times
            $_ = sprintf( '%02s00', $_ ) if length == 1 || length == 2;
            $_ = sprintf( '%04s', $_ );

            my( $h, $m ) = m{(..)(..)};

            # normalize input times for use with DateTime
            push @{ $self->{ _timing_norms }->[ -1 ] }, { hour => $h, minute => $m };
        }
    }

    for my $tn ( @{ $self->{ _timing_norms } } ) {
        my $dur = DateTime->new( year => 2012, %{ $tn->[ 1 ] } )
            - DateTime->new( year => 2012, %{ $tn->[ 0 ] } );
        $self->{ _day_length } += $dur->in_units( 'minutes' ) / 60;
    }

    return $self->{ _day_length };
}

sub _get_holidays {
    my $self = shift;

    my @holidays = @{ $self->holidays };
    my $filename = $self->holidayfile;

    if( $filename && -e $filename ) {
        open( my $fh, '<', $filename );
        while ( <$fh> ) { chomp; push @holidays, $_ };
        close $fh;
    }

    return @holidays;
}

sub getdays {
    return shift->_calculate->{ days };
}

sub gethours {
    return shift->_calculate->{ hours };
}

1;

__END__

=head1 NAME

DateTime::BusinessHours - An object that calculates business days and hours 

=head1 SYNOPSIS

    my $d1 = DateTime->new( year => 2007, month => 10, day => 15 );
    my $d2 = DateTime->now;

    my $test = DateTime:::BusinessHours->new(
        datetime1 => $d1,
        datetime2 => $d2,
        worktiming => [ 9, 17 ], # 9am to 5pm
        # lunch from 12 to 1
        # worktiming => [ [ 9, 12 ], [ 13, 17 ] ],
        weekends => [ 6, 7 ], # Saturday and Sunday
        holidays => [ '2007-10-31', '2007-12-24' ],
        holidayfile => 'holidays.txt'
        # holidayfile is a text file with each date in a new line
        # in the format yyyy-mm-dd  
    );

    # total business hours
    print $test->gethours, "\n";
    # total business days, based on the number of business hours in a day
    print $test->getdays, "\n"; 

=head1 DESCRIPTION

BusinessHours a class for caculating the business hours between two DateTime 
objects. It can be useful in situations like escalation where an action has to 
happen after a certain number of business hours.

=head1 METHODS

=head2 new( %args )

This class method accepts the following arguments as parameters:

=over 4

=item * datetime1 - Starting Date 

=item * datetime2 - Ending Date

=item * worktiming - May be one of the following:

=over 4

=item * An array reference with two values: starting and ending hour of the day

=item * An array reference of array references. Each reference being a slice of the 24-hour clock where business is conducted. Useful if you want to leave a "lunch hour" out of the calculation. Defaults to [ [ 9, 17 ] ]

=back

=item * weekends - An array reference with values of the days that must be considered as non-working in a week. Defaults to [6,7] (Saturday & Sunday)

=item * holidays - An array reference with holiday dates in 'yyyy-mm-dd' format

=item * holidayfile - The name of a file from which predefined holidays can be excluded from business days/hours calculation. Defaults to no file

=back

=head2 calculate( )

This will force a recalculation of the business hours and days. useful if you've changed any values (datetime1, datetime2, worktiming, etc) or updated the holiday file

=head2 getdays( )

Returns the number of business days

=head2 gethours( )

Returns the number of business hours.

=head1 INSTALLATION

To install this module, run the following commands:

	perl Makefile.PL
	make
	make test
	make install

=head1 SUPPORT AND DOCUMENTATION

After installing, you can find documentation for this module with the
perldoc command.

    perldoc DateTime::BusinessHours

You can also look for information at:

    RT, CPAN's request tracker
        http://rt.cpan.org/NoAuth/Bugs.html?Dist=DateTime-BusinessHours

    AnnoCPAN, Annotated CPAN documentation
        http://annocpan.org/dist/DateTime-BusinessHours

    CPAN Ratings
        http://cpanratings.perl.org/d/DateTime-BusinessHours

    Search CPAN
        http://search.cpan.org/dist/DateTime-BusinessHours

=head1 AUTHOR

Antano Solar John <solar345@gmail.com>

=head1 MAINTAINER

Brian Cassidy <bricas@cpan.org>

=head1 COPYRIGHT AND LICENSE

Copyright (c) 2007-2011 Antano Solar John, 2012 by Brian Cassidy

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

=cut

