<?php

declare(strict_types=1);

namespace PpPhp\Core\Plugins;

use PpPhp\Core\PluginOwnership;
use PpPhp\Core\PluginRegistry;

/**
 * PluginInstaller — the single, modular gateway for every plugin file
 * operation (install · update · remove). All Plugin Manager actions go through
 * here so the rules live in one place and a bad package can never corrupt a
 * working install:
 *
 *   • ZIP is validated + extracted into an isolated STAGING dir first
 *     (zip-slip-safe: absolute paths and `..` traversal are rejected).
 *   • The staged folder is verified (Plugin.php present + implements a plugin
 *     interface) BEFORE anything live is touched.
 *   • update() BACKS UP the current version, swaps atomically, and ROLLS BACK
 *     from the backup on any failure — enabled/disabled state and ownership are
 *     preserved across the update.
 *
 * Every method returns a uniform result: ['ok'=>bool,'message'=>string,'name'=>string,'backup'=>?string].
 * Adding a new plugin category later means extending self::CATEGORIES only.
 */
final class PluginInstaller
{
    /** Plugin categories this installer manages (extend to add more families). */
    public const CATEGORIES = ['View', 'Edit', 'Filter', 'Validation'];

    /** The interface a plugin's class must implement, per category. */
    private const CATEGORY_INTERFACE = [
        'View'       => 'ViewPlugin',
        'Edit'       => 'EditPlugin',
        'Filter'     => 'FilterPlugin',
        'Validation' => 'ValidationPlugin',
    ];

    /** @return array{ok:bool,message:string,name:string,backup:?string} */
    private static function result(bool $ok, string $message, string $name = '', ?string $backup = null): array
    {
        return ['ok' => $ok, 'message' => $message, 'name' => $name, 'backup' => $backup];
    }

    private static function pluginsBase(): string { return constant('BASE_PATH') . '/plugins'; }
    private static function stagingBase(): string { return constant('BASE_PATH') . '/storage/plugins/staging'; }
    private static function backupBase(): string { return constant('BASE_PATH') . '/storage/plugins/backups'; }

    public static function isValidCategory(string $category): bool
    {
        return in_array($category, self::CATEGORIES, true);
    }

    // ── Public operations ────────────────────────────────────────────────

    /**
     * Install a brand-new plugin from a ZIP. Fails if a plugin of that name
     * already exists (use update() to replace).
     */
    public static function install(string $zipPath, string $category, int $userId, string $username): array
    {
        if (!self::isValidCategory($category)) {
            return self::result(false, 'Unknown plugin category.');
        }
        $staged = self::stageZip($zipPath, $category);
        if (!$staged['ok']) {
            return self::result(false, $staged['message']);
        }
        $name = $staged['name'];
        $stageDir = $staged['dir'];
        $target = self::pluginsBase() . "/{$category}/{$name}";

        try {
            if (is_dir($target)) {
                return self::result(false, "A plugin named “{$name}” already exists. Use Update to replace it.", $name);
            }
            if (!@rename($stageDir, $target)) {
                self::copyDir($stageDir, $target);
            }
            PluginOwnership::claim($category, $name, $userId, $username);
            PluginRegistry::reset();
            return self::result(true, "Plugin “{$name}” installed.", $name);
        } finally {
            self::rmrf(dirname($stageDir)); // clean the staging root
        }
    }

    /**
     * Replace an existing plugin's files from a new ZIP. Backs up the current
     * version, preserves its enabled/disabled state and ownership, verifies the
     * new version, and rolls back automatically if the swap fails.
     */
    public static function update(string $zipPath, string $category, string $name, int $userId, string $username): array
    {
        if (!self::isValidCategory($category)) {
            return self::result(false, 'Unknown plugin category.');
        }
        $name = self::sanitizeName($name);
        $target = self::pluginsBase() . "/{$category}/{$name}";
        if ($name === '' || !is_dir($target)) {
            return self::result(false, 'Plugin not found.', $name);
        }
        if (!PluginOwnership::canManage($category, $name)) {
            return self::result(false, 'You do not have permission to update this plugin.', $name);
        }

        $staged = self::stageZip($zipPath, $category);
        if (!$staged['ok']) {
            return self::result(false, $staged['message'], $name);
        }
        $stageDir = $staged['dir'];
        $wasDisabled = is_file($target . '/.disabled');

        // Back up the live version before touching it.
        $backup = self::backup($category, $name);
        if ($backup === null) {
            self::rmrf(dirname($stageDir));
            return self::result(false, 'Could not create a safety backup — update aborted.', $name);
        }

        try {
            self::rmrf($target);
            if (!@rename($stageDir, $target)) {
                self::copyDir($stageDir, $target);
            }
            // Re-verify what actually landed; roll back if it's not a valid plugin.
            if (!self::verify($target, $category, $name)['ok']) {
                throw new \RuntimeException('The updated files are not a valid plugin.');
            }
            if ($wasDisabled) {
                @file_put_contents($target . '/.disabled', '1'); // keep it disabled if it was
            }
            PluginRegistry::reset();
            return self::result(true, "Plugin “{$name}” updated (previous version backed up).", $name, $backup);
        } catch (\Throwable $e) {
            // Roll back to the backed-up version.
            self::rmrf($target);
            self::copyDir($backup, $target);
            PluginRegistry::reset();
            return self::result(false, 'Update failed and was rolled back: ' . $e->getMessage(), $name, $backup);
        } finally {
            self::rmrf(dirname($stageDir));
        }
    }

