# Copyright 2000-2006, Fred Steinberg, Brown Bear Software

# MailReminder.pm

package MailReminder;
use strict;
use Fcntl qw(:DEFAULT :flock);
use File::Copy;
use vars '$AUTOLOAD';
use Calendar::Defines;
use Calendar::Database;
use Calendar::Date;
use Calendar::EventFormatter;
use Calendar::I18N;
use Calendar::User;

# The Reminder file consists of lines which look something like this:
#    948995368 Default 38 2000/01/18 3

#  - fields are "reminder time" "calendar name" "event id" "event date"
#               ["repeating event instance"]

#  - Repeat instance is used only for repeating events, to keep track of
#    how many reminders are stored for this event, and whether we need to
#    generate some more. Currently, a max of 6 reminders per event are
#    stored (e.g. 3 days worth for a "repeat every day" event w/two
#    reminders.)

# The file is sorted by reminder date, calendar name, event id

# Class methods
#    - new
#    - open, close
#    - getUpTo
#    - add, delete
#    - calendarRenamed

# Object methods
#    - AUTOLOAD for members
#    - sendIt

# Object data
#    - reminderDate (int # of second)
#    - calendarName
#    - eventID
#    - eventDate    (object)
#    - n            (only for repeating events)

sub AUTOLOAD {
    my $self = shift;
    my $name = $AUTOLOAD;
    $name =~ s/.*://;           # get rid of package names, etc.
    return unless $name =~ /[^A-Z]/;  # ignore all cap methods; e.g. DESTROY 

    # Make sure it's a valid field, eh wot?
    die "Bad Field Name to MailReminder! '$name'\n"
        unless {reminderDate => 1,
                calendarName => 1,
                eventID      => 1,
                eventDate    => 1,
                sendStatus   => 1,
                n            => 1}->{$name};

    $self->{$name} = shift if (@_);
    $self->{$name};
}

sub new {
    my $class = shift;
    my $self = {reminderDate => $_[0],
                calendarName => $_[1],
                eventID      => $_[2],
                eventDate    => $_[3],
                n            => $_[4]};
    bless $self, $class;
}

# Open the file, unless it's already open, in which case we just rewind
# Return the handle and flag which is true if we opened or reopened.
{
    my ($filehandle, $openForWrite);

sub open {
    my $classname = shift;
    my $readwrite = shift;

    my $filename = Defines->baseDirectory . '/data/Master/reminders';

    my $writing = ($readwrite =~ /^(rw|rdwr|o_rdwr)$|write/i);

    # If already open in desired mode, just rewind and return. If open in
    # read only and we need to write, close and re-open.
    if ($filehandle) {
        if ($openForWrite or !$writing) {
            seek ($filehandle, 0, 0) or die "Couldn't seek '$filename'! $!\n";
            return wantarray ? ($filehandle, undef) : $filehandle;
        }
        $classname->close;      # we'll re-open for writing
    }

    $openForWrite = $writing;

    my ($status, $lock);
    if ($writing) {
        $status = O_CREAT|O_RDWR;
        $lock   = LOCK_EX;
        copy ($filename, $filename . "backup");
    } else {
        $status = O_CREAT|O_RDONLY;
        $lock   = LOCK_SH;
    }

    sysopen (RFILE, $filename, $status)
        or die "Couldn't open Reminder file: '$filename'! $!\n";
    unless (flock (RFILE, $lock | LOCK_NB)) {
        warn ('Reminder file is locked; waiting to ' .
              (($lock == LOCK_EX) ? 'write.' : 'read.'));
        unless (flock (RFILE, $lock)) { die "couldn't lock database! $!\n" }
    }

    $filehandle = \*RFILE;
    return wantarray ? ($filehandle, 1) : $filehandle; # 1 means opened here
}

sub close {
    close $filehandle;
    undef $filehandle;
    undef $openForWrite;
}
}

