# --
# Copyright (C) 2001-2021 OTRS AG, https://otrs.com/
# Copyright (C) 2021 Znuny GmbH, https://znuny.org/
# --
# This software comes with ABSOLUTELY NO WARRANTY. For details, see
# the enclosed file COPYING for license information (GPL). If you
# did not receive this file, see https://www.gnu.org/licenses/gpl-3.0.txt.
# --
## nofilter(TidyAll::Plugin::Znuny::CodeStyle::DollarUnderscore)

package Kernel::System::Calendar::Appointment;

use strict;
use warnings;

use Digest::MD5;

use vars qw(@ISA);

use Kernel::System::VariableCheck qw(:all);
use Kernel::System::EventHandler;

our @ObjectDependencies = (
    'Kernel::Config',
    'Kernel::System::Cache',
    'Kernel::System::Calendar',
    'Kernel::System::DB',
    'Kernel::System::Daemon::SchedulerDB',
    'Kernel::System::DateTime',
    'Kernel::System::Group',
    'Kernel::System::Log',
    'Kernel::System::Main',
    'Kernel::System::Scheduler',
);

=head1 NAME

Kernel::System::Calendar::Appointment - calendar appointment lib

=head1 DESCRIPTION

All appointment functions.

=head1 PUBLIC INTERFACE

=head2 new()

create an object. Do not use it directly, instead use:

    use Kernel::System::ObjectManager;
    local $Kernel::OM = Kernel::System::ObjectManager->new();
    my $AppointmentObject = $Kernel::OM->Get('Kernel::System::Calendar::Appointment');

=cut

sub new {
    my ( $Type, %Param ) = @_;

    # allocate new hash for object
    my $Self = {%Param};
    bless( $Self, $Type );

    @ISA = qw(
        Kernel::System::EventHandler
    );

    # init of event handler
    $Self->EventHandlerInit(
        Config => 'AppointmentCalendar::EventModulePost',
    );

    $Self->{CacheType} = 'Appointment';
    $Self->{CacheTTL}  = 60 * 60 * 24 * 20;

    return $Self;
}

=head2 AppointmentCreate()

creates a new appointment.

    my $AppointmentID = $AppointmentObject->AppointmentCreate(
        ParentID              => 1,                                       # (optional) valid ParentID for recurring appointments
        CalendarID            => 1,                                       # (required) valid CalendarID
        UniqueID              => 'jwioji-fwjio',                          # (optional) provide desired UniqueID; if there is already existing Appointment
                                                                          #            with same UniqueID, system will delete it
        Title                 => 'Webinar',                               # (required) Title
        Description           => 'How to use Process tickets...',         # (optional) Description
        Location              => 'Straubing',                             # (optional) Location
        StartTime             => '2016-01-01 16:00:00',                   # (required)
        EndTime               => '2016-01-01 17:00:00',                   # (required)
        AllDay                => 0,                                       # (optional) default 0
        TeamID                => [ 1 ],                                   # (optional) must be an array reference if supplied
        ResourceID            => [ 1, 3 ],                                # (optional) must be an array reference if supplied
        Recurring             => 1,                                       # (optional) flag the appointment as recurring (parent only!)
        RecurringRaw          => 1,                                       # (optional) skip loop for recurring appointments (do not create occurrences!)
        RecurrenceType        => 'Daily',                                 # (required if Recurring) Possible "Daily", "Weekly", "Monthly", "Yearly",
                                                                          #           "CustomWeekly", "CustomMonthly", "CustomYearly"

        RecurrenceFrequency   => [1, 3, 5],                               # (required if Custom Recurring) Recurrence pattern
                                                                          #           for CustomWeekly: 1-Mon, 2-Tue,..., 7-Sun
                                                                          #           for CustomMonthly: 1-1st, 2-2nd,.., 31th
                                                                          #           for CustomYearly: 1-Jan, 2-Feb,..., 12-Dec
                                                                          # ...
        RecurrenceCount       => 1,                                       # (optional) How many Appointments to create
        RecurrenceInterval    => 2,                                       # (optional) Repeating interval (default 1)
        RecurrenceUntil       => '2016-01-10 00:00:00',                   # (optional) Until date
        RecurrenceID          => '2016-01-10 00:00:00',                   # (optional) Expected start time for this occurrence
        RecurrenceExclude     => [                                        # (optional) Which specific occurrences to exclude
            '2016-01-10 00:00:00',
            '2016-01-11 00:00:00',
        ],
        NotificationTime      => '2016-01-01 17:00:00',                   # (optional) Point of time to execute the notification event
        NotificationTemplate  => 'Custom',                                # (optional) Template to be used for notification point of time
        NotificationCustom    => 'relative',                              # (optional) Type of the custom template notification point of time
                                                                          #            Possible "relative", "datetime"
        NotificationCustomRelativeUnitCount   => '12',                    # (optional) minutes, hours or days count for custom template
        NotificationCustomRelativeUnit        => 'minutes',               # (optional) minutes, hours or days unit for custom template
        NotificationCustomRelativePointOfTime => 'beforestart',           # (optional) Point of execute for custom templates
                                                                          #            Possible "beforestart", "afterstart", "beforeend", "afterend"
        NotificationCustomDateTime => '2016-01-01 17:00:00',              # (optional) Notification date time for custom template
        TicketAppointmentRuleID    => '9bb20ea035e7a9930652a9d82d00c725', # (optional) Ticket appointment rule ID (for ticket appointments only!)
        UserID                     => 1,                                  # (required) UserID
    );

returns parent AppointmentID if successful

Events:
    AppointmentCreate

=cut

sub AppointmentCreate {
    my ( $Self, %Param ) = @_;

    for my $Needed (qw(CalendarID Title StartTime EndTime UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # prepare possible notification params
    $Self->_AppointmentNotificationPrepare(
        Data => \%Param,
    );

    # if Recurring is provided, additional parameters must be present
    if ( $Param{Recurring} ) {

        my @RecurrenceTypes = (
            "Daily",       "Weekly",       "Monthly",       "Yearly",
            "CustomDaily", "CustomWeekly", "CustomMonthly", "CustomYearly"
        );

        if (
            !$Param{RecurrenceType}
            || !grep { $_ eq $Param{RecurrenceType} } @RecurrenceTypes
            )
        {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "RecurrenceType invalid!",
            );
            return;
        }

        if (
            (
                $Param{RecurrenceType} eq 'CustomWeekly'
                || $Param{RecurrenceType} eq 'CustomMonthly'
                || $Param{RecurrenceType} eq 'CustomYearly'
            )
            && !$Param{RecurrenceFrequency}
            )
        {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "RecurrenceFrequency needed!",
            );
            return;
        }
    }

    $Param{RecurrenceInterval} ||= 1;

    if ( $Param{UniqueID} && !$Param{ParentID} ) {
        my %Appointment = $Self->AppointmentGet(
            UniqueID   => $Param{UniqueID},
            CalendarID => $Param{CalendarID},
        );

        # delete existing appointment with same UniqueID
        if ( %Appointment && $Appointment{AppointmentID} ) {
            $Self->AppointmentDelete(
                AppointmentID => $Appointment{AppointmentID},
                UserID        => $Param{UserID},
            );
        }
    }

    # check ParentID
    if ( $Param{ParentID} && !IsInteger( $Param{ParentID} ) ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "ParentID must be a number!",
        );
        return;
    }

    # Check StartTime.
    my $StartTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{StartTime},
        },
    );
    if ( !$StartTimeObject ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "Invalid StartTime!",
        );
        return;
    }

    # check UniqueID
    if ( !$Param{UniqueID} ) {
        $Param{UniqueID} = $Self->GetUniqueID(
            CalendarID => $Param{CalendarID},
            StartTime  => $Param{StartTime},
            UserID     => $Param{UserID},
        );
    }

    # Check EndTime.
    my $EndTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{EndTime},
        },
    );
    if ( !$EndTimeObject ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "Invalid EndTime!",
        );
        return;
    }

    # check if array refs
    my %Arrays;
    for my $Parameter (
        qw(TeamID ResourceID RecurrenceFrequency RecurrenceExclude)
        )
    {
        if ( $Param{$Parameter} && @{ $Param{$Parameter} // [] } ) {
            if ( !IsArrayRefWithData( $Param{$Parameter} ) ) {
                $Kernel::OM->Get('Kernel::System::Log')->Log(
                    Priority => 'error',
                    Message  => "$Parameter not ARRAYREF!",
                );
                return;
            }

            my @Array = @{ $Param{$Parameter} };

            # remove undefined values
            @Array = grep { defined $_ } @Array;

            $Arrays{$Parameter} = join( ',', @Array ) if @Array;
        }
    }

    # check if numbers
    for my $Parameter (
        qw(Recurring RecurrenceCount RecurrenceInterval)
        )
    {
        if ( $Param{$Parameter} && !IsInteger( $Param{$Parameter} ) ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "$Parameter must be a number!",
            );
            return;
        }
    }

    # check RecurrenceUntil
    if ( $Param{RecurrenceUntil} ) {

        # Usually hour, minute and second = 0. In this case, take time from StartTime.
        $Param{RecurrenceUntil} = $Self->_TimeCheck(
            OriginalTime => $Param{StartTime},
            Time         => $Param{RecurrenceUntil},
        );

        my $RecurrenceUntilObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{RecurrenceUntil},
            },
        );

        if (
            !$RecurrenceUntilObject
            || $StartTimeObject > $RecurrenceUntilObject
            )
        {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Invalid RecurrenceUntil!",
            );
            return;
        }
    }

    # get db object
    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');
    my @Bind;

    # parent ID supplied
    my $ParentIDCol = my $ParentIDVal = '';
    if ( $Param{ParentID} ) {
        $ParentIDCol = 'parent_id,';
        $ParentIDVal = '?,';
        push @Bind, \$Param{ParentID};

        # turn off all recurring fields
        delete $Param{Recurring};
        delete $Param{RecurrenceType};
        delete $Param{RecurrenceFrequency};
        delete $Param{RecurrenceCount};
        delete $Param{RecurrenceInterval};
        delete $Param{RecurrenceUntil};
    }

    push @Bind, \$Param{CalendarID}, \$Param{UniqueID}, \$Param{Title}, \$Param{Description},
        \$Param{Location}, \$Param{StartTime},   \$Param{EndTime},   \$Param{AllDay},
        \$Arrays{TeamID},  \$Arrays{ResourceID}, \$Param{Recurring}, \$Param{RecurrenceType},
        \$Arrays{RecurrenceFrequency}, \$Param{RecurrenceCount},      \$Param{RecurrenceInterval},
        \$Param{RecurrenceUntil},      \$Param{RecurrenceID},         \$Arrays{RecurrenceExclude},
        \$Param{NotificationDate},     \$Param{NotificationTemplate}, \$Param{NotificationCustom},
        \$Param{NotificationCustomRelativeUnitCount},   \$Param{NotificationCustomRelativeUnit},
        \$Param{NotificationCustomRelativePointOfTime}, \$Param{NotificationCustomDateTime},
        \$Param{TicketAppointmentRuleID},               \$Param{UserID}, \$Param{UserID};

    my $SQL = "
        INSERT INTO calendar_appointment
            ($ParentIDCol calendar_id, unique_id, title, description, location, start_time,
            end_time, all_day, team_id, resource_id, recurring, recur_type, recur_freq, recur_count,
            recur_interval, recur_until, recur_id, recur_exclude, notify_time, notify_template,
            notify_custom, notify_custom_unit_count, notify_custom_unit, notify_custom_unit_point,
            notify_custom_date, ticket_appointment_rule_id, create_time, create_by, change_time,
            change_by)
        VALUES ($ParentIDVal ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
            ?, ?, current_timestamp, ?, current_timestamp, ?)
    ";

    # create db record
    return if !$DBObject->Do(
        SQL  => $SQL,
        Bind => \@Bind,
    );

    my $AppointmentID;

    # return parent id for appointment occurrences
    if ( $Param{ParentID} ) {
        $AppointmentID = $Param{ParentID};
    }

    # get appointment id for parent appointment
    else {
        return if !$DBObject->Prepare(
            SQL => '
                SELECT id FROM calendar_appointment
                WHERE unique_id=? AND parent_id IS NULL
            ',
            Bind  => [ \$Param{UniqueID} ],
            Limit => 1,
        );

        while ( my @Row = $DBObject->FetchrowArray() ) {
            $AppointmentID = $Row[0] || '';
        }

        # return if there is not appointment created
        if ( !$AppointmentID ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => 'Can\'t get AppointmentID from INSERT!',
            );
            return;
        }
    }

    # add recurring appointments
    if ( $Param{Recurring} && !$Param{RecurringRaw} ) {
        return if !$Self->_AppointmentRecurringCreate(
            ParentID    => $AppointmentID,
            Appointment => \%Param,
        );
    }

    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');

    # clean up list methods cache
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'List' . $Param{CalendarID},
    );
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'Days' . $Param{UserID},
    );

    # fire event
    $Self->EventHandler(
        Event => 'AppointmentCreate',
        Data  => {
            AppointmentID => $AppointmentID,
            CalendarID    => $Param{CalendarID},
        },
        UserID => $Param{UserID},
    );

    return $AppointmentID;
}

