<?php
namespace Grav\Plugin;

use Composer\Autoload\ClassLoader;
use Grav\Common\Debugger;
use Grav\Common\Grav;
use Grav\Common\Page\Interfaces\PageInterface;
use Grav\Common\Page\Pages;
use Grav\Common\Page\Types;
use Grav\Common\Plugin;
use Grav\Common\User\Interfaces\UserInterface;
use Grav\Common\Utils;
use Grav\Events\FlexRegisterEvent;
use Grav\Events\PermissionsRegisterEvent;
use Grav\Events\PluginsLoadedEvent;
use Grav\Framework\Acl\PermissionsReader;
use Grav\Framework\Flex\FlexDirectory;
use Grav\Framework\Flex\FlexForm;
use Grav\Framework\Flex\Interfaces\FlexAuthorizeInterface;
use Grav\Framework\Flex\Interfaces\FlexInterface;
use Grav\Framework\Form\Interfaces\FormInterface;
use Grav\Framework\Route\Route;
use Grav\Plugin\Api\PermissionResolver;
use Grav\Plugin\FlexObjects\Api\DirectoryPermission;
use Grav\Plugin\FlexObjects\Controllers\ObjectController;
use Grav\Plugin\FlexObjects\FlexFormFactory;
use Grav\Plugin\Form\Forms;
use Grav\Plugin\FlexObjects\Flex;
use Psr\Http\Message\ServerRequestInterface;
use RocketTheme\Toolbox\Event\Event;
use function is_array;

/**
 * Class FlexObjectsPlugin
 * @package Grav\Plugin
 */
class FlexObjectsPlugin extends Plugin
{
    /** @var string */
    protected const MIN_GRAV_VERSION = '1.7.0';

    /** @var int[] */
    public $features = [
        'blueprints' => 1000,
    ];

    /**
     * True once Flex has been booted and FlexRegisterEvent has been handled.
     *
     * @var bool
     */
    protected $flexInitialized = false;

    /**
     * @return bool
     */
    public static function checkRequirements(): bool
    {
        return version_compare(GRAV_VERSION, static::MIN_GRAV_VERSION, '>=');
    }

    /**
     * @return array
     *
     * The getSubscribedEvents() gives the core a list of events
     *     that the plugin wants to listen to. The key of each
     *     array section is the event that the plugin listens to
     *     and the value (in the form of an array) contains the
     *     callable (or function) as well as the priority. The
     *     higher the number the higher the priority.
     */
    public static function getSubscribedEvents(): array
    {
        if (!static::checkRequirements()) {
            return [];
        }

        return [
            PluginsLoadedEvent::class => [
                ['initializeFlex', 10]
            ],
            PermissionsRegisterEvent::class => [
                ['onRegisterPermissions', 100]
            ],
            FlexRegisterEvent::class => [
                ['onRegisterFlex', 100]
            ],
            'onCliInitialize' => [
                ['autoload', 100000],
                ['initializeFlex', 10]
            ],
            'onPluginsInitialized' => [
                ['onPluginsInitialized', 0],
            ],
            'onFormRegisterTypes' => [
                ['onFormRegisterTypes', 0]
            ],
            'onApiRegisterRoutes' => [
                ['onApiRegisterRoutes', 0]
            ],
            'onApiSidebarItems' => [
                ['onApiSidebarItems', 0]
            ],
            'onApiBlueprintResolved' => [
                ['onApiBlueprintResolved', 0]
            ],
            'onShortcodeHandlers' => [
                ['onShortcodeHandlers', 0]
            ]
        ];
    }

    /**
     * Get list of form field types specified in this plugin. Only special types needs to be listed.
     *
     * @return array
     */
    public function getFormFieldTypes()
    {
        return [
            'list' => [
                'array' => true
            ],
            'pagemedia' => [
                'array' => true,
                'media_field' => true,
                'validate' => [
                    'type' => 'ignore'
                ]
            ],
            'filepicker' => [
                'media_picker_field' => true
            ],
        ];
    }

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

    /**
     * [PluginsLoadedEvent:10]: Initialize Flex
     *
     * @return void
     */
    public function initializeFlex(): void
    {
        $config = $this->config->get('plugins.flex-objects.directories') ?? [];

        // Add to DI container
        $this->grav['flex_objects'] = static function (Grav $grav) use ($config) {
            /** @var FlexInterface $flex */
            $flex = $grav['flex'];

            $flexObjects = new Flex($flex, $config);

            // This event is for backwards compatibility only, do not use it!
            $grav->fireEvent('onFlexInit', new Event(['flex' => $flexObjects]));

            return $flexObjects;
        };

        // `onPluginsInitialized` is never fired in CLI, so subscribe here instead. CLI
        // commands that initialize themes need the same late re-registration as web
        // requests do.
        if ($this->isCli()) {
            $this->enable([
                'onThemeInitialized' => [
                    ['onThemeInitialized', 0]
                ],
            ]);
        }
    }

