<?php

namespace Duplicator\Libs\DupArchive;

use Duplicator\Libs\DupArchive\Headers\DupArchiveDirectoryHeader;
use Duplicator\Libs\DupArchive\Headers\DupArchiveFileHeader;
use Duplicator\Libs\DupArchive\Headers\DupArchiveGlobHeader;
use Duplicator\Libs\DupArchive\Headers\DupArchiveHeader;
use Error;
use Exception;

/**
 * Dup archive
 */
class DupArchive
{
    const EXCEPTION_CODE_FILE_DONT_EXISTS = 10;
    const EXCEPTION_CODE_OPEN_ERROR       = 11;
    const EXCEPTION_CODE_INVALID_PASSWORD = 12;
    const EXCEPTION_CODE_INVALID_MARKER   = 13;
    const EXCEPTION_CODE_INVALID_PARAM    = 14;
    const EXCEPTION_CODE_ADD_ERROR        = 15;
    const EXCEPTION_CODE_EXTRACT_ERROR    = 16;
    const EXCEPTION_CODE_VALIDATION_ERROR = 17;

    const DUPARCHIVE_VERSION  = '5.0.1';
    const INDEX_FILE_NAME     = '__dup__archive__index.json';
    const INDEX_FILE_SIZE     = 2000; // reserver 2K
    const EXTRA_FILES_POS_KEY = 'extraPos';

    const HEADER_TYPE_NONE = 0;
    const HEADER_TYPE_FILE = 1;
    const HEADER_TYPE_DIR  = 2;
    const HEADER_TYPE_GLOB = 3;

    const FLAG_COMPRESS = 1; //bitmask
    const FLAG_CRYPT    = 2; //bitmask

    const HASH_ALGO      = 'crc32b';
    const PWD_ALGO       = '$6$rounds=50000$'; // SHA-512 50000 times with salt
    const PWD_SALT_CHARS = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#%^-_%^&*()[]{}<>~`+=,.;:/?|';
    const CRYPT_ALGO     = 'AES-256-CBC';

    /**
     * Get header type enum
     *
     * @param resource $archiveHandle archive resource
     *
     * @return int
     */
    protected static function getNextHeaderType($archiveHandle): int
    {
        $retVal = self::HEADER_TYPE_NONE;
        $marker = fgets($archiveHandle, 4);

        if (feof($archiveHandle) === false) {
            switch ($marker) {
                case '<D>':
                    $retVal = self::HEADER_TYPE_DIR;
                    break;
                case '<F>':
                    $retVal = self::HEADER_TYPE_FILE;
                    break;
                case '<G>':
                    $retVal = self::HEADER_TYPE_GLOB;
                    break;
                default:
                    throw new Exception("Invalid header marker {$marker}. Location:" . ftell($archiveHandle), self::EXCEPTION_CODE_INVALID_MARKER);
            }
        }

        return $retVal;
    }

    /**
     * Check if archvie is encrypted
     *
     * @param string $path archvie path
     *
     * @return bool
     */
    public static function isEncrypted($path): bool
    {
        return !self::checkPassword($path, '');
    }

    /**
     * Get archive header from file path
     *
     * @param string $path     archive path
     * @param string $password password archive, empty no password
     *
     * @return bool
     */
    public static function checkPassword($path, $password): bool
    {
        try {
            $header = self::getArchiveHeader($path, $password);
        } catch (Exception $e) {
            if ($e->getCode() == self::EXCEPTION_CODE_INVALID_PASSWORD) {
                return false;
            } else {
                throw $e;
            }
        }
        return true;
    }

    /**
     * Get archive header from file path
     *
     * @param string $path     archive path
     * @param string $password password archive, empty no password
     *
     * @return DupArchiveHeader
     */
    public static function getArchiveHeader($path, $password)
    {
        try {
            $archiveHandle = null;
            if (!file_exists($path)) {
                throw new Exception('Archive file don\'t exists', self::EXCEPTION_CODE_FILE_DONT_EXISTS);
            }

            if (($archiveHandle = fopen($path, 'r')) == false) {
                throw new Exception('Can\'t open archive file', self::EXCEPTION_CODE_OPEN_ERROR);
            }
            $result = (new DupArchiveHeader())->readFromArchive($archiveHandle, $password);
        } finally {
            if (is_resource($archiveHandle)) {
                fclose($archiveHandle);
            }
        }
        return $result;
    }

    /**
     * Return true if DupArchive encryption is available
     *
     * @return bool
     */
    public static function isEncryptionAvaliable()
    {
        static $isAvaliable = null;
        if ($isAvaliable === null) {
            $isAvaliable = (
                function_exists('openssl_cipher_iv_length') &&
                function_exists('openssl_encrypt') &&
                function_exists('openssl_decrypt')
            );
        }
        return $isAvaliable;
    }