sub getStoredValue {
    my ($classname, $keyName) = @_;
    my ($handle, $openedHere) = $classname->open ('read');
    my ($key, @values);
    while (<$handle>) {
        last unless /^#/;
        (undef, $key, @values) = split;
        last if ($key eq $keyName);
        undef @values;
    }
    $classname->close if $openedHere;
    return wantarray ? @values : $values[0];
}
sub setStoredValue {
    my ($classname, $keyName, @values) = @_;
    my $handle = $classname->open ('readwrite');
    my @lines = <$handle>;
    my $foundIt;
    my $newLine = "# $keyName " . join (' ', @values) . "\n";
    foreach (@lines) {
        last unless /^#/;
        my $key;
        (undef, $key) = split;
        if ($key eq $keyName) {
            $_ = $newLine;
            $foundIt++;
            last;
        }
    }
    unshift @lines, $newLine unless ($foundIt);

    # and re-write the file
    seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
    truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
    print $handle @lines;
}

sub getLastProcessed {
    my $classname = shift;
    my ($theTime, $pid) = $classname->getStoredValue ('LastProcessed');
    return ($theTime, $pid);
#     my ($handle, $openedHere) = $classname->open ('read');
#     my ($key, $value, $pid);
#     while (<$handle>) {
#         last unless /^#/;
#         (undef, $key, $value, $pid) = split;
#         next unless ($key eq 'LastProcessed');
#     }
#     $classname->close if $openedHere;
#     return ($value, $pid);
}
sub setLastProcessed {
    my ($classname, $date, $pid) = @_;
    $date ||= time;
    $pid  ||= $$;
    my $niceDate = localtime ($date);
    $classname->setStoredValue ('LastProcessed', $date, $pid, "($niceDate)");
#     my $handle = $classname->open ('readwrite');
#     my @lines = <$handle>;
#     my $foundIt;
#     foreach (@lines) {
#         last unless /^#/;
#         my $key;
#         (undef, $key, undef, undef, undef) = split;
#         if ($key eq 'LastProcessed') {
#             $_ = "# LastProcessed $date $pid ($niceDate)\n";
#             $foundIt++;
#             last;
#         }
#     }
#     unshift @lines, "# LastProcessed $date $pid ($niceDate)\n"
#         unless ($foundIt);

#     # and re-write the file
#     seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
#     truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
#     print $handle @lines;
}

sub getLastSubscriptionRun {
    my $classname = shift;
    my $theTime = $classname->getStoredValue ('LastSubscriptionRun');
    return $theTime;
}
sub setLastSubscriptionRun {
    my ($classname, $date) = @_;
    $date ||= time;
    my $niceDate = localtime ($date);
    $classname->setStoredValue ('LastSubscriptionRun', $date, "($niceDate)");
}


# Pass the date (a la time()) to get reminders up to.
# Return a list of Reminder objects that are ready to go
# Open file if necessary, but we don't close it!
sub getUpTo {
    my $classname = shift;
    my $date = shift;
    $date ||= time;

    my @reminders;

    my $handle = $classname->open ('read');
    while (<$handle>) {
        next if /^#/;
        my ($reminderDate, $calName, $eventID, $eventDate, $n) = split;
        last if ($reminderDate > $date);
        push @reminders, MailReminder->new ($reminderDate, $calName,
                                            $eventID, Date->new ($eventDate),
                                            $n);
    }
    @reminders;
}

# Pass a single or list of MailReminders.
# Open file if necessary, but we don't close it!
sub delete {
    my $classname = shift;
    my @reminders = @_;

    return unless @reminders;

    # get in same order as file; sort by reminder time, cal name, event id
    # Not the most efficient here (could use an ST), but we're not
    # expecting long lists.
    @reminders = sort {$a->reminderDate <=> $b->reminderDate or
                       $a->calendarName cmp $b->calendarName or
                       $a->eventID      <=> $b->eventID}
                   @reminders;
    my @newLines;

    my $handle = $classname->open ('readwrite');
    my @lines = <$handle>;

    my $reminder = shift @reminders;
    foreach (@lines) {
        if ($reminder and !/^#/) {
            my ($reminderDate, $calendarName, $eventID, $eventDate,$n) = split;

            # if the right one, don't save it
            if ($reminderDate == $reminder->reminderDate and
                $eventID      == $reminder->eventID      and
                $calendarName eq $reminder->calendarName) {
                $reminder = shift @reminders;
                next;
            }
        }
        push @newLines, $_;         # copy everything else
    }

    # and re-write the file
    seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
    truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
    print $handle @newLines;
}

