# Qisutu - Open Source Ticket System
# Copyright (C) 2026 Franziska Steps
# Qisutu - Kim-KI, https://qisutu.de
#
# This file is part of Qisutu.
#
# Qisutu is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Qisutu is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Qisutu. If not, see <https://www.gnu.org/licenses/>.
#
# SPDX-FileCopyrightText: 2026 Franziska Steps
# SPDX-License-Identifier: AGPL-3.0-or-later

package AgentTicketZoom;

use strict;
use warnings;
use utf8;
use JSON::PP qw(encode_json);
use POSIX qw(strftime);
use Time::Local qw(timelocal);
use QisutuService;
use QisutuChecklist;
use QisutuResponseTemplate;
use QisutuTimeAccounting;
use QisutuTicketLink;
use QisutuTicketHistory;
use QisutuCMDB;
use QisutuKnowledgeBase;
use QisutuLocalizedContent;
use QisutuNotification;
use QisutuTicketPDF;

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

    my $Self = {
        Config  => $Param{Config},
        DB      => $Param{DB},
        Output  => $Param{Output},
        Program => $Param{Program},
    };

    bless $Self, $Class;

    return $Self;
}

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

    my $Request  = $Param{Request} || {};
    my $Language = $Request->{Language} || $Self->{Config}->{Language}->{Default} || 'en';
    my $TicketID = $Request->{TicketID} || 0;
    my $Ticket   = undef;
    my $Articles = [];
    my $ArticleCreateError = '';
    my $CMDBActionError = '';

    my $TicketObject       = $Self->_TicketObject();
    my $DynamicFieldObject = $Self->_DynamicFieldObject();
    my $TicketFormObject   = $Self->_TicketFormObject();
    my $ChecklistObject    = QisutuChecklist->new( Config => $Self->{Config}, DB => $Self->{DB} );
    my $TimeAccountingObject = QisutuTimeAccounting->new( Config => $Self->{Config}, DB => $Self->{DB}, Output => $Self->{Output} );
    my $TicketLinkObject = QisutuTicketLink->new( Config => $Self->{Config}, DB => $Self->{DB} );
    my $TicketHistoryObject = QisutuTicketHistory->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
        Output => $Self->{Output},
    );
    my $CMDBObject = QisutuCMDB->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
        Output => $Self->{Output},
    );
    my $KnowledgeObject = QisutuKnowledgeBase->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
        Output => $Self->{Output},
    );
    my $KnowledgePermission = $KnowledgeObject->PermissionLevel( User => $Param{User} || {} );
    my $AttachmentMaxSizeMB    = $Self->_AttachmentMaxSizeMB();
    my $AttachmentMaxSizeBytes = $AttachmentMaxSizeMB * 1024 * 1024;

    if ( ( $Request->{Step} || '' ) eq 'AgentLookup' ) {
        return $Self->_JSONResponse(
            Data => $Self->_AgentLookup(
                Query => $Request->{Term} || $Request->{term} || $Request->{Query} || $Request->{query} || $Request->{q} || '',
            ),
        );
    }

    if ( ( $Request->{Step} || '' ) eq 'CustomerUserLookup' ) {
        return $Self->_JSONResponse(
            Data => $Self->_CustomerUserLookup(
                Query => $Request->{Term} || $Request->{term} || $Request->{Query} || $Request->{query} || $Request->{q} || '',
            ),
        );
    }

    if ( ( $Request->{Step} || '' ) eq 'TicketPrint' ) {
        return $Self->_TicketPrintResponse(
            TicketObject => $TicketObject,
            TicketID     => $TicketID,
            ArticleID    => $Request->{ArticleID} || 0,
            User         => $Param{User} || {},
            Language     => $Language,
        );
    }

    if ( ( $Request->{Step} || '' ) eq 'TicketLookup' ) {
        return $Self->_JSONResponse(
            Data => $TicketLinkObject->TicketLookup(
                Query           => $Request->{Term} || $Request->{term} || $Request->{Query} || $Request->{query} || $Request->{q} || '',
                CurrentTicketID => $TicketID,
                User            => $Param{User} || {},
                RequireEdit     => 1,
                ExcludeMerged   => $Request->{ExcludeMerged} ? 1 : 0,
            ),
        );
    }

    if ( ( $Request->{Step} || '' ) eq 'TicketHistory' ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject ? $TicketObject->TicketGet(
            TicketID => $TicketID,
            User     => $User,
            Language => $Language,
        ) : undef;
        my $Allowed = ( $User->{account_type} || '' ) eq 'agent' && $AccessibleTicket && $Self->_QueueAccessCheck(
            User       => $User,
            QueueID    => $AccessibleTicket->{queue_id} || 0,
            Permission => 'ticket.view',
        );

        if ( !$Allowed ) {
            return $Self->_JSONResponse(
                Data => {
                    success => 0,
                    error   => 'TicketHistoryAccessDenied',
                },
            );
        }

        my $HistoryPage = $TicketHistoryObject->List(
            TicketID => $TicketID,
            Category => $Request->{Category} || 'all',
            BeforeID => $Request->{BeforeID} || 0,
            Limit    => 50,
        );
        return $Self->_JSONResponse(
            Data => {
                success        => 1,
                html           => $TicketHistoryObject->TimelineHTML(
                    Items    => $HistoryPage->{Items},
                    Language => $Language,
                ),
                count          => scalar @{ $HistoryPage->{Items} || [] },
                has_more       => $HistoryPage->{HasMore} ? 1 : 0,
                next_before_id => 0 + ( $HistoryPage->{NextBeforeID} || 0 ),
            },
        );
    }

    if ( ( $Request->{Step} || '' ) eq 'CMDBSearch' ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject ? $TicketObject->TicketGet(
            TicketID => $TicketID,
            User     => $User,
            Language => $Language,
        ) : undef;
        my $CMDBPermission = $CMDBObject->PermissionLevel( User => $User );
        my $Allowed = $AccessibleTicket && $CMDBPermission->{View} && $Self->_QueueAccessCheck(
            User       => $User,
            QueueID    => $AccessibleTicket->{queue_id} || 0,
            Permission => 'ticket.view',
        );
        return $Self->_JSONResponse(
            Data => {
                success => $Allowed ? 1 : 0,
                items   => $Allowed ? $CMDBObject->SearchItems(
                    Query => $Request->{Term} || $Request->{term} || $Request->{Query} || $Request->{query} || $Request->{q} || '',
                    Limit => 30,
                ) : [],
            },
        );
    }

    if ( $TicketObject && ( $Request->{Step} || '' ) =~ m{\ACMDB(?:Link|Unlink)\z} ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject->TicketGet(
            TicketID => $TicketID,
            User     => $User,
            Language => $Language,
        );
        my $CMDBPermission = $CMDBObject->PermissionLevel( User => $User );
        my $Allowed = $AccessibleTicket && ( $AccessibleTicket->{state_name} || '' ) ne 'merged'
            && $CMDBPermission->{Change} && $Self->_QueueAccessCheck(
                User       => $User,
                QueueID    => $AccessibleTicket->{queue_id} || 0,
                Permission => 'ticket.edit',
            );
        if ($Allowed) {
            my $Success = ( $Request->{Step} || '' ) eq 'CMDBLink'
                ? $CMDBObject->TicketLinkAdd( TicketID => $TicketID, CIID => $Request->{CIID}, User => $User )
                : $CMDBObject->TicketLinkRemove( TicketID => $TicketID, CIID => $Request->{CIID}, User => $User );
            if ($Success) {
                return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-cmdb' };
            }
            $CMDBActionError = $CMDBObject->Error() || 'Translate:CMDBLinkFailed';
        }
        else {
            $CMDBActionError = 'Translate:CMDBAccessDenied';
        }
    }

    if ( ( $Request->{Step} || '' ) eq 'DynamicFields' ) {
        my $User = $Param{User} || {};
        my $QueueID = $Request->{QueueID} || 0;
        my $AccessibleTicket = $TicketObject ? $TicketObject->TicketGet(
            TicketID => $TicketID,
            User     => $User,
            Language => $Language,
        ) : undef;
        my $Queue = $Self->_QueueGet( QueueID => $QueueID );
        my $QueueAccess = $Queue ? $Self->_QueueAccessCheck(
            User       => $User,
            QueueID    => $QueueID,
            Permission => 'ticket.edit',
        ) : 0;

        my $HTML = '';
        my $Success = $AccessibleTicket && $Queue && $QueueAccess && $DynamicFieldObject ? 1 : 0;

        if ($Success) {
            $HTML = $DynamicFieldObject->FormHTML(
                QueueID  => $QueueID,
                TicketID => $TicketID,
                Language => $Language,
                IDPrefix => 'qisutu-ticket-tool-queue-dynamic-field',
            );
            $Success = 0 if $DynamicFieldObject->Error();
        }

        return $Self->_JSONResponse(
            Data => {
                success => $Success,
                html    => $Success ? $HTML : '',
            },
        );
    }

    if ( ( $Request->{Step} || '' ) eq 'ResponseTemplateGet' ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject ? $TicketObject->TicketGet(
            TicketID => $TicketID,
            User     => $User,
            Language => $Language,
        ) : undef;
        my $TemplateID = $Request->{ResponseTemplateID} || 0;
        my $TemplateObject = $Self->_ResponseTemplateObject();
        my $TemplateLanguage = $TemplateObject ? $TemplateObject->LanguageClean(
            $Request->{ResponseTemplateLanguage} || $Language,
        ) : $Language;
        my $Template;

        if (
            $AccessibleTicket
            && $TemplateObject
            && $Self->_QueueAccessCheck(
                User       => $User,
                QueueID    => $AccessibleTicket->{queue_id},
                Permission => 'ticket.edit',
            )
        ) {
            $Template = $TemplateObject->TemplateForQueueGet(
                TemplateID => $TemplateID,
                QueueID    => $AccessibleTicket->{queue_id},
                Language   => $TemplateLanguage,
            );
        }

        my @Attachments;
        if ($Template) {
            @Attachments = map {
                {
                    id           => 0 + ( $_->{id} || 0 ),
                    filename     => $_->{filename} || 'attachment.bin',
                    content_type => $_->{content_type} || 'application/octet-stream',
                    content_size => 0 + ( $_->{content_size} || 0 ),
                    size_display => $_->{size_display} || '',
                }
            } @{ $Template->{attachments} || [] };
        }

        my $BodyTemplate = '';
        if ($Template) {
            my $QueueReplyTemplate = $Self->_QueueReplyTemplate(
                TicketID => $TicketID,
                User     => $User,
                Language => $TemplateLanguage,
            );
            my $ReplyArticleID = $Request->{ReplyArticleID} || 0;
            my $ReplyArticle;

            if ( $ReplyArticleID =~ m{\A\d+\z} && $ReplyArticleID ) {
                my $ReplyArticles = $TicketObject->ArticleList(
                    TicketID => $TicketID,
                    User     => $User,
                    Language => $TemplateLanguage,
                ) || [];
                ($ReplyArticle) = grep {
                    ( $_->{id} || 0 ) == $ReplyArticleID
                } @{$ReplyArticles};
            }

            $BodyTemplate = $ReplyArticle ? $Self->_ArticleReplyBodyTemplate(
                Ticket             => $AccessibleTicket,
                Article            => $ReplyArticle,
                QueueReplyTemplate => $QueueReplyTemplate,
                Language           => $TemplateLanguage,
            ) : $QueueReplyTemplate;
        }

        return $Self->_JSONResponse(
            Data => {
                success     => $Template ? 1 : 0,
                content     => $Template ? ( $Template->{content} || '' ) : '',
                body_template => $BodyTemplate,
                language    => $Template ? $TemplateLanguage : '',
                attachments => \@Attachments,
                error       => $Template ? '' : 'TicketResponseTemplateLoadFailed',
            },
        );
    }

    my $ChecklistActionError = '';
    if ( $TicketObject && ( $Request->{Step} || '' ) =~ m{\AChecklist(?:ItemToggle|Add|Remove)\z} ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject->TicketGet(
            TicketID => $TicketID,
            User     => $User,
            Language => $Language,
        );
        if ( !$AccessibleTicket || ( $AccessibleTicket->{state_name} || '' ) eq 'merged' || !$Self->_QueueAccessCheck(
            User       => $User,
            QueueID    => $AccessibleTicket->{queue_id} || 0,
            Permission => 'ticket.edit',
        ) ) {
            $ChecklistActionError = 'Translate:TicketChecklistChangeDenied';
        }
        elsif ( ( $Request->{Step} || '' ) eq 'ChecklistItemToggle' ) {
            if ( $ChecklistObject->TicketItemToggle(
                TicketID        => $TicketID,
                ItemID          => $Request->{ChecklistItemID},
                Done            => $Request->{ChecklistItemDone},
                ChangedByUserID => $User->{user_account_id} || 1,
            ) ) {
                return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-checklists' };
            }
            $ChecklistActionError = $ChecklistObject->Error() || 'Translate:TicketChecklistItemSaveFailed';
        }
        elsif ( ( $Request->{Step} || '' ) eq 'ChecklistAdd' ) {
            my $TemplateID = $Request->{ChecklistTemplateID} || 0;
            my %Allowed = map { ( $_->{id} || 0 ) => 1 } @{ $ChecklistObject->TicketManualTemplateList( TicketID => $TicketID ) || [] };
            if ( !$Allowed{$TemplateID} ) {
                $ChecklistActionError = 'Translate:TicketChecklistTemplateNotAvailable';
            }
            elsif ( $Self->{DB}->BeginWork() ) {
                my $ID = $ChecklistObject->TicketChecklistAdd(
                    TicketID        => $TicketID,
                    TemplateID      => $TemplateID,
                    ChangedByUserID => $User->{user_account_id} || 1,
                    Source          => 'manual',
                );
                if ( $ID && $Self->{DB}->Commit() ) {
                    return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-checklists' };
                }
                eval { $Self->{DB}->Rollback(); 1; };
                $ChecklistActionError = $ChecklistObject->Error() || 'Translate:TicketChecklistAddFailed';
            }
            else {
                $ChecklistActionError = 'Translate:TicketChecklistAddFailed';
            }
        }
        else {
            if ( $ChecklistObject->TicketChecklistRemove(
                TicketID          => $TicketID,
                TicketChecklistID => $Request->{TicketChecklistID},
                ChangedByUserID   => $User->{user_account_id} || 1,
            ) ) {
                return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-checklists' };
            }
            $ChecklistActionError = $ChecklistObject->Error() || 'Translate:TicketChecklistRemoveFailed';
        }
    }

    my $ToolActionError  = '';
    my $ToolActionActive = 'priority';
    my $TimeAccountingActionError = '';
    my $SplitActionError = '';
    my $SplitArticleID   = 0;

    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'TimeAccountingCreate' ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject->TicketGet( TicketID => $TicketID, User => $User, Language => $Language );
        my $Input = $TimeAccountingObject->InputParse( Request => $Request );
        my $Description = $Request->{TimeAccountingDescription} || '';
        $Description =~ s{\A\s+|\s+\z}{}g;
        if ( !$AccessibleTicket || ( $AccessibleTicket->{state_name} || '' ) eq 'merged' || !$Self->_QueueAccessCheck( User => $User, QueueID => $AccessibleTicket->{queue_id} || 0, Permission => 'ticket.edit' ) ) {
            $TimeAccountingActionError = 'Translate:TimeAccountingCreateDenied';
        }
        elsif ( !$Input ) {
            $TimeAccountingActionError = $TimeAccountingObject->Error() || 'Translate:TimeAccountingCreateFailed';
        }
        elsif ( !$Input->{DurationMinutes} ) {
            $TimeAccountingActionError = 'Translate:TimeAccountingDurationRequired';
        }
        elsif ( !$Description ) {
            $TimeAccountingActionError = 'Translate:TimeAccountingDescriptionRequired';
        }
        elsif ( $TimeAccountingObject->EntryCreate(
            TicketID => $TicketID, AgentUserID => $User->{user_account_id},
            ActivityTypeID => $Input->{ActivityTypeID}, DurationMinutes => $Input->{DurationMinutes},
            Billable => $Input->{Billable}, Description => $Description, Source => 'manual',
            CreatedByUserID => $User->{user_account_id},
        ) ) {
            return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-time-accounting' };
        }
        else {
            $TimeAccountingActionError = $TimeAccountingObject->Error() || 'Translate:TimeAccountingCreateFailed';
        }
        $ToolActionActive = 'time-accounting';
    }

    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'TimeAccountingCorrect' ) {
        my $User = $Param{User} || {};
        my $AccessibleTicket = $TicketObject->TicketGet( TicketID => $TicketID, User => $User, Language => $Language );
        my $Input = $TimeAccountingObject->InputParse( Request => $Request, NamePrefix => 'Replacement' );
        if ( !$AccessibleTicket || ( $AccessibleTicket->{state_name} || '' ) eq 'merged' || !$Self->_QueueAccessCheck( User => $User, QueueID => $AccessibleTicket->{queue_id} || 0, Permission => 'ticket.view' ) ) {
            $TimeAccountingActionError = 'Translate:TimeAccountingCorrectionDenied';
        }
        elsif ( !$Input ) {
            $TimeAccountingActionError = $TimeAccountingObject->Error() || 'Translate:TimeAccountingCorrectionFailed';
        }
        elsif ( $TimeAccountingObject->EntryCorrect(
            TimeAccountingID => $Request->{TimeAccountingID}, TicketID => $TicketID,
            UserID => $User->{user_account_id}, Reason => $Request->{TimeAccountingCorrectionReason},
            Description => $Request->{ReplacementTimeAccountingDescription}, Input => $Input,
        ) ) {
            return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-time-accounting' };
        }
        else {
            $TimeAccountingActionError = $TimeAccountingObject->Error() || 'Translate:TimeAccountingCorrectionFailed';
        }
    }

    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'TicketToolUpdate' ) {
        my $ToolResult = $Self->_TicketToolUpdate(
            Request      => $Request,
            User         => $Param{User} || {},
            Language     => $Language,
            TicketObject => $TicketObject,
            TicketID     => $TicketID,
        );

        $ToolActionActive = $ToolResult->{ActiveTool} || $ToolActionActive;

        if ( $ToolResult->{Success} ) {
            return {
                Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID,
            };
        }

        $ToolActionError = $ToolResult->{Error} || 'Translate:TicketToolUpdateFailed';
    }

    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'TicketLinkCreate' ) {
        $ToolActionActive = 'link';

        if ( $TicketLinkObject->LinkCreate(
            SourceTicketID => $TicketID,
            TargetTicketID => $Request->{TargetTicketID},
            User           => $Param{User} || {},
        ) ) {
            return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID . '#qisutu-ticket-links' };
        }

        $ToolActionError = $TicketLinkObject->Error() || 'Translate:TicketLinkCreateFailed';
    }

    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'TicketMerge' ) {
        $ToolActionActive = 'merge';

        my $MergeDirection = $Request->{MergeDirection} || '';
        if ( $MergeDirection ne 'current_into_selected' && $MergeDirection ne 'selected_into_current' ) {
            $ToolActionError = 'Translate:TicketMergeDirectionRequired';
        }
        elsif ( !$Request->{MergeConfirmed} ) {
            $ToolActionError = 'Translate:TicketMergeConfirmationRequired';
        }
        else {
            my $SelectedTicketID = $Request->{TargetTicketID} || 0;
            my ( $SourceTicketID, $TargetTicketID ) = $MergeDirection eq 'selected_into_current'
                ? ( $SelectedTicketID, $TicketID )
                : ( $TicketID, $SelectedTicketID );
            my $MergeResult = $TicketLinkObject->TicketMerge(
                SourceTicketID => $SourceTicketID,
                TargetTicketID => $TargetTicketID,
                User           => $Param{User} || {},
            );

            if ($MergeResult) {
                return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $MergeResult->{TicketID} . '#qisutu-ticket-links' };
            }

            $ToolActionError = $TicketLinkObject->Error() || 'Translate:TicketMergeFailed';
        }
    }

    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'TicketArticleSplit' ) {
        $SplitArticleID = $Request->{SourceArticleID} || 0;

        my $SplitResult = $TicketLinkObject->ArticleSplit(
            SourceTicketID   => $TicketID,
            SourceArticleID  => $SplitArticleID,
            Title            => $Request->{SplitTitle},
            QueueID          => $Request->{SplitQueueID},
            StateID          => $Request->{SplitStateID},
            PriorityID       => $Request->{SplitPriorityID},
            OwnerUserID      => $Request->{SplitOwnerUserID},
            ResponsibleUserID => $Request->{SplitResponsibleUserID},
            CopyAttachments  => $Request->{SplitCopyAttachments} ? 1 : 0,
            User             => $Param{User} || {},
        );

        if ($SplitResult) {
            return { Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $SplitResult->{TicketID} . '#qisutu-ticket-links' };
        }

        $SplitActionError = $TicketLinkObject->Error() || 'Translate:TicketSplitCreateFailed';
    }


    if ( $TicketObject && ( $Request->{Step} || '' ) eq 'ArticleCreate' ) {
        my $User        = $Param{User} || {};
        my $ArticleMode = $Request->{ArticleMode} || 'note';
        my $Body        = $Request->{Body} || '';
        my $UploadResult = $Self->_UploadedAttachments(
            Request      => $Request,
            MaxSizeBytes => $AttachmentMaxSizeBytes,
        );
        my $Attachments = $UploadResult->{Attachments} || [];
        my $TimeAccountingInput = $TimeAccountingObject->InputParse( Request => $Request );

        if ( !$TimeAccountingInput ) {
            $ArticleCreateError = $TimeAccountingObject->Error() || 'Translate:TimeAccountingCreateFailed';
        }

        if ( @{ $UploadResult->{Oversized} || [] } ) {
            $ArticleCreateError = $Self->_AttachmentTooLargeMessage(
                Attachment => $UploadResult->{Oversized}->[0],
                MaxSizeMB  => $AttachmentMaxSizeMB,
                Language   => $Language,
            );
        }

        if ( $ArticleMode ne 'email' && $ArticleMode ne 'forward' ) {
            $ArticleMode = 'note';
        }

        if ( $ArticleMode eq 'email' || $ArticleMode eq 'forward' ) {
            $Body = $Self->_ForwardMailHTMLNormalize( HTML => $Body );
        }

        my $TicketForSubmit = $TicketObject->TicketGet(
            TicketID  => $TicketID,
            User      => $User,
            Language  => $Language,
        );

        if ( !$TicketForSubmit ) {
            $ArticleCreateError = $TicketObject->Error() || 'Translate:TicketArticleCreateFailed';
        }
        elsif ( ( $TicketForSubmit->{state_name} || '' ) eq 'merged' ) {
            $ArticleCreateError = 'Translate:TicketMergedReadOnly';
        }

        if ( !$ArticleCreateError && $ArticleMode eq 'email' && ( $Request->{ResponseTemplateID} || 0 ) ) {
            my $TemplateObject = $Self->_ResponseTemplateObject();
            my $TemplateLanguage = $TemplateObject ? $TemplateObject->LanguageClean(
                $Request->{ResponseTemplateLanguage} || $Language,
            ) : $Language;
            my $AttachmentIDs  = $Self->_RequestIDList( Value => $Request->{ResponseTemplateAttachmentID} );
            my $TemplateAttachments = $TemplateObject ? $TemplateObject->AttachmentsForArticle(
                TemplateID   => $Request->{ResponseTemplateID},
                QueueID      => $TicketForSubmit->{queue_id},
                Language     => $TemplateLanguage,
                AttachmentIDs => $AttachmentIDs,
                UseSelection => $Request->{ResponseTemplateAttachmentSelection} ? 1 : 0,
            ) : undef;

            if ( !$TemplateObject || !defined $TemplateAttachments ) {
                $ArticleCreateError = 'Translate:TicketResponseTemplateLoadFailed';
            }
            elsif ( $TemplateObject->Error() ) {
                $ArticleCreateError = 'Translate:TicketResponseTemplateLoadFailed';
            }
            else {
                push @{$Attachments}, @{$TemplateAttachments};
            }
        }

        my $Channel    = $ArticleMode eq 'note' ? 'note' : 'email';
        my $Visibility = $ArticleMode eq 'forward' ? 'agent' : 'both';
        my $SenderType = 'agent';

        if ( $ArticleMode eq 'note' ) {
            $Visibility = $Request->{CustomerVisible} ? 'both' : 'agent';
        }

        my ( $ToName, $ToEmail ) = ( '', '' );
        my $CcEmail = '';
        my ( $FromName, $FromEmail ) = (
            $Self->_UserName( User => $User ),
            $User->{email} || '',
        );
        my $Subject      = $Request->{Subject} || '';
        my $StatusID     = $Self->_SubmittedStatusID( StatusID => $Request->{StatusID} );
        my $PendingUntil = $Request->{PendingUntil} || '';

        if ( !$ArticleCreateError && !$Self->_BodyHasVisibleContent( Body => $Body ) ) {
            $ArticleCreateError = 'Translate:TicketArticleBodyRequired';
        }

        if ( !$ArticleCreateError && !$StatusID ) {
            $ArticleCreateError = 'Translate:TicketStatusUpdateFailed';
        }

        if ( !$ArticleCreateError && $ArticleMode ne 'forward' && $DynamicFieldObject ) {
            if ( !$DynamicFieldObject->TicketValueValidate(
                QueueID  => $TicketForSubmit->{queue_id},
                Request  => $Request,
                Language => $Language,
            ) ) {
                $ArticleCreateError = $DynamicFieldObject->Error() || 'Translate:TicketDynamicFieldInvalid';
            }
        }

        if ( !$ArticleCreateError && ( $ArticleMode eq 'email' || $ArticleMode eq 'forward' ) ) {
            if ( $ArticleMode eq 'email' ) {
                ( $ToName, $ToEmail ) = $Self->_ReplyRecipient(
                    TicketObject => $TicketObject,
                    TicketID     => $TicketID,
                    User         => $User,
                );
            }

            my $SubmittedTo = $Request->{ReplyTo} || '';
            $ToEmail = $SubmittedTo if $SubmittedTo;

            my $ToCheck = $Self->_EmailRecipientsParse(
                Value    => $ToEmail,
                Required => 1,
            );

            if ( !$ToCheck->{Valid} ) {
                $ArticleCreateError = $ToCheck->{Error} || 'Translate:TicketArticleRecipientRequired';
            }
            else {
                $ToName  = '' if $SubmittedTo;
                $ToEmail = $ToCheck->{Header};
            }

            if ( !$ArticleCreateError ) {
                my $CcCheck = $Self->_EmailRecipientsParse(
                    Value    => $Request->{ReplyCc} || '',
                    Required => 0,
                );

                if ( !$CcCheck->{Valid} ) {
                    $ArticleCreateError = $CcCheck->{Error} || 'Translate:TicketToolForwardRecipientInvalid';
                }
                else {
                    $CcEmail = $CcCheck->{Header};
                }
            }

            ( $FromName, $FromEmail ) = $Self->_ReplySender(
                TicketID => $TicketID,
                User     => $User,
            );

            $Subject ||= $Self->_DefaultReplySubject(
                TicketObject => $TicketObject,
                TicketID     => $TicketID,
                User         => $User,
                Language     => $Language,
            );

            if ( !$ArticleCreateError && $TicketObject->can('TicketSubjectBuild') ) {
                $Subject = $TicketObject->TicketSubjectBuild(
                    TicketID => $TicketID,
                    Subject  => $Subject,
                );
            }

            if ( !$ArticleCreateError ) {
                my $Precheck = $Self->_EmailSendPrecheck(
                    FromEmail => $FromEmail,
                    ToEmail   => $ToEmail,
                );

                if ( !$Precheck->{Success} ) {
                    $ArticleCreateError = $Precheck->{Message} || 'Translate:TicketArticleSendFailed';
                }
            }
        }

        if ( !$ArticleCreateError ) {
            $Self->{DB}->BeginWork() || do {
                $ArticleCreateError = 'Translate:TicketArticleCreateFailed';
            };
        }

        my $ArticleID;

        if ( !$ArticleCreateError ) {
            $ArticleID = $TicketObject->ArticleCreate(
                TicketID        => $TicketID,
                User            => $User,
                Subject         => $Subject,
                Body            => $Body,
                Channel         => $Channel,
                SenderType      => $SenderType,
                FromName        => $FromName,
                FromEmail       => $FromEmail,
                ToName          => $ToName,
                ToEmail         => $ToEmail,
                Cc              => $CcEmail,
                ContentType     => 'text/html',
                Visibility      => $Visibility,
                Language        => $Language,
                CreatedByUserID => $User->{user_account_id},
                ChangedByUserID => $User->{user_account_id},
                Attachments     => $Attachments,
            );

            if ( !$ArticleID ) {
                $ArticleCreateError = $TicketObject->Error() || 'Translate:TicketArticleCreateFailed';
                $Self->{DB}->Rollback();
            }
        }

        if ( !$ArticleCreateError && $ArticleMode eq 'email' ) {
            my $AgentUserID = $User->{user_account_id} || 0;

            if ( !$AgentUserID ) {
                $ArticleCreateError = 'Translate:TicketArticleCreateFailed';
                $Self->{DB}->Rollback();
            }
            elsif ( ( $TicketForSubmit->{owner_user_id} || 0 ) != $AgentUserID ) {
                if (
                    !$TicketObject->TicketOwnerUpdate(
                        TicketID             => $TicketID,
                        OwnerUserID          => $AgentUserID,
                        User                 => $User,
                        ChangedByUserID      => $AgentUserID,
                        SuppressNotification => 1,
                    )
                ) {
                    $ArticleCreateError = $TicketObject->Error() || 'Translate:TicketArticleCreateFailed';
                    $Self->{DB}->Rollback();
                }
            }
        }

        if ( !$ArticleCreateError ) {
            if (
                !$TicketObject->TicketStatusUpdate(
                    TicketID        => $TicketID,
                    StatusID        => $StatusID,
                    ChangedByUserID => $User->{user_account_id},
                    PendingUntil     => $PendingUntil,
                )
            ) {
                $ArticleCreateError = $TicketObject->Error() || 'Translate:TicketStatusUpdateFailed';
                if ( $ArticleCreateError eq 'Translate:TicketChecklistCloseBlocked' ) {
                    $ArticleCreateError = $Self->_ChecklistCloseError(
                        Items    => $TicketObject->LastChecklistOpenItems(),
                        Language => $Language,
                    );
                }
                $Self->{DB}->Rollback();
            }
        }

        if ( !$ArticleCreateError && $ArticleMode ne 'forward' && $DynamicFieldObject ) {
            if ( !$DynamicFieldObject->TicketValueSave(
                TicketID        => $TicketID,
                QueueID         => $TicketForSubmit->{queue_id},
                Request         => $Request,
                Language        => $Language,
                ChangedByUserID => $User->{user_account_id},
            ) ) {
                $ArticleCreateError = $DynamicFieldObject->Error() || 'Translate:TicketDynamicFieldSaveFailed';
                $Self->{DB}->Rollback();
            }
        }

        if ( !$ArticleCreateError && ( $TimeAccountingInput->{DurationMinutes} || 0 ) > 0 ) {
            if ( !$TimeAccountingObject->EntryCreate(
                TicketID => $TicketID, TicketArticleID => $ArticleID,
                AgentUserID => $User->{user_account_id}, ActivityTypeID => $TimeAccountingInput->{ActivityTypeID},
                DurationMinutes => $TimeAccountingInput->{DurationMinutes}, Billable => $TimeAccountingInput->{Billable},
                Source => 'article_' . $ArticleMode, Description => $Subject,
                CreatedByUserID => $User->{user_account_id}, TransactionActive => 1,
            ) ) {
                $ArticleCreateError = $TimeAccountingObject->Error() || 'Translate:TimeAccountingCreateFailed';
                $Self->{DB}->Rollback();
            }
        }

        if ( !$ArticleCreateError && ( $ArticleMode eq 'email' || $ArticleMode eq 'forward' ) ) {
            my $SendResult = $Self->_EmailSend(
                TicketID     => $TicketID,
                ArticleID    => $ArticleID,
                FromName    => $FromName,
                FromEmail   => $FromEmail,
                ToName      => $ToName,
                ToEmail     => $ToEmail,
                Cc          => $CcEmail,
                Subject     => $Subject,
                Body        => $Body,
                Attachments => $Attachments,
            );

            if ( !$SendResult->{Success} ) {
                $ArticleCreateError = $SendResult->{Message} || 'Translate:TicketArticleSendFailed';
                $Self->{DB}->Rollback();
            }
        }

        if ( !$ArticleCreateError ) {
            if ( $Self->{DB}->Commit() ) {
                return {
                    Redirect => 'index.pl?Page=AgentTicketZoom&TicketID=' . $TicketID,
                };
            }

            $ArticleCreateError = 'Translate:TicketArticleCreateFailed';
            $Self->{DB}->Rollback();
        }
    }

    if ($TicketObject) {
        $Ticket = $TicketObject->TicketGet(
            TicketID  => $TicketID,
            User      => $Param{User} || {},
            Language  => $Language,
        );
    }

    my $TicketChecklists = [];
    my $TicketChecklistTemplates = [];
    my $ChecklistTotalItems = 0;
    my $ChecklistDoneItems  = 0;
    if ($Ticket) {
        $TicketChecklists = $ChecklistObject->TicketChecklistList( TicketID => $TicketID ) || [];
        $TicketChecklistTemplates = $ChecklistObject->TicketManualTemplateList( TicketID => $TicketID ) || [];
        for my $Checklist ( @{$TicketChecklists} ) {
            $ChecklistTotalItems += $Checklist->{item_count} || 0;
            $ChecklistDoneItems  += $Checklist->{done_count} || 0;
            $Checklist->{complete_class} = ( $Checklist->{item_count} || 0 ) == ( $Checklist->{done_count} || 0 )
                ? 'qisutu-ticket-checklist-complete' : '';
            for my $Item ( @{ $Checklist->{items} || [] } ) {
                $Item->{checked} = $Item->{is_done} ? 'checked' : '';
                $Item->{done_class} = $Item->{is_done} ? 'qisutu-ticket-checklist-item-done' : '';
                $Item->{required_label} = $Item->{is_required} ? 'Translate:TicketChecklistRequired' : '';
                $Item->{completed_by_name} = $Self->_UserName(
                    User => {
                        firstname => $Item->{completed_by_firstname},
                        lastname  => $Item->{completed_by_lastname},
                        login     => $Item->{completed_by_login},
                    },
                );
                $Item->{completed_at_display} = $Self->_DateTimeFormat(
                    DateTime => $Item->{completed_at},
                    Language => $Language,
                );
            }
        }
    }

    my $TicketChecklistListHTML = $Self->_TicketChecklistListHTML(
        TicketID   => $TicketID,
        Checklists => $TicketChecklists,
        Language   => $Language,
        ReadOnly   => $Ticket && ( $Ticket->{state_name} || '' ) eq 'merged' ? 1 : 0,
    );
    my $TicketChecklistAddFormHTML = $Self->_TicketChecklistAddFormHTML(
        TicketID  => $TicketID,
        Templates => $TicketChecklistTemplates,
        Language  => $Language,
        ReadOnly  => $Ticket && ( $Ticket->{state_name} || '' ) eq 'merged' ? 1 : 0,
    );

    if ( !$Ticket ) {
        return {
            Template => 'AgentTicketZoom.tt',
            Data     => {
                PageTitle          => 'Translate:TicketZoomTitle',
                ProgramTitle       => 'Translate:TicketZoomTitle',
                ProgramDescription => 'Translate:TicketZoomDescription',
                TicketFound        => 0,
                TicketListURL      => 'index.pl?Page=AgentTicketList',
            },
        };
    }

    my $TicketHistoryPage = $TicketHistoryObject->List(
        TicketID => $Ticket->{id},
        Category => 'all',
        Limit    => 50,
    );
    my $TicketHistoryHTML = $TicketHistoryObject->TimelineHTML(
        Items    => $TicketHistoryPage->{Items},
        Language => $Language,
    );

    my $TicketMerged = ( $Ticket->{state_name} || '' ) eq 'merged' ? 1 : 0;
    my $CanChangeTicket = !$TicketMerged && $Self->_QueueAccessCheck(
        User       => $Param{User} || {},
        QueueID    => $Ticket->{queue_id},
        Permission => 'ticket.edit',
    );
    my $CMDBPermission = $CMDBObject->PermissionLevel( User => $Param{User} || {} );
    my $HasCMDBAccess = $CMDBPermission->{View} ? 1 : 0;
    my $CanChangeCMDB = $HasCMDBAccess && $CMDBPermission->{Change} && $CanChangeTicket ? 1 : 0;
    my $TicketCMDBItems = $HasCMDBAccess ? $CMDBObject->TicketCIList( TicketID => $Ticket->{id} ) : [];
    my $TicketCMDBHTML = $HasCMDBAccess ? $CMDBObject->TicketSummaryHTML(
        TicketID  => $Ticket->{id},
        CanChange => $CanChangeCMDB,
        Language  => $Language,
    ) : '';
    my $TicketLinks = $TicketLinkObject->LinkList(
        TicketID => $Ticket->{id},
        User     => $Param{User} || {},
    ) || [];
    for my $Link ( @{$TicketLinks} ) {
        $Link->{created_at_display} = $Self->_DateTimeFormat(
            DateTime => $Link->{created_at},
            Language => $Language,
        );
        $Link->{state} = $Self->_TicketStateText(
            State    => $Link->{state},
            Language => $Language,
        );
    }
    my ($MergedTargetLink) = grep {
        ( $_->{link_type} || '' ) eq 'merge' && $_->{current_is_source}
    } @{$TicketLinks};
    $MergedTargetLink ||= {};
    my $ArticleOriginMap = $TicketLinkObject->ArticleOriginMap(
        TicketID => $Ticket->{id},
        User     => $Param{User} || {},
    ) || {};

    $Articles = $TicketObject->ArticleList(
        TicketID => $Ticket->{id},
        User     => $Param{User} || {},
        Language => $Language,
    );

    my $FallbackSenderName  = '';
    my $FallbackSenderEmail = '';
    my $ArticleIndex        = 0;
    my $ArticleCount        = scalar @{$Articles};
    my $ReplyEmailTemplate  = $Self->_QueueReplyTemplate(
        TicketID => $Ticket->{id},
        User     => $Param{User} || {},
        Language => $Language,
    );
    my $ResponseTemplateObject = $Self->_ResponseTemplateObject();
    my $ResponseTemplateList = $ResponseTemplateObject ? $ResponseTemplateObject->TemplateListForQueueAllLanguages(
        QueueID => $Ticket->{queue_id},
    ) : [];
    my $SelectedResponseTemplateID = $ArticleCreateError ? ( $Request->{ResponseTemplateID} || 0 ) : 0;
    my $SelectedResponseTemplateLanguage = $ArticleCreateError
        ? ( $Request->{ResponseTemplateLanguage} || '' )
        : '';
    for my $ResponseTemplate ( @{$ResponseTemplateList} ) {
        $ResponseTemplate->{selected}
            = ( $ResponseTemplate->{id} || 0 ) == $SelectedResponseTemplateID
                && ( $ResponseTemplate->{language} || '' ) eq $SelectedResponseTemplateLanguage
            ? 'selected'
            : '';
    }

    for my $Article ( @{$Articles} ) {
        $ArticleIndex++;

        $Article->{created_at_display} = $Self->_DateTimeFormat(
            DateTime => $Article->{created_at},
            Language => $Language,
        );

        $Article->{article_open_class} = $ArticleIndex == $ArticleCount
            ? 'qisutu-ticket-article-open'
            : '';

        my $ReplyData = $Self->_ArticleReplyData(
            Ticket             => $Ticket,
            Article            => $Article,
            QueueReplyTemplate => $ReplyEmailTemplate,
            Language           => $Language,
        );

        $Article->{reply_allowed}       = $ReplyData->{Allowed} ? 1 : 0;
        $Article->{reply_button_class}  = $ReplyData->{Allowed} ? '' : 'qisutu-hidden';
        $Article->{reply_to}            = $ReplyData->{To} || '';
        $Article->{reply_cc}            = $ReplyData->{Cc} || '';
        $Article->{reply_subject}       = $ReplyData->{Subject} || '';
        $Article->{reply_body_template} = $ReplyData->{BodyTemplate} || '';

        my $ForwardData = $Self->_ArticleForwardData(
            Article  => $Article,
            Language => $Language,
        );

        $Article->{forward_subject}       = $ForwardData->{Subject} || '';
        $Article->{forward_body_template} = $ForwardData->{BodyTemplate} || '';
        $Article->{split_button_class}     = $CanChangeTicket ? '' : 'qisutu-hidden';
        $Article->{split_title}            = $Article->{subject} || $Ticket->{title} || '';
        my $ArticleOrigin = $ArticleOriginMap->{ $Article->{id} } || {};
        $Article->{origin_class}         = $ArticleOrigin->{ticket_id} ? '' : 'qisutu-hidden';
        $Article->{origin_ticket_id}     = $ArticleOrigin->{ticket_id} || 0;
        $Article->{origin_ticket_number} = $ArticleOrigin->{ticket_number} || '';
        $Article->{origin_title}         = $ArticleOrigin->{title} || '';
        $Article->{origin_label}         = $ArticleOrigin->{label_key} || '';

        if ( !$FallbackSenderEmail && ( $Article->{channel} || '' ) eq 'email' ) {
            $FallbackSenderEmail = $Article->{from_email} || '';
            $FallbackSenderName  = $Article->{from_name}  || $Article->{sender_name} || '';
        }
    }

    if ( !$FallbackSenderEmail ) {
        for my $Article ( @{$Articles} ) {
            if ( $Article->{from_email} ) {
                $FallbackSenderEmail = $Article->{from_email};
                $FallbackSenderName  = $Article->{from_name} || $Article->{sender_name} || '';
                last;
            }
        }
    }

    my $TicketCustomerUser  = $Ticket->{customer_user_name}  || $FallbackSenderName  || '-';
    my $TicketCustomerEmail = $Ticket->{customer_user_email} || $FallbackSenderEmail || '-';
    my $TicketOwnerAutocompleteValue = $Ticket->{owner_user_id} ? ( $Ticket->{owner_name} || $Ticket->{owner_login} || '' ) : '';
    my $TicketResponsibleAutocompleteValue = $Ticket->{responsible_user_id} ? ( $Ticket->{responsible_name} || $Ticket->{responsible_login} || '' ) : '';
    my $TicketCustomerAutocompleteValue = $Ticket->{customer_user_id}
        ? ( ( $Ticket->{customer_user_name} || $TicketCustomerUser || '' ) . ( ( $Ticket->{customer_name} || '' ) ? ' — ' . $Ticket->{customer_name} : '' ) )
        : '';
    my $SplitDefaultOwnerValue = $TicketOwnerAutocompleteValue || $Self->_UserName( User => $Param{User} || {} );
    my $StatusOptionsHTML   = $Self->_StatusOptionsHTML(
        Language         => $Language,
        DefaultStateName => 'open',
        CurrentStateName => $Ticket->{state_name},
    );
    my $PriorityOptionsHTML = $Self->_PriorityOptionsHTML(
        CurrentPriorityID => $Ticket->{priority_id},
        Language          => $Language,
    );
    my $QueueOptionCurrentID = $ToolActionError && $ToolActionActive eq 'queue' && ( $Request->{QueueID} || 0 )
        ? $Request->{QueueID}
        : $Ticket->{queue_id};
    my $QueueOptionsHTML = $Self->_QueueOptionsHTML(
        CurrentQueueID => $QueueOptionCurrentID,
    );
    my $ServiceOptionCurrentID = $ToolActionError && $ToolActionActive eq 'service'
        ? ( defined $Request->{ServiceID} ? $Request->{ServiceID} : ( $Ticket->{service_id} || 0 ) )
        : ( $Ticket->{service_id} || 0 );
    my $ServiceOptionsHTML = $Self->_ServiceOptionsHTML(
        CustomerID       => $Ticket->{customer_id},
        CurrentServiceID => $ServiceOptionCurrentID,
        Language         => $Language,
    );
    my $ClosedStateOptionsHTML = $Self->_ClosedStateOptionsHTML(
        Language       => $Language,
        CurrentStateID => $Ticket->{state_id},
    );
    my $SplitQueueOptionsHTML = $Self->_SplitQueueOptionsHTML(
        CurrentQueueID => $SplitActionError ? ( $Request->{SplitQueueID} || $Ticket->{queue_id} ) : $Ticket->{queue_id},
        User           => $Param{User} || {},
    );
    my $SplitStateOptionsHTML = $Self->_SplitStateOptionsHTML(
        CurrentStateID => $SplitActionError ? ( $Request->{SplitStateID} || 0 ) : 0,
        Language       => $Language,
    );
    my $SplitPriorityOptionsHTML = $Self->_PriorityOptionsHTML(
        CurrentPriorityID => $SplitActionError ? ( $Request->{SplitPriorityID} || $Ticket->{priority_id} ) : $Ticket->{priority_id},
        Language          => $Language,
    );

    my $ArticleDynamicFieldsHTML = $DynamicFieldObject ? $DynamicFieldObject->FormHTML(
        QueueID  => $Ticket->{queue_id},
        TicketID => $Ticket->{id},
        Request  => $ArticleCreateError ? $Request : {},
        Language => $Language,
        IDPrefix => 'qisutu-ticket-article-dynamic-field',
    ) : '';
    my $PriorityDynamicFieldsHTML = $DynamicFieldObject ? $DynamicFieldObject->FormHTML(
        QueueID  => $Ticket->{queue_id},
        TicketID => $Ticket->{id},
        Request  => $ToolActionError && $ToolActionActive eq 'priority' ? $Request : {},
        Language => $Language,
        IDPrefix => 'qisutu-ticket-tool-priority-dynamic-field',
    ) : '';
    my $QueueDynamicFieldQueueID = $ToolActionError && $ToolActionActive eq 'queue' && ( $Request->{QueueID} || 0 )
        ? $Request->{QueueID}
        : $Ticket->{queue_id};
    my $QueueDynamicFieldsHTML = $DynamicFieldObject ? $DynamicFieldObject->FormHTML(
        QueueID  => $QueueDynamicFieldQueueID,
        TicketID => $Ticket->{id},
        Request  => $ToolActionError && $ToolActionActive eq 'queue' ? $Request : {},
        Language => $Language,
        IDPrefix => 'qisutu-ticket-tool-queue-dynamic-field',
    ) : '';
    my $CloseDynamicFieldsHTML = $DynamicFieldObject ? $DynamicFieldObject->FormHTML(
        QueueID  => $Ticket->{queue_id},
        TicketID => $Ticket->{id},
        Request  => $ToolActionError && $ToolActionActive eq 'close' ? $Request : {},
        Language => $Language,
        IDPrefix => 'qisutu-ticket-tool-close-dynamic-field',
    ) : '';
    my $TicketDynamicFieldsDisplayHTML = $DynamicFieldObject ? $DynamicFieldObject->DisplayHTML(
        QueueID  => $Ticket->{queue_id},
        TicketID => $Ticket->{id},
        Language => $Language,
    ) : '';
    my $TicketFormInformationHTML = $TicketFormObject ? $TicketFormObject->SubmissionDisplayHTML(
        TicketID => $Ticket->{id},
        Language => $Language,
    ) : '';

    my $ArticleTimeAccountingFieldsHTML = $TimeAccountingObject->FormHTML(
        Language => $Language,
        Request  => $ArticleCreateError ? $Request : {},
        IDPrefix => 'qisutu-ticket-article-time-accounting',
    );
    my %ToolTimeAccountingFieldsHTML;
    for my $Tool (qw(priority owner responsible customer service queue close)) {
        $ToolTimeAccountingFieldsHTML{$Tool} = $TimeAccountingObject->FormHTML(
            Language => $Language,
            Request  => $ToolActionError && $ToolActionActive eq $Tool ? $Request : {},
            IDPrefix => 'qisutu-ticket-tool-' . $Tool . '-time-accounting',
        );
    }
    my $ManualTimeAccountingFieldsHTML = $TimeAccountingObject->FormHTML(
        Language => $Language,
        Request  => $TimeAccountingActionError && ( $Request->{Step} || '' ) eq 'TimeAccountingCreate' ? $Request : {},
        IDPrefix => 'qisutu-ticket-manual-time-accounting',
    );
    my $TicketTimeAccountingHTML = $TimeAccountingObject->TicketSummaryHTML(
        TicketID => $TicketID,
        UserID   => ( $Param{User} || {} )->{user_account_id},
        Language => $Language,
        ReadOnly => $TicketMerged,
    );

    $ToolActionError = $TimeAccountingActionError
        if $TimeAccountingActionError && ( $Request->{Step} || '' ) eq 'TimeAccountingCreate';

    my $ArticleEmptyClass = $ArticleCount ? 'qisutu-hidden' : '';
    my $ArticleCreateErrorClass = $ArticleCreateError ? '' : 'qisutu-hidden';
    my $ArticleReplyFormClass = $ArticleCreateError ? '' : 'qisutu-hidden';
    my $TicketToolErrorClass = $ToolActionError ? '' : 'qisutu-hidden';
    my $TicketToolsOverlayClass = $ToolActionError ? '' : 'qisutu-hidden';
    my $TicketToolsOverlayAriaHidden = $ToolActionError ? 'false' : 'true';
    my $TicketToolsButtonExpanded = $ToolActionError ? 'true' : 'false';

    return {
        Template => 'AgentTicketZoom.tt',
        Data     => {
            PageTitle          => $Ticket->{ticket_number} . ' - ' . $Ticket->{title},
            ProgramTitle       => $Ticket->{ticket_number},
            ProgramDescription => $Ticket->{title},
            TicketFound        => 1,
            TicketListURL      => 'index.pl?Page=AgentTicketList',

            TicketID              => $Ticket->{id},
            TicketNumber          => $Ticket->{ticket_number},
            TicketTitle           => $Ticket->{title},
            TicketHistoryHTML     => $TicketHistoryHTML,
            TicketHistoryHasMore  => $TicketHistoryPage->{HasMore} ? 1 : 0,
            TicketHistoryNextBeforeID => 0 + ( $TicketHistoryPage->{NextBeforeID} || 0 ),
            TicketHistoryLoadMoreHidden => $TicketHistoryPage->{HasMore} ? '' : 'hidden',
            TicketLinks           => $TicketLinks,
            TicketLinkCount       => scalar @{$TicketLinks},
            HasTicketLinks        => @{$TicketLinks} ? 1 : 0,
            TicketLinksHiddenAttribute => @{$TicketLinks} ? '' : 'hidden',
            CanChangeTicket       => $CanChangeTicket ? 1 : 0,
            HasCMDBAccess         => $HasCMDBAccess,
            CanChangeCMDB         => $CanChangeCMDB,
            TicketCMDBHTML        => $TicketCMDBHTML,
            TicketCMDBCount       => scalar @{$TicketCMDBItems},
            CMDBExpandedClass     => @{$TicketCMDBItems} ? 'qisutu-ticket-info-block-open' : '',
            CMDBExpandedAria      => @{$TicketCMDBItems} ? 'true' : 'false',
            CMDBHiddenAttribute   => @{$TicketCMDBItems} ? '' : 'hidden',
            CMDBActionError       => $CMDBActionError,
            CMDBActionErrorClass  => $CMDBActionError ? '' : 'qisutu-hidden',
            TicketMerged          => $TicketMerged,
            TicketMergedTargetID  => $MergedTargetLink->{ticket_id} || 0,
            TicketMergedTargetNumber => $MergedTargetLink->{ticket_number} || '',
            TicketMergedTargetTitle  => $MergedTargetLink->{title} || '',
            MergeTargetTicketID      => $ToolActionError && $ToolActionActive eq 'merge' ? ( $Request->{TargetTicketID} || 0 ) : 0,
            MergeTargetTicketQuery   => $ToolActionError && $ToolActionActive eq 'merge' ? ( $Request->{TargetTicketQuery} || '' ) : '',
            MergeDirectionCurrentChecked => !$ToolActionError || ( $Request->{MergeDirection} || '' ) eq 'current_into_selected' ? 'checked' : '',
            MergeDirectionSelectedChecked => $ToolActionError && ( $Request->{MergeDirection} || '' ) eq 'selected_into_current' ? 'checked' : '',
            TicketQueue           => $Ticket->{queue_full_name} || $Ticket->{queue_name},
            TicketQueueID         => $Ticket->{queue_id} || 0,
            TicketServiceID       => $Ticket->{service_id} || 0,
            TicketService         => $Ticket->{service_name} || '-',
            TicketSLA             => $Ticket->{sla_name_display} || '-',
            TicketSLACalendar     => $Ticket->{sla_calendar_display} || '-',
            TicketSLASource       => $Ticket->{sla_source_label} || 'Translate:TicketSLASourceQueue',
            TicketSLAUpdateMode   => $Ticket->{sla_update_mode_label} || 'Translate:AdminSLAUpdateModeCustomer',
            TicketSLAFirstResponseMinutes => $Ticket->{sla_source} && $Ticket->{sla_source} eq 'sla' ? ( $Ticket->{sla_first_response_minutes} || 0 ) : ( $Ticket->{escalation_first_response_minutes} || 0 ),
            TicketSLAUpdateMinutes        => $Ticket->{sla_source} && $Ticket->{sla_source} eq 'sla' ? ( $Ticket->{sla_update_minutes} || 0 ) : ( $Ticket->{escalation_update_minutes} || 0 ),
            TicketSLASolutionMinutes      => $Ticket->{sla_source} && $Ticket->{sla_source} eq 'sla' ? ( $Ticket->{sla_solution_minutes} || 0 ) : ( $Ticket->{escalation_solution_minutes} || 0 ),
            TicketSLAPause         => $Ticket->{sla_pause} ? 'Translate:CommonYes' : 'Translate:CommonNo',
            TicketState           => $Ticket->{state_name_display},
            TicketStateType       => $Ticket->{state_type},
            TicketPriority        => $Ticket->{priority_name_display} || $Ticket->{priority_name},
            TicketPriorityID      => $Ticket->{priority_id} || 0,
            TicketPriorityValue   => $Ticket->{priority_value},
            TicketAge             => $Self->_AgeFormat(
                DateTime => $Ticket->{created_at},
                Language => $Language,
            ),
            TicketCustomerNumber   => $Ticket->{customer_number},
            TicketCustomerID       => $Ticket->{customer_id} || 0,
            TicketCustomerUserID   => $Ticket->{customer_user_id} || 0,
            TicketCustomer         => $Ticket->{customer_name} || '-',
            TicketCustomerUser     => $TicketCustomerUser,
            TicketCustomerAutocompleteValue => $TicketCustomerAutocompleteValue,
            TicketCustomerEmail    => $TicketCustomerEmail,
            HasKnowledgeAccess     => $KnowledgePermission->{View} ? 1 : 0,
            TicketOwnerID          => $Ticket->{owner_user_id} || 0,
            TicketOwner            => $Ticket->{owner_name} || '-',
            TicketOwnerAutocompleteValue => $TicketOwnerAutocompleteValue,
            TicketOwnerEmail       => $Ticket->{owner_email},
            TicketResponsibleID    => $Ticket->{responsible_user_id} || 0,
            TicketResponsible      => $Ticket->{responsible_name} || '-',
            TicketResponsibleAutocompleteValue => $TicketResponsibleAutocompleteValue,
            TicketResponsibleEmail => $Ticket->{responsible_email},
            TicketCreatedAt        => $Self->_DateTimeFormat(
                DateTime => $Ticket->{created_at},
                Language => $Language,
            ),
            TicketChangedAt        => $Self->_DateTimeFormat(
                DateTime => $Ticket->{changed_at},
                Language => $Language,
            ),
            TicketEscalationStateLabel => $Ticket->{escalation_state_label},
            TicketEscalationStateClass => $Ticket->{escalation_state_class},
            TicketFirstResponseDueAt   => $Self->_DateTimeFormat( DateTime => $Ticket->{first_response_due_at}, Language => $Language ) || '-',
            TicketFirstResponseAt      => $Self->_DateTimeFormat( DateTime => $Ticket->{first_response_at}, Language => $Language ) || '-',
            TicketUpdateDueAt          => $Self->_DateTimeFormat( DateTime => $Ticket->{update_due_at}, Language => $Language ) || '-',
            TicketSolutionDueAt        => $Self->_DateTimeFormat( DateTime => $Ticket->{solution_due_at}, Language => $Language ) || '-',
            TicketSolutionAt           => $Self->_DateTimeFormat( DateTime => $Ticket->{solution_at}, Language => $Language ) || '-',
            TicketPendingUntil        => $Self->_DateTimeFormat( DateTime => $Ticket->{pending_until}, Language => $Language ) || '-',
            TicketPendingUntilClass   => $Ticket->{pending_until_class} || '',
            TicketPendingUntilReached => $Ticket->{pending_until_reached} || 0,
            TicketPendingUntilReachedSince => $Ticket->{pending_until_reached_since} || '',
            TicketFirstResponseDueClass => $Ticket->{first_response_due_class} || '',
            TicketFirstResponseEscalated => $Ticket->{first_response_escalated} || 0,
            TicketFirstResponseEscalatedSince => $Ticket->{first_response_escalated_since} || '',
            TicketUpdateDueClass => $Ticket->{update_due_class} || '',
            TicketUpdateEscalated => $Ticket->{update_escalated} || 0,
            TicketUpdateEscalatedSince => $Ticket->{update_escalated_since} || '',
            TicketSolutionDueClass => $Ticket->{solution_due_class} || '',
            TicketSolutionEscalated => $Ticket->{solution_escalated} || 0,
            TicketSolutionEscalatedSince => $Ticket->{solution_escalated_since} || '',

            TicketChecklists         => $TicketChecklists,
            TicketChecklistCount    => scalar @{$TicketChecklists},
            TicketChecklistTemplates => $TicketChecklistTemplates,
            TicketChecklistTemplateCount => scalar @{$TicketChecklistTemplates},
            TicketChecklistTotalItems => $ChecklistTotalItems,
            TicketChecklistDoneItems  => $ChecklistDoneItems,
            TicketChecklistProgressPercent => $ChecklistTotalItems ? int( ( $ChecklistDoneItems * 100 ) / $ChecklistTotalItems ) : 100,
            TicketChecklistActionError => $ChecklistActionError,
            TicketChecklistActionErrorClass => $ChecklistActionError ? '' : 'qisutu-hidden',
            TicketChecklistExpanded => $ChecklistActionError ? 1 : 0,
            TicketChecklistExpandedAria => $ChecklistActionError ? 'true' : 'false',
            TicketChecklistHiddenAttribute => $ChecklistActionError ? '' : 'hidden',
            TicketChecklistListHTML => $TicketChecklistListHTML,
            TicketChecklistAddFormHTML => $TicketChecklistAddFormHTML,

            ArticleList              => $Articles,
            ArticleCount             => $ArticleCount,
            ArticleEmptyClass        => $ArticleEmptyClass,
            ArticleFormAction        => 'index.pl',
            AttachmentMaxSizeMB      => $AttachmentMaxSizeMB,
            AttachmentMaxSizeBytes   => $AttachmentMaxSizeBytes,
            ArticleCreateError       => $ArticleCreateError,
            ArticleCreateErrorClass  => $ArticleCreateErrorClass,
            ArticleReplyFormClass    => $ArticleReplyFormClass,
            TicketReplyEmailTemplate => $ReplyEmailTemplate,
            TicketResponseTemplateList => $ResponseTemplateList,
            TicketResponseTemplateCount => scalar @{$ResponseTemplateList},
            HasTicketResponseTemplates => @{$ResponseTemplateList} ? 1 : 0,
            TicketSelectedResponseTemplateID => $ArticleCreateError ? ( $Request->{ResponseTemplateID} || 0 ) : 0,
            TicketSelectedResponseTemplateLanguage => $SelectedResponseTemplateLanguage,
            SubmittedReplyArticleID => $ArticleCreateError ? ( $Request->{ReplyArticleID} || 0 ) : 0,
            SubmittedResponseTemplateAttachmentIDs => $ArticleCreateError ? $Self->_RequestIDList( Value => $Request->{ResponseTemplateAttachmentID} ) : [],
            SubmittedResponseTemplateAttachmentSelection => $ArticleCreateError && $Request->{ResponseTemplateAttachmentSelection} ? 1 : 0,
            ResponseTemplateFieldClass => $ArticleCreateError && ( $Request->{ArticleMode} || '' ) eq 'email' && @{$ResponseTemplateList} ? '' : 'qisutu-hidden',
            TicketStatusOptionsHTML  => $StatusOptionsHTML,
            TicketPriorityOptionsHTML => $PriorityOptionsHTML,
            TicketQueueOptionsHTML   => $QueueOptionsHTML,
            TicketServiceOptionsHTML => $ServiceOptionsHTML,
            TicketClosedStateOptionsHTML => $ClosedStateOptionsHTML,
            SplitQueueOptionsHTML => $SplitQueueOptionsHTML,
            SplitStateOptionsHTML => $SplitStateOptionsHTML,
            SplitPriorityOptionsHTML => $SplitPriorityOptionsHTML,
            SplitActionError      => $SplitActionError,
            SplitActionErrorClass => $SplitActionError ? '' : 'qisutu-hidden',
            SplitDialogOpen       => $SplitActionError ? 'open' : '',
            SplitArticleID        => $SplitActionError ? $SplitArticleID : 0,
            SplitTitle            => $SplitActionError ? ( $Request->{SplitTitle} || '' ) : '',
            SplitOwnerUserID      => $SplitActionError ? ( $Request->{SplitOwnerUserID} || $Ticket->{owner_user_id} || ( $Param{User} || {} )->{user_account_id} || 0 ) : ( $Ticket->{owner_user_id} || ( $Param{User} || {} )->{user_account_id} || 0 ),
            SplitOwnerValue       => $SplitActionError ? ( $Request->{SplitOwnerValue} || $SplitDefaultOwnerValue ) : $SplitDefaultOwnerValue,
            SplitResponsibleUserID => $SplitActionError ? ( $Request->{SplitResponsibleUserID} || 0 ) : ( $Ticket->{responsible_user_id} || 0 ),
            SplitResponsibleValue => $SplitActionError ? ( $Request->{SplitResponsibleValue} || '' ) : $TicketResponsibleAutocompleteValue,
            SplitCopyAttachmentsChecked => !$SplitActionError || $Request->{SplitCopyAttachments} ? 'checked' : '',
            TicketArticleDynamicFieldsHTML => $ArticleDynamicFieldsHTML,
            TicketPriorityDynamicFieldsHTML => $PriorityDynamicFieldsHTML,
            TicketQueueDynamicFieldsHTML => $QueueDynamicFieldsHTML,
            TicketCloseDynamicFieldsHTML => $CloseDynamicFieldsHTML,
            TicketDynamicFieldsDisplayHTML => $TicketDynamicFieldsDisplayHTML,
            HasTicketDynamicFields => $TicketDynamicFieldsDisplayHTML ? 1 : 0,
            TicketFormInformationHTML => $TicketFormInformationHTML,
            HasTicketFormInformation  => $TicketFormInformationHTML ? 1 : 0,
            ArticleTimeAccountingFieldsHTML => $ArticleTimeAccountingFieldsHTML,
            TicketToolPriorityTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{priority},
            TicketToolOwnerTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{owner},
            TicketToolResponsibleTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{responsible},
            TicketToolCustomerTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{customer},
            TicketToolServiceTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{service},
            TicketToolQueueTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{queue},
            TicketToolCloseTimeAccountingFieldsHTML => $ToolTimeAccountingFieldsHTML{close},
            ManualTimeAccountingFieldsHTML => $ManualTimeAccountingFieldsHTML,
            ManualTimeAccountingDescription => $TimeAccountingActionError && ( $Request->{Step} || '' ) eq 'TimeAccountingCreate' ? ( $Request->{TimeAccountingDescription} || '' ) : '',
            TicketTimeAccountingHTML => $TicketTimeAccountingHTML,
            TimeAccountingActionError => $TimeAccountingActionError,
            TimeAccountingActionErrorClass => $TimeAccountingActionError ? '' : 'qisutu-hidden',
            TimeAccountingExpandedAria => $TimeAccountingActionError ? 'true' : 'false',
            TimeAccountingHiddenAttribute => $TimeAccountingActionError ? '' : 'hidden',
            TicketToolError          => $ToolActionError,
            TicketToolErrorClass     => $TicketToolErrorClass,
            TicketToolActive         => $ToolActionActive,
            TicketToolsOverlayClass  => $TicketToolsOverlayClass,
            TicketToolsOverlayAriaHidden => $TicketToolsOverlayAriaHidden,
            TicketToolsButtonExpanded => $TicketToolsButtonExpanded,
            DefaultArticleMode       => $ArticleCreateError ? ( $Request->{ArticleMode} || 'note' ) : 'note',
            SubmittedArticleSubject => $ArticleCreateError ? ( $Request->{Subject} || '' ) : '',
            SubmittedArticleBody    => $ArticleCreateError ? ( $Request->{Body} || '' ) : '',
            SubmittedReplyTo        => $ArticleCreateError ? ( $Request->{ReplyTo} || '' ) : '',
            SubmittedReplyCc        => $ArticleCreateError ? ( $Request->{ReplyCc} || '' ) : '',
            SubmittedPendingUntil   => $ArticleCreateError ? ( $Request->{PendingUntil} || '' ) : '',
            ReplyFormDefaultTitle    => $ArticleCreateError && ( $Request->{ArticleMode} || '' ) eq 'email'
                ? 'Translate:TicketReplyCustomer'
                : $ArticleCreateError && ( $Request->{ArticleMode} || '' ) eq 'forward'
                    ? 'Translate:TicketToolForward'
                    : 'Translate:TicketCreateNote',
            ReplySubmitDefaultLabel  => $ArticleCreateError && ( $Request->{ArticleMode} || '' ) eq 'email'
                ? 'Translate:TicketArticleSendEmail'
                : $ArticleCreateError && ( $Request->{ArticleMode} || '' ) eq 'forward'
                    ? 'Translate:TicketToolForwardSend'
                    : 'Translate:TicketArticleSaveNote',
        },
    };
}


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

    my $Data = $Param{Data} || { items => [] };
    my $Body = encode_json($Data);

    return {
        Response => $Self->{Output}->Response(
            ContentType => 'application/json; charset=UTF-8',
            Body        => $Body,
        ),
    };
}

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

    my $Query = $Self->_Trim( $Param{Query} );

    return { items => [] } if length($Query) < 2;

    my $Like = '%' . $Query . '%';
    my $Rows = $Self->{DB}->SelectAll(
        'SELECT
            id,
            login,
            email,
            firstname,
            lastname
         FROM user_account
         WHERE account_type = ?
            AND is_active = 1
            AND is_system_user = 0
            AND (
                login LIKE ?
                OR email LIKE ?
                OR firstname LIKE ?
                OR lastname LIKE ?
                OR CONCAT(firstname, " ", lastname) LIKE ?
            )
         ORDER BY login ASC
         LIMIT 20',
        'agent',
        $Like,
        $Like,
        $Like,
        $Like,
        $Like,
    ) || [];

    my @Items;

    for my $Row ( @{$Rows} ) {
        my $Name = $Self->_UserName( User => $Row ) || $Row->{login} || '';
        my $Email = $Row->{email} || '';
        my $Login = $Row->{login} || '';
        my $Label = $Name;

        if ( $Login && $Login ne $Name ) {
            $Label .= ' (' . $Login . ')';
        }

        if ($Email) {
            $Label .= ' <' . $Email . '>';
        }

        push @Items, {
            id          => 0 + ( $Row->{id} || 0 ),
            label       => $Label,
            description => $Email || $Login,
        };
    }

    return { items => \@Items };
}

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

    my $Query = $Self->_Trim( $Param{Query} );

    return { items => [] } if length($Query) < 2;

    my $Like = '%' . $Query . '%';
    my $Rows = $Self->{DB}->SelectAll(
        'SELECT
            cu.id,
            cu.customer_id,
            c.customer_number,
            c.name AS customer_name,
            ua.login,
            ua.email,
            ua.firstname,
            ua.lastname
         FROM customer_user cu
         INNER JOIN customer c
            ON c.id = cu.customer_id
         INNER JOIN user_account ua
            ON ua.id = cu.user_account_id
         WHERE cu.active = 1
            AND c.active = 1
            AND ua.is_active = 1
            AND ua.account_type = ?
            AND (
                c.customer_number LIKE ?
                OR c.name LIKE ?
                OR ua.login LIKE ?
                OR ua.email LIKE ?
                OR ua.firstname LIKE ?
                OR ua.lastname LIKE ?
                OR CONCAT(ua.firstname, " ", ua.lastname) LIKE ?
            )
         ORDER BY c.name ASC, ua.login ASC
         LIMIT 20',
        'customer',
        $Like,
        $Like,
        $Like,
        $Like,
        $Like,
        $Like,
        $Like,
    ) || [];

    my @Items;

    for my $Row ( @{$Rows} ) {
        my $Name = $Self->_UserName( User => $Row ) || $Row->{login} || '';
        my $Email = $Row->{email} || '';
        my $CustomerName = $Row->{customer_name} || '';
        my $CustomerNumber = $Row->{customer_number} || '';
        my $Label = $Name;

        if ($Email) {
            $Label .= ' <' . $Email . '>';
        }

        if ($CustomerName) {
            $Label .= ' — ' . $CustomerName;
        }

        push @Items, {
            id          => 0 + ( $Row->{id} || 0 ),
            customer_id => 0 + ( $Row->{customer_id} || 0 ),
            label       => $Label,
            description => join( ' · ', grep {$_} ( $CustomerNumber, $CustomerName, $Email ) ),
        };
    }

    return { items => \@Items };
}


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

    my $Article  = $Param{Article} || {};
    my $Language = $Param{Language} || 'en';
    my $Subject  = $Article->{subject} || 'Ticket';

    $Subject =~ s{\r|\n}{ }g;
    $Subject =~ s{\s+}{ }g;
    $Subject =~ s{\A\s+|\s+\z}{}g;
    $Subject = 'Fwd: ' . $Subject if $Subject !~ m{\A\s*Fwd\s*:}i;

    my $BodyTemplate = '<p><br></p>';
    $BodyTemplate .= '<div style="height:14px; line-height:14px; font-size:14px; margin:0; padding:0;">&nbsp;</div>';
    $BodyTemplate .= $Self->_ForwardHistoryArticleHTML(
        Article  => $Article,
        Language => $Language,
    );

    return {
        Subject      => $Subject,
        BodyTemplate => $BodyTemplate,
    };
}

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

    my $Ticket             = $Param{Ticket} || {};
    my $Article            = $Param{Article} || {};
    my $QueueReplyTemplate = $Param{QueueReplyTemplate} || '';
    my $Language           = $Param{Language} || 'en';

    my $Channel    = lc( $Article->{channel} || '' );
    my $SenderType = lc( $Article->{sender_type} || '' );
    my $Visibility = lc( $Article->{visibility} || '' );

    # Customer requests submitted through the customer portal or a public
    # ticket form keep their real source channel "web".  They must still be
    # answerable by e-mail in the agent zoom.  Never offer a customer reply
    # for internal articles or for arbitrary agent-created web articles.
    my $Allowed = $Visibility ne 'agent'
        && (
            $Channel eq 'email'
            || ( $Channel eq 'web' && $SenderType eq 'customer' )
        )
        ? 1
        : 0;

    return {
        Allowed      => 0,
        To           => '',
        Cc           => '',
        Subject      => '',
        BodyTemplate => '',
    } if !$Allowed;

    my $To = $Self->_ArticleReplyRecipient(
        Ticket  => $Ticket,
        Article => $Article,
    );

    # Without a customer address an e-mail reply cannot be delivered.  Keep
    # forwarding available, but do not expose a reply action that is bound to
    # fail on submit.
    return {
        Allowed      => 0,
        To           => '',
        Cc           => '',
        Subject      => '',
        BodyTemplate => '',
    } if !$To;

    my $Subject = $Self->_ArticleReplySubject( Article => $Article );
    my $BodyTemplate = $Self->_ArticleReplyBodyTemplate(
        Ticket             => $Ticket,
        Article            => $Article,
        QueueReplyTemplate => $QueueReplyTemplate,
        Language           => $Language,
    );

    return {
        Allowed      => 1,
        To           => $To,
        Cc           => '',
        Subject      => $Subject,
        BodyTemplate => $BodyTemplate,
    };
}

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

    my $Ticket  = $Param{Ticket} || {};
    my $Article = $Param{Article} || {};

    if ( ( $Article->{sender_type} || '' ) eq 'customer' && ( $Article->{from_email} || '' ) ) {
        return $Self->_MailAddressBuild(
            Name  => $Article->{from_name} || $Article->{sender_name} || '',
            Email => $Article->{from_email},
        );
    }

    if ( $Ticket->{customer_user_email} ) {
        return $Self->_MailAddressBuild(
            Name  => $Ticket->{customer_user_name} || '',
            Email => $Ticket->{customer_user_email},
        );
    }

    if ( $Article->{from_email} ) {
        return $Self->_MailAddressBuild(
            Name  => $Article->{from_name} || $Article->{sender_name} || '',
            Email => $Article->{from_email},
        );
    }

    return '';
}

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

    my $Name  = $Param{Name} || '';
    my $Email = $Param{Email} || '';

    $Name  =~ s{\r|\n}{ }g;
    $Email =~ s{\r|\n}{ }g;
    $Name  =~ s{<|>}{}g;
    $Name  =~ s{"}{}g;
    $Name  =~ s{\bdefault\b}{}gi;
    $Name  =~ s{\A\s+|\s+\z}{}g;
    $Email =~ s{\A\s+|\s+\z}{}g;

    return '' if !$Email;
    return $Email if !$Name || $Name eq '-';

    return $Name . ' <' . $Email . '>';
}

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

    my $Article = $Param{Article} || {};
    my $Subject = $Article->{subject} || '';

    $Subject =~ s{\r|\n}{ }g;
    $Subject =~ s{\s+}{ }g;
    $Subject =~ s{\A\s+|\s+\z}{}g;
    $Subject ||= 'Ticket';

    return $Subject if $Subject =~ m{\A\s*Re\s*:}i;

    return 'Re: ' . $Subject;
}

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

    my $Article            = $Param{Article} || {};
    my $QueueReplyTemplate = $Param{QueueReplyTemplate} || '';
    my $Language           = $Param{Language} || 'en';

    my $HTML = $QueueReplyTemplate || '<p><br></p>';
    $HTML =~ s{\A\s+|\s+\z}{}g;
    $HTML .= '<div style="height:14px; line-height:14px; font-size:14px; margin:0; padding:0;">&nbsp;</div>';
    $HTML .= $Self->_ArticleReplyQuoteHTML(
        Article  => $Article,
        Language => $Language,
    );

    return $HTML;
}

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

    my $Article  = $Param{Article} || {};
    my $Language = $Param{Language} || 'en';

    my $CreatedAt = $Article->{created_at_display} || $Self->_DateTimeFormat(
        DateTime => $Article->{created_at},
        Language => $Language,
    );

    my $From = $Article->{from_name} || $Article->{sender_name} || $Article->{from_email} || '-';
    if ( $Article->{from_email} && $From !~ m{\Q$Article->{from_email}\E} ) {
        $From .= ' <' . $Article->{from_email} . '>';
    }

    my $Body = $Article->{body_html} || '';
    $Body = $Self->_ForwardMailHTMLNormalize( HTML => $Body );

    my $Header = $Language eq 'de'
        ? 'Am ' . ( $CreatedAt || '-' ) . ' schrieb ' . ( $From || '-' ) . ':'
        : 'On ' . ( $CreatedAt || '-' ) . ', ' . ( $From || '-' ) . ' wrote:';

    my $HTML = '<div style="margin:14px 0 8px 0; padding:0; font-family:Arial, Helvetica, sans-serif; font-size:13px; line-height:1.35; color:#000000;">'
        . $Self->_Escape($Header)
        . '</div>';

    $HTML .= '<blockquote style="border-left:3px solid #4f8df7; margin:0; padding:0 0 0 10px; color:#000000; font-family:Arial, Helvetica, sans-serif; font-size:13px; line-height:1.35;">'
        . $Body
        . '</blockquote>';

    return $HTML;
}

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

    my $Request      = $Param{Request} || {};
    my $User         = $Param{User} || {};
    my $Language     = $Param{Language} || 'en';
    my $TicketObject = $Param{TicketObject};
    my $TicketID     = $Param{TicketID} || 0;
    my $ForwardToRaw = $Request->{ForwardTo} || '';
    my $ForwardCcRaw = $Request->{ForwardCc} || '';
    my $Body         = $Request->{ForwardBody} || '';

    $Body = $Self->_ForwardMailHTMLNormalize( HTML => $Body );

    if ( !$TicketObject || $TicketID !~ m{\A\d+\z} || !$TicketID ) {
        return {
            Success => 0,
            Error   => 'Translate:TicketToolForwardSendFailed',
        };
    }

    my $ToCheck = $Self->_EmailRecipientsParse( Value => $ForwardToRaw, Required => 1 );
    if ( !$ToCheck->{Valid} ) {
        return {
            Success => 0,
            Error   => $ToCheck->{Error} || 'Translate:TicketToolForwardRecipientRequired',
        };
    }

    my $CcCheck = $Self->_EmailRecipientsParse( Value => $ForwardCcRaw, Required => 0 );
    if ( !$CcCheck->{Valid} ) {
        return {
            Success => 0,
            Error   => $CcCheck->{Error} || 'Translate:TicketToolForwardRecipientInvalid',
        };
    }

    if ( !$Self->_BodyHasVisibleContent( Body => $Body ) ) {
        return {
            Success => 0,
            Error   => 'Translate:TicketArticleBodyRequired',
        };
    }

    my $Ticket = $TicketObject->TicketGet(
        TicketID => $TicketID,
        User     => $User,
        Language => $Language,
    );

    if ( !$Ticket ) {
        return {
            Success => 0,
            Error   => $TicketObject->Error() || 'Translate:TicketToolForwardSendFailed',
        };
    }

    my ( $FromName, $FromEmail ) = $Self->_ReplySender(
        TicketID => $TicketID,
        User     => $User,
    );

    my $Precheck = $Self->_EmailSendPrecheck(
        FromEmail => $FromEmail,
        ToEmail   => $ToCheck->{Header},
    );

    if ( !$Precheck->{Success} ) {
        return {
            Success => 0,
            Error   => $Precheck->{Message} || 'Translate:TicketToolForwardSendFailed',
        };
    }

    my $RawSubject = $Self->_ForwardSubject(
        Ticket   => $Ticket,
        Language => $Language,
    );

    my $MailSubject = $RawSubject;
    if ( $TicketObject->can('TicketSubjectBuild') ) {
        $MailSubject = $TicketObject->TicketSubjectBuild(
            TicketID => $TicketID,
            Subject  => $RawSubject,
        );
    }

    $Self->{DB}->BeginWork() || return {
        Success => 0,
        Error   => 'Translate:TicketToolForwardSendFailed',
    };

    my $ArticleID = $TicketObject->ArticleCreate(
        TicketID        => $TicketID,
        User            => $User,
        Subject         => $RawSubject,
        Body            => $Body,
        Channel         => 'email',
        SenderType      => 'agent',
        FromName        => $FromName,
        FromEmail       => $FromEmail,
        ToName          => '',
        ToEmail         => $ToCheck->{Header},
        Cc              => $CcCheck->{Header},
        ContentType     => 'text/html',
        Visibility      => 'agent',
        Language        => $Language,
        CreatedByUserID => $User->{user_account_id},
        ChangedByUserID => $User->{user_account_id},
        SkipNotification => 1,
    );

    if ( !$ArticleID ) {
        my $Error = $TicketObject->Error() || 'Translate:TicketArticleCreateFailed';
        $Self->{DB}->Rollback();
        return {
            Success => 0,
            Error   => $Error,
        };
    }

    my $SendResult = $Self->_EmailSend(
        TicketID   => $TicketID,
        ArticleID  => $ArticleID,
        FromName  => $FromName,
        FromEmail => $FromEmail,
        ToName    => '',
        ToEmail   => $ToCheck->{Header},
        Cc        => $CcCheck->{Header},
        Subject   => $MailSubject,
        Body      => $Body,
    );

    if ( !$SendResult->{Success} ) {
        $Self->{DB}->Rollback();
        return {
            Success => 0,
            Error   => $SendResult->{Message} || 'Translate:TicketToolForwardSendFailed',
        };
    }

    if ( !$Self->{DB}->Commit() ) {
        $Self->{DB}->Rollback();
        return {
            Success => 0,
            Error   => 'Translate:TicketToolForwardSendFailed',
        };
    }

    return {
        Success => 1,
    };
}

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

    my $Ticket = $Param{Ticket} || {};
    my $Title  = $Ticket->{title} || $Ticket->{ticket_number} || '';
    $Title =~ s{\r|\n}{ }g;
    $Title =~ s{\s+}{ }g;
    $Title =~ s{\A\s+|\s+\z}{}g;
    $Title ||= 'Ticket';

    return 'Fwd: ' . $Title;
}

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

    my $Ticket   = $Param{Ticket} || {};
    my $Articles = ref $Param{Articles} eq 'ARRAY' ? $Param{Articles} : [];
    my $Language = $Param{Language} || 'en';
    my @VisibleArticles;

    for my $Article ( @{$Articles} ) {
        my $Visibility = lc( $Article->{visibility} || '' );
        next if $Visibility eq 'agent';
        push @VisibleArticles, $Article;
    }

    my $HistoryTitle = $Language eq 'de' ? 'Weitergeleiteter Ticketverlauf' : 'Forwarded ticket history';
    my $TicketLabel  = $Language eq 'de' ? 'Ticket' : 'Ticket';
    my $EmptyText    = $Language eq 'de'
        ? 'Es gibt noch keine für Kunden sichtbaren Artikel in diesem Ticket.'
        : 'There are no customer-visible articles in this ticket yet.';

    my $TicketNumber = $Ticket->{ticket_number} || '';
    my $TicketTitle  = $Ticket->{title} || '';

    my $HTML = '<p><br></p>';
    $HTML .= '<div style="margin-top:18px; padding-top:12px; border-top:1px solid #b8c2cc; font-family:Arial, Helvetica, sans-serif; font-size:13px; line-height:1.45; color:#000000;">';
    $HTML .= '<div style="margin:0 0 10px 0; font-weight:bold; color:#333333;">----- ' . $Self->_Escape($HistoryTitle) . ' -----</div>';
    $HTML .= '<div style="margin:0 0 14px 0; color:#333333;"><strong>'
        . $Self->_Escape($TicketLabel)
        . ':</strong> '
        . $Self->_Escape($TicketNumber);

    if ($TicketTitle) {
        $HTML .= ' &ndash; ' . $Self->_Escape($TicketTitle);
    }

    $HTML .= '</div>';

    if (!@VisibleArticles) {
        $HTML .= '<div style="margin:0 0 12px 0; color:#333333;">' . $Self->_Escape($EmptyText) . '</div>';
        $HTML .= '</div>';
        return $HTML;
    }

    for my $Article ( @VisibleArticles ) {
        $HTML .= $Self->_ForwardHistoryArticleHTML(
            Article  => $Article,
            Language => $Language,
        );
    }

    $HTML .= '</div>';

    return $HTML;
}

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

    my $Article  = $Param{Article} || {};
    my $Language = $Param{Language} || 'en';

    my %Label = $Language eq 'de'
        ? (
            OriginalData => 'Ursprüngliche Daten',
            Date         => 'Datum',
            From         => 'Von',
            To           => 'An',
            Cc           => 'CC',
            Subject      => 'Betreff',
            Channel      => 'Kanal',
            Attachments  => 'Anhänge',
        )
        : (
            OriginalData => 'Original data',
            Date         => 'Date',
            From         => 'From',
            To           => 'To',
            Cc           => 'CC',
            Subject      => 'Subject',
            Channel      => 'Channel',
            Attachments  => 'Attachments',
        );

    my $CreatedAt = $Article->{created_at_display} || $Self->_DateTimeFormat(
        DateTime => $Article->{created_at},
        Language => $Language,
    );

    my $From = $Article->{from_name} || $Article->{sender_name} || $Article->{from_email} || '-';
    if ( $Article->{from_email} && $From !~ m{\Q$Article->{from_email}\E} ) {
        $From .= ' <' . $Article->{from_email} . '>';
    }

    my $To = $Article->{to_name} || $Article->{to_email} || $Article->{recipient} || '-';
    if ( $Article->{to_email} && $To !~ m{\Q$Article->{to_email}\E} ) {
        $To .= ' <' . $Article->{to_email} . '>';
    }

    my $Body        = $Article->{body_html} || '';
    my $Subject     = $Article->{subject} || '-';
    my $Channel     = $Article->{channel} || '-';
    my $Cc          = $Article->{cc} || '';
    my $Attachments = ref $Article->{attachments} eq 'ARRAY' ? $Article->{attachments} : [];

    my $HTML = '<div style="margin:0 0 22px 0; padding:0; font-family:Arial, Helvetica, sans-serif; font-size:13px; line-height:1.45; color:#000000;">';
    $HTML .= '<div style="background-color:#dddddd; border:1px solid #bdbdbd; padding:8px 10px; margin:0 0 12px 0; color:#000000;">';
    $HTML .= '<div style="font-weight:bold; margin:0 0 6px 0;">-----' . $Self->_Escape( $Label{OriginalData} ) . '-----</div>';
    $HTML .= $Self->_ForwardMetaLineHTML( Label => $Label{Date},    Value => $CreatedAt || '-' );
    $HTML .= $Self->_ForwardMetaLineHTML( Label => $Label{From},    Value => $From );
    $HTML .= $Self->_ForwardMetaLineHTML( Label => $Label{To},      Value => $To );
    $HTML .= $Self->_ForwardMetaLineHTML( Label => $Label{Cc},      Value => $Cc ) if $Cc;
    $HTML .= $Self->_ForwardMetaLineHTML( Label => $Label{Subject}, Value => $Subject );
    $HTML .= $Self->_ForwardMetaLineHTML( Label => $Label{Channel}, Value => $Channel );

    if ( @{$Attachments} ) {
        my @Names = map { $_->{filename} || '' } @{$Attachments};
        @Names = grep {$_} @Names;
        if (@Names) {
            $HTML .= $Self->_ForwardMetaLineHTML(
                Label => $Label{Attachments},
                Value => join( ', ', @Names ),
            );
        }
    }

    $HTML .= '</div>';
    $HTML .= '<div style="border-left:3px solid #4f8df7; margin:0; padding:0 0 0 10px; color:#000000;">';
    $HTML .= $Body;
    $HTML .= '</div>';
    $HTML .= '</div>';

    return $HTML;
}

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

    my $Label = $Param{Label} || '';
    my $Value = $Param{Value} || '-';

    return '<div style="margin:0 0 2px 0;"><strong>'
        . $Self->_Escape($Label)
        . ':</strong> '
        . $Self->_Escape($Value)
        . '</div>';
}

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

    my $HTML = $Param{HTML} || '';

    return '' if !$HTML;

    # CKEditor erzeugt bei Enter normalerweise <p>...</p>. Viele Mailprogramme
    # rendern <p> mit eigenen Standard-Abständen. Deshalb werden die Absätze vor
    # dem Versand mit Inline-Styles versehen, damit einfache Zeilen auch beim
    # Empfänger einfache Zeilen bleiben.
    $HTML =~ s{<p\b([^>]*)>}{ $Self->_ForwardOpeningTagNormalize( Tag => 'p', Attributes => $1 ) }egis;

    # Leere CKEditor-Absätze sollen als echte, kontrollierte Leerzeile erhalten
    # bleiben, aber nicht mit Mailclient-Default-Margins größer werden.
    $HTML =~ s{<p\s+style="margin:0; padding:0; line-height:1\.35;"\s*>\s*(?:<br\s*/?>|&nbsp;|\s)*</p>}{<div style="height:12px; line-height:12px; font-size:12px; margin:0; padding:0;">&nbsp;</div>}gis;

    return $HTML;
}

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

    my $Tag        = $Param{Tag} || 'p';
    my $Attributes = defined $Param{Attributes} ? $Param{Attributes} : '';
    my $InlineCSS  = 'margin:0; padding:0; line-height:1.35;';

    if ( $Attributes =~ m{\bstyle\s*=\s*(['"])(.*?)\1}is ) {
        my $Quote = $1;
        my $Style = $2 || '';
        $Style =~ s{\A\s+|\s+\z}{}g;
        $Style = $InlineCSS . ( $Style ? ' ' . $Style : '' );
        $Attributes =~ s{\bstyle\s*=\s*(['"])(.*?)\1}{style=$Quote$Style$Quote}is;
        return '<' . $Tag . $Attributes . '>';
    }

    return '<' . $Tag . $Attributes . ' style="' . $InlineCSS . '">';
}

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

    my $Value    = $Param{Value} || '';
    my $Required = $Param{Required} ? 1 : 0;

    $Value =~ s{\r|\n}{ }g;
    $Value =~ s{\A\s+|\s+\z}{}g;

    if ( !$Value ) {
        return $Required
            ? { Valid => 0, Error => 'Translate:TicketToolForwardRecipientRequired' }
            : { Valid => 1, Header => '', Emails => [] };
    }

    my @Parts = grep { length } map {
        my $Part = $_;
        $Part =~ s{\A\s+|\s+\z}{}g;
        $Part;
    } split m{[;,]}, $Value;

    @Parts = ($Value) if !@Parts;

    my @Emails;
    my %Seen;

    for my $Part (@Parts) {
        my $Email = $Part;

        if ( $Email =~ m{<([^>]+)>} ) {
            $Email = $1;
        }

        $Email =~ s{\A\s+|\s+\z}{}g;

        if ( $Email !~ m{\A[A-Z0-9._%+\-]+\@[A-Z0-9.\-]+\.[A-Z]{2,}\z}i ) {
            return {
                Valid => 0,
                Error => 'Translate:TicketToolForwardRecipientInvalid',
            };
        }

        my $Key = lc $Email;
        next if $Seen{$Key}++;
        push @Emails, $Email;
    }

    if ( $Required && !@Emails ) {
        return {
            Valid => 0,
            Error => 'Translate:TicketToolForwardRecipientRequired',
        };
    }

    return {
        Valid  => 1,
        Header => join( ', ', @Emails ),
        Emails => \@Emails,
    };
}

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

    my $Request      = $Param{Request} || {};
    my $User         = $Param{User} || {};
    my $Language     = $Param{Language} || 'en';
    my $TicketObject = $Param{TicketObject};
    my $TicketID     = $Param{TicketID} || 0;
    my $Action       = $Self->_Trim( $Request->{ToolAction} );
    my $Body         = $Request->{ToolArticleBody} || '';

    my %Allowed = map { $_ => 1 } qw(priority owner responsible customer service queue close);

    if ( !$Allowed{$Action} ) {
        return {
            Success    => 0,
            ActiveTool => 'priority',
            Error      => 'Translate:TicketToolInvalidAction',
        };
    }

    if ( !$TicketObject || $TicketID !~ m{\A\d+\z} || !$TicketID ) {
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };
    }

    if ( $Action ne 'service' && !$Self->_BodyHasVisibleContent( Body => $Body ) ) {
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolArticleBodyRequired',
        };
    }

    my $TimeObject = QisutuTimeAccounting->new(
        Config => $Self->{Config}, DB => $Self->{DB}, Output => $Self->{Output},
    );
    my $TimeInput = $TimeObject->InputParse( Request => $Request );
    if (!$TimeInput) {
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => $TimeObject->Error() || 'Translate:TimeAccountingCreateFailed',
        };
    }

    my $TicketBefore = $TicketObject->TicketGet(
        TicketID => $TicketID,
        User     => $User,
        Language => $Language,
    );

    if ( !$TicketBefore ) {
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => $TicketObject->Error() || 'Translate:TicketToolUpdateFailed',
        };
    }

    if ( ( $TicketBefore->{state_name} || '' ) eq 'merged' ) {
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketMergedReadOnly',
        };
    }

    my $Summary = '';
    my $UpdateOK;
    my $DynamicFieldQueueID = $Action eq 'queue'
        ? ( $Request->{QueueID} || 0 )
        : ( $TicketBefore->{queue_id} || 0 );
    my $SaveDynamicFields = $Action eq 'priority' || $Action eq 'queue' || $Action eq 'close' ? 1 : 0;
    my $ToolDynamicFieldObject = $SaveDynamicFields ? $Self->_DynamicFieldObject() : undef;

    if ( $SaveDynamicFields && $DynamicFieldQueueID ) {
        if ( !$ToolDynamicFieldObject || !$ToolDynamicFieldObject->TicketValueValidate(
            QueueID  => $DynamicFieldQueueID,
            Request  => $Request,
            Language => $Language,
        ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => $ToolDynamicFieldObject
                    ? ( $ToolDynamicFieldObject->Error() || 'Translate:TicketDynamicFieldInvalid' )
                    : 'Translate:AdminDynamicFieldLoadFailed',
            };
        }
    }

    if ( $Action eq 'priority' ) {
        my $PriorityID = $Request->{PriorityID} || 0;
        my $Priority = $Self->_PriorityGet( PriorityID => $PriorityID );

        if (!$Priority) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{priority_id} || 0 ) == ( $Priority->{id} || 0 ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        $Summary = $Self->_ToolSummary(
            Language => $Language,
            Action   => $Action,
            OldValue => $Self->_PriorityDisplayName(
                Name     => $TicketBefore->{priority_name},
                Language => $Language,
            ) || '-',
            NewValue => $Self->_PriorityDisplayName(
                Name     => $Priority->{name},
                Language => $Language,
            ) || '-',
        );

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketPriorityUpdate(
            TicketID        => $TicketID,
            PriorityID      => $Priority->{id},
            User            => $User,
            ChangedByUserID => $User->{user_account_id},
        );
    }
    elsif ( $Action eq 'owner' ) {
        my $OwnerUserID = $Request->{OwnerUserID} || 0;
        my $Agent = $Self->_AgentGet( UserID => $OwnerUserID );

        if (!$Agent) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{owner_user_id} || 0 ) == ( $Agent->{id} || 0 ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        $Summary = $Self->_ToolSummary(
            Language => $Language,
            Action   => $Action,
            OldValue => $TicketBefore->{owner_name} || '-',
            NewValue => $Self->_UserName( User => $Agent ) || $Agent->{login} || '-',
        );

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketOwnerUpdate(
            TicketID        => $TicketID,
            OwnerUserID     => $Agent->{id},
            User            => $User,
            ChangedByUserID => $User->{user_account_id},
        );
    }
    elsif ( $Action eq 'responsible' ) {
        my $ResponsibleUserID = $Request->{ResponsibleUserID} || 0;
        my $Agent = $Self->_AgentGet( UserID => $ResponsibleUserID );

        if (!$Agent) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{responsible_user_id} || 0 ) == ( $Agent->{id} || 0 ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        $Summary = $Self->_ToolSummary(
            Language => $Language,
            Action   => $Action,
            OldValue => $TicketBefore->{responsible_name} || '-',
            NewValue => $Self->_UserName( User => $Agent ) || $Agent->{login} || '-',
        );

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketResponsibleUpdate(
            TicketID         => $TicketID,
            ResponsibleUserID => $Agent->{id},
            User             => $User,
            ChangedByUserID  => $User->{user_account_id},
        );
    }
    elsif ( $Action eq 'customer' ) {
        my $CustomerUserID = $Request->{CustomerUserID} || 0;
        my $CustomerUser = $Self->_CustomerUserGet( CustomerUserID => $CustomerUserID );

        if (!$CustomerUser) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{customer_user_id} || 0 ) == ( $CustomerUser->{id} || 0 ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        my $OldValue = join( ' / ', grep {$_ && $_ ne '-'} ( $TicketBefore->{customer_name} || '-', $TicketBefore->{customer_user_name} || '-' ) ) || '-';
        my $NewValue = join( ' / ', grep {$_} ( $CustomerUser->{customer_name} || '', $Self->_UserName( User => $CustomerUser ) || $CustomerUser->{login} || '' ) ) || '-';

        $Summary = $Self->_ToolSummary(
            Language => $Language,
            Action   => $Action,
            OldValue => $OldValue,
            NewValue => $NewValue,
        );

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketCustomerUserUpdate(
            TicketID        => $TicketID,
            CustomerUserID  => $CustomerUser->{id},
            User            => $User,
            ChangedByUserID => $User->{user_account_id},
        );
    }
    elsif ( $Action eq 'service' ) {
        my $ServiceID = defined $Request->{ServiceID} ? $Request->{ServiceID} : 0;

        if ( $ServiceID !~ m{\A\d+\z} ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{service_id} || 0 ) == $ServiceID ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        my $NewValue = $Language eq 'de' ? 'Kein Service / Queue-Eskalation' : 'No service / queue escalation';
        if ($ServiceID) {
            my $ServiceObject = QisutuService->new(
                Config => $Self->{Config},
                DB     => $Self->{DB},
            );
            my $Resolved = $ServiceObject->SLAResolve(
                CustomerID => $TicketBefore->{customer_id},
                ServiceID  => $ServiceID,
            );
            if ( !$Resolved ) {
                return {
                    Success    => 0,
                    ActiveTool => $Action,
                    Error      => $ServiceObject->Error() || 'Translate:TicketServiceNotAvailable',
                };
            }
            $NewValue = ( $Resolved->{service_name} || '-' ) . ' / ' . ( $Resolved->{sla_name} || '-' );
        }

        my $OldValue = ( $TicketBefore->{service_id} || 0 )
            ? ( ( $TicketBefore->{service_name} || '-' ) . ' / ' . ( $TicketBefore->{sla_name_display} || '-' ) )
            : ( $Language eq 'de' ? 'Kein Service / Queue-Eskalation' : 'No service / queue escalation' );

        $Summary = $Self->_ToolSummary(
            Language => $Language,
            Action   => $Action,
            OldValue => $OldValue,
            NewValue => $NewValue,
        );

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketServiceUpdate(
            TicketID        => $TicketID,
            ServiceID       => $ServiceID,
            User            => $User,
            ChangedByUserID => $User->{user_account_id},
        );
    }
    elsif ( $Action eq 'queue' ) {
        my $QueueID = $Request->{QueueID} || 0;
        my $Queue = $Self->_QueueGet( QueueID => $QueueID );

        if (!$Queue) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{queue_id} || 0 ) == ( $Queue->{id} || 0 ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        $Summary = $Self->_ToolSummary(
            Language => $Language,
            Action   => $Action,
            OldValue => $TicketBefore->{queue_full_name} || $TicketBefore->{queue_name} || '-',
            NewValue => $Queue->{full_name} || $Queue->{name} || '-',
        );

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketQueueUpdate(
            TicketID        => $TicketID,
            QueueID         => $Queue->{id},
            User            => $User,
            ChangedByUserID => $User->{user_account_id},
        );
    }
    elsif ( $Action eq 'close' ) {
        my $ClosedStateID = $Request->{ClosedStateID} || 0;
        my $ClosedState = $Self->_ClosedStateGet( StateID => $ClosedStateID );

        if (!$ClosedState) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolSelectionRequired',
            };
        }

        if ( ( $TicketBefore->{state_id} || 0 ) == ( $ClosedState->{id} || 0 ) ) {
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => 'Translate:TicketToolNoChange',
            };
        }

        $Self->{DB}->BeginWork() || return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };

        $UpdateOK = $TicketObject->TicketCloseUpdate(
            TicketID        => $TicketID,
            StateID         => $ClosedState->{id},
            User            => $User,
            ChangedByUserID => $User->{user_account_id},
        );
    }

    if (!$UpdateOK) {
        my $Error = $TicketObject->Error() || 'Translate:TicketToolUpdateFailed';
        if ( $Error eq 'Translate:TicketChecklistCloseBlocked' ) {
            $Error = $Self->_ChecklistCloseError(
                Items    => $TicketObject->LastChecklistOpenItems(),
                Language => $Language,
            );
        }
        $Self->{DB}->Rollback();
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => $Error,
        };
    }

    if ($SaveDynamicFields) {
        if ( !$ToolDynamicFieldObject->TicketValueSave(
            TicketID        => $TicketID,
            QueueID         => $DynamicFieldQueueID,
            Request         => $Request,
            Language        => $Language,
            ChangedByUserID => $User->{user_account_id},
        ) ) {
            my $Error = $ToolDynamicFieldObject->Error() || 'Translate:TicketDynamicFieldSaveFailed';
            $Self->{DB}->Rollback();
            return {
                Success    => 0,
                ActiveTool => $Action,
                Error      => $Error,
            };
        }
    }

    my $ArticleID = $TicketObject->ArticleCreate(
        TicketID        => $TicketID,
        User            => $User,
        Subject         => $Self->_ToolArticleSubject( Language => $Language, Action => $Action ),
        Body            => $Self->_ToolArticleBody(
            Summary        => $Summary,
            Body           => $Body,
            IncludeSummary => ( $Action eq 'close' ? 0 : 1 ),
        ),
        Channel         => 'note',
        SenderType      => 'agent',
        FromName        => $Self->_UserName( User => $User ),
        FromEmail       => $User->{email} || '',
        ToName          => '',
        ToEmail         => '',
        ContentType     => 'text/html',
        Visibility      => 'agent',
        Language        => $Language,
        CreatedByUserID => $User->{user_account_id},
        ChangedByUserID => $User->{user_account_id},
        SkipNotification => 1,
    );

    if (!$ArticleID) {
        my $Error = $TicketObject->Error() || 'Translate:TicketArticleCreateFailed';
        $Self->{DB}->Rollback();
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => $Error,
        };
    }

    if ( ( $TimeInput->{DurationMinutes} || 0 ) > 0 ) {
        if ( !$TimeObject->EntryCreate(
            TicketID => $TicketID, TicketArticleID => $ArticleID,
            AgentUserID => $User->{user_account_id}, ActivityTypeID => $TimeInput->{ActivityTypeID},
            DurationMinutes => $TimeInput->{DurationMinutes}, Billable => $TimeInput->{Billable},
            Source => 'ticket_tool_' . $Action,
            Description => $Self->_ToolArticleSubject( Language => $Language, Action => $Action ),
            CreatedByUserID => $User->{user_account_id},
            TransactionActive => 1,
        ) ) {
            my $Error = $TimeObject->Error() || 'Translate:TimeAccountingCreateFailed';
            $Self->{DB}->Rollback();
            return { Success => 0, ActiveTool => $Action, Error => $Error };
        }
    }

    if ( !$Self->{DB}->Commit() ) {
        $Self->{DB}->Rollback();
        return {
            Success    => 0,
            ActiveTool => $Action,
            Error      => 'Translate:TicketToolUpdateFailed',
        };
    }

    return {
        Success    => 1,
        ActiveTool => $Action,
    };
}

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

    my $PriorityID = $Param{PriorityID} || 0;
    return if $PriorityID !~ m{\A\d+\z} || !$PriorityID;

    return $Self->{DB}->SelectRow(
        'SELECT id, name, priority_value
         FROM ticket_priority
         WHERE id = ?
            AND active = 1
         LIMIT 1',
        $PriorityID,
    );
}

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

    my $UserID = $Param{UserID} || 0;
    return if $UserID !~ m{\A\d+\z} || !$UserID;

    return $Self->{DB}->SelectRow(
        'SELECT id, login, email, firstname, lastname
         FROM user_account
         WHERE id = ?
            AND account_type = ?
            AND is_active = 1
            AND is_system_user = 0
         LIMIT 1',
        $UserID,
        'agent',
    );
}

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

    my $CustomerUserID = $Param{CustomerUserID} || 0;
    return if $CustomerUserID !~ m{\A\d+\z} || !$CustomerUserID;

    return $Self->{DB}->SelectRow(
        'SELECT
            cu.id,
            cu.customer_id,
            c.customer_number,
            c.name AS customer_name,
            ua.login,
            ua.email,
            ua.firstname,
            ua.lastname
         FROM customer_user cu
         INNER JOIN customer c
            ON c.id = cu.customer_id
         INNER JOIN user_account ua
            ON ua.id = cu.user_account_id
         WHERE cu.id = ?
            AND cu.active = 1
            AND c.active = 1
            AND ua.is_active = 1
            AND ua.account_type = ?
         LIMIT 1',
        $CustomerUserID,
        'customer',
    );
}

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

    my $QueueID = $Param{QueueID} || 0;
    return if $QueueID !~ m{\A\d+\z} || !$QueueID;

    return $Self->{DB}->SelectRow(
        'SELECT id, name, full_name
         FROM ticket_queue
         WHERE id = ?
            AND active = 1
         LIMIT 1',
        $QueueID,
    );
}

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

    my $StateID = $Param{StateID} || 0;
    return if $StateID !~ m{\A\d+\z} || !$StateID;

    return $Self->{DB}->SelectRow(
        'SELECT id, name, state_type
         FROM ticket_state
         WHERE id = ?
            AND active = 1
            AND state_type = ?
         LIMIT 1',
        $StateID,
        'closed',
    );
}

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

    my $Language       = $Param{Language} || 'en';
    my $CurrentStateID = $Param{CurrentStateID} || 0;
    my $States = $Self->{DB}->SelectAll(
        'SELECT id, name, state_type
         FROM ticket_state
         WHERE active = 1
            AND state_type = ?
         ORDER BY sort_order ASC, id ASC',
        'closed',
    ) || [];

    my $SelectedID = 0;

    for my $State ( @{$States} ) {
        if ( $CurrentStateID && ( $State->{id} || 0 ) == $CurrentStateID ) {
            $SelectedID = $State->{id};
            last;
        }
    }

    $SelectedID = $States->[0]->{id} if !$SelectedID && @{$States};

    my $HTML = '';

    for my $State ( @{$States} ) {
        my $Selected = $SelectedID && ( $State->{id} || 0 ) == $SelectedID ? ' selected' : '';
        my $Label = $Self->_TicketStateText(
            State    => $State->{name},
            Language => $Language,
        );

        $HTML .= '<option value="' . $Self->_Escape( $State->{id} ) . '"' . $Selected . '>'
            . $Self->_Escape( $Label || $State->{name} || '' )
            . '</option>';
    }

    return $HTML;
}

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

    my $CurrentPriorityID = $Param{CurrentPriorityID} || 0;
    my $Language          = $Param{Language} || 'en';
    my $Rows = $Self->{DB}->SelectAll(
        'SELECT id, name, priority_value
         FROM ticket_priority
         WHERE active = 1
         ORDER BY sort_order ASC, priority_value ASC, id ASC'
    ) || [];

    my $HTML = '';

    for my $Row ( @{$Rows} ) {
        my $Selected = $CurrentPriorityID && ( $Row->{id} || 0 ) == $CurrentPriorityID ? ' selected' : '';
        my $Label = $Self->_PriorityDisplayName(
            Name     => $Row->{name},
            Language => $Language,
        );
        $HTML .= '<option value="' . $Self->_Escape( $Row->{id} ) . '"' . $Selected . '>'
            . $Self->_Escape($Label)
            . '</option>';
    }

    return $HTML;
}

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

    my $Name     = $Param{Name} || '';
    my $Language = $Param{Language} || 'en';
    my $Key      = lc $Name;

    $Key =~ s{\A\s+}{};
    $Key =~ s{\s+\z}{};
    $Key =~ s{[^a-z0-9]+}{_}g;
    $Key =~ s{\A_+}{};
    $Key =~ s{_+\z}{};

    my %Supported = map { $_ => 1 } qw(
        1_very_low
        2_low
        3_normal
        4_high
        5_very_high
    );

    return $Name if !$Supported{$Key};

    return $Self->{Output}->Translate(
        Key      => 'TicketPriorityName_' . $Key,
        Language => $Language,
    );
}

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

    my $CurrentQueueID = $Param{CurrentQueueID} || 0;
    my $Rows = $Self->{DB}->SelectAll(
        'SELECT id, name, full_name
         FROM ticket_queue
         WHERE active = 1
         ORDER BY sort_order ASC, full_name ASC, id ASC'
    ) || [];

    my $HTML = '';

    for my $Row ( @{$Rows} ) {
        my $Selected = $CurrentQueueID && ( $Row->{id} || 0 ) == $CurrentQueueID ? ' selected' : '';
        my $Label = $Row->{full_name} || $Row->{name} || '';
        $HTML .= '<option value="' . $Self->_Escape( $Row->{id} ) . '"' . $Selected . '>'
            . $Self->_Escape($Label)
            . '</option>';
    }

    return $HTML;
}

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

    my $CurrentQueueID = $Param{CurrentQueueID} || 0;
    my $User           = $Param{User} || {};
    my $Rows = $Self->{DB}->SelectAll(
        'SELECT id, name, full_name
         FROM ticket_queue
         WHERE active = 1
         ORDER BY sort_order ASC, full_name ASC, id ASC'
    ) || [];

    my $HTML = '';
    for my $Row ( @{$Rows} ) {
        next if !$Self->_QueueAccessCheck(
            User       => $User,
            QueueID    => $Row->{id},
            Permission => 'ticket.create',
        );

        my $Selected = $CurrentQueueID && ( $Row->{id} || 0 ) == $CurrentQueueID ? ' selected' : '';
        my $Label = $Row->{full_name} || $Row->{name} || '';
        $HTML .= '<option value="' . $Self->_Escape( $Row->{id} ) . '"' . $Selected . '>'
            . $Self->_Escape($Label)
            . '</option>';
    }

    return $HTML;
}

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

    my $CurrentStateID = $Param{CurrentStateID} || 0;
    my $Language       = $Param{Language} || 'en';
    my $Rows = $Self->{DB}->SelectAll(
        'SELECT id, name, state_type
         FROM ticket_state
         WHERE active = 1
           AND state_type <> ?
           AND name <> ?
         ORDER BY sort_order ASC, id ASC',
        'pending',
        'merged',
    ) || [];

    my $SelectedID = $CurrentStateID;
    if (!$SelectedID) {
        for my $Row ( @{$Rows} ) {
            if ( ( $Row->{name} || '' ) eq 'new' ) {
                $SelectedID = $Row->{id};
                last;
            }
        }
    }
    $SelectedID ||= @{$Rows} ? $Rows->[0]->{id} : 0;

    my $HTML = '';
    for my $Row ( @{$Rows} ) {
        my $Selected = $SelectedID && ( $Row->{id} || 0 ) == $SelectedID ? ' selected' : '';
        my $Label = $Self->_TicketStateText(
            State    => $Row->{name},
            Language => $Language,
        );
        $HTML .= '<option value="' . $Self->_Escape( $Row->{id} ) . '"' . $Selected . '>'
            . $Self->_Escape( $Label || $Row->{name} || '' )
            . '</option>';
    }

    return $HTML;
}

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

    my $CustomerID       = $Param{CustomerID} || 0;
    my $CurrentServiceID = $Param{CurrentServiceID} || 0;
    my $Language         = $Param{Language} || 'en';
    my $ServiceObject = QisutuService->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
    );
    my $Rows = $CustomerID
        ? $ServiceObject->AvailableServiceTreeList(
            CustomerID      => $CustomerID,
            IncludeServiceID => $CurrentServiceID,
        )
        : [];
    $Rows ||= [];

    my $EmptyLabel = $Language eq 'de'
        ? 'Kein Service / Queue-Eskalation verwenden'
        : 'No service / use queue escalation';
    my $HTML = '<option value="0"' . ( !$CurrentServiceID ? ' selected' : '' ) . '>'
        . $Self->_Escape($EmptyLabel) . '</option>';

    for my $Row ( @{$Rows} ) {
        my $ID = $Row->{id} || 0;
        next if !$ID;

        my $Selected = $ID == $CurrentServiceID ? ' selected' : '';
        my $Disabled = $Row->{selectable} ? '' : ' disabled';
        my $Indent = ( "\x{00A0}\x{00A0}" x ( $Row->{depth} || 0 ) );
        my $Name = $Row->{name} || $Row->{full_name} || '';
        my $FallbackLabel = $Indent . $Name;
        $FallbackLabel .= ' — ' . $Row->{sla_name} if $Row->{sla_name};

        $HTML .= '<option value="' . $Self->_Escape($ID) . '"'
            . $Selected . $Disabled
            . ' data-qisutu-service-node="1"'
            . ' data-parent-id="' . $Self->_Escape( $Row->{parent_id} || 0 ) . '"'
            . ' data-service-name="' . $Self->_Escape($Name) . '"'
            . ' data-service-full-name="' . $Self->_Escape( $Row->{full_name} || $Name ) . '"'
            . ' data-service-meta="' . $Self->_Escape( $Row->{sla_name} || '' ) . '"'
            . ' data-service-selectable="' . ( $Row->{selectable} ? 1 : 0 ) . '"'
            . ' data-service-has-children="' . ( $Row->{has_children} ? 1 : 0 ) . '"'
            . ' data-service-depth="' . $Self->_Escape( $Row->{depth} || 0 ) . '">'
            . $Self->_Escape($FallbackLabel) . '</option>';
    }

    return $HTML;
}

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

    my $Language = $Param{Language} || 'en';
    my $Action   = $Param{Action} || '';
    my $OldValue = $Param{OldValue} || '-';
    my $NewValue = $Param{NewValue} || '-';

    my %DE = (
        priority => 'Priorität geändert',
        owner       => 'Besitzer geändert',
        responsible => 'Verantwortlicher geändert',
        customer    => 'Kunde / Ansprechpartner geändert',
        service     => 'Service / SLA geändert',
        queue       => 'Queue geändert',
        close       => 'Ticket geschlossen',
    );

    my %EN = (
        priority => 'Priority changed',
        owner       => 'Owner changed',
        responsible => 'Responsible changed',
        customer    => 'Customer / contact changed',
        service     => 'Service / SLA changed',
        queue       => 'Queue changed',
        close       => 'Ticket closed',
    );

    my $Label = $Language eq 'de' ? ( $DE{$Action} || 'Ticket-Aktion' ) : ( $EN{$Action} || 'Ticket action' );
    my $From  = $Language eq 'de' ? 'von' : 'from';
    my $To    = $Language eq 'de' ? 'auf' : 'to';

    return $Label . ': ' . $From . ' "' . $OldValue . '" ' . $To . ' "' . $NewValue . '"';
}

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

    my $Language = $Param{Language} || 'en';
    my $Action   = $Param{Action} || '';

    if ( $Action eq 'close' && $Self->{Output} ) {
        my $Subject = $Self->{Output}->Translate(
            Key      => 'TicketToolNote',
            Language => $Language,
        );
        return $Subject if $Subject && $Subject ne 'TicketToolNote';
    }

    my %DE = (
        priority => 'Ticket-Aktion: Priorität geändert',
        owner       => 'Ticket-Aktion: Besitzer geändert',
        responsible => 'Ticket-Aktion: Verantwortlicher geändert',
        customer    => 'Ticket-Aktion: Kunde / Ansprechpartner geändert',
        service     => 'Ticket-Aktion: Service / SLA geändert',
        queue       => 'Ticket-Aktion: Queue geändert',
        close       => 'Notiz',
    );

    my %EN = (
        priority => 'Ticket action: priority changed',
        owner       => 'Ticket action: owner changed',
        responsible => 'Ticket action: responsible changed',
        customer    => 'Ticket action: customer / contact changed',
        service     => 'Ticket action: service / SLA changed',
        queue       => 'Ticket action: queue changed',
        close       => 'Note',
    );

    return $Language eq 'de' ? ( $DE{$Action} || 'Ticket-Aktion' ) : ( $EN{$Action} || 'Ticket action' );
}

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

    my $Summary = $Param{Summary} || '';
    my $Body    = $Param{Body} || '';

    return $Body if exists $Param{IncludeSummary} && !$Param{IncludeSummary};
    return $Body if !$Summary;

    return '<p><strong>' . $Self->_Escape($Summary) . '</strong></p>' . $Body;
}