=head2 AppointmentList()

get a hash of Appointments.

    my @Appointments = $AppointmentObject->AppointmentList(
        CalendarID          => 1,                                       # (required) Valid CalendarID
        Title               => '*',                                     # (optional) Filter by title, wildcard supported
        Description         => '*',                                     # (optional) Filter by description, wildcard supported
        Location            => '*',                                     # (optional) Filter by location, wildcard supported
        StartTime           => '2016-01-01 00:00:00',                   # (optional) Filter by start date
        EndTime             => '2016-02-01 00:00:00',                   # (optional) Filter by end date
        TeamID              => 1,                                       # (optional) Filter by team
        ResourceID          => 2,                                       # (optional) Filter by resource
        Result              => 'HASH',                                  # (optional), HASH|ARRAY
    );

returns an array of hashes with select Appointment data or simple array of AppointmentIDs:

Result => 'HASH':

    @Appointments = [
        {
            AppointmentID => 1,
            CalendarID    => 1,
            UniqueID      => '20160101T160000-71E386@localhost',
            Title         => 'Webinar',
            Description   => 'How to use Process tickets...',
            Location      => 'Straubing',
            StartTime     => '2016-01-01 16:00:00',
            EndTime       => '2016-01-01 17:00:00',
            AllDay        => 0,
            Recurring     => 1,                                           # for recurring (parent) appointments only
        },
        {
            AppointmentID => 2,
            ParentID      => 1,                                           # for recurred (child) appointments only
            CalendarID    => 1,
            UniqueID      => '20160101T180000-A78B57@localhost',
            Title         => 'Webinar',
            Description   => 'How to use Process tickets...',
            Location      => 'Straubing',
            StartTime     => '2016-01-02 16:00:00',
            EndTime       => '2016-01-02 17:00:00',
            TeamID        => [ 1 ],
            ResourceID    => [ 1, 3 ],
            AllDay        => 0,
        },
        {
            AppointmentID                         => 3,
            CalendarID                            => 1,
            UniqueID                              => '20160101T180000-A78B57@localhost',
            Title                                 => 'Webinar',
            Description                           => 'How to use Process tickets...',
            Location                              => 'Straubing',
            StartTime                             => '2016-01-02 16:00:00',
            EndTime                               => '2016-01-02 17:00:00',
            TimezoneID                            => 1,
            TeamID                                => [ 1 ],
            ResourceID                            => [ 1, 3 ],
            NotificationDate                      => '2016-01-02 16:10:00',
            NotificationTemplate                  => 'Custom',
            NotificationCustom                    => 'relative',
            NotificationCustomRelativeUnitCount   => '10',
            NotificationCustomRelativeUnit        => 'minutes',
            NotificationCustomRelativePointOfTime => 'afterstart',
            NotificationCustomDateTime            => '2016-01-02 16:00:00',
            TicketAppointmentRuleID               => '9bb20ea035e7a9930652a9d82d00c725',    # for ticket appointments only!
        },
        # ...
    ];

Result => 'ARRAY':

    @Appointments = [ 1, 2, ... ]

=cut

sub AppointmentList {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(CalendarID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # output array of hashes by default
    $Param{Result} = $Param{Result} || 'HASH';

    # cache keys
    my $CacheType        = $Self->{CacheType} . 'List' . $Param{CalendarID};
    my $CacheKeyTitle    = $Param{Title} || 'any';
    my $CacheKeyDesc     = $Param{Description} || 'any';
    my $CacheKeyLocation = $Param{Location} || 'any';
    my $CacheKeyStart    = $Param{StartTime} || 'any';
    my $CacheKeyEnd      = $Param{EndTime} || 'any';
    my $CacheKeyTeam     = $Param{TeamID} || 'any';
    my $CacheKeyResource = $Param{ResourceID} || 'any';

    if ( defined $Param{Title} && $Param{Title} =~ /^[\*]+$/ ) {
        $CacheKeyTitle = 'any';
    }
    if ( defined $Param{Description} && $Param{Description} =~ /^[\*]+$/ ) {
        $CacheKeyDesc = 'any';
    }
    if ( defined $Param{Location} && $Param{Location} =~ /^[\*]+$/ ) {
        $CacheKeyLocation = 'any';
    }

    my $CacheKey
        = "$CacheKeyTitle-$CacheKeyDesc-$CacheKeyLocation-$CacheKeyStart-$CacheKeyEnd-$CacheKeyTeam-$CacheKeyResource-$Param{Result}";

    # check cache
    my $Data = $Kernel::OM->Get('Kernel::System::Cache')->Get(
        Type => $CacheType,
        Key  => $CacheKey,
    );

    if ( ref $Data eq 'ARRAY' ) {
        return @{$Data};
    }

    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # check time
    if ( $Param{StartTime} ) {
        my $StartTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{StartTime},
            },
        );
        if ( !$StartTimeObject ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "StartTime invalid!",
            );
            return;
        }
    }
    if ( $Param{EndTime} ) {
        my $EndTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{EndTime},
            },
        );
        if ( !$EndTimeObject ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "EndTime invalid!",
            );
            return;
        }
    }

    my $SQL = '
        SELECT id, parent_id, calendar_id, unique_id, title, description, location, start_time,
            end_time, team_id, resource_id, all_day, recurring, notify_time, notify_template,
            notify_custom, notify_custom_unit_count, notify_custom_unit, notify_custom_unit_point,
            notify_custom_date, ticket_appointment_rule_id
        FROM calendar_appointment
        WHERE calendar_id=?
    ';

    my @Bind;

    push @Bind, \$Param{CalendarID};

    # Filter title, description and location fields by using QueryCondition method, which will
    #   return backend specific SQL statements in order to provide case insensitive match and
    #   wildcard support.
    FILTER:
    for my $Filter (qw(Title Description Location)) {
        next FILTER if !$Param{$Filter};
        $SQL .= ' AND ' . $DBObject->QueryCondition(
            Key          => lc $Filter,
            Value        => $Param{$Filter},
            SearchPrefix => '*',
            SearchSuffix => '*',
        );
    }

    if ( $Param{StartTime} && $Param{EndTime} ) {

        $SQL .= ' AND (
            (start_time >= ? AND start_time < ?) OR
            (end_time > ? AND end_time <= ?) OR
            (start_time <= ? AND end_time >= ?)
        ) ';
        push @Bind, \$Param{StartTime}, \$Param{EndTime}, \$Param{StartTime}, \$Param{EndTime}, \$Param{StartTime},
            \$Param{EndTime};
    }
    elsif ( $Param{StartTime} && !$Param{EndTime} ) {

        $SQL .= ' AND end_time >= ? ';
        push @Bind, \$Param{StartTime};
    }
    elsif ( !$Param{StartTime} && $Param{EndTime} ) {

        $SQL .= ' AND start_time <= ? ';
        push @Bind, \$Param{EndTime};
    }

    $SQL .= ' ORDER BY id ASC';

    # db query
    return if !$DBObject->Prepare(
        SQL  => $SQL,
        Bind => \@Bind,
    );

    my @Result;

    ROW:
    while ( my @Row = $DBObject->FetchrowArray() ) {

        # team id
        my @TeamID = split( ',', $Row[9] // '' );
        if ( $Param{TeamID} ) {
            next ROW if !grep { $_ == $Param{TeamID} } @TeamID;
        }

        # resource id
        $Row[10] = $Row[10] ? $Row[10] : 0;
        my @ResourceID = $Row[10] =~ /,/ ? split( ',', $Row[10] ) : ( $Row[10] );
        if ( $Param{ResourceID} ) {
            next ROW if !grep { $_ == $Param{ResourceID} } @ResourceID;
        }

        my %Appointment = (
            AppointmentID                         => $Row[0],
            ParentID                              => $Row[1],
            CalendarID                            => $Row[2],
            UniqueID                              => $Row[3],
            Title                                 => $Row[4],
            Description                           => $Row[5],
            Location                              => $Row[6],
            StartTime                             => $Row[7],
            EndTime                               => $Row[8],
            TeamID                                => \@TeamID,
            ResourceID                            => \@ResourceID,
            AllDay                                => $Row[11],
            Recurring                             => $Row[12],
            NotificationDate                      => $Row[13] || '',
            NotificationTemplate                  => $Row[14],
            NotificationCustom                    => $Row[15],
            NotificationCustomRelativeUnitCount   => $Row[16],
            NotificationCustomRelativeUnit        => $Row[17],
            NotificationCustomRelativePointOfTime => $Row[18],
            NotificationCustomDateTime            => $Row[19] || '',
            TicketAppointmentRuleID               => $Row[20],
        );
        push @Result, \%Appointment;
    }

    # if Result was ARRAY, output only IDs
    if ( $Param{Result} eq 'ARRAY' ) {
        my @ResultList;
        for my $Appointment (@Result) {
            push @ResultList, $Appointment->{AppointmentID};
        }
        @Result = @ResultList;
    }

    # cache
    $Kernel::OM->Get('Kernel::System::Cache')->Set(
        Type  => $CacheType,
        Key   => $CacheKey,
        Value => \@Result,
        TTL   => $Self->{CacheTTL},
    );

    return @Result;
}