    /**
     * Get archive index data
     *
     * @param string $archivePath archive path
     * @param string $password    password archive, empty no password
     *
     * @return false|mixed[] return index data, false if don't exists
     */
    public static function getIndexData($archivePath, $password)
    {
        try {
            $indexContent = self::getSrcFile($archivePath, self::INDEX_FILE_NAME, $password, 0, 3000);
            if ($indexContent === false) {
                return false;
            }
            $indexData = json_decode(rtrim($indexContent, "\0"), true);

            if (!is_array($indexData)) {
                return false;
            }
        } catch (Exception | Error $e) {
            return false;
        }

        return $indexData;
    }

    /**
     * Get extra files offset if set or 0
     *
     * @param string $archivePath archive path
     * @param string $password    password archive, empty no password
     *
     * @return int
     */
    public static function getExtraOffset($archivePath, $password)
    {
        if (($indexData = self::getIndexData($archivePath, $password)) === false) {
            return 0;
        }
        return ($indexData[self::EXTRA_FILES_POS_KEY] ?? 0);
    }

    /**
     * Add file in archive from src
     *
     * @param string $archivePath  archive path
     * @param string $relativePath relative path
     * @param string $password     password archive
     * @param int    $offset       start search location
     * @param int    $sizeToSearch max size where search
     *
     * @return bool|int false if file not found of path position
     */
    public static function seachPathInArchive($archivePath, $relativePath, $password, $offset = 0, $sizeToSearch = 0)
    {
        try {
            $archiveHandle = null;
            if (($archiveHandle = fopen($archivePath, 'rb')) === false) {
                throw new Exception("Can’t open archive at $archivePath!", self::EXCEPTION_CODE_OPEN_ERROR);
            }
            $archiveHeader = (new DupArchiveHeader())->readFromArchive($archiveHandle, $password);

            $result = self::searchPath($archiveHandle, $archiveHeader, $relativePath, $offset, $sizeToSearch);
        } finally {
            if (is_resource($archiveHandle)) {
                fclose($archiveHandle);
            }
        }
        return $result;
    }

    /**
     * Search path, if found set and return position
     *
     * @param resource         $archiveHandle dup archive resource
     * @param DupArchiveHeader $archiveHeader archive header
     * @param string           $relativePath  relative path to extract
     * @param int              $offset        start search location
     * @param int              $sizeToSearch  max size where search
     *
     * @return bool|int false if file not found of path position
     */
    public static function searchPath($archiveHandle, DupArchiveHeader $archiveHeader, $relativePath, $offset = 0, $sizeToSearch = 0)
    {
        if (!is_resource($archiveHandle)) {
            throw new Exception('Archive handle must be a resource', self::EXCEPTION_CODE_INVALID_PARAM);
        }

        if (fseek($archiveHandle, $offset, SEEK_SET) < 0) {
            return false;
        }

        if ($offset == 0) {
            $hd = (new DupArchiveHeader())->readFromArchive($archiveHandle, $archiveHeader->getPassword());
        }

        $result   = false;
        $position = ftell($archiveHandle);
        $continue = true;

        do {
            switch (($type = self::getNextHeaderType($archiveHandle))) {
                case self::HEADER_TYPE_FILE:
                    $currentFileHeader = (new DupArchiveFileHeader($archiveHeader))->readFromArchive($archiveHandle, true, true);
                    if ($currentFileHeader->relativePath == $relativePath) {
                        $continue = false;
                        $result   = $position;
                    }
                    break;
                case self::HEADER_TYPE_DIR:
                    $directoryHeader = (new DupArchiveDirectoryHeader($archiveHeader))->readFromArchive($archiveHandle, true);
                    if ($directoryHeader->relativePath == $relativePath) {
                        $continue = false;
                        $result   = $position;
                    }
                    break;
                case self::HEADER_TYPE_NONE:
                    $continue = false;
                    break;
                default:
                    throw new Exception('Invali header type "' . $type . '"', self::EXCEPTION_CODE_INVALID_MARKER);
            }
            $position = ftell($archiveHandle);
            if ($sizeToSearch > 0 && ($position - $offset) >= $sizeToSearch) {
                break;
            }
        } while ($continue);

        if ($result !== false) {
            if (fseek($archiveHandle, $result, SEEK_SET) < 0) {
                return false;
            }
        }
        return $result;
    }

