<?php

namespace Velis\Bpm\Ticket;

use Cia\Ticket\Remark\Image as RemarkFile;
use Exception;
use InvalidArgumentException;
use LogicException;
use Ticket\Notification;
use User\User;
use Velis\App;
use Velis\Arrays;
use Velis\Bpm\Email\Account;
use Velis\Bpm\Email\Attachment;
use Velis\Bpm\Email\Message;
use Velis\Bpm\Ticket\Post\Checklist;
use Velis\Bpm\Ticket\Post\File;
use Velis\Bpm\Ticket\Service\ActionResult;
use Velis\Bpm\Ticket\Service\ContextInterface;
use Velis\Db\Lob;
use Velis\Filter;
use Velis\Lang;
use Velis\Model\DataObject\NoColumnsException;
use Velis\Mvc\Controller\AccessException;
use Velis\Output;

/**
 * Tickets service
 * @author Olek Procki <olo@velis.pl>
 */
class Service
{
    /**
     * Ticket object fields to be unfiltered
     * @var array
     */
    protected static $_unfilteredTicketFields = [
        'direction',
        'description',
    ];

    /**
     * Ticket instance
     * @var Ticket
     */
    protected $_ticket;


    /**
     * Current ticket instance
     * @var Ticket
     */
    protected $_currTicket;


    /**
     * Incoming email message
     * @var Message
     */
    protected $_emailMessage;


    /**
     * Incoming email message body
     * @var string
     */
    protected $_originalMessageBody;


    /**
     * Grouped logs for single action
     * @var array
     */
    protected $_connectedLogs = [];



    /**
     * Action log entry
     * @var Log
     */
    protected $_log;


    /**
     * Ticket post
     * @var Post
     */
    protected $_post;


    /**
     * Related ticket post
     * @var Post
     */
    protected $_relatedTicketPost;


    /**
     * Ticket changes
     * @var array
     */
    protected $_changes = [];


    /**
     * Action params
     * @var Filter
     */
    protected $_params;


    /**
     * Action non interrupting errors
     * @var array
     */
    protected $_errors = [];


    /**
     * Service constructor
     */
    public function __construct()
    {
        $this->_params = new Filter([]);
    }


    /**
     * Returns list of unfiltered ticket fields
     * @return array
     */
    public function getUnfilteredTicketFields(): array
    {
        return static::$_unfilteredTicketFields;
    }


    /**
     * Creates new ticket
     *
     * @param mixed $params
     * @param ContextInterface|null $context
     * @return ActionResult
     * @throws Exception
     */
    public function create($params, ContextInterface $context = null): ActionResult
    {
        $this->_prepare($params);

        // set ticket instance
        $this->_createTicketInstance($context);

        // set current user as owner if not set
        if (!$this->_ticket['owner_user_id'] && !$this->_ticket['owner_person_id']) {
            $this->_ticket['owner_user_id'] = App::$user->id();
        }

        // execute pre create $context method
        if ($context) {
            $context->preAction($this->_ticket);
        }

        // connect trigger
        $this->_ticket->on('add', $this->_getOnCreatedClosure($context));

        // add ticket
        $this->_ticket->add();

        return new ActionResult(
            $this->_ticket,
            $this->_log,
            $this->_errors
        );
    }