    /**
     * Initialize the plugin
     *
     * @return void
     */
    public function onPluginsInitialized(): void
    {
        if ($this->isAdmin()) {
            /** @var UserInterface|null $user */
            $user = $this->grav['user'] ?? null;

            if (null === $user || !$user->authorize('login', 'admin')) {
                return;
            }

            $this->enable([
                'onThemeInitialized' => [
                    ['onThemeInitialized', 0]
                ],
                'onGetPageTemplates' => [
                    ['onGetPageTemplates', 0]
                ],
            ]);

        } else {
            $this->enable([
                'onThemeInitialized' => [
                    ['onThemeInitialized', 0]
                ],
                'onTwigTemplatePaths' => [
                    ['onTwigTemplatePaths', 0]
                ],
                'onTwigInitialized' => [
                    ['onTwigInitialized', 0]
                ],
                'onFlexObjectMedia' => [
                    ['onFlexObjectMedia', 0]
                ],
                'onPagesInitialized' => [
                    // Serve Flex Object media through the permission-aware proxy
                    // before the default flex router / 404 handler runs.
                    ['serveMediaProxy', 100000],
                    ['onPagesInitialized', -10000]
                ],
                'onPageInitialized' => [
                    ['authorizePage', 10000]
                ],
                'onBeforeFlexFormInitialize' => [
                    ['onBeforeFlexFormInitialize', -10]
                ],
                'onPageTask' => [
                    ['onPageTask', -10]
                ],
            ]);
        }
    }

    /**
     * @param FlexRegisterEvent $event
     * @return void
     */
    public function onRegisterFlex(FlexRegisterEvent $event): void
    {
        $this->flexInitialized = true;

        /** @var \Grav\Framework\Flex\Flex $flex */
        $flex = $event->flex;
        $types = (array)$this->config->get('plugins.flex-objects.directories', []);
        $this->registerDirectories($flex, $types);
    }

    /**
     * @return void
     */
    public function onThemeInitialized(): void
    {
        // Themes only join the `blueprints://` stream when they initialize, so any Flex
        // directory whose blueprint is shipped by the theme is silently skipped if Flex
        // was booted earlier in the request. That happens on plenty of ordinary requests:
        // starting a session unserializes the stored user, which reloads the account
        // blueprint and resolves its `groups` field through Flex, and sites using Flex
        // user accounts boot Flex during initialization on every single request.
        //
        // Re-run the registration now that the theme stream resolves. Blueprints that are
        // already registered and enabled are left alone, so this is a no-op when nothing
        // was missed.
        if (!$this->flexInitialized && !$this->isAdmin()) {
            // Flex has not been booted yet, so nothing was missed. Leave it that way
            // rather than booting Flex (and hitting the filesystem) on requests that
            // would never have used it; registration happens on first use, by which time
            // the theme stream is already in place.
            return;
        }

        // Register directories defined in the theme.
        /** @var \Grav\Framework\Flex\Flex $flex */
        $flex = $this->grav['flex'];
        $types = (array)$this->config->get('plugins.flex-objects.directories', []);
        $this->registerDirectories($flex, $types, true);

        /** @var Debugger $debugger */
        $debugger = Grav::instance()['debugger'];
        $names = implode(', ', array_keys($flex->getDirectories()));
        $debugger->addMessage(sprintf('Registered flex types: %s', $names), 'debug');
    }

    /**
     * @param Event $event
     */
    public function onBeforeFlexFormInitialize(Event $event): void
    {
        /** @var array $form */
        $form = $event['form'];
        $edit = $form['actions']['edit'] ?? false;
        if (!isset($form['flex']['key']) && $edit === true) {
            /** @var Route $route */
            $route = $this->grav['route'];
            $id = rawurldecode($route->getGravParam('id'));
            if (null !== $id) {
                $form['flex']['key'] = $id;
                $event['form'] = $form;
            }
        }
    }