=head2 AppointmentDays()

get a hash of days with Appointments in all user calendars.

    my %AppointmentDays = $AppointmentObject->AppointmentDays(
        StartTime           => '2016-01-01 00:00:00',                   # (optional) Filter by start date
        EndTime             => '2016-02-01 00:00:00',                   # (optional) Filter by end date
        UserID              => 1,                                       # (required) Valid UserID
    );

returns a hash with days as keys and number of Appointments as values:

    %AppointmentDays = {
        '2016-01-01' => 1,
        '2016-01-13' => 2,
        '2016-01-30' => 1,
    };

=cut

sub AppointmentDays {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # cache keys
    my $CacheType     = $Self->{CacheType} . 'Days' . $Param{UserID};
    my $CacheKeyStart = $Param{StartTime} || 'any';
    my $CacheKeyEnd   = $Param{EndTime} || 'any';

    # check time
    if ( $Param{StartTime} ) {
        my $StartTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{StartTime},
            },
        );
        if ( !$StartTimeObject ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "StartTime invalid!",
            );
            return;
        }
    }
    if ( $Param{EndTime} ) {
        my $EndTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{EndTime},
            },
        );
        if ( !$EndTimeObject ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "EndTime invalid!",
            );
            return;
        }
    }

    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');

    # check cache
    my $Data = $CacheObject->Get(
        Type => $CacheType,
        Key  => "$CacheKeyStart-$CacheKeyEnd",
    );

    if ( ref $Data eq 'HASH' ) {
        return %{$Data};
    }

    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # get user groups
    my %GroupList = $Kernel::OM->Get('Kernel::System::Group')->PermissionUserGet(
        UserID => $Param{UserID},
        Type   => 'ro',
    );
    my @GroupIDs = sort keys %GroupList;

    my $SQL = "
        SELECT ca.start_time, ca.end_time
        FROM calendar_appointment ca
        JOIN calendar c ON ca.calendar_id = c.id
        WHERE c.group_id IN ( ${\(join ', ', @GroupIDs)} )
    ";

    my @Bind;

    if ( $Param{StartTime} && $Param{EndTime} ) {

        $SQL .= 'AND (
            (ca.start_time >= ? AND ca.start_time < ?) OR
            (ca.end_time > ? AND ca.end_time <= ?) OR
            (ca.start_time <= ? AND ca.end_time >= ?)
        ) ';
        push @Bind, \$Param{StartTime}, \$Param{EndTime}, \$Param{StartTime}, \$Param{EndTime}, \$Param{StartTime},
            \$Param{EndTime};
    }
    elsif ( $Param{StartTime} && !$Param{EndTime} ) {

        $SQL .= 'AND ca.end_time >= ? ';
        push @Bind, \$Param{StartTime};
    }
    elsif ( !$Param{StartTime} && $Param{EndTime} ) {

        $SQL .= 'AND ca.start_time <= ? ';
        push @Bind, \$Param{EndTime};
    }

    $SQL .= 'ORDER BY ca.id ASC';

    # db query
    return if !$DBObject->Prepare(
        SQL  => $SQL,
        Bind => \@Bind,
    );

    my %Result;

    while ( my @Row = $DBObject->FetchrowArray() ) {

        my ( $StartTime, $EndTime, $StartTimeSystem, $EndTimeSystem );

        # StartTime
        if ( $Param{StartTime} ) {
            $StartTime = $Row[0] lt $Param{StartTime} ? $Param{StartTime} : $Row[0];
        }
        else {
            $StartTime = $Row[0];
        }

        # EndTime
        if ( $Param{EndTime} ) {
            $EndTime = $Row[1] gt $Param{EndTime} ? $Param{EndTime} : $Row[1];
        }
        else {
            $EndTime = $Row[1];
        }

        # Get system times.
        my $StartTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $StartTime,
            },
        );
        my $EndTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $EndTime,
            },
        );

        for (
            my $LoopTimeObject = $StartTimeObject->Clone();
            $LoopTimeObject < $EndTimeObject;
            $LoopTimeObject->Add( Days => 1 )
            )
        {
            my $LoopTime = $LoopTimeObject->ToString();

            $LoopTime =~ s/\s.*?$//gsm;

            if ( $Result{$LoopTime} ) {
                $Result{$LoopTime}++;
            }
            else {
                $Result{$LoopTime} = 1;
            }
        }
    }

    # cache
    $CacheObject->Set(
        Type  => $CacheType,
        Key   => "$CacheKeyStart-$CacheKeyEnd",
        Value => \%Result,
        TTL   => $Self->{CacheTTL},
    );

    return %Result;
}

=head2 AppointmentGet()

Get appointment data.

    my %Appointment = $AppointmentObject->AppointmentGet(
        AppointmentID => 1,                                  # (required)
                                                             # or
        UniqueID      => '20160101T160000-71E386@localhost', # (required) will return only parent for recurring appointments
        CalendarID    => 1,                                  # (required)
    );

Returns a hash:

    %Appointment = (
        AppointmentID       => 2,
        ParentID            => 1,                                  # only for recurred (child) appointments
        CalendarID          => 1,
        UniqueID            => '20160101T160000-71E386@localhost',
        Title               => 'Webinar',
        Description         => 'How to use Process tickets...',
        Location            => 'Straubing',
        StartTime           => '2016-01-01 16:00:00',
        EndTime             => '2016-01-01 17:00:00',
        AllDay              => 0,
        TeamID              => [ 1 ],
        ResourceID          => [ 1, 3 ],
        Recurring           => 1,
        RecurrenceType      => 'Daily',
        RecurrenceFrequency => 1,
        RecurrenceCount     => 1,
        RecurrenceInterval  => 2,
        RecurrenceUntil     => '2016-01-10 00:00:00',
        RecurrenceID        => '2016-01-10 00:00:00',
        RecurrenceExclude   => [
            '2016-01-10 00:00:00',
            '2016-01-11 00:00:00',
        ],
        NotificationTime                  => '2016-01-01 17:0:00',
        NotificationTemplate              => 'Custom',
        NotificationCustomUnitCount       => '12',
        NotificationCustomUnit            => 'minutes',
        NotificationCustomUnitPointOfTime => 'beforestart',

        TicketAppointmentRuleID => '9bb20ea035e7a9930652a9d82d00c725',  # for ticket appointments only!
        CreateTime              => '2016-01-01 00:00:00',
        CreateBy                => 2,
        ChangeTime              => '2016-01-01 00:00:00',
        ChangeBy                => 2,
    );

=cut