sub _Trim {
    my ( $Self, $Value ) = @_;

    $Value = '' if !defined $Value;
    $Value =~ s{\A\s+}{};
    $Value =~ s{\s+\z}{};

    return $Value;
}


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

    my $User       = $Param{User} || {};
    my $QueueID    = $Param{QueueID} || 0;
    my $Permission = $Param{Permission} || 'ticket.edit';
    my $UserID     = $User->{user_account_id} || 0;

    return 0 if $UserID !~ m{\A\d+\z} || !$UserID;
    return 0 if $QueueID !~ m{\A\d+\z} || !$QueueID;

    my $PermissionObject = $Self->_PermissionObject();
    return 0 if !$PermissionObject;

    return $PermissionObject->QueueAccessCheck(
        UserID     => $UserID,
        QueueID    => $QueueID,
        Permission => $Permission,
    ) ? 1 : 0;
}

sub _PermissionObject {
    my ($Self) = @_;

    return $Self->{PermissionObject} if $Self->{PermissionObject};
    return if !$Self->{DB};

    my $Loaded = eval {
        require QisutuPermission;
        1;
    };
    return if !$Loaded;

    $Self->{PermissionObject} = QisutuPermission->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
    );

    return $Self->{PermissionObject};
}

sub _DynamicFieldObject {
    my ($Self) = @_;

    return $Self->{DynamicFieldObject} if $Self->{DynamicFieldObject};
    return if !$Self->{DB};

    my $Loaded = eval {
        require QisutuDynamicField;
        1;
    };
    return if !$Loaded;

    $Self->{DynamicFieldObject} = QisutuDynamicField->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
        Output => $Self->{Output},
    );

    return $Self->{DynamicFieldObject};
}