# Pass a single or ref to a list of Event IDs to delete all reminders for,
# and the calendar name, and optionally, a date to delete just reminders
# for a single instance of a repeating event.
# This one does close the file.
sub deleteEventReminders {
    my $classname = shift;
    my ($eventIDs, $calName, $justOne) = @_;

    $eventIDs = [$eventIDs] unless (ref ($eventIDs) eq 'ARRAY');
    my %lookup;
    foreach (@$eventIDs) {
        $lookup{"$_ $calName"}++;
    }

    my @single_instance_info_list;

    my $handle = $classname->open ('readwrite');
    my @lines = <$handle>;

    my @newLines;
    foreach (@lines) {
        if (!/^#/) {
            my ($reminderDate, $calendarName, $eventID, $eventDate,$n) = split;

            $n ||= 0;           # avoid use of undef

            # Keep track if we're deleting a single-instance and it's
            # the last repeating instance
            if ($justOne
                and $n == 1
                and ("$justOne" eq $eventDate)
                and $lookup{"$eventID $calendarName"}) {
                push @single_instance_info_list, [$calendarName, $eventID,
                                                  $eventDate];
            }
            next if ($lookup{"$eventID $calendarName"} and
                     (!$justOne or "$justOne" eq $eventDate));
        }
        push @newLines, $_;
    }

    # and re-write the file
    seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
    truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
    print $handle @newLines;
    $classname->close;

    # NOW, if we deleted a single instance for a repeating event AND it was
    # the last one, we need to add more reminders
    foreach my $instance_info (@single_instance_info_list) {
        my $cal_name = $instance_info->[0];
        my $event_id = $instance_info->[1];
        my $date     = $instance_info->[2]; # a string, not a Date
        $date = Date->new ($date);
        my ($event, $prefs);
        eval {
            my $db = Database->new ($cal_name);
            if ($db) {
                ($event, undef) = $db->getEventById ($event_id);
                $prefs = Preferences->new ($db);
            }
        };
        if ($@) {
            warn "Error: $@\n";
            next;
        }
        MailReminder->add ($event, $cal_name, $date + 1);
    }
}

sub deleteAllForCalendar {
    my ($classname, $calendar) = @_;
    my $handle = $classname->open ('readwrite');
    my @lines = <$handle>;
    my @newLines;
    foreach (@lines) {
        if (!/^#/) {
            my ($reminderDate, $calendarName, $eventID, $eventDate,$n) = split;
            next if ($calendarName eq $calendar);
        }
        push @newLines, $_;
    }

    # and re-write the file
    seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
    truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
    print $handle @newLines;
    $classname->close;
}

# Calendar renamed; must rename the reminders
sub calendarRenamed {
    my ($classname, $oldCalName, $newCalName) = @_;
    my $handle = $classname->open ('readwrite');
    my @lines = <$handle>;
    chomp @lines;
    my (@newLines, $modified);
    foreach (@lines) {
        my ($reminderDate, $calendarName, $eventID, $eventDate,$n) = split;
        if ($calendarName eq $oldCalName) {
            $n = (defined $n and $n) ? " $n" : '';
            $_ = "$reminderDate $newCalName $eventID $eventDate" . "$n";
            $modified++;
        }
        push @newLines, "$_\n";
    }

    # and re-write the file if any changes
    if ($modified) {
        seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
        truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
        print $handle @newLines;
    }
    $classname->close;
}

