<?php

declare(strict_types=1);

namespace Grav\Plugin;

use Grav\Common\Plugin;
use Grav\Events\PermissionsRegisterEvent;
use Grav\Framework\Acl\PermissionsReader;

/**
 * Admin2 — Modern administration panel for Grav CMS.
 *
 * Serves a pre-built SvelteKit SPA from the plugin's app/ directory.
 * The SPA communicates with the Grav API plugin for all data operations.
 *
 * The SvelteKit build bakes a single placeholder (`__GRAV_ADMIN2_BASE__`)
 * into index.html's entry-chunk preload links and into one runtime chunk
 * as the fallback for `globalThis.__sveltekit_<nonce>?.base`. On every
 * shell request, admin2.php substitutes that placeholder in the served
 * HTML and injects a `<script>` that sets the runtime global with two
 * separate per-site values:
 *
 *   - `base`   = the configured admin route (`/admin`) — used for in-app
 *                navigation and history.
 *   - `assets` = the same admin route — used for the SvelteKit version
 *                poll, which we intercept here in PHP because Grav's stock
 *                .htaccess blocks `user/*.json`.
 *
 * Every other byte (chunks, CSS, fonts, immutable assets) lives on disk
 * under `user/plugins/admin2/app/_app/...` and is served directly by the
 * webserver — no PHP, no materialization, no per-site copies. A single
 * plugin install can be symlinked into many sites with different rootUrls
 * or routes without them trampling each other.
 */
class Admin2Plugin extends Plugin
{
    /**
     * Token that the SvelteKit build uses as its `kit.paths.base`. Defined
     * in svelte.config.js — keep these in sync.
     */
    private const BASE_PLACEHOLDER = '/__GRAV_ADMIN2_BASE__';

    /** @var bool Whether the current request is for the Admin2 route */
    protected bool $isAdmin2Route = false;

    /**
     * The configured route, route-local (matches $uri->route() output).
     * Example: '/admin2' or '/admin'.
     */
    protected string $base = '';

    /**
     * The full URL path from the webserver root to the admin route — the
     * Grav site's rootUrl plus $base. Used as both the in-app route base
     * and the version-poll URL prefix in the injected runtime global.
     * Example: '/admin' on a root-hosted site, '/grav-api/admin' when
     * Grav is mounted at /grav-api/.
     */
    protected string $routeBase = '';

    /**
     * The full URL path from the webserver root to the on-disk bundle.
     * Substituted into index.html so chunk preload links resolve to real
     * files that the webserver can serve directly. Example:
     * '/user/plugins/admin2/app' on root, '/grav-api/user/plugins/admin2/app'
     * in a subfolder install.
     */
    protected string $assetsPath = '';

    public static function getSubscribedEvents(): array
    {
        return [
            'onPluginsInitialized' => [
                ['setup', 100000],
                ['onPluginsInitialized', 1001],
            ],
            'onApiBlueprintResolved' => ['onApiBlueprintResolved', 0],
            PermissionsRegisterEvent::class => ['onRegisterPermissions', 1000],
        ];
    }

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

