<?php
/**
 * @file classes/decision/Repository.php
 *
 * Copyright (c) 2014-2024 Simon Fraser University
 * Copyright (c) 2000-2024 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class Repository
 *
 * @brief A repository to find and manage editorial decisions.
 */

namespace PKP\decision;

use APP\core\Application;
use APP\core\Request;
use APP\core\Services;
use APP\decision\Decision;
use APP\facades\Repo;
use APP\notification\Notification;
use APP\notification\NotificationManager;
use APP\submission\Submission;
use Exception;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\App;
use PKP\context\Context;
use PKP\core\Core;
use PKP\core\PKPApplication;
use PKP\db\DAORegistry;
use PKP\log\event\PKPSubmissionEventLogEntry;
use PKP\log\SubmissionLog;
use PKP\observers\events\DecisionAdded;
use PKP\plugins\Hook;
use PKP\security\Role;
use PKP\security\Validation;
use PKP\services\PKPSchemaService;
use PKP\stageAssignment\StageAssignment;
use PKP\stageAssignment\StageAssignmentDAO;
use PKP\submission\reviewRound\ReviewRoundDAO;
use PKP\submissionFile\SubmissionFile;
use PKP\validation\ValidatorFactory;

abstract class Repository
{
    /** @var DAO $dao */
    public $dao;

    /** @var string $schemaMap The name of the class to map this entity to its schemaa */
    public $schemaMap = maps\Schema::class;

    /** @var Request $request */
    protected $request;

    /** @var PKPSchemaService<Decision> $schemaService */
    protected $schemaService;


    public function __construct(DAO $dao, Request $request, PKPSchemaService $schemaService)
    {
        $this->dao = $dao;
        $this->request = $request;
        $this->schemaService = $schemaService;
    }

    /** @copydoc DAO::newDataObject() */
    public function newDataObject(array $params = []): Decision
    {
        $object = $this->dao->newDataObject();
        if (!empty($params)) {
            $object->setAllData($params);
        }
        return $object;
    }

    /** @copydoc DAO::get() */
    public function get(int $id, ?int $submissionId = null): ?Decision
    {
        return $this->dao->get($id, $submissionId);
    }

    /** @copydoc DAO::exists() */
    public function exists(int $id, ?int $submissionId = null): bool
    {
        return $this->dao->exists($id, $submissionId);
    }

    /** @copydoc DAO::getCollector() */
    public function getCollector(): Collector
    {
        return App::make(Collector::class);
    }

    /**
     * Get an instance of the map class for mapping
     * decisions to their schema
     */
    public function getSchemaMap(): maps\Schema
    {
        return app('maps')->withExtensions($this->schemaMap);
    }

