<?php

/**
 * @file api/v1/submissions/PKPSubmissionFileHandler.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2003-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class PKPSubmissionFileHandler
 *
 * @ingroup api_v1_submission
 *
 * @brief Handle API requests for submission operations.
 *
 */

namespace PKP\API\v1\submissions;

use APP\core\Application;
use APP\core\Services;
use APP\facades\Repo;
use PKP\core\APIResponse;
use PKP\db\DAORegistry;
use PKP\file\FileManager;
use PKP\handler\APIHandler;
use PKP\security\authorization\ContextAccessPolicy;
use PKP\security\authorization\internal\SubmissionFileStageAccessPolicy;
use PKP\security\authorization\SubmissionAccessPolicy;
use PKP\security\authorization\SubmissionFileAccessPolicy;
use PKP\security\authorization\UserRolesRequiredPolicy;
use PKP\security\Role;
use PKP\services\PKPSchemaService;
use PKP\submission\GenreDAO;
use PKP\submission\reviewRound\ReviewRoundDAO;
use PKP\submissionFile\SubmissionFile;

class PKPSubmissionFileHandler extends APIHandler
{
    /**
     * Constructor
     */
    public function __construct()
    {
        $this->_handlerPath = 'submissions/{submissionId:\d+}/files';
        $this->_endpoints = [
            'GET' => [
                [
                    'pattern' => $this->getEndpointPattern(),
                    'handler' => [$this, 'getMany'],
                    'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR],
                ],
                [
                    'pattern' => $this->getEndpointPattern() . '/{submissionFileId:\d+}',
                    'handler' => [$this, 'get'],
                    'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR],
                ],
            ],
            'POST' => [
                [
                    'pattern' => $this->getEndpointPattern(),
                    'handler' => [$this, 'add'],
                    'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR],
                ],
            ],
            'PUT' => [
                [
                    'pattern' => $this->getEndpointPattern() . '/{submissionFileId:\d+}',
                    'handler' => [$this, 'edit'],
                    'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR],
                ],
                [
                    'pattern' => $this->getEndpointPattern() . '/{submissionFileId:\d+}/copy',
                    'handler' => [$this, 'copy'],
                    'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_SUB_EDITOR],
                ],
            ],
            'DELETE' => [
                [
                    'pattern' => $this->getEndpointPattern() . '/{submissionFileId:\d+}',
                    'handler' => [$this, 'delete'],
                    'roles' => [Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN, Role::ROLE_ID_SUB_EDITOR, Role::ROLE_ID_ASSISTANT, Role::ROLE_ID_AUTHOR],
                ],
            ],
        ];
        parent::__construct();
    }

    //
    // Implement methods from PKPHandler
    //
    public function authorize($request, &$args, $roleAssignments)
    {
        $route = $this->getSlimRequest()->getAttribute('route');

        $this->addPolicy(new UserRolesRequiredPolicy($request), true);

        $this->addPolicy(new ContextAccessPolicy($request, $roleAssignments));

        $this->addPolicy(new SubmissionAccessPolicy($request, $args, $roleAssignments));

        if ($route->getName() === 'add') {
            $params = $this->getSlimRequest()->getParsedBody();
            $fileStage = isset($params['fileStage']) ? (int) $params['fileStage'] : 0;
            $this->addPolicy(new SubmissionFileStageAccessPolicy($fileStage, SubmissionFileAccessPolicy::SUBMISSION_FILE_ACCESS_MODIFY, 'api.submissionFiles.403.unauthorizedFileStageIdWrite'));
        } elseif ($route->getName() === 'getMany') {
            // Anyone passing SubmissionAccessPolicy is allowed to access getMany,
            // but the endpoint will return different files depending on the user's
            // stage assignments.
        } else {
            $accessMode = $this->getSlimRequest()->getMethod() === 'GET'
                ? SubmissionFileAccessPolicy::SUBMISSION_FILE_ACCESS_READ
                : SubmissionFileAccessPolicy::SUBMISSION_FILE_ACCESS_MODIFY;
            $this->addPolicy(new SubmissionFileAccessPolicy($request, $args, $roleAssignments, $accessMode, (int) $route->getArgument('submissionFileId')));
        }

        return parent::authorize($request, $args, $roleAssignments);
    }

