package Pitonyak::DateUtil;

#************************************************************

=head1 NAME

Pitonyak::DateUtil - Format and convert Time/Date strings

=head1 SYNOPSIS

use Pitonyak::DateUtil;


=head1 Formatting

A time is formatted based on a format string.
The number of times that a special character exists indicates the number of digits that will be used.
The format character 'YYYYMMDD.hhmmss' yields a four digit year, two digit month, two digit day,
a period, two digit hour, etc... Characters that are not considered special are inserted verbatim.
Characters that are escaped with a backslash '\' are also inserted verbatim. Use '\\' to insert a backslash.

The special format characters are as follows:

=over 4

=item h = hour (0-24)

=item m = minute (0-59)

=item s = second (0-59)

=item Y = year

In Perl, the earliest year is 1900.

=item M = Month (1-12)

Perl uses (0-11)

=item W = Month (JANUARY, FEBRUARY, ..., DECEMBER)

I really should allow these to be in other languages as well.

=item y = year day (0-364)

=item D = month day (0-30)

=item w = week day (1=Sunday, ..., 7=Saturday)

Perl uses (0=Sunday, ... , 6=Saturday)

=back

=head1 DESCRIPTION

Convert time and date information between different formats.
This can convert between text representations and an integer.

=cut

#************************************************************

require Exporter;
use vars qw($VERSION @ISA @EXPORT @EXPORT_OK);
require Exporter;
$VERSION = '1.01';
@ISA     = qw(Exporter);
@EXPORT  = qw(
  time_date_str
  invert_time_date_str
  time_date_str_to_int
  change_time_date_str
  est_str_to_utc_str
  day_in_month
);

@EXPORT_OK = qw(
);

use Carp;
use strict;
use Time::Local;
use Pitonyak::StringUtil qw(num_with_leading_zeros);

#************************************************************

=pod

=head2  time_date_str

=over 4

=item  time_date_str($format_string, [$time_integer])

=back

Ignoring the time to make the call, both of the following calls are equivalent.

time_date_str($format_string);
time_date_str($format_string, time());

This will return a formatted string with the specified time.

=cut

#************************************************************

sub time_date_str {
    if ( $#_ < 0 ) {
        carp( 'Usage: time_date_str($format_string, [$time_integer])'
              . "\nIllegal number of parameters, 1 desired, not $#_" );
        return undef;
    }

    # If called from an object, then simply discard
    if ( $#_ >= 0 && UNIVERSAL::isa( $_[0], 'Pitonyak::DateUtil' ) ) {
        shift;
    }
    my $fmt    = ( scalar(@_) > 0 ) ? shift: "YYYYMMDD.hhmmss";
    my $a_time = ( scalar(@_) > 0 ) ? shift: time();
    my $rv     = "";
    my ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst );
    my $month_word;
    my ( @the_chars, $a_key );
    my @months = (
        'JANUARY',   'FEBRUARY', 'MARCH',    'APRIL',
        'MAY',       'JUNE',     'JULY',     'AUGUST',
        'SEPTEMBER', 'OCTOBER',  'NOVEMBER', 'DECEMBER'
    );
    my %fmtHash = (
        "h" => \$hour,
        "m" => \$min,
        "s" => \$sec,
        "Y" => \$year,
        "M" => \$mon,
        "W" => \$month_word,
        "D" => \$mday,
        "w" => \$wday,
        "y" => \$yday
    );
    ( $sec, $min, $hour, $mday, $mon, $year, $wday, $yday, $isdst ) =
      ( localtime($a_time) );
    $month_word = $months[$mon];
    ++$mon;
    ++$wday;
    $year += 1900;
    @the_chars = split ( //, $fmt );

    while ( scalar(@the_chars) > 0 ) {

        #
        # Remember that $[ is the first array reference. It should be
        # zero unless some evil person changed it, but better safe than
        # sorry, so this is used in substr.
        #
        $a_key = shift @the_chars;
        if ( $a_key eq "\\" ) {
            $rv .= shift @the_chars if scalar(@the_chars) > 0;
        }
        elsif ( exists $fmtHash{$a_key} ) {

            #
            # It is a key char so how many in a row are there?
            #
            my $len = 1;
            while ( scalar(@the_chars) > 0 && $the_chars[0] eq $a_key ) {
                shift @the_chars;
                $len++;
            }
            if ( $a_key ne 'W' ) {
                $rv .= num_with_leading_zeros( $len, ${ $fmtHash{$a_key} } );
            }
            else {
                $rv .=
                  Pitonyak::StringUtil::trim_fmt( $len, ${ $fmtHash{$a_key} } );
            }
        }
        else {
            $rv .= $a_key;
        }
    }
    return $rv;
}