    /**
     * Validate properties for a decision
     *
     * Perform validation checks on data used to add a decision. It is not
     * possible to edit a decision.
     *
     * @param array $props A key/value array with the new data to validate
     * @param Submission $submission The submission for this decision
     *
     * @return array A key/value array with validation errors. Empty if no errors
     */
    public function validate(array $props, DecisionType $decisionType, Submission $submission, Context $context): array
    {
        // Return early if no valid decision type exists
        if (!isset($props['decision']) || $props['decision'] !== $decisionType->getDecision()) {
            return ['decision' => [__('editor.submission.workflowDecision.typeInvalid')]];
        }

        // Return early if an invalid submission ID is passed
        if (!isset($props['submissionId']) || $props['submissionId'] !== $submission->getId()) {
            return ['submissionId' => [__('editor.submission.workflowDecision.submissionInvalid')]];
        }

        $validator = ValidatorFactory::make(
            $props,
            $this->schemaService->getValidationRules($this->dao->schema, []),
        );

        // Check required
        ValidatorFactory::required(
            $validator,
            null,
            $this->schemaService->getRequiredProps($this->dao->schema),
            $this->schemaService->getMultilingualProps($this->dao->schema),
            [],
            ''
        );

        $validator->after(function ($validator) use ($props, $decisionType, $submission, $context) {
            // The decision stage id must match the decision type's stage id
            // and the submission's current workflow stage
            if ($props['stageId'] !== $decisionType->getStageId()
                    || $props['stageId'] !== $submission->getData('stageId')) {
                $validator->errors()->add('decision', __('editor.submission.workflowDecision.invalidStage'));
            }

            // The editorId must match an existing editor
            if (isset($props['editorId'])) {
                $user = Repo::user()->get((int) $props['editorId']);
                if (!$user) {
                    $validator->errors()->add('editorId', __('editor.submission.workflowDecision.invalidEditor'));
                }
            }

            // A recommendation can not be made if the submission does not
            // have at least one assigned editor who can make a decision
            if ($this->isRecommendation($decisionType->getDecision())) {
                /** @var StageAssignmentDAO $stageAssignmentDao  */
                $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO');
                $assignedEditorIds = $stageAssignmentDao->getDecidingEditorIds($submission->getId(), $decisionType->getStageId());
                if (!$assignedEditorIds) {
                    $validator->errors()->add('decision', __('editor.submission.workflowDecision.requiredDecidingEditor'));
                }
            }

            // Validate the review round
            if (isset($props['reviewRoundId'])) {
                // The decision must be taken during a review stage
                if (!$decisionType->isInReview() && !$validator->errors()->get('reviewRoundId')) {
                    $validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.invalidReviewRoundStage'));
                }

                // The review round must exist and be related to the correct submission.
                if (!$validator->errors()->get('reviewRoundId')) {
                    $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
                    $reviewRound = $reviewRoundDao->getById($props['reviewRoundId']);
                    if (!$reviewRound) {
                        $validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.invalidReviewRound'));
                    } elseif ($reviewRound->getSubmissionId() !== $submission->getId()) {
                        $validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.invalidReviewRoundSubmission'));
                    }
                }
            } elseif ($decisionType->isInReview()) {
                $validator->errors()->add('reviewRoundId', __('editor.submission.workflowDecision.requiredReviewRound'));
            }

            // Allow the decision type to add validation checks
            $decisionType->validate($props, $submission, $context, $validator, isset($reviewRound) ? $reviewRound->getId() : null);
        });

        $errors = [];

        if ($validator->fails()) {
            $errors = $this->schemaService->formatValidationErrors($validator->errors());
        }

        Hook::call('Decision::validate', [&$errors, $props]);

        return $errors;
    }

    /**
     * Record an editorial decision
     */
    public function add(Decision $decision): int
    {
        // Actions are handled separately from the decision object
        $actions = $decision->getData('actions') ?? [];
        $decision->unsetData('actions');

        // Set the review round automatically from the review round id
        if ($decision->getData('reviewRoundId')) {
            $decision->setData('round', $this->getRoundByReviewRoundId($decision->getData('reviewRoundId')));
        }
        $decision->setData('dateDecided', Core::getCurrentDate());
        $id = $this->dao->insert($decision);
        Hook::call('Decision::add', [$decision]);

        $decision = $this->get($id);

        $decisionType = $decision->getDecisionType();
        $submission = Repo::submission()->get($decision->getData('submissionId'));
        $editor = Repo::user()->get($decision->getData('editorId'));
        $decision = $this->get($decision->getId());
        $context = Application::get()->getRequest()->getContext();
        if (!$context || $context->getId() !== $submission->getData('contextId')) {
            $context = Services::get('context')->get($submission->getData('contextId'));
        }

        // Log the decision
        $eventLog = Repo::eventLog()->newDataObject([
            'assocType' => PKPApplication::ASSOC_TYPE_SUBMISSION,
            'assocId' => $submission->getId(),
            'eventType' => $this->isRecommendation($decisionType->getDecision())
                ? PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_RECOMMENDATION
                : PKPSubmissionEventLogEntry::SUBMISSION_LOG_EDITOR_DECISION,
            'userId' => Validation::loggedInAs() ?? $this->request->getUser()?->getId(),
            'editorName' => $editor->getFullName(),
            'message' => $decisionType->getLog(),
            'isTranslated' => false,
            'dateLogged' => Core::getCurrentDate()
        ]);
        Repo::eventLog()->add($eventLog);

        // Allow the decision type to perform additional actions
        $decisionType->runAdditionalActions($decision, $submission, $editor, $context, $actions);

        try {
            event(new DecisionAdded(
                $decision,
                $decisionType,
                $submission,
                $editor,
                $context,
                $actions
            ));
        } catch (Exception $e) {
            error_log($e->getMessage());
            error_log($e->getTraceAsString());
        }

        $this->updateNotifications($decision, $decisionType, $submission);

        return $id;
    }

