<?php

declare(strict_types=1);

namespace SeoScope\Updates;

use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use ZipArchive;

/**
 * Applies a CodeCanyon SeoScope ZIP on top of the current installation.
 */
final class PackageInstaller
{
    public const ERR_NO_ZIP_EXT = 'no_zip';
    public const ERR_INVALID_ZIP = 'invalid_zip';
    public const ERR_NO_PACKAGE = 'no_package';
    public const ERR_NO_VERSION = 'no_version';
    public const ERR_NOT_NEWER = 'not_newer';
    public const ERR_WRITE = 'write';
    public const ERR_PATH = 'path';

    public function __construct(
        private string $projectRoot
    ) {
    }

    /**
     * @return array{ok: true, new_version: string, files: int}|array{ok: false, error: string, detail?: string}
     */
    public function applyFromZipPath(string $zipPath, string $currentVersion): array
    {
        if (!class_exists(ZipArchive::class)) {
            return ['ok' => false, 'error' => self::ERR_NO_ZIP_EXT];
        }
        if (!is_readable($zipPath)) {
            return ['ok' => false, 'error' => self::ERR_INVALID_ZIP, 'detail' => 'unreadable'];
        }

        $extractBase = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . 'seoscope-extract-' . bin2hex(random_bytes(6));
        if (!@mkdir($extractBase, 0770, true) && !is_dir($extractBase)) {
            return ['ok' => false, 'error' => self::ERR_WRITE, 'detail' => 'mkdir extract'];
        }

        try {
            $zip = new ZipArchive();
            if ($zip->open($zipPath) !== true) {
                return ['ok' => false, 'error' => self::ERR_INVALID_ZIP, 'detail' => 'open'];
            }
            if (!$zip->extractTo($extractBase)) {
                $zip->close();

                return ['ok' => false, 'error' => self::ERR_INVALID_ZIP, 'detail' => 'extract'];
            }
            $zip->close();

            $packageRoot = $this->resolvePackageRoot($extractBase);
            if ($packageRoot === null) {
                return ['ok' => false, 'error' => self::ERR_NO_PACKAGE];
            }

            $newVersion = $this->readVersionFromPackage($packageRoot);
            if ($newVersion === null) {
                return ['ok' => false, 'error' => self::ERR_NO_VERSION];
            }

            if (version_compare($newVersion, $currentVersion) <= 0) {
                return ['ok' => false, 'error' => self::ERR_NOT_NEWER, 'detail' => $newVersion];
            }

            $count = $this->copyTree($packageRoot, $this->projectRoot);
            if (function_exists('opcache_reset')) {
                @opcache_reset();
            }

            return ['ok' => true, 'new_version' => $newVersion, 'files' => $count];
        } finally {
            $this->deleteDir($extractBase);
        }
    }

    private function resolvePackageRoot(string $extractBase): ?string
    {
        $extractBase = rtrim($extractBase, DIRECTORY_SEPARATOR);
        $indexFlat = $extractBase . '/public/index.php';
        if (is_file($indexFlat) && is_file($extractBase . '/app/Version.php')) {
            return $extractBase;
        }

        $items = @scandir($extractBase);
        if (!is_array($items)) {
            return null;
        }
        $dirs = [];
        foreach ($items as $it) {
            if ($it === '.' || $it === '..') {
                continue;
            }
            $p = $extractBase . '/' . $it;
            if (is_dir($p)) {
                $dirs[] = $p;
            }
        }
        if (count($dirs) === 1) {
            $cand = $dirs[0];
            if (is_file($cand . '/public/index.php') && is_file($cand . '/app/Version.php')) {
                return $cand;
            }
        }

        return null;
    }

    private function readVersionFromPackage(string $packageRoot): ?string
    {
        $path = rtrim($packageRoot, '/') . '/app/Version.php';
        if (!is_readable($path)) {
            return null;
        }
        $content = (string) file_get_contents($path);
        if ($content === '') {
            return null;
        }
        if (preg_match("/const\\s+VERSION\\s*=\\s*'([^']+)'/s", $content, $m)) {
            return (string) $m[1];
        }

        return null;
    }

    private function copyTree(string $from, string $to): int
    {
        $fromReal = realpath($from);
        $toReal = realpath($to);
        if ($fromReal === false || $toReal === false) {
            return 0;
        }

        $fromPrefix = rtrim(str_replace('\\', '/', $fromReal), '/');

        $count = 0;
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($from, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::SELF_FIRST
        );

        /** @var SplFileInfo $f */
        foreach ($it as $f) {
            if (!$f->isFile()) {
                continue;
            }
            $real = $f->getRealPath();
            if ($real === false) {
                continue;
            }
            $fullNorm = str_replace('\\', '/', $real);
            if (!str_starts_with($fullNorm, $fromPrefix . '/')) {
                throw new \RuntimeException(self::ERR_PATH);
            }
            $sub = ltrim(substr($fullNorm, strlen($fromPrefix) + 1), '/');
            if (str_contains($sub, '..')) {
                throw new \RuntimeException(self::ERR_PATH);
            }
            if ($this->isExcludedRelativePath($sub)) {
                continue;
            }
            $target = $toReal . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $sub);
            if (!$this->isTargetUnderProjectRoot($toReal, $target)) {
                throw new \RuntimeException(self::ERR_PATH);
            }
            $dir = dirname($target);
            if (!is_dir($dir) && !@mkdir($dir, 0775, true) && !is_dir($dir)) {
                throw new \RuntimeException(self::ERR_WRITE);
            }
            if (!@copy($real, $target)) {
                throw new \RuntimeException(self::ERR_WRITE);
            }
            $count++;
        }

        return $count;
    }

    private function isExcludedRelativePath(string $unixRel): bool
    {
        $rel = ltrim(str_replace('\\', '/', $unixRel), '/');
        if ($rel === 'config/local.php') {
            return true;
        }
        if ($rel === 'storage' || str_starts_with($rel, 'storage/')) {
            return true;
        }

        return false;
    }

    private function isTargetUnderProjectRoot(string $toReal, string $targetPath): bool
    {
        $rootP = rtrim(str_replace('\\', '/', $toReal), '/') . '/';
        $t = str_replace('\\', '/', $targetPath);

        return str_starts_with($t, $rootP) || $t === rtrim($rootP, '/');
    }

    private function deleteDir(string $dir): void
    {
        if (!is_dir($dir)) {
            return;
        }
        $it = new RecursiveIteratorIterator(
            new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS),
            RecursiveIteratorIterator::CHILD_FIRST
        );
        /** @var SplFileInfo $f */
        foreach ($it as $f) {
            if ($f->isDir()) {
                @rmdir($f->getPathname());
            } else {
                @unlink($f->getPathname());
            }
        }
        @rmdir($dir);
    }
}