    /**
     * Add file in archive from src
     *
     * @param string $archivePath  archive path
     * @param string $pattern      relative path
     * @param string $password     password archive
     * @param int    $offset       start search location
     * @param int    $sizeToSearch max size where search
     *
     * @return false|array{name: string, position: int} false if file not found or path info
     */
    public static function seachRegexInArchive(
        $archivePath,
        $pattern,
        $password,
        $offset = 0,
        $sizeToSearch = 0
    ) {
        try {
            $archiveHandle = null;
            if (($archiveHandle = fopen($archivePath, 'rb')) === false) {
                throw new Exception("Can’t open archive at $archivePath!", self::EXCEPTION_CODE_OPEN_ERROR);
            }
            $archiveHeader = (new DupArchiveHeader())->readFromArchive($archiveHandle, $password);

            $result = self::searchRegex($archiveHandle, $archiveHeader, $pattern, $offset, $sizeToSearch);
        } finally {
            if (is_resource($archiveHandle)) {
                fclose($archiveHandle);
            }
        }
        return $result;
    }

    /**
     * Search path, if found set and return position
     *
     * @param resource         $archiveHandle dup archive resource
     * @param DupArchiveHeader $archiveHeader archive header
     * @param string           $pattern       regex pattern
     * @param int              $offset        start search location
     * @param int              $sizeToSearch  max size where search
     *
     * @return false|array{name: string, position: int} false if file not found or path info
     */
    public static function searchRegex(
        $archiveHandle,
        DupArchiveHeader $archiveHeader,
        $pattern,
        $offset = 0,
        $sizeToSearch = 0
    ) {
        if (!is_resource($archiveHandle)) {
            throw new Exception('Archive handle must be a resource', self::EXCEPTION_CODE_INVALID_PARAM);
        }

        if (fseek($archiveHandle, $offset, SEEK_SET) < 0) {
            return false;
        }

        if ($offset == 0) {
            $hd = (new DupArchiveHeader())->readFromArchive($archiveHandle, $archiveHeader->getPassword());
        }

        $result   = false;
        $position = ftell($archiveHandle);
        $continue = true;

        do {
            switch (($type = self::getNextHeaderType($archiveHandle))) {
                case self::HEADER_TYPE_FILE:
                    $currentFileHeader = (new DupArchiveFileHeader($archiveHeader))->readFromArchive($archiveHandle, true, true);
                    if (preg_match($pattern, $currentFileHeader->relativePath)) {
                        $continue = false;
                        $result   = [
                            'name'     => $currentFileHeader->relativePath,
                            'position' => $position,
                        ];
                    }
                    break;
                case self::HEADER_TYPE_DIR:
                    $directoryHeader = (new DupArchiveDirectoryHeader($archiveHeader))->readFromArchive($archiveHandle, true);
                    if (preg_match($pattern, $directoryHeader->relativePath)) {
                        $continue = false;
                        $result   = [
                            'name'     => $directoryHeader->relativePath,
                            'position' => $position,
                        ];
                    }
                    break;
                case self::HEADER_TYPE_NONE:
                    $continue = false;
                    break;
                default:
                    throw new Exception('Invali header type "' . $type . '"', self::EXCEPTION_CODE_INVALID_MARKER);
            }
            $position = ftell($archiveHandle);
            if ($sizeToSearch > 0 && ($position - $offset) >= $sizeToSearch) {
                break;
            }
        } while ($continue);

        if ($result !== false) {
            if (fseek($archiveHandle, $result['position'], SEEK_SET) < 0) {
                return false;
            }
        }
        return $result;
    }

    /**
     * Get file content
     *
     * @param string $archivePath  archvie path
     * @param string $relativePath relative path to extract
     * @param string $password     password archive
     * @param int    $offset       start search location
     * @param int    $sizeToSearch max size where search
     *
     * @return bool|string false if file not found
     */
    public static function getSrcFile($archivePath, $relativePath, $password, $offset = 0, $sizeToSearch = 0)
    {
        try {
            $archiveHandle = null;
            if (($archiveHandle = fopen($archivePath, 'rb')) === false) {
                throw new Exception("Can’t open archive at $archivePath!", self::EXCEPTION_CODE_OPEN_ERROR);
            }
            $archiveHeader = (new DupArchiveHeader())->readFromArchive($archiveHandle, $password);
            if (self::searchPath($archiveHandle, $archiveHeader, $relativePath, $offset, $sizeToSearch) === false) {
                return false;
            }

            if (self::getNextHeaderType($archiveHandle) != self::HEADER_TYPE_FILE) {
                return false;
            }

            $header = (new DupArchiveFileHeader($archiveHeader))->readFromArchive($archiveHandle, false, true);
            $result = self::getSrcFromHeader($archiveHandle, $header);
        } finally {
            if (is_resource($archiveHandle)) {
                fclose($archiveHandle);
            }
        }
        return $result;
    }