    /**
     * [onPagesInitialized:-10000] Default router for flex pages.
     *
     * @param Event $event
     */
    public function onPagesInitialized(Event $event): void
    {
        /** @var Route|null $route */
        $route = $event['route'] ?? null;
        if (null === $route) {
            // Stop if in CLI.
            return;
        }

        /** @var PageInterface|null $page */
        $page = $this->grav['page'] ?? null;

        $base = '';
        $path = [];
        if (!$page->routable() || $page->template() === 'notfound') {
            /** @var Pages $pages */
            $pages = $this->grav['pages'];

            // Find first existing and routable parent page.
            $parts = explode('/', $route->getRoute());
            array_shift($parts);
            $page = null;
            while (!$page && $parts) {
                $path[] = array_pop($parts);
                $base = '/' . implode('/', $parts);
                $page = $pages->find($base);
                if ($page && !$page->routable()) {
                    $page = null;
                }
            }
        }

        // If page is found, check if it contains flex directory router.
        if ($page) {
            $flex = $this->grav['flex'];
            $options = $page->header()->flex ?? null;
            $router = $options['router'] ?? null;
            $type = $options['directory'] ?? null;
            $directory = $type ? $flex->getDirectory($type) : null;
            if (\is_string($router)) {
                $path = implode('/', array_reverse($path));
                $response = null;
                $flexEvent = new Event([
                    'flex' => $flex,
                    'directory' => $directory,
                    'parent' => $page,
                    'page' => $page,
                    'base' => $base,
                    'path' => $path,
                    'route' => $route,
                    'options' => $options,
                    'request' => $event['request'],
                    'response' => &$response,
                ]);
                $flexEvent = $this->grav->fireEvent("flex.router.{$router}", $flexEvent);
                if ($response) {
                    $this->grav->close($response);
                }

                /** @var PageInterface|null $routedPage */
                $routedPage = $flexEvent['page'];
                if ($routedPage) {
                    /** @var Debugger $debugger */
                    $debugger = Grav::instance()['debugger'];
                    $debugger->addMessage(sprintf('Flex uses page %s', $routedPage->route()));

                    unset($this->grav['page']);
                    $this->grav['page'] = $routedPage;
                    $event->stopPropagation();
                }
            }
        }
    }

    /**
     * [onPagesInitialized:100000] Serve Flex Object media through the
     * permission-aware proxy (prototype — see docs/specs/media-proxy.md).
     *
     * Matches `<base>/<type>/<key>/<filename>` (base configurable, default
     * `/flex-media`) and streams the file via MediaProxyController, so media can
     * stay under a locked-down `user/data` instead of being linked directly.
     *
     * @param Event $event
     */
    public function serveMediaProxy(Event $event): void
    {
        if (!$this->config->get('plugins.flex-objects.media_proxy.enabled', false)) {
            return;
        }

        /** @var Route|null $route */
        $route = $event['route'] ?? null;
        if (null === $route) {
            return;
        }

        $base = '/' . trim((string) $this->config->get('plugins.flex-objects.media_proxy.base', '/flex-media'), '/');
        $path = $route->getRoute();
        if ($path !== $base && !str_starts_with($path, $base . '/')) {
            return;
        }

        // <type>/<key>/<filename...> — filename may contain sub-paths.
        $rest = trim(substr($path, strlen($base)), '/');
        $parts = explode('/', $rest);
        if (count($parts) < 3) {
            return;
        }
        $type = array_shift($parts);
        $key = array_shift($parts);
        $filename = rawurldecode(implode('/', $parts));
        $field = $route->getQueryParam('field');

        $controller = new \Grav\Plugin\FlexObjects\Controllers\MediaProxyController($this->grav);
        $response = $controller->serve($type, $key, $filename, is_string($field) ? $field : null, $event['request']);

        $this->grav->close($response);
    }

    /**
     * [onFlexObjectMedia] Stamp a proxy `url` override on each of an object's
     * media items so `medium.url` routes the original through the proxy
     * (prototype — see docs/specs/media-proxy.md). Core's ImageMedium honours the
     * override only for unmodified originals, so resized/cropped derivatives keep
     * serving straight from `images/`. No-op unless the proxy is enabled.
     *
     * @param Event $event
     */
    public function onFlexObjectMedia(Event $event): void
    {
        if (!$this->config->get('plugins.flex-objects.media_proxy.enabled', false)) {
            return;
        }

        $object = $event['object'] ?? null;
        $media = $event['media'] ?? null;
        if (!$object instanceof \Grav\Framework\Flex\Interfaces\FlexObjectInterface
            || !$media instanceof \Grav\Common\Media\Interfaces\MediaCollectionInterface) {
            return;
        }

        foreach ($media as $filename => $medium) {
            $medium->set('url', \Grav\Plugin\FlexObjects\Controllers\MediaProxyController::url($object, (string) $filename));
        }
    }

