<?php

namespace LimeSurvey\ExtensionInstaller;

use Exception;
use InvalidArgumentException;
use ExtensionConfig;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;

/**
 * Extension file fetcher for upload ZIP file.
 * Must work for all extension types: plugins, theme, question theme, etc.
 *
 * @since 2018-09-25
 * @author LimeSurvey GmbH
 */
class FileFetcherUploadZip extends FileFetcher
{
    /**
     * Filter to apply to unzipping.
     * @var string
     */
    protected $filterName;

    /**
     * @param string $source
     * @return void
     */
    public function setSource($source)
    {
        // Not used.
    }

    /**
     * Fetch files, meaning grab uploaded ZIP file and
     * unzip it in system tmp folder.
     *
     * @return void
     */
    public function fetch()
    {
        $this->checkFileSizeError();
        $this->clearTmpdir();
        $this->extractZipFile($this->getTempdir());
    }

    /**
     * Move files from tempdir to final destdir.
     *
     * @param string $destdir
     * @return boolean
     */
    public function move($destdir)
    {
        if (empty($destdir)) {
            throw new InvalidArgumentException('Missing destdir argument');
        }

        $tempdir = $this->getTempdir();
        if (empty($tempdir)) {
            throw new Exception(gT('Temporary folder cannot be determined.'));
        }

        if (!file_exists($tempdir)) {
            throw new Exception(gT('Temporary folder does not exist.'));
        }

        if (!file_exists($destdir)) {
            // NB: mkdir() always applies the set umask to 0777. See https://www.php.net/manual/en/function.mkdir
            mkdir($destdir, 0777, true);
        }

        if (!is_writable(dirname($destdir))) {
            throw new Exception(gT('Cannot move files due to permission problem. ' . $destdir));
        }

        if (file_exists($destdir) && !rmdirr($destdir)) {
            throw new Exception('Could not remove old files.');
        }

        return $this->recurseCopy($tempdir, $destdir);
    }

    /**
     * Get config from unzipped zip file, but in temp dir. fetch() must be called before this.
     *
     * @return ExtensionConfig
     * @throws Exception
     */
    public function getConfig()
    {
        $tempdir = $this->getTempdir();
        if (empty($tempdir)) {
            throw new Exception(gT('No temporary folder, cannot read configuration file.'));
        }

        $config = $this->getConfigFromDir($tempdir);

        if (empty($config)) {
            throw new Exception(gT('Could not parse config.xml file.'));
        }

        return $config;
    }

    /**
     * Look for config.xml in $tempdir
     * Recursively searches the folders if config.xml is not in root folder.
     *
     * @param string $tempdir
     * @return ExtensionConfig|null
     */
    public function getConfigFromDir(string $tempdir)
    {
        $configFile = $tempdir . DIRECTORY_SEPARATOR . 'config.xml';

        if (file_exists($configFile)) {
             return ExtensionConfig::loadFromFile($configFile);
        } else {
            $it = new RecursiveDirectoryIterator($tempdir);
            // @see https://stackoverflow.com/questions/1860393/recursive-file-search-php
            foreach (new RecursiveIteratorIterator($it) as $file) {
                // @see https://stackoverflow.com/questions/619610/whats-the-most-efficient-test-of-whether-a-php-string-ends-with-another-string?lq=1
                if (stripos(strrev((string) $file), strrev('config.xml')) === 0) {
                    return ExtensionConfig::loadFromFile($file);
                }
            }
        }
        throw new Exception(gT('Configuration file config.xml does not exist.'));
    }

    /**
     * @param string $filterName
     * @return void
     */
    public function setUnzipFilter($filterName)
    {
        $this->filterName = $filterName;
    }

    /**
     * Abort unzip, clear files and session.
     * @return void
     */
    public function abort()
    {
        // Remove any files.
        $tempdir = $this->getTempdir();
        if ($tempdir) {
            rmdirr($tempdir);
        }

        // Reset user state.
        $this->clearTmpdir();
    }

