<?php

/**
 * Клас для роботи з ZIP архівами
 * Створення, витягування та маніпуляції з ZIP файлами
 *
 * @package Flowaxy\Infrastructure\Filesystem\Archive
 * @version 1.0.0 Alpha prerelease
 */

declare(strict_types=1);

namespace Flowaxy\Infrastructure\Filesystem\Archive;

use Flowaxy\Contracts\Filesystem\ArchiveInterface;
use Flowaxy\Support\Facades\Log;
use Exception;

final class Zip implements ArchiveInterface
{
    private ?\ZipArchive $zip = null;
    private string $filePath;
    private bool $isOpen = false;

    // Константи ZipArchive
    private const ZIP_CREATE = 1;
    private const ZIP_OVERWRITE = 8;
    private const ZIP_RDONLY = 0;
    private const ZIP_ER_OK = 0;
    private const ZIP_ER_MULTIDISK = 1;
    private const ZIP_ER_RENAME = 2;
    private const ZIP_ER_CLOSE = 3;
    private const ZIP_ER_SEEK = 4;
    private const ZIP_ER_READ = 5;
    private const ZIP_ER_WRITE = 6;
    private const ZIP_ER_CRC = 7;
    private const ZIP_ER_ZIPCLOSED = 8;
    private const ZIP_ER_NOENT = 9;
    private const ZIP_ER_EXISTS = 10;
    private const ZIP_ER_OPEN = 11;
    private const ZIP_ER_TMPOPEN = 12;
    private const ZIP_ER_ZLIB = 13;
    private const ZIP_ER_MEMORY = 14;
    private const ZIP_ER_CHANGED = 15;
    private const ZIP_ER_COMPNOTSUPP = 16;
    private const ZIP_ER_EOF = 17;
    private const ZIP_ER_INVAL = 18;
    private const ZIP_ER_NOZIP = 19;
    private const ZIP_ER_INTERNAL = 20;
    private const ZIP_ER_INCONS = 21;
    private const ZIP_ER_REMOVE = 22;
    private const ZIP_ER_DELETED = 23;

    /**
     * Конструктор
     *
     * @param string|null $filePath Шлях до ZIP файлу
     * @param int $flags Прапорці для відкриття архіву
     */
    public function __construct(?string $filePath = null, int $flags = self::ZIP_CREATE)
    {
        if (! extension_loaded('zip')) {
            throw new Exception('Розширення ZIP не встановлено');
        }

        if ($filePath !== null) {
            $this->open($filePath, $flags);
        }
    }

    /**
     * Деструктор - закриття архіву при знищенні об'єкта
     */
    public function __destruct()
    {
        $this->close();
    }

    /**
     * Відкриття ZIP архіву
     *
     * @param string $filePath Шлях до ZIP файлу
     * @param int $flags Прапорці для відкриття (ZipArchive::CREATE, ZipArchive::OVERWRITE тощо)
     * @return self
     * @throws Exception Якщо не вдалося відкрити архів
     */
    public function open(string $filePath, int $flags = 1): self
    {
        $this->close();

        /** @var class-string<\ZipArchive> $zipArchiveClass */
        $zipArchiveClass = 'ZipArchive';
        // @phpstan-ignore-next-line
        $this->zip = new $zipArchiveClass();
        $this->filePath = $filePath;

        // Створюємо директорію, якщо її немає
        $dir = dirname($filePath);
        if (! is_dir($dir)) {
            if (! @mkdir($dir, 0755, true)) {
                throw new Exception("Не вдалося створити директорію: {$dir}");
            }
        }

        $result = $this->zip->open($filePath, $flags);

        if ($result !== true) {
            $error = $this->getZipError($result);

            throw new Exception("Не вдалося відкрити ZIP архів '{$filePath}': {$error}");
        }

        $this->isOpen = true;

        return $this;
    }