    /**
     * Returns ticket 'add' trigger closure
     * @param ContextInterface|null $context
     * @return callable
     */
    protected function _getOnCreatedClosure(ContextInterface $context = null): callable
    {
        $closure = function (Ticket $ticket) use ($context) {
            $this->_log = $ticket->getFirstLog();

            // execute post create actions
            $this
                ->_addObservers()
                ->_connectRelated()
                ->_connectEmail()
            ;

            if ($context) {
                $context->postAction(
                    $this->_ticket,
                    $this->_log,
                    $this->_connectedLogs,
                    $this->_changes
                );
            }

            $this->_cleanCachedNote($this->_ticket['ticket_type_id']);

            // create post if required
            if (
                $this->_params['files']
                || $this->_params['fileHashes']
                || $this->_params['ticketFiles']
                || $this->_params['remarkFiles']
                || $this->_connectedLogs
                || $this->_emailMessage
                || $this->_params['post']['content']
                || Checklist::containChecklist($this->_ticket['description'])
            ) {
                $this->_post = new Post(
                    $this->_params->get('post', [
                        'direction',
                        'content'
                    ])
                );
                $this->_post['ticket_post_visibility_id'] = $this->_params['protected_visibility'] ? Post::TYPE_PROTECTED : Post::TYPE_PUBLIC;

                if (!$this->_post['content']) {
                    if ($this->_params['files'] || $this->_params['fileHashes'] || $this->_params['emailAttachments'] || $this->_params['ticketFiles'] || $this->_params['remarkFiles']) {
                        $this->_post['content'] = '{GENERAL_ATTACHMENTS}';
                    }

                    if (Checklist::containChecklist($this->_ticket['description'])) {
                        $this->_post['content'] = $this->_ticket['description'];
                    }
                }

                $this->_log->setPost($this->_addPost());

                if (Checklist::containChecklist($this->_ticket['description'])) {
                    $description = $this->_ticket['description'];
                    $description = str_replace(':chl', '', $description);
                    $description = str_replace(':/chl', '', $description);
                    $this->_ticket['description'] = $description;
                    $this->_ticket->modify();
                }

                $this->_log->modify();

                if ($this->_connectedLogs) {
                    $this->_post->connectLogs($this->_connectedLogs);
                }
            }

            // change notification visibility if defaults overridden
            if ($this->_params['protected_visibility']) {
                $this->_log->setNotificationVisibility(Post::TYPE_PROTECTED);
            }

            if ($context) {
                $context->preNotify($this->_ticket, $this->_log);
            }

            if (!$this->_params['disable_notifications']) {
                // send notification
                $this->_log->notify();
            }
        };

        return $closure->bindTo($this);
    }


    /**
     * Updates ticket
     *
     * @param mixed $params
     * @param ContextInterface|null $context
     * @return ActionResult
     * @throws AccessException
     * @throws \Velis\Exception
     * @throws NoColumnsException
     */
    public function update($params, ContextInterface $context = null): ActionResult
    {
        $this->_prepare($params);

        // set ticket instance
        $this->_createTicketInstance($context);

        if (!isset($this->_currTicket)) {
            $this->_currTicket = $this->_ticket->getCurrentInstance();
        }

        // check access for logged user
        if (!$this->_currTicket->checkAccess()) {
            throw new AccessException(Lang::get('GENERAL_PERM_DENIED'));
        }

        if (
            ($this->_ticket['responsible_user_id'] && (int)$this->_ticket['responsible_user_id'] !== $this->_currTicket['responsible_user_id'])
            || ($this->_ticket['responsible_department_id'] && $this->_currTicket['responsible_user_id'])
        ) {
            if ($params['add_to_observers'] && $params['add_to_observers'] != (int)$this->_ticket['responsible_user_id']) {
                try {
                    $logId = $this->_ticket->addObserver((int) $params['add_to_observers']);
                    $this->_connectedLogs[] = $logId;
                } catch (Exception $e) {
                    // silently step over
                }
            }

            try {
                $logId = $this->_ticket->removeObserver((int)$this->_ticket['responsible_user_id']);
                $this->_connectedLogs[] = $logId;
            } catch (Exception $e) {
                // silently step over
            }
        }

        // execute pre save $context method
        if ($context) {
            $context->preAction($this->_ticket);
        }

        // create post instance
        $this->_post = new Post(
            $this->_params->get('post', [
                'direction',
                'content'
            ])
        );

        if (App::$user->isLogged()) {
            $this->_post->setAuthor(App::$user);
        }

        $this->_ticket->setCurrentPost($this->_post);

        if (!$this->_ticket['direction']) {
            $this->_ticket['direction'] = $this->_post['direction'] ?: $this->_currTicket->direction;
        }

        // modify ticket
        $this->_ticket->on('modify', $this->_getOnSavedClosure($context));
        $this->_ticket->modify(true);

        return new ActionResult(
            $this->_ticket,
            $this->_log,
            $this->_errors
        );
    }