sub _TicketFormObject {
    my ($Self) = @_;

    return $Self->{TicketFormObject} if $Self->{TicketFormObject};
    return if !$Self->{DB};

    my $Loaded = eval {
        require QisutuTicketForm;
        1;
    };
    return if !$Loaded;

    $Self->{TicketFormObject} = QisutuTicketForm->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
        Output => $Self->{Output},
    );
    return $Self->{TicketFormObject};
}

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

    my $TicketObject = $Param{TicketObject};
    my $TicketID     = $Param{TicketID} || 0;
    my $ArticleID    = $Param{ArticleID} || 0;
    my $User         = $Param{User} || {};
    my $Language     = $Param{Language} || 'en';
    my $FallbackURL  = 'index.pl?Page=AgentTicketZoom&TicketID=' . int($TicketID);

    return { Redirect => $FallbackURL }
        if !$TicketObject || !$TicketID || ( $User->{account_type} || '' ) ne 'agent';
    return { Redirect => $FallbackURL }
        if $ArticleID && $ArticleID !~ m{\A\d+\z};

    my $Ticket = $TicketObject->TicketGet(
        TicketID => $TicketID,
        User     => $User,
        Language => $Language,
    );
    return { Redirect => 'index.pl?Page=AgentTicketList' } if !$Ticket;

    my $Articles = $TicketObject->ArticleList(
        TicketID => $TicketID,
        User     => $User,
        Language => $Language,
        All      => 1,
    ) || [];

    if ($ArticleID) {
        my @Selected = grep { ( $_->{id} || 0 ) == $ArticleID } @{$Articles};
        return { Redirect => $FallbackURL } if !@Selected;
        $Articles = \@Selected;
    }

    $Ticket->{created_at_display} = $Self->_DateTimeFormat(
        DateTime => $Ticket->{created_at},
        Language => $Language,
    );
    $Ticket->{changed_at_display} = $Self->_DateTimeFormat(
        DateTime => $Ticket->{changed_at},
        Language => $Language,
    );
    for my $Field (qw(state_name_display priority_name_display)) {
        $Ticket->{$Field} = $Self->_TranslationResolve(
            Value    => $Ticket->{$Field},
            Language => $Language,
        );
    }

    for my $Article ( @{$Articles} ) {
        $Article->{created_at_display} = $Self->_DateTimeFormat(
            DateTime => $Article->{created_at},
            Language => $Language,
        );
        $Article->{visibility_label_display} = $Self->_TranslationResolve(
            Value    => $Article->{visibility_label},
            Language => $Language,
        );
    }

    my %LabelKey = (
        Ticket             => 'TicketPrintTicket',
        Queue              => 'TicketQueue',
        Status             => 'TicketState',
        Priority           => 'TicketPriority',
        Customer           => 'TicketCustomer',
        Contact            => 'TicketCustomerUser',
        Email              => 'TicketCustomerEmail',
        Owner              => 'TicketOwner',
        Responsible        => 'TicketResponsible',
        CreatedAt          => 'TicketCreated',
        ChangedAt          => 'TicketChanged',
        Article            => 'TicketPrintArticle',
        From               => 'TicketArticleFrom',
        To                 => 'TicketArticleTo',
        Cc                 => 'TicketArticleReplyCc',
        Channel            => 'TicketArticleChannel',
        Visibility         => 'TicketArticleVisibility',
        VisibilityAgent    => 'TicketArticleVisibilityAgent',
        VisibilityBoth     => 'TicketArticleVisibilityBoth',
        Attachments        => 'TicketArticleAttachments',
        AllArticlesTitle   => 'TicketPrintWholeTitle',
        SingleArticleTitle => 'TicketPrintSingleTitle',
        Internal           => 'TicketPrintInternal',
        NoArticles         => 'TicketPrintNoArticles',
        EmptyArticle       => 'TicketPrintEmptyArticle',
        Page               => 'TicketPrintPage',
        GeneratedAt        => 'TicketPrintGeneratedAt',
    );
    my %Labels = map {
        $_ => $Self->{Output}->Translate( Key => $LabelKey{$_}, Language => $Language ) || $LabelKey{$_}
    } keys %LabelKey;

    my $GeneratedAt = strftime( '%Y-%m-%d %H:%M:%S', localtime );
    $GeneratedAt = $Self->_DateTimeFormat( DateTime => $GeneratedAt, Language => $Language );
    my $LogoPath = ( $Self->{Config}->{Paths}->{Static} || '' ) . '/img/logo-pdf.jpg';

    my $PDFObject = QisutuTicketPDF->new( Config => $Self->{Config} );
    my $PDF = $PDFObject->Create(
        Ticket        => $Ticket,
        Articles      => $Articles,
        Labels        => \%Labels,
        SystemName    => $Self->{Config}->{System}->{Name} || 'Qisutu',
        LogoPath      => $LogoPath,
        GeneratedAt   => $GeneratedAt,
        SingleArticle => $ArticleID ? 1 : 0,
    );
    return { Redirect => $FallbackURL } if !$PDF;

    my $Filename = 'qisutu-ticket-' . ( $Ticket->{ticket_number} || $TicketID );
    if ($ArticleID) {
        my $ArticleNumber = $Articles->[0]->{article_number} || $ArticleID;
        $Filename .= '-article-' . $ArticleNumber;
    }
    $Filename =~ s{[^A-Za-z0-9_-]+}{-}g;
    $Filename =~ s{\A-+|-+\z}{}g;
    $Filename ||= 'qisutu-ticket';

    return {
        Response => $Self->{Output}->Response(
            Body        => $PDF,
            ContentType => 'application/pdf',
            Headers     => [
                'Content-Disposition: attachment; filename="' . $Filename . '.pdf"',
                'Cache-Control: private, no-store, max-age=0',
                'X-Content-Type-Options: nosniff',
            ],
        ),
    };
}

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

    my $Value = defined $Param{Value} ? $Param{Value} : '';
    return $Value if $Value !~ m{\ATranslate:(.+)\z};

    return $Self->{Output}->Translate(
        Key      => $1,
        Language => $Param{Language} || 'en',
    ) || $1;
}