    /**
     * Get tmp tempdir for extension to unzip in.
     * @return string
     */
    protected function getTempdir()
    {
        // NB: Since the installation procedure can span several page reloads,
        // we save the tempdir in the user session.
        $tempdir = App()->user->getState('filefetcheruploadzip_tmpdir');
        if (empty($tempdir)) {
            $tempdir = \Yii::app()->getConfig("tempdir");
            $tempdir = createRandomTempDir($tempdir, 'install_');
            App()->user->setState('filefetcheruploadzip_tmpdir', $tempdir);
        }
        return $tempdir;
    }

    /**
     * Set user session tempdir to null.
     * @return void
     */
    protected function clearTmpdir()
    {
        App()->user->setState('filefetcheruploadzip_tmpdir', null);
    }

    /**
     * @todo Duplicate from themes.php.
     * @return void
     * @throws Exception
     */
    protected function checkFileSizeError()
    {
        if (!isset($_FILES['the_file'])) {
            throw new Exception(gT('Found no file'));
        }

        if ($_FILES['the_file']['error'] == 1 || $_FILES['the_file']['error'] == 2) {
            throw new Exception(
                sprintf(
                    gT("Sorry, this file is too large. Only files up to %01.2f MB are allowed."),
                    getMaximumFileUploadSize() / 1024 / 1024
                )
            );
        }
    }

    /**
     * Check if uploaded zip file is a zip bomb.
     * @return void
     */
    protected function checkZipBomb()
    {
        // Check zip bomb.
        \Yii::import('application.helpers.common_helper', true);
        if (isZipBomb($_FILES['the_file']['name'])) {
            throw new Exception(gT('Unzipped file is too big.'));
        }
    }

    /**
     * @param string $tempdir
     * @return void
     */
    protected function extractZipFile($tempdir)
    {
        \Yii::import('application.helpers.common_helper', true);

        /** @todo: Move this after checking if the file exists? */
        $this->checkZipBomb();

        if (!is_file($_FILES['the_file']['tmp_name'])) {
            throw new Exception(
                gT("An error occurred uploading your file. This may be caused by incorrect permissions for the application /tmp folder.")
            );
        }

        if (empty($this->filterName)) {
            throw new Exception("No filter name is set, can't unzip.");
        }

        $zipExtractor = new \LimeSurvey\Models\Services\ZipExtractor($_FILES['the_file']['tmp_name']);
        $zipExtractor->setFilterCallback($this->filterName);

        if ($zipExtractor->extractTo($tempdir) === false) {
            throw new Exception(
                gT("This file is not a valid ZIP file archive. Import failed.")
                . ' ' . $zipExtractor->getExtractStatus()
            );
        }
    }

    /**
     * Recursively copy source folder $src to destination $dest.
     *
     * @param string $src
     * @param string $dest
     * @return boolean
     * @see https://stackoverflow.com/questions/2050859/copy-entire-contents-of-a-directory-to-another-using-php
     * @todo Inject FileIO wrapper and add unit-test
     */
    public function recurseCopy($src, $dest)
    {
        $dir = opendir($src);
        if (!file_exists($dest)) {
            if (!mkdir($dest)) {
                throw new Exception('Could not create folder ' . $dest);
            }
        }
        while (false !== ($file = readdir($dir))) {
            if ($file != '.' && $file != '..') {
                if (is_dir($src . '/' . $file)) {
                    // If folder name === extension name, skip one folder level to avoid duplicates.
                    if ($file === $this->getConfig()->getName()) {
                        $this->recurseCopy($src . '/' . $file, $dest . '/../' . $file);
                    } else {
                        $this->recurseCopy($src . '/' . $file, $dest . '/' . $file);
                    }
                } else {
                    if (!copy($src . '/' . $file, $dest . '/' . $file)) {
                        throw new Exception('Could not copy to ' . $file);
                    }
                }
            }
        }
        closedir($dir);

        // TODO: When should this return false?
        return true;
    }
}