    /**
     * Returns ticket 'modify' trigger closure
     * @param ContextInterface $context
     * @return callable
     */
    public function _getOnSavedClosure(ContextInterface $context = null): callable
    {
        $closure = function (Ticket $ticket) use ($context) {
            $this->_cleanCachedNote($ticket->id());

            // check and log what has changed
            $this->_changes = $ticket->getDiff();

            // force public visibility for customers
            // or when public notes after ticket change are enabled in settings
            if (
                App::$user->hasCompany() ||
                (!empty($this->_changes) && App::$config->settings->ticketChangePublicNotes && !isset($this->_post['content']))
            ) {
                $this->_post['ticket_post_visibility_id'] = Post::TYPE_PUBLIC;
            }

            // create log instance
            $this->_log = Log::factory($ticket, $this->_post);

            // execute post save handler
            if ($context) {
                $context->postAction(
                    $this->_ticket,
                    $this->_log,
                    $this->_connectedLogs,
                    $this->_changes
                );
            }

            if ($this->_log->hasLabels()) {
                $this->_changes = array_merge($this->_changes, $this->_log->labelLog());
            }

            if ($this->_shouldAddPost()) {
                $this->_log['ticket_log_action_id'] = !empty($this->_changes) ? Log::EDIT : Log::POST_ADD;
                $this->_log->setPost($this->_addPost());

                if (!empty($this->_changes)) {
                    // Adding of additional log in case if ticket attributes changed and post will be added with content
                    if (strlen(trim($this->_post['content'] ?? '')) || isset($this->_params['files'])) {
                        $log = Log::factory($ticket, $this->_post);
                        $log['ticket_log_action_id'] = Log::POST_ADD;
                        $log['description'] = Output::cut($this->_post['content'], 125);

                        $log->add();
                    }
                    $this->_log['description'] = "* " . implode("\n* ", $this->_changes);
                } else {
                    $this->_log['description'] = Output::cut($this->_post['content'], 125);
                }

                if (
                    ($this->_ticket->offsetExists('responsible_user_id') && $this->_ticket['responsible_user_id'] != $this->_currTicket['responsible_user_id'])
                    || ($this->_ticket->offsetExists('responsible_department_id') && $this->_ticket['responsible_department_id'] != $this->_currTicket['responsible_department_id'])
                ) {
                    $this->_log->responsibleUserChanged = true;
                    $this->_log['ticket_log_action_id'] = Log::DELEGATE;
                }

                $this->_log->add(true);

                if ($this->_connectedLogs) {
                    $this->_post->connectLogs($this->_connectedLogs);
                }

                if ($context) {
                    $context->preNotify($this->_ticket, $this->_log);
                }

                if (!$this->_params['disable_notifications']) {
                    // send notification
                    $this->_log->notify();
                }
            }
        };

        return $closure->bindTo($this);
    }


    /**
     * Returns true if post must be added
     * @return bool
     */
    protected function _shouldAddPost()
    {
        return !empty($this->_changes)
                || strlen(trim($this->_post['content']))
                || $this->_params['files']
                || $this->_connectedLogs;
    }


    /**
     * Sets parameters
     * @param Filter $params
     */
    protected function _setParams($params)
    {
        if ($params instanceof Filter) {
            $this->_params = $params;
        } else {
            $this->_params = new Filter($params, true);
        }
    }


    /**
     * Prepares service before action execution
     *
     * @param mixed $params
     * @return self
     */
    protected function _prepare($params): self
    {
        $this->_errors = [];
        $this->_setParams($params);
        return $this;
    }


    /**
     * Initialize ticket object
     * @param ContextInterface $context
     * @return Ticket
     * @throws InvalidArgumentException
     */
    protected function _createTicketInstance(ContextInterface $context = null): Ticket
    {
        // 'ticket' key must be accessed as raw value first
        // otherwise reading it filtered will cause it might be permanently filtered when passed as object
        $ticketData = $this->_params->getRaw('ticket');

        if ($ticketData instanceof Ticket) {
            return $this->_ticket = $ticketData;
        }
        if (!is_array($ticketData)) {
            throw new InvalidArgumentException(Lang::get('GENERAL_NO_PARAMETERES'));
        }

        // update $ticketData with filtered value
        $ticketData = $this->_params->get('ticket', static::$_unfilteredTicketFields);

        if (!$ticketData['ticket_type_id'] && $context) {
            $ticketData['ticket_type_id'] = Arrays::getFirst(explode('\\', get_class($context)));
        }

        $this->_ticket = Factory::create($ticketData);

        return $this->_ticket;
    }


