<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

namespace Magento\Framework\Archive;

use Magento\Framework\Archive\Helper\File;

/**
 * Class to work with tar archives
 *
 * @author      Magento Core Team <core@magentocommerce.com>
 */
class Tar extends \Magento\Framework\Archive\AbstractArchive implements \Magento\Framework\Archive\ArchiveInterface
{
    /**
     * Tar block size in bytes
     *
     * @const int
     */
    const TAR_BLOCK_SIZE = 512;

    /**
     * Keep file or directory for packing.
     *
     * @var string
     */
    protected $_currentFile;

    /**
     * Keep path to file or directory for packing.
     *
     * @var string
     */
    protected $_currentPath;

    /**
     * Skip first level parent directory. Example:
     *   use test/fip.php instead test/test/fip.php;
     *
     * @var bool
     */
    protected $_skipRoot;

    /**
     * Tarball data writer
     *
     * @var File
     */
    protected $_writer;

    /**
     * Tarball data reader
     *
     * @var File
     */
    protected $_reader;

    /**
     * Path to file where tarball should be placed
     *
     * @var string
     */
    protected $_destinationFilePath;

    /**
     * Initialize tarball writer
     *
     * @return $this
     */
    protected function _initWriter()
    {
        $this->_writer = new File($this->_destinationFilePath);
        $this->_writer->open('w');

        return $this;
    }

    /**
     * Returns string that is used for tar's header parsing
     *
     * @return string
     */
    protected static function _getFormatParseHeader()
    {
        return 'Z100name/Z8mode/Z8uid/Z8gid/Z12size/Z12mtime/Z8checksum/Z1type/Z100symlink/Z6magic/Z2version/' .
            'Z32uname/Z32gname/Z8devmajor/Z8devminor/Z155prefix/Z12closer';
    }

    /**
     * Destroy tarball writer
     *
     * @return $this
     */
    protected function _destroyWriter()
    {
        if ($this->_writer instanceof File) {
            $this->_writer->close();
            $this->_writer = null;
        }

        return $this;
    }

    /**
     * Get tarball writer
     *
     * @return File
     */
    protected function _getWriter()
    {
        if (!$this->_writer) {
            $this->_initWriter();
        }

        return $this->_writer;
    }

    /**
     * Initialize tarball reader
     *
     * @return $this
     */
    protected function _initReader()
    {
        $this->_reader = new File($this->_getCurrentFile());
        $this->_reader->open('r');

        return $this;
    }

    /**
     * Destroy tarball reader
     *
     * @return $this
     */
    protected function _destroyReader()
    {
        if ($this->_reader instanceof File) {
            $this->_reader->close();
            $this->_reader = null;
        }

        return $this;
    }

    /**
     * Get tarball reader
     *
     * @return File
     */
    protected function _getReader()
    {
        if (!$this->_reader) {
            $this->_initReader();
        }

        return $this->_reader;
    }

    /**
     * Set option that define ability skip first catalog level.
     *
     * @param bool $skipRoot
     * @return $this
     */
    protected function _setSkipRoot($skipRoot)
    {
        $this->_skipRoot = $skipRoot;
        return $this;
    }

    /**
     * Set file which is packing.
     *
     * @param string $file
     * @return $this
     */
    protected function _setCurrentFile($file)
    {
        $file = str_replace('\\', '/', $file);
        $this->_currentFile = $file . (!is_link($file) && is_dir($file) && substr($file, -1) != '/' ? '/' : '');
        return $this;
    }

    /**
     * Set path to file where tarball should be placed
     *
     * @param string $destinationFilePath
     * @return $this
     */
    protected function _setDestinationFilePath($destinationFilePath)
    {
        $this->_destinationFilePath = $destinationFilePath;
        return $this;
    }

    /**
     * Retrieve file which is packing.
     *
     * @return string
     */
    protected function _getCurrentFile()
    {
        return $this->_currentFile;
    }