sub AppointmentGet {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    if (
        !$Param{AppointmentID}
        && !( $Param{UniqueID} && $Param{CalendarID} )
        )
    {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "Need AppointmentID or UniqueID and CalendarID!",
        );
        return;
    }

    my $Data;

    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');

    if ( $Param{AppointmentID} ) {

        # check cache
        $Data = $CacheObject->Get(
            Type => $Self->{CacheType},
            Key  => $Param{AppointmentID},
        );
    }

    if ( ref $Data eq 'HASH' ) {
        return %{$Data};
    }

    # needed objects
    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    my @Bind;
    my $SQL = '
        SELECT id, parent_id, calendar_id, unique_id, title, description, location, start_time,
            end_time, all_day, team_id, resource_id, recurring, recur_type, recur_freq, recur_count,
            recur_interval, recur_until, recur_id, recur_exclude, notify_time, notify_template,
            notify_custom, notify_custom_unit_count, notify_custom_unit, notify_custom_unit_point,
            notify_custom_date, ticket_appointment_rule_id, create_time, create_by, change_time,
            change_by
        FROM calendar_appointment
        WHERE
    ';

    if ( $Param{AppointmentID} ) {
        $SQL .= 'id=? ';
        push @Bind, \$Param{AppointmentID};
    }
    else {
        $SQL .= 'unique_id=? AND calendar_id=? AND parent_id IS NULL ';
        push @Bind, \$Param{UniqueID}, \$Param{CalendarID};
    }

    # db query
    return if !$DBObject->Prepare(
        SQL   => $SQL,
        Bind  => \@Bind,
        Limit => 1,
    );

    my %Result;

    while ( my @Row = $DBObject->FetchrowArray() ) {

        # team id
        my @TeamID = split( ',', $Row[10] // '' );

        # resource id
        my @ResourceID = split( ',', $Row[11] // '0' );

        # recurrence frequency
        my @RecurrenceFrequency = $Row[14] ? split( ',', $Row[14] ) : undef;

        # recurrence exclude
        my @RecurrenceExclude = $Row[19] ? split( ',', $Row[19] ) : undef;

        $Result{AppointmentID}                         = $Row[0];
        $Result{ParentID}                              = $Row[1];
        $Result{CalendarID}                            = $Row[2];
        $Result{UniqueID}                              = $Row[3];
        $Result{Title}                                 = $Row[4];
        $Result{Description}                           = $Row[5];
        $Result{Location}                              = $Row[6];
        $Result{StartTime}                             = $Row[7];
        $Result{EndTime}                               = $Row[8];
        $Result{AllDay}                                = $Row[9];
        $Result{TeamID}                                = \@TeamID;
        $Result{ResourceID}                            = \@ResourceID;
        $Result{Recurring}                             = $Row[12];
        $Result{RecurrenceType}                        = $Row[13];
        $Result{RecurrenceFrequency}                   = \@RecurrenceFrequency;
        $Result{RecurrenceCount}                       = $Row[15];
        $Result{RecurrenceInterval}                    = $Row[16];
        $Result{RecurrenceUntil}                       = $Row[17];
        $Result{RecurrenceID}                          = $Row[18];
        $Result{RecurrenceExclude}                     = \@RecurrenceExclude;
        $Result{NotificationDate}                      = $Row[20] || '';
        $Result{NotificationTemplate}                  = $Row[21] || '';
        $Result{NotificationCustom}                    = $Row[22] || '';
        $Result{NotificationCustomRelativeUnitCount}   = $Row[23] || 0;
        $Result{NotificationCustomRelativeUnit}        = $Row[24] || '';
        $Result{NotificationCustomRelativePointOfTime} = $Row[25] || '';
        $Result{NotificationCustomDateTime}            = $Row[26] || '';
        $Result{TicketAppointmentRuleID}               = $Row[27];
        $Result{CreateTime}                            = $Row[28];
        $Result{CreateBy}                              = $Row[29];
        $Result{ChangeTime}                            = $Row[30];
        $Result{ChangeBy}                              = $Row[31];
    }

    if ( $Param{AppointmentID} ) {

        # cache
        $CacheObject->Set(
            Type  => $Self->{CacheType},
            Key   => $Param{AppointmentID},
            Value => \%Result,
            TTL   => $Self->{CacheTTL},
        );
    }

    return %Result;
}

=head2 AppointmentRecurringGet()

Get data of all of an appointment's recurring appointments.

    my @RecurringAppointments = $AppointmentObject->AppointmentRecurringGet(
        AppointmentID => 1,                                  # (required)
                                                             # or
        UniqueID      => '20160101T160000-71E386@localhost', # (required)
        CalendarID    => 1,                                  # (required)
    );

Returns an array:

    @RecurringAppointment = (
        {
            AppointmentID       => 2,
            ParentID            => 1,
            CalendarID          => 1,
            UniqueID            => '20160101T160000-71E386@localhost',
            Title               => 'Webinar',
            Description         => 'How to use Process tickets...',
            Location            => 'Straubing',
            StartTime           => '2016-01-01 16:00:00',
            EndTime             => '2016-01-01 17:00:00',
            AllDay              => 0,
            TeamID              => [ 1 ],
            ResourceID          => [ 1, 3 ],
            Recurring           => 1,
            RecurrenceType      => 'Daily',
            RecurrenceFrequency => 1,
            RecurrenceCount     => 1,
            RecurrenceInterval  => 2,
            RecurrenceUntil     => '2016-01-10 00:00:00',
            RecurrenceID        => '2016-01-10 00:00:00',
            RecurrenceExclude   => [
                '2016-01-10 00:00:00',
                '2016-01-11 00:00:00',
            ],
            NotificationTime                  => '2016-01-01 17:0:00',
            NotificationTemplate              => 'Custom',
            NotificationCustomUnitCount       => '12',
            NotificationCustomUnit            => 'minutes',
            NotificationCustomUnitPointOfTime => 'beforestart',

            TicketAppointmentRuleID => '9bb20ea035e7a9930652a9d82d00c725',  # for ticket appointments only!
            CreateTime              => '2016-01-01 00:00:00',
            CreateBy                => 2,
            ChangeTime              => '2016-01-01 00:00:00',
            ChangeBy                => 2,
        },
        # ...
    );

=cut

sub AppointmentRecurringGet {
    my ( $Self, %Param ) = @_;

    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # Fetch parent appointment.
    my %Appointment = $Self->AppointmentGet(%Param);
    return if !%Appointment;

    my $AppointmentID = $Appointment{AppointmentID};

    # Get IDs of recurring appointments.
    my $SQL = '
        SELECT id
        FROM   calendar_appointment
        WHERE  parent_id = ?
    ';
    my @Bind = (
        \$AppointmentID,
    );

    return if !$DBObject->Prepare(
        SQL  => $SQL,
        Bind => \@Bind,
    );

    my %RecurringAppointmentIDs;
    while ( my @Row = $DBObject->FetchrowArray() ) {
        my $RecurringAppointmentID = $Row[0];
        $RecurringAppointmentIDs{$RecurringAppointmentID} = 1;
    }
    return if !%RecurringAppointmentIDs;

    my @RecurringAppointments;

    RECURRINGAPPOINTMENTID:
    for my $RecurringAppointmentID ( sort keys %RecurringAppointmentIDs ) {
        my %RecurringAppointment = $Self->AppointmentGet(
            AppointmentID => $RecurringAppointmentID,

        );
        next RECURRINGAPPOINTMENTID if !%RecurringAppointment;

        push @RecurringAppointments, \%RecurringAppointment;
    }

    return @RecurringAppointments;
}

=head2 AppointmentUpdate()

updates an existing appointment.

    my $Success = $AppointmentObject->AppointmentUpdate(
        AppointmentID         => 2,                                       # (required)
        CalendarID            => 1,                                       # (required) Valid CalendarID
        Title                 => 'Webinar',                               # (required) Title
        Description           => 'How to use Process tickets...',         # (optional) Description
        Location              => 'Straubing',                             # (optional) Location
        StartTime             => '2016-01-01 16:00:00',                   # (required)
        EndTime               => '2016-01-01 17:00:00',                   # (required)
        AllDay                => 0,                                       # (optional) Default 0
        Team                  => 1,                                       # (optional)
        ResourceID            => [ 1, 3 ],                                # (optional) must be an array reference if supplied
        Recurring             => 1,                                       # (optional) flag the appointment as recurring (parent only!)

        RecurrenceType        => 'Daily',                                 # (required if Recurring) Possible "Daily", "Weekly", "Monthly", "Yearly",
                                                                          #           "CustomWeekly", "CustomMonthly", "CustomYearly"

        RecurrenceFrequency   => 1,                                       # (required if Custom Recurring) Recurrence pattern
                                                                          #           for CustomWeekly: 1-Mon, 2-Tue,..., 7-Sun
                                                                          #           for CustomMonthly: 1-Jan, 2-Feb,..., 12-Dec
                                                                          # ...
        RecurrenceCount       => 1,                                       # (optional) How many Appointments to create
        RecurrenceInterval    => 2,                                       # (optional) Repeating interval (default 1)
        RecurrenceUntil       => '2016-01-10 00:00:00',                   # (optional) Until date
        NotificationTime      => '2016-01-01 17:00:00',                   # (optional) Point of time to execute the notification event
        NotificationTemplate  => 'Custom',                                # (optional) Template to be used for notification point of time
        NotificationCustom    => 'relative',                              # (optional) Type of the custom template notification point of time
                                                                          #            Possible "relative", "datetime"
        NotificationCustomRelativeUnitCount   => '12',                    # (optional) minutes, hours or days count for custom template
        NotificationCustomRelativeUnit        => 'minutes',               # (optional) minutes, hours or days unit for custom template
        NotificationCustomRelativePointOfTime => 'beforestart',           # (optional) Point of execute for custom templates
                                                                          #            Possible "beforestart", "afterstart", "beforeend", "afterend"
        NotificationCustomDateTime => '2016-01-01 17:00:00',              # (optional) Notification date time for custom template
        TicketAppointmentRuleID    => '9bb20ea035e7a9930652a9d82d00c725', # (optional) Ticket appointment rule ID (for ticket appointments only!)
        UserID                     => 1,                                  # (required) UserID
    );

returns 1 if successful:
    $Success = 1;

Events:
    AppointmentUpdate

=cut

sub AppointmentUpdate {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(AppointmentID CalendarID Title StartTime EndTime UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # prepare possible notification params
    my $Success = $Self->_AppointmentNotificationPrepare(
        Data => \%Param,
    );

    # if Recurring is provided, additional parameters must be present
    if ( $Param{Recurring} ) {

        my @RecurrenceTypes = (
            "Daily",       "Weekly",       "Monthly",       "Yearly",
            "CustomDaily", "CustomWeekly", "CustomMonthly", "CustomYearly"
        );

        if (
            !$Param{RecurrenceType}
            || !grep { $_ eq $Param{RecurrenceType} } @RecurrenceTypes
            )
        {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "RecurrenceType invalid!",
            );
            return;
        }

        if (
            (
                $Param{RecurrenceType} eq 'CustomWeekly'
                || $Param{RecurrenceType} eq 'CustomMonthly'
                || $Param{RecurrenceType} eq 'CustomYearly'
            )
            && !$Param{RecurrenceFrequency}
            )
        {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "RecurrenceFrequency needed!",
            );
            return;
        }
    }

    $Param{RecurrenceInterval} ||= 1;

    # Check StartTime.
    my $StartTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{StartTime},
        },
    );
    if ( !$StartTimeObject ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "StartTime invalid!",
        );
        return;
    }

    # Check EndTime.
    my $EndTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{EndTime},
        },
    );
    if ( !$EndTimeObject ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "EndTime invalid!",
        );
        return;
    }

    # needed objects
    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');

    # check if array refs
    my %Arrays;
    for my $Parameter (
        qw(TeamID ResourceID RecurrenceFrequency)
        )
    {
        if ( $Param{$Parameter} && @{ $Param{$Parameter} // [] } ) {
            if ( !IsArrayRefWithData( $Param{$Parameter} ) ) {
                $Kernel::OM->Get('Kernel::System::Log')->Log(
                    Priority => 'error',
                    Message  => "$Parameter not ARRAYREF!",
                );
                return;
            }

            my @Array = @{ $Param{$Parameter} };

            # remove undefined values
            @Array = grep { defined $_ } @Array;

            $Arrays{$Parameter} = join( ',', @Array ) if @Array;
        }
    }

    # check if numbers
    for my $Parameter (
        qw(Recurring RecurrenceCount RecurrenceInterval)
        )
    {
        if ( $Param{$Parameter} && !IsInteger( $Param{$Parameter} ) ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "$Parameter must be a number!",
            );
            return;
        }
    }

    # check RecurrenceUntil
    if ( $Param{RecurrenceUntil} ) {

        # Usually hour, minute and second = 0. In this case, take time from StartTime.
        $Param{RecurrenceUntil} = $Self->_TimeCheck(
            OriginalTime => $Param{StartTime},
            Time         => $Param{RecurrenceUntil},
        );

        my $RecurrenceUntilObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{RecurrenceUntil},
            },
        );

        if (
            !$RecurrenceUntilObject
            || $StartTimeObject > $RecurrenceUntilObject
            )
        {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "RecurrenceUntil invalid!",
            );
            return;
        }
    }

    # get previous CalendarID
    my $PreviousCalendarID = $Self->_AppointmentGetCalendarID(
        AppointmentID => $Param{AppointmentID},
    );

    # set recurrence exclude list
    my @RecurrenceExclude = @{ $Param{RecurrenceExclude} // [] };

    # get RecurrenceID
    my $RecurrenceID = $Self->_AppointmentGetRecurrenceID(
        AppointmentID => $Param{AppointmentID},
    );

    # use exclude list to flag the recurring occurrence as updated
    if ($RecurrenceID) {
        @RecurrenceExclude = ($RecurrenceID);
    }

    # reset exclude list if recurrence is turned off
    elsif ( !$Param{Recurring} ) {
        @RecurrenceExclude = ();
    }

    # remove undefined values
    @RecurrenceExclude = grep { defined $_ } @RecurrenceExclude;

    # serialize data
    my $RecurrenceExclude = join( ',', @RecurrenceExclude ) || undef;

    # delete existing recurred appointments
    my $DeleteSuccess = $Self->_AppointmentRecurringDelete(
        ParentID => $Param{AppointmentID},
    );

    if ( !$DeleteSuccess ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "Unable to delete recurring Appointment!",
        );
        return;
    }

    # update appointment
    my $SQL = '
        UPDATE calendar_appointment
        SET
            calendar_id=?, title=?, description=?, location=?, start_time=?, end_time=?, all_day=?,
            team_id=?, resource_id=?, recurring=?, recur_type=?, recur_freq=?, recur_count=?,
            recur_interval=?, recur_until=?, recur_exclude=?, notify_time=?, notify_template=?,
            notify_custom=?, notify_custom_unit_count=?, notify_custom_unit=?,
            notify_custom_unit_point=?, notify_custom_date=?, ticket_appointment_rule_id=?,
            change_time=current_timestamp, change_by=?
        WHERE id=?
    ';

    # update db record
    return if !$Kernel::OM->Get('Kernel::System::DB')->Do(
        SQL  => $SQL,
        Bind => [
            \$Param{CalendarID}, \$Param{Title},   \$Param{Description}, \$Param{Location},
            \$Param{StartTime},  \$Param{EndTime}, \$Param{AllDay},      \$Arrays{TeamID},
            \$Arrays{ResourceID},          \$Param{Recurring},       \$Param{RecurrenceType},
            \$Arrays{RecurrenceFrequency}, \$Param{RecurrenceCount}, \$Param{RecurrenceInterval},
            \$Param{RecurrenceUntil}, \$RecurrenceExclude, \$Param{NotificationDate},
            \$Param{NotificationTemplate},                  \$Param{NotificationCustom},
            \$Param{NotificationCustomRelativeUnitCount},   \$Param{NotificationCustomRelativeUnit},
            \$Param{NotificationCustomRelativePointOfTime}, \$Param{NotificationCustomDateTime},
            \$Param{TicketAppointmentRuleID},               \$Param{UserID}, \$Param{AppointmentID},
        ],
    );

    # add recurred appointments again
    if ( $Param{Recurring} ) {
        return if !$Self->_AppointmentRecurringCreate(
            ParentID    => $Param{AppointmentID},
            Appointment => \%Param,
        );
    }

    # delete cache
    $CacheObject->Delete(
        Type => $Self->{CacheType},
        Key  => $Param{AppointmentID},
    );

    # clean up list methods cache
    my @CalendarIDs = ( $Param{CalendarID} );
    push @CalendarIDs, $PreviousCalendarID if $PreviousCalendarID ne $Param{CalendarID};
    for my $CalendarID (@CalendarIDs) {
        $CacheObject->CleanUp(
            Type => $Self->{CacheType} . 'List' . $CalendarID,
        );
    }
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'Days' . $Param{UserID},
    );

    # fire event
    $Self->EventHandler(
        Event => 'AppointmentUpdate',
        Data  => {
            AppointmentID => $Param{AppointmentID},
            CalendarID    => $Param{CalendarID},
        },
        UserID => $Param{UserID},
    );

    return 1;
}