    /**
     * Закриття ZIP архіву
     *
     * @return bool
     */
    public function close(): bool
    {
        if ($this->isOpen && $this->zip !== null) {
            $result = $this->zip->close();
            $this->isOpen = false;

            if ($result && file_exists($this->filePath)) {
                @chmod($this->filePath, 0644);
            }

            return $result;
        }

        return true;
    }

    /**
     * Додати файл до архіву
     *
     * @param string $filePath Шлях до файлу для додавання
     * @param string|null $localName Ім'я файлу в архіві (якщо null, використовується ім'я файлу)
     * @return bool
     */
    public function addFile(string $filePath, ?string $localName = null): bool
    {
        $this->ensureOpen();

        if (! file_exists($filePath)) {
            Log::Error("Zip::addFile: Файл не існує: {$filePath}");
            return false;
        }

        if (! is_readable($filePath)) {
            Log::Error("Zip::addFile: Файл недоступний для читання: {$filePath}");
            return false;
        }

        $name = $localName ?? basename($filePath);

        if (! $this->zip->addFile($filePath, $name)) {
            Log::Error("Zip::addFile: Не вдалося додати файл '{$filePath}' в архів");
            return false;
        }

        return true;
    }

    /**
     * Додавання вмісту рядка в архів
     *
     * @param string $localName Ім'я файлу в архіві
     * @param string $contents Вміст файлу
     * @return self
     * @throws Exception Якщо не вдалося додати вміст
     */
    public function addFromString(string $localName, string $contents): self
    {
        $this->ensureOpen();

        if (! $this->zip->addFromString($localName, $contents)) {
            throw new Exception("Не вдалося додати вміст в архів з ім'ям '{$localName}'");
        }

        return $this;
    }

    /**
     * Додати директорію до архіву
     *
     * @param string $directoryPath Шлях до директорії
     * @return bool
     */
    public function addDirectory(string $directoryPath): bool
    {
        $this->ensureOpen();

        if (! is_dir($directoryPath)) {
            Log::Error("Zip::addDirectory: Директорія не існує: {$directoryPath}");
            return false;
        }

        try {
            $dirPath = rtrim($directoryPath, '/\\') . DS;

            /** @var class-string<\RecursiveIteratorIterator> $iteratorClass */
            $iteratorClass = 'RecursiveIteratorIterator';
            /** @var class-string<\RecursiveDirectoryIterator> $dirIteratorClass */
            $dirIteratorClass = 'RecursiveDirectoryIterator';
            // @phpstan-ignore-next-line
            $iterator = new $iteratorClass(
                // @phpstan-ignore-next-line
                new $dirIteratorClass($dirPath, 4096), // SKIP_DOTS = 4096
                1 // SELF_FIRST = 1
            );

            foreach ($iterator as $file) {
                if ($file->isDir()) {
                    continue;
                }

                $filePath = $file->getRealPath();
                $relativePath = str_replace($dirPath, '', $filePath);
                $relativePath = str_replace('\\', '/', $relativePath);

                if (! $this->zip->addFile($filePath, $relativePath)) {
                    Log::Error("Zip::addDirectory: Не вдалося додати файл '{$filePath}' в архів");
                    return false;
                }
            }

            return true;
        } catch (Exception $e) {
            Log::Error("Zip::addDirectory: Помилка додавання директорії: " . $e->getMessage(), ['exception' => $e, 'directory' => $directoryPath]);
            return false;
        }
    }

    /**
     * Розпакувати архів
     *
     * @param string $destinationPath Шлях призначення
     * @return bool
     */
    public function extractTo(string $destinationPath): bool
    {
        $this->ensureOpen();

        if (! is_dir($destinationPath)) {
            if (! @mkdir($destinationPath, 0755, true)) {
                Log::Error("Zip::extractTo: Не вдалося створити директорію: {$destinationPath}");
                return false;
            }
        }

        $result = $this->zip->extractTo($destinationPath);

        if (! $result) {
            Log::Error("Zip::extractTo: Не вдалося витягти файли з архіву в '{$destinationPath}'");
            return false;
        }

        return true;
    }

