<?php

namespace App\Services;

/**
 * ITSWEBER CMS – Plugin Loader (Phase 17, hardened Phase 2.4.1)
 *
 * Loads active plugins at bootstrap time.
 *
 * Active plugins are persisted in storage/plugins_active.json as a JSON array
 * of slug strings, e.g. ["my-plugin", "another-plugin"].
 *
 * Security rules enforced before loading any plugin.php:
 *  1. Slug must pass the whitelist regex [a-z0-9][a-z0-9\-]*
 *  2. Plugin directory must exist under plugins/
 *  3. plugin.php entry-point must be a regular file
 *  4. Realpath of plugin.php must be inside plugins/{slug}/
 *  5. Static pre-scan rejects plugins using exit()/die() at top level
 *  6. Each plugin.php is loaded in an isolated try/catch – a single broken
 *     plugin cannot abort the entire boot sequence
 *  7. Failed plugins are auto-deactivated and logged for admin notification
 *
 * Safe mode:
 *  If storage/.plugin_safe_mode exists, ALL plugins are skipped. This allows
 *  recovery when a broken plugin locks out the admin. Create the file manually
 *  via FTP/SSH to regain access, then delete it after fixing the issue.
 *
 * Plugins are loaded in the order they appear in the active list.
 * At load time, plugin.php receives no arguments.  It may call add_hook()
 * and apply_hook() to integrate with the CMS.
 */
class PluginLoader
{
    /** Whitelist pattern for plugin slugs (same as PluginController). */
    private const SLUG_PATTERN = '/^[a-z0-9][a-z0-9\-]*$/';

    /** Path to the active-plugins JSON file, relative to $basePath. */
    private const ACTIVE_FILE = 'storage/plugins_active.json';

    /** Path to the safe-mode flag file, relative to $basePath. */
    private const SAFE_MODE_FILE = 'storage/.plugin_safe_mode';

    /** Path to the failed-plugins log, relative to $basePath. */
    private const FAILED_LOG_FILE = 'storage/.plugin_load_errors';

    /**
     * Patterns that indicate a plugin may call exit/die at the top level.
     * These are checked via static analysis before including the file.
     * We look for exit/die outside of function/class definitions.
     */
    private const DANGEROUS_PATTERNS = [
        // exit('...') or exit("...") or exit; or die('...') or die;
        '/^\s*(exit|die)\s*[\(;]/m',
    ];