    /**
     * Inject admin-next-only fields into resolved blueprints.
     *
     * Currently used to add a `state` (account-enabled) toggle to the user
     * account blueprint, which Grav core's `account.yaml` doesn't carry —
     * admin-classic has no UI for the field either, so previously the only
     * way to disable a user was to hand-edit YAML. The toggle is gated on
     * `api.users.write`, since the underlying PATCH /users/{name} also
     * rejects non-managers writing to `state` (see grav-plugin-api
     * v1.0.0-beta.15).
     */
    public function onApiBlueprintResolved(\RocketTheme\Toolbox\Event\Event $event): void
    {
        if (($event['template'] ?? null) !== 'account') {
            return;
        }

        $fields = $event['fields'];

        // Core's account.yaml references admin-classic callables
        // (\Grav\Plugin\Admin\Admin::adminLanguages and ::contentEditor)
        // for the `language` and `content_editor` fields. On admin-next
        // sites where admin-classic isn't installed, the API can't resolve
        // these and emits `data_options` for the client, which then 404s
        // against /data/resolve. Substitute admin-next-friendly options
        // here so the form is usable without admin-classic present.
        $fields = $this->rewriteAdminClassicDataOptions($fields);

        $user = $event['user'] ?? null;
        $isManager = $user ? (bool) (
            $user->get('access.api.super')
            ?? $user->get('access.admin.super')
            ?? $user->get('access.api.users.write')
        ) : false;

        if ($isManager) {
            // Note: injected fields bypass BlueprintController::serializeFields(),
            // so emit the post-serialization shape — `options` as an ordered
            // array of `{value, label}` objects rather than the YAML-blueprint
            // map form. Client-side i18n picks up `ADMIN_NEXT.*` labels via the
            // ICU.* dual-namespace lookup.
            $stateField = [
                'name'    => 'state',
                'type'    => 'select',
                'size'    => 'medium',
                'classes' => 'fancy',
                'label'   => 'ADMIN_NEXT.USERS.STATUS',
                'help'    => 'ADMIN_NEXT.USERS.STATUS_HELP',
                'default' => 'enabled',
                'options' => [
                    ['value' => 'enabled',  'label' => 'ADMIN_NEXT.ENABLED'],
                    ['value' => 'disabled', 'label' => 'ADMIN_NEXT.DISABLED'],
                ],
            ];
            $fields = $this->insertFieldAfter($fields, 'title', $stateField);
        }

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

    /**
     * Recursively replace the legacy admin-classic data-options@ stand-ins
     * (which the API serializer left as `data_options` references because
     * the Admin class isn't loadable here) with concrete option lists.
     *
     * @param array<int, array<string, mixed>> $fields
     * @return array<int, array<string, mixed>>
     */
    private function rewriteAdminClassicDataOptions(array $fields): array
    {
        $out = [];
        foreach ($fields as $field) {
            if (isset($field['fields']) && is_array($field['fields'])) {
                $field['fields'] = $this->rewriteAdminClassicDataOptions($field['fields']);
            }
            $directive = $field['data_options'] ?? null;
            if (is_string($directive) && $directive !== '') {
                $normalized = ltrim($directive, '\\');
                if ($normalized === 'Grav\\Plugin\\Admin\\Admin::adminLanguages') {
                    $field['options'] = $this->adminLanguageOptions();
                    unset($field['data_options']);
                } elseif ($normalized === 'Grav\\Plugin\\Admin\\Admin::contentEditor') {
                    $field['options'] = $this->contentEditorOptions();
                    unset($field['data_options']);
                }
            }
            $out[] = $field;
        }
        return $out;
    }

    /**
     * Stand-in for \Grav\Plugin\Admin\Admin::adminLanguages when
     * admin-classic isn't installed. Admin-next currently only ships
     * English UI strings, so that's the only honest choice we can offer.
     *
     * @return array<int, array{value: string, label: string}>
     */
    private function adminLanguageOptions(): array
    {
        return [
            ['value' => 'en', 'label' => 'English'],
        ];
    }

    /**
     * Stand-in for \Grav\Plugin\Admin\Admin::contentEditor when
     * admin-classic isn't installed. Mirrors the legacy default list and
     * fires the same `onAdminListContentEditors` event so editor plugins
     * (editor-pro etc.) can register themselves the way they always have.
     *
     * @return array<int, array{value: string, label: string}>
     */
    private function contentEditorOptions(): array
    {
        $options = [
            'default'    => 'Default',
            'codemirror' => 'CodeMirror',
        ];
        $event = new \RocketTheme\Toolbox\Event\Event(['options' => &$options]);
        $this->grav->fireEvent('onAdminListContentEditors', $event);

        $out = [];
        foreach ($options as $value => $label) {
            $out[] = [
                'value' => (string) $value,
                'label' => is_string($label) ? $label : (string) $value,
            ];
        }
        return $out;
    }

    /**
     * Insert a field directly after a named sibling. Recurses into
     * container fields (`fields:` children) so the target is found
     * regardless of nesting depth. Returns the original list unchanged
     * if the anchor isn't present.
     *
     * @param array<int, array<string, mixed>> $fields
     * @param array<string, mixed> $newField
     * @return array<int, array<string, mixed>>
     */
    private function insertFieldAfter(array $fields, string $afterName, array $newField): array
    {
        $out = [];
        foreach ($fields as $field) {
            if (isset($field['fields']) && is_array($field['fields'])) {
                $field['fields'] = $this->insertFieldAfter($field['fields'], $afterName, $newField);
            }
            $out[] = $field;
            if (($field['name'] ?? '') === $afterName) {
                $out[] = $newField;
            }
        }
        return $out;
    }

    /**
     * Early setup — detect if the current request targets our route.
     * Most chunk / CSS / font URLs the SPA emits point at the bundle on
     * disk (`/user/plugins/admin2/app/...`) and never reach PHP. The only
     * static asset the SPA hits via the admin route is `_app/version.json`,
     * which we serve here because Grav's stock .htaccess blocks
     * `user/*.json`.
     */
    public function setup(): void
    {
        // Admin2 is a web-only plugin; skip entirely in CLI. Otherwise the
        // bootstrap hijack in onPluginsInitialized() can fire redirect('/admin')
        // during console commands (e.g. the cache clear after `bin/gpm install`),
        // which calls Grav::close() and aborts the command.
        if (\PHP_SAPI === 'cli') {
            return;
        }

        $route = $this->config->get('plugins.admin2.route');
        if (!$route) {
            return;
        }

        $this->base = '/' . trim($route, '/');

        /** @var \Grav\Common\Uri $uri */
        $uri = $this->grav['uri'];

        // Full path from webserver root. rootUrl(false) returns the path-only
        // portion of the Grav root (e.g. '/grav-api' or ''), never the host.
        $rootPath = rtrim($uri->rootUrl(false), '/');
        $this->routeBase = $rootPath . $this->base;

        // Derive the bundle's on-disk URL from the plugin's filesystem path,
        // not from a config knob: if a host relocates plugins (e.g. via a
        // custom stream override) the URL stays consistent with the files
        // Apache will actually be serving.
        $this->assetsPath = $rootPath . '/user/plugins/' . $this->name . '/app';

        // Grav core strips known "page" extensions (html, json, xml, rss…)
        // from $uri->route(), per system.pages.types. Reattach the
        // extension *only* when it was actually stripped (route doesn't
        // already end with it), so we recognize `_app/version.json` here.
        $currentRoute = $uri->route();
        $stripped = $uri->extension();
        if ($stripped && !str_ends_with($currentRoute, '.' . $stripped)) {
            $currentRoute .= '.' . $stripped;
        }

        if ($currentRoute === $this->base || str_starts_with($currentRoute, $this->base . '/')) {
            $this->isAdmin2Route = true;

            // version.json poll — serve from the plugin's app/ dir.
            $subPath = substr($currentRoute, strlen($this->base));
            if ($subPath === '/_app/version.json') {
                $this->serveVersionJson();
                // serveVersionJson exits
            }
        }
    }

    /**
     * Serve the SPA's version.json file. The SvelteKit runtime polls this
     * to detect when the bundle has been updated underneath the live SPA.
     * We pipe it through PHP because Grav's stock .htaccess blocks direct
     * access to `user/*.json`.
     */
    private function serveVersionJson(): void
    {
        $file = __DIR__ . '/app/_app/version.json';
        if (!is_file($file)) {
            http_response_code(404);
            exit;
        }

        header('Content-Type: application/json');
        header('Cache-Control: no-cache, no-store, must-revalidate');
        header('Content-Length: ' . filesize($file));
        readfile($file);
        exit;
    }

    /**
     * If on our route (and not a static asset), register the hook to serve the SPA shell.
     */
    public function onPluginsInitialized(): void
    {
        // Bootstrap hijack — parity with admin-classic. If there are no user
        // accounts, send any frontend page request to the admin2 route so the
        // SPA's /auth/setup probe can take over and walk the visitor through
        // first-user creation. Without this, a site with admin2 installed but
        // no accounts would let the first random visitor who discovers the
        // admin route create the super user.
        //
        // Skip the API plugin's own route prefix — otherwise we'd intercept the
        // SPA's own /auth/setup probe and redirect it away.
        //
        // Pass the route-local base (e.g. '/admin') — Grav's redirect() prepends
        // the site root itself. $this->routeBase already includes the root, so
        // using it here would double-prefix on sites mounted in a subpath.
        if (!$this->isAdmin2Route && $this->base && !$this->isApiRoute() && !$this->anyUsersExist()) {
            $this->grav->redirect($this->base);
        }

        if (!$this->isAdmin2Route) {
            return;
        }

        $this->enable([
            'onPagesInitialized' => ['onPagesInitialized', 1000],
        ]);
    }

    /**
     * Whether the current request targets the API plugin's route prefix.
     * The bootstrap hijack must not intercept these, or the SPA's own
     * /auth/setup probe would be redirected away from the API it needs.
     */
    private function isApiRoute(): bool
    {
        $apiRoute = rtrim((string) $this->config->get('plugins.api.route', '/api'), '/');
        if ($apiRoute === '') {
            return false;
        }
        /** @var \Grav\Common\Uri $uri */
        $uri = $this->grav['uri'];
        $current = $uri->route();
        return $current === $apiRoute || str_starts_with($current, $apiRoute . '/');
    }

    /**
     * Check whether any user accounts exist. Mirrors Admin::doAnyUsersExist()
     * from admin-classic but is self-contained so admin2 does not depend on
     * admin-classic being installed.
     */
    private function anyUsersExist(): bool
    {
        // Count through the same account backend the Users page uses, so the
        // check stays accurate across regular flat-file, Flex, and custom
        // nested layouts. A raw glob of account://*.yaml only sees top-level
        // flat files and misses nested accounts like user/accounts/<name>/user.yaml.
        try {
            $accounts = $this->grav['accounts'] ?? null;
            if ($accounts) {
                return $accounts->count() > 0;
            }
        } catch (\Throwable) {
            // Fall through to the flat-file scan below if the accounts
            // service is unavailable this early in the bootstrap.
        }

        $locator = $this->grav['locator'];
        $accountsDir = $locator->findResource('account://', true);
        if (!$accountsDir || !is_dir($accountsDir)) {
            return false;
        }

        foreach (glob($accountsDir . '/*.yaml') ?: [] as $file) {
            if (is_file($file)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Serve the SPA shell for all non-asset routes.
     */
    public function onPagesInitialized(): void
    {
        $this->serveSpaShell();
    }

    /**
     * Serve the SPA index.html from the plugin's app/ directory with
     * per-site URLs substituted into the chunk preload links and a
     * runtime override for SvelteKit's `__sveltekit_<nonce>` global.
     */
    private function serveSpaShell(): void
    {
        $indexFile = __DIR__ . '/app/index.html';

        if (!file_exists($indexFile)) {
            header('HTTP/1.1 500 Internal Server Error');
            echo 'Admin2: app not available. Run `npm run build:plugin` from grav-admin-next.';
            exit;
        }

        $html = file_get_contents($indexFile);

        // The build emits one placeholder used in two contexts:
        //
        //   1. URL prefixes for chunk preload links and dynamic imports —
        //      these need to resolve to the real on-disk bundle location
        //      so the webserver serves each file directly without PHP.
        //   2. JS string literals inside the inline `__sveltekit_<nonce>`
        //      initializer — these need to be the admin *route* (so the
        //      SPA router stays mounted there, in-app navigation works,
        //      and version polling hits our setup() PHP path).
        //
        // Pattern (1): every URL-context occurrence is followed by `/`
        // (e.g. `/__GRAV_ADMIN2_BASE__/_app/...`). Pattern (2): the JS
        // literal is closed by `"` (e.g. `"/__GRAV_ADMIN2_BASE__"`).
        // Substitute each context with its correct value.
        $html = str_replace(
            ['"' . self::BASE_PLACEHOLDER . '"', self::BASE_PLACEHOLDER . '/'],
            ['"' . $this->routeBase . '"', $this->assetsPath . '/'],
            $html
        );

        // Inject our own per-site config that the SPA reads at boot.
        $apiRoute = $this->config->get('plugins.api.route', '/api');
        $apiVersion = $this->config->get('plugins.api.version_prefix', 'v1');

        /** @var \Grav\Common\Uri $uri */
        $uri = $this->grav['uri'];

        // Path-only site root (e.g. '' or '/grav-api'), never the host. The SPA
        // prefixes this onto every API/asset request, so a relative value keeps
        // those requests same-origin with whatever host actually served this
        // shell. Using the absolute rootUrl(true) instead would bake in the
        // canonical host from system.custom_base_url: visiting the admin on an
        // alternate host (e.g. www.example.com when the base is example.com)
        // would then fire every request cross-origin, where the auth token and
        // session cookie aren't sent — so login fails and even the pre-login
        // translations never load, leaving raw keys like "subtitle" on screen
        // (#56).
        $serverUrl = rtrim($uri->rootUrl(false), '/');

        // Boot-critical fields the SPA needs before login to render the sign-in
        // screen and reach the auth API.
        $config = [
            'serverUrl' => $serverUrl,
            'apiPrefix' => '/' . trim($apiRoute, '/') . '/' . trim($apiVersion, '/'),
            'basePath' => $this->routeBase,
            'admin' => [
                'name' => $this->getBlueprint()->get('name'),
            ],
        ];

        // Site branding + admin language, exposed pre-auth so the sign-in screen
        // renders the configured logo, title and language on first visit (e.g. a
        // fresh incognito window) instead of falling back to the stock Grav logo
        // and English until the user logs in once. Unlike versions/environment,
        // none of this is a security-relevant fingerprint — it's the same public
        // branding the operator deliberately put on the login page.
        $branding = $this->resolveBrandingForBoot();
        if ($branding !== null) {
            $config['branding'] = $branding['branding'];
            $config['brandingUrls'] = $branding['brandingUrls'];
            if ($branding['language'] !== '') {
                $config['language'] = $branding['language'];
            }
        }

        // Exact Grav/Admin versions and the environment type are a free
        // technology fingerprint for an unauthenticated visitor (it served on
        // the pre-login shell and every admin subroute), letting an attacker
        // pick version-specific exploits with no reconnaissance. Only expose
        // them once the request is authenticated; the SPA reads these fields
        // defensively and pulls authoritative values from the API after login.
        // GHSA-pfjq-chp8-3vgh.
        $user = $this->grav['user'] ?? null;
        if ($user && $user->authenticated) {
            $config['environment'] = $uri->environment();
            $config['grav'] = ['version' => GRAV_VERSION];
            $config['admin']['version'] = $this->getBlueprint()->get('version');
        }

        // JSON_HEX_TAG/JSON_HEX_AMP are what make this safe to drop inside a
        // <script> block. An HTML parser does not understand JavaScript string
        // context: a literal `</script>` anywhere in this JSON closes the block
        // early and everything after it is parsed as page markup. Branding title
        // and subtitle are operator-supplied free text, so without escaping the
        // angle brackets a value of `</script><img src=x onerror=...>` breaks out
        // and executes on the pre-auth login page.
        //
        // Escaping the TAGS rather than relying on JSON_UNESCAPED_SLASHES being
        // absent is deliberate: the `\/` form only protects the closing tag by
        // accident, so anyone re-adding JSON_UNESCAPED_SLASHES for URL
        // readability would silently reopen the hole. Here the slashes stay
        // readable and the brackets are neutralised directly, which is the part
        // that actually matters. The escapes decode back to the identical string,
        // so the SPA still sees exactly what the operator typed.
        $config = json_encode($config, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP);

        $configScript = "<script>window.__GRAV_CONFIG__ = {$config};</script>";
        $html = str_replace('<head>', '<head>' . "\n    " . $configScript, $html);

        header('Content-Type: text/html; charset=UTF-8');
        echo $html;
        exit;
    }

    /**
     * Resolve site branding + admin language for the pre-auth boot config.
     *
     * Reuses the API plugin's PreferencesResolver so the shape and storage
     * (user/config/admin-next.yaml) stay identical to the post-auth
     * /admin-next/preferences payload. Best-effort: any failure (API plugin
     * absent, config unreadable) returns null and the SPA falls back to its
     * built-in defaults.
     *
     * @return array{branding: array<string, mixed>, brandingUrls: array<string, string>, language: string}|null
     */
    private function resolveBrandingForBoot(): ?array
    {
        $resolverClass = \Grav\Plugin\Api\Services\PreferencesResolver::class;
        if (!class_exists($resolverClass)) {
            return null;
        }

        try {
            $resolver = new $resolverClass($this->grav);
            $branding = $resolver->siteBranding();
            $sitePrefs = $resolver->sitePreferences();
            $language = is_string($sitePrefs['adminLanguage'] ?? null) ? $sitePrefs['adminLanguage'] : '';

            return [
                'branding' => $branding,
                'brandingUrls' => [
                    'light' => $resolver->brandingMediaUrl((string) ($branding['logoLight'] ?? '')),
                    'dark' => $resolver->brandingMediaUrl((string) ($branding['logoDark'] ?? '')),
                    'favicon' => $resolver->brandingMediaUrl((string) ($branding['favicon'] ?? '')),
                ],
                'language' => $language,
            ];
        } catch (\Throwable) {
            return null;
        }
    }

}