    /**
     * Set path to file which is packing.
     *
     * @param string $path
     * @return $this
     */
    protected function _setCurrentPath($path)
    {
        $path = str_replace('\\', '/', $path);
        if ($this->_skipRoot && is_dir($path)) {
            $this->_currentPath = $path . (substr($path, -1) != '/' ? '/' : '');
        } else {
            $this->_currentPath = dirname($path) . '/';
        }
        return $this;
    }

    /**
     * Retrieve path to file which is packing.
     *
     * @return string
     */
    protected function _getCurrentPath()
    {
        return $this->_currentPath;
    }

    /**
     * Recursively walk through file tree and create tarball
     *
     * @param bool $skipRoot
     * @param bool $finalize
     * @return void
     * @throws \Magento\Framework\Exception\LocalizedException
     */
    protected function _createTar($skipRoot = false, $finalize = false)
    {
        if (!$skipRoot) {
            $this->_packAndWriteCurrentFile();
        }

        $file = $this->_getCurrentFile();

        if (is_dir($file)) {
            $dirFiles = scandir($file, SCANDIR_SORT_NONE);

            if (false === $dirFiles) {
                throw new \Magento\Framework\Exception\LocalizedException(
                    new \Magento\Framework\Phrase('Can\'t scan dir: %1', [$file])
                );
            }

            $dirFiles = array_diff($dirFiles, ['..', '.']);

            foreach ($dirFiles as $item) {
                $this->_setCurrentFile($file . $item)->_createTar();
            }
        }

        if ($finalize) {
            $this->_getWriter()->write(str_repeat("\0", self::TAR_BLOCK_SIZE * 12));
        }
    }

    /**
     * Write current file to tarball
     *
     * @return void
     */
    protected function _packAndWriteCurrentFile()
    {
        $archiveWriter = $this->_getWriter();
        $archiveWriter->write($this->_composeHeader());

        $currentFile = $this->_getCurrentFile();

        $fileSize = 0;

        if (is_file($currentFile) && !is_link($currentFile)) {
            $fileReader = new File($currentFile);
            $fileReader->open('r');

            while (!$fileReader->eof()) {
                $archiveWriter->write($fileReader->read());
            }

            $fileReader->close();

            $fileSize = filesize($currentFile);
        }

        $appendZerosCount = (self::TAR_BLOCK_SIZE - $fileSize % self::TAR_BLOCK_SIZE) % self::TAR_BLOCK_SIZE;
        $archiveWriter->write(str_repeat("\0", $appendZerosCount));
    }

    /**
     * Compose header for current file in TAR format.
     * If length of file's name greater 100 characters,
     * method breaks header into two pieces. First contains
     * header and data with long name. Second contain only header.
     *
     * @param bool $long
     * @return string
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     * @SuppressWarnings(PHPMD.NPathComplexity)
     */
    protected function _composeHeader($long = false)
    {
        $file = $this->_getCurrentFile();
        $path = $this->_getCurrentPath();
        $infoFile = stat($file);
        $nameFile = str_replace($path, '', $file);
        $nameFile = str_replace('\\', '/', $nameFile);
        $packedHeader = '';
        $longHeader = '';
        if (!$long && strlen($nameFile) > 100) {
            $longHeader = $this->_composeHeader(true);
            $longHeader .= str_pad($nameFile, floor((strlen($nameFile) + 512 - 1) / 512) * 512, "\0");
        }
        $header = [];
        $header['100-name'] = $long ? '././@LongLink' : substr($nameFile, 0, 100);
        $header['8-mode'] = $long ? '       ' : str_pad(
            substr(sprintf("%07o", $infoFile['mode']), -4),
            6,
            '0',
            STR_PAD_LEFT
        );
        $header['8-uid'] = $long || $infoFile['uid'] == 0 ? "\0\0\0\0\0\0\0" : sprintf("%07o", $infoFile['uid']);
        $header['8-gid'] = $long || $infoFile['gid'] == 0 ? "\0\0\0\0\0\0\0" : sprintf("%07o", $infoFile['gid']);
        $header['12-size'] = $long ? sprintf(
            "%011o",
            strlen($nameFile)
        ) : sprintf(
            "%011o",
            is_dir($file) ? 0 : filesize($file)
        );
        $header['12-mtime'] = $long ? '00000000000' : sprintf("%011o", $infoFile['mtime']);
        $header['8-check'] = sprintf('% 8s', '');
        $header['1-type'] = $long ? 'L' : (is_link($file) ? 2 : (is_dir($file) ? 5 : 0));
        $header['100-symlink'] = is_link($file) ? readlink($file) : '';
        $header['6-magic'] = 'ustar ';
        $header['2-version'] = ' ';
        $a = function_exists('posix_getpwuid') ? posix_getpwuid(fileowner($file)) : ['name' => ''];
        $header['32-uname'] = $a['name'];
        $a = function_exists('posix_getgrgid') ? posix_getgrgid(filegroup($file)) : ['name' => ''];
        $header['32-gname'] = $a['name'];
        $header['8-devmajor'] = '';
        $header['8-devminor'] = '';
        $header['155-prefix'] = '';
        $header['12-closer'] = '';

        $packedHeader = '';
        foreach ($header as $key => $element) {
            $length = explode('-', $key);
            $packedHeader .= pack('a' . $length[0], $element);
        }

        $checksum = 0;
        for ($i = 0; $i < 512; $i++) {
            $checksum += ord(substr($packedHeader, $i, 1));
        }
        $packedHeader = substr_replace($packedHeader, sprintf("%07o", $checksum) . "\0", 148, 8);

        return $longHeader . $packedHeader;
    }

