<?php

/**
 * @file classes/services/PKPFileService.php
 *
 * Copyright (c) 2014-2021 Simon Fraser University
 * Copyright (c) 2000-2021 John Willinsky
 * Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
 *
 * @class PKPFileService
 *
 * @ingroup services
 *
 * @brief Helper class that encapsulates business logic for publications
 */

namespace PKP\services;

use Exception;
use finfo;
use Illuminate\Support\Facades\DB;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use League\Flysystem\UnixVisibility\PortableVisibilityConverter;
use PKP\config\Config;
use PKP\core\PKPString;
use PKP\file\FileManager;
use PKP\plugins\Hook;

class PKPFileService
{
    private const FALLBACK_MIME_TYPE = 'application/octet-stream';

    /** @var Filesystem */
    public $fs;

    /**
     * Initialize and configure flysystem
     *
     * @hook File::adapter [[&$adapter, $this]]
     */
    public function __construct()
    {
        $umask = Config::getVar('files', 'umask', 0022);
        $adapter = new LocalFilesystemAdapter(
            Config::getVar('files', 'files_dir'),
            PortableVisibilityConverter::fromArray([
                'file' => [
                    'public' => FileManager::FILE_MODE_MASK & ~$umask,
                    'private' => FileManager::FILE_MODE_MASK & ~$umask,
                ],
                'dir' => [
                    'public' => FileManager::DIRECTORY_MODE_MASK & ~$umask,
                    'private' => FileManager::DIRECTORY_MODE_MASK & ~$umask,
                ]
            ]),
            LOCK_EX,
            LocalFilesystemAdapter::DISALLOW_LINKS
        );

        Hook::call('File::adapter', [&$adapter, $this]);

        $this->fs = new Filesystem($adapter);
    }

    /**
     * Get a file by its id
     *
     * @param int $id
     *
     * @return object
     */
    public function get($id)
    {
        $file = DB::table('files')
            ->where('file_id', '=', $id)
            ->select(['file_id as id', 'path', 'mimetype'])
            ->first();
        return $file;
    }

    /**
     * Add a file
     *
     * @param string $from absolute path to file
     * @param string $to relative path in file dir
     *
     * @return int file id
     */
    public function add($from, $to)
    {
        $stream = fopen($from, 'r+');
        if (!$stream) {
            throw new Exception("Unable to copy {$from} to {$to}.");
        }
        $this->fs->writeStream($to, $stream);
        if (is_resource($stream)) {
            fclose($stream);
        }
        try {
            $mimetype = $this->fs->mimeType($to);
        } catch (Exception $e) {
            // When a very good mime-type cannot be guessed, FlySystem emits an Exception
            $mimetype = (new finfo(FILEINFO_MIME_TYPE))->file($to) ?: static::FALLBACK_MIME_TYPE;
        }

        // Check and override ambiguous mime types based on file extension
        if ($extension = pathinfo($to, PATHINFO_EXTENSION)) {
            $checkAmbiguous = strtolower($extension . ':' . $mimetype);
            if (array_key_exists($checkAmbiguous, $extensionsMap = PKPString::getAmbiguousExtensionsMap())) {
                $mimetype = $extensionsMap[$checkAmbiguous];
            }
        }

        return DB::table('files')->insertGetId([
            'path' => $to,
            'mimetype' => $mimetype,
        ], 'file_id');
    }

    /**
     * Delete an uploaded file
     *
     * @param int $id
     */
    public function delete($id)
    {
        $file = $this->get($id);
        if (!$file) {
            throw new Exception("Unable to locate file {$id}.");
        }
        $path = $file->path;
        if ($this->fs->has($path)) {
            try {
                $this->fs->delete($path);
            } catch (Exception $e) {
                throw new Exception("Unable to delete file {$id} at {$path}.");
            }
        }
        DB::table('files')
            ->where('file_id', '=', $file->id)
            ->delete();
    }