    /**
     * Adds observers to created ticket
     * @return self
     */
    protected function _addObservers(): self
    {
        if (!is_array($this->_params['observers'])) {
            return $this;
        }

        // make sure we don't have duplicated items
        $observersIds = array_unique($this->_params['observers']);

        // keep in mind that it's possible here to retrieve Person instead of User as ticket observer
        $observerUsers = User::bufferedInstance($observersIds);

        // iterate observers and add them one by one
        foreach ($observersIds as $observerId) {
            $observerUser = $observerUsers[$observerId];
            try {
                $this->_connectedLogs[] = $this->_ticket->addObserver($observerId);

                // no need to send notification for currently logged user
                if (App::$user->id() == $observerId) {
                    continue;
                }

                $isInternalObserver = false;

                if ($observerUser instanceof User) {
                    if (isset($observerUser['is_internal'])) {
                        // this should work when acl.user has this column computed by user's roles
                        $isInternalObserver = $observerUser['is_internal'];
                    } else {
                        // otherwise rely on user's company assignment
                        $isInternalObserver = !$observerUser->hasCompany();
                    }
                }

                // send notification for internal observers or public visibility
                $notifyObservers = $isInternalObserver || !$this->_params['protected_visibility'];
                if ($notifyObservers && !$this->_params['disable_notifications']) {
                    $notification = new Notification($this->_ticket, Notification::TICKET_OBSERVER_ADDED);

                    $observers = $this->_ticket->getObservers();
                    if ($notification->send($observers[$observerId])) {
                        $this->_errors[] = Lang::get('GENERAL_NOTIFICATION_EMAIL_SENT');
                    }
                }
            } catch (Exception $e) {
                if ($e instanceof LogicException) {
                    $this->_errors[] = $e->getMessage();
                } else {
                    $this->_errors[] = Lang::get('GENERAL_SAVING_ERROR') . ': ' . $e->getMessage();
                }
            }
        }

        return $this;
    }

    /**
     * Checks whether the user can remove the observer
     *
     * @param Ticket $ticket
     * @param User $observer
     * @param User $user
     *
     * @return bool
     */
    public function canRemoveObserver($ticket, $observer, $user = null)
    {
        $user = $user ?? App::$user;
        return $user->hasPriv('Ticket', 'ObserverAdd');
    }

    /**
     * Connect created ticket with rel ticket
     * @return Service
     */
    protected function _connectRelated(): self
    {
        if ($this->_params->getInt('rel_ticket')) {
            $relTicket = Ticket::instance($this->_params->getInt('rel_ticket'));
            if ($relTicket && $relTicket->checkAccess()) {
                $relTicket->addLink($this->_ticket->id());
                $this->_ticket->addLinkedTicket($relTicket);
            }
        }
        return $this;
    }


    /**
     * Connects with email message
     * @return Service
     * @throws Exception
     */
    protected function _connectEmail(): self
    {
        if ($this->_params->getInt('email_message_id')) {
            if ($this->_emailMessage = Message::instance($this->_params->getInt('email_message_id'))) {
                $this->_emailMessage['is_pending'] = 0;
                $this->_emailMessage['ticket_id']  = $this->_ticket->id();

                $this->_originalMessageBody = $this->_emailMessage['body_html'];

                unset($this->_emailMessage['body_html']);

                $this->_emailMessage->modify();

                Account::resetInboxCount();
            }
        }
        return $this;
    }