    /**
     * Витягування конкретного файлу з архіву
     *
     * @param string $entryName Ім'я файлу в архіві
     * @param string $destinationPath Шлях призначення
     * @return bool
     * @throws Exception Якщо файл не знайдено або не вдалося витягти
     */
    public function extractFile(string $entryName, string $destinationPath): bool
    {
        $this->ensureOpen();

        if (! $this->hasEntry($entryName)) {
            throw new Exception("Файл '{$entryName}' не знайдено в архіві");
        }

        // Створюємо директорію, якщо її немає
        $dir = dirname($destinationPath);
        if (! is_dir($dir)) {
            if (! @mkdir($dir, 0755, true)) {
                throw new Exception("Не вдалося створити директорію: {$dir}");
            }
        }

        $contents = $this->getFromName($entryName);

        if ($contents === false) {
            throw new Exception("Не вдалося прочитати вміст файлу '{$entryName}' з архіву");
        }

        $result = @file_put_contents($destinationPath, $contents, LOCK_EX);

        if ($result === false) {
            throw new Exception("Не вдалося записати файл: {$destinationPath}");
        }

        @chmod($destinationPath, 0644);

        return true;
    }

    /**
     * Отримати файл з архіву
     *
     * @param string $fileName Ім'я файлу в архіві
     * @return string|false
     */
    public function getFromName(string $fileName): string|false
    {
        $this->ensureOpen();

        return $this->zip->getFromName($fileName);
    }

    /**
     * Видалити файл з архіву
     *
     * @param string $fileName Ім'я файлу в архіві
     * @return bool
     */
    public function deleteName(string $fileName): bool
    {
        $this->ensureOpen();

        if (! $this->zip->deleteName($fileName)) {
            Log::Error("Zip::deleteName: Не вдалося видалити файл '{$fileName}' з архіву");
            return false;
        }

        return true;
    }

    /**
     * Перейменування файлу в архіві
     *
     * @param string $oldName Старе ім'я
     * @param string $newName Нове ім'я
     * @return self
     * @throws Exception Якщо не вдалося перейменувати
     */
    public function renameEntry(string $oldName, string $newName): self
    {
        $this->ensureOpen();

        if (! $this->zip->renameName($oldName, $newName)) {
            throw new Exception("Не вдалося перейменувати файл '{$oldName}' в '{$newName}'");
        }

        return $this;
    }

    /**
     * Отримати список файлів
     *
     * @return array<int, string>
     */
    public function listFiles(): array
    {
        $this->ensureOpen();

        $entries = [];

        for ($i = 0; $i < $this->zip->numFiles; $i++) {
            $entryName = $this->zip->getNameIndex($i);
            if ($entryName !== false) {
                $entries[] = $entryName;
            }
        }

        return $entries;
    }

    /**
     * Отримання інформації про файл в архіві
     *
     * @param string $entryName Ім'я файлу в архіві
     * @return array|false
     */
    public function getEntryInfo(string $entryName)
    {
        $this->ensureOpen();

        $stat = $this->zip->statName($entryName);

        if ($stat === false) {
            return false;
        }

        return [
            'name' => $stat['name'],
            'index' => $stat['index'],
            'crc' => $stat['crc'],
            'size' => $stat['size'],
            'mtime' => $stat['mtime'],
            'comp_size' => $stat['comp_size'],
            'comp_method' => $stat['comp_method'],
        ];
    }

    /**
     * Перевірка наявності файлу в архіві
     *
     * @param string $entryName Ім'я файлу в архіві
     * @return bool
     */
    public function hasEntry(string $entryName): bool
    {
        $this->ensureOpen();

        return $this->zip->locateName($entryName) !== false;
    }

    /**
     * Отримання кількості файлів в архіві
     *
     * @return int
     */
    public function getEntryCount(): int
    {
        $this->ensureOpen();

        return $this->zip->numFiles;
    }