    /**
     * [onTwigInitialized] Register the `flex_media_url()` Twig helper so templates
     * can link object media through the proxy while it is opt-in.
     */
    public function onTwigInitialized(): void
    {
        $twig = $this->grav['twig']->twig();
        $twig->addFunction(new \Twig\TwigFunction(
            'flex_media_url',
            static function ($object, string $filename, ?string $field = null): string {
                if (!$object instanceof \Grav\Framework\Flex\Interfaces\FlexObjectInterface) {
                    return '';
                }

                return \Grav\Plugin\FlexObjects\Controllers\MediaProxyController::url($object, $filename, $field);
            }
        ));
    }

    /**
     * [onPageInitialized:10000] Authorize Flex Objects Page
     *
     * @param Event $event
     */
    public function authorizePage(Event $event): void
    {
        /** @var PageInterface|null $page */
        $page = $event['page'];
        if (!$page instanceof PageInterface) {
            return;
        }

        $header = $page->header();
        $forms = $page->getForms();

        // Update dynamic flex forms from the page.
        $flexForms = [];
        foreach ($forms as $name => $test) {
            $type = $test['type'] ?? null;
            if ($type === 'flex') {
                $form = $test;

                // Update the form and add it back to the page.
                $this->grav->fireEvent('onBeforeFlexFormInitialize', new Event(['page' => $page, 'name' => $name, 'form' => &$form]));
                $page->addForms([$form], true);

                $flexForms[] = $form;
                unset($form);
            }
        }

        // Make sure the page contains flex.
        $config = $header->flex ?? null;
        if (!is_array($config) && !$flexForms) {
            return;
        }

        // Every flex form on the page has to pass, not only the last one.
        $hasAccess = true;
        foreach ($flexForms ?: [null] as $form) {
            if (!$this->isFlexFormAuthorized($form, $config)) {
                $hasAccess = false;
                break;
            }
        }

        if (!$hasAccess) {
            // Hide the page (404).
            $page->routable(false);
            $page->visible(false);

            // If page is not a module, replace the current page with unauthorized page.
            if (!$page->isModule()) {
                $login = $this->grav['login'] ?? null;
                $unauthorized = $login ? $login->addPage('unauthorized') : null;
                if ($unauthorized) {
                    unset($this->grav['page']);
                    $this->grav['page'] = $unauthorized;
                }
            }
        } elseif ($config['access']['override'] ?? false) {
            // Override page access settings (allow).
            $page->modifyHeader('access', []);
        }
    }

    /**
     * Check the access the current user has to the flex directory (and object) a page, or one of its flex forms, is for.
     *
     * A flex form which does not name a directory that exists is denied: there is nothing to check its access against.
     *
     * @param array|null $form The flex form, or null when the page only has the flex header.
     * @param mixed $config The page's `flex` header.
     * @return bool
     */
    protected function isFlexFormAuthorized(?array $form, $config): bool
    {
        /** @var Route $route */
        $route = $this->grav['route'];

        $type = $form['flex']['type'] ?? $config['directory'] ?? $route->getGravParam('directory') ?? null;
        $key = $form['flex']['key'] ?? $config['id'] ?? $route->getGravParam('id') ?? '';
        if (\is_string($type)) {
            /** @var Flex $flex */
            $flex = $this->grav['flex_objects'];
            $directory = $flex->getDirectory($type);
        } else {
            $directory = null;
        }

        if (!$directory) {
            return $form === null;
        }

        $create = (bool)($form['actions']['create'] ?? false);
        $edit = (bool)($form['actions']['edit'] ?? false);

        $scope = $config['access']['scope'] ?? null;

        $object = $key !== '' ? $directory->getObject($key) : null;
        $hasAccess = null;

        $action = $config['access']['action'] ?? null;
        if (null === $action) {
            if (!$form) {
                $action = $key !== '' ? 'read' : 'list';
                if (null === $scope) {
                    $hasAccess = true;
                }
            } elseif ($object) {
                if ($edit) {
                    $scope = $scope ?? 'admin';
                    $action = 'update';
                } else {
                    $hasAccess = false;
                }
            } elseif ($create) {
                $object = $directory->createObject([], $key);
                $scope = $scope ?? 'admin';
                $action = 'create';
            } else {
                $hasAccess = false;
            }
        }

        if ($action && $hasAccess === null) {
            if ($object instanceof FlexAuthorizeInterface) {
                $hasAccess = $object->isAuthorized($action, $scope);
            } else {
                $hasAccess = $directory->isAuthorized($action, $scope);
            }
        }

        return (bool)$hasAccess;
    }