sub _TicketObject {
    my ($Self) = @_;

    return if !$Self->{DB};

    my $Loaded = eval {
        require QisutuPermission;
        require QisutuTicket;
        1;
    };

    if ( !$Loaded ) {
        return;
    }

    my $PermissionObject = QisutuPermission->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
    );

    return QisutuTicket->new(
        Config     => $Self->{Config},
        DB         => $Self->{DB},
        Permission => $PermissionObject,
    );
}

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

    my $User      = $Param{User} || {};
    my $Firstname = $User->{firstname} || '';
    my $Lastname  = $User->{lastname}  || '';
    my $Login     = $User->{login}     || '';
    my $Name      = join ' ', grep {$_} ( $Firstname, $Lastname );

    if ( !$Name ) {
        $Name = $Login;
    }

    return $Name;
}

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

    my $TicketObject = $Param{TicketObject};
    my $TicketID     = $Param{TicketID} || 0;
    my $User         = $Param{User} || {};

    return ( '', '' ) if !$TicketObject || !$TicketID;

    my $Ticket = $TicketObject->TicketGet(
        TicketID => $TicketID,
        User     => $User,
    );

    if ($Ticket) {
        my $Name  = $Ticket->{customer_user_name}  || '';
        my $Email = $Ticket->{customer_user_email} || '';

        return ( $Name, $Email ) if $Email;
    }

    my $Articles = $TicketObject->ArticleList( TicketID => $TicketID, User => $User, Language => $Param{Language} || 'en' );

    for ( my $Index = @{$Articles} - 1; $Index >= 0; $Index-- ) {
        my $Article = $Articles->[$Index];

        next if ( $Article->{channel} || '' ) ne 'email';
        next if ( $Article->{sender_type} || '' ) ne 'customer';
        next if !$Article->{from_email};

        return (
            $Article->{from_name} || $Article->{sender_name} || '',
            $Article->{from_email},
        );
    }

    for ( my $Index = @{$Articles} - 1; $Index >= 0; $Index-- ) {
        my $Article = $Articles->[$Index];

        next if !$Article->{from_email};

        return (
            $Article->{from_name} || $Article->{sender_name} || '',
            $Article->{from_email},
        );
    }

    return ( '', '' );
}


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

    my $TicketID = $Param{TicketID} || 0;

    return ( '', '' ) if !$Self->{DB} || !$TicketID;

    my $Row = $Self->{DB}->SelectRow(
        'SELECT
            se.name,
            se.email
         FROM ticket t
         INNER JOIN ticket_queue q
            ON q.id = t.queue_id
         LEFT JOIN system_email se
            ON se.id = q.system_email_id
           AND se.active = 1
         WHERE t.id = ?
         LIMIT 1',
        $TicketID,
    );

    return ( '', '' ) if !$Row;

    return (
        $Row->{name}  || '',
        $Row->{email} || '',
    );
}

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

    my $TicketID = $Param{TicketID} || 0;
    my $User     = $Param{User} || {};

    my $UserName  = $Self->_UserName( User => $User );
    my $UserEmail = $User->{email} || '';

    return ( $UserName, $UserEmail ) if !$Self->{DB} || !$TicketID;

    my $Sender = $Self->{DB}->SelectRow(
        'SELECT
            se.name,
            se.email
         FROM ticket t
         INNER JOIN ticket_queue q
            ON q.id = t.queue_id
         LEFT JOIN system_email se
            ON se.id = q.system_email_id
         WHERE t.id = ?
         LIMIT 1',
        $TicketID,
    );

    return (
        $Sender->{name}  || $UserName,
        $Sender->{email} || $UserEmail,
    ) if $Sender;

    return ( $UserName, $UserEmail );
}

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

    my $TicketObject = $Param{TicketObject};
    my $TicketID     = $Param{TicketID} || 0;
    my $User         = $Param{User} || {};
    my $Language     = $Param{Language} || 'en';

    my $Ticket = $TicketObject
        ? $TicketObject->TicketGet( TicketID => $TicketID, User => $User )
        : undef;

    my $TicketNumber = $Ticket ? ( $Ticket->{ticket_number} || $TicketID ) : $TicketID;

    return $Language eq 'de'
        ? 'Antwort zu ' . $TicketNumber
        : 'Reply to ' . $TicketNumber;
}

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

    my $Body = $Param{Body} || '';

    $Body =~ s{<style\b[^>]*>.*?</style>}{}gis;
    $Body =~ s{<script\b[^>]*>.*?</script>}{}gis;
    $Body =~ s{<[^>]+>}{}g;
    $Body =~ s{&nbsp;}{ }gi;
    $Body =~ s{&#160;}{ }g;
    $Body =~ s{\s+}{ }g;
    $Body =~ s{\A\s+}{};
    $Body =~ s{\s+\z}{};

    return $Body ? 1 : 0;
}

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

    eval {
        require QisutuHTML;
        require QisutuMail;
        1;
    } || return {
        Success => 0,
        Message => 'Translate:TicketArticleSendFailed',
    };

    my $Recipients = $Self->_EmailRecipientsParse(
        Value    => $Param{ToEmail} || '',
        Required => 1,
    );

    if ( !$Recipients->{Valid} ) {
        return {
            Success => 0,
            Message => $Recipients->{Error} || 'Translate:TicketArticleRecipientRequired',
        };
    }

    if ( !$Param{FromEmail} ) {
        return {
            Success => 0,
            Message => 'Translate:TicketArticleSenderRequired',
        };
    }

    if ( !$Self->_ActiveSMTPAccount() ) {
        return {
            Success => 0,
            Message => 'Translate:TicketArticleSMTPRequired',
        };
    }

    return {
        Success => 1,
        Message => '',
    };
}

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

    eval {
        require QisutuHTML;
        require QisutuMail;
        1;
    } || return {
        Success => 0,
        Message => 'Mail modules could not be loaded',
    };

    my $Recipients = $Self->_EmailRecipientsParse(
        Value    => $Param{ToEmail} || '',
        Required => 1,
    );

    if ( !$Recipients->{Valid} ) {
        return {
            Success => 0,
            Message => $Recipients->{Error} || 'Recipient e-mail address is required',
        };
    }

    my $CcRecipients = $Self->_EmailRecipientsParse(
        Value    => $Param{Cc} || '',
        Required => 0,
    );

    if ( !$CcRecipients->{Valid} ) {
        return {
            Success => 0,
            Message => $CcRecipients->{Error} || 'Invalid CC recipient e-mail address',
        };
    }

    if ( !$Param{FromEmail} ) {
        return {
            Success => 0,
            Message => 'Sender e-mail address is required',
        };
    }

    my $SMTPAccount = $Self->_ActiveSMTPAccount();

    if ( !$SMTPAccount ) {
        return {
            Success => 0,
            Message => 'No active SMTP transport configured',
        };
    }

    my $Body = QisutuHTML->Sanitize( $Param{Body} || '' );

    return QisutuMail->new( Config => $Self->{Config}, DB => $Self->{DB} )->SMTPSend(
        Account   => $SMTPAccount,
        TicketID  => $Param{TicketID},
        ArticleID => $Param{ArticleID},
        FromName  => $Param{FromName},
        FromEmail => $Param{FromEmail},
        ToName    => $Param{ToName},
        ToEmail   => $Recipients->{Header},
        Cc        => $CcRecipients->{Header},
        Subject   => $Param{Subject},
        Body        => $Body,
        Attachments => $Param{Attachments},
    );
}

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

    my $Request      = $Param{Request} || {};
    my $MaxSizeBytes = $Param{MaxSizeBytes} || $Self->_AttachmentMaxSizeMB() * 1024 * 1024;
    my $Uploads      = $Request->{__Uploads} || {};
    my $RawList      = [];

    if ( ref $Uploads->{ArticleAttachment} eq 'ARRAY' ) {
        $RawList = $Uploads->{ArticleAttachment};
    }
    elsif ( ref $Uploads->{'ArticleAttachment[]'} eq 'ARRAY' ) {
        $RawList = $Uploads->{'ArticleAttachment[]'};
    }

    my @Attachments;
    my @Oversized;

    for my $Upload ( @{$RawList} ) {
        next if ref $Upload ne 'HASH';

        my $Filename = $Upload->{Filename} || '';
        $Filename =~ s{\\}{/}g;
        $Filename =~ s{\A.*/}{}g;
        $Filename =~ s{[\r\n\x00]}{}g;
        $Filename =~ s{\A\s+|\s+\z}{}g;
        next if !$Filename;

        my $Content = $Upload->{Content};
        next if !defined $Content;

        my $ContentSize = $Upload->{ContentSize};
        $ContentSize = length($Content) if !defined $ContentSize || $ContentSize !~ m{\A\d+\z};

        my $Attachment = {
            Filename           => $Filename,
            ContentType        => $Upload->{ContentType} || 'application/octet-stream',
            Content            => $Content,
            ContentSize        => $ContentSize,
            ContentDisposition => 'attachment',
        };

        if ( $MaxSizeBytes && $ContentSize > $MaxSizeBytes ) {
            push @Oversized, $Attachment;
            next;
        }

        push @Attachments, $Attachment;
    }

    return {
        Attachments => \@Attachments,
        Oversized   => \@Oversized,
    };
}