    /**
     * Отримання коментаря архіву
     *
     * @return string|false
     */
    public function getComment()
    {
        $this->ensureOpen();

        return $this->zip->getArchiveComment();
    }

    /**
     * Встановлення коментаря архіву
     *
     * @param string $comment Коментар
     * @return self
     * @throws Exception Якщо не вдалося встановити коментар
     */
    public function setComment(string $comment): self
    {
        $this->ensureOpen();

        if (! $this->zip->setArchiveComment($comment)) {
            throw new Exception('Не вдалося встановити коментар архіву');
        }

        return $this;
    }

    /**
     * Перевірка, чи відкрито архів
     *
     * @return bool
     */
    public function isOpen(): bool
    {
        return $this->isOpen;
    }

    /**
     * Отримання шляху до файлу архіву
     *
     * @return string
     */
    public function getFilePath(): string
    {
        return $this->filePath;
    }

    /**
     * Створення ZIP архіву з директорії
     *
     * @param string $sourceDir Вихідна директорія
     * @param string $zipPath Шлях до створюваного архіву
     * @param array $exclude Паттерни файлів для виключення
     * @return self
     */
    public static function createFromDirectory(string $sourceDir, string $zipPath, array $exclude = []): self
    {
        $zip = new self($zipPath, self::ZIP_CREATE | self::ZIP_OVERWRITE);
        $zip->addDirectory($sourceDir);

        return $zip;
    }

    /**
     * Перевірка, чи відкрито архів (внутрішній метод)
     *
     * @return void
     * @throws Exception Якщо архів не відкрито
     */
    private function ensureOpen(): void
    {
        if (! $this->isOpen || $this->zip === null) {
            throw new Exception('ZIP архів не відкрито');
        }
    }

    /**
     * Отримання текстового опису помилки ZIP
     *
     * @param int $code Код помилки
     * @return string
     */
    private function getZipError(int $code): string
    {
        $errors = [
            self::ZIP_ER_OK => 'OK',
            self::ZIP_ER_MULTIDISK => 'Multi-disk zip archives not supported',
            self::ZIP_ER_RENAME => 'Renaming temporary file failed',
            self::ZIP_ER_CLOSE => 'Closing zip archive failed',
            self::ZIP_ER_SEEK => 'Seek error',
            self::ZIP_ER_READ => 'Read error',
            self::ZIP_ER_WRITE => 'Write error',
            self::ZIP_ER_CRC => 'CRC error',
            self::ZIP_ER_ZIPCLOSED => 'Containing zip archive was closed',
            self::ZIP_ER_NOENT => 'No such file',
            self::ZIP_ER_EXISTS => 'File already exists',
            self::ZIP_ER_OPEN => 'Can\'t open file',
            self::ZIP_ER_TMPOPEN => 'Failure to create temporary file',
            self::ZIP_ER_ZLIB => 'Zlib error',
            self::ZIP_ER_MEMORY => 'Memory allocation failure',
            self::ZIP_ER_CHANGED => 'Entry has been changed',
            self::ZIP_ER_COMPNOTSUPP => 'Compression method not supported',
            self::ZIP_ER_EOF => 'Premature EOF',
            self::ZIP_ER_INVAL => 'Invalid argument',
            self::ZIP_ER_NOZIP => 'Not a zip archive',
            self::ZIP_ER_INTERNAL => 'Internal error',
            self::ZIP_ER_INCONS => 'Zip archive inconsistent',
            self::ZIP_ER_REMOVE => 'Can\'t remove file',
            self::ZIP_ER_DELETED => 'Entry has been deleted',
        ];

        return $errors[$code] ?? "Unknown error (code: {$code})";
    }