    /** Remove a plugin, backing it up first so it can be recovered. */
    public static function remove(string $category, string $name): array
    {
        if (!self::isValidCategory($category)) {
            return self::result(false, 'Unknown plugin category.');
        }
        $name = self::sanitizeName($name);
        $target = self::pluginsBase() . "/{$category}/{$name}";
        if ($name === '' || !is_dir($target)) {
            return self::result(false, 'Plugin not found.', $name);
        }
        if (!PluginOwnership::canManage($category, $name)) {
            return self::result(false, 'You do not have permission to remove this plugin.', $name);
        }
        $backup = self::backup($category, $name);
        self::rmrf($target);
        PluginOwnership::release($category, $name);
        PluginRegistry::reset();
        return self::result(true, "Plugin “{$name}” removed" . ($backup ? ' (backed up).' : '.'), $name, $backup);
    }

    // ── Staging / validation ─────────────────────────────────────────────

    /**
     * Extract a ZIP into an isolated staging dir and verify it's a single valid
     * plugin. Returns ['ok'=>bool,'message'=>string,'name'=>string,'dir'=>string].
     */
    private static function stageZip(string $zipPath, string $category = ''): array
    {
        if (!is_file($zipPath)) {
            return ['ok' => false, 'message' => 'No upload received.', 'name' => '', 'dir' => ''];
        }
        $zip = new \ZipArchive();
        if ($zip->open($zipPath) !== true) {
            return ['ok' => false, 'message' => 'The file is not a valid ZIP archive.', 'name' => '', 'dir' => ''];
        }

        // Determine the single top-level folder + reject unsafe / malformed entries.
        $topDirs = [];
        for ($i = 0; $i < $zip->numFiles; $i++) {
            $entry = (string) $zip->getNameIndex($i);
            $norm = str_replace('\\', '/', $entry);
            if ($norm === '' || $norm[0] === '/' || preg_match('#(^|/)\.\.(/|$)#', $norm) || preg_match('#^[A-Za-z]:#', $norm)) {
                $zip->close();
                return ['ok' => false, 'message' => 'The ZIP contains unsafe file paths and was rejected.', 'name' => '', 'dir' => ''];
            }
            $top = explode('/', $norm)[0];
            if ($top !== '' && $top !== '__MACOSX') {
                $topDirs[$top] = true;
            }
        }
        if (count($topDirs) !== 1) {
            $zip->close();
            return ['ok' => false, 'message' => 'A plugin ZIP must contain exactly one top-level folder.', 'name' => '', 'dir' => ''];
        }
        $folder = self::sanitizeName((string) array_key_first($topDirs));
        if ($folder === '') {
            $zip->close();
            return ['ok' => false, 'message' => 'The plugin folder name is invalid.', 'name' => '', 'dir' => ''];
        }

        $stageRoot = self::stagingBase() . '/' . uniqid('pl_', true);
        if (!is_dir($stageRoot) && !@mkdir($stageRoot, 0755, true)) {
            $zip->close();
            return ['ok' => false, 'message' => 'Could not create a staging area.', 'name' => '', 'dir' => ''];
        }
        $ok = $zip->extractTo($stageRoot);
        $zip->close();
        if (!$ok) {
            self::rmrf($stageRoot);
            return ['ok' => false, 'message' => 'Could not extract the ZIP.', 'name' => '', 'dir' => ''];
        }

        $stageDir = $stageRoot . '/' . $folder;
        $verdict = self::verify($stageDir, $category, $folder);
        if (!$verdict['ok']) {
            self::rmrf($stageRoot);
            return ['ok' => false, 'message' => $verdict['message'], 'name' => '', 'dir' => ''];
        }
        return ['ok' => true, 'message' => '', 'name' => $folder, 'dir' => $stageDir];
    }