    /**
     * @param Event $event
     */
    public function onPageTask(Event $event): void
    {
        /** @var FormInterface|null $form */
        $form = $event['form'] ?? null;
        if (!$form instanceof FlexForm) {
            return;
        }

        $object = $form->getObject();

        /** @var ServerRequestInterface $request */
        $request = $event['request'];
        $request = $request
            ->withAttribute('type', $object->getFlexType())
            ->withAttribute('key', $object->getKey())
            ->withAttribute('object', $object)
            ->withAttribute('form', $form);

        $controller = new ObjectController();

        $response = $controller->handle($request);
        if ($response->getStatusCode() !== 418) {
            $this->grav->close($response);
        }
    }

    /**
     * @param \Grav\Framework\Flex\Flex $flex
     * @param array $types
     * @param bool $report
     */
    protected function registerDirectories(\Grav\Framework\Flex\Flex $flex, array $types, bool $report = false): void
    {
        $map = Flex::getLegacyBlueprintMap(false);
        foreach ($types as $blueprint) {
            // Backwards compatibility to v1.0.0-rc.3
            $blueprint = $map[$blueprint] ?? $blueprint;
            $type = Utils::basename((string)$blueprint, '.yaml');
            if (!$type) {
                continue;
            }

            if (!file_exists($blueprint)) {
                if ($report) {
                    /** @var Debugger $debugger */
                    $debugger = Grav::instance()['debugger'];
                    $debugger->addMessage(sprintf('Flex: blueprint for flex type %s is missing', $type), 'error');
                }

                continue;
            }

            $directory = $flex->getDirectory($type);
            if (!$directory || !$directory->isEnabled()) {
                $flex->addDirectoryType($type, $blueprint);
            }
        }
    }

    /**
     * Initial stab at registering permissions (WIP)
     *
     * @param PermissionsRegisterEvent $event
     * @return void
     */
    public function onRegisterPermissions(PermissionsRegisterEvent $event): void
    {
        /** @var Flex $flex */
        $flex = $this->grav['flex_objects'];
        $directories = $flex->getDirectories();

        $permissions = $event->permissions;

        $actions = [];
        foreach ($directories as $directory) {
            $data = $directory->getConfig('admin.permissions', []);
            $actions[] = PermissionsReader::fromArray($data, $permissions->getTypes());

        }
        $actions[] = PermissionsReader::fromYaml("plugin://{$this->name}/permissions.yaml");

        $permissions->addActions(array_replace(...$actions));
    }

    /**
     * @param Event $event
     * @return void
     */
    public function onFormRegisterTypes(Event $event): void
    {
        /** @var Forms $forms */
        $forms = $event['forms'];
        $forms->registerType('flex', new FlexFormFactory());
    }

    /**
     * [onShortcodeHandlers]: register the [flex-objects] / [flex] shortcode.
     *
     * Only fires when the Shortcode Core plugin is installed and active, so it
     * adds no hard dependency — without it the event simply never runs.
     *
     * @return void
     */
    public function onShortcodeHandlers(): void
    {
        $this->grav['shortcode']->registerAllShortcodes(__DIR__ . '/classes/shortcodes');
    }

    /**
     * @param Event $event
     * @return void
     */
    public function onGetPageTemplates(Event $event): void
    {
        /** @var Types $types */
        $types = $event->types;
        $types->register('flex-objects', 'plugins://flex-objects/blueprints/pages/flex-objects.yaml');
    }

    /**
     * Form select options listing all enabled directories.
     *
     * @return array
     */
    public static function directoryOptions(): array
    {
        /** @var Flex $flex */
        $flex = Grav::instance()['flex_objects'];
        $directories = $flex->getDirectories();

        $list = [];
        /**
         * @var string $type
         * @var FlexDirectory $directory
         */
        foreach ($directories as $type => $directory) {
            if (!$directory->getConfig('site.hidden')) {
                $list[$type] = $directory->getTitle();
            }
        }

        return $list;
    }