=head2 AppointmentDelete()

deletes an existing appointment.

    my $Success = $AppointmentObject->AppointmentDelete(
        AppointmentID   => 1,                              # (required)
        UserID          => 1,                              # (required)
    );

returns 1 if successful:
    $Success = 1;

Events:
    AppointmentDelete

=cut

sub AppointmentDelete {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(AppointmentID UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # needed objects
    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');

    # get CalendarID
    my $CalendarID = $Self->_AppointmentGetCalendarID(
        AppointmentID => $Param{AppointmentID},
    );

    # check user's permissions for this calendar
    my $Permission = $Kernel::OM->Get('Kernel::System::Calendar')->CalendarPermissionGet(
        CalendarID => $CalendarID,
        UserID     => $Param{UserID},
    );

    my @RequiredPermissions = ( 'create', 'rw' );

    if ( !grep { $Permission eq $_ } @RequiredPermissions ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => "User($Param{UserID}) has no permission to delete Appointment($Param{AppointmentID})!",
        );
        return;
    }

    my %Appointment = $Self->AppointmentGet(
        AppointmentID => $Param{AppointmentID},
    );

    # save exclusion info to parent appointment
    if ( $Appointment{ParentID} && $Appointment{RecurrenceID} ) {
        $Self->_AppointmentRecurringExclude(
            ParentID     => $Appointment{ParentID},
            RecurrenceID => $Appointment{RecurrenceID},
        );
    }

    # delete recurring appointments
    my $DeleteRecurringSuccess = $Self->_AppointmentRecurringDelete(
        ParentID => $Param{AppointmentID},
    );

    if ( !$DeleteRecurringSuccess ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message  => 'Recurring appointments couldn\'t be deleted!',
        );
        return;
    }

    # delete appointment
    my $SQL = '
        DELETE FROM calendar_appointment
        WHERE id=?
    ';

    # delete db record
    return if !$Kernel::OM->Get('Kernel::System::DB')->Do(
        SQL  => $SQL,
        Bind => [
            \$Param{AppointmentID},
        ],
    );

    # Fire event.
    $Self->EventHandler(
        Event => 'AppointmentDelete',
        Data  => {
            AppointmentID => $Param{AppointmentID},
            CalendarID    => $CalendarID,
        },
        UserID => $Param{UserID},
    );

    # delete cache
    $CacheObject->Delete(
        Type => $Self->{CacheType},
        Key  => $Param{AppointmentID},
    );

    # clean up list methods cache
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'List' . $CalendarID,
    );
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'Days' . $Param{UserID},
    );

    return 1;
}

=head2 AppointmentDeleteOccurrence()

deletes a single recurring appointment occurrence.

    my $Success = $AppointmentObject->AppointmentDeleteOccurrence(
        UniqueID     => '20160101T160000-71E386@localhost',    # (required)
        RecurrenceID => '2016-01-10 00:00:00',                 # (required)
        UserID       => 1,                                     # (required)
    );

returns 1 if successful:
    $Success = 1;

=cut

sub AppointmentDeleteOccurrence {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(UniqueID CalendarID RecurrenceID UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # get db object
    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # db query
    return if !$DBObject->Prepare(
        SQL => '
            SELECT id FROM calendar_appointment
            WHERE unique_id=? AND calendar_id=? AND recur_id=?',
        Bind  => [ \$Param{UniqueID}, \$Param{CalendarID}, \$Param{RecurrenceID} ],
        Limit => 1,
    );

    my %Appointment;

    # get additional info
    while ( my @Row = $DBObject->FetchrowArray() ) {
        $Appointment{AppointmentID} = $Row[0];
    }
    return if !%Appointment;

    # delete db record
    return if !$DBObject->Do(
        SQL   => 'DELETE FROM calendar_appointment WHERE id=?',
        Bind  => [ \$Appointment{AppointmentID} ],
        Limit => 1,
    );

    # get cache object
    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');

    # delete cache
    $CacheObject->Delete(
        Type => $Self->{CacheType},
        Key  => $Appointment{AppointmentID},
    );

    # clean up list methods cache
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'List' . $Param{CalendarID},
    );
    $CacheObject->CleanUp(
        Type => $Self->{CacheType} . 'Days' . $Param{UserID},
    );

    return 1;
}

=head2 GetUniqueID()

Returns UniqueID containing appointment start time, random hash and system C<FQDN>.

    my $UniqueID = $AppointmentObject->GetUniqueID(
        CalendarID => 1,                        # (required)
        StartTime  => '2016-01-01 00:00:00',    # (required)
        UserID     => 1,                        # (required)
    );

    $UniqueID = '20160101T000000-B9909D@localhost';

=cut

sub GetUniqueID {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(CalendarID StartTime UserID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # calculate a hash
    my $RandomString = $Kernel::OM->Get('Kernel::System::Main')->GenerateRandomString( Length => 32 );
    my $String       = "$Param{CalendarID}-$RandomString-$Param{UserID}";
    my $Digest       = unpack( 'N', Digest::MD5->new()->add($String)->digest() );
    my $DigestHex    = sprintf( '%x', $Digest );
    my $Hash         = uc( sprintf( "%.6s", $DigestHex ) );

    # Prepare start timestamp for UniqueID.
    my $StartTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{StartTime},
        },
    );
    return if !$StartTimeObject;
    my $StartTimeStrg = $StartTimeObject->ToString();
    $StartTimeStrg =~ s/[-:]//g;
    $StartTimeStrg =~ s/\s/T/;

    # get system ExternalFQDN
    my $ConfigObject = $Kernel::OM->Get('Kernel::Config');
    my $ExternalFQDN = $ConfigObject->Get('ExternalFQDN') || $ConfigObject->Get('FQDN');

    # return UniqueID
    return "$StartTimeStrg-$Hash\@$ExternalFQDN";
}

=head2 AppointmentUpcomingGet()

Get appointment data for upcoming appointment start or end.

    my @UpcomingAppointments = $AppointmentObject->AppointmentUpcomingGet(
        Timestamp => '2016-08-02 03:59:00', # get appointments for the related notification timestamp
    );

Returns appointment data of AppointmentGet().

=cut