sub _AttachmentMaxSizeMB {
    my ($Self) = @_;

    my $Value = 25;

    if ( $Self->{DB} ) {
        my $Loaded = eval {
            require QisutuSystemSetting;
            1;
        };

        if ($Loaded) {
            my $SettingObject = QisutuSystemSetting->new(
                Config => $Self->{Config},
                DB     => $Self->{DB},
            );
            $Value = $SettingObject->AttachmentMaxSizeMB();
        }
    }

    return $Value;
}

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

    my $Attachment = $Param{Attachment} || {};
    my $MaxSizeMB  = $Param{MaxSizeMB} || 25;
    my $Language   = $Param{Language} || 'en';
    my $Filename   = $Attachment->{Filename} || 'attachment';
    my $Template   = '';

    if ( $Self->{Output} ) {
        $Template = $Self->{Output}->Translate(
            Key      => 'TicketArticleAttachmentTooLargeServer',
            Language => $Language,
        ) || '';
    }

    $Template ||= $Language eq 'de'
        ? 'Der Anhang „{{Filename}}“ überschreitet die erlaubte Maximalgröße von {{MaxSize}}. Bitte wenden Sie sich an den Administrator.'
        : 'The attachment “{{Filename}}” exceeds the permitted maximum size of {{MaxSize}}. Please contact the administrator.';

    my $MaxSizeLabel = $MaxSizeMB . ' MB';
    $Template =~ s{\{\{Filename\}\}}{$Filename}g;
    $Template =~ s{\{\{MaxSize\}\}}{$MaxSizeLabel}g;

    return $Template;
}


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

    my $TicketID = $Param{TicketID} || 0;

    return '' if !$Self->{DB} || !$TicketID;

    my $LocalizedContent = QisutuLocalizedContent->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
    );
    return '' if !$LocalizedContent->SchemaEnsure();

    my $Language = $LocalizedContent->LanguageClean( $Param{Language} );
    my $DefaultLanguage = $LocalizedContent->LanguageClean(
        $Self->{Config}->{Language}->{Default},
    );

    eval {
        require QisutuHTML;
        1;
    } || return '';

    my $Row = $Self->{DB}->SelectRow(
        'SELECT
            COALESCE(
                NULLIF(sal_current.content, ""),
                NULLIF(sal_default.content, ""),
                sal.content
            ) AS salutation_content,
            COALESCE(
                NULLIF(sig_current.content, ""),
                NULLIF(sig_default.content, ""),
                sig.content
            ) AS signature_content
         FROM ticket t
         INNER JOIN ticket_queue q
            ON q.id = t.queue_id
         LEFT JOIN salutation sal
            ON sal.id = q.salutation_id
           AND sal.active = 1
         LEFT JOIN salutation_translation sal_current
            ON sal_current.salutation_id = sal.id
           AND sal_current.language = ?
         LEFT JOIN salutation_translation sal_default
            ON sal_default.salutation_id = sal.id
           AND sal_default.language = ?
         LEFT JOIN signature sig
            ON sig.id = q.signature_id
           AND sig.active = 1
         LEFT JOIN signature_translation sig_current
            ON sig_current.signature_id = sig.id
           AND sig_current.language = ?
         LEFT JOIN signature_translation sig_default
            ON sig_default.signature_id = sig.id
           AND sig_default.language = ?
         WHERE t.id = ?
         LIMIT 1',
        $Language,
        $DefaultLanguage,
        $Language,
        $DefaultLanguage,
        $TicketID,
    );

    return '' if !$Row;

    my $Salutation = QisutuHTML->Sanitize( $Row->{salutation_content} || '' );
    my $Signature  = QisutuHTML->Sanitize( $Row->{signature_content}  || '' );
    my $User = $Param{User} || {};
    my $Renderer = QisutuNotification->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
    );
    my %RenderParam = (
        TicketID    => $TicketID,
        AgentUserID => $User->{user_account_id},
        ChangedByID => $User->{user_account_id},
        SystemPlaceholder => $Renderer->SystemPlaceholderHash(),
        Language     => $Language,
    );
    $Salutation = $Renderer->ContentTemplateRenderHTML( %RenderParam, HTML => $Salutation ) if $Salutation;
    $Signature  = $Renderer->ContentTemplateRenderHTML( %RenderParam, HTML => $Signature ) if $Signature;
    my @Parts;

    push @Parts, $Salutation if $Salutation;
    push @Parts, '<div class="qisutu-response-template-slot"><p><br></p><p><br></p></div>';
    push @Parts, $Self->_SystemSignatureWrap( Signature => $Signature ) if $Signature;

    return join "\n", @Parts;
}

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

    my $Signature = $Param{Signature} || '';
    $Signature =~ s{\A\s+}{};
    $Signature =~ s{\s+\z}{};

    return '' if !$Signature;

    $Signature = $Self->_SystemSignatureBlockToBreaks( HTML => $Signature );

    return $Signature if $Signature =~ m{\bqisutu-mail-signature\b}i;

    return '<div class="qisutu-mail-signature" style="margin-top: 10px; padding-top: 6px; border-top: 1px dashed #cbd5df; color: #5d6b7c; font-size: 13px; line-height: 1.25;">'
        . $Signature
        . '</div>';
}

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

    my $HTML = $Param{HTML} || '';

    return '' if !$HTML;

    # Die Queue-/Systemsignatur wird in eine mailtaugliche, kompakte Form
    # gebracht. Normale <p>/<div>-Blöcke erzeugen in Mailclients oft große
    # Standardabstände. Deshalb werden nur innerhalb der Signatur die Block-
    # Absätze in explizite <br>-Zeilenumbrüche umgewandelt. Leere Absätze
    # bleiben als zusätzliche Leerzeile erhalten.
    for ( 1 .. 8 ) {
        last if $HTML !~ s{<\s*(p|div)\b([^>]*)>(.*?)<\s*/\s*\1\s*>}{
            my $Content = $3 || '';

            if ( $Content =~ m{\A(?:\s|&nbsp;|&#160;|<\s*br\s*/?\s*>)*\z}is ) {
                '<br>';
            }
            else {
                $Content . '<br>';
            }
        }gexis;
    }

    $HTML =~ s{(?:\s*<\s*br\s*/?\s*>\s*){4,}}{<br><br><br>}gis;
    $HTML =~ s{(?:\s*<\s*br\s*/?\s*>\s*)+\z}{}gis;
    $HTML =~ s{\A\s+|\s+\z}{}g;

    return $HTML;
}

sub _ActiveSMTPAccount {
    my ($Self) = @_;

    return if !$Self->{DB};

    return $Self->{DB}->SelectRow(
        'SELECT *
         FROM smtp_account
         WHERE active = 1
         ORDER BY sort_order ASC, id ASC
         LIMIT 1'
    );
}

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

    my $Language         = $Param{Language} || 'en';
    my $DefaultStateName = $Param{DefaultStateName} || 'open';
    my $CurrentStateName = $Param{CurrentStateName} || '';

    my $States = $Self->{DB}->SelectAll(
        'SELECT id, name, state_type
         FROM ticket_state
         WHERE active = 1
         ORDER BY sort_order ASC, id ASC'
    ) || [];

    my $SelectedID = 0;

    for my $State ( @{$States} ) {
        if ( !$SelectedID && ( $State->{name} || '' ) eq $DefaultStateName ) {
            $SelectedID = $State->{id};
        }
    }

    if ( !$SelectedID ) {
        for my $State ( @{$States} ) {
            if ( !$SelectedID && ( $State->{name} || '' ) eq $CurrentStateName ) {
                $SelectedID = $State->{id};
            }
        }
    }

    my $HTML = '';

    for my $State ( @{$States} ) {
        my $Selected = $SelectedID && $State->{id} == $SelectedID ? ' selected' : '';
        my $Label    = $Self->_TicketStateText(
            State    => $State->{name},
            Language => $Language,
        );

        my $StateTypeAttribute = $State->{state_type}
            ? ' data-state-type="' . $Self->_Escape( $State->{state_type} ) . '"'
            : '';

        $HTML .= '<option value="' . $Self->_Escape( $State->{id} ) . '"' . $Selected . $StateTypeAttribute . '>'
            . $Self->_Escape($Label)
            . '</option>';
    }

    return $HTML;
}

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

    my $StatusID = $Param{StatusID} || 0;

    if ( $StatusID =~ m{\A\d+\z} && $StatusID ) {
        my $State = $Self->{DB}->SelectRow(
            'SELECT id
             FROM ticket_state
             WHERE id = ?
               AND active = 1
             LIMIT 1',
            $StatusID,
        );

        return $State->{id} if $State;
    }

    my $Default = $Self->{DB}->SelectRow(
        'SELECT id
         FROM ticket_state
         WHERE name = ?
           AND active = 1
         LIMIT 1',
        'open',
    );

    return $Default ? $Default->{id} : 0;
}

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

    my $TicketID        = $Param{TicketID} || 0;
    my $StatusID        = $Param{StatusID} || 0;
    my $ChangedByUserID = $Param{ChangedByUserID} || 1;

    return if $TicketID !~ m{\A\d+\z} || !$TicketID;
    return if $StatusID !~ m{\A\d+\z} || !$StatusID;

    my $State = $Self->{DB}->SelectRow(
        'SELECT id, state_type
         FROM ticket_state
         WHERE id = ?
           AND active = 1
         LIMIT 1',
        $StatusID,
    );

    return if !$State;

    my $StateType = $State->{state_type} || '';

    if ( $StateType eq 'pending' ) {
        return $Self->{DB}->Do(
            'UPDATE ticket
             SET state_id = ?,
                 pending_started_at = COALESCE(pending_started_at, NOW()),
                 changed_by_user_id = ?,
                 changed_at = NOW()
             WHERE id = ?',
            $StatusID,
            $ChangedByUserID,
            $TicketID,
        );
    }

    if ( $StateType eq 'closed' ) {
        return $Self->{DB}->Do(
            'UPDATE ticket
             SET state_id = ?,
                 pending_total_minutes = pending_total_minutes + IF(pending_started_at IS NULL, 0, TIMESTAMPDIFF(MINUTE, pending_started_at, NOW())),
                 pending_started_at = NULL,
                 pending_until = NULL,
                 solution_at = COALESCE(solution_at, NOW()),
                 changed_by_user_id = ?,
                 changed_at = NOW()
             WHERE id = ?',
            $StatusID,
            $ChangedByUserID,
            $TicketID,
        );
    }

    return $Self->{DB}->Do(
        'UPDATE ticket
         SET state_id = ?,
             pending_total_minutes = pending_total_minutes + IF(pending_started_at IS NULL, 0, TIMESTAMPDIFF(MINUTE, pending_started_at, NOW())),
             pending_started_at = NULL,
             pending_until = NULL,
             solution_at = NULL,
             changed_by_user_id = ?,
             changed_at = NOW()
         WHERE id = ?',
        $StatusID,
        $ChangedByUserID,
        $TicketID,
    );
}

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

    my $State    = $Param{State} || '';
    my $Language = $Param{Language} || 'en';
    my $Key      = lc $State;

    $Key =~ s{\A\s+}{};
    $Key =~ s{\s+\z}{};
    $Key =~ s{\+}{ plus }g;
    $Key =~ s{-}{ minus }g;
    $Key =~ s{[^a-z0-9]+}{_}g;
    $Key =~ s{\A_+}{};
    $Key =~ s{_+\z}{};

    my %DE = (
        new                      => 'Neu',
        open                     => 'Offen',
        pending                  => 'Wartend',
        pending_reminder         => 'Warten auf Erinnerung',
        pending_auto_close       => 'Warten auf automatische Schließung',
        pending_auto_close_plus  => 'Warten auf automatische Schließung +',
        pending_auto_close_minus => 'Warten auf automatische Schließung -',
        closed                   => 'Geschlossen',
        closed_successful        => 'Erfolgreich geschlossen',
        closed_unsuccessful      => 'Erfolglos geschlossen',
        resolved                 => 'Gelöst',
        merged                   => 'Zusammengeführt',
        removed                  => 'Entfernt',
        deleted                  => 'Gelöscht',
        escalated                => 'Eskaliert',
    );

    my %EN = (
        new                      => 'New',
        open                     => 'Open',
        pending                  => 'Pending',
        pending_reminder         => 'Pending reminder',
        pending_auto_close       => 'Pending auto close',
        pending_auto_close_plus  => 'Pending auto close +',
        pending_auto_close_minus => 'Pending auto close -',
        closed                   => 'Closed',
        closed_successful        => 'Closed successful',
        closed_unsuccessful      => 'Closed unsuccessful',
        resolved                 => 'Resolved',
        merged                   => 'Merged',
        removed                  => 'Removed',
        deleted                  => 'Deleted',
        escalated                => 'Escalated',
    );

    return $DE{$Key} if $Language eq 'de' && $DE{$Key};
    return $EN{$Key} if $EN{$Key};

    return $State;
}