    /**
     * Register API routes for admin-next.
     */
    public function onApiRegisterRoutes(Event $event): void
    {
        if (!$this->config->get('plugins.flex-objects.enabled', true)) {
            return;
        }

        $routes = $event['routes'];
        $controller = \Grav\Plugin\FlexObjects\Api\FlexApiController::class;
        $blueprintController = \Grav\Plugin\FlexObjects\Api\FlexBlueprintController::class;

        // Config endpoint — static routes first (FastRoute constraint)
        $routes->get('/flex-objects/config', [$controller, 'config']);
        // Available blueprints (powers the plugin-settings `directories` field)
        $routes->get('/flex-objects/blueprints', [$controller, 'blueprints']);
        // Directory listing
        $routes->get('/flex-objects', [$controller, 'directories']);

        // CRUD routes — static paths before parameterized (FastRoute constraint)
        $routes->get('/flex-objects/{type}/metadata', [$controller, 'metadata']);
        $routes->get('/flex-objects/{type}/export', [$controller, 'export']);
        $routes->get('/flex-objects/{type}', [$controller, 'index']);
        $routes->post('/flex-objects/{type}', [$controller, 'create']);
        $routes->get('/flex-objects/{type}/{key}', [$controller, 'show']);
        $routes->patch('/flex-objects/{type}/{key}', [$controller, 'update']);
        $routes->delete('/flex-objects/{type}/{key}', [$controller, 'delete']);

        // Object media — stored alongside the object file in folder-based
        // directories (e.g. user-data://flex-objects/contacts/{id}).
        $routes->get('/flex-objects/{type}/{key}/media', [$controller, 'mediaList']);
        $routes->post('/flex-objects/{type}/{key}/media', [$controller, 'mediaUpload']);
        $routes->delete('/flex-objects/{type}/{key}/media/{filename}', [$controller, 'mediaDelete']);

        // Blueprint endpoint
        $routes->get('/blueprints/flex-objects/{type}', [$blueprintController, 'flexBlueprint']);
    }

    /**
     * Inject the shared Flex configure tabs (Caching) into admin-next plugin
     * page blueprints owned by a plugin that registers a Flex directory.
     *
     * In admin-classic, FlexDirectory::getDirectoryBlueprint() automatically
     * merges system/blueprints/flex/shared/configure.yaml on top of each
     * directory's configure view, which is how the "Caching" tab appears in
     * the configure form. Admin-next plugin pages don't go through the Flex
     * configure pipeline — they're served by the API plugin's
     * BlueprintController::pluginPageBlueprint, which fires
     * onApiBlueprintResolved with context='plugin-page'. We hook that event
     * here and append the same Caching tab so the two UIs match.
     */
    public function onApiBlueprintResolved(Event $event): void
    {
        if (($event['context'] ?? null) !== 'plugin-page') {
            return;
        }

        $plugin = (string) ($event['plugin'] ?? '');
        if ($plugin === '' || !$this->pluginOwnsFlexDirectory($plugin)) {
            return;
        }

        $cacheTab = $this->buildSharedCacheTab();
        if ($cacheTab === null) {
            return;
        }

        $fields = $event['fields'];
        $fields = $this->appendTab($fields, $cacheTab);
        $event['fields'] = $fields;
    }