    /**
     * Read TAR string from file, and unpacked it.
     * Create files and directories information about described
     * in the string.
     *
     * @param string $destination path to file is unpacked
     * @return string[] list of files
     * @throws \Magento\Framework\Exception\LocalizedException
     * @SuppressWarnings(PHPMD.CyclomaticComplexity)
     */
    protected function _unpackCurrentTar($destination)
    {
        $archiveReader = $this->_getReader();
        $list = [];

        while (!$archiveReader->eof()) {
            $header = $this->_extractFileHeader();

            if (!$header) {
                continue;
            }

            $currentFile = $destination . $header['name'];
            $dirname = dirname($currentFile);

            if (in_array($header['type'], ["0", chr(0), ''])) {
                if (!file_exists($dirname)) {
                    $mkdirResult = @mkdir($dirname, 0777, true);

                    if (false === $mkdirResult) {
                        throw new \Magento\Framework\Exception\LocalizedException(
                            new \Magento\Framework\Phrase('Failed to create directory %1', [$dirname])
                        );
                    }
                }

                $this->_extractAndWriteFile($header, $currentFile);
                $list[] = $currentFile;
            } elseif ($header['type'] == '5') {
                if (!file_exists($dirname)) {
                    $mkdirResult = @mkdir($currentFile, $header['mode'], true);

                    if (false === $mkdirResult) {
                        throw new \Magento\Framework\Exception\LocalizedException(
                            new \Magento\Framework\Phrase('Failed to create directory %1', [$currentFile])
                        );
                    }
                }
                $list[] = $currentFile . '/';
            } elseif ($header['type'] == '2') {
                //we do not interrupt unpack process if symlink creation failed as symlinks are not so important
                @symlink($header['symlink'], $currentFile);
            }
        }
        return $list;
    }

    /**
     * Read and decode file header information from tarball
     *
     * @return array|bool
     */
    protected function _extractFileHeader()
    {
        $archiveReader = $this->_getReader();

        $headerBlock = $archiveReader->read(self::TAR_BLOCK_SIZE);

        if (strlen($headerBlock) < self::TAR_BLOCK_SIZE) {
            return false;
        }

        $header = unpack(self::_getFormatParseHeader(), $headerBlock);

        $header = $this->convertOctToDec($header);

        if ($header['type'] == "5") {
            $header['size'] = 0;
        }

        $checksum = 0;
        $headerBlock = substr_replace($headerBlock, '        ', 148, 8);

        for ($i = 0; $i < 512; $i++) {
            $checksum += ord(substr($headerBlock, $i, 1));
        }

        $checksumOk = $header['checksum'] == $checksum;
        if (isset($header['name']) && $checksumOk) {
            $header['name'] = trim($header['name']);
            if (!($header['name'] == '././@LongLink' && $header['type'] == 'L')) {
                return $header;
            }

            $realNameBlockSize = floor(
                ($header['size'] + self::TAR_BLOCK_SIZE - 1) / self::TAR_BLOCK_SIZE
            ) * self::TAR_BLOCK_SIZE;
            $realNameBlock = $archiveReader->read($realNameBlockSize);
            $realName = substr($realNameBlock, 0, $header['size']);

            $headerMain = $this->_extractFileHeader();
            $headerMain['name'] = trim($realName);
            return $headerMain;
        }

        return false;
    }