    /**
     * Get a collection of submission files
     *
     * @param \Slim\Http\Request $slimRequest
     * @param APIResponse $response
     * @param array $args arguments
     *
     * @return APIResponse
     */
    public function getMany($slimRequest, $response, $args)
    {
        $request = $this->getRequest();

        $params = [];

        foreach ($slimRequest->getQueryParams() as $param => $val) {
            switch ($param) {
                case 'fileStages':
                case 'reviewRoundIds':
                    if (is_string($val)) {
                        $val = explode(',', $val);
                    } elseif (!is_array($val)) {
                        $val = [$val];
                    }
                    $params[$param] = array_map('intval', $val);
                    break;
            }
        }

        $userRoles = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_USER_ROLES);
        $stageAssignments = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_ACCESSIBLE_WORKFLOW_STAGES);
        $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION);

        // @see PKP\submissionFile\Repository::getAssignedFileStages() for excluded file stages
        $allowedFileStages = [
            SubmissionFile::SUBMISSION_FILE_SUBMISSION,
            SubmissionFile::SUBMISSION_FILE_REVIEW_FILE,
            SubmissionFile::SUBMISSION_FILE_FINAL,
            SubmissionFile::SUBMISSION_FILE_COPYEDIT,
            SubmissionFile::SUBMISSION_FILE_PROOF,
            SubmissionFile::SUBMISSION_FILE_PRODUCTION_READY,
            SubmissionFile::SUBMISSION_FILE_ATTACHMENT,
            SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION,
            SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE,
            SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_REVISION,
        ];

        // Managers can access files for submissions they are not assigned to
        if (!$stageAssignments && !count(array_intersect([Role::ROLE_ID_MANAGER, Role::ROLE_ID_SITE_ADMIN], $userRoles))) {
            return $response->withStatus(403)->withJsonError('api.403.unauthorized');
        }

        // Set the allowed file stages based on stage assignment
        // @see PKP\submissionFile\Repository::getAssignedFileStages() for excluded file stages
        if ($stageAssignments) {
            $allowedFileStages = Repo::submissionFile()
                ->getAssignedFileStages(
                    $stageAssignments,
                    SubmissionFileAccessPolicy::SUBMISSION_FILE_ACCESS_READ
                );
        }

        $fileStages = empty($params['fileStages'])
            ? $allowedFileStages
            : $params['fileStages'];
        foreach ($fileStages as $fileStage) {
            if (!in_array($fileStage, $allowedFileStages)) {
                return $response->withStatus(403)->withJsonError('api.submissionFiles.403.unauthorizedFileStageId');
            }
        }

        $collector = Repo::submissionFile()
            ->getCollector()
            ->filterBySubmissionIds([$submission->getId()])
            ->filterByFileStages($fileStages);

        // Filter by requested review round ids
        if (!empty($params['reviewRoundIds'])) {
            $reviewRoundIds = $params['reviewRoundIds'];
            $allowedReviewRoundIds = [];
            $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO  $reviewRoundDao*/
            if (!empty(array_intersect([SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE, SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_REVISION], $fileStages))) {
                $result = $reviewRoundDao->getBySubmissionId($submission->getId(), WORKFLOW_STAGE_ID_INTERNAL_REVIEW);
                while ($reviewRound = $result->next()) {
                    $allowedReviewRoundIds[] = $reviewRound->getId();
                }
            }
            if (!empty(array_intersect([SubmissionFile::SUBMISSION_FILE_REVIEW_FILE, SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION], $fileStages))) {
                $result = $reviewRoundDao->getBySubmissionId($submission->getId(), WORKFLOW_STAGE_ID_EXTERNAL_REVIEW);
                while ($reviewRound = $result->next()) {
                    $allowedReviewRoundIds[] = $reviewRound->getId();
                }
            }

            foreach ($reviewRoundIds as $reviewRoundId) {
                if (!in_array($reviewRoundId, $allowedReviewRoundIds)) {
                    return $response->withStatus(403)->withJsonError('api.submissionFiles.403.unauthorizedReviewRound');
                }
            }

            $collector->filterByReviewRoundIds($reviewRoundIds);
        }

        $files = $collector->getMany();

        $items = Repo::submissionFile()
            ->getSchemaMap()
            ->summarizeMany($files, $this->getFileGenres());

        $data = [
            'itemsMax' => $files->count(),
            'items' => $items->values(),
        ];

        return $response->withJson($data, 200);
    }

    /**
     * Get a single submission file
     *
     * @param \Slim\Http\Request $slimRequest
     * @param APIResponse $response
     * @param array $args arguments
     *
     * @return APIResponse
     */
    public function get($slimRequest, $response, $args)
    {
        $submissionFile = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION_FILE);

        $data = Repo::submissionFile()
            ->getSchemaMap()
            ->map($submissionFile, $this->getFileGenres());

        return $response->withJson($data, 200);
    }

    /**
     * Add a new submission file
     *
     * @param \Slim\Http\Request $slimRequest
     * @param APIResponse $response
     * @param array $args arguments
     *
     * @return APIResponse
     */
    public function add($slimRequest, $response, $args)
    {
        $request = $this->getRequest();
        $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION);

        if (empty($_FILES)) {
            return $response->withStatus(400)->withJsonError('api.files.400.noUpload');
        }

        if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
            return $this->getUploadErrorResponse($response, $_FILES['file']['error']);
        }

        $fileManager = new FileManager();
        $extension = $fileManager->parseFileExtension($_FILES['file']['name']);

        $submissionDir = Repo::submissionFile()
            ->getSubmissionDir(
                $request->getContext()->getId(),
                $submission->getId()
            );
        $fileId = Services::get('file')->add(
            $_FILES['file']['tmp_name'],
            $submissionDir . '/' . uniqid() . '.' . $extension
        );

        $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_SUBMISSION_FILE, $slimRequest->getParsedBody());
        $params['fileId'] = $fileId;
        $params['submissionId'] = $submission->getId();
        $params['uploaderUserId'] = (int) $request->getUser()->getId();

        $primaryLocale = $request->getContext()->getPrimaryLocale();
        $allowedLocales = $request->getContext()->getData('supportedSubmissionLocales');

        // Set the name if not passed with the request
        if (empty($params['name'])) {
            $params['name'][$primaryLocale] = $_FILES['file']['name'];
        }

        // If no genre has been set and there is only one genre possible, set it automatically
        if (empty($params['genreId'])) {
            /** @var GenreDAO */
            $genreDao = DAORegistry::getDAO('GenreDAO');
            $genres = $genreDao->getEnabledByContextId($request->getContext()->getId());
            [$firstGenre, $secondGenre] = [$genres->next(), $genres->next()];
            if ($firstGenre && !$secondGenre) {
                $params['genreId'] = $firstGenre->getId();
            }
        }

        $errors = Repo::submissionFile()
            ->validate(
                null,
                $params,
                $allowedLocales,
                $primaryLocale
            );

        if (!empty($errors)) {
            Services::get('file')->delete($fileId);
            return $response->withStatus(400)->withJson($errors);
        }

        // Review attachments and discussion files can not be uploaded through this API endpoint
        $notAllowedFileStages = [
            SubmissionFile::SUBMISSION_FILE_NOTE,
            SubmissionFile::SUBMISSION_FILE_REVIEW_ATTACHMENT,
            SubmissionFile::SUBMISSION_FILE_QUERY,
        ];
        if (in_array($params['fileStage'], $notAllowedFileStages)) {
            Services::get('file')->delete($fileId);
            return $response->withStatus(400)->withJsonError('api.submissionFiles.403.unauthorizedFileStageIdWrite');
        }

        // A valid review round is required when uploading to a review file stage
        $reviewFileStages = [
            SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE,
            SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_REVISION,
            SubmissionFile::SUBMISSION_FILE_REVIEW_FILE,
            SubmissionFile::SUBMISSION_FILE_REVIEW_REVISION,
        ];
        if (in_array($params['fileStage'], $reviewFileStages)) {
            if (empty($params['assocType']) || $params['assocType'] !== Application::ASSOC_TYPE_REVIEW_ROUND || empty($params['assocId'])) {
                Services::get('file')->delete($fileId);
                return $response->withStatus(400)->withJsonError('api.submissionFiles.400.missingReviewRoundAssocType');
            }
            $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO'); /** @var ReviewRoundDAO $reviewRoundDao */
            $reviewRound = $reviewRoundDao->getById($params['assocId']);
            $stageId = in_array($params['fileStage'], [SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_FILE, SubmissionFile::SUBMISSION_FILE_INTERNAL_REVIEW_REVISION])
                ? WORKFLOW_STAGE_ID_INTERNAL_REVIEW
                : WORKFLOW_STAGE_ID_EXTERNAL_REVIEW;
            if (!$reviewRound
                    || $reviewRound->getData('submissionId') != $params['submissionId']
                    || $reviewRound->getData('stageId') != $stageId) {
                Services::get('file')->delete($fileId);
                return $response->withStatus(400)->withJsonError('api.submissionFiles.400.reviewRoundSubmissionNotMatch');
            }
        }

        $submissionFile = Repo::submissionFile()
            ->newDataObject($params);

        $submissionFileId = Repo::submissionFile()
            ->add($submissionFile);

        $submissionFile = Repo::submissionFile()
            ->get($submissionFileId);

        $data = Repo::submissionFile()
            ->getSchemaMap()
            ->map($submissionFile, $this->getFileGenres());

        return $response->withJson($data, 200);
    }

    /**
     * Edit a submission file
     *
     * @param \Slim\Http\Request $slimRequest
     * @param APIResponse $response
     * @param array $args arguments
     *
     * @return APIResponse
     */
    public function edit($slimRequest, $response, $args)
    {
        $request = $this->getRequest();
        $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION);
        $submissionFile = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION_FILE);

        $params = $this->convertStringsToSchema(PKPSchemaService::SCHEMA_SUBMISSION_FILE, $slimRequest->getParsedBody());

        // Don't allow these properties to be modified
        unset($params['submissionId'], $params['fileId'], $params['uploaderUserId']);

        if (empty($params) && empty($_FILES['file'])) {
            return $response->withStatus(400)->withJsonError('api.submissionsFiles.400.noParams');
        }

        $primaryLocale = $request->getContext()->getPrimaryLocale();
        $allowedLocales = $request->getContext()->getData('supportedSubmissionLocales');

        $errors = Repo::submissionFile()
            ->validate(
                $submissionFile,
                $params,
                $allowedLocales,
                $primaryLocale
            );

        if (!empty($errors)) {
            return $response->withStatus(400)->withJson($errors);
        }

        // Upload a new file
        if (!empty($_FILES['file'])) {
            if ($_FILES['file']['error'] !== UPLOAD_ERR_OK) {
                return $this->getUploadErrorResponse($response, $_FILES['file']['error']);
            }

            $fileManager = new FileManager();
            $extension = $fileManager->parseFileExtension($_FILES['file']['name']);
            $submissionDir = Repo::submissionFile()
                ->getSubmissionDir(
                    $request->getContext()->getId(),
                    $submission->getId()
                );
            $fileId = Services::get('file')->add(
                $_FILES['file']['tmp_name'],
                $submissionDir . '/' . uniqid() . '.' . $extension
            );

            $params['fileId'] = $fileId;
            $params['uploaderUserId'] = $request->getUser()->getId();
            if (empty($params['name'])) {
                $params['name'][$primaryLocale] = $_FILES['file']['name'];
            }
        }

        Repo::submissionFile()
            ->edit(
                $submissionFile,
                $params
            );

        $submissionFile = Repo::submissionFile()
            ->get($submissionFile->getId());

        $data = Repo::submissionFile()
            ->getSchemaMap()
            ->map($submissionFile, $this->getFileGenres());

        return $response->withJson($data, 200);
    }

    /**
     * Copy a submission file to another file stage
     *
     * @param \Slim\Http\Request $slimRequest
     * @param APIResponse $response
     * @param array $args arguments
     *
     * @return APIResponse
     */
    public function copy($slimRequest, $response, $args)
    {
        $submission = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION);
        $submissionFile = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION_FILE);

        $params = $slimRequest->getParsedBody();
        if (empty($params['toFileStage'])) {
            return $response->withStatus(400)->withJsonError('api.submissionFiles.400.noFileStageId');
        }

        $toFileStage = (int) $params['toFileStage'];

        if (!in_array($toFileStage, Repo::submissionFile()->getFileStages())) {
            return $response->withStatus(400)->withJsonError('api.submissionFiles.400.invalidFileStage');
        }

        // Expect a review round id when copying to a review stage, or use the latest
        // round in that stage by default
        $reviewRoundId = null;
        if (in_array($toFileStage, Repo::submissionFile()->reviewFileStages)) {
            if (!empty($params['reviewRoundId'])) {
                $reviewRoundId = (int) $params['reviewRoundId'];
                /** @var ReviewRoundDAO $reviewRoundDao */
                $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO');
                $reviewRound = $reviewRoundDao->getById($reviewRoundId);
                if (!$reviewRound || $reviewRound->getSubmissionId() != $submission->getId()) {
                    return $response->withStatus(400)->withJsonError('api.submissionFiles.400.reviewRoundSubmissionNotMatch');
                }
            } else {
                // Use the latest review round of the appropriate stage
                $stageId = in_array($toFileStage, SubmissionFile::INTERNAL_REVIEW_STAGES)
                    ? WORKFLOW_STAGE_ID_INTERNAL_REVIEW
                    : WORKFLOW_STAGE_ID_EXTERNAL_REVIEW;
                /** @var ReviewRoundDAO $reviewRoundDao */
                $reviewRoundDao = DAORegistry::getDAO('ReviewRoundDAO');
                $reviewRound = $reviewRoundDao->getLastReviewRoundBySubmissionId($submission->getId(), $stageId);
                if ($reviewRound) {
                    $reviewRoundId = $reviewRound->getId();
                }
            }
            if ($reviewRoundId === null) {
                return $response->withStatus(400)->withJsonError('api.submissionFiles.400.reviewRoundIdRequired');
            }
        }

        $newSubmissionFileId = Repo::submissionFile()->copy(
            $submissionFile,
            $toFileStage,
            $reviewRoundId
        );

        $newSubmissionFile = Repo::submissionFile()->get($newSubmissionFileId);

        $data = Repo::submissionFile()
            ->getSchemaMap()
            ->map($newSubmissionFile, $this->getFileGenres());

        return $response->withJson($data, 200);
    }

    /**
     * Delete a submission file
     *
     * @param \Slim\Http\Request $slimRequest
     * @param APIResponse $response
     * @param array $args arguments
     *
     * @return APIResponse
     */
    public function delete($slimRequest, $response, $args)
    {
        $submissionFile = $this->getAuthorizedContextObject(Application::ASSOC_TYPE_SUBMISSION_FILE);

        $data = Repo::submissionFile()
            ->getSchemaMap()
            ->map($submissionFile, $this->getFileGenres());

        Repo::submissionFile()->delete($submissionFile);

        return $response->withJson($data, 200);
    }

    /**
     * Helper method to get the file genres for the current context
     *
     * @return \PKP\submission\Genre[]
     */
    protected function getFileGenres(): array
    {
        /** @var GenreDAO $genreDao */
        $genreDao = DAORegistry::getDAO('GenreDAO');
        return $genreDao->getByContextId($this->getRequest()->getContext()->getId())->toArray();
    }

    /**
     * Helper method to get the appropriate response when an error
     * has occurred during a file upload
     *
     * @param APIResponse $response
     * @param int $error One of the UPLOAD_ERR_ constants
     *
     * @return APIResponse
     */
    private function getUploadErrorResponse($response, $error)
    {
        switch ($error) {
            case UPLOAD_ERR_INI_SIZE:
            case UPLOAD_ERR_FORM_SIZE:
                return $response->withStatus(400)->withJsonError('api.files.400.fileSize', ['maxSize' => Application::getReadableMaxFileSize()]);
            case UPLOAD_ERR_PARTIAL:
                return $response->withStatus(400)->withJsonError('api.files.400.uploadFailed');
            case UPLOAD_ERR_NO_FILE:
                return $response->withStatus(400)->withJsonError('api.files.400.noUpload');
            case UPLOAD_ERR_NO_TMP_DIR:
            case UPLOAD_ERR_CANT_WRITE:
            case UPLOAD_ERR_EXTENSION:
                return $response->withStatus(400)->withJsonError('api.files.400.config');
        }
        return $response->withStatus(400)->withJsonError('api.files.400.uploadFailed');
    }
}