    /**
     * Walk every registered Flex directory's blueprint path and check if it
     * lives under plugin://{$slug}/. If so, the plugin owns at least one
     * Flex directory and is a candidate for caching-tab injection.
     */
    private function pluginOwnsFlexDirectory(string $slug): bool
    {
        $flex = $this->grav['flex_objects'] ?? null;
        if (!$flex) {
            return false;
        }

        /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */
        $locator = $this->grav['locator'];
        $pluginPath = $locator->findResource("plugin://{$slug}", true);
        if (!$pluginPath) {
            return false;
        }
        $pluginPath = rtrim($pluginPath, '/') . '/';

        foreach ($flex->getDirectories() as $directory) {
            if (!$directory instanceof FlexDirectory) {
                continue;
            }

            $blueprintFile = $directory->getBlueprint()->getFilename();
            if (!$blueprintFile) {
                continue;
            }

            $resolved = $locator->isStream($blueprintFile)
                ? $locator->findResource($blueprintFile, true)
                : $blueprintFile;
            if ($resolved && str_starts_with((string) $resolved, $pluginPath)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Load system/blueprints/flex/shared/configure.yaml, isolate the
     * `cache` tab, and serialize it into the same shape the API plugin's
     * BlueprintController emits — so the result can be appended to a
     * resolved field list directly.
     */
    private function buildSharedCacheTab(): ?array
    {
        /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */
        $locator = $this->grav['locator'];
        $path = $locator->findResource('blueprints://flex/shared/configure.yaml');
        if (!$path) {
            return null;
        }

        $blueprint = new \Grav\Common\Data\Blueprint($path);
        $blueprint->load();

        $form = $blueprint->form();
        $cacheTab = $form['fields']['tabs']['fields']['cache'] ?? null;
        if (!is_array($cacheTab)) {
            return null;
        }

        // Translate language keys (best-effort — admin-next will also try).
        $language = $this->grav['language'] ?? null;
        $translate = static function ($value) use ($language) {
            if (!is_string($value) || $language === null) {
                return $value;
            }
            $translated = $language->translate($value);
            return is_string($translated) ? $translated : $value;
        };

        return $this->serializeCacheTab($cacheTab, $translate);
    }

    /**
     * Minimal field serializer mirroring BlueprintController::serializeFields
     * for the property set the shared cache tab actually uses (toggle / text
     * with toggleable, options, validate, config-default@). We don't need
     * the full serializer here — the input is fixed and well-known.
     */
    private function serializeCacheTab(array $cacheTab, callable $translate): array
    {
        $serialized = [
            'name'  => 'cache',
            'type'  => 'tab',
            'title' => $translate($cacheTab['title'] ?? 'Caching'),
            'fields' => [],
        ];

        foreach ($cacheTab['fields'] ?? [] as $name => $field) {
            if (!is_array($field)) {
                continue;
            }

            $entry = [
                'name' => (string) $name,
                'type' => (string) ($field['type'] ?? 'text'),
            ];

            foreach (['label', 'help', 'highlight', 'toggleable', 'default', 'size'] as $prop) {
                if (isset($field[$prop])) {
                    $entry[$prop] = $field[$prop];
                }
            }

            if (isset($entry['label'])) {
                $entry['label'] = $translate($entry['label']);
            }

            if (isset($field['options']) && is_array($field['options'])) {
                $ordered = [];
                foreach ($field['options'] as $optKey => $optLabel) {
                    $ordered[] = ['value' => (string) $optKey, 'label' => $translate($optLabel)];
                }
                $entry['options'] = $ordered;
            }

            if (isset($field['validate']) && is_array($field['validate'])) {
                $entry['validate'] = $field['validate'];
            }

            // Resolve config-default@ — read the system config value so the
            // form falls back to the system-wide cache defaults when the
            // plugin hasn't overridden them.
            if (isset($field['config-default@'])) {
                $key = (string) $field['config-default@'];
                $entry['default'] = $this->grav['config']->get($key);
            }

            $serialized['fields'][] = $entry;
        }

        return $serialized;
    }

    /**
     * Append a tab into the first `tabs` container we find in the serialized
     * fields list. If no tabs container exists, wrap the existing top-level
     * fields under a synthesized tabs container so the Caching tab still
     * has somewhere to live.
     */
    private function appendTab(array $fields, array $tab): array
    {
        foreach ($fields as $i => $field) {
            if (($field['type'] ?? null) === 'tabs' && isset($field['fields']) && is_array($field['fields'])) {
                $fields[$i]['fields'][] = $tab;
                return $fields;
            }
        }

        return [
            [
                'name'   => 'tabs',
                'type'   => 'tabs',
                'fields' => array_merge($fields, [$tab]),
            ],
        ];
    }

    /**
     * Register sidebar items for admin-next.
     */
    public function onApiSidebarItems(Event $event): void
    {
        if (!$this->config->get('plugins.flex-objects.enabled', true)) {
            return;
        }

        /** @var Flex $flex */
        $flex = $this->grav['flex_objects'];
        $user = $event['user'] ?? null;
        $items = $event['items'] ?? [];

        // Skip built-in types that already have dedicated admin-next UI
        $builtIn = ['pages', 'user-accounts', 'user-groups'];
        // Accept either super-admin scope: admin-next sessions use api.super,
        // legacy admin-classic accounts use admin.super.
        $isSuperAdmin = $user && ($user->get('access.api.super') || $user->get('access.admin.super'));

        // We iterate every enabled directory, not just those returned by
        // getAdminMenuItems(). Directories whose blueprint omits `admin.menu`
        // get collapsed into a single empty-key entry by getAdminMenuItems()
        // and would otherwise never appear in the admin-next sidebar (admin
        // classic shows them via the Flex Objects index page instead). Pull
        // the menu config per-directory so we can synthesize a fallback when
        // it's absent.
        $menuItems = $flex->getAdminMenuItems();

        // Track the sidebar ids we've already emitted so a directory can never
        // appear twice in the nav (issue #4122). A collection registered under
        // two type keys — e.g. a migrated site where it sits in both the
        // flex-objects `directories` config and a theme/plugin registration —
        // would otherwise yield two identical entries. Seed from any items
        // already on the event so a duplicate is dropped even if this handler
        // somehow runs more than once.
        $seen = [];
        foreach ($items as $existing) {
            if (isset($existing['id'])) {
                $seen[$existing['id']] = true;
            }
        }

        // Build the permission resolver once for the whole sweep; it caches the
        // user's flattened access, so a fresh instance per directory would
        // rebuild that map every iteration. Only needed for non-super-admins.
        $resolver = $user && !$isSuperAdmin ? new PermissionResolver($this->grav['permissions']) : null;

        foreach ($flex->getDirectories() as $directory) {
            $type = $directory->getFlexType();
            if (empty($type) || in_array($type, $builtIn, true) || !$directory->isEnabled()) {
                continue;
            }
            if (!empty($directory->getConfig('admin.disabled'))) {
                continue;
            }

            $id = 'flex-objects-' . $type;
            if (isset($seen[$id])) {
                continue;
            }

            $menuItem = $menuItems[$type] ?? null;

            // Plugins that ship their own admin-next sidebar entry can opt out
            // of the generic flex auto-registration via the menu blueprint.
            if ($menuItem && !empty($menuItem['hidden_in_admin_next'])) {
                continue;
            }

            // Check list permission for this directory. FlexDirectory::isAuthorized()
            // applies a 'test' scope prefix when a user is passed explicitly, so use
            // the shared PermissionResolver-based check (see DirectoryPermission).
            if ($resolver && !DirectoryPermission::isAuthorized($directory, 'list', $user, $resolver)) {
                continue;
            }

            // Get object count
            $count = null;
            try {
                $count = $directory->getCollection()->count();
            } catch (\Exception $e) {
                // Non-critical
            }

            // Derive the api permission key from the blueprint's permission config
            $perms = $directory->getConfig('admin.permissions', []);
            $authorizeKey = null;
            foreach ($perms as $prefix => $cfg) {
                if (str_starts_with($prefix, 'api.')) {
                    $authorizeKey = $prefix . '.list';
                    break;
                }
            }

            // Fall back to the directory's own title/icon when the blueprint
            // doesn't define an admin.menu block (issue #209). This mirrors
            // admin-classic, which lists every enabled directory regardless.
            $rawLabel = $menuItem['title'] ?? $directory->getTitle();
            $rawIcon = $menuItem['icon'] ?? $directory->getConfig('icon') ?? 'fa-database';

            $seen[$id] = true;
            $items[] = [
                'id'        => $id,
                'plugin'    => 'flex-objects',
                // Keep translation keys intact. SidebarController resolves
                // them against the signed-in user's Admin Next language.
                'label'     => $rawLabel,
                'icon'      => $this->normalizeFaIcon($rawIcon),
                'route'     => '/flex-objects/' . $type,
                'priority'  => $menuItem['priority'] ?? 0,
                'badge'     => $count,
                'authorize' => $authorizeKey,
            ];
        }

        $event['items'] = $items;
    }

    /**
     * Normalize legacy FontAwesome 4 icon names (e.g. `fa-clock-o`) to the
     * FA5+ equivalents that admin-next renders. Falls back to the input.
     */
    private function normalizeFaIcon(string $icon): string
    {
        // Strip any leading style prefix so we can normalize the bare name.
        $stripped = preg_replace('/^(fas|far|fab|fa-solid|fa-regular|fa-brands)\s+/', '', $icon) ?? $icon;
        if (str_ends_with($stripped, '-o')) {
            return substr($stripped, 0, -2);
        }
        return $stripped;
    }

    /**
     * Add current directory to twig lookup paths.
     *
     * `extra_site_twig_path` lets a site keep its Flex site templates somewhere a
     * plugin update cannot reach. This matters because updating a plugin deletes and
     * replaces the whole plugin directory, so anything hand-added under
     * `user/plugins/flex-objects/templates/` is gone after the next update.
     *
     * The value is a stream URI (or a list of them) pointing at a *templates root*,
     * not at an individual file: `user://templates` means Flex site templates live at
     * `user/templates/flex/<type>/collection/default.html.twig` and so on. Each
     * resolved root is registered before the plugin's own `templates/` directory, so
     * files there override the shipped ones, while the theme still wins over both
     * (Grav adds `theme://templates` before firing this event).
     *
     * @return void
     */
    public function onTwigTemplatePaths(): void
    {
        /** @var \RocketTheme\Toolbox\ResourceLocator\UniformResourceLocator $locator */
        $locator = $this->grav['locator'];

        $extra = $this->config->get('plugins.flex-objects.extra_site_twig_path');
        foreach ((array)$extra as $uri) {
            if (!is_string($uri) || $uri === '') {
                continue;
            }

            // findResources(), not findResource(): a stream can map to several
            // roots (`config://` spans environment/user/system, an inherited theme
            // spans child and parent), and all of them should be searchable.
            foreach ($locator->findResources($uri) as $path) {
                $this->grav['twig']->twig_paths[] = $path;
            }
        }

        $this->grav['twig']->twig_paths[] = __DIR__ . '/templates';
    }
}
