<?php

declare(strict_types=1);

namespace ShieldX\Core;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Private extensions expose documented shieldx_* extension hooks.

use ShieldX\Licensing\LicenseManager;
use ShieldX\Licensing\LicenseSettings;
use ShieldX\Storage\FileStorage;
use ShieldX\Support\Json;

final class AddonRegistry
{
    public const API_VERSION = 1;
    public const DISABLED_OPTION = 'shieldx_disabled_addons';
    private const DISABLED_LOCK_OPTION = 'shieldx_disabled_addons_lock';
    private const DISABLED_LOCK_TTL = 10;
    private const DISABLED_LOCK_RETRIES = 5;
    private const DISABLED_LOCK_WAIT_MICROSECONDS = 10000;
    private const COMPANION_MANIFEST_FILE = '.shieldx-addons.json';
    private const ALLOWED_ADMIN_CALLBACK_CLASSES = [
        'ShieldXAI\\AiPlugin' => true,
        'ShieldXNotifications\\NotificationsPlugin' => true,
        'ShieldXCloudflare\\CloudflarePlugin' => true,
        'ShieldXPulse\\PulsePlugin' => true,
        'ShieldXRecovery\\RecoveryPlugin' => true,
        'ShieldXScheduler\\SchedulerPlugin' => true,
        'ShieldXUpdates\\UpdatesPlugin' => true,
        'ShieldXThreatIntelligence\\ThreatIntelligencePlugin' => true,
    ];

    /** @var array<string,array<string,mixed>>|null */
    private $manifests;

    /** @var array<string,array<string,mixed>> */
    private $rejected = [];

    /** @var array<string,array<int,array<string,mixed>>> */
    private $headerActionsCache = [];

    /** @var array<int,array<string,mixed>>|null */
    private $submenusCache;

    /** @var array<string,array<string,mixed>>|null */
    private $statusExtensionsCache;

    /** @var array<string,array<string,mixed>>|null */
    private $notificationEventsCache;

    /** @var array<int,string>|null */
    private $externalPathsCache;

    /** @var array<string,bool> */
    private static $loaded = [];

    /** @var array<string,string> */
    private static $loadedSources = [];

    /** @var bool */
    private static $discovered = false;

    public static function fromWordPress(): self
    {
        $cached = RequestCache::get(self::class);
        if ($cached instanceof self) {
            return $cached;
        }

        $instance = new self();
        RequestCache::rememberOption(self::class, self::DISABLED_OPTION, $instance);

        return $instance;
    }

    public static function apiVersion(): int
    {
        return defined('SHIELDX_ADDON_API_VERSION') ? (int) SHIELDX_ADDON_API_VERSION : self::API_VERSION;
    }