    /**
     * Статичний метод: Розпакування ZIP архіву
     *
     * @param string $zipPath Шлях до ZIP архіву
     * @param string $destinationPath Шлях для розпакування
     * @param array|null $entries Список файлів для витягування (null = всі)
     * @return bool Повертає true при успіху, false при помилці
     */
    public static function unpack(string $zipPath, string $destinationPath, ?array $entries = null): bool
    {
        if (! extension_loaded('zip')) {
            Log::Error('Zip::unpack error: ZIP розширення не встановлено');
            return false;
        }

        if (! file_exists($zipPath)) {
            Log::Error("Zip::unpack error: ZIP файл не існує: {$zipPath}", ['zip_path' => $zipPath]);
            return false;
        }

        try {
            $zip = new self($zipPath, self::ZIP_RDONLY);
            $result = $zip->extractTo($destinationPath);
            $zip->close();

            return $result;
        } catch (Exception $e) {
            Log::Error('Zip::unpack error: ' . $e->getMessage(), ['exception' => $e, 'zip_path' => $zipPath, 'destination' => $destinationPath]);
            return false;
        }
    }

    /**
     * Статичний метод: Перейменування файлу в ZIP архіві
     *
     * @param string $zipPath Шлях до ZIP архіву
     * @param string $oldName Старе ім'я файлу в архіві
     * @param string $newName Нове ім'я файлу в архіві
     * @return bool Повертає true при успіху, false при помилці
     */
    public static function rename_file(string $zipPath, string $oldName, string $newName): bool
    {
        if (! extension_loaded('zip')) {
            Log::Error('Zip::rename_file error: ZIP розширення не встановлено');
            return false;
        }

        if (! file_exists($zipPath)) {
            Log::Error("Zip::rename_file error: ZIP файл не існує: {$zipPath}", ['zip_path' => $zipPath]);
            return false;
        }

        try {
            $zip = new self($zipPath);
            $zip->renameEntry($oldName, $newName);
            $result = $zip->close();

            return $result;
        } catch (Exception $e) {
            Log::Error('Zip::rename_file error: ' . $e->getMessage(), ['exception' => $e, 'zip_path' => $zipPath, 'old_name' => $oldName, 'new_name' => $newName]);
            return false;
        }
    }

    /**
     * Статичний метод: Упаковка директорії в ZIP архів
     *
     * @param string $sourceDir Вихідна директорія
     * @param string $zipPath Шлях до створюваного архіву
     * @param array $exclude Паттерни файлів для виключення
     * @return bool Повертає true при успіху, false при помилці
     */
    public static function pack(string $sourceDir, string $zipPath, array $exclude = []): bool
    {
        if (! extension_loaded('zip')) {
            Log::Error('Zip::pack error: ZIP розширення не встановлено');
            return false;
        }

        if (! is_dir($sourceDir)) {
            Log::Error("Zip::pack error: Директорія не існує: {$sourceDir}", ['source_dir' => $sourceDir]);
            return false;
        }

        try {
            $zip = self::createFromDirectory($sourceDir, $zipPath, $exclude);
            $result = $zip->close();

            return $result;
        } catch (Exception $e) {
            Log::Error('Zip::pack error: ' . $e->getMessage(), ['exception' => $e, 'source_dir' => $sourceDir, 'zip_path' => $zipPath]);
            return false;
        }
    }

    /**
     * Статичний метод: Отримання списку файлів в ZIP архіві
     *
     * @param string $zipPath Шлях до ZIP архіву
     * @return array|false Масив імен файлів або false при помилці
     */
    public static function listFilesStatic(string $zipPath)
    {
        if (! extension_loaded('zip')) {
            Log::Error('Zip::listFiles error: ZIP розширення не встановлено');
            return false;
        }

        if (! file_exists($zipPath)) {
            Log::Error("Zip::listFiles error: ZIP файл не існує: {$zipPath}", ['zip_path' => $zipPath]);
            return false;
        }

        try {
            $zip = new self($zipPath, self::ZIP_RDONLY);
            $files = $zip->listFiles();
            $zip->close();

            return $files;
        } catch (Exception $e) {
            Log::Error('Zip::listFiles error: ' . $e->getMessage(), ['exception' => $e, 'zip_path' => $zipPath]);
            return false;
        }
    }
}