# Pass an Event, Calendar Name, and the date (object). For repeating
# events, we'll add reminders for up to N occurrences, starting from the
# specified date. (N is defined in this sub.) Then, when the last one is
# sent, we'll add N more (see sendIt()). If an event has multiple
# reminders, we'll always add all of them for the same occurrence.
# For daily repeating events, only add reminder for first occurrence.
# Only close file if we opened it here.
sub add {
    my $classname = shift;
    my ($event, $calName, $eventDate) = @_;
    return unless ($event and defined ($calName));

    my $eventHash;
    my $isRepeating = $event->isRepeating;
    my $n;
    if (!$isRepeating) {
        $eventHash->{"$eventDate"} = [$event];
    } else {
        $n = 3;
        # Only do first for daily repeaters; see also sendIt()
        if ($event->repeatInfo->frequency == 1   and
            $event->repeatInfo->period =~ /day/i) {
            $n = 1;
        }
        my $prefs = Preferences->new ($calName);
        $eventHash = $event->repeatInfo->nextNOccurrences ($event, $n,
                                                           $eventDate, $prefs);
    }

    my @newOnes;    # list of [ReminderTime, EventDate] pairs for each new line

    require Time::Local;
    while (my ($theDate, $eventListRef) = each %$eventHash) {
        $theDate = Date->new ($theDate);
        foreach my $theEvent (@$eventListRef) {
            my $hour = int (($theEvent->startTime || 0) / 60);
            my $min  = ($theEvent->startTime || 0) % 60;
            my $seconds = Time::Local::timelocal (0, $min, $hour,
                                                  $theDate->day,
                                                  $theDate->month - 1,
                                                  $theDate->year - 1900);

            foreach (split /\s/, $theEvent->reminderTimes) {
                unshift @newOnes, [$seconds - $_ * 60, "$theDate"];
            }
        }
    }

    @newOnes = sort {$a->[0] <=> $b->[0]} @newOnes; # sort by reminder time

    $n = @newOnes if $isRepeating;

    my ($handle, $openedHere) = $classname->open ('readwrite');
    my @lines = <$handle>;

    my $newID = $event->id;

    foreach my $newOneInfo (@newOnes) {
        my ($reminderTime, $eventDate) = @$newOneInfo;
        my $newLine = "$reminderTime $calName $newID $eventDate " .
                      (defined $n ? " $n" : '') . "\n";

        # find where it goes
        my $position = 0;
        foreach (@lines) {
            if (!/^#/) {
                my ($rTime, $cal, $id, $eDate, $n) = split;
                my $cmp = _compareLines ([$reminderTime, $calName, $newID],
                                         [$rTime,        $cal,     $id]);
                last if ($cmp < 0);
            }
            $position++;
        }

        # splice it in there
        splice @lines, $position, 0, $newLine;

        $n-- if ($isRepeating);
    }

    # and re-write the file
    seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
    truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
    print $handle @lines;

    $classname->close if $openedHere;
}

sub _compareLines {
    my ($l1, $l2) = @_;
    return ($l1->[0] <=> $l2->[0]     # reminder time  (note comments == 0)
            or
            $l1->[1] cmp $l2->[1]     # calendar name
            or
            $l1->[2] <=> $l2->[2]);   # event id
}


