<?php

declare(strict_types=1);

namespace Grav\Plugin;

use Composer\Autoload\ClassLoader;
use Grav\Common\Plugin;
use Grav\Common\Processors\Events\RequestHandlerEvent;
use Grav\Common\Utils;
use Grav\Events\PermissionsRegisterEvent;
use Grav\Events\PluginsLoadedEvent;
use Grav\Framework\Acl\PermissionsReader;
use Grav\Plugin\Api\ApiRouter;
use Grav\Plugin\Api\Auth\JwtAuthenticator;
use Grav\Plugin\Api\Audit\AuditStore;
use Grav\Plugin\Api\Audit\AuditSubscriber;
use Grav\Plugin\Api\Demo\DemoManager;
use Grav\Plugin\Api\Auth\ApiKeyManager;
use Grav\Plugin\Api\Popularity\PopularityTracker;
use Grav\Plugin\Api\Services\TranslationOverrideStore;
use Grav\Plugin\Api\Services\TranslationSourceIndex;
use Grav\Plugin\Api\Webhooks\WebhookDispatcher;
use RocketTheme\Toolbox\Event\Event;

class ApiPlugin extends Plugin
{
    public $features = [
        'blueprints' => 1000,
    ];

    protected $active = false;
    protected string $base = '';
    protected string $apiRoute = '';