    /**
     * Rebuild request-local extension collections after a companion plugin
     * registers add-on hooks later in the WordPress bootstrap sequence.
     */
    public function refreshRuntimeCaches(): void
    {
        $this->manifests = null;
        $this->rejected = [];
        $this->clearDerivedCaches();
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array{alg:string,value:string}
     */
    public static function signManifest(array $manifest): array
    {
        return [
            'alg' => 'hmac-sha256-v1',
            'value' => hash_hmac('sha256', self::manifestSignatureJson($manifest), self::manifestSignatureKey()),
        ];
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function manifests(): array
    {
        if (!$this->runtimeEnabled()) {
            $this->rejected = [];

            // Do not cache an inactive result: a license/session refresh can
            // become valid later in this same request. Every inactive check
            // still returns no executable extension manifests.
            return [];
        }

        if ($this->manifests !== null) {
            return $this->manifests;
        }

        $this->rejected = [];
        $this->discoverLocalAddons();
        $this->manifests = $this->registeredManifests();

        foreach ($this->manifests as $id => $manifest) {
            if (!isset(self::$loaded[$id])) {
                self::$loaded[$id] = true;
                $this->doAction('shieldx_addon_loaded', $manifest);
            }
        }

        return $this->manifests;
    }

    /**
     * @param array<string,mixed> $manifest
     */
    public function verifyManifestSignature(string $slug, array $manifest): bool
    {
        if (!self::manifestSignaturesRequired()) {
            return true;
        }

        $signature = isset($manifest['signature']) && is_array($manifest['signature']) ? $manifest['signature'] : [];
        $algorithm = isset($signature['alg']) && is_scalar($signature['alg']) ? (string) $signature['alg'] : '';
        $value = isset($signature['value']) && is_scalar($signature['value']) ? (string) $signature['value'] : '';
        if ($algorithm !== 'hmac-sha256-v1' || preg_match('/^[a-f0-9]{64}$/', $value) !== 1) {
            return false;
        }

        $manifest['id'] = $slug;
        $expected = self::signManifest($manifest);

        return function_exists('hash_equals')
            ? hash_equals($expected['value'], $value)
            : $expected['value'] === $value;
    }

    public function dispatchLifecycle(string $hook): void
    {
        if (!in_array($hook, ['shieldx_addon_activated', 'shieldx_addon_deactivated', 'shieldx_addon_uninstalled'], true)) {
            return;
        }

        if ($hook === 'shieldx_addon_uninstalled') {
            // Teardown must remain available after a license expires: Recovery
            // may still own an MU bridge, auto_prepend_file helper, and vault.
            $manifests = $this->uninstallManifests();
        } else {
            if (!$this->runtimeEnabled()) {
                return;
            }
            $manifests = $this->manifests();
        }

        foreach ($manifests as $manifest) {
            $this->doAction($hook, $manifest);
        }
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    private function uninstallManifests(): array
    {
        $this->rejected = [];
        // Include disabled modules too: a disabled Recovery module can still
        // leave an installed Early WAF layer that must be removed on uninstall.
        $this->discoverLocalAddons(true);

        return $this->registeredManifests();
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    private function registeredManifests(): array
    {
        $registered = $this->applyFilters('shieldx_addons', []);
        $manifests = [];

        foreach ((array) $registered as $manifest) {
            if (!is_array($manifest)) {
                continue;
            }

            $fallbackId = $this->sanitizeIdentifier((string) ($manifest['id'] ?? 'invalid-addon'));
            if (!$this->verifyManifestSignature($fallbackId !== '' ? $fallbackId : 'invalid-addon', $manifest)) {
                $this->rejected[$fallbackId !== '' ? $fallbackId : 'invalid-addon'] = ['id' => '', 'error' => 'invalid_manifest_signature'];
                continue;
            }

            $validated = $this->validateManifest($manifest);
            $id = (string) ($validated['id'] ?? '');
            if ($id === '') {
                $this->rejected[$fallbackId !== '' ? $fallbackId : 'invalid-addon'] = $validated;
                continue;
            }

            if ((string) ($validated['source'] ?? '') === '' && isset(self::$loadedSources[$id])) {
                $validated['source'] = self::$loadedSources[$id];
            }

            $manifests[$id] = $validated;
        }

        return $manifests;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function localAddons(): array
    {
        return $this->moduleItems();
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function addonItems(): array
    {
        return $this->moduleItems();
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function moduleItems(): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        $manifests = $this->manifests();
        $definitions = $this->moduleDefinitions();
        $disabled = array_fill_keys($this->disabledIds(), true);
        $items = [];
        $seen = [];

        foreach ($definitions as $id => $definition) {
            $manifest = $manifests[$id] ?? [];
            $items[] = [
                'id' => $id,
                'name' => (string) ($manifest['name'] ?? $definition['name'] ?? $this->humanizeId($id)),
                'version' => (string) ($manifest['version'] ?? $definition['version'] ?? ''),
                'admin_page' => (string) ($manifest['admin_page'] ?? ''),
                'icon' => (string) ($manifest['icon'] ?? $definition['icon'] ?? 'activity'),
                'updated_at' => $this->fileModifiedAt((string) ($definition['file'] ?? '')),
                'enabled' => !isset($disabled[$id]),
                'loaded' => isset($manifests[$id]),
                'path' => '',
                'can_disable' => true,
                'can_uninstall' => false,
                'source' => 'private_extension',
                'source_label' => __('Private extension', 'xerotact-shield-x-security'),
                'plugin_file' => '',
            ];
            $seen[$id] = true;
        }

        foreach ($manifests as $id => $manifest) {
            if (isset($seen[$id])) {
                continue;
            }

            $source = (string) ($manifest['source'] ?? 'private_extension');
            $items[] = [
                'id' => $id,
                'name' => (string) ($manifest['name'] ?? $id),
                'version' => (string) ($manifest['version'] ?? ''),
                'admin_page' => (string) ($manifest['admin_page'] ?? ''),
                'icon' => (string) ($manifest['icon'] ?? 'activity'),
                'updated_at' => $this->fileModifiedAt((string) ($manifest['plugin_file'] ?? '')),
                'enabled' => true,
                'loaded' => true,
                'path' => '',
                'can_disable' => false,
                'can_uninstall' => false,
                'source' => $source,
                'source_label' => in_array($source, ['bundled_module', 'private_extension'], true) ? __('Private extension', 'xerotact-shield-x-security') : $this->humanizeId($source),
                'plugin_file' => (string) ($manifest['plugin_file'] ?? ''),
            ];
        }

        usort($items, static function (array $a, array $b): int {
            return strcasecmp((string) ($a['name'] ?? ''), (string) ($b['name'] ?? ''));
        });

        return $items;
    }

    private function fileModifiedAt(string $file): string
    {
        if ($file === '' || !is_file($file)) {
            return '';
        }

        $modified = filemtime($file);

        return is_int($modified) && $modified > 0 ? gmdate('c', $modified) : '';
    }

    /**
     * @return array<int,string>
     */
    public function disabledIds(): array
    {
        try {
            $stored = function_exists('get_option') ? get_option(self::DISABLED_OPTION, []) : [];
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list read failed: ' . $error->getMessage());

            return [];
        }
        $ids = [];
        foreach ((array) $stored as $id) {
            $clean = $this->sanitizeIdentifier((string) $id);
            if ($clean !== '' && !in_array($clean, $ids, true)) {
                $ids[] = $clean;
            }
        }

        return $ids;
    }

    /**
     * @return array<string,mixed>
     */
    public function setModuleEnabled(string $moduleId, bool $enabled): array
    {
        if (!$this->runtimeEnabled()) {
            return $this->managementResult(false, __('Activate SHIELD-X to manage private extensions.', 'xerotact-shield-x-security'));
        }

        $id = $this->sanitizeIdentifier($moduleId);
        if ($id === '') {
            return $this->managementResult(false, __('Invalid extension id.', 'xerotact-shield-x-security'));
        }

        if (!isset($this->moduleDefinitions()[$id])) {
            return $this->managementResult(false, __('Extension was not found.', 'xerotact-shield-x-security'), $id);
        }

        return $this->withDisabledIdsLock(function () use ($id, $enabled): array {
            $disabled = $this->disabledIds();
            $alreadyDisabled = in_array($id, $disabled, true);
            if ($enabled) {
                if ($alreadyDisabled) {
                    $disabled = array_values(array_diff($disabled, [$id]));
                    $this->saveDisabledIds($disabled);
                }

                return $this->managementResult(true, __('Extension enabled.', 'xerotact-shield-x-security'), $id);
            }

            if (!$alreadyDisabled) {
                $manifest = $this->manifestForId($id);
                if ($manifest !== []) {
                    $this->doAction('shieldx_addon_deactivated', $manifest);
                }
                $disabled[] = $id;
                $this->saveDisabledIds($disabled);
            }

            return $this->managementResult(true, __('Extension disabled.', 'xerotact-shield-x-security'), $id);
        }, $id);
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function rejected(): array
    {
        $this->manifests();

        return $this->rejected;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function headerActions(string $activePage): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        if (isset($this->headerActionsCache[$activePage])) {
            return $this->headerActionsCache[$activePage];
        }

        $actions = $this->applyFilters('shieldx_admin_header_actions', [], $activePage, $this->manifests());
        $validated = [];

        foreach ((array) $actions as $action) {
            if (!is_array($action)) {
                continue;
            }

            $item = $this->validateHeaderAction($action);
            if ($item !== []) {
                $validated[] = $item;
            }
        }

        usort($validated, static function (array $a, array $b): int {
            return (int) ($a['priority'] ?? 50) <=> (int) ($b['priority'] ?? 50);
        });

        $this->headerActionsCache[$activePage] = $validated;

        return $validated;
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    public function submenus(): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        if ($this->submenusCache !== null) {
            return $this->submenusCache;
        }

        $submenus = $this->applyFilters('shieldx_admin_submenus', [], $this->manifests());
        $validated = [];

        foreach ((array) $submenus as $submenu) {
            if (!is_array($submenu)) {
                continue;
            }

            $item = $this->validateSubmenu($submenu);
            if ($item !== []) {
                $validated[] = $item;
            }
        }

        usort($validated, static function (array $a, array $b): int {
            return (int) ($a['position'] ?? 90) <=> (int) ($b['position'] ?? 90);
        });

        $this->submenusCache = $validated;

        return $validated;
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function statusExtensions(): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        if ($this->statusExtensionsCache !== null) {
            return $this->statusExtensionsCache;
        }

        $extensions = $this->applyFilters('shieldx_status_extensions', [], $this->manifests());
        $validated = [];

        foreach ((array) $extensions as $id => $extension) {
            $key = $this->sanitizeIdentifier((string) $id);
            if ($key === '' || !is_array($extension)) {
                continue;
            }

            $validated[$key] = $extension;
        }

        foreach ($this->manifests() as $id => $manifest) {
            $statusProvider = $this->validateStatusProvider($manifest['status_provider'] ?? null);
            if (isset($validated[$id]) || $statusProvider === []) {
                continue;
            }

            try {
                $status = call_user_func($statusProvider, $manifest);
                if (is_array($status)) {
                    $validated[$id] = $status;
                }
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write(sprintf('SHIELD-X addon status provider %s failed: %s', $id, $error->getMessage()));
                $validated[$id] = [
                    'status' => 'error',
                    'message' => $error->getMessage(),
                ];
            }
        }

        $this->statusExtensionsCache = $validated;

        return $validated;
    }

    /**
     * @return array<string,array<string,mixed>>
     */
    public function notificationEvents(): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        if ($this->notificationEventsCache !== null) {
            return $this->notificationEventsCache;
        }

        $events = $this->applyFilters('shieldx_notification_events', [], $this->manifests());
        $validated = [];

        foreach ((array) $events as $key => $event) {
            $eventKey = $this->sanitizeEventKey((string) $key);
            if ($eventKey === '' || !is_array($event)) {
                continue;
            }

            $item = $this->validateNotificationEvent($event);
            if ($item !== []) {
                $validated[$eventKey] = $item;
            }
        }

        $this->notificationEventsCache = $validated;

        return $validated;
    }

    /**
     * @return array<int,string>
     */
    public function externalPaths(): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        if ($this->externalPathsCache !== null) {
            return $this->externalPathsCache;
        }

        $paths = [];
        foreach ($this->manifests() as $manifest) {
            foreach ((array) ($manifest['external_paths'] ?? []) as $path) {
                $clean = $this->sanitizePath((string) $path);
                if ($clean !== '' && !in_array($clean, $paths, true)) {
                    $paths[] = $clean;
                }
            }
        }

        $this->externalPathsCache = $paths;

        return $paths;
    }

    /**
     * @return array<string,string>
     */
    public function privateStoragePaths(FileStorage $storage): array
    {
        if (!$this->runtimeEnabled()) {
            return [];
        }

        $baseDir = rtrim($storage->baseDir(), '/\\') . '/addons';
        $paths = [];

        foreach ($this->manifests() as $id => $manifest) {
            unset($manifest);
            $paths[$id] = $baseDir . '/' . $id;
        }

        $filtered = $this->applyFilters('shieldx_private_storage_paths', $paths, $this->manifests(), $baseDir);
        $clean = [];
        $normalizedBase = str_replace('\\', '/', rtrim($baseDir, '/\\'));
        foreach ((array) $filtered as $id => $path) {
            $key = $this->sanitizeIdentifier((string) $id);
            if ($key === '' || !is_scalar($path)) {
                continue;
            }

            $candidate = rtrim((string) $path, '/\\');
            $normalized = str_replace('\\', '/', $candidate);
            if (strpos($normalized, $normalizedBase . '/') === 0) {
                $clean[$key] = $candidate;
            }
        }

        return $clean;
    }

    public function privateStoragePath(string $addonId, FileStorage $storage): string
    {
        $id = $this->sanitizeIdentifier($addonId);
        $paths = $this->privateStoragePaths($storage);

        return $id !== '' && isset($paths[$id]) ? $paths[$id] : '';
    }

    /**
     * @return array<string,mixed>
     */
    public function summary(): array
    {
        $manifests = $this->manifests();
        $extensions = $this->statusExtensions();
        $items = $this->addonItems();

        return [
            'api_version' => self::apiVersion(),
            'runtime_enabled' => $this->runtimeEnabled(),
            'registered' => count($manifests),
            'rejected' => count($this->rejected()),
            'items' => $items,
            'extensions' => $extensions,
            'disabled' => $this->disabledIds(),
        ];
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array<string,mixed>
     */
    private function validateManifest(array $manifest): array
    {
        $id = $this->sanitizeIdentifier((string) ($manifest['id'] ?? ''));
        if ($id === '') {
            return ['error' => 'missing_id'];
        }

        $name = trim((string) ($manifest['name'] ?? ''));
        $version = trim((string) ($manifest['version'] ?? ''));
        $minShieldXVersion = trim((string) ($manifest['min_shieldx_version'] ?? ''));
        $addonApiVersion = (int) ($manifest['addon_api_version'] ?? 0);

        if ($name === '' || $version === '' || $minShieldXVersion === '' || $addonApiVersion < 1) {
            return ['id' => '', 'error' => 'missing_required_manifest_fields'];
        }

        if ($addonApiVersion > self::apiVersion()) {
            return ['id' => '', 'error' => 'unsupported_addon_api_version'];
        }

        $coreVersion = defined('SHIELDX_VERSION') ? (string) SHIELDX_VERSION : '0.0.0';
        if (version_compare($coreVersion, $minShieldXVersion, '<')) {
            return ['id' => '', 'error' => 'requires_newer_shieldx'];
        }

        return [
            'id' => $id,
            'name' => $name,
            'version' => $version,
            'min_shieldx_version' => $minShieldXVersion,
            'addon_api_version' => $addonApiVersion,
            'admin_page' => $this->sanitizePageSlug((string) ($manifest['admin_page'] ?? '')),
            'capability' => $this->sanitizeCapability((string) ($manifest['capability'] ?? $this->defaultCapability())),
            'icon' => $this->sanitizeIdentifier((string) ($manifest['icon'] ?? 'activity')),
            'status_provider' => $this->validateStatusProvider($manifest['status_provider'] ?? null),
            'external_paths' => $this->sanitizePaths((array) ($manifest['external_paths'] ?? [])),
            'source' => $this->sanitizeIdentifier((string) ($manifest['source'] ?? '')),
            'plugin_file' => $this->sanitizePluginFile((string) ($manifest['plugin_file'] ?? '')),
        ];
    }

    private function discoverLocalAddons(bool $includeDisabled = false): void
    {
        if (self::$discovered && !$includeDisabled) {
            return;
        }

        if (!$includeDisabled) {
            self::$discovered = true;
        }
        $disabled = array_fill_keys($this->disabledIds(), true);
        foreach ($this->moduleDefinitions() as $id => $definition) {
            if (!$includeDisabled && isset($disabled[$id])) {
                continue;
            }

            $file = (string) ($definition['file'] ?? '');
            if ($file !== '' && $this->requireModuleFile($file)) {
                self::$loadedSources[$id] = (string) ($definition['source'] ?? 'bundled_module');
            }
        }
    }

    public function runtimeEnabled(): bool
    {
        $summary = LicenseManager::fromWordPress()->summaryForCurrentSession();

        return LicenseSettings::sanitizeStatus((string) ($summary['status'] ?? LicenseSettings::STATUS_INACTIVE)) === LicenseSettings::STATUS_ACTIVE
            && !empty($summary['has_stored_key']);
    }

    /**
     * @param array<string,mixed> $action
     * @return array<string,mixed>
     */
    private function validateHeaderAction(array $action): array
    {
        $label = trim((string) ($action['label'] ?? ''));
        $href = trim((string) ($action['href'] ?? ''));
        $modalId = $this->sanitizeIdentifier((string) ($action['modal_id'] ?? ''));
        if ($label === '' || ($href === '' && $modalId === '')) {
            return [];
        }

        return [
            'id' => $this->sanitizeIdentifier((string) ($action['id'] ?? 'addon-action')),
            'label' => $label,
            'href' => $href,
            'modal_id' => $modalId,
            'icon' => $this->sanitizeIdentifier((string) ($action['icon'] ?? 'activity')),
            'active' => !empty($action['active']),
            'warning' => !empty($action['warning']),
            'notice' => !empty($action['notice']),
            'priority' => max(0, min(999, (int) ($action['priority'] ?? 50))),
            'screen_reader_text' => trim((string) ($action['screen_reader_text'] ?? $label)),
        ];
    }

    /**
     * @param array<string,mixed> $submenu
     * @return array<string,mixed>
     */
    private function validateSubmenu(array $submenu): array
    {
        $pageTitle = trim((string) ($submenu['page_title'] ?? ''));
        $menuTitle = trim((string) ($submenu['menu_title'] ?? $pageTitle));
        $menuSlug = $this->sanitizePageSlug((string) ($submenu['menu_slug'] ?? ''));
        $callback = $this->validateAdminCallback($submenu['callback'] ?? null);

        if ($pageTitle === '' || $menuTitle === '' || $menuSlug === '' || $callback === []) {
            return [];
        }

        return [
            'parent_slug' => $this->sanitizePageSlug((string) ($submenu['parent_slug'] ?? 'shield-x')),
            'page_title' => $pageTitle,
            'menu_title' => $menuTitle,
            'capability' => $this->sanitizeCapability((string) ($submenu['capability'] ?? $this->defaultCapability())),
            'menu_slug' => $menuSlug,
            'callback' => $callback,
            'position' => max(0, min(999, (int) ($submenu['position'] ?? 90))),
        ];
    }

    /**
     * @param mixed $callback
     * @return array{0:object|string,1:string}|array{}
     */
    private function validateAdminCallback($callback): array
    {
        if (!is_array($callback) || count($callback) !== 2) {
            return [];
        }

        $target = $callback[0] ?? null;
        $method = $callback[1] ?? null;
        if (!is_string($method) || $method === '' || strpos($method, '__') === 0) {
            return [];
        }

        if (is_object($target)) {
            $class = get_class($target);
        } elseif (is_string($target)) {
            $class = ltrim($target, '\\');
        } else {
            return [];
        }

        if (!$this->isAllowedAdminCallbackClass($class) || !is_callable([$target, $method])) {
            return [];
        }

        return [$target, $method];
    }

    /**
     * Status callbacks run during privileged status collection, so they are
     * subject to the same bundled-addon class allowlist as admin callbacks.
     *
     * @param mixed $callback
     * @return array{0:object|string,1:string}|array{}
     */
    private function validateStatusProvider($callback): array
    {
        $validated = $this->validateAdminCallback($callback);

        // Status collection is read-only. Restrict it to the one explicit
        // status method instead of granting every public method on a bundled
        // add-on class to a manifest field.
        return $validated !== [] && $validated[1] === 'statusSummary' ? $validated : [];
    }

    private function isAllowedAdminCallbackClass(string $class): bool
    {
        $class = ltrim($class, '\\');

        return isset(self::ALLOWED_ADMIN_CALLBACK_CLASSES[$class]);
    }

    /**
     * @param array<string,mixed> $event
     * @return array<string,mixed>
     */
    private function validateNotificationEvent(array $event): array
    {
        $label = trim((string) ($event['label'] ?? ''));
        $group = trim((string) ($event['group'] ?? 'Extensions'));
        $description = trim((string) ($event['description'] ?? ''));
        if ($label === '' || $description === '') {
            return [];
        }

        $profiles = [];
        foreach ((array) ($event['profiles'] ?? []) as $profile) {
            $key = $this->sanitizeIdentifier((string) $profile);
            if (in_array($key, ['normal', 'extreme', 'panic'], true) && !in_array($key, $profiles, true)) {
                $profiles[] = $key;
            }
        }

        return [
            'label' => $label,
            'group' => $group,
            'description' => $description,
            'noisy' => !empty($event['noisy']),
            'critical' => !empty($event['critical']),
            'profiles' => $profiles,
        ];
    }

    /**
     * @param array<int,mixed> $paths
     * @return array<int,string>
     */
    private function sanitizePaths(array $paths): array
    {
        $clean = [];
        foreach ($paths as $path) {
            $value = $this->sanitizePath((string) $path);
            if ($value !== '' && !in_array($value, $clean, true)) {
                $clean[] = $value;
            }
        }

        return $clean;
    }

    private function sanitizePath(string $path): string
    {
        $path = '/' . ltrim(strtolower(trim($path)), '/');
        $path = rtrim($path, '/');

        return preg_match('~^/[a-z0-9][a-z0-9/_-]{1,120}$~', $path) === 1 ? $path : '';
    }

    private function sanitizeIdentifier(string $value): string
    {
        $value = strtolower(trim($value));
        $value = preg_replace('/[^a-z0-9_-]/', '', $value) ?: '';

        return preg_match('/^[a-z0-9][a-z0-9_-]{1,80}$/', $value) === 1 ? $value : '';
    }

    private function sanitizeEventKey(string $value): string
    {
        $value = strtolower(trim($value));

        return preg_match('/^[a-z0-9][a-z0-9_.-]{1,100}$/', $value) === 1 ? $value : '';
    }

    private function sanitizePageSlug(string $value): string
    {
        $value = strtolower(trim($value));
        $value = preg_replace('/[^a-z0-9_-]/', '', $value) ?: '';

        return $value;
    }

    private function sanitizePluginFile(string $value): string
    {
        $value = trim(str_replace('\\', '/', $value));
        if ($value === '' || strpos($value, "\0") !== false || strpos($value, '../') !== false || strpos($value, '/..') !== false) {
            return '';
        }

        return preg_match('~^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+\.php$~', $value) === 1 ? $value : '';
    }

    private function sanitizeCapability(string $value): string
    {
        $value = strtolower(trim($value));
        $value = preg_replace('/[^a-z0-9_]/', '', $value) ?: '';

        return $value !== '' ? $value : $this->defaultCapability();
    }

    private function defaultCapability(): string
    {
        return defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';
    }

    private function sourceAddonBaseDir(): string
    {
        return defined('SHIELDX_PLUGIN_DIR') ? rtrim((string) SHIELDX_PLUGIN_DIR, '/\\') . '/addons' : '';
    }

    /**
     * Return the core source tree first and the companion tree second. The
     * companion tree is driven by its installed-addons registry, which the
     * private updater updates after each verified installation.
     *
     * @return array<int,array{path:string,source:string,uses_registry:bool}>
     */
    private function addonBaseDirectories(): array
    {
        $candidates = [[
            'path' => $this->sourceAddonBaseDir(),
            'source' => 'bundled_module',
            'uses_registry' => false,
        ]];
        $companionBaseDir = $this->companionAddonBaseDir();
        if ($companionBaseDir !== '') {
            $candidates[] = [
                'path' => $companionBaseDir,
                'source' => 'private_extension',
                'uses_registry' => true,
            ];
        }

        $directories = [];
        foreach ($candidates as $candidate) {
            $path = (string) ($candidate['path'] ?? '');
            if ($path === '' || !is_dir($path) || !is_readable($path)) {
                continue;
            }

            $realPath = realpath($path);
            if (!is_string($realPath)) {
                continue;
            }

            $key = rtrim(str_replace('\\', '/', $realPath), '/');
            if ($key === '' || isset($directories[$key])) {
                continue;
            }

            $directories[$key] = [
                'path' => $realPath,
                'source' => (string) ($candidate['source'] ?? 'private_extension'),
                'uses_registry' => !empty($candidate['uses_registry']),
            ];
        }

        return array_values($directories);
    }

    private function companionAddonBaseDir(): string
    {
        if (defined('SHIELDX_EXTENSIONS_DIR')) {
            return rtrim((string) SHIELDX_EXTENSIONS_DIR, '/\\') . '/addons';
        }
        if (defined('SHIELDX_ADDON_DIR')) {
            return rtrim((string) SHIELDX_ADDON_DIR, '/\\');
        }
        if (defined('SHIELDX_UPDATES_DIR')) {
            return dirname(rtrim((string) SHIELDX_UPDATES_DIR, '/\\'));
        }

        return '';
    }

    /**
     * @return array<string,array{name?:string,version?:string,icon?:string,min_shieldx_version?:string,file:string,source:string}>
     */
    private function moduleDefinitions(): array
    {
        $modules = [];
        foreach ($this->addonBaseDirectories() as $base) {
            $baseReal = (string) ($base['path'] ?? '');
            $items = !empty($base['uses_registry'])
                ? $this->companionAddonIds($baseReal)
                : ($baseReal !== '' ? scandir($baseReal) : false);
            if (!is_array($items)) {
                continue;
            }

            foreach ($items as $item) {
                $id = $this->sanitizeIdentifier((string) $item);
                if ($item === '.' || $item === '..' || $id === '' || $id !== (string) $item) {
                    continue;
                }

                $file = rtrim($baseReal, '/\\') . DIRECTORY_SEPARATOR . $id . DIRECTORY_SEPARATOR . 'addon.php';
                if (!is_file($file) || !is_readable($file)) {
                    continue;
                }

                $headers = $this->addonFileHeaders($file);
                $modules[$id] = $headers + [
                    'file' => $file,
                    'source' => (string) ($base['source'] ?? 'private_extension'),
                ];
            }
        }

        ksort($modules);

        return $modules;
    }

    /**
     * Read the private companion's install registry. Older companion versions
     * did not write it, so initialise it once from their already-installed
     * add-on directories instead of hiding a successfully installed add-on.
     *
     * @return array<int,string>
     */
    private function companionAddonIds(string $baseDir): array
    {
        if ($baseDir === '') {
            return [];
        }

        $manifestPath = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . self::COMPANION_MANIFEST_FILE;
        $raw = is_readable($manifestPath) ? file_get_contents($manifestPath) : false;
        $decoded = Json::decodeArray($raw, 65536);
        if (is_array($decoded) && isset($decoded['addons']) && is_array($decoded['addons'])) {
            return $this->sanitizeAddonIds($decoded['addons']);
        }

        $discovered = [];
        $items = scandir($baseDir);
        if (is_array($items)) {
            foreach ($items as $item) {
                $id = $this->sanitizeIdentifier((string) $item);
                if ($item === '.' || $item === '..' || $id === '' || $id !== (string) $item) {
                    continue;
                }

                $file = rtrim($baseDir, '/\\') . DIRECTORY_SEPARATOR . $id . DIRECTORY_SEPARATOR . 'addon.php';
                if (is_file($file) && is_readable($file)) {
                    $discovered[] = $id;
                }
            }
        }

        $discovered = $this->sanitizeAddonIds($discovered);
        $this->writeCompanionAddonManifest($manifestPath, $discovered);

        return $discovered;
    }

    /**
     * @param array<int,mixed> $ids
     * @return array<int,string>
     */
    private function sanitizeAddonIds(array $ids): array
    {
        $clean = [];
        foreach ($ids as $id) {
            $value = $this->sanitizeIdentifier((string) $id);
            if ($value !== '' && !in_array($value, $clean, true)) {
                $clean[] = $value;
            }
        }

        sort($clean, SORT_STRING);

        return $clean;
    }

    /**
     * @param array<int,string> $ids
     */
    private function writeCompanionAddonManifest(string $path, array $ids): void
    {
        $payloadData = [
            'schema' => 1,
            'addons' => $ids,
        ];
        $payload = function_exists('wp_json_encode')
            ? wp_json_encode($payloadData, JSON_UNESCAPED_SLASHES)
            : call_user_func('json_encode', $payloadData, JSON_UNESCAPED_SLASHES);
        if (!is_string($payload)) {
            return;
        }

        // The registry is only a durable index of add-ons already present in
        // the companion directory. A failed write is harmless: discovery above
        // still makes the installed add-on available on this request.
        @file_put_contents($path, $payload . "\n", LOCK_EX);
    }

    private function requireModuleFile(string $file): bool
    {
        $moduleFileRealPath = realpath($file);
        if (!is_string($moduleFileRealPath) || !is_readable($moduleFileRealPath)) {
            return false;
        }

        $moduleFile = str_replace('\\', '/', $moduleFileRealPath);
        if (basename($moduleFile) !== 'addon.php' || !$this->isAddonModulePath($moduleFile)) {
            return false;
        }

        $id = $this->sanitizeIdentifier(basename(dirname($moduleFileRealPath)));
        if ($id === '') {
            return false;
        }

        $headers = $this->addonFileHeaders($moduleFileRealPath);
        $minimumShieldXVersion = $this->sanitizeVersion((string) ($headers['min_shieldx_version'] ?? ''));
        $coreVersion = defined('SHIELDX_VERSION') ? $this->sanitizeVersion((string) SHIELDX_VERSION) : '0.0.0';
        if ($minimumShieldXVersion !== '' && version_compare($coreVersion, $minimumShieldXVersion, '<')) {
            if ($id !== '') {
                $this->rejected[$id] = [
                    'id' => '',
                    'error' => 'requires_newer_shieldx',
                    'min_shieldx_version' => $minimumShieldXVersion,
                ];
            }

            return false;
        }

        require_once $moduleFileRealPath;

        return true;
    }

    private function isAddonModulePath(string $moduleFile): bool
    {
        $moduleFile = str_replace('\\', '/', $moduleFile);
        foreach ($this->addonBaseDirectories() as $base) {
            $basePath = rtrim(str_replace('\\', '/', (string) ($base['path'] ?? '')), '/') . '/';
            if ($basePath !== '' && strpos($moduleFile, $basePath) === 0) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function manifestForId(string $id): array
    {
        $clean = $this->sanitizeIdentifier($id);
        if ($clean === '') {
            return [];
        }

        $registered = $this->applyFilters('shieldx_addons', []);
        foreach ((array) $registered as $manifest) {
            if (!is_array($manifest)) {
                continue;
            }

            $validated = $this->validateManifest($manifest);
            if ((string) ($validated['id'] ?? '') === $clean) {
                return $validated;
            }
        }

        return [];
    }

    /**
     * @return array{name?:string,version?:string,icon?:string,min_shieldx_version?:string}
     */
    private function addonFileHeaders(string $file): array
    {
        $contents = is_readable($file) ? (string) file_get_contents($file, false, null, 0, 4096) : '';

        return $this->parseAddonHeaders($contents);
    }

    /**
     * @return array{name?:string,version?:string,icon?:string,min_shieldx_version?:string}
     */
    private function parseAddonHeaders(string $contents): array
    {
        $headers = [];
        foreach ([
            'name' => ['Module Name', 'Add-on Name'],
            'version' => ['Version'],
            'icon' => ['Icon'],
            'min_shieldx_version' => ['Requires SHIELD-X'],
        ] as $key => $labels) {
            foreach ($labels as $label) {
                if (preg_match('/^[ \t\/*#@]*' . preg_quote($label, '/') . ':\s*(.+)$/mi', $contents, $matches) === 1) {
                    $headers[$key] = trim((string) $matches[1]);
                    break;
                }
            }
        }

        return $headers;
    }

    private static function manifestSignaturesRequired(): bool
    {
        // Private extensions are trusted only when explicitly signed. A local
        // development install can opt out, but production never does so by default.
        return !defined('SHIELDX_EXTENSIONS_ALLOW_UNSIGNED');
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private static function manifestSignatureJson(array $manifest): string
    {
        unset($manifest['signature']);
        $payload = [
            'id' => (string) ($manifest['id'] ?? ''),
            'name' => (string) ($manifest['name'] ?? ''),
            'version' => (string) ($manifest['version'] ?? ''),
            'min_shieldx_version' => (string) ($manifest['min_shieldx_version'] ?? ''),
            'addon_api_version' => (int) ($manifest['addon_api_version'] ?? 0),
            'admin_page' => (string) ($manifest['admin_page'] ?? ''),
            'capability' => (string) ($manifest['capability'] ?? ''),
            'icon' => (string) ($manifest['icon'] ?? ''),
            'status_provider' => self::callbackSignature($manifest['status_provider'] ?? null),
            'external_paths' => array_values(array_map('strval', (array) ($manifest['external_paths'] ?? []))),
        ];

        $json = function_exists('wp_json_encode') ? wp_json_encode($payload) : call_user_func('json_encode', $payload);

        return is_string($json) ? $json : '';
    }

    /**
     * @param mixed $callback
     */
    private static function callbackSignature($callback): string
    {
        if (!is_array($callback) || count($callback) !== 2 || !is_string($callback[1] ?? null)) {
            return '';
        }

        $target = $callback[0] ?? null;
        if (is_object($target)) {
            $class = get_class($target);
        } elseif (is_string($target)) {
            $class = ltrim($target, '\\');
        } else {
            return '';
        }

        return $class . '::' . (string) $callback[1];
    }

    private static function manifestSignatureKey(): string
    {
        if (function_exists('wp_salt')) {
            return (string) wp_salt('auth');
        }

        if (defined('AUTH_KEY')) {
            return (string) AUTH_KEY;
        }

        return 'shield-x-extension-manifest';
    }

    private function sanitizeVersion(string $version): string
    {
        $version = trim($version);

        return preg_match('/^[0-9]+(?:\.[0-9]+){1,3}(?:[-+][A-Za-z0-9.\-]+)?$/', $version) === 1 ? $version : '';
    }

    private function humanizeId(string $id): string
    {
        return ucwords(str_replace(['-', '_'], ' ', $id));
    }

    /**
     * @param array<int,string> $ids
     */
    private function saveDisabledIds(array $ids): void
    {
        $clean = [];
        foreach ($ids as $id) {
            $value = $this->sanitizeIdentifier((string) $id);
            if ($value !== '' && !in_array($value, $clean, true)) {
                $clean[] = $value;
            }
        }

        if (function_exists('update_option')) {
            try {
                update_option(self::DISABLED_OPTION, $clean, false);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list write failed: ' . $error->getMessage());

                throw $error;
            }
        }

        $this->manifests = null;
        $this->clearDerivedCaches();
        RequestCache::rememberOption(self::class, self::DISABLED_OPTION, $this);
    }

    /**
     * @param callable():array<string,mixed> $callback
     * @return array<string,mixed>
     */
    private function withDisabledIdsLock(callable $callback, string $id): array
    {
        if (!function_exists('add_option') || !function_exists('get_option') || !function_exists('delete_option')) {
            try {
                return $callback();
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list update failed: ' . $error->getMessage());

                return $this->managementResult(false, __('Extension settings could not be saved.', 'xerotact-shield-x-security'), $id);
            }
        }

        $random = function_exists('wp_rand') ? (string) wp_rand() : (string) random_int(0, PHP_INT_MAX);
        $token = hash('sha256', self::DISABLED_LOCK_OPTION . '|' . microtime(true) . '|' . $random);

        for ($attempt = 0; $attempt < self::DISABLED_LOCK_RETRIES; $attempt++) {
            $lock = [
                'token' => $token,
                'expires' => time() + self::DISABLED_LOCK_TTL,
            ];

            try {
                $acquired = add_option(self::DISABLED_LOCK_OPTION, $lock, '', false);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list lock failed: ' . $error->getMessage());

                return $this->managementResult(false, __('Extension settings are busy. Try again in a moment.', 'xerotact-shield-x-security'), $id);
            }

            if ($acquired) {
                try {
                    return $callback();
                } catch (\Throwable $error) {
                    \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list update failed: ' . $error->getMessage());

                    return $this->managementResult(false, __('Extension settings could not be saved.', 'xerotact-shield-x-security'), $id);
                } finally {
                    $this->releaseDisabledIdsLock($token);
                }
            }

            try {
                $existing = get_option(self::DISABLED_LOCK_OPTION, []);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list lock read failed: ' . $error->getMessage());

                return $this->managementResult(false, __('Extension settings are busy. Try again in a moment.', 'xerotact-shield-x-security'), $id);
            }
            $expires = is_array($existing) && isset($existing['expires']) && is_numeric($existing['expires'])
                ? (int) $existing['expires']
                : 0;
            if ($expires > 0 && $expires <= time()) {
                try {
                    delete_option(self::DISABLED_LOCK_OPTION);
                } catch (\Throwable $error) {
                    \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list stale lock clear failed: ' . $error->getMessage());
                }
                continue;
            }

            usleep(self::DISABLED_LOCK_WAIT_MICROSECONDS);
        }

        return $this->managementResult(false, __('Extension settings are busy. Try again in a moment.', 'xerotact-shield-x-security'), $id);
    }

    private function releaseDisabledIdsLock(string $token): void
    {
        if (!function_exists('get_option') || !function_exists('delete_option')) {
            return;
        }

        try {
            $existing = get_option(self::DISABLED_LOCK_OPTION, []);
            if (is_array($existing) && isset($existing['token']) && is_string($existing['token']) && hash_equals($token, $existing['token'])) {
                delete_option(self::DISABLED_LOCK_OPTION);
            }
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write('SHIELD-X addon disabled-list lock release failed: ' . $error->getMessage());
        }
    }

    private function clearDerivedCaches(): void
    {
        $this->headerActionsCache = [];
        $this->submenusCache = null;
        $this->statusExtensionsCache = null;
        $this->notificationEventsCache = null;
        $this->externalPathsCache = null;
    }

    /**
     * @return array<string,mixed>
     */
    private function managementResult(bool $ok, string $message, string $id = ''): array
    {
        return [
            'ok' => $ok,
            'message' => $message,
            'id' => $id,
        ];
    }

    /**
     * @param mixed $value
     * @return mixed
     */
    private function applyFilters(string $hook, $value)
    {
        $args = func_get_args();
        array_shift($args);

        if (!function_exists('apply_filters')) {
            return $value;
        }

        try {
            return call_user_func_array('apply_filters', array_merge([$hook], $args));
        } catch (\Throwable $error) {
            \ShieldX\Support\ErrorLog::write(sprintf('SHIELD-X addon filter %s failed: %s', $hook, $error->getMessage()));

            return $value;
        }
    }

    /**
     * @param mixed ...$args
     */
    private function doAction(string $hook, ...$args): void
    {
        if (function_exists('do_action')) {
            try {
                do_action($hook, ...$args);
            } catch (\Throwable $error) {
                \ShieldX\Support\ErrorLog::write(sprintf('SHIELD-X addon action %s failed: %s', $hook, $error->getMessage()));
            }
        }
    }
}