# Send the mail!
sub sendIt {
    my $self = shift;
    my %args = (earliest  => 1,     # time which reminders must be newer then
                dryRun    => undef, # if true, don't send the mail
                deliverTo => undef, # actually send to this address
                BCC       => undef, # send BCC to this address
                verbose   => undef,
                @_);
    my $tooOld    = $args{earliest} || 1;
    my $dryRun    = $args{dryRun};
    my $verbose   = $args{verbose};
    my $deliverTo = $args{deliverTo};
    my $BCC       = $args{BCC};

    require Calendar::MasterDB;
    require Calendar::Mail::MailSender;
    my ($event, $prefs);
    eval {
        my $db = Database->new ($self->calendarName);
        if ($db) {
            $event = $db->getEvent ($self->eventDate, $self->eventID);
            $prefs = Preferences->new ($db);
        }
    };
    warn "Error: $@" if ($@);

    unless ($event) {
        warn "couldn't find event: " . $self->calendarName . ", " .
                                       $self->eventDate    . ", " .
                                       $self->eventID      . "\n";
        $self->{sendStatus} = 'errornotfound';
        return;
    }

    # Make copy since adjustForTimezone() changes start/end times, and
    # we might be adding more future reminders for this event if
    # repeating
    my $event_orig = $event->copy;

    if ($self->reminderDate > $tooOld) {
        my %params;
        my $mPrefs = Preferences->new (MasterDB->new);
        $params{To}   = $event->reminderTo if defined $event->reminderTo;
        $params{SMTP} = $mPrefs->MailSMTP  if defined $mPrefs->MailSMTP;
        $params{From} = $prefs->MailFrom
                        || (defined ($mPrefs->MailFrom) && $mPrefs->MailFrom)
                        || "calcium\@$ENV{SERVER_NAME}";
        my $i18n = I18N->new ($prefs->Language);

        # check for TZ offset
        # Get first address; if > 1 in list, just use TZ of first....
        my $offset;
        if ($params{To}) {
            $params{To} =~ /([^, ]*)/;
            my $address = $1;
            if (my $user = User->userFromAddress ($address)) {
                $offset = $user->timezone || 0;
            }
        }
        if (!defined $offset) {
            $offset = $prefs->DefaultTimezone;
            $offset ||= 0;
        }

        if ($verbose) {
            warn "Sending reminder - Calendar: '" . $self->calendarName .
                 "', " . "Event: '" . $event->text . "'\n  Address: " .
                 $event->reminderTo . "\n";
            warn "    TZ Offset: $offset hours\n" if $offset;
        }

        my $dateChange = $event->adjustForTimezone ($self->eventDate, $offset);
        my $theDate = $self->eventDate + $dateChange;

        my $html =
          qq {<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"} .
          "\n" .
          qq {        "http://www.w3.org/TR/REC-html40/loose.dtd">} .
          "\n<html><head><title>Calcium Email Reminder</title></head><body>\n";

        # Message body
        my ($text, $htmlbody) = $event->formatForMail ($theDate,
                                                       $self->calendarName,
                                                       $prefs, $i18n);
        $html .= $htmlbody;

        my $subject = $prefs->RemindSubject ||
                          $i18n->get ('Calcium Event Reminder');
        $subject = $event->expandString ($subject, $theDate, $i18n);
        $subject =~ s/[\r\n]/ /g; # remove newlines or mail gets munged

        if (my $sig = $prefs->MailSignature || $mPrefs->MailSignature) {
            $text .= "\n----\n$sig";
            $html .= '<br><hr width="25%" align="left"/><p>' .
                     Event::_escapeThis ($sig, undef, 1) . '</p>';
        }

        $html .= "</body></html>\n";

        if (!$dryRun) {
            my $format = $prefs->MailFormat || 'both';
            my %contents = (text => $text,
                            html => $html);
            if (lc ($format) eq 'text') {
                delete $contents{html};
            } elsif (lc ($format) eq 'html') {
                delete $contents{text};
            }

            $params{To}  = $deliverTo if ($deliverTo);
            $params{BCC} = $BCC       if ($BCC);
            my $mailer = MailSender->new (%params);
            unless ($mailer->send ($subject, \%contents)) {
                warn "Mailing error: " . $mailer->error;
                $self->{sendStatus} = 'errormailer';
                return;
            }
        }

        $self->{sendStatus} = 'sent';
    } else {
        $self->{sendStatus} = 'tooOld';
    }

    # OK, if we sent (or ignored) the last occurrence of a repeating event
    # reminder, let's add some more, starting with the next day.
    if ($event->isRepeating and $self->n == 1) {
        # But not if it's a daily repeater.
        if ($event->repeatInfo->frequency != 1   or
            $event->repeatInfo->period !~ /day/i) {
            # Use version of event NOT ADJUSTED for timezone
            MailReminder->add ($event_orig, $self->calendarName,
                               $self->eventDate + 1);
        }
    }

    return;
}

# Used only for maintenance. Removes duplicates and ones for nonexistent
# events, ensure its sorted properly

sub _cleanup {
    my $classname = shift;

    my ($handle, $openedHere) = $classname->open ('readwrite');
    my @lines = <$handle>;

    # Sort 'em (split line to get date,cal,id fields, pass that to compare)
    @lines = map {$_->[0]}
              sort {_compareLines ($a->[1], $b->[1])}
                map {[$_, /^#/ ? [0,0,0] : [(split)[0,1,2]]]}
                    @lines;

    my (%dbs, @newLines);
    my $previousLine = '';

    foreach (@lines) {
        next if ($_ eq $previousLine); # remove dupes
        $previousLine = $_;

        if (!/^#/) {      # always keep comment lines....
            my ($date, $cal, $id, $eventDate, $n) = split;
            $dbs{$cal} ||= Database->new ($cal);

            next unless -f $dbs{$cal}->_getFilename;

            my $event;
            eval {
                $event = $dbs{$cal}->getEvent ($eventDate, $id);
            };
            next if ($@ or !$event); # remove bad events, bad calendars
        }

        push @newLines, $_;
    }

    # and re-write the file
    seek ($handle, 0, 0)     or die "Couldn't seek 'reminders'! $!\n";
    truncate ($handle, 0)    or die "Couldn't truncate 'reminders'! $!\n";
    print $handle @newLines;
    $classname->close;

    return (@lines - @newLines);
}

1;