    /**
     * Delete all decisions by the submission ID
     */
    public function deleteBySubmissionId(int $submissionId)
    {
        $decisionIds = $this->getCollector()
            ->filterBySubmissionIds([$submissionId])
            ->getIds();

        foreach ($decisionIds as $decisionId) {
            $this->dao->deleteById($decisionId);
        }
    }

    /**
     * Get a decision type by the DECISION::* constant
     */
    public function getDecisionType(int $decision): ?DecisionType
    {
        $decision = $this->getDecisionTypes()->first(function (DecisionType $decisionType) use ($decision) {
            return $decisionType->getDecision() === $decision;
        });

        return $decision ?? null;
    }

    /**
     * Find the most recent revisions decision that is still active. An active
     * decision is one that is not overriden by any other decision.
     */
    public function getActivePendingRevisionsDecision(int $submissionId, int $stageId, int $decision = Decision::PENDING_REVISIONS): ?Decision
    {
        $postReviewDecisions = [Decision::SEND_TO_PRODUCTION];
        $revisionDecisions = $stageId === WORKFLOW_STAGE_ID_EXTERNAL_REVIEW 
            ? [Decision::PENDING_REVISIONS, Decision::RESUBMIT] 
            : [Decision::PENDING_REVISIONS_INTERNAL, Decision::RESUBMIT_INTERNAL];

        if (!in_array($decision, $revisionDecisions)) {
            return null;
        }

        $revisionsDecisions = $this->getCollector()
            ->filterBySubmissionIds([$submissionId])
            ->getMany();

        // Most recent decision first
        $revisionsDecisions = $revisionsDecisions->reverse();

        $pendingRevisionDecision = null;
        foreach ($revisionsDecisions as $revisionDecision) {
            if (in_array($revisionDecision->getData('decision'), $postReviewDecisions)) {
                // Decisions at later stages do not override the pending revisions one.
                continue;
            } elseif ($revisionDecision->getData('decision') == $decision) {
                if ($revisionDecision->getData('stageId') == $stageId) {
                    $pendingRevisionDecision = $revisionDecision;
                    // Only the last pending revisions decision is relevant.
                    break;
                } else {
                    // Both internal and external pending revisions decisions are
                    // valid at the same time. Continue to search.
                    continue;
                }
            } else {
                break;
            }
        }


        return $pendingRevisionDecision;
    }

    /**
     * Have any submission files been uploaded to the revision file stage since
     * this decision was taken?
     */
    public function revisionsUploadedSinceDecision(Decision $decision, int $submissionId): bool
    {
        $stageId = $decision->getData('stageId');
        $round = $decision->getData('round');
        $sentRevisions = false;

        $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
        $reviewRound = $reviewRoundDao->getReviewRound($submissionId, $stageId, $round);

        $fileStage = $stageId === WORKFLOW_STAGE_ID_EXTERNAL_REVIEW
            ? SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION
            : SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_REVISION;

        $submissionFiles = Repo::submissionFile()
            ->getCollector()
            ->filterByReviewRoundIds([$reviewRound->getId()])
            ->filterByFileStages([$fileStage])
            ->getMany();

        foreach ($submissionFiles as $submissionFile) {
            if ($submissionFile->getData('updatedAt') > $decision->getData('dateDecided')) {
                $sentRevisions = true;
                break;
            }
        }

        return $sentRevisions;
    }