sub AppointmentUpcomingGet {
    my ( $Self, %Param ) = @_;

    # get current timestamp
    my $CurrentTimestamp;

    # create needed sql query based on the current or a given timestamp
    my $SQL = 'SELECT id, parent_id, calendar_id, unique_id FROM calendar_appointment ';

    if ( $Param{Timestamp} ) {
        $CurrentTimestamp = $Param{Timestamp};
        $SQL .= "WHERE notify_time = ? ";
    }
    else {
        $CurrentTimestamp = $Kernel::OM->Create('Kernel::System::DateTime')->ToString();
        $SQL .= "WHERE notify_time >= ? ";
    }

    $SQL .= 'ORDER BY notify_time ASC';

    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # db query
    return if !$DBObject->Prepare(
        SQL  => $SQL,
        Bind => [ \$CurrentTimestamp ],
    );

    my @ResultRaw;

    while ( my @Row = $DBObject->FetchrowArray() ) {

        my %UpcomingAppointment;

        $UpcomingAppointment{AppointmentID} = $Row[0];
        $UpcomingAppointment{ParentID}      = $Row[1];
        $UpcomingAppointment{CalendarID}    = $Row[2];
        $UpcomingAppointment{UniqueID}      = $Row[3];

        push @ResultRaw, \%UpcomingAppointment;
    }

    my @Results;

    APPOINTMENTDATA:
    for my $AppointmentData (@ResultRaw) {

        next APPOINTMENTDATA if !IsHashRefWithData($AppointmentData);
        next APPOINTMENTDATA if !$AppointmentData->{CalendarID};
        next APPOINTMENTDATA if !$AppointmentData->{AppointmentID};

        my %Appointment = $Self->AppointmentGet( %{$AppointmentData} );

        push @Results, \%Appointment;
    }

    return @Results;
}

=head2 AppointmentFutureTasksDelete()

Delete all calendar appointment future tasks.

    my $Success = $AppointmentObject->AppointmentFutureTasksDelete();

returns:

    True if future task deletion was successful, otherwise false.

=cut

sub AppointmentFutureTasksDelete {
    my ( $Self, %Param ) = @_;

    # get a local scheduler db object
    my $SchedulerObject = $Kernel::OM->Get('Kernel::System::Scheduler');

    # get a list of already stored future tasks
    my @FutureTaskList = $SchedulerObject->FutureTaskList(
        Type => 'CalendarAppointment',
    );

    # flush obsolete future tasks
    if ( IsArrayRefWithData( \@FutureTaskList ) ) {

        FUTURETASK:
        for my $FutureTask (@FutureTaskList) {

            next FUTURETASK if !$FutureTask;
            next FUTURETASK if !IsHashRefWithData($FutureTask);

            my $Success = $SchedulerObject->FutureTaskDelete(
                TaskID => $FutureTask->{TaskID},
            );

            if ( !$Success ) {
                $Kernel::OM->Get('Kernel::System::Log')->Log(
                    Priority => 'error',
                    Message  => "Could not delete future task with id $FutureTask->{TaskID}!",
                );
                return;
            }
        }
    }

    return 1;
}

=head2 AppointmentFutureTasksUpdate()

Update OTRS daemon future task list for upcoming appointments.

    my $Success = $AppointmentObject->AppointmentFutureTasksUpdate();

returns:

    True if future task update was successful, otherwise false.

=cut

sub AppointmentFutureTasksUpdate {
    my ( $Self, %Param ) = @_;

    # get appointment data for upcoming appointments
    my @UpcomingAppointments = $Self->AppointmentUpcomingGet();

    # check for no upcoming appointments
    if ( !IsArrayRefWithData( \@UpcomingAppointments ) ) {

        # flush obsolete future tasks
        my $Success = $Self->AppointmentFutureTasksDelete();

        if ( !$Success ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => 'Could not delete appointment future tasks!',
            );
            return;
        }

        return 1;
    }

    # get a local scheduler db object
    my $SchedulerObject = $Kernel::OM->Get('Kernel::System::Scheduler');

    # get a list of already stored future tasks
    my @FutureTaskList = $SchedulerObject->FutureTaskList(
        Type => 'CalendarAppointment',
    );

    # check for invalid task count (just one task max allowed)
    if ( scalar @FutureTaskList > 1 ) {

        # flush obsolete future tasks
        my $Success = $Self->AppointmentFutureTasksDelete();

        if ( !$Success ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => 'Could not delete appointment future tasks!',
            );
            return;
        }
    }

    # check if it is needed to update the future task list
    if ( IsArrayRefWithData( \@FutureTaskList ) ) {
        my $UpdateNeeded = 0;

        FUTURETASK:
        for my $FutureTask (@FutureTaskList) {

            if (
                !IsHashRefWithData($FutureTask)
                || !$FutureTask->{TaskID}
                || !$FutureTask->{ExecutionTime}
                )
            {
                $UpdateNeeded = 1;
                last FUTURETASK;
            }

            # get the stored future task
            my %FutureTaskData = $Kernel::OM->Get('Kernel::System::Daemon::SchedulerDB')->FutureTaskGet(
                TaskID => $FutureTask->{TaskID},
            );

            if ( !IsHashRefWithData( \%FutureTaskData ) ) {
                $UpdateNeeded = 1;
                last FUTURETASK;
            }

            # Get date time objects of stored and upcoming times to compare.
            my $FutureTaskTimeObject = $Kernel::OM->Create(
                'Kernel::System::DateTime',
                ObjectParams => {
                    String => $FutureTaskData{Data}->{NotifyTime},
                },
            );
            my $UpcomingAppointmentTimeObject = $Kernel::OM->Create(
                'Kernel::System::DateTime',
                ObjectParams => {
                    String => $UpcomingAppointments[0]->{NotificationDate},
                },
            );

            # Do nothing if the upcoming notification time equals the stored value.
            if ( $UpcomingAppointmentTimeObject != $FutureTaskTimeObject ) {
                $UpdateNeeded = 1;
                last FUTURETASK;
            }
        }

        if ($UpdateNeeded) {

            # flush obsolete future tasks
            my $Success = $Self->AppointmentFutureTasksDelete();

            if ( !$Success ) {
                $Kernel::OM->Get('Kernel::System::Log')->Log(
                    Priority => 'error',
                    Message  => 'Could not delete appointment future tasks!',
                );
                return;
            }
        }
        else {
            return 1;
        }
    }

    # schedule new future tasks for notification actions
    my $TaskID = $SchedulerObject->TaskAdd(
        ExecutionTime => $UpcomingAppointments[0]->{NotificationDate},
        Name          => 'AppointmentNotification',
        Type          => 'CalendarAppointment',
        Data          => {
            NotifyTime => $UpcomingAppointments[0]->{NotificationDate},
        },
    );

    if ( !$TaskID ) {
        $Kernel::OM->Get('Kernel::System::Log')->Log(
            Priority => 'error',
            Message =>
                "Could not schedule future task for AppointmentID $UpcomingAppointments[0]->{AppointmentID}!",
        );
        return;
    }

    return 1;
}

=head2 _AppointmentNotificationPrepare()

Prepare appointment notification data.

    my $Success = $AppointmentObject->_AppointmentNotificationPrepare();

returns:

    True if preparation was successful, otherwise false.

=cut

sub _AppointmentNotificationPrepare {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(Data)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # reset notification data if needed
    if ( !$Param{Data}->{NotificationTemplate} ) {

        for my $PossibleParam (
            qw(
            NotificationDate NotificationTemplate NotificationCustom NotificationCustomRelativeUnitCount
            NotificationCustomRelativeUnit NotificationCustomRelativePointOfTime NotificationCustomDateTime
            )
            )
        {
            $Param{Data}->{$PossibleParam} = undef;
        }
    }

    # prepare possible notification params
    for my $PossibleParam (
        qw(
        NotificationTemplate NotificationCustom NotificationCustomRelativeUnit
        NotificationCustomRelativePointOfTime
        )
        )
    {
        $Param{Data}->{$PossibleParam} ||= '';
    }

    # special check for relative unit count as it can be zero
    # (empty and negative values will be treated as zero to avoid errors)
    if (
        !IsNumber( $Param{Data}->{NotificationCustomRelativeUnitCount} )
        || $Param{Data}->{NotificationCustomRelativeUnitCount} <= 0
        )
    {
        $Param{Data}->{NotificationCustomRelativeUnitCount} = 0;
    }

    # set empty datetime strings to undef
    for my $PossibleParam (qw(NotificationDate NotificationCustomDateTime)) {
        $Param{Data}->{$PossibleParam} ||= undef;
    }

    return if !$Param{Data}->{NotificationTemplate};

    #
    # template Start
    #
    if ( $Param{Data}->{NotificationTemplate} eq 'Start' ) {

        # setup the appointment start date as notification date
        $Param{Data}->{NotificationDate} = $Param{Data}->{StartTime};
    }

    #
    # template time before start
    #
    elsif (
        $Param{Data}->{NotificationTemplate} ne 'Custom'
        && IsNumber( $Param{Data}->{NotificationTemplate} )
        && $Param{Data}->{NotificationTemplate} > 0
        )
    {

        return if !IsNumber( $Param{Data}->{NotificationTemplate} );

        # offset template (before start datetime) used
        my $Offset = $Param{Data}->{NotificationTemplate};

        # Get date time object of appointment start time.
        my $StartTimeObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{Data}->{StartTime},
            },
        );

        # Subtract offset in seconds for new notification date time.
        $StartTimeObject->Subtract(
            Seconds => $Offset,
        );

        $Param{Data}->{NotificationDate} = $StartTimeObject->ToString();
    }

    #
    # template Custom
    #
    else {

        # Compute date of custom relative input.
        if ( $Param{Data}->{NotificationCustom} eq 'relative' ) {

            my $CustomUnitCount = $Param{Data}->{NotificationCustomRelativeUnitCount};
            my $CustomUnit      = $Param{Data}->{NotificationCustomRelativeUnit};
            my $CustomUnitPoint = $Param{Data}->{NotificationCustomRelativePointOfTime};

            # setup the count to compute for the offset
            my %UnitOffsetCompute = (
                minutes => 60,
                hours   => 3600,
                days    => 86400,
            );

            my $NotificationLocalTimeObject;

            # Compute from start time.
            if ( $CustomUnitPoint eq 'beforestart' || $CustomUnitPoint eq 'afterstart' ) {
                $NotificationLocalTimeObject = $Kernel::OM->Create(
                    'Kernel::System::DateTime',
                    ObjectParams => {
                        String => $Param{Data}->{StartTime},
                    },
                );
            }

            # Compute from end time.
            elsif ( $CustomUnitPoint eq 'beforeend' || $CustomUnitPoint eq 'afterend' ) {
                $NotificationLocalTimeObject = $Kernel::OM->Create(
                    'Kernel::System::DateTime',
                    ObjectParams => {
                        String => $Param{Data}->{EndTime},
                    },
                );
            }

            # Not supported point of time.
            else {
                return;
            }

            # compute the offset to be used
            my $Offset = ( $CustomUnitCount * $UnitOffsetCompute{$CustomUnit} );

            # save the newly computed notification datetime string
            if ( $CustomUnitPoint eq 'beforestart' || $CustomUnitPoint eq 'beforeend' ) {
                $NotificationLocalTimeObject->Subtract(
                    Seconds => $Offset,
                );
                $Param{Data}->{NotificationDate} = $NotificationLocalTimeObject->ToString();
            }
            else {
                $NotificationLocalTimeObject->Add(
                    Seconds => $Offset,
                );
                $Param{Data}->{NotificationDate} = $NotificationLocalTimeObject->ToString();
            }
        }

        # Compute date of custom date/time input.
        elsif ( $Param{Data}->{NotificationCustom} eq 'datetime' ) {

            $Param{Data}->{NotificationCustom} = 'datetime';

            # validation
            if ( !IsStringWithData( $Param{Data}->{NotificationCustomDateTime} ) ) {
                return;
            }

            # save the given date time values as notification datetime string (i.e. 2016-06-28 02:00:00)
            $Param{Data}->{NotificationDate} = $Param{Data}->{NotificationCustomDateTime};
        }
    }

    if ( !IsStringWithData( $Param{Data}->{NotificationDate} ) ) {
        $Param{Data}->{NotificationDate} = undef;
    }

    if ( !IsStringWithData( $Param{Data}->{NotificationCustomDateTime} ) ) {
        $Param{Data}->{NotificationCustomDateTime} = undef;
    }

    return 1;
}