sub _ResponseTemplateObject {
    my ($Self) = @_;

    return if !$Self->{DB};

    return QisutuResponseTemplate->new(
        Config => $Self->{Config},
        DB     => $Self->{DB},
    );
}

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

    my $Value = $Param{Value};
    my @Values = ref $Value eq 'ARRAY' ? @{$Value} : ( defined $Value ? ($Value) : () );
    my %Seen;
    my @IDs = grep { defined $_ && $_ =~ m{\A\d+\z} && $_ > 0 && !$Seen{$_}++ } @Values;

    return \@IDs;
}

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

    my $TicketID = int( $Param{TicketID} || 0 );
    my $Language = $Param{Language} || 'en';
    my $ReadOnly = $Param{ReadOnly} ? 1 : 0;
    my $Checklists = ref $Param{Checklists} eq 'ARRAY' ? $Param{Checklists} : [];

    if ( !@{$Checklists} ) {
        my $None = $Self->{Output}->Translate( Key => 'TicketChecklistNone', Language => $Language );
        return '<p class="qisutu-form-hint">' . $Self->_Escape($None) . '</p>';
    }

    my $RequiredLabel = $Self->{Output}->Translate( Key => 'TicketChecklistRequired', Language => $Language );
    my $DetailsLabel = $Self->{Output}->Translate( Key => 'TicketChecklistDetails', Language => $Language );
    my $DetailsTitle = $Self->{Output}->Translate( Key => 'TicketChecklistDetailsTitle', Language => $Language );
    my $CloseLabel = $Self->{Output}->Translate( Key => 'CommonClose', Language => $Language );
    my $RemoveLabel = $Self->{Output}->Translate( Key => 'TicketChecklistRemove', Language => $Language );
    my $RemoveConfirm = $Self->{Output}->Translate( Key => 'TicketChecklistRemoveConfirm', Language => $Language );

    my $HTML = '<div class="qisutu-ticket-checklist-list">';
    for my $Checklist ( @{$Checklists} ) {
        my $ChecklistID = int( $Checklist->{id} || 0 );
        my $ItemCount = int( $Checklist->{item_count} || 0 );
        my $DoneCount = int( $Checklist->{done_count} || 0 );
        my $Percent = $ItemCount ? int( ( $DoneCount * 100 ) / $ItemCount ) : 100;
        $Percent = 0 if $Percent < 0;
        $Percent = 100 if $Percent > 100;
        my $CompleteClass = $ItemCount == $DoneCount ? ' qisutu-ticket-checklist-complete' : '';

        $HTML .= '<details class="qisutu-ticket-checklist' . $CompleteClass . '" open>';
        $HTML .= '<summary><span>' . $Self->_Escape( $Checklist->{name} || '' ) . '</span><span>' . $DoneCount . ' / ' . $ItemCount . '</span></summary>';
        if ( $Checklist->{description} ) {
            $HTML .= '<p class="qisutu-ticket-checklist-description">' . $Self->_Escape( $Checklist->{description} ) . '</p>';
        }
        $HTML .= '<div class="qisutu-ticket-checklist-progress-track"><span style="width:' . $Percent . '%"></span></div>';
        $HTML .= '<div class="qisutu-ticket-checklist-items">';

        for my $Item ( @{ $Checklist->{items} || [] } ) {
            my $ItemID = int( $Item->{id} || 0 );
            my $IsDone = $Item->{is_done} ? 1 : 0;
            my $DoneClass = $IsDone ? ' qisutu-ticket-checklist-item-done' : '';
            my $Checked = $IsDone ? ' checked' : '';
            my $NextDone = $IsDone ? 0 : 1;

            my $ItemElement = $ReadOnly ? 'div' : 'form';
            $HTML .= '<' . $ItemElement . ' class="qisutu-ticket-checklist-item-form' . $DoneClass . '"';
            if (!$ReadOnly) {
                $HTML .= ' method="post" action="index.pl" data-qisutu-checklist-item-form>';
                $HTML .= '<input type="hidden" name="Page" value="AgentTicketZoom">';
                $HTML .= '<input type="hidden" name="Step" value="ChecklistItemToggle">';
                $HTML .= '<input type="hidden" name="TicketID" value="' . $TicketID . '">';
                $HTML .= '<input type="hidden" name="ChecklistItemID" value="' . $ItemID . '">';
                $HTML .= '<input type="hidden" name="ChecklistItemDone" value="' . $NextDone . '">';
            }
            else {
                $HTML .= '>';
            }
            my $ItemDescription = $Self->_Escape( $Item->{description} || '' );
            $ItemDescription =~ s{\r\n?}{\n}g;
            $ItemDescription =~ s{\n}{<br>}g;

            $HTML .= '<label><input type="checkbox"' . $Checked . ( $ReadOnly ? ' disabled' : ' data-qisutu-checklist-item-toggle' ) . '><span class="qisutu-ticket-checklist-item-content"><span class="qisutu-ticket-checklist-item-text">' . $Self->_Escape( $Item->{name} || '' ) . '</span></span></label>';
            if ( $Item->{is_required} || $ItemDescription ) {
                $HTML .= '<span class="qisutu-ticket-checklist-item-meta">';
                if ( $Item->{is_required} ) {
                    $HTML .= '<span class="qisutu-ticket-checklist-required">' . $Self->_Escape($RequiredLabel) . '</span>';
                }
                if ($ItemDescription) {
                    $HTML .= '<button class="qisutu-button qisutu-button-small qisutu-button-secondary qisutu-ticket-checklist-details-button" type="button" data-qisutu-checklist-details-open aria-haspopup="dialog">' . $Self->_Escape($DetailsLabel) . '</button>';
                    $HTML .= '<span data-qisutu-checklist-details-content hidden>' . $ItemDescription . '</span>';
                }
                $HTML .= '</span>';
            }
            if ( $IsDone && $Item->{completed_at_display} ) {
                my $CompletedBy = $Item->{completed_by_name} || '';
                my $CompletionText = $CompletedBy ? $CompletedBy . ' · ' . $Item->{completed_at_display} : $Item->{completed_at_display};
                $HTML .= '<span class="qisutu-ticket-checklist-completion">' . $Self->_Escape($CompletionText) . '</span>';
            }
            $HTML .= '</' . $ItemElement . '>';
        }

        $HTML .= '</div>';
        if (!$ReadOnly) {
            $HTML .= '<form class="qisutu-ticket-checklist-remove-form" method="post" action="index.pl" data-qisutu-checklist-remove-form data-confirm="' . $Self->_Escape($RemoveConfirm) . '">';
            $HTML .= '<input type="hidden" name="Page" value="AgentTicketZoom">';
            $HTML .= '<input type="hidden" name="Step" value="ChecklistRemove">';
            $HTML .= '<input type="hidden" name="TicketID" value="' . $TicketID . '">';
            $HTML .= '<input type="hidden" name="TicketChecklistID" value="' . $ChecklistID . '">';
            $HTML .= '<button class="qisutu-button qisutu-button-small qisutu-button-danger" type="submit">' . $Self->_Escape($RemoveLabel) . '</button>';
            $HTML .= '</form>';
        }
        $HTML .= '</details>';
    }
    $HTML .= '</div>';
    $HTML .= '<div class="qisutu-ticket-checklist-details-overlay" data-qisutu-checklist-details-overlay aria-hidden="true" hidden>';
    $HTML .= '<section class="qisutu-ticket-checklist-details-dialog" role="dialog" aria-modal="true" aria-labelledby="qisutu-ticket-checklist-details-title">';
    $HTML .= '<div class="qisutu-ticket-checklist-details-header"><h3 id="qisutu-ticket-checklist-details-title">' . $Self->_Escape($DetailsTitle) . '</h3>';
    $HTML .= '<button class="qisutu-ticket-checklist-details-close" type="button" aria-label="' . $Self->_Escape($CloseLabel) . '" data-qisutu-checklist-details-close>×</button></div>';
    $HTML .= '<strong class="qisutu-ticket-checklist-details-name" data-qisutu-checklist-details-name></strong>';
    $HTML .= '<div class="qisutu-ticket-checklist-details-content" data-qisutu-checklist-details-dialog-content></div>';
    $HTML .= '<div class="qisutu-form-actions"><button class="qisutu-button qisutu-button-secondary" type="button" data-qisutu-checklist-details-close>' . $Self->_Escape($CloseLabel) . '</button></div>';
    $HTML .= '</section></div>';

    return $HTML;
}

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

    my $Templates = ref $Param{Templates} eq 'ARRAY' ? $Param{Templates} : [];
    return '' if $Param{ReadOnly} || !@{$Templates};

    my $TicketID = int( $Param{TicketID} || 0 );
    my $Language = $Param{Language} || 'en';
    my $AddLabel = $Self->{Output}->Translate( Key => 'TicketChecklistAdd', Language => $Language );
    my $NoSelection = $Self->{Output}->Translate( Key => 'AdminNoSelection', Language => $Language );
    my $ButtonLabel = $Self->{Output}->Translate( Key => 'TicketChecklistAddButton', Language => $Language );

    my $HTML = '<form class="qisutu-ticket-checklist-add-form" method="post" action="index.pl">';
    $HTML .= '<input type="hidden" name="Page" value="AgentTicketZoom">';
    $HTML .= '<input type="hidden" name="Step" value="ChecklistAdd">';
    $HTML .= '<input type="hidden" name="TicketID" value="' . $TicketID . '">';
    $HTML .= '<label for="qisutu-ticket-checklist-template">' . $Self->_Escape($AddLabel) . '</label>';
    $HTML .= '<div class="qisutu-ticket-checklist-add-row"><select id="qisutu-ticket-checklist-template" name="ChecklistTemplateID" required>';
    $HTML .= '<option value="">' . $Self->_Escape($NoSelection) . '</option>';
    for my $Template ( @{$Templates} ) {
        $HTML .= '<option value="' . int( $Template->{id} || 0 ) . '">' . $Self->_Escape( $Template->{name} || '' ) . '</option>';
    }
    $HTML .= '</select><button class="qisutu-button qisutu-button-primary qisutu-button-small" type="submit">' . $Self->_Escape($ButtonLabel) . '</button></div>';
    $HTML .= '</form>';

    return $HTML;
}