    /**
     * Converts octal numbers to decimal ones for specific header types.
     *
     * @param array $header
     * @return array
     */
    private function convertOctToDec(array $header): array
    {
        $typesToConvert = ['mode', 'uid', 'gid', 'size', 'mtime', 'checksum'];
        foreach ($typesToConvert as $type) {
            $octNum = preg_replace('/[^0-7]/', '', $header[$type]);
            $header[$type] = octdec($octNum);
        }

        return $header;
    }

    /**
     * Extract next file from tarball by its $header information and save it to $destination
     *
     * @param array $fileHeader
     * @param string $destination
     * @return void
     */
    protected function _extractAndWriteFile($fileHeader, $destination)
    {
        $fileWriter = new File($destination);
        $fileWriter->open('w', $fileHeader['mode']);

        $archiveReader = $this->_getReader();

        $filesize = $fileHeader['size'];
        $bytesExtracted = 0;

        while ($filesize > $bytesExtracted && !$archiveReader->eof()) {
            $block = $archiveReader->read(self::TAR_BLOCK_SIZE);
            $nonExtractedBytesCount = $filesize - $bytesExtracted;

            $data = substr($block, 0, $nonExtractedBytesCount);
            $fileWriter->write($data);

            $bytesExtracted += strlen($block);
        }
    }

    /**
     * Pack file to TAR (Tape Archiver).
     *
     * @param string $source
     * @param string $destination
     * @param bool $skipRoot
     * @return string
     * @SuppressWarnings(PHPMD.UnusedLocalVariable)
     */
    public function pack($source, $destination, $skipRoot = false)
    {
        $this->_setSkipRoot($skipRoot);
        $source = realpath($source);
        $tarData = $this->_setCurrentPath($source)->_setDestinationFilePath($destination)->_setCurrentFile($source);

        $this->_initWriter();
        $this->_createTar($skipRoot, true);
        $this->_destroyWriter();

        return $destination;
    }

    /**
     * Unpack file from TAR (Tape Archiver).
     *
     * @param string $source
     * @param string $destination
     * @return string
     */
    public function unpack($source, $destination)
    {
        $this->_setCurrentFile($source)->_setCurrentPath($source);

        $this->_initReader();
        $this->_unpackCurrentTar($destination);
        $this->_destroyReader();

        return $destination;
    }

    /**
     * Extract one file from TAR (Tape Archiver).
     *
     * @param string $file
     * @param string $source
     * @param string $destination
     * @return string
     */
    public function extract($file, $source, $destination)
    {
        $this->_setCurrentFile($source);
        $this->_initReader();

        $archiveReader = $this->_getReader();
        $extractedFile = '';

        while (!$archiveReader->eof()) {
            $header = $this->_extractFileHeader();
            if ($header['name'] == $file) {
                $extractedFile = $destination . basename($header['name']);
                $this->_extractAndWriteFile($header, $extractedFile);
                break;
            }

            if ($header['type'] != 5) {
                $skipBytes = floor(
                    ($header['size'] + self::TAR_BLOCK_SIZE - 1) / self::TAR_BLOCK_SIZE
                ) * self::TAR_BLOCK_SIZE;
                $archiveReader->read($skipBytes);
            }
        }

        $this->_destroyReader();
        return $extractedFile;
    }
}