    public static function getSubscribedEvents(): array
    {
        return [
            'onPluginsInitialized' => [
                ['setup', 100000],
                ['onPluginsInitialized', 1001],
            ],
            'onRequestHandlerInit' => [
                ['onRequestHandlerInit', 99000],
            ],
            'onBeforeCacheClear' => ['onBeforeCacheClear', 0],
            // Re-merge this site's translation overrides. Fires unconditionally
            // (not gated on isAdmin()) because overrides apply to the front end
            // as much as the admin. See onThemeInitialized() for the timing.
            'onThemeInitialized' => ['onThemeInitialized', 0],
            PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000],
            // Fires from Plugins::init(), which runs BEFORE InitializeProcessor
            // starts the session — the only window in which we can still stop the
            // shared front-end session from being started for a preview request.
            PluginsLoadedEvent::class => ['onPluginsLoaded', 0],
        ];
    }

    /**
     * Isolate the Admin-Next page preview from the visitor's front-end session.
     *
     * Admin-Next renders the preview by pointing an iframe (and the "open in new
     * tab" link) at the real front-end URL. That request rides the shared
     * front-end `grav-site-*` session cookie, and booting/reading that session
     * under the admin context can rotate or invalidate it — logging out a visitor
     * signed in to the public site in the same browser (admin2#88, #79).
     *
     * When the preview flags itself with `admin_preview`, suppress session
     * initialization for this one request so the visitor's session is never read,
     * rewritten, or destroyed. The page still renders (as an anonymous visitor,
     * which is exactly what a layout preview wants), and no `grav-site-*` cookie
     * is planted even on a cross-origin iframe. This must run before the session
     * starts, so it hooks PluginsLoadedEvent rather than onPluginsInitialized
     * (which fires a processor later, after the session is already up).
     *
     * The same switch covers API calls that authenticate with a token
     * (`X-API-Token`, `X-API-Key` or `Authorization: Bearer`). Token auth never
     * reads the session, so starting one only created a session file for
     * cookieless callers, sent `Expires: 1981` / `Pragma: no-cache` on every
     * response (which also defeated the conditional GET on /translations) and
     * took the session lock. A caller that also sends this site's session cookie
     * (Admin-Next does) keeps its session, as does a cookie-only caller. The SSO hand-off
     * parks state in the session, so it is left alone.
     */
    public function onPluginsLoaded(): void
    {
        if (!$this->config->get('plugins.api.protect_frontend_session', true)) {
            return;
        }

        if (isset($_GET['admin_preview']) || $this->isTokenAuthenticatedApiRequest()) {
            $this->config->set('system.session.initialize', false);
        }
    }

    /**
     * Whether this request targets the API and carries a token credential.
     *
     * Runs before Grav's Uri is initialised, so the path comes from the raw
     * request and the site's base path from the front controller's location.
     */
    protected function isTokenAuthenticatedApiRequest(): bool
    {
        $route = $this->config->get('plugins.api.route');
        if (!$route) {
            return false;
        }

        try {
            $request = $this->grav['request'];
            $hasToken = $request->getHeaderLine('X-API-Token') !== ''
                || $request->getHeaderLine('X-API-Key') !== ''
                || stripos($request->getHeaderLine('Authorization'), 'Bearer ') === 0;
            $path = $request->getUri()->getPath();
        } catch (\Throwable) {
            return false;
        }

        // Admin-Next sends its token alongside the site's session cookie. That
        // session keeps a front-end login alive and backs the token, so only
        // cookieless callers (scripts, MCP, curl) go without one.
        if (!$hasToken || $this->requestHasSessionCookie()) {
            return false;
        }

        $scriptDir = str_replace('\\', '/', dirname((string) ($_SERVER['SCRIPT_NAME'] ?? '')));
        $gravBase = rtrim($scriptDir, '/.');
        if ($gravBase !== '' && str_starts_with($path, $gravBase . '/')) {
            $path = substr($path, strlen($gravBase));
        }

        $apiBase = '/' . trim((string) $route, '/') . '/' . trim((string) $this->config->get('plugins.api.version_prefix', 'v1'), '/');
        if ($path !== $apiBase && !str_starts_with($path, $apiBase . '/')) {
            return false;
        }

        // The SSO hand-off keeps its CSRF state in the session between the
        // two browser navigations (see ApiRouter::isStatefulSessionRoute()).
        $apiPath = substr($path, strlen($apiBase));

        return !preg_match('#^/auth/sso/[^/]+/(start|callback)/?$#', $apiPath);
    }

    /**
     * Whether the request carries one of this site's session cookies.
     *
     * Matches on the uniqueness suffix core appends to every session name
     * (SessionServiceProvider), so a custom `system.session.name` prefix still
     * matches without building the session service this early.
     */
    protected function requestHasSessionCookie(): bool
    {
        if (!$_COOKIE) {
            return false;
        }

        $uniqueness = $this->config->get('system.session.uniqueness', 'path') === 'path'
            ? substr(md5(GRAV_ROOT), 0, 7)
            : md5(\Grav\Common\Security::getNonceKey());

        foreach (array_keys($_COOKIE) as $name) {
            $name = (string) $name;
            if (str_ends_with($name, '-' . $uniqueness) || str_ends_with($name, '-' . $uniqueness . '-admin')) {
                return true;
            }
        }

        return false;
    }

    /**
     * Force-publish a single page for a validated admin draft-preview request
     * (getgrav/grav-plugin-admin2#100).
     *
     * A page with `published: false` is not routable on the front end, so the
     * admin's preview (an iframe / new-tab navigation to the real front-end URL)
     * 404s. The admin obtains a short-lived, route-scoped token from
     * `POST /pages/{route}/preview-token` — which requires page-read permission —
     * and appends it to the preview URL alongside `admin_preview=1`.
     *
     * This runs on `onPagesInitialized`, after the page index is built but before
     * the page is dispatched (Grav resolves `grav['page']` a few lines later in
     * PagesProcessor). We validate the token's signature and read the single
     * route it authorizes, then flip that one page to published+routable in
     * memory for this request only. Dispatch then finds it and it renders. The
     * token is the entire authorization: `admin_preview=1` on its own never
     * unlocks anything, and a token can only ever reveal the exact page it was
     * minted for. The session stays suppressed throughout (see onPluginsLoaded),
     * so this doesn't reintroduce the front-end-session rotation of admin2#88/#79.
     */
    public function onPreviewPagesInitialized(Event $event): void
    {
        $token = (string) ($_GET['preview_token'] ?? '');
        if ($token === '') {
            return;
        }

        $route = (new JwtAuthenticator($this->grav, $this->config))->validatePreviewToken($token);
        if ($route === null) {
            return;
        }

        $pages = $event['pages'] ?? $this->grav['pages'];
        $page = $pages->find($route);
        if ($page === null) {
            return;
        }

        // Unlock for this request only (nothing is written to disk). Setting
        // both flags also covers a page that is explicitly `routable: false`,
        // which the author still wants rendered in a preview, and every module
        // is non-routable by definition.
        //
        // A module cannot be shown on its own: it exists only as a section
        // inside its parent, so the admin points the preview at the nearest
        // ordinary ancestor instead (admin2#170). Keep walking up while the
        // page is a module, so a nested module and the ordinary page hosting it
        // are unlocked too. An ordinary page leaves the loop after one pass,
        // exactly as before, and the chain is read from the site's own page
        // tree, so the token still reaches nothing but the pages the module
        // physically lives in. `$seen` guards a malformed tree.
        //
        // The unlock has to land on the hydrated objects that find() and
        // parent() return: a collection filter trusts the frozen index flag
        // while a page is still lazy, and only reads the live one once the page
        // is instantiated (Collection::filterByPageFlag, grav#4201). Touching
        // the chain here is what lets the parent's `@self.modular` collection
        // see an unpublished module, so do not "optimise" the walk away.
        $seen = [];
        while ($page !== null && !$page->root() && !isset($seen[(string) $page->path()])) {
            $seen[(string) $page->path()] = true;
            $page->published(true);
            $page->routable(true);
            $page = $page->isModule() ? $page->parent() : null;
        }
    }

    public function autoload(): ClassLoader
    {
        return require __DIR__ . '/vendor/autoload.php';
    }

    /**
     * Early setup - determine if we're on an API route.
     */
    public function setup(): void
    {
        $route = $this->config->get('plugins.api.route');
        if (!$route) {
            return;
        }

        $this->base = '/' . trim($route, '/');
        $prefix = $this->config->get('plugins.api.version_prefix', 'v1');
        $this->apiRoute = $this->base . '/' . $prefix;

        $uri = $this->grav['uri'];
        $currentPath = $uri->path();

        // On subpath installs (e.g. /sync-testing/grav-c) $uri->path() may
        // include Grav's base; strip it before testing the api prefix so
        // the plugin still activates and the api router gets installed.
        $gravBase = rtrim((string)$uri->rootUrl(false), '/');
        if ($gravBase !== '' && str_starts_with($currentPath, $gravBase)) {
            $currentPath = substr($currentPath, strlen($gravBase)) ?: '/';
        }

        if ($currentPath === $this->base || str_starts_with($currentPath, $this->base . '/')) {
            $this->active = true;
        }
    }

    /**
     * Raise this site's translation overrides above the active theme's strings.
     *
     * `user/languages/<lang>.yaml` is already merged at config-compile time, and
     * that is enough to beat Grav core and every plugin. It is not enough to
     * beat a theme: theme language files are absent from the compile-time scan
     * in `ConfigServiceProvider::languages()` and are merged into the finished
     * object later, by `Themes::loadLanguages()`. So an override of a theme
     * string — the most common thing a site owner wants to change — would
     * silently lose.
     *
     * `onThemeInitialized` is the first event after that merge, so re-applying
     * the same file here is what actually makes an override an override. The
     * store reads only the languages this request can render, so the cost is one
     * small YAML file, and none at all on a site with no overrides.
     */
    public function onThemeInitialized(): void
    {
        if (!$this->config->get('plugins.api.translation_overrides', true)) {
            return;
        }

        try {
            $sources = TranslationSourceIndex::shared($this->grav);
            (new TranslationOverrideStore($this->grav, $sources))->applyRuntime();
        } catch (\Throwable $e) {
            // A malformed override file must never take the site down.
            $this->grav['log']->warning('[api] translation overrides skipped: ' . $e->getMessage());
        }
    }

    public function onPluginsInitialized(): void
    {
        // Register webhook event listeners (always active, not just on API routes)
        $this->registerWebhookListeners();

        // Register audit-trail listeners (always active; each listener checks the
        // audit.enabled flag at fire time, so this is a no-op when the feature is
        // off (it is off by default)..
        $this->registerAuditListeners();

        // Register the demo-mode reset scheduler job. Inert until a baseline has
        // been captured, so this is a no-op on normal installs.
        $this->registerDemoListeners();

        // Page-view tracking subscribes for FRONTEND requests only — the
        // handler itself short-circuits for admin/API/non-page requests.
        if (!$this->active && !$this->isAdmin()) {
            $listeners = [
                'onPageInitialized' => ['onFrontendPageInitialized', 0],
            ];

            // Admin draft preview: when a signed, route-scoped preview token
            // rides the request (admin2#100), unlock that one page so an
            // unpublished draft renders instead of 404ing. Only wired up when the
            // preview flags are actually present, so it costs nothing otherwise.
            if (
                isset($_GET['admin_preview'], $_GET['preview_token'])
                && $this->config->get('plugins.api.allow_draft_preview', true)
            ) {
                $listeners['onPagesInitialized'] = ['onPreviewPagesInitialized', 0];
            }

            $this->enable($listeners);
        }

        if ($this->active) {
            // Keep the object cache warm for API requests even when the global
            // cache is switched off. Disabling cache is a frontend-dev workflow
            // (see fresh template/page output) — but the API renders no Twig and
            // no frontend pages, so for it cache-off buys nothing and forces a
            // full page-tree rebuild on every one of the SPA's many small calls
            // (admin2#65). This runs before PagesProcessor builds the index, so
            // the override is in place when the page index is first fetched.
            if ($this->config->get('plugins.api.force_cache', true)) {
                $this->grav['cache']->setEnabled(true);
            }

            // Disable pages processing for API requests - we don't need Twig/templates
            $this->grav['pages']->disablePages();

            // Register the plugin's templates path so server-side operations
            // that need to render Twig (e.g. password reset emails composed
            // by AuthController) can find emails/api/*.html.twig.
            $this->enable([
                'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
                // Disable the audit toggle in the plugin's own config form when
                // the SQLite backend the trail depends on isn't available.
                'onApiBlueprintResolved' => ['onApiBlueprintResolved', 0],
            ]);
            return;
        }

        // Handle admin API key tasks and templates
        if ($this->isAdmin()) {
            // Intercept API key tasks early, before admin's Flex routing
            $this->handleAdminApiKeyTask();

            $this->enable([
                'onTwigTemplatePaths' => ['onTwigTemplatePaths', 0],
                'onTwigExtensions' => ['onTwigExtensions', 0],
            ]);
        }
    }

    /**
     * Register Twig function to read API keys from centralized store.
     */
    public function onTwigExtensions(): void
    {
        $manager = new ApiKeyManager();
        $this->grav['twig']->twig()->addFunction(
            new \Twig\TwigFunction('api_keys_for_user', function (string $username) use ($manager) {
                $accounts = $this->grav['accounts'];
                $user = $accounts->load($username);
                if (!$user->exists()) {
                    return [];
                }
                return $manager->listKeys($user);
            })
        );
    }

    /**
     * Check for and handle API key admin tasks directly.
     * This runs before admin's Flex controller, which doesn't fire onAdminTaskExecute.
     */
    protected function handleAdminApiKeyTask(): void
    {
        $uri = $this->grav['uri'];
        $task = $uri->param('task') ?? $_POST['task'] ?? null;

        if (!$task || !in_array($task, ['apiKeyGenerate', 'apiKeyRevoke'], true)) {
            return;
        }

        // Validate nonce
        $nonce = $uri->param('admin-nonce') ?? $_POST['admin-nonce'] ?? null;
        if (!$nonce || !Utils::verifyNonce($nonce, 'admin-form')) {
            $this->outputJson(['status' => 'error', 'message' => 'Invalid security nonce.']);
        }

        // Verify admin is logged in
        $this->grav['session']->init();
        $user = $this->grav['session']->user ?? null;
        if (!$user || !$user->authorized || !$user->authorize('admin.login')) {
            $this->outputJson(['status' => 'error', 'message' => 'Not authorized.']);
        }

        // Authorize the caller against the target account. admin.login is the
        // baseline permission every panel user holds; on its own it only grants
        // management of the caller's OWN keys. Minting or revoking keys for any
        // other account is an account-management operation and requires
        // admin.users / admin.super, and only a super-admin may target a
        // super-admin account. Without this gate an admin.login user could forge
        // a persistent key bound to any account and inherit its API permissions.
        // Mirrors the REST path's requireApiKeyPermission + requireNotSuperTarget.
        // (GHSA-7v74-m76q-8wf3)
        $this->authorizeApiKeyTarget($user);

        match ($task) {
            'apiKeyGenerate' => $this->handleApiKeyGenerate(),
            'apiKeyRevoke' => $this->handleApiKeyRevoke(),
        };
    }

    /**
     * Enforce that the logged-in admin may manage API keys for the account named
     * in the route. Acting on your own account is never an escalation and is
     * always allowed. Acting on any other account requires admin.users or
     * admin.super, and a non-super caller may never target a super-admin
     * account. Terminates the request with a JSON error when the check fails.
     * (GHSA-7v74-m76q-8wf3)
     *
     * @param object $current The logged-in session user.
     */
    protected function authorizeApiKeyTarget($current): void
    {
        $username = $this->getAdminRouteUsername();
        if (!$username) {
            $this->outputJson(['status' => 'error', 'message' => 'Could not determine username.']);
        }

        // Managing your own keys is always allowed.
        if (($current->username ?? null) === $username) {
            return;
        }

        // Managing another account's keys requires account-management rights.
        if (!$current->authorize('admin.super') && !$current->authorize('admin.users')) {
            $this->outputJson(['status' => 'error', 'message' => 'Not authorized.']);
        }

        // Only a super-admin may act on a super-admin account.
        // NOT $target->authorize(): a target loaded from storage is not
        // `authenticated`, so authorize() returns false for every action and this
        // guard never fired for ANY super target. Read the effective access map —
        // own plus group-inherited — instead (GHSA-vv8m-jqpm-38x4).
        $target = $this->grav['accounts']->load($username);
        if ($target->exists() && $this->accountIsSuper($target) && !$current->authorize('admin.super')) {
            $this->outputJson(['status' => 'error', 'message' => 'Only super-admins can manage super-admin accounts.']);
        }
    }

    /**
     * Whether an account loaded from storage is effectively super, through its own
     * access map or any group it belongs to. Login-state independent by design, so
     * it works on the non-authenticated user objects `accounts->load()` returns.
     *
     * @param object $target
     */
    protected function accountIsSuper($target): bool
    {
        $maps = [$target->get('access')];
        foreach ((array) $target->get('groups', []) as $group) {
            if (is_string($group)) {
                $maps[] = $this->grav['config']->get("groups.{$group}.access");
            }
        }

        foreach ($maps as $access) {
            if (!is_array($access)) {
                continue;
            }
            foreach (['admin', 'api'] as $scope) {
                if (!empty($access[$scope]['super']) || !empty($access["{$scope}.super"])) {
                    return true;
                }
            }
        }

        return false;
    }

    protected function handleApiKeyGenerate(): void
    {
        $post = $_POST;
        $username = $this->getAdminRouteUsername();

        if (!$username) {
            $this->outputJson(['status' => 'error', 'message' => 'Could not determine username.']);
        }

        $user = $this->grav['accounts']->load($username);
        if (!$user->exists()) {
            $this->outputJson(['status' => 'error', 'message' => "User '{$username}' not found."]);
        }

        $name = $post['name'] ?? 'API Key';
        $expiryDays = !empty($post['expiry_days']) ? (int) $post['expiry_days'] : null;

        $manager = new ApiKeyManager();
        $result = $manager->generateKey($user, $name, [], $expiryDays);

        $this->outputJson([
            'status' => 'success',
            'key' => $result['key'],
            'id' => $result['id'],
            'message' => 'API key generated successfully.',
        ]);
    }

    protected function handleApiKeyRevoke(): void
    {
        $post = $_POST;
        $keyId = $post['key_id'] ?? '';
        $username = $this->getAdminRouteUsername();

        if (!$username || !$keyId) {
            $this->outputJson(['status' => 'error', 'message' => 'Missing parameters.']);
        }

        $user = $this->grav['accounts']->load($username);
        if (!$user->exists()) {
            $this->outputJson(['status' => 'error', 'message' => "User '{$username}' not found."]);
        }

        $manager = new ApiKeyManager();
        $revoked = $manager->revokeKey($user, $keyId);

        $this->outputJson([
            'status' => $revoked ? 'success' : 'error',
            'message' => $revoked ? 'API key revoked.' : 'API key not found.',
        ]);
    }

    /**
     * Output JSON and terminate. Used for admin AJAX tasks.
     */
    protected function outputJson(array $data): never
    {
        header('Content-Type: application/json');
        header('Cache-Control: no-store');
        echo json_encode($data);
        exit;
    }

    /**
     * Extract username from admin route (e.g. /admin/accounts/admin)
     */
    protected function getAdminRouteUsername(): ?string
    {
        $uri = $this->grav['uri'];
        $path = $uri->path();

        if (preg_match('#/(?:accounts|user)/([^/]+)#', $path, $matches)) {
            return $matches[1];
        }

        return null;
    }

    /**
     * Register plugin templates so admin can find the api_keys field type.
     */
    public function onTwigTemplatePaths(): void
    {
        $this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
    }

    /**
     * Register the API router middleware into Grav's request pipeline.
     */
    public function onRequestHandlerInit(RequestHandlerEvent $event): void
    {
        if (!$this->active) {
            return;
        }

        $route = $event->getRoute();
        $path = $route->getRoute();

        // Only the versioned base. The router cuts that base off by length, so
        // a looser match handed it `/api/v2/users/x` or `/apiary/users/x` as
        // `/users/x`, past any plugin that guards the API by its real address,
        // and took over a site's own pages whose route starts with `/api`.
        if ($path === $this->apiRoute || str_starts_with($path, $this->apiRoute . '/')) {
            $event->addMiddleware('api_router', new ApiRouter($this->grav, $this->config));
        }
    }

    /**
     * Register webhook event listeners for all API mutation events.
     */
    protected function registerWebhookListeners(): void
    {
        $events = WebhookDispatcher::getSubscribedEvents();

        /** @var \Symfony\Component\EventDispatcher\EventDispatcher $dispatcher */
        $eventDispatcher = $this->grav['events'];
        $webhookDispatcher = null;

        foreach ($events as $eventName => [$method, $priority]) {
            $eventDispatcher->addListener($eventName, function (Event $event) use ($eventName, &$webhookDispatcher) {
                // Lazy-load dispatcher only when first event fires
                if ($webhookDispatcher === null) {
                    $webhookDispatcher = new WebhookDispatcher();
                }
                $webhookDispatcher->dispatch($eventName, $event->toArray());
            }, $priority);
        }
    }

    /**
     * When the API plugin's own config form is resolved for admin-next, disable
     * the "Enable Audit Trail" toggle and surface a warning if SQLite (the audit
     * store's backend) is not available, so it cannot be switched on with no way
     * to persist the data. Backend writes already fail closed without SQLite;
     * this just makes the constraint visible in the UI.
     */
    public function onApiBlueprintResolved(Event $event): void
    {
        if (($event['plugin'] ?? null) !== 'api' || AuditStore::available()) {
            return;
        }

        $event['fields'] = $this->annotateAuditUnavailable((array) ($event['fields'] ?? []));
    }

    /**
     * Recursively walk the serialized field tree and, on the `audit.enabled`
     * node, set `disabled` and prepend a SQLite-required warning to its help.
     *
     * @param array<int,array<string,mixed>> $fields
     * @return array<int,array<string,mixed>>
     */
    protected function annotateAuditUnavailable(array $fields): array
    {
        $warning = 'SQLite (the pdo_sqlite PHP extension) is required to store audit data and is not available on this server. Install it to enable the audit trail. ';

        foreach ($fields as &$field) {
            if (!is_array($field)) {
                continue;
            }
            if (($field['name'] ?? null) === 'audit.enabled') {
                $field['disabled'] = true;
                $field['help'] = $warning . (string) ($field['help'] ?? '');
            }
            if (isset($field['fields']) && is_array($field['fields'])) {
                $field['fields'] = $this->annotateAuditUnavailable($field['fields']);
            }
        }
        unset($field);

        return $fields;
    }

    /**
     * Register audit-trail event listeners. Mirrors the webhook listener wiring:
     * one closure per event that forwards the event name + payload to the
     * AuditSubscriber. The subscriber itself is lazily created on first fire and
     * short-circuits when the feature is disabled or SQLite is unavailable.
     */
    protected function registerAuditListeners(): void
    {
        if (!AuditStore::available()) {
            return;
        }

        /** @var \Symfony\Component\EventDispatcher\EventDispatcher $eventDispatcher */
        $eventDispatcher = $this->grav['events'];
        $subscriber = null;

        foreach (AuditSubscriber::getSubscribedEvents() as $eventName => [$method, $priority]) {
            $eventDispatcher->addListener($eventName, function (Event $event) use ($eventName, $method, &$subscriber) {
                if ($subscriber === null) {
                    $subscriber = new AuditSubscriber();
                }
                $subscriber->{$method}($eventName, $event);
            }, $priority);
        }
    }

    /**
     * Register the demo-mode reset scheduler job. The listener forwards the
     * onSchedulerInitialized event to DemoManager, which itself no-ops unless
     * reset_on_schedule is on AND a baseline has been captured — so this stays
     * dormant on any install that isn't running a demo.
     */
    protected function registerDemoListeners(): void
    {
        /** @var \Symfony\Component\EventDispatcher\EventDispatcher $eventDispatcher */
        $eventDispatcher = $this->grav['events'];

        $eventDispatcher->addListener('onSchedulerInitialized', function (Event $event) {
            (new DemoManager($this->grav, $this->grav['config']))->onSchedulerInitialized($event);
        });
    }

    /**
     * Register API-specific permissions.
     */
    /**
     * Clear the API route cache when Grav cache is cleared.
     */
    public function onBeforeCacheClear(\RocketTheme\Toolbox\Event\Event $event): void
    {
        $locator = $this->grav['locator'];
        $cacheDir = $locator->findResource('cache://', true);

        if ($cacheDir) {
            $apiCachePath = $cacheDir . '/api';
            if (is_dir($apiCachePath)) {
                $paths = $event['paths'] ?? [];
                $paths[] = $apiCachePath;
                $event['paths'] = $paths;
            }
        }
    }

    public function onRegisterPermissions(PermissionsRegisterEvent $event): void
    {
        $actions = PermissionsReader::fromYaml("plugin://{$this->name}/permissions.yaml");
        $event->permissions->addActions($actions);
    }

    /**
     * Track a frontend page view. Replaces admin-classic's Popularity
     * tracker so popularity stats keep working in admin-next-only installs.
     */
    public function onFrontendPageInitialized(): void
    {
        (new PopularityTracker())->trackHit();
    }

}