    /**
     * A staged/extracted folder must be a real, safe-to-install plugin: a
     * syntactically valid Plugin.php that declares a class implementing the
     * category's interface, with a class name that doesn't collide with any
     * already-installed plugin (all plugins share the PpPhp\Plugins namespace).
     *
     * @param string $category   target category (''=accept any interface)
     * @param string $selfFolder folder being (re)installed — excluded from the
     *                           collision scan so an update never clashes with itself
     */
    private static function verify(string $dir, string $category = '', string $selfFolder = ''): array
    {
        $file = $dir . '/Plugin.php';
        if (!is_file($file)) {
            return ['ok' => false, 'message' => 'The package has no Plugin.php.'];
        }
        $src = (string) @file_get_contents($file);

        // Real syntax check — no exec / php -l needed (shared-hosting-safe). The
        // TOKEN_PARSE flag runs the full compiler parser and throws on bad syntax
        // without executing a line of the plugin.
        try {
            token_get_all($src, TOKEN_PARSE);
        } catch (\ParseError $e) {
            return ['ok' => false, 'message' => 'Plugin.php has a syntax error: ' . $e->getMessage()];
        }

        if (!preg_match('/\bclass\s+([A-Za-z_]\w*)/', $src, $cm)) {
            return ['ok' => false, 'message' => 'Plugin.php declares no class.'];
        }
        $className = $cm[1];

        // The class must implement the interface expected for its category.
        $needed = self::CATEGORY_INTERFACE[$category] ?? null;
        if ($needed !== null) {
            if (!preg_match('/implements\s+[^;{]*\b' . preg_quote($needed, '/') . '\b/', $src)) {
                return ['ok' => false, 'message' => "A {$category} plugin's class must implement {$needed}."];
            }
        } elseif (!preg_match('/implements\s+[^;{]*\b(EditPlugin|ViewPlugin|FilterPlugin|ValidationPlugin)\b/', $src)) {
            return ['ok' => false, 'message' => 'Plugin.php must implement a plugin interface (EditPlugin / ViewPlugin / FilterPlugin / ValidationPlugin).'];
        }

        // Class-name collision: every plugin class must be globally unique because
        // they share one namespace and are all require_once'd. A duplicate would
        // fail to load after install, so reject it here with a clear message.
        $ns   = preg_match('/namespace\s+([^;]+);/', $src, $nm) ? trim($nm[1]) : 'PpPhp\\Plugins';
        $fqcn = ltrim($ns, '\\') . '\\' . $className;
        $clash = self::classDeclaredElsewhere($fqcn, $category, $selfFolder);
        if ($clash !== null) {
            return ['ok' => false, 'message' => "Class “{$className}” is already used by the {$clash} plugin. Rename your class to something unique."];
        }

        return ['ok' => true, 'message' => ''];
    }

    /**
     * Find an installed plugin (other than $exceptCategory/$exceptFolder) whose
     * Plugin.php declares $fqcn. Returns its "Category/Folder" tag, or null.
     */
    private static function classDeclaredElsewhere(string $fqcn, string $exceptCategory = '', string $exceptFolder = ''): ?string
    {
        foreach (self::CATEGORIES as $cat) {
            $catDir = self::pluginsBase() . '/' . $cat;
            if (!is_dir($catDir)) {
                continue;
            }
            foreach (scandir($catDir) ?: [] as $folder) {
                if ($folder === '.' || $folder === '..') {
                    continue;
                }
                if ($cat === $exceptCategory && $folder === $exceptFolder) {
                    continue; // don't clash with the plugin being (re)installed
                }
                $pf = $catDir . '/' . $folder . '/Plugin.php';
                if (!is_file($pf)) {
                    continue;
                }
                $s = (string) @file_get_contents($pf);
                $n = preg_match('/namespace\s+([^;]+);/', $s, $m1) ? trim($m1[1]) : 'PpPhp\\Plugins';
                if (preg_match('/\bclass\s+([A-Za-z_]\w*)/', $s, $m2)
                    && (ltrim($n, '\\') . '\\' . $m2[1]) === $fqcn) {
                    return $cat . '/' . $folder;
                }
            }
        }
        return null;
    }

    // ── Backups + filesystem helpers ─────────────────────────────────────

    /** Copy the live plugin into a timestamped backup. Returns the backup path or null. */
    private static function backup(string $category, string $name): ?string
    {
        $src = self::pluginsBase() . "/{$category}/{$name}";
        if (!is_dir($src)) {
            return null;
        }
        $dest = self::backupBase() . "/{$category}/{$name}/" . date('Ymd-His');
        if (!self::copyDir($src, $dest)) {
            return null;
        }
        return $dest;
    }

    private static function sanitizeName(string $name): string
    {
        return (string) preg_replace('/[^A-Za-z0-9_\-]/', '', $name);
    }

    private static function copyDir(string $src, string $dest): bool
    {
        if (!is_dir($src)) {
            return false;
        }
        if (!is_dir($dest) && !@mkdir($dest, 0755, true)) {
            return false;
        }
        foreach (scandir($src) ?: [] as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $s = $src . '/' . $item;
            $d = $dest . '/' . $item;
            if (is_dir($s)) {
                if (!self::copyDir($s, $d)) {
                    return false;
                }
            } elseif (!@copy($s, $d)) {
                return false;
            }
        }
        return true;
    }

    private static function rmrf(string $dir): void
    {
        if (!is_dir($dir)) {
            if (is_file($dir)) {
                @unlink($dir);
            }
            return;
        }
        foreach (scandir($dir) ?: [] as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }
            $path = $dir . '/' . $item;
            is_dir($path) ? self::rmrf($path) : @unlink($path);
        }
        @rmdir($dir);
    }
}
