<?php
/**
 * FleekDash package synchronizer.
 *
 * Applies FleekDash release or backup ZIPs without routing through the
 * recursive WP_Filesystem dirlist path that can exhaust memory on large
 * development installs.
 *
 * @package FleekDash\Core
 */

namespace FleekDash\Core;

defined('ABSPATH') || exit;

class FleekDashPackageSynchronizer
{
    private const TEMP_PREFIX = 'fleekdash-sync-';
    private const PLUGIN_BOOTSTRAP_FILE = 'fleekdash.php';

    /**
     * @return array{copied_files: int, removed_files: int}|\WP_Error
     */
    public static function synchronizePluginFromRemoteZip(string $downloadUrl, string $pluginSlug)
    {
        if (!\function_exists('download_url')) {
            require_once ABSPATH . 'wp-admin/includes/file.php';
        }

        $tempZip = \download_url($downloadUrl, 300);
        if (\is_wp_error($tempZip)) {
            return $tempZip;
        }

        $result = self::synchronizePluginFromLocalZip($tempZip, $pluginSlug);
        if (\is_string($tempZip) && \file_exists($tempZip)) {
            @\unlink($tempZip);
        }

        return $result;
    }

    /**
     * @return array{copied_files: int, removed_files: int}|\WP_Error
     */
    public static function synchronizePluginFromLocalZip(string $zipPath, string $pluginSlug)
    {
        $workingDirectory = self::createWorkingDirectory();
        if (\is_wp_error($workingDirectory)) {
            return $workingDirectory;
        }

        $result = new \WP_Error('unknown_sync_error', 'Unknown synchronization error');
        try {
            $extractDirectory = $workingDirectory . '/extract';
            $paths = self::prepareSynchronizationPaths($zipPath, $extractDirectory, $pluginSlug);
            if (!\is_wp_error($paths)) {
                $result = self::synchronizeDirectoryContents(
                    $paths['source_root'],
                    $paths['destination_root'],
                    $pluginSlug
                );
            } else {
                $result = $paths;
            }
        } finally {
            self::deleteDirectoryRecursively($workingDirectory);
        }

        return $result;
    }

    /**
     * @return array{source_root: string, destination_root: string}|\WP_Error
     */
    private static function prepareSynchronizationPaths(string $zipPath, string $extractDirectory, string $pluginSlug)
    {
        $result = new \WP_Error('extract_dir_error', 'Could not create extraction directory');
        $extractDirectoryReady = \wp_mkdir_p($extractDirectory);
        if ($extractDirectoryReady) {
            $extractResult = self::extractZipToDirectory($zipPath, $extractDirectory);
            if (!\is_wp_error($extractResult)) {
                $sourceRoot = self::resolveExtractedPluginRoot($extractDirectory, $pluginSlug);
                $destinationRoot = WP_PLUGIN_DIR . '/' . $pluginSlug;
                $destinationReady = $sourceRoot !== null
                    && (\is_dir($destinationRoot) || \wp_mkdir_p($destinationRoot));
                if ($sourceRoot === null) {
                    $result = new \WP_Error('package_root_not_found', 'Could not locate extracted FleekDash package root');
                } elseif (!$destinationReady) {
                    $result = new \WP_Error('destination_dir_error', 'Could not create destination plugin directory');
                } else {
                    $result = [
                        'source_root' => $sourceRoot,
                        'destination_root' => $destinationRoot,
                    ];
                }
            } else {
                $result = $extractResult;
            }
        }

        return $result;
    }

    /**
     * @return array{copied_files: int, removed_files: int}|\WP_Error
     */
    private static function synchronizeDirectoryContents(string $sourceRoot, string $destinationRoot, string $pluginSlug)
    {
        $sourceFiles = self::collectSourceFiles($sourceRoot);
        $result = \is_wp_error($sourceFiles)
            ? $sourceFiles
            : new \WP_Error('unknown_sync_error', 'Unknown synchronization error');
        if (!\is_wp_error($sourceFiles)) {
            $removedFiles = self::removeManagedFilesMissingFromSource($destinationRoot, $pluginSlug, $sourceFiles);
            $copiedFiles = \is_wp_error($removedFiles)
                ? $removedFiles
                : self::copySourceFilesToDestination($destinationRoot, $sourceFiles);

            if (!\is_wp_error($removedFiles) && !\is_wp_error($copiedFiles)) {
                $result = [
                    'copied_files' => $copiedFiles,
                    'removed_files' => $removedFiles,
                ];
            } else {
                $result = \is_wp_error($removedFiles) ? $removedFiles : $copiedFiles;
            }
        }

        return $result;
    }