    /**
     * Get a list of all the decision types available
     *
     * @return Collection<int,DecisionType>
     */
    abstract public function getDecisionTypes(): Collection;

    /**
     * Get a list of the decline decision types
     *
     * @return DecisionType[]
     */
    abstract public function getDeclineDecisionTypes(): array;

    /**
     * Get a list of the decision types that a recommending user is
     * allowed to make given a submission stage id.
     *
     * @return DecisionType[]
     */
    abstract public function getDecisionTypesMadeByRecommendingUsers(int $stageId): array;

    /**
     * Is the given decision a recommendation?
     */
    public function isRecommendation(int $decision): bool
    {
        return in_array($decision, [
            Decision::RECOMMEND_ACCEPT,
            Decision::RECOMMEND_DECLINE,
            Decision::RECOMMEND_PENDING_REVISIONS,
            Decision::RECOMMEND_RESUBMIT,
        ]);
    }

    protected function getRoundByReviewRoundId(int $reviewRoundId): int
    {
        $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
        $reviewRound = $reviewRoundDao->getById($reviewRoundId);
        return $reviewRound->getData('round');
    }

    /**
     * Update notifications controlled by the NotificationManager
     */
    protected function updateNotifications(Decision $decision, DecisionType $decisionType, Submission $submission)
    {
        $notificationMgr = new NotificationManager();

        // Update editor decision and pending revisions notifications.
        $notificationTypes = $this->getReviewNotificationTypes();
        if ($editorDecisionNotificationType = $notificationMgr->getNotificationTypeByEditorDecision($decision)) {
            array_unshift($notificationTypes, $editorDecisionNotificationType);
        }

        $authorIds = [];
        /** @var StageAssignmentDAO $stageAssignmentDao */
        $stageAssignmentDao = DAORegistry::getDAO('StageAssignmentDAO');
        $result = $stageAssignmentDao->getBySubmissionAndRoleIds($submission->getId(), [Role::ROLE_ID_AUTHOR], $decisionType->getStageId());
        /** @var StageAssignment $stageAssignment */
        while ($stageAssignment = $result->next()) {
            $authorIds[] = (int) $stageAssignment->getUserId();
        }

        $notificationMgr->updateNotification(
            Application::get()->getRequest(),
            $notificationTypes,
            $authorIds,
            Application::ASSOC_TYPE_SUBMISSION,
            $submission->getId()
        );

        // Update submission notifications
        $submissionNotificationTypes = $this->getSubmissionNotificationTypes($decision);
        if (count($submissionNotificationTypes)) {
            $notificationMgr->updateNotification(
                Application::get()->getRequest(),
                $submissionNotificationTypes,
                null,
                Application::ASSOC_TYPE_SUBMISSION,
                $submission->getId()
            );
        }
    }

    /**
     * Get the notification types related to a review stage
     *
     * @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants
     */
    abstract protected function getReviewNotificationTypes(): array;

    /**
     * Get additional notifications to be updated on a submission
     *
     * @return int[] One or more of the Notification::NOTIFICATION_TYPE_ constants
     */
    protected function getSubmissionNotificationTypes(Decision $decision): array
    {
        switch ($decision->getData('decision')) {
            case Decision::ACCEPT:
                return [
                    Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR,
                    Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS
                ];
            case Decision::SEND_TO_PRODUCTION:
                return [
                    Notification::NOTIFICATION_TYPE_ASSIGN_COPYEDITOR,
                    Notification::NOTIFICATION_TYPE_AWAITING_COPYEDITS,
                    Notification::NOTIFICATION_TYPE_ASSIGN_PRODUCTIONUSER,
                    Notification::NOTIFICATION_TYPE_AWAITING_REPRESENTATIONS,
                ];
        }
        return [];
    }
}