=head2 AppointmentNotification()

Will be triggered by the OTRS daemon to fire events for appointments,
that reaches it's reminder (notification) time.

    my $Success = $AppointmentObject->AppointmentNotification();

returns:

    True if notify action was successful, otherwise false.

=cut

sub AppointmentNotification {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(NotifyTime)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # get appointments for the related notification timestamp
    my @UpcomingAppointments = $Self->AppointmentUpcomingGet(
        Timestamp => $Param{NotifyTime},
    );

    return if !IsArrayRefWithData( \@UpcomingAppointments );

    # sleep at least 1 second to make sure the timestamp doesn't
    # equals the last one for update upcoming future tasks
    sleep 1;

    UPCOMINGAPPOINTMENT:
    for my $UpcomingAppointment (@UpcomingAppointments) {

        next UPCOMINGAPPOINTMENT if !$UpcomingAppointment;
        next UPCOMINGAPPOINTMENT if !IsHashRefWithData($UpcomingAppointment);
        next UPCOMINGAPPOINTMENT if !$UpcomingAppointment->{AppointmentID};

        # fire event
        $Self->EventHandler(
            Event => 'AppointmentNotification',
            Data  => {
                AppointmentID => $UpcomingAppointment->{AppointmentID},
                CalendarID    => $UpcomingAppointment->{CaledarID},
            },
            UserID => 1,
        );
    }

    return 1;
}

=begin Internal:

=cut

sub _AppointmentRecurringCreate {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(ParentID Appointment)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    my $StartTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{Appointment}->{StartTime},
        },
    );
    my $EndTimeObject = $Kernel::OM->Create(
        'Kernel::System::DateTime',
        ObjectParams => {
            String => $Param{Appointment}->{EndTime},
        },
    );

    my @RecurrenceExclude = @{ $Param{Appointment}->{RecurrenceExclude} // [] };

    # remove undefined values
    @RecurrenceExclude = grep { defined $_ } @RecurrenceExclude;

    # reset the parameter for occurrences
    $Param{Appointment}->{RecurrenceExclude} = undef;

    my $OriginalStartTimeObject = $StartTimeObject->Clone();
    my $OriginalEndTimeObject   = $EndTimeObject->Clone();
    my $Step                    = 0;

    # until ...
    if ( $Param{Appointment}->{RecurrenceUntil} ) {
        my $RecurrenceUntilObject = $Kernel::OM->Create(
            'Kernel::System::DateTime',
            ObjectParams => {
                String => $Param{Appointment}->{RecurrenceUntil},
            },
        );

        UNTIL_TIME:
        while ( $StartTimeObject <= $RecurrenceUntilObject ) {
            $Step += $Param{Appointment}->{RecurrenceInterval};

            # calculate recurring times
            $StartTimeObject = $Self->_CalculateRecurrenceTime(
                Appointment  => $Param{Appointment},
                Step         => $Step,
                OriginalTime => $OriginalStartTimeObject,
                CurrentTime  => $StartTimeObject,
            );

            last UNTIL_TIME if !$StartTimeObject;
            last UNTIL_TIME if $StartTimeObject > $RecurrenceUntilObject;

            $EndTimeObject = $StartTimeObject->Clone();
            $EndTimeObject->Add(
                Seconds =>
                    $OriginalEndTimeObject->Delta( DateTimeObject => $OriginalStartTimeObject )->{AbsoluteSeconds},
            );

            my $StartTime = $StartTimeObject->ToString();
            my $EndTime   = $EndTimeObject->ToString();

            # Bugfix: on some systems with older perl version system might calculate timezone difference.
            $StartTime = $Self->_TimeCheck(
                OriginalTime => $Param{Appointment}->{StartTime},
                Time         => $StartTime,
            );
            $EndTime = $Self->_TimeCheck(
                OriginalTime => $Param{Appointment}->{EndTime},
                Time         => $EndTime,
            );

            # skip excluded appointments
            next UNTIL_TIME if grep { $StartTime eq $_ } @RecurrenceExclude;

            $Self->AppointmentCreate(
                %{ $Param{Appointment} },
                ParentID     => $Param{ParentID},
                StartTime    => $StartTime,
                EndTime      => $EndTime,
                RecurrenceID => $StartTime,
            );
        }
    }

    # for ... time(s)
    elsif ( $Param{Appointment}->{RecurrenceCount} ) {

        COUNT:
        for my $Count ( 1 .. $Param{Appointment}->{RecurrenceCount} - 1 ) {
            $Step += $Param{Appointment}->{RecurrenceInterval};

            # calculate recurring times
            $StartTimeObject = $Self->_CalculateRecurrenceTime(
                Appointment  => $Param{Appointment},
                Step         => $Step,
                OriginalTime => $OriginalStartTimeObject,
                CurrentTime  => $StartTimeObject,
            );

            last COUNT if !$StartTimeObject;

            $EndTimeObject = $StartTimeObject->Clone();
            $EndTimeObject->Add(
                Seconds =>
                    $OriginalEndTimeObject->Delta( DateTimeObject => $OriginalStartTimeObject )->{AbsoluteSeconds},
            );

            my $StartTime = $StartTimeObject->ToString();
            my $EndTime   = $EndTimeObject->ToString();

            # Bugfix: on some systems with older perl version system might calculate timezone difference.
            $StartTime = $Self->_TimeCheck(
                OriginalTime => $Param{Appointment}->{StartTime},
                Time         => $StartTime,
            );
            $EndTime = $Self->_TimeCheck(
                OriginalTime => $Param{Appointment}->{EndTime},
                Time         => $EndTime,
            );

            # skip excluded appointments
            next COUNT if grep { $StartTime eq $_ } @RecurrenceExclude;

            $Self->AppointmentCreate(
                %{ $Param{Appointment} },
                ParentID     => $Param{ParentID},
                StartTime    => $StartTime,
                EndTime      => $EndTime,
                RecurrenceID => $StartTime,
            );
        }
    }

    return 1;
}