    /**
     * @return array<string, string>|\WP_Error
     */
    private static function collectSourceFiles(string $sourceRoot)
    {
        $result = [];
        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($sourceRoot, \RecursiveDirectoryIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::LEAVES_ONLY
            );

            foreach ($iterator as $file) {
                if ($file->isFile()) {
                    $path = $file->getPathname();
                    $relativePath = self::buildRelativePath($sourceRoot, $path);
                    $result[$relativePath] = $path;
                }
            }
        } catch (\Exception $exception) {
            $result = new \WP_Error('source_enumeration_error', $exception->getMessage());
        }

        return $result;
    }

    /**
     * @param array<string, string> $sourceFiles
     * @return int|\WP_Error
     */
    private static function removeManagedFilesMissingFromSource(string $destinationRoot, string $pluginSlug, array $sourceFiles)
    {
        $result = 0;
        if (\is_dir($destinationRoot)) {
            try {
                $iterator = new \RecursiveIteratorIterator(
                    new \RecursiveDirectoryIterator($destinationRoot, \RecursiveDirectoryIterator::SKIP_DOTS),
                    \RecursiveIteratorIterator::CHILD_FIRST
                );

                foreach ($iterator as $node) {
                    $nodeRemovalResult = self::removeManagedDestinationNode($destinationRoot, $pluginSlug, $sourceFiles, $node);
                    if (\is_wp_error($nodeRemovalResult)) {
                        $result = $nodeRemovalResult;
                        break;
                    }

                    $result += $nodeRemovalResult;
                }
            } catch (\Exception $exception) {
                $result = new \WP_Error('destination_scan_error', $exception->getMessage());
            }
        }

        return $result;
    }

    /**
     * @param array<string, string> $sourceFiles
     * @return int|\WP_Error
     */
    private static function removeManagedDestinationNode(
        string $destinationRoot,
        string $pluginSlug,
        array $sourceFiles,
        \SplFileInfo $node
    ) {
        $result = 0;
        $path = $node->getPathname();
        $relativePath = self::buildRelativePath($destinationRoot, $path);
        if ($node->isFile()) {
            $managedPath = self::shouldManageDestinationRelativePath($pluginSlug, $relativePath);
            $missingFromSource = !isset($sourceFiles[$relativePath]);
            if ($managedPath && $missingFromSource) {
                if (!@\unlink($path)) {
                    $result = new \WP_Error('destination_cleanup_error', 'Could not remove outdated file: ' . $relativePath);
                } else {
                    $result = 1;
                }
            }
        } elseif ($node->isDir() && self::isDirectoryEmpty($path)) {
            @\rmdir($path);
        }

        return $result;
    }

    /**
     * @param array<string, string> $sourceFiles
     * @return int|\WP_Error
     */
    private static function copySourceFilesToDestination(string $destinationRoot, array $sourceFiles)
    {
        $result = 0;
        foreach ($sourceFiles as $relativePath => $sourcePath) {
            $copyResult = self::copySingleFileToDestination($destinationRoot, $relativePath, $sourcePath);
            if (\is_wp_error($copyResult)) {
                $result = $copyResult;
                break;
            }

            $result++;
        }

        return $result;
    }

    /**
     * @return true|\WP_Error
     */
    private static function copySingleFileToDestination(string $destinationRoot, string $relativePath, string $sourcePath)
    {
        $result = true;
        $destinationPath = $destinationRoot . '/' . $relativePath;
        $destinationDirectory = \dirname($destinationPath);
        $directoryReady = \is_dir($destinationDirectory) || \wp_mkdir_p($destinationDirectory);
        if (!$directoryReady) {
            $result = new \WP_Error('destination_parent_dir_error', 'Could not create directory for: ' . $relativePath);
        } else {
            $replaceFailed = \file_exists($destinationPath) && !@\unlink($destinationPath);
            if ($replaceFailed) {
                $result = new \WP_Error('destination_replace_error', 'Could not replace file: ' . $relativePath);
            } elseif (!@\copy($sourcePath, $destinationPath)) {
                $result = new \WP_Error('destination_copy_error', 'Could not copy file: ' . $relativePath);
            }
        }

        return $result;
    }

    private static function shouldManageDestinationRelativePath(string $pluginSlug, string $relativePath): bool
    {
        $shouldManage = true;
        if ($pluginSlug === FleekDashRollbackRegistry::PLUGIN_SLUG) {
            $shouldManage = FleekDashRollbackRegistry::shouldIncludeBackupRelativePath($relativePath);
        }

        return $shouldManage;
    }

    private static function resolveExtractedPluginRoot(string $extractDirectory, string $pluginSlug): ?string
    {
        $resolvedRoot = null;
        $directMatch = $extractDirectory . '/' . $pluginSlug;
        if (self::isValidExtractedPluginRoot($directMatch)) {
            $resolvedRoot = $directMatch;
        } elseif (self::isValidExtractedPluginRoot($extractDirectory)) {
            $resolvedRoot = $extractDirectory;
        } else {
            $entries = \scandir($extractDirectory);
            if (\is_array($entries)) {
                $resolvedRoot = self::findValidPluginRootInEntries($extractDirectory, $entries);
            }
        }

        return $resolvedRoot;
    }

    /**
     * @return string|\WP_Error
     */
    private static function createWorkingDirectory()
    {
        $result = \trailingslashit(\get_temp_dir()) . self::TEMP_PREFIX . \wp_generate_uuid4();
        if (!\wp_mkdir_p($result)) {
            $result = new \WP_Error('working_dir_error', 'Could not create temporary working directory');
        }

        return $result;
    }

    /**
     * @return true|\WP_Error
     */
    private static function extractZipToDirectory(string $zipPath, string $extractDirectory)
    {
        $result = true;
        if (!\class_exists('ZipArchive')) {
            $result = new \WP_Error('zip_not_available', 'ZipArchive extension not available');
        } else {
            $zip = new \ZipArchive();
            if ($zip->open($zipPath) !== true) {
                $result = new \WP_Error('zip_open_error', 'Could not open ZIP package');
            } else {
                $extractOk = $zip->extractTo($extractDirectory);
                $zip->close();
                if (!$extractOk) {
                    $result = new \WP_Error('zip_extract_error', 'Could not extract ZIP package');
                }
            }
        }

        return $result;
    }

    private static function deleteDirectoryRecursively(string $directory): void
    {
        if (!\is_dir($directory)) {
            return;
        }

        $iterator = new \RecursiveIteratorIterator(
            new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS),
            \RecursiveIteratorIterator::CHILD_FIRST
        );

        foreach ($iterator as $node) {
            $path = $node->getPathname();
            if ($node->isDir()) {
                @\rmdir($path);
            } else {
                @\unlink($path);
            }
        }

        @\rmdir($directory);
    }

    private static function buildRelativePath(string $root, string $path): string
    {
        return \str_replace('\\', '/', \substr($path, \strlen($root) + 1));
    }

    private static function isDirectoryEmpty(string $directory): bool
    {
        $entries = @\scandir($directory);
        if (!\is_array($entries)) {
            return false;
        }

        return \count($entries) === 2;
    }

    private static function isValidExtractedPluginRoot(string $directory): bool
    {
        return \is_dir($directory) && \file_exists($directory . '/' . self::PLUGIN_BOOTSTRAP_FILE);
    }

    /**
     * @param list<string> $entries
     */
    private static function findValidPluginRootInEntries(string $extractDirectory, array $entries): ?string
    {
        $resolvedRoot = null;
        foreach ($entries as $entry) {
            if ($entry === '.' || $entry === '..') {
                continue;
            }

            $candidate = $extractDirectory . '/' . $entry;
            if (self::isValidExtractedPluginRoot($candidate)) {
                $resolvedRoot = $candidate;
                break;
            }
        }

        return $resolvedRoot;
    }
}