    /**
     * Get src file form header
     *
     * @param resource             $archiveHandle archive handle
     * @param DupArchiveFileHeader $fileHeader    file header
     *
     * @return string
     */
    protected static function getSrcFromHeader($archiveHandle, DupArchiveFileHeader $fileHeader): string
    {
        if ($fileHeader->fileSize == 0) {
            return '';
        }
        $dataSize = 0;
        $result   = '';

        $globHeader = new DupArchiveGlobHeader($fileHeader);
        do {
            $globHeader->readFromArchive($archiveHandle);
            $result   .= $globHeader->readContent($archiveHandle);
            $dataSize += $globHeader->originalSize;
        } while ($dataSize < $fileHeader->fileSize);

        return $result;
    }

    /**
     * Skip file in archive
     *
     * @param resource             $archiveHandle dup archive resource
     * @param DupArchiveFileHeader $fileHeader    file header
     *
     * @return void
     */
    protected static function skipFileInArchive($archiveHandle, DupArchiveFileHeader $fileHeader)
    {
        if ($fileHeader->fileSize == 0) {
            return;
        }
        $dataSize   = 0;
        $globHeader = new DupArchiveGlobHeader($fileHeader);
        do {
            $globHeader->readFromArchive($archiveHandle, true);
            $dataSize += $globHeader->originalSize;
        } while ($dataSize < $fileHeader->fileSize);
    }

    /**
     * Assumes we are on one header and just need to get to the next
     *
     * @param resource         $archiveHandle dup archive resource
     * @param DupArchiveHeader $archiveHeader archive header
     *
     * @return void
     */
    protected static function skipToNextHeader($archiveHandle, DupArchiveHeader $archiveHeader)
    {
        $headerType = self::getNextHeaderType($archiveHandle);
        switch ($headerType) {
            case self::HEADER_TYPE_FILE:
                $fileHeader = (new DupArchiveFileHeader($archiveHeader))->readFromArchive($archiveHandle, false, true);
                self::skipFileInArchive($archiveHandle, $fileHeader);
                break;
            case self::HEADER_TYPE_DIR:
                $directoryHeader = (new DupArchiveDirectoryHeader($archiveHeader))->readFromArchive($archiveHandle, true);
                break;
            case self::HEADER_TYPE_NONE:
            default:
                break;
        }
    }

    /**
     * Generates a random salt
     *
     * @param int $length salt len
     *
     * @return string The random salt.
     */
    public static function generateSalt($length): string
    {
        $maxRand = (strlen(self::PWD_SALT_CHARS) - 1);

        $password = '';
        for ($i = 0; $i < $length; $i++) {
            if (function_exists('random_int')) {
                // phpcs:ignore PHPCompatibility.FunctionUse.NewFunctions.random_intFound
                $cIndex = random_int(0, $maxRand);
            } else {
                mt_srand(time());
                $cIndex = mt_rand(0, $maxRand);
            }
            $password .= substr(self::PWD_SALT_CHARS, $cIndex, 1);
        }

        return $password;
    }

    /**
     * Encrypt content
     *
     * @param string $content content
     * @param string $key     encrypt key
     * @param bool   $hashKey apply additional hash at key
     *
     * @return false|string The encrypted string on success or false on failure.
     */
    public static function encrypt($content, $key, $hashKey = false)
    {
        static $ivLen = null;
        if ($ivLen === null) {
            if (!self::isEncryptionAvaliable()) {
                throw new Exception('Encryption is unavailable', self::EXCEPTION_CODE_OPEN_ERROR);
            }
            $ivLen = openssl_cipher_iv_length(DupArchive::CRYPT_ALGO);
        }
        $iv = openssl_random_pseudo_bytes($ivLen);

        if ($hashKey) {
            $key = hash('sha256', $key);
        }

        if (($result = openssl_encrypt($content, DupArchive::CRYPT_ALGO, $key, OPENSSL_RAW_DATA, $iv)) === false) {
            return false;
        }

        return $iv . $result;
    }

    /**
     * Decrypt content
     *
     * @param string $content content
     * @param string $key     encrypt key
     * @param bool   $hashKey apply additional hash at key
     *
     * @return string|bool The decrypted string on success or false on failure.
     */
    public static function decrypt($content, $key, $hashKey = false)
    {
        static $ivLen = null;
        if ($ivLen === null) {
            if (!self::isEncryptionAvaliable()) {
                throw new Exception('Encryption is unavailable', self::EXCEPTION_CODE_OPEN_ERROR);
            }
            $ivLen = openssl_cipher_iv_length(DupArchive::CRYPT_ALGO);
        }
        $iv = substr($content, 0, $ivLen);
        if ($hashKey) {
            $key = hash('sha256', $key);
        }
        return openssl_decrypt(substr($content, $ivLen), DupArchive::CRYPT_ALGO, $key, OPENSSL_RAW_DATA, $iv);
    }
}