sub _AppointmentRecurringDelete {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(ParentID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # delete recurring appointments
    my $SQL = '
        DELETE FROM calendar_appointment
        WHERE parent_id=?
    ';

    # delete db record
    return if !$Kernel::OM->Get('Kernel::System::DB')->Do(
        SQL  => $SQL,
        Bind => [
            \$Param{ParentID},
        ],
    );

    return 1;
}

sub _AppointmentRecurringExclude {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(ParentID RecurrenceID)) {
        if ( !$Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    my $CacheObject = $Kernel::OM->Get('Kernel::System::Cache');
    my $DBObject    = $Kernel::OM->Get('Kernel::System::DB');

    # db query
    return if !$DBObject->Prepare(
        SQL  => 'SELECT recur_exclude FROM calendar_appointment WHERE id=?',
        Bind => [ \$Param{ParentID} ],
    );

    # get existing exclusions
    my @RecurrenceExclude;
    while ( my @Row = $DBObject->FetchrowArray() ) {
        @RecurrenceExclude = split( ',', $Row[0] ) if $Row[0];
    }
    push @RecurrenceExclude, $Param{RecurrenceID};
    @RecurrenceExclude = sort @RecurrenceExclude;

    # join into string
    my $RecurrenceExclude;
    if (@RecurrenceExclude) {
        $RecurrenceExclude = join( ',', @RecurrenceExclude );
    }

    # update db record
    return if !$DBObject->Do(
        SQL  => 'UPDATE calendar_appointment SET recur_exclude=? WHERE id=?',
        Bind => [ \$RecurrenceExclude, \$Param{ParentID} ],
    );

    # delete cache
    $CacheObject->Delete(
        Type => $Self->{CacheType},
        Key  => $Param{ParentID},
    );

    return 1;
}

sub _AppointmentGetCalendarID {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(AppointmentID)) {
        if ( !defined $Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # sql query
    my $SQL  = 'SELECT calendar_id FROM calendar_appointment WHERE id=?';
    my @Bind = ( \$Param{AppointmentID} );

    # get database object
    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # start query
    return if !$DBObject->Prepare(
        SQL   => $SQL,
        Bind  => \@Bind,
        Limit => 1,
    );

    my $CalendarID;
    while ( my @Row = $DBObject->FetchrowArray() ) {
        $CalendarID = $Row[0];
    }

    return $CalendarID;
}

sub _AppointmentGetRecurrenceID {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(AppointmentID)) {
        if ( !defined $Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    # sql query
    my $SQL  = 'SELECT recur_id FROM calendar_appointment WHERE id=?';
    my @Bind = ( \$Param{AppointmentID} );

    # get database object
    my $DBObject = $Kernel::OM->Get('Kernel::System::DB');

    # start query
    return if !$DBObject->Prepare(
        SQL   => $SQL,
        Bind  => \@Bind,
        Limit => 1,
    );

    my $RecurrenceID;
    while ( my @Row = $DBObject->FetchrowArray() ) {
        $RecurrenceID = $Row[0];
    }

    return $RecurrenceID;
}

sub _CalculateRecurrenceTime {
    my ( $Self, %Param ) = @_;

    # check needed stuff
    for my $Needed (qw(Appointment Step OriginalTime CurrentTime)) {
        if ( !defined $Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    my $OriginalTimeObject = $Param{OriginalTime};

    # We will modify this object throughout the function.
    my $CurrentTimeObject = $Param{CurrentTime};

    # Use floating time zone to be able to add and subtract whole days, etc.
    # regardless of daylight saving time.
    #
    # This might lead to other problems like calculating a time that does not exist,
    # e.g. 2:00 AM on the day of the DST switch from winter to summer time in time zone
    # Europe/Berlin (1:59 AM switches to 3:00 AM).
    #
    # Kernel::System::DateTime takes care of this by adjusting the date.
    my $CurrentTimeObjectValues = $CurrentTimeObject->Get();
    my $TimeZone                = $CurrentTimeObjectValues->{TimeZone};

    $CurrentTimeObject->ToTimeZone( TimeZone => 'floating' );

    if ( $Param{Appointment}->{RecurrenceType} eq 'Daily' ) {

        # Add one day.
        $CurrentTimeObject->Add(
            Days => 1,
        );
    }
    elsif ( $Param{Appointment}->{RecurrenceType} eq 'Weekly' ) {

        # Add 7 days.
        $CurrentTimeObject->Add(
            Days => 7,
        );
    }
    elsif ( $Param{Appointment}->{RecurrenceType} eq 'Monthly' ) {
        my $TempTimeObject = $OriginalTimeObject->Clone();

        # Remember start day.
        my $StartDay = $TempTimeObject->Get()->{Day};

        # Add months based on current step.
        $TempTimeObject->Add(
            Months => $Param{Step},
        );

        # Get end day.
        my $EndDay = $TempTimeObject->Get()->{Day};

        # Check if month doesn't have enough days (for example: January 31 + 1 month = March 1).
        if ( $StartDay != $EndDay ) {
            $TempTimeObject->Subtract(
                Days => $EndDay,
            );
        }

        $CurrentTimeObject = $TempTimeObject->Clone();
    }
    elsif ( $Param{Appointment}->{RecurrenceType} eq 'Yearly' ) {
        my $TempTimeObject = $OriginalTimeObject->Clone();

        # Remember start day.
        my $StartDay = $TempTimeObject->Get()->{Day};

        # Add years based on current step.
        $TempTimeObject->Add(
            Years => $Param{Step},
        );

        # Get end day.
        my $EndDay = $TempTimeObject->Get()->{Day};

        # Check if month doesn't have enough days (for example: January 31 + 1 month = March 1).
        if ( $StartDay != $EndDay ) {
            $TempTimeObject->Subtract(
                Days => $EndDay,
            );
        }

        $CurrentTimeObject = $TempTimeObject->Clone();
    }
    elsif ( $Param{Appointment}->{RecurrenceType} eq 'CustomDaily' ) {

        # Add number of days.
        $CurrentTimeObject->Add(
            Days => $Param{Appointment}->{RecurrenceInterval},
        );
    }
    elsif ( $Param{Appointment}->{RecurrenceType} eq 'CustomWeekly' ) {

        # this block covers following use case:
        # each n-th Monday and Friday

        my $Found;

        # loop up to 7*n times (7 days in week * frequency)
        DAY:
        for ( my $Counter = 0; $Counter < 7 * $Param{Appointment}->{RecurrenceInterval}; $Counter++ ) {

            # Add one day.
            $CurrentTimeObject->Add(
                Days => 1,
            );

            my $CWDiff = $Self->_CWDiff(
                CurrentTime  => $CurrentTimeObject,
                OriginalTime => $OriginalTimeObject,
            );

            next DAY if $CWDiff % $Param{Appointment}->{RecurrenceInterval};

            my $WeekDay = $CurrentTimeObject->Get()->{DayOfWeek};

            # check if SystemTime match requirements
            if ( grep { $WeekDay == $_ } @{ $Param{Appointment}->{RecurrenceFrequency} } ) {
                $Found = 1;
                last DAY;
            }
        }

        return if !$Found;
    }

    elsif ( $Param{Appointment}->{RecurrenceType} eq 'CustomMonthly' ) {

        # Occurs every 2nd month on 5th, 10th and 15th day
        my $Found;

        # loop through each day (max one year), and check if day matches.
        DAY:
        for ( my $Counter = 0; $Counter < 31 * 366; $Counter++ ) {

            # Add one day.
            $CurrentTimeObject->Add(
                Days => 1,
            );

            # Skip month if needed
            next DAY
                if ( $CurrentTimeObject->Get()->{Month} - $OriginalTimeObject->Get()->{Month} )
                % $Param{Appointment}->{RecurrenceInterval};

            # next day if this day should be skipped
            next DAY
                if !grep { $CurrentTimeObject->Get()->{Day} == $_ } @{ $Param{Appointment}->{RecurrenceFrequency} };

            $Found = 1;
            last DAY;
        }
        return if !$Found;
    }
    elsif ( $Param{Appointment}->{RecurrenceType} eq 'CustomYearly' ) {

        # this block covers following use case:
        # Occurs each 3th year, January 18th and March 18th
        my $Found;

        my $RecurrenceUntilObject;
        if ( $Param{Appointment}->{RecurrenceUntil} ) {
            $RecurrenceUntilObject = $Kernel::OM->Create(
                'Kernel::System::DateTime',
                ObjectParams => {
                    String => $Param{Appointment}->{RecurrenceUntil},
                },
            );
        }

        my $NextDayObject = $CurrentTimeObject->Clone();
        $NextDayObject->Add(
            Days => 1,
        );

        MONTH:
        for ( my $Counter = 1;; $Counter++ ) {
            my $TempTimeObject = $OriginalTimeObject->Clone();

            # remember start day
            my $StartDay = $TempTimeObject->Get()->{Day};

            $TempTimeObject->Add(
                Months => $Counter,
            );

            # get end day
            my $EndDay = $TempTimeObject->Get()->{Day};

            # check if month doesn't have enough days (for example: january 31 + 1 month = march 01)
            if ( $StartDay != $EndDay ) {
                $TempTimeObject->Subtract(
                    Days => $EndDay,
                );
            }

            $CurrentTimeObject = $TempTimeObject->Clone();

            # skip this time, since it was already checked
            next MONTH if $CurrentTimeObject < $NextDayObject;

            # check loop conditions (according to Until / )
            if ($RecurrenceUntilObject) {
                last MONTH if $CurrentTimeObject > $RecurrenceUntilObject;
            }
            else {
                last MONTH
                    if $Counter
                    > 12 * $Param{Appointment}->{RecurrenceInterval} * $Param{Appointment}->{RecurrenceCount};
            }

            # check if year is OK
            next MONTH
                if ( $CurrentTimeObject->Get()->{Year} - $OriginalTimeObject->Get()->{Year} )
                % $Param{Appointment}->{RecurrenceInterval};

            # next month if this month should be skipped
            next MONTH
                if !grep { $CurrentTimeObject->Get()->{Month} == $_ } @{ $Param{Appointment}->{RecurrenceFrequency} };

            $Found = 1;
            last MONTH;
        }
        return if !$Found;
    }
    else {
        return;
    }

    $CurrentTimeObject->ToTimeZone( TimeZone => $TimeZone );

    return $CurrentTimeObject;
}

=head2 _TimeCheck()

Check if Time and OriginalTime have same hour, minute and second value, and return timestamp with
values (hour, minute and second) as in Time.

    my $Result = $Self->_TimeCheck(
        OriginalTime => '2016-01-01 00:01:00',     # (required)
        Time         => '2016-02-01 00:02:00',     # (required)
    );

Returns:
    $Result = '2016-02-01 00:01:00';

=cut

sub _TimeCheck {
    my ( $Self, %Param ) = @_;

    for my $Needed (qw(OriginalTime Time)) {
        if ( !defined $Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    my $Result = '';

    $Param{OriginalTime} =~ /(.*?)\s(.*?)$/;
    my $OriginalDate = $1;
    my $OriginalTime = $2;

    $Param{Time} =~ /(.*?)\s(.*?)$/;
    my $Date = $1;

    $Result = "$Date $OriginalTime";
    return $Result;
}

=head2 _CWDiff()

Returns how many calendar weeks has passed between two unix times.

    my $CWDiff = $Self->_CWDiff(
        CurrentTime  => $CurrentTimeObject,     (required) Date time object with current time
        OriginalTime => $OriginalTimeObject,    (required) Date time object with original time
    );

returns:
    $CWDiff = 5;

=cut

sub _CWDiff {
    my ( $Self, %Param ) = @_;

    for my $Needed (qw(CurrentTime OriginalTime)) {
        if ( !defined $Param{$Needed} ) {
            $Kernel::OM->Get('Kernel::System::Log')->Log(
                Priority => 'error',
                Message  => "Need $Needed!",
            );
            return;
        }
    }

    my $OriginalTimeObject = $Param{OriginalTime};
    my $CurrentTimeObject  = $Param{CurrentTime};

    my $StartYear = $OriginalTimeObject->Get()->{Year};
    my $EndYear   = $CurrentTimeObject->Get()->{Year};

    my $Result = $CurrentTimeObject->{CPANDateTimeObject}->week_number()
        - $OriginalTimeObject->{CPANDateTimeObject}->week_number();

    # If date is end of the year and date CW starts with 1, we need to include additional year.
    if ( $Result < 0 && $CurrentTimeObject->Get()->{Day} == 31 && $CurrentTimeObject->Get()->{Month} == 12 ) {
        $EndYear++;
    }

    for my $Year ( $StartYear .. $EndYear - 1 ) {
        my $CW  = 0;
        my $Day = 31;

        while ( $CW < 50 ) {

            # To get how many CW's are in this year, we set temporary date to 31-dec.
            my $DateTimeObject = $Kernel::OM->Create(
                'Kernel::System::DateTime',
                ObjectParams => {
                    String => "$Year-12-$Day 23:59:00",
                },
            );

            $CW = $DateTimeObject->{CPANDateTimeObject}->week_number();
            $Day--;
        }

        $Result += $CW;
    }

    return $Result;
}

1;

=end Internal:

=head1 TERMS AND CONDITIONS

This software is part of the OTRS project (L<https://otrs.org/>).

This software comes with ABSOLUTELY NO WARRANTY. For details, see
the enclosed file COPYING for license information (GPL). If you
did not receive this file, see L<https://www.gnu.org/licenses/gpl-3.0.txt>.

=cut
