<?php
/**
 * @file classes/publication/Repository.php
 *
 * Copyright (c) 2014-2020 Simon Fraser University
 * Copyright (c) 2000-2020 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 publications.
 */

namespace PKP\publication;

use APP\core\Application;
use APP\core\Request;
use APP\core\Services;
use APP\facades\Repo;
use APP\file\PublicFileManager;
use APP\publication\DAO;
use APP\publication\Publication;
use APP\submission\Submission;
use Illuminate\Support\Enumerable;
use Illuminate\Support\LazyCollection;
use PKP\context\Context;
use PKP\core\Core;
use PKP\core\PKPApplication;
use PKP\db\DAORegistry;
use PKP\file\TemporaryFileManager;
use PKP\log\event\PKPSubmissionEventLogEntry;
use PKP\observers\events\PublicationPublished;
use PKP\observers\events\PublicationUnpublished;
use PKP\plugins\Hook;
use PKP\security\Validation;
use PKP\services\PKPSchemaService;
use PKP\submission\Genre;
use PKP\submission\PKPSubmission;
use PKP\userGroup\UserGroup;
use PKP\validation\ValidatorFactory;

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

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

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

    /** @var PKPSchemaService<Publication> */
    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 = []): Publication
    {
        $object = $this->dao->newDataObject();
        if (!empty($params)) {
            $object->setAllData($params);
        }
        return $object;
    }

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

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

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

    /**
     * Get an instance of the map class for mapping
     * publications to their schema
     *
     * @param LazyCollection<int,UserGroup> $userGroups
     * @param Genre[] $genres
     */
    public function getSchemaMap(Submission $submission, LazyCollection $userGroups, array $genres): maps\Schema
    {
        return app('maps')->withExtensions(
            $this->schemaMap,
            [
                'submission' => $submission,
                'userGroups' => $userGroups,
                'genres' => $genres,
            ]
        );
    }

    /** @copydoc DAO:: getIdsBySetting()*/
    public function getIdsBySetting(string $settingName, $settingValue, int $contextId): Enumerable
    {
        return $this->dao->getIdsBySetting($settingName, $settingValue, $contextId);
    }

    /** @copydoc DAO:: getDateBoundaries()*/
    public function getDateBoundaries(Collector $query): object
    {
        return $this->dao->getDateBoundaries($query);
    }

    /**
     * Validate properties for a publication
     *
     * Perform validation checks on data used to add or edit a publication.
     *
     * @param Publication|null $publication The publication being edited. Pass `null` if creating a new publication
     * @param array $props A key/value array with the new data to validate
     *
     * @return array A key/value array with validation errors. Empty if no errors
     */
    public function validate(?Publication $publication, array $props, Submission $submission, Context $context): array
    {
        $allowedLocales = $context->getSupportedSubmissionLocales();
        $primaryLocale = $submission->getLocale();

        $errors = [];

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

        ValidatorFactory::required(
            $validator,
            $publication,
            $this->schemaService->getRequiredProps($this->dao->schema),
            $this->schemaService->getMultilingualProps($this->dao->schema),
            $allowedLocales,
            $primaryLocale
        );

        ValidatorFactory::allowedLocales($validator, $this->schemaService->getMultilingualProps($this->dao->schema), $allowedLocales);

        // The submissionId must match an existing submission
        if (isset($props['submissionId'])) {
            $validator->after(function ($validator) use ($props) {
                if (!$validator->errors()->get('submissionId')) {
                    $submission = Repo::submission()->get($props['submissionId']);
                    if (!$submission) {
                        $validator->errors()->add('submissionId', __('publication.invalidSubmission'));
                    }
                }
            });
        }

        // A title must be provided if the submission is not still in progress
        if (!$submission->getData('submissionProgress')) {
            $validator->after(function ($validator) use ($props, $publication, $primaryLocale) {
                $title = isset($props['title']) && isset($props['title'][$primaryLocale])
                    ? $props['title'][$primaryLocale]
                    : $publication?->getData('title', $primaryLocale);
                if (empty($title)) {
                    $validator->errors()->add('title.' . $primaryLocale, __('validator.required'));
                }
            });
        }

        // The urlPath must not be used in a publication attached to
        // any submission other than this publication's submission
        if (strlen($props['urlPath'] ?? '')) {
            $validator->after(function ($validator) use ($publication, $props) {
                if (!$validator->errors()->get('urlPath')) {
                    if (ctype_digit((string) $props['urlPath'])) {
                        $validator->errors()->add('urlPath', __('publication.urlPath.numberInvalid'));
                        return;
                    }

                    // If there is no submissionId the validator will throw it back anyway
                    if (is_null($publication) && !empty($props['submissionId'])) {
                        $submission = Repo::submission()->get($props['submissionId']);
                    } elseif (!is_null($publication)) {
                        $submission = Repo::submission()->get($publication->getData('submissionId'));
                    }

                    // If there's no submission we can't validate but the validator should
                    // fail anyway, so we can return without setting a separate validation
                    // error.
                    if (!$submission) {
                        return;
                    }

                    if ($this->dao->isDuplicateUrlPath($props['urlPath'], $submission->getId(), $submission->getData('contextId'))) {
                        $validator->errors()->add('urlPath', __('publication.urlPath.duplicate'));
                    }
                }
            });
        }

        // If a new file has been uploaded, check that the temporary file exists and
        // the current user owns it
        $user = Application::get()->getRequest()->getUser();
        ValidatorFactory::temporaryFilesExist(
            $validator,
            ['coverImage'],
            ['coverImage'],
            $props,
            $allowedLocales,
            $user ? $user->getId() : null
        );

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

        Hook::call('Publication::validate', [&$errors, $publication, $props, $allowedLocales, $primaryLocale]);

        return $errors;
    }

    /**
     * Validate a publication against publishing requirements
     *
     * This validation check should return zero errors before
     * publishing a publication.
     *
     * It should not be necessary to repeat validation rules from
     * self::validate(). These rules should be applied during all add
     * or edit actions.
     *
     * This additional check should be used when a journal or press
     * wants to enforce particular publishing requirements, such as
     * requiring certain metadata or other information.
     *
     * @param array $allowedLocales The context's supported submission locales
     * @param string $primaryLocale The submission's primary locale
     */
    public function validatePublish(Publication $publication, Submission $submission, array $allowedLocales, string $primaryLocale): array
    {
        $errors = [];

        // Don't allow declined submissions to be published
        if ($submission->getData('status') === PKPSubmission::STATUS_DECLINED) {
            $errors['declined'] = __('publication.required.declined');
        }

        // Don't allow a publication to be published before passing the review stage
        if ($submission->getData('stageId') <= WORKFLOW_STAGE_ID_EXTERNAL_REVIEW) {
            $errors['reviewStage'] = __('publication.required.reviewStage');
        }

        Hook::call('Publication::validatePublish', [&$errors, $publication, $submission, $allowedLocales, $primaryLocale]);

        return $errors;
    }

    /** @copydoc DAO::insert() */
    public function add(Publication $publication): int
    {
        $publication->stampModified();
        $publicationId = $this->dao->insert($publication);
        $publication = Repo::publication()->get($publicationId);
        $submission = Repo::submission()->get($publication->getData('submissionId'));

        // Move uploaded files into place and update the settings
        if ($publication->getData('coverImage')) {
            $userId = $this->request->getUser() ? $this->request->getUser()->getId() : null;

            $submissionContext = $this->request->getContext();
            if ($submissionContext->getId() !== $submission->getData('contextId')) {
                $submissionContext = Services::get('context')->get($submission->getData('contextId'));
            }

            $supportedLocales = $submissionContext->getSupportedSubmissionLocales();
            foreach ($supportedLocales as $localeKey) {
                if (!array_key_exists($localeKey, $publication->getData('coverImage'))) {
                    continue;
                }
                $value[$localeKey] = $this->_saveFileParam($publication, $submission, $publication->getData('coverImage', $localeKey), 'coverImage', $userId, $localeKey, true);
            }

            $this->edit($publication, ['coverImage' => $value]);
        }

        Hook::call('Publication::add', [&$publication]);

        // Update a submission's status based on the status of its publications
        Repo::submission()->updateStatus($submission);

        return $publication->getId();
    }

    /**
     * Create a new version of a publication
     *
     * Makes a copy of an existing publication, without the datePublished,
     * and makes copies of all associated objects.
     */
    public function version(Publication $publication): int
    {
        $newPublication = clone $publication;
        $newPublication->setData('id', null);
        $newPublication->setData('datePublished', null);
        $newPublication->setData('status', Submission::STATUS_QUEUED);
        $newPublication->setData('version', $publication->getData('version') + 1);
        $newPublication->stampModified();

        $request = Application::get()->getRequest();
        $context = $request->getContext();

        if ($context->getData(Context::SETTING_DOI_VERSIONING)) {
            $newPublication->setData('doiId', null);
        }
        $newId = $this->add($newPublication);
        $newPublication = Repo::publication()->get($newId);

        $authors = $publication->getData('authors');
        if (!empty($authors)) {
            foreach ($authors as $author) {
                $newAuthor = clone $author;
                $newAuthor->setData('id', null);
                $newAuthor->setData('publicationId', $newPublication->getId());
                $newAuthorId = Repo::author()->add($newAuthor);

                if ($author->getId() === $publication->getData('primaryContactId')) {
                    $this->edit($newPublication, ['primaryContactId' => $newAuthorId]);
                }
            }
        }

        if (!empty($newPublication->getData('citationsRaw'))) {
            $citationDao = DAORegistry::getDAO('CitationDAO'); /** @var \PKP\citation\CitationDAO $citationDao */
            $citationDao->importCitations($newPublication->getId(), $newPublication->getData('citationsRaw'));
        }

        $newPublication = Repo::publication()->get($newPublication->getId());

        Hook::call('Publication::version', [&$newPublication, $publication]);

        $submission = Repo::submission()->get($newPublication->getData('submissionId'));

        $eventLog = Repo::eventLog()->newDataObject([
            'assocType' => PKPApplication::ASSOC_TYPE_SUBMISSION,
            'assocId' => $submission->getId(),
            'eventType' => PKPSubmissionEventLogEntry::SUBMISSION_LOG_CREATE_VERSION,
            'userId' => Validation::loggedInAs() ?? $request->getUser()?->getId(),
            'message' => 'publication.event.versionCreated',
            'isTranslated' => false,
            'dateLogged' => Core::getCurrentDate(),
        ]);
        Repo::eventLog()->add($eventLog);

        return $newPublication->getId();
    }

    /** @copydoc DAO::update() */
    public function edit(Publication $publication, array $params): Publication
    {
        $submission = Repo::submission()->get($publication->getData('submissionId'));
        $userId = $this->request->getUser()?->getId();

        // Move uploaded files into place and update the params
        if (array_key_exists('coverImage', $params)) {
            $submissionContext = $this->request->getContext();
            if ($submissionContext->getId() !== $submission->getData('contextId')) {
                $submissionContext = Services::get('context')->get($submission->getData('contextId'));
            }

            $supportedLocales = $submissionContext->getSupportedSubmissionLocales();
            foreach ($supportedLocales as $localeKey) {
                if (!array_key_exists($localeKey, $params['coverImage'])) {
                    continue;
                }
                $params['coverImage'][$localeKey] = $this->_saveFileParam($publication, $submission, $params['coverImage'][$localeKey], 'coverImage', $userId, $localeKey, true);
            }
        }

        $newPublication = Repo::publication()->newDataObject(array_merge($publication->_data, $params));
        $newPublication->stampModified();

        Hook::call('Publication::edit', [&$newPublication, $publication, $params, $this->request]);

        $this->dao->update($newPublication, $publication);

        $newPublication = Repo::publication()->get($newPublication->getId());

        $submission = Repo::submission()->get($newPublication->getData('submissionId'));

        // Log an event when publication data is updated
        $eventLog = Repo::eventLog()->newDataObject([
            'assocType' => PKPApplication::ASSOC_TYPE_SUBMISSION,
            'assocId' => $submission->getId(),
            'eventType' => PKPSubmissionEventLogEntry::SUBMISSION_LOG_METADATA_UPDATE,
            'userId' => Validation::loggedInAs() ?? $userId,
            'message' => 'submission.event.general.metadataUpdated',
            'isTranslated' => false,
            'dateLogged' => Core::getCurrentDate(),
        ]);
        Repo::eventLog()->add($eventLog);

        return $newPublication;
    }

    /**
     * Publish a publication
     *
     * This method performs all actions needed when publishing an item, such
     * as setting metadata, logging events, updating the search index, etc.
     *
     * @throws \Exception
     *
     * @see self::setStatusOnPublish()
     */
    public function publish(Publication $publication)
    {
        $newPublication = clone $publication;
        $newPublication->stampModified();

        $this->setStatusOnPublish($newPublication);

        // Set the copyright and license information
        $submission = Repo::submission()->get($newPublication->getData('submissionId'));

        $itsPublished = ($newPublication->getData('status') === PKPSubmission::STATUS_PUBLISHED);

        if ($itsPublished && !$newPublication->getData('copyrightHolder')) {
            $newPublication->setData(
                'copyrightHolder',
                $submission->_getContextLicenseFieldValue(
                    null,
                    PKPSubmission::PERMISSIONS_FIELD_COPYRIGHT_HOLDER,
                    $newPublication
                )
            );
        }

        if ($itsPublished && !$newPublication->getData('copyrightYear')) {
            $newPublication->setData(
                'copyrightYear',
                $submission->_getContextLicenseFieldValue(
                    null,
                    PKPSubmission::PERMISSIONS_FIELD_COPYRIGHT_YEAR,
                    $newPublication
                )
            );
        }

        if ($itsPublished && !$newPublication->getData('licenseUrl')) {
            $newPublication->setData(
                'licenseUrl',
                $submission->_getContextLicenseFieldValue(
                    null,
                    PKPSubmission::PERMISSIONS_FIELD_LICENSE_URL,
                    $newPublication
                )
            );
        }

        Hook::call('Publication::publish::before', [&$newPublication, $publication]);

        $this->dao->update($newPublication);

        $newPublication = Repo::publication()->get($newPublication->getId());
        $submission = Repo::submission()->get($newPublication->getData('submissionId'));

        // Update a submission's status based on the status of its publications
        if ($newPublication->getData('status') !== $publication->getData('status')) {
            Repo::submission()->updateStatus($submission);
            $submission = Repo::submission()->get($submission->getId());
        }

        $msg = ($newPublication->getData('status') === Submission::STATUS_SCHEDULED) ? 'publication.event.scheduled' : 'publication.event.published';

        // Log an event when publication is published. Adjust the message depending
        // on whether this is the first publication or a subsequent version
        if (count($submission->getData('publications')) > 1) {
            $msg = ($newPublication->getData('status') === Submission::STATUS_SCHEDULED) ? 'publication.event.versionScheduled' : 'publication.event.versionPublished';
        }

        $eventLog = Repo::eventLog()->newDataObject([
            'assocType' => PKPApplication::ASSOC_TYPE_SUBMISSION,
            'assocId' => $submission->getId(),
            'eventType' => PKPSubmissionEventLogEntry::SUBMISSION_LOG_METADATA_PUBLISH,
            'userId' => Validation::loggedInAs() ?? $this->request->getUser()?->getId(),
            'message' => $msg,
            'isTranslated' => false,
            'dateLogged' => Core::getCurrentDate()
        ]);
        Repo::eventLog()->add($eventLog);

        // Mark DOIs stale (if applicable).
        if ($newPublication->getData('status') === Submission::STATUS_PUBLISHED) {
            $staleDoiIds = Repo::doi()->getDoisForSubmission($newPublication->getData('submissionId'));
            Repo::doi()->markStale($staleDoiIds);
        }

        Hook::call(
            'Publication::publish',
            [
                &$newPublication,
                $publication,
                $submission
            ]
        );

        $context = $submission->getData('contextId') === Application::get()->getRequest()->getContext()?->getId()
            ? Application::get()->getRequest()->getContext()
            : Services::get('context')->get($submission->getData('contextId'));

        event(new PublicationPublished($newPublication, $publication, $submission, $context));
    }

    /**
     * Set the status when an item is published
     *
     * Each application may handle publishing in a different way. Implement this method
     * in an app-specific child class by assigning `status` and `datePublished` for this
     * publication.
     *
     * This method should be called by self::publish().
     */
    abstract protected function setStatusOnPublish(Publication $publication);

    /**
     * Unpublish a publication
     *
     * This method performs all actions needed when unpublishing an item, such
     * as changing the status, logging events, updating the search index, etc.
     *
     * @see self::setStatusOnPublish()
     */
    public function unpublish(Publication $publication)
    {
        $newPublication = clone $publication;
        $newPublication->setData('status', Submission::STATUS_QUEUED);
        $newPublication->stampModified();

        Hook::call(
            'Publication::unpublish::before',
            [
                &$newPublication,
                $publication
            ]
        );

        $this->dao->update($newPublication);

        $newPublication = Repo::publication()->get($newPublication->getId());
        $submission = Repo::submission()->get($newPublication->getData('submissionId'));

        // Update a submission's status based on the status of its publications
        if ($newPublication->getData('status') !== $publication->getData('status')) {
            Repo::submission()->updateStatus($submission);
            $submission = Repo::submission()->get($submission->getId());
        }

        // Log an event when publication is unpublished. Adjust the message depending
        // on whether this is the first publication or a subsequent version
        $msg = 'publication.event.unpublished';

        if (count($submission->getData('publications')) > 1) {
            $msg = 'publication.event.versionUnpublished';
        }

        // Mark DOIs stable (if applicable).
        if ($submission->getData('status') !== Submission::STATUS_PUBLISHED) {
            $staleDoiIds = Repo::doi()->getDoisForSubmission($newPublication->getData('submissionId'));
            Repo::doi()->markStale($staleDoiIds);
        }

        $eventLog = Repo::eventLog()->newDataObject([
            'assocType' => PKPApplication::ASSOC_TYPE_SUBMISSION,
            'assocId' => $submission->getId(),
            'eventType' => PKPSubmissionEventLogEntry::SUBMISSION_LOG_METADATA_UNPUBLISH,
            'userId' => Validation::loggedInAs() ?? $this->request->getUser()?->getId(),
            'message' => $msg,
            'isTranslated' => false,
            'dateLogged' => Core::getCurrentDate()
        ]);
        Repo::eventLog()->add($eventLog);

        Hook::call(
            'Publication::unpublish',
            [
                &$newPublication,
                $publication,
                $submission
            ]
        );

        $context = $submission->getData('contextId') === Application::get()->getRequest()->getContext()->getId()
            ? Application::get()->getRequest()->getContext()
            : Services::get('context')->get($submission->getData('contextId'));

        event(new PublicationUnpublished($newPublication, $publication, $submission, $context));
    }

    /** @copydoc DAO::delete() */
    public function delete(Publication $publication)
    {
        Hook::call('Publication::delete::before', [&$publication]);

        $submission = Repo::submission()->get($publication->getData('submissionId'));
        $sectionId = $publication->getData(Application::getSectionIdPropName());
        $section = $sectionId ? Repo::section()->get($sectionId) : null;

        $this->dao->delete($publication);

        // Update a submission's status based on the status of its remaining publications
        $submission = Repo::submission()->get($publication->getData('submissionId'));
        Repo::submission()->updateStatus($submission, null, $section);

        Hook::call('Publication::delete', [&$publication]);
    }

    /**
     * Handle a publication setting for an uploaded file
     *
     * - Moves the temporary file to the public directory
     * - Resets the param value to what is expected to be stored in the db
     * - If a null value is passed, deletes any existing file
     *
     * This method is protected because all operations which edit publications should
     * go through the add and edit methods in order to ensure that
     * the appropriate hooks are fired.
     *
     * @param Publication $publication The publication being edited
     * @param Submission $submission The submission this publication is part of
     * @param mixed $value The param value to be saved. Contains the temporary
     *  file ID if a new file has been uploaded.
     * @param string $settingName The name of the setting to save, typically used
     *  in the filename.
     * @param int $userId ID of the user who owns the temporary file
     * @param string $localeKey Optional. Pass if the setting is multilingual
     * @param bool $isImage Optional. For image files which include alt text in value
     *
     * @return string|array|bool New param value or false on failure
     */
    protected function _saveFileParam(
        Publication $publication,
        Submission $submission,
        $value,
        string $settingName,
        int $userId,
        string $localeKey = '',
        bool $isImage = false
    ) {
        // If the value is null, delete any existing unused file in the system
        if (is_null($value)) {
            $oldPublication = Repo::publication()->get($publication->getId());
            $oldValue = $oldPublication->getData($settingName, $localeKey);
            $fileName = $oldValue['uploadName'] ?? null;
            if ($fileName) {
                // File may be in use by other publications
                $fileInUse = false;
                foreach ($submission->getData('publications') as $iPublication) {
                    if ($publication->getId() === $iPublication->getId()) {
                        continue;
                    }
                    $iValue = $iPublication->getData($settingName, $localeKey);
                    if (!empty($iValue['uploadName']) && $iValue['uploadName'] === $fileName) {
                        $fileInUse = true;
                        continue;
                    }
                }
                if (!$fileInUse) {
                    $publicFileManager = new PublicFileManager();
                    $publicFileManager->removeContextFile($submission->getData('contextId'), $fileName);
                }
            }
            return null;
        }

        // Check if there is something to upload
        if (empty($value['temporaryFileId'])) {
            return $value;
        }

        // Get the submission context
        $submissionContext = $this->request->getContext();
        if ($submissionContext->getId() !== $submission->getData('contextId')) {
            $submissionContext = Services::get('context')->get($submission->getData('contextId'));
        }

        $temporaryFileManager = new TemporaryFileManager();
        $temporaryFile = $temporaryFileManager->getFile((int) $value['temporaryFileId'], $userId);
        $fileNameBase = join('_', ['submission', $submission->getId(), $publication->getId(), $settingName]); // eg - submission_1_1_coverImage
        $fileName = Services::get('context')->moveTemporaryFile($submissionContext, $temporaryFile, $fileNameBase, $userId, $localeKey);

        if ($fileName) {
            if ($isImage) {
                return [
                    'altText' => !empty($value['altText']) ? $value['altText'] : '',
                    'dateUploaded' => Core::getCurrentDate(),
                    'uploadName' => $fileName,
                ];
            } else {
                return [
                    'dateUploaded' => Core::getCurrentDate(),
                    'uploadName' => $fileName,
                ];
            }
        }

        return null;
    }

    /**
     * Get error message overrides for the validator
     */
    protected function getErrorMessageOverrides(): array
    {
        return [
            'locale.regex' => __('validator.localeKey'),
            'datePublished.date_format' => __('publication.datePublished.errorFormat'),
            'urlPath.regex' => __('validator.alpha_dash_period'),
        ];
    }

    /**
     * Create all DOIs associated with the publication.
     */
    abstract protected function createDois(Publication $newPublication): void;
}