sub _ChecklistCloseError {
    my ( $Self, %Param ) = @_;
    my $Items = ref $Param{Items} eq 'ARRAY' ? $Param{Items} : [];
    my $Language = $Param{Language} || 'en';
    my $Prefix = $Language eq 'de'
        ? 'Das Ticket kann nicht geschlossen werden. Offene Pflichtpunkte:'
        : 'The ticket cannot be closed. Required checklist items are still open:';
    my @Lines = map {
        '- ' . ( $_->{checklist_name} || '' ) . ': ' . ( $_->{item_name} || '' )
    } @{$Items};
    return join( "
", $Prefix, @Lines );
}

sub _Escape {
    my ( $Self, $Value ) = @_;

    $Value = '' if !defined $Value;

    if ( $Self->{Output} ) {
        return $Self->{Output}->HTMLEscape($Value);
    }

    $Value =~ s{&}{&amp;}g;
    $Value =~ s{<}{&lt;}g;
    $Value =~ s{>}{&gt;}g;
    $Value =~ s{"}{&quot;}g;

    return $Value;
}

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

    my $DateTime = $Param{DateTime} || '';
    my $Language = $Param{Language} || 'en';

    return '' if !$DateTime;

    if ( $DateTime =~ m{\A(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\z} ) {
        my ( $Year, $Month, $Day, $Hour, $Minute, $Second ) = ( $1, $2, $3, $4, $5, $6 );

        if ( $Language eq 'de' ) {
            return "$Day.$Month.$Year $Hour:$Minute:$Second";
        }

        return "$Year-$Month-$Day $Hour:$Minute:$Second";
    }

    return $DateTime;
}

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

    my $DateTime = $Param{DateTime} || '';
    my $Language = $Param{Language} || 'en';

    return '-' if !$DateTime;

    if ( $DateTime !~ m{\A(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})\z} ) {
        return '-';
    }

    my ( $Year, $Month, $Day, $Hour, $Minute, $Second ) = ( $1, $2, $3, $4, $5, $6 );
    my $CreatedEpoch;

    eval {
        $CreatedEpoch = timelocal( $Second, $Minute, $Hour, $Day, $Month - 1, $Year - 1900 );
        1;
    } || return '-';

    my $AgeSeconds = time() - $CreatedEpoch;

    if ( $AgeSeconds < 0 ) {
        $AgeSeconds = 0;
    }

    my $Minutes = int( $AgeSeconds / 60 );
    my $Hours   = int( $Minutes / 60 );
    my $Days    = int( $Hours / 24 );

    if ( $Language eq 'de' ) {
        return $Days . ( $Days == 1 ? ' Tag' : ' Tage' ) if $Days;
        return $Hours . ( $Hours == 1 ? ' Stunde' : ' Stunden' ) if $Hours;
        return $Minutes . ( $Minutes == 1 ? ' Minute' : ' Minuten' );
    }

    return $Days . ( $Days == 1 ? ' day' : ' days' ) if $Days;
    return $Hours . ( $Hours == 1 ? ' hour' : ' hours' ) if $Hours;
    return $Minutes . ( $Minutes == 1 ? ' minute' : ' minutes' );
}

1;