    /**
     * Creates ticket post
     * @return Post
     */
    protected function _addPost(): Post
    {
        $this->_post['ticket_id'] = $this->_ticket->id();

        if (empty($this->_post['user_id']) && empty($this->_post['person_id'])) {
            $this->_post->setAuthor();
        }

        if (!isset($this->_post['email_account_id'])) {
            unset($this->_post['email_footer_id']);
        }

        try {
            $this->_post->add(true);
            $this->_post->load();

            $this->_addPostAttachments();

            if ($this->_emailMessage) {
                if (is_array($this->_params['emailAttachments'])) {
                    /** @var Attachment[] $attachments */
                    if ($attachments = Attachment::instance($this->_params['emailAttachments'])) {
                        foreach ($attachments as $attachment) {
                            $file = $attachment->toPostFile($this->_post);
                            if ($attachment->cid) {
                                $this->_emailMessage['body_html'] = new Lob(
                                    str_ireplace(
                                        'cid:' . trim($attachment->cid, '<>'),
                                        'http://' . $_SERVER['SERVER_NAME'] . $file->getDownloadUrl(),
                                        (string)($this->_originalMessageBody ? $this->_originalMessageBody : $this->_emailMessage->getCurrentInstance()->body_html)
                                    )
                                );
                            }
                        }
                    } else {
                        unset($this->_emailMessage['body_html']);
                    }
                } else {
                    unset($this->_emailMessage['body_html']);
                }

                $this->_emailMessage['ticket_post_id'] = $this->_post->id();
                $this->_emailMessage->modify();
            }
        } catch (Exception $e) {
            $this->_errors[] = Lang::get('GENERAL_SAVING_ERROR') . ': ' . $e->getMessage();
        }

        return $this->_post;
    }


    /**
     * Adds post attachments
     */
    protected function _addPostAttachments(): self
    {
        if (
            (!$this->_params['files'] || !is_array($this->_params['files']))
            && (!$this->_params['ticketFiles'] || !is_array($this->_params['ticketFiles']))
            && (!$this->_params['remarkFiles'] || !is_array($this->_params['remarkFiles']))
        ) {
            return $this;
        }

        foreach ($this->_params['files'] as $fileData) {
            if (!strlen($fileData['name'])) {
                continue;
            }
            try {
                $file = new File([
                    'ticket_post_id' => $this->_post->id(),
                    'filename'       => $fileData['name'],
                    'type'           => $fileData['type'],
                    'tmp_name'       => $fileData['tmp_name'],
                    'hash'           => md5('ticket-post-file-' . microtime()),
                    'size'           => $fileData['size'],
                    'width'          => $fileData['width'],
                    'height'         => $fileData['height']
                ]);
                $file->setPost($this->_post);

                if ($this->_params['versionedFileId']) {
                    $file['filename'] = str_replace($this->_params['versionedFileId'] . '-', '', $file['filename']);

                    $versionInfo = [
                        'versionedFileId' => $this->_params['versionedFileId'],
                        'versionComment'  => $this->_params['fileComment'],
                    ];
                } else {
                    $versionInfo = null;
                }

                $file->add($versionInfo);
            } catch (Exception $e) {
                $this->_errors[] = $e->getMessage();
            }
        }

        if (is_array($this->_params['ticketFiles'])) {
            foreach (File::instance($this->_params['ticketFiles']) as $file) {
                try {
                    if (!$this->_relatedTicketPost || $this->_relatedTicketPost->id() != $file['ticket_post_id']) {
                        $this->_relatedTicketPost = $file->getPost()->load();
                    }

                    $file->duplicate($this->_post, $this->_relatedTicketPost);
                } catch (Exception $e) {
                    $this->_errors[] = $e->getMessage();
                }
            }
        }

        if (is_array($this->_params['remarkFiles'])) {
            foreach (RemarkFile::instance($this->_params['remarkFiles']) as $file) {
                try {
                    $file->toPostFile($this->_post);
                } catch (Exception $e) {
                    $this->_errors[] = $e->getMessage();
                }
            }
        }

        return $this;
    }


    /**
     * Function add note identifier to session
     * @param string $noteIdentifier
     */
    protected function _cleanCachedNote($noteIdentifier)
    {
        if (!is_array(App::$session->noteCache)) {
            App::$session->noteCache = array();
        }
        App::$session->noteCache[] = 'cached-note-' . $noteIdentifier;
    }


    /**
     * Returns class name to be used for ticket posts
     */
    public function getPostClass(): string
    {
        return Post::class;
    }


    /**
     * Registers error message
     * @param string $message
     */
    public function error($message)
    {
        $this->_errors[] = $message;
    }
}