#************************************************************

=pod

=head2 invert_time_date_str

=over 4

=item invert_time_date_str($format_string, $formatted_time_date_string)

=back

Convert a formatted time/date string to the same output as timelocal().

returns ($sec, $min, $hours, $mday, $mon, $year) where the $year will be -1 on error.
December is mapped to 11 and the year 1900 is mapped to 0. One digit years are always
mapped to 200y and two digit years are windowed so that 00-79 map to 20xx and 80-99 map to 19xx.
Three digit years are obvious

=cut

#************************************************************

sub invert_time_date_str {
    if ( $#_ != 1 ) {
        carp('Usage: invert_time_date_str($format_string, $formatted_time_date_string)'
              . "\nIllegal number of parameters, 2 desired, not $#_" );
        return undef;
    }
    my $fmt    = shift;
    my $a_time = shift;
    my ( @the_chars, $a_key );
    my ( $tmp,       $len );
    my $sec      = 0;
    my $min      = 0;
    my $hour     = 0;
    my $mday     = 0;
    my $mon      = 0;
    my $year     = 0;
    my $tmp_time = $a_time;

    my %fmtHash = (
        "h" => \$hour,
        "m" => \$min,
        "s" => \$sec,
        "Y" => \$year,
        "M" => \$mon,
        "W" => \$mon,
        "D" => \$mday
    );

    my ( $aKey, $aVal );

    @the_chars = split ( //, $fmt );
    while ( scalar(@the_chars) > 0 ) {

        #
        # Find the next character
        #
        $a_key = shift @the_chars;
        if ( exists $fmtHash{$a_key} ) {

            #
            # It is a key char so how many in a row there are...
            #
            $len = 1;
            while ( scalar(@the_chars) > 0 && $the_chars[0] eq $a_key ) {
                shift @the_chars;
                $len++;
            }

            #
            # Now we know how many characters make up this value
            # ie, mmm means that we have three digits for minute
            # strip them out and set the value in the hash and then
            # shorten the time string.
            #
            if ( $len > length($a_time) ) {
                carp "Time string too short in invert_time_date_str($fmt, $tmp_time)";
                undef @the_chars;
            }
            else {
                if ( $a_key eq 'W' ) {
                    my @months = (
                        'JANUARY',   'FEBRUARY', 'MARCH',    'APRIL',
                        'MAY',       'JUNE',     'JULY',     'AUGUST',
                        'SEPTEMBER', 'OCTOBER',  'NOVEMBER', 'DECEMBER'
                    );
                    my $fnd = -1;
                    my $match_str = substr $a_time, $[, $len;
                    for ( my $i = 0 ; $fnd == -1 && $i < 12 ; ++$i ) {
                        $fnd = $i if $months[$i] =~ /^$match_str/;
                    }
                    ${ $fmtHash{$a_key} } = $fnd unless $fnd == -1;

                }
                else {
                    ${ $fmtHash{$a_key} } = scalar( substr $a_time, $[, $len );
                }
                $a_time = substr $a_time, $[ + $len;
                if ( $a_key eq "M" ) {

                    #
                    # It is the month which is stored as a zero offset
                    #
                    --${ $fmtHash{$a_key} };

                }
                elsif ( $a_key eq "Y" ) {

                    #
                    # What if we do not have ALL the digits then try windowing?
                    # where pre 1980 means the century is 2000.
                    #
                    if ( $len < 4 ) {

                        #
                        # We did not get all the year digits so attempt to do some
                        # intelligent processing. Less than 80 means year 20xx.
                        # Two digits between 80 and 99 inclusive are 19xx.
                        # Greater than 899 means 1xxx and otherwise we have 2xxx
                        #
                        if ( ${ $fmtHash{$a_key} } < 80 ) {
                            ${ $fmtHash{$a_key} } += 2000;
                        }
                        elsif ( $len == 2 ) { ${ $fmtHash{$a_key} } += 1900; }
                        elsif ( ${ $fmtHash{$a_key} } > 899 ) {
                            ${ $fmtHash{$a_key} } += 1000;
                        }
                        else { ${ $fmtHash{$a_key} } += 2000; }
                    }

                    #
                    # We should now have the year to full accuracy. Perl stores the
                    # year as 0 means 1900, so a current value less than 1900 should
                    # indicate an error.
                    #
                    if ( ${ $fmtHash{$a_key} } >= 1900 ) {
                        ${ $fmtHash{$a_key} } -= 1900;
                    }
                    else {
                        ${ $fmtHash{$a_key} } = -1;
                        carp "Invalid year in invert_time_date_str($fmt, $tmp_time)";
                    }
                }
            }
        }
        else {

            #
            # Eat escape character
            #
            if ( $a_key eq "\\" ) {
                shift @the_chars;
            }

            #
            # Eat a character from time string
            #
            ($a_time) = ( $a_time =~ /^.(.*)$/ );
        }
    }
    return ( $sec, $min, $hour, $mday, $mon, $year );
}

#************************************************************

=pod

=head2 time_date_str_to_int

=over 4

=item time_date_str_to_int($format_string, $formatted_time_date_string)

=back

Convert a formatted time/date string to an integer. -1 is returned on error.
The integer can be used as input to other routines.

=cut

#************************************************************

sub time_date_str_to_int {
    if ( $#_ != 1 ) {
        carp(
'Usage: time_date_str_to_int($format_string, $formatted_time_date_string)'
              . "\nIllegal number of parameters, 2 desired, not $#_" );
        return undef;
    }
    my @tda = invert_time_date_str( $_[0], $_[1] );
    my $rc  = -1;

    eval { $rc = timelocal(@tda); };
    if ( $@ || $rc < 0 ) {
        carp
"error $@ with rc = $rc Calling timelocal($tda[0], $tda[1], $tda[2], $tda[3], $tda[4], $tda[5])\nin time_date_str_to_int($_[0], $_[1])";
        $rc = -1;
    }
    return $rc;
}

#************************************************************

=pod

=head2 change_time_date_str

=over 4

=item change_time_date_str($desired_format, $current_format, $formatted_time_date_string)

=back

Change a formatted time/date string to a different format.

This is nothing more than a shortcut to using time_date_str($desired_format, time_date_str_to_int($current_format, $formatted_time_date_string));

=cut

#************************************************************

sub change_time_date_str {
    if ( $#_ != 2 ) {
        carp(
'Usage: change_time_date_str($desired_format, $current_format, $formatted_time_date_string)'
              . "\nIllegal number of parameters, 3 desired, not $#_" );
        return undef;
    }
    return time_date_str( $_[0], time_date_str_to_int( $_[1], $_[2] ) );
}


#************************************************************

=pod

=head2 day_in_month

Return a date object corresponding to a specified instance of a specific week day
in a given year and month.

=over 4

=item C<< change_time_date_str($year, $month, $day_of_month, $instance) >>

An example is worth a thousand words.
The call C<< change_time_date_str(2007, 3, 1, 2) >>
returns the second monday in March 2007. The day of the month must be in the
range 0=Sunday to 6=Saturday. No error checking is performed to verify that
there are two Mondays in March.

=back

=cut

#************************************************************
sub day_in_month {
    if ( $#_ != 3 ) {
        carp ('Usage: day_in_month(2007, 3, 1, 2) finds second (2) monday (1) in March (3) for 2007.');
        return undef;
    }
    if ( $_[2] < 0 || $_[2] > 6 ) {
        carp ('The desired day must be in the range of 0=Sunday to 6=Saturday.');
        return undef;
    }

    if ( $_[3] < 0 || $_[2] > 5 ) {
        carp ('The desired instance must at least be a positive number less than 6.');
        return undef;
    }

    my @days = ('Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday');
    my ( $sec, $min, $hour, $mday, $month, $year ) = (0, 0, 0, 1, $_[1], $_[0]);
    my ( $wday, $yday, $isdst );
    my $desired_day = $_[2];
    my $desired_instance = $_[3];

    --$month;
    $year -= 1900;
    my $time = timelocal( $sec, $min, $hour, $mday, $month, $year );
    ($sec, $min, $hour, $mday, $month, $year, $wday, $yday, $isdst) = localtime($time);
    my $day_offset = (($desired_day - $wday + 7) % 7) + ($desired_instance - 1) * 7;
    if ($day_offset + $mday > 31)
    {
        carp ("Month $month in $year does not have $desired_instance instances of day number $desired_day");
        return undef;
    }
    return timelocal( $sec, $min, $hour, $mday + $day_offset, $month, $year );
}

#************************************************************

=pod

=head2 est_str_to_utc_str

Convert a date/time string in EST to a date/time string in UTC.

=over 4

=item C<< est_str_to_utc_str($date_time, $input_fmt, $output_fmt) >>

=back

=cut

#************************************************************
sub est_str_to_utc_str
{
    my $provided_time = time_date_str_to_int($_[1], $_[0]);
    $provided_time += is_int_time_in_dst($provided_time) ? 14400 : 18000;
    time_date_str($_[2], $provided_time);
}

#************************************************************

=pod

=head2 is_int_time_in_dst

=over 4

=item C<< is_int_time_in_dst($date_time_int) >>

Return 1 if the specified date/time value is during daylight savings time in EST.
most of the U.S. will begin Daylight Saving Time at 2:00 a.m. on the second
Sunday in March and revert to standard time on the first Sunday in November.
In the U.S., each time zone switches at a different time.

At 2:00 AM on the second Sunday in March, the time immediately turns into 3:00 AM.
It is, therefore, easy to determine if these times use DST.
It is more difficult to determine when the time ends. Times from 1:00 AM until
2:00 AM are repeated, which is a very real problem. Luckily, this routine
is based on the number of seconds since a specific point in time.

=back

=cut

#************************************************************
sub is_int_time_in_dst
{
    # most of the U.S. will begin Daylight Saving Time at 2:00 a.m. on the second
    # Sunday in March and revert to standard time on the first Sunday in November.
    # In the U.S., each time zone switches at a different time.
    my ($sec, $min, $hour, $mday, $month, $year, $wday, $yday, $isdst) = localtime($_[0]);

    return 0 if $month < 2 || 10 < $month;  # Before March or after Novermber is easy.
    return 1 if 2 < $month && $month < 10;  # Between April and October (inclusive) is easy.

    my $break_point;
    if ($month == 2)
    {
        # Second Monday in March.
        $break_point = day_in_month($year+1900, 3, 0, 2);
        # 3600 seconds in an hour
        # 2 hours = 7200 seconds
        # At 2:00 AM, the time moves forward one hour so we should never
        # see this time.

        return ($break_point + 7200 <= $_[0]) ? 1 : 0;
    }
    # First Sunday in November
    $break_point = day_in_month($year+1900, 11, 0, 1);

    # 3600 seconds in an hour
    # 2 hours = 7200 seconds
    # At 2:00 AM, the time moves back one hour so we should
    # see this time twice.

    return ($break_point + 7200 <= $_[0]) ? 0 : 1;
}

#************************************************************

=pod

=head1 COPYRIGHT

Copyright 1998-2007, Andrew Pitonyak

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

=head1 Modification History

=head2 March 13, 1998

Version 1.00 First release

=head2 September 10, 2002

Version 1.01 Changed internal documentation to POD documentation. Added parameter checking.

=cut

#************************************************************

1;