<?php
declare(strict_types=1);

class PluginManager
{
    private static array $hooks              = [];
    private static array $filters            = [];
    private static array $routes             = [];
    private static array $dynamicRoutes      = [];
    private static array $publicDynamicRoutes = [];

    // ── Load ─────────────────────────────────────────────────────────────────────

    public static function load(): void
    {
        $dir = BASE_PATH . '/plugins';
        if (!is_dir($dir)) return;

        // One-shot migration: when a new built-in plugin ships disabled-by-default,
        // existing installations that already have a plugins_disabled.json must
        // get the new id added so the plugin does not silently activate itself
        // on first boot after the update.
        self::ensureBuiltinDefaults();

        $disabled = self::getDisabled();

        foreach (glob($dir . '/*/plugin.json') ?: [] as $manifest) {
            $meta = json_decode((string)file_get_contents($manifest), true);
            if (!$meta || empty($meta['class'])) continue;
            // Defense-in-depth: the upload pipeline already rejects executable
            // payloads, but a malicious manifest could still try path traversal
            // (e.g. "class": "../../../etc/passwd") to make require_once load
            // an arbitrary file. Restrict to a valid PHP class name.
            if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $meta['class'])) continue;
            if (!empty($meta['id']) && in_array($meta['id'], $disabled)) continue;

            $classFile = dirname($manifest) . '/' . $meta['class'] . '.php';
            if (!file_exists($classFile)) continue;

            require_once $classFile;
            if (class_exists($meta['class'])) {
                (new $meta['class']())->register();
            }
        }
    }

    // ── Hooks ────────────────────────────────────────────────────────────────────

    public static function on(string $hook, callable $cb): void
    {
        self::$hooks[$hook][] = $cb;
    }

    public static function fire(string $hook, mixed $context = null): void
    {
        foreach (self::$hooks[$hook] ?? [] as $cb) {
            $cb($context);
        }
    }

    // ── Filters ──────────────────────────────────────────────────────────────────

    public static function addFilter(string $hook, callable $cb): void
    {
        self::$filters[$hook][] = $cb;
    }

    public static function filter(string $hook, mixed $value, mixed $context = null): mixed
    {
        foreach (self::$filters[$hook] ?? [] as $cb) {
            $value = $cb($value, $context);
        }
        return $value;
    }

    // ── Routes ───────────────────────────────────────────────────────────────────

    public static function addRoute(string $path, callable $handler): void
    {
        self::$routes[$path] = $handler;
    }

    public static function addDynamicRoute(string $pattern, callable $handler): void
    {
        self::$dynamicRoutes[$pattern] = $handler;
    }

    /** Register a dynamic route that must be reachable without authentication. */
    public static function addPublicDynamicRoute(string $pattern, callable $handler): void
    {
        self::$publicDynamicRoutes[$pattern] = $handler;
    }

    public static function getRoutes(): array              { return self::$routes; }
    public static function getDynamicRoutes(): array       { return self::$dynamicRoutes; }
    public static function getPublicDynamicRoutes(): array { return self::$publicDynamicRoutes; }

    // ── Plugin management ────────────────────────────────────────────────────────

    /** Returns all installed plugins with metadata and enabled state. */
    public static function getAll(): array
    {
        $dir = BASE_PATH . '/plugins';
        if (!is_dir($dir)) return [];

        $disabled = self::getDisabled();
        $plugins  = [];

        foreach (glob($dir . '/*/plugin.json') ?: [] as $manifest) {
            $meta = json_decode((string)file_get_contents($manifest), true);
            if (!$meta || empty($meta['id'])) continue;
            $meta['enabled']  = !in_array($meta['id'], $disabled);
            $meta['dir_name'] = basename(dirname($manifest));
            // Validate the optional settings_url field. The plugin listing renders
            // a "Settings" button that links here; allowing arbitrary URLs would
            // let a malicious manifest plant a phishing link in the admin UI.
            // Constraint: must start with "/admin/" and contain only safe path chars.
            $settingsUrl = $meta['settings_url'] ?? null;
            if (is_string($settingsUrl) && preg_match('#^/admin/[A-Za-z0-9/_\-]*$#', $settingsUrl)) {
                $meta['settings_url'] = $settingsUrl;
            } else {
                unset($meta['settings_url']);
            }
            // Optional description_key points at a central lang entry so the
            // description renders in the operator's language. Constraint:
            // simple lang-key shape only ([A-Za-z0-9_]+).
            $descKey = $meta['description_key'] ?? null;
            if (is_string($descKey) && preg_match('#^[A-Za-z0-9_]+$#', $descKey)) {
                $meta['description_key'] = $descKey;
            } else {
                unset($meta['description_key']);
            }
            $plugins[] = $meta;
        }

        usort($plugins, fn($a, $b) => strcasecmp($a['name'] ?? '', $b['name'] ?? ''));
        return $plugins;
    }

    public static function isEnabled(string $id): bool
    {
        if (!preg_match('/^[a-zA-Z0-9_-]+$/', $id)) return false;
        if (!file_exists(BASE_PATH . '/plugins/' . $id . '/plugin.json')) return false;
        return !in_array($id, self::getDisabled(), true);
    }

    public static function enable(string $id): void
    {
        $disabled = array_values(array_filter(self::getDisabled(), fn($d) => $d !== $id));
        self::saveDisabled($disabled);
    }

    public static function disable(string $id): void
    {
        $disabled = self::getDisabled();
        if (!in_array($id, $disabled)) {
            $disabled[] = $id;
            self::saveDisabled($disabled);
        }
    }

    // ── Helpers ──────────────────────────────────────────────────────────────────

    private static function disabledFile(): string
    {
        return BASE_PATH . '/data/plugins_disabled.json';
    }

    /**
     * Apply DEFAULT_DISABLED entries to existing installations that already
     * have a plugins_disabled.json. Without this, a newly-shipped built-in
     * plugin would skip the "no file → defaults" path and silently start
     * enabled on every old install.
     *
     * Diff-based: a settings row holds the list of ids we've already applied;
     * on each boot we look at DEFAULT_DISABLED and only add the ids that are
     * not in that list yet. Adding a new built-in plugin to DEFAULT_DISABLED
     * makes the migration fire once per existing install for THAT id, then
     * never again. The legacy v1 marker (which only knew about ['impersonate',
     * 'ldap']) is recognised and migrated to the new applied-ids list so
     * existing 1.14.0+ installs don't re-disable plugins the operator has
     * already activated.
     */
    private static function ensureBuiltinDefaults(): void
    {
        try {
            $applied = self::loadAppliedDefaults();
            $missing = array_values(array_diff(self::DEFAULT_DISABLED, $applied));
            if (empty($missing)) return;

            $file = self::disabledFile();
            // No file → no-file branch in getDisabled() already serves
            // DEFAULT_DISABLED. We just record what's been "applied" so the
            // diff stays correct on later runs.
            if (file_exists($file)) {
                $disabled = json_decode((string)file_get_contents($file), true) ?: [];
                $changed  = false;
                foreach ($missing as $id) {
                    if (!in_array($id, $disabled, true)) {
                        $disabled[] = $id;
                        $changed = true;
                    }
                }
                if ($changed) {
                    self::saveDisabled(array_values($disabled));
                }
            }
            Helper::setSetting('plugin_builtin_defaults_applied_ids', json_encode(self::DEFAULT_DISABLED));
        } catch (\Throwable) {
            // Database / settings not ready yet (e.g. /install pre-bootstrap).
            // The defaults still apply correctly via the no-file branch.
        }
    }

    /**
     * Existing v1 installs only have the boolean marker `plugin_builtin_defaults_v1`,
     * which corresponds to the original two-id list. Treat that as "applied
     * impersonate + ldap" so the diff for any newer additions still fires.
     */
    private static function loadAppliedDefaults(): array
    {
        try {
            $json = Helper::getSetting('plugin_builtin_defaults_applied_ids', '');
            if ($json !== '') {
                $arr = json_decode((string)$json, true);
                if (is_array($arr)) return $arr;
            }
            if (Helper::getSetting('plugin_builtin_defaults_v1', '0') === '1') {
                return ['impersonate', 'ldap'];
            }
        } catch (\Throwable) {
            // settings table not ready
        }
        return [];
    }

    /** Plugins that ship disabled by default and require an explicit admin opt-in.
     *  New plugins NOT listed here are enabled by default and activate automatically
     *  on every instance after an update. */
    private const DEFAULT_DISABLED = ['impersonate', 'ldap', 'oidc', 'gdpr'];

    private static function getDisabled(): array
    {
        $file = self::disabledFile();
        if (!file_exists($file)) return self::DEFAULT_DISABLED;
        return json_decode((string)file_get_contents($file), true) ?: [];
    }

    private static function saveDisabled(array $disabled): void
    {
        file_put_contents(self::disabledFile(), json_encode(array_values($disabled), JSON_PRETTY_PRINT));
    }
}