    /**
     * Download a file
     *
     * This method sends a HTTP response and ends the request handling.
     * No code will run after this method is called.
     *
     * @param int $fileId File ID
     * @param string $filename Filename to give to the downloaded file
     * @param bool $inline Whether to stream the file to the browser
     *
     * @hook File::download [[$file, &$filename, $inline]]
     */
    public function download($fileId, $filename, $inline = false)
    {
        $file = $this->get($fileId);
        if (!$file) {
            throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException();
        }

        $path = $file->path;
        if (!$this->fs->has($path)) {
            throw new \Symfony\Component\HttpKernel\Exception\NotFoundHttpException();
        }

        if (Hook::call('File::download', [$file, &$filename, $inline])) {
            return;
        }

        // Stream the file to the end user.
        $mimetype = $file->mimetype ?? 'application/octet-stream';
        $filesize = $this->fs->fileSize($path);
        $encodedFilename = urlencode($filename);
        header("Content-Type: {$mimetype}");
        header("Content-Length: {$filesize}");
        header('Accept-Ranges: none');
        header('Content-Disposition: ' . ($inline ? 'inline' : 'attachment') . ";filename={$encodedFilename};filename*=UTF-8''{$encodedFilename}");
        header('Cache-Control: private'); // Workarounds for IE weirdness
        header('Pragma: public');

        fpassthru($this->fs->readStream($path));
        exit;
    }

    /**
     * Convert a filename into a consistent format with the correct extension
     *
     * @param string $path Path to the file
     * @param string $filename Source filename to sanitize
     *
     * @return string
     *
     * @hook File::formatFilename [[&$newFilename, $path, $filename]]
     */
    public function formatFilename($path, $filename)
    {
        $newFilename = $filename;
        # pattern extended to also capture captures .tar.gz extensions
        if (preg_match('/(\\.\\w{1,3})?\\.\\w+$/', $path, $extension)) {
            # If $newFilename has no/not the correct extension: Append extension
            if (strcasecmp(substr($newFilename, (strlen($extension[0]) * -1)), $extension[0]) != 0) {
                $newFilename .= $extension[0];
            }
        }
        Hook::call('File::formatFilename', [&$newFilename, $path, $filename]);

        return $newFilename;
    }

    /**
     * Get document type based on the mimetype
     *
     * @param string $mimetype
     *
     * @return string One of the FileManager::DOCUMENT_TYPE_ constants
     */
    public function getDocumentType($mimetype)
    {
        switch ($mimetype) {
            case 'application/pdf':
            case 'application/x-pdf':
            case 'text/pdf':
            case 'text/x-pdf':
                return FileManager::DOCUMENT_TYPE_PDF;
            case 'application/msword':
            case 'application/word':
            case 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
                return FileManager::DOCUMENT_TYPE_WORD;
            case 'application/excel':
            case 'application/vnd.ms-excel':
            case 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
                return FileManager::DOCUMENT_TYPE_EXCEL;
            case 'text/html':
                return FileManager::DOCUMENT_TYPE_HTML;
            case 'application/zip':
            case 'application/x-zip':
            case 'application/x-zip-compressed':
            case 'application/x-compress':
            case 'application/x-compressed':
            case 'multipart/x-zip':
                return FileManager::DOCUMENT_TYPE_ZIP;
            case 'application/epub':
            case 'application/epub+zip':
                return FileManager::DOCUMENT_TYPE_EPUB;
            case 'image/gif':
            case 'image/jpeg':
            case 'image/pjpeg':
            case 'image/png':
            case 'image/x-png':
            case 'image/vnd.microsoft.icon':
            case 'image/x-icon':
            case 'image/x-ico':
            case 'image/ico':
                return FileManager::DOCUMENT_TYPE_IMAGE;
            case 'application/x-shockwave-flash':
            case 'video/x-flv':
            case 'application/x-flash-video':
            case 'flv-application/octet-stream':
            case 'video/mpeg':
            case 'video/quicktime':
            case 'video/mp4':
                return FileManager::DOCUMENT_TYPE_VIDEO;
            case 'audio/mpeg':
            case 'audio/x-aiff':
            case 'audio/x-wav':
                return FileManager::DOCUMENT_TYPE_AUDIO;
            default:
                return FileManager::DOCUMENT_TYPE_DEFAULT;
        }
    }

    /**
     * Get a pretty file size string
     *
     * Examples: 82B, 12KB, 2MB, 2GB
     *
     * @param int $size File size in bytes
     *
     * @return string
     */
    public function getNiceFileSize($size)
    {
        $niceFileSizeUnits = ['B', 'KB', 'MB', 'GB'];
        for ($i = 0; $i < 4 && $size > 1024; $i++) {
            $size >>= 10;
        }
        return $size . $niceFileSizeUnits[$i];
    }
}