    // ─────────────────────────────────────────────────────────────────────────
    // Public API
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Load all currently active plugins.
     *
     * Called once at bootstrap, after App::init(), before route dispatch.
     *
     * @param string $basePath Absolute path to the CMS root (BASE_PATH)
     */
    public static function loadActive(string $basePath): void
    {
        // ── Safe mode: skip ALL plugins if flag file exists ──────────────
        if (is_file($basePath . '/' . self::SAFE_MODE_FILE)) {
            error_log('[PluginLoader] Safe mode active – all plugins skipped. Remove ' . self::SAFE_MODE_FILE . ' to resume.');
            return;
        }

        $activeFile = $basePath . '/' . self::ACTIVE_FILE;

        if (!is_file($activeFile)) {
            return; // No active list yet – nothing to do
        }

        $slugs = self::readActiveFile($activeFile);

        if (empty($slugs)) {
            return;
        }

        // Clear previous load errors
        $failedLogPath = $basePath . '/' . self::FAILED_LOG_FILE;
        if (is_file($failedLogPath)) {
            @unlink($failedLogPath);
        }

        $pluginsBase = rtrim($basePath, '/') . '/plugins';
        $failedSlugs = [];

        foreach ($slugs as $slug) {
            $error = self::loadPlugin($pluginsBase, $slug);
            if ($error !== null) {
                $failedSlugs[$slug] = $error;
            }
        }

        // Auto-deactivate failed plugins and log for admin notification
        if (!empty($failedSlugs)) {
            self::handleFailedPlugins($basePath, $slugs, $failedSlugs);
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Static helpers used by PluginController
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Read the list of active plugin slugs from storage.
     * Returns an empty array on any error.
     *
     * @param string $basePath CMS root path
     * @return string[]
     */
    public static function getActiveSlugs(string $basePath): array
    {
        $activeFile = $basePath . '/' . self::ACTIVE_FILE;

        if (!is_file($activeFile)) {
            return [];
        }

        return self::readActiveFile($activeFile);
    }

    /**
     * Persist the list of active plugin slugs to storage.
     * Slugs are whitelist-validated before saving.
     *
     * @param string   $basePath CMS root path
     * @param string[] $slugs    Array of slug strings to activate
     * @return bool True on success
     */
    public static function saveActiveSlugs(string $basePath, array $slugs): bool
    {
        // Sanitize: only valid slugs survive
        $clean = [];
        foreach ($slugs as $slug) {
            if (is_string($slug) && preg_match(self::SLUG_PATTERN, $slug)) {
                $clean[] = $slug;
            }
        }

        // Deduplicate and re-index
        $clean = array_values(array_unique($clean));

        $activeFile = $basePath . '/' . self::ACTIVE_FILE;
        $storageDir = dirname($activeFile);

        if (!is_dir($storageDir)) {
            return false;
        }

        $json = json_encode($clean, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if ($json === false) {
            return false;
        }

        return file_put_contents($activeFile, $json) !== false;
    }

    /**
     * Check if safe mode is active.
     *
     * @param string $basePath CMS root path
     * @return bool
     */
    public static function isSafeMode(string $basePath): bool
    {
        return is_file($basePath . '/' . self::SAFE_MODE_FILE);
    }

    /**
     * Read plugin load errors from the last boot (if any).
     * Returns an associative array of slug => error message.
     *
     * @param string $basePath CMS root path
     * @return array<string, string>
     */
    public static function getLoadErrors(string $basePath): array
    {
        $path = $basePath . '/' . self::FAILED_LOG_FILE;
        if (!is_file($path)) {
            return [];
        }
        $raw = @file_get_contents($path);
        if ($raw === false) {
            return [];
        }
        $data = json_decode($raw, true);
        return is_array($data) ? $data : [];
    }

    /**
     * Clear the plugin load errors log.
     *
     * @param string $basePath CMS root path
     */
    public static function clearLoadErrors(string $basePath): void
    {
        $path = $basePath . '/' . self::FAILED_LOG_FILE;
        if (is_file($path)) {
            @unlink($path);
        }
    }

    // ─────────────────────────────────────────────────────────────────────────
    // Private helpers
    // ─────────────────────────────────────────────────────────────────────────

    /**
     * Load a single plugin's plugin.php with full security checks.
     * Returns null on success, or an error message string on failure.
     */
    private static function loadPlugin(string $pluginsBase, mixed $slug): ?string
    {
        // ── 1. Slug must be a non-empty string passing the whitelist ──────────
        if (!is_string($slug) || !preg_match(self::SLUG_PATTERN, $slug)) {
            $msg = 'Invalid slug: ' . print_r($slug, true);
            error_log('[PluginLoader] Skipping plugin – ' . $msg);
            return $msg;
        }

        // ── 2. Plugin directory must exist ────────────────────────────────────
        $pluginDir = $pluginsBase . '/' . $slug;
        if (!is_dir($pluginDir)) {
            $msg = 'Plugin directory not found';
            error_log('[PluginLoader] Skipping "' . $slug . '": ' . $msg);
            return $msg;
        }

        // ── 3. Entry-point file must exist ────────────────────────────────────
        $entryPoint = $pluginDir . '/plugin.php';
        if (!is_file($entryPoint)) {
            $msg = 'plugin.php not found';
            error_log('[PluginLoader] Skipping "' . $slug . '": ' . $msg);
            return $msg;
        }

        // ── 4. Realpath check – entry-point must be inside plugins/{slug}/ ────
        $realPluginDir  = realpath($pluginDir);
        $realEntryPoint = realpath($entryPoint);

        if ($realPluginDir === false || $realEntryPoint === false) {
            $msg = 'Realpath resolution failed';
            error_log('[PluginLoader] Skipping "' . $slug . '": ' . $msg);
            return $msg;
        }

        // Ensure plugin.php is genuinely inside the plugin directory
        $prefix = $realPluginDir . DIRECTORY_SEPARATOR;
        if (strpos($realEntryPoint, $prefix) !== 0) {
            $msg = 'Security: plugin.php outside plugin directory';
            error_log('[PluginLoader] Skipping "' . $slug . '": ' . $msg);
            return $msg;
        }

        // ── 5. Static pre-scan: reject plugins with exit/die at top level ────
        $scanResult = self::preScanPlugin($realEntryPoint);
        if ($scanResult !== null) {
            $msg = 'Pre-scan rejected: ' . $scanResult;
            error_log('[PluginLoader] Skipping "' . $slug . '": ' . $msg);
            return $msg;
        }

        // ── 6. Load with error isolation ──────────────────────────────────────
        try {
            (static function (string $__pluginFile): void {
                include $__pluginFile;
            })($entryPoint);
        } catch (\Throwable $e) {
            $msg = $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine();
            error_log('[PluginLoader] Error loading plugin "' . $slug . '": ' . $msg);
            return 'Runtime error: ' . $e->getMessage();
        }

        return null; // Success
    }

    /**
     * Static pre-scan of a plugin.php file before including it.
     *
     * Reads the file and checks for dangerous patterns that could abort
     * the entire PHP process (exit/die), which cannot be caught by try/catch.
     *
     * Returns null if file is safe, or a reason string if rejected.
     */
    private static function preScanPlugin(string $filePath): ?string
    {
        $content = @file_get_contents($filePath);
        if ($content === false) {
            return 'Could not read file';
        }

        // Strip PHP comments to reduce false positives (but keep strings)
        // Simple approach: check for exit/die outside of function bodies
        // More robust: use token_get_all

        $tokens = @token_get_all($content);
        if ($tokens === false) {
            return 'Could not tokenize file';
        }

        $depth = 0;             // Track {} nesting depth
        $inFunctionOrClass = 0; // Depth at which we entered a function/class body

        for ($i = 0, $count = count($tokens); $i < $count; $i++) {
            $token = $tokens[$i];

            // String tokens (single characters like { } ;)
            if (is_string($token)) {
                if ($token === '{') {
                    $depth++;
                } elseif ($token === '}') {
                    $depth--;
                    if ($depth < $inFunctionOrClass) {
                        $inFunctionOrClass = 0;
                    }
                }
                continue;
            }

            [$tokenId, $tokenText] = $token;

            // Track function/class/closure definitions
            if (in_array($tokenId, [T_FUNCTION, T_CLASS], true)) {
                // The next { will start a function/class body
                // Find the opening {
                for ($j = $i + 1; $j < $count; $j++) {
                    $next = $tokens[$j];
                    if (is_string($next) && $next === '{') {
                        $inFunctionOrClass = $depth + 1;
                        break;
                    }
                    // Stop searching if we hit ; (abstract method or use statement)
                    if (is_string($next) && $next === ';') {
                        break;
                    }
                }
                continue;
            }

            // Check for exit/die at top level (depth 0, not inside function/class)
            if ($tokenId === T_EXIT && $inFunctionOrClass === 0 && $depth === 0) {
                $line = $token[2] ?? '?';
                return 'exit/die found at top-level scope (line ' . $line . ') – plugin rejected for safety';
            }
        }

        return null; // File is safe
    }

    /**
     * Handle plugins that failed to load: auto-deactivate and log for admin.
     *
     * @param string $basePath
     * @param string[] $allSlugs Original active slug list
     * @param array<string, string> $failedSlugs slug => error message
     */
    private static function handleFailedPlugins(string $basePath, array $allSlugs, array $failedSlugs): void
    {
        // Log failed plugins for admin notification
        $failedLogPath = $basePath . '/' . self::FAILED_LOG_FILE;
        @file_put_contents(
            $failedLogPath,
            json_encode($failedSlugs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
        );

        // Auto-deactivate failed plugins
        $remaining = array_values(array_filter($allSlugs, function ($s) use ($failedSlugs) {
            return !isset($failedSlugs[$s]);
        }));

        self::saveActiveSlugs($basePath, $remaining);

        foreach ($failedSlugs as $slug => $error) {
            error_log('[PluginLoader] Auto-deactivated plugin "' . $slug . '": ' . $error);
        }
    }

    /**
     * Parse and validate the active-plugins JSON file.
     * Returns a flat array of strings.
     *
     * @return string[]
     */
    private static function readActiveFile(string $path): array
    {
        $raw = @file_get_contents($path);
        if ($raw === false) {
            return [];
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            return [];
        }

        // Return only string values (defensive)
        return array_values(array_filter($data, 'is_string'));
    }
}
