<?php

declare(strict_types=1);

namespace Grav\Plugin\Api\Controllers;

use Grav\Common\Backup\Backups;
use Grav\Common\Language\LanguageCodes;
use Grav\Plugin\Api\Exceptions\NotFoundException;
use Grav\Plugin\Api\Exceptions\ValidationException;
use Grav\Plugin\Api\Response\ApiResponse;
use Grav\Plugin\Api\Services\DisabledPluginLangIndex;
use Grav\Plugin\Api\Services\EnvironmentService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

class SystemController extends AbstractApiController
{
    /**
     * Locale every other locale falls back to, and the one admin2 authors its
     * strings in. Must be the canonical form `normalizeLangCode()` produces, so a
     * request for `en` or `en-US` skips the backfill instead of merging with itself.
     */
    private const FALLBACK_LANG = 'en-US';

    /**
     * GET /system/environments — list writable environment targets.
     *
     * Response shape:
     *   {
     *     detected: "host.example",     // what Grav inferred from the URL
     *     environments: [
     *       { name: "",      label: "Default", exists: true, hasOverrides: true|false },
     *       { name: "staging", exists: true, hasOverrides: true }
     *     ]
     *   }
     *
     * `name: ""` represents the base user/config target. Any other entry is an
     * existing user/env/<name>/ folder that can be selected as a write target.
     * Legacy user/<host>/config/ layouts (Grav 1.6 fallback) are included too.
     */
    public function environments(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $envService = new EnvironmentService($this->grav);
        $list = [[
            'name' => '',
            'label' => 'Default',
            'exists' => true,
            'hasOverrides' => false,
        ]];

        foreach ($envService->listEnvironments() as $name) {
            $list[] = [
                'name' => $name,
                'label' => $name,
                'exists' => true,
                'hasOverrides' => $envService->envHasOverrides($name),
            ];
        }

        return ApiResponse::create([
            'detected' => $this->grav['uri']->environment(),
            'environments' => $list,
        ]);
    }

    /**
     * POST /system/environments — create a new env folder.
     *
     * Body: { "name": "staging.foo.com" }
     * Creates user/env/<name>/config/ (and user/env/ if missing).
     */
    public function createEnvironment(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.config.write');

        $body = $this->getRequestBody($request);
        $name = trim((string)($body['name'] ?? ''));

        $envService = new EnvironmentService($this->grav);
        try {
            $envService->createEnvironment($name);
        } catch (\InvalidArgumentException $e) {
            throw new ValidationException($e->getMessage());
        }

        return ApiResponse::create([
            'name' => $name,
            'label' => $name,
            'exists' => true,
            'hasOverrides' => false,
        ], 201, ['X-Invalidates' => 'system:environments']);
    }

    /**
     * DELETE /system/environments/{name} — remove a user/env/<name>/ folder.
     *
     * Refuses to delete the env that Grav resolved for the current request, and
     * refuses to act on legacy user/<name>/ layouts. See EnvironmentService for
     * the full safety rules.
     */
    public function deleteEnvironment(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.config.write');

        $name = (string) $this->getRouteParam($request, 'name');

        $envService = new EnvironmentService($this->grav);
        try {
            $envService->deleteEnvironment($name);
        } catch (\InvalidArgumentException $e) {
            throw new ValidationException($e->getMessage());
        }

        return ApiResponse::noContent(['X-Invalidates' => 'system:environments']);
    }

    public function info(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $plugins = $this->getPluginsInfo();
        $themes = $this->getThemesInfo();

        // Demo accounts see the same report with server-path / host-fingerprint
        // fields redacted (see getPhpConfig()); a public demo must not leak the
        // host's filesystem layout, open_basedir, temp dir, or web-server banner.
        $redact = $this->isDemoUser($request);

        $data = [
            'grav_version' => GRAV_VERSION,
            'php_version' => PHP_VERSION,
            'php_extensions' => get_loaded_extensions(),
            'server_software' => $redact ? self::DEMO_REDACTED : ($_SERVER['SERVER_SOFTWARE'] ?? 'unknown'),
            'environment' => $this->config->get('system.environment') ?? $this->grav['uri']->environment(),
            'plugins' => $plugins,
            'themes' => $themes,
            'php_config' => $this->getPhpConfig($redact),
        ];

        return ApiResponse::create($data);
    }

    private function getPhpConfig(bool $redact = false): array
    {
        $path = fn (string $value): string => $redact ? self::DEMO_REDACTED : $value;

        $ini = function (string $key): string {
            return (string) ini_get($key);
        };

        return [
            'Upload & POST' => [
                'file_uploads' => $ini('file_uploads'),
                'upload_max_filesize' => $ini('upload_max_filesize'),
                'max_file_uploads' => $ini('max_file_uploads'),
                'post_max_size' => $ini('post_max_size'),
            ],
            'Memory & Execution' => [
                'memory_limit' => $ini('memory_limit'),
                'max_execution_time' => $ini('max_execution_time') . 's',
                'max_input_time' => $ini('max_input_time') . 's',
                'max_input_vars' => $ini('max_input_vars'),
            ],
            'Error Handling' => [
                'display_errors' => $ini('display_errors'),
                'error_reporting' => (string) error_reporting(),
                'log_errors' => $ini('log_errors'),
                'error_log' => $path($ini('error_log') ?: '(none)'),
            ],
            'Paths & Environment' => [
                'open_basedir' => $path($ini('open_basedir') ?: '(none)'),
                'sys_temp_dir' => $path(sys_get_temp_dir()),
                'doc_root' => $path($_SERVER['DOCUMENT_ROOT'] ?? '(unknown)'),
                'include_path' => $path($ini('include_path')),
            ],
            'Session' => [
                'session.save_handler' => $ini('session.save_handler'),
                'session.save_path' => $path($ini('session.save_path') ?: '(default)'),
                'session.gc_maxlifetime' => $ini('session.gc_maxlifetime') . 's',
                'session.cookie_lifetime' => $ini('session.cookie_lifetime') . 's',
                'session.cookie_secure' => $ini('session.cookie_secure'),
                'session.cookie_httponly' => $ini('session.cookie_httponly'),
            ],
            'OPcache' => function_exists('opcache_get_status') ? [
                'opcache.enable' => $ini('opcache.enable'),
                'opcache.memory_consumption' => $ini('opcache.memory_consumption') . 'MB',
                'opcache.max_accelerated_files' => $ini('opcache.max_accelerated_files'),
                'opcache.validate_timestamps' => $ini('opcache.validate_timestamps'),
                'opcache.revalidate_freq' => $ini('opcache.revalidate_freq') . 's',
            ] : ['opcache.enable' => '0'],
            'Security' => [
                'allow_url_fopen' => $ini('allow_url_fopen'),
                'allow_url_include' => $ini('allow_url_include'),
                'disable_functions' => $ini('disable_functions') ?: '(none)',
                'expose_php' => $ini('expose_php'),
            ],
            'Date & Locale' => [
                'date.timezone' => $ini('date.timezone') ?: date_default_timezone_get(),
                'default_charset' => $ini('default_charset'),
                'mbstring.internal_encoding' => $ini('mbstring.internal_encoding') ?: '(default)',
            ],
        ];
    }

    /**
     * GET /ping - Lightweight keep-alive endpoint.
     * Health/connectivity check. No auth required — session keep-alive
     * is handled by proactive token refresh on the client side.
     */
    public function ping(ServerRequestInterface $request): ResponseInterface
    {
        return ApiResponse::create(['pong' => true]);
    }

    public function clearCache(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.write');

        $query = $request->getQueryParams();
        $scope = $query['scope'] ?? 'standard';

        $allowedScopes = ['all', 'standard', 'images', 'assets', 'tmp'];
        if (!in_array($scope, $allowedScopes, true)) {
            throw new ValidationException(
                "Invalid cache scope '{$scope}'. Allowed: " . implode(', ', $allowedScopes),
            );
        }

        $results = $this->grav['cache']->clearCache($scope);

        return ApiResponse::create([
            'scope' => $scope,
            'message' => "Cache cleared successfully (scope: {$scope}).",
            'details' => $results,
        ]);
    }

    /**
     * GET /system/logs/files — list log files registered for the admin viewer.
     *
     * Lists the known core logs plus every other *.log discovered in the
     * log:// directory, then fires onApiLogFiles so plugins can append their
     * own. The file names returned here are the only values accepted by
     * GET /system/logs?file=...
     */
    public function logFiles(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');
        // Raw logs can carry stack traces with absolute paths, IPs, and the odd
        // leaked token — too easy to miss something to redact line-by-line, so
        // block the endpoint outright for demo accounts.
        $this->denyIfDemo($request);

        $files = $this->getRegisteredLogFiles();

        return ApiResponse::create([
            'files'   => array_values($files),
            'default' => 'grav.log',
        ]);
    }

    public function logs(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');
        $this->denyIfDemo($request);

        $pagination = $this->getPagination($request);
        $query = $request->getQueryParams();
        $levelFilter = $query['level'] ?? null;
        $search = $query['search'] ?? null;

        // Validate ?file= against the registered whitelist. Without this an
        // attacker could read any file the locator can resolve.
        $registered = $this->getRegisteredLogFiles();
        $allowed = array_column($registered, 'file');
        $requested = $query['file'] ?? 'grav.log';
        if (!in_array($requested, $allowed, true)) {
            throw new ValidationException('Unknown log file: ' . $requested, [
                ['field' => 'file', 'message' => 'Must be one of: ' . implode(', ', $allowed)],
            ]);
        }

        $logFile = $this->grav['locator']->findResource('log://' . $requested);
        if (!$logFile || !file_exists($logFile)) {
            return ApiResponse::paginated([], 0, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs');
        }

        $content = file_get_contents($logFile);
        $lines = explode("\n", $content);
        $entries = [];

        foreach ($lines as $line) {
            if ($line === '' || $line[0] !== '[') {
                continue;
            }

            // Extract date
            $closeBracket = strpos($line, ']');
            if ($closeBracket === false) {
                continue;
            }
            $date = substr($line, 1, $closeBracket - 1);

            // Extract logger.LEVEL: message
            $rest = ltrim(substr($line, $closeBracket + 1));
            $colonPos = strpos($rest, ':');
            if ($colonPos === false) {
                continue;
            }

            $loggerLevel = substr($rest, 0, $colonPos);
            $dotPos = strpos($loggerLevel, '.');
            if ($dotPos === false) {
                continue;
            }

            $logger = substr($loggerLevel, 0, $dotPos);
            $level = strtoupper(substr($loggerLevel, $dotPos + 1));
            $message = trim(substr($rest, $colonPos + 1));

            // Strip trailing [] []
            $message = preg_replace('/\s*\[\]\s*\[\]\s*$/', '', $message);

            if ($levelFilter !== null && $level !== strtoupper($levelFilter)) {
                continue;
            }

            if ($search !== null && $search !== '' && stripos($message, $search) === false) {
                continue;
            }

            $entries[] = [
                'date' => $date,
                'logger' => $logger,
                'level' => $level,
                'message' => $message,
            ];
        }

        $entries = array_reverse($entries);
        $total = count($entries);
        $paged = array_slice($entries, $pagination['offset'], $pagination['limit']);

        return ApiResponse::paginated($paged, $total, $pagination['page'], $pagination['per_page'], $this->getApiBaseUrl() . '/system/logs');
    }

    /**
     * DELETE /system/logs?file=grav.log — empty out a log file.
     *
     * Super-admin only. Clearing a log destroys the forensic record of
     * everything that happened before it — including the trail of an attack an
     * operator may be in the middle of reviewing — so this sits above the
     * api.system.read/write tiers that the rest of the log viewer uses, and
     * above what a scoped API key minted on a super account can reach.
     *
     * The file is truncated rather than deleted, so the inode, ownership and
     * mode survive and any handler holding it open keeps writing to the same
     * place. A single marker entry is left behind recording who cleared it, in
     * the same format the viewer parses — a wiped log that says nothing about
     * being wiped is worse than one that does.
     *
     * The target accepts `file` from the JSON body or the query string, and is
     * validated against the same whitelist GET /system/logs uses.
     */
    public function clearLog(ServerRequestInterface $request): ResponseInterface
    {
        $this->requireSuper($request);
        $this->denyIfDemo($request, 'Clearing logs is disabled in demo mode.');

        $body = $this->getRequestBody($request);
        $query = $request->getQueryParams();
        $requested = (string) ($body['file'] ?? $query['file'] ?? 'grav.log');

        $registered = $this->getRegisteredLogFiles();
        $allowed = array_column($registered, 'file');
        if (!in_array($requested, $allowed, true)) {
            throw new ValidationException('Unknown log file: ' . $requested, [
                ['field' => 'file', 'message' => 'Must be one of: ' . implode(', ', $allowed)],
            ]);
        }

        $logFile = $this->grav['locator']->findResource('log://' . $requested);
        if (!$logFile || !file_exists($logFile)) {
            // A curated core log is listed before its first write; nothing to
            // clear is a success, not an error.
            return ApiResponse::create([
                'file' => $requested,
                'cleared_bytes' => 0,
                'message' => "Log is already empty: {$requested}",
            ]);
        }

        if (!is_writable($logFile)) {
            throw new ValidationException('Log file is not writable: ' . $requested, [
                ['field' => 'file', 'message' => 'Check filesystem permissions on logs/' . $requested],
            ]);
        }

        $size = (int) (@filesize($logFile) ?: 0);
        $user = $this->getUser($request);
        $username = (string) ($user->get('username') ?: 'unknown');
        $marker = $this->buildClearedMarker($logFile, $username);

        if (@file_put_contents($logFile, $marker, LOCK_EX) === false) {
            throw new \RuntimeException('Failed to clear log file: ' . $requested);
        }

        // Audited separately from the log itself (the audit trail lives in its
        // own store), so wiping a log cannot erase the record of the wipe.
        $this->fireEvent('onApiLogCleared', [
            'file' => $requested,
            'bytes' => $size,
            'user' => $user,
        ]);

        return ApiResponse::create([
            'file' => $requested,
            'cleared_bytes' => $size,
            'message' => "Log cleared: {$requested}",
        ]);
    }

    /**
     * The one line left in a freshly cleared log. Mirrors Monolog's line format
     * (`[datetime] channel.LEVEL: message [] []`) so the viewer's parser reads
     * it like any other entry. The channel is taken from the log's own first
     * line — grav.log is `grav`, security.log is `grav-security`, a plugin log
     * is whatever that plugin uses — read with fgets rather than slurping the
     * file, which can be very large.
     */
    private function buildClearedMarker(string $logFile, string $username): string
    {
        $channel = 'grav';
        $handle = @fopen($logFile, 'rb');
        if ($handle) {
            $first = fgets($handle, 512);
            fclose($handle);
            if (is_string($first) && preg_match('/^\[[^\]]+\]\s+([^.\s]+)\./', $first, $m)) {
                $channel = $m[1];
            }
        }

        // The marker is only worth writing if it can be trusted, so the one
        // caller-influenced part of it is reduced to characters that cannot
        // forge a log line — no newlines, brackets or colons. Grav validates
        // usernames anyway, so in practice this only ever trims.
        $safeUser = substr((string) preg_replace('/[^\w.@\-]+/u', '', $username), 0, 64);

        $date = (new \DateTimeImmutable('now'))->format('Y-m-d\TH:i:s.uP');

        return sprintf('[%s] %s.NOTICE: Log cleared by %s [] []%s', $date, $channel, $safeUser ?: 'unknown', PHP_EOL);
    }

    /**
     * Human-friendly labels for the log files Grav core writes itself. Any
     * discovered log not listed here falls back to a humanized filename.
     */
    private const CORE_LOG_LABELS = [
        'grav.log'      => 'Grav System Log',
        'security.log'  => 'Security Log',
        'email.log'     => 'Email Log',
        'scheduler.log' => 'Scheduler Log',
    ];

    /**
     * Build the list of log files available to the admin viewer.
     *
     * The known core logs are always listed (in a stable order, and even
     * before their first write) so the viewer is predictable. On top of that,
     * every other `*.log` in the log:// directory is auto-discovered so plugin
     * and future core logs appear without needing to register. Plugins can
     * still add or relabel entries via onApiLogFiles — useful for logs that
     * live outside the log:// stream. Result is deduped by `file` (first wins)
     * so the curated core labels are never shadowed.
     *
     * @return array<int, array{file: string, label: string}>
     */
    private function getRegisteredLogFiles(): array
    {
        // Curated core logs first, so they keep their order and labels and
        // remain listed even when the file does not exist yet.
        $files = [];
        foreach (self::CORE_LOG_LABELS as $name => $label) {
            $files[] = ['file' => $name, 'label' => $label];
        }

        // Auto-discover any additional *.log in the (single) log:// directory.
        // log:// resolves to one folder (GRAV_LOG_PATH), so this is scoped to
        // logs an admin with api.system.read is already entitled to read.
        $logDir = $this->grav['locator']->findResource('log://', true, true);
        if ($logDir && is_dir($logDir)) {
            foreach (glob(rtrim($logDir, '/') . '/*.log') ?: [] as $path) {
                $name = basename($path);
                if (isset(self::CORE_LOG_LABELS[$name])) {
                    continue;
                }
                $files[] = ['file' => $name, 'label' => $this->humanizeLogName($name)];
            }
        }

        $event = $this->fireEvent('onApiLogFiles', ['files' => $files]);
        $merged = $event['files'] ?? $files;

        // Dedupe by file name; first occurrence wins so core entries above
        // are preserved even if a plugin tries to re-register the same name.
        $seen = [];
        $result = [];
        foreach ($merged as $entry) {
            if (!is_array($entry) || empty($entry['file'])) {
                continue;
            }
            $name = (string) $entry['file'];
            if (isset($seen[$name])) {
                continue;
            }
            // Strip path components defensively — log names must be simple
            // basenames so they resolve through the log:// stream.
            if ($name !== basename($name)) {
                continue;
            }
            $seen[$name] = true;
            $result[] = [
                'file'  => $name,
                'label' => (string) ($entry['label'] ?? $name),
            ];
        }

        return $result;
    }

    /**
     * Turn a bare log filename into a readable label, e.g. `custom-plugin.log`
     * becomes "Custom Plugin Log". Used for discovered logs we have no curated
     * label for.
     */
    private function humanizeLogName(string $name): string
    {
        $base = preg_replace('/\.log$/', '', $name);
        $base = str_replace(['-', '_', '.'], ' ', $base);
        $base = trim(preg_replace('/\s+/', ' ', $base));

        return $base === '' ? $name : ucwords($base) . ' Log';
    }

    public function backup(ServerRequestInterface $request): ResponseInterface
    {
        // Backups archive the full Grav root, including user/accounts (admin
        // password hashes) and user/config secrets. Gate creation, listing,
        // download and deletion behind a dedicated api.system.backup permission
        // (or api.super) rather than the broader read/write tiers, so only
        // operators explicitly trusted with the credential-bearing archive can
        // touch it (GHSA-2f86-9cp8-6hcf).
        $this->requirePermission($request, 'api.system.backup');
        // Backup archives contain password hashes and config secrets — hide the
        // whole backup surface (create/list/download/delete) from demo accounts,
        // not just the mutating verbs the write gate already blocks.
        $this->denyIfDemo($request);

        // Ensure backup directory is initialized
        $backups = $this->grav['backups'] ?? new Backups();
        if (method_exists($backups, 'init')) {
            $backups->init();
        }

        $result = Backups::backup();

        $filename = basename($result);
        $size = file_exists($result) ? filesize($result) : 0;

        return ApiResponse::created(
            data: [
                'filename' => $filename,
                'path' => $result,
                'size' => $size,
                'date' => date('c'),
            ],
            location: $this->getApiBaseUrl() . '/system/backups',
        );
    }

    public function backups(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.backup');
        // Backup archives contain password hashes and config secrets — hide the
        // whole backup surface (create/list/download/delete) from demo accounts,
        // not just the mutating verbs the write gate already blocks.
        $this->denyIfDemo($request);

        // Ensure backup directory is initialized before listing
        $backups = $this->grav['backups'] ?? new Backups();
        if (method_exists($backups, 'init')) {
            $backups->init();
        }

        $list = Backups::getAvailableBackups(true);

        $items = [];
        foreach ($list as $backup) {
            // getAvailableBackups returns stdClass objects, not arrays
            $b = is_object($backup) ? $backup : (object) $backup;
            $items[] = [
                'filename' => $b->filename ?? basename($b->path ?? ''),
                'title' => $b->title ?? null,
                'date' => $b->date ?? null,
                'size' => $b->size ?? 0,
            ];
        }

        // Include purge config for storage usage display
        $purge = Backups::getPurgeConfig();

        return ApiResponse::create([
            'backups' => $items,
            'purge' => $purge,
            'profiles_count' => count(Backups::getBackupProfiles() ?? []),
        ]);
    }

    /**
     * DELETE /system/backups/{filename} - Delete a backup file.
     */
    public function deleteBackup(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.backup');
        // Backup archives contain password hashes and config secrets — hide the
        // whole backup surface (create/list/download/delete) from demo accounts,
        // not just the mutating verbs the write gate already blocks.
        $this->denyIfDemo($request);

        $b = $this->grav['backups'] ?? new Backups();
        if (method_exists($b, 'init')) { $b->init(); }

        $filename = $this->getRouteParam($request, 'filename');

        // Validate filename (no path traversal)
        if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) {
            throw new ValidationException(
                'Invalid backup filename.',
                [['field' => 'filename', 'message' => 'Invalid backup filename.']],
            );
        }

        $backupDir = $this->grav['locator']->findResource('backup://', true);
        $filepath = $backupDir . '/' . $filename;

        if (!file_exists($filepath)) {
            throw new NotFoundException("Backup '{$filename}' not found.");
        }

        unlink($filepath);

        return ApiResponse::noContent();
    }

    /**
     * GET /system/backups/{filename}/download - Download a backup file.
     */
    public function downloadBackup(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.backup');
        // Backup archives contain password hashes and config secrets — hide the
        // whole backup surface (create/list/download/delete) from demo accounts,
        // not just the mutating verbs the write gate already blocks.
        $this->denyIfDemo($request);

        $b = $this->grav['backups'] ?? new Backups();
        if (method_exists($b, 'init')) { $b->init(); }

        $filename = $this->getRouteParam($request, 'filename');

        if (!$filename || $filename !== basename($filename) || !str_ends_with($filename, '.zip')) {
            throw new ValidationException(
                'Invalid backup filename.',
                [['field' => 'filename', 'message' => 'Invalid backup filename.']],
            );
        }

        $backupDir = $this->grav['locator']->findResource('backup://', true);
        $filepath = $backupDir . '/' . $filename;

        if (!file_exists($filepath)) {
            throw new NotFoundException("Backup '{$filename}' not found.");
        }

        $stream = fopen($filepath, 'rb');

        return new \Grav\Framework\Psr7\Response(
            200,
            [
                'Content-Type' => 'application/zip',
                'Content-Disposition' => 'attachment; filename="' . $filename . '"',
                'Content-Length' => (string) filesize($filepath),
            ],
            $stream,
        );
    }

    /**
     * GET /translations/{lang} - Get all translation strings for a language.
     *
     * Returns a flat key-value object of all translation strings for efficient
     * client-side caching. Optionally filter by prefix (e.g., ?prefix=PLUGIN_ADMIN).
     */
    public function translations(ServerRequestInterface $request): ResponseInterface
    {
        // No auth required — translation strings are not sensitive

        $lang = $this->getRouteParam($request, 'lang');
        $prefix = $request->getQueryParams()['prefix'] ?? null;

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

        // Validate language code shape only — admin UI languages are a
        // different concept from site content languages, so we DO NOT gate
        // on $language->getLanguages() (which lists languages configured in
        // system.yaml for site content). Any plugin shipping a `languages/
        // <lang>.yaml` should be loadable here, even if the site itself only
        // serves English content.
        if (!is_string($lang) || !preg_match('/^[a-zA-Z]{2,3}(-[a-zA-Z]{2,4})?$/', $lang)) {
            $lang = $language->getDefault() ?: 'en-US';
        }
        // Coerce legacy short codes to their BCP 47 canonical form so a request
        // for `/translations/en` resolves to admin2's `en-US.yaml`.
        $lang = self::normalizeLangCode($lang);

        $translations = $this->buildTranslationDictionary($lang);

        // Backfill gaps from English. `flattenByLang()` returns the requested
        // language *only* — it does not merge a base locale — so any key a
        // translator hasn't reached yet simply vanishes from the response and the
        // client humanizes it: `ADMIN_NEXT.CACHE_CLEAR_BUTTON.LABEL` rendered as
        // "Label" instead of "Cache" (admin2#129). Worse, a humanized key reads as
        // plausible English, so gaps stay invisible until someone runs the admin in
        // another language. Merging here fixes it once for every client, needs no
        // extra request, and keeps the checksum honest.
        //
        // The requested language always wins: we only add keys it doesn't already
        // resolve. The `ICU.<key>` / `<key>` pair counts as one key for that test —
        // backfilling `ICU.FOO` from English when the requested language only ships
        // the flat `FOO` would shadow a real translation, because the client checks
        // ICU first.
        if ($lang !== self::FALLBACK_LANG) {
            $base = $this->buildTranslationDictionary(self::FALLBACK_LANG);
            foreach ($base as $key => $value) {
                if (!is_string($key) || isset($translations[$key])) {
                    continue;
                }
                $twin = str_starts_with($key, 'ICU.') ? substr($key, 4) : 'ICU.' . $key;
                if (isset($translations[$twin])) {
                    continue;
                }
                $translations[$key] = $value;
            }
        }

        // Filter by prefix if requested
        if ($prefix && is_array($translations)) {
            $prefixLower = strtolower($prefix) . '.';
            $translations = array_filter(
                $translations,
                fn($key) => str_starts_with(strtolower($key), $prefixLower),
                ARRAY_FILTER_USE_KEY
            );
        }

        // Include a checksum for cache invalidation
        $checksum = md5(json_encode($translations));

        return ApiResponse::create([
            'lang' => $lang,
            'dir' => LanguageCodes::getOrientation(self::primarySubtag($lang)),
            'count' => count($translations),
            'checksum' => $checksum,
            'strings' => $translations,
        ]);
    }

    /**
     * GET /admin/languages - Locales the admin UI itself can be rendered in.
     *
     * Distinct from GET /languages, which returns *site content* languages
     * configured in system.yaml. This endpoint returns locales for which a
     * translation file exists in the admin2 plugin's languages directory —
     * i.e. languages a user can pick for their admin interface.
     */
    public function adminLanguages(ServerRequestInterface $request): ResponseInterface
    {
        $this->requirePermission($request, 'api.system.read');

        $dir = GRAV_ROOT . '/user/plugins/admin2/languages';
        $languages = [];

        if (is_dir($dir)) {
            foreach (glob($dir . '/*.yaml') ?: [] as $file) {
                $code = basename($file, '.yaml');
                $languages[] = [
                    'code' => $code,
                    'name' => LanguageCodes::getName($code) ?: $code,
                    'native_name' => LanguageCodes::getNativeName($code) ?: $code,
                    'rtl' => LanguageCodes::isRtl(self::primarySubtag($code)),
                ];
            }
        }

        // Stable sort by native name so the dropdown order doesn't depend on
        // filesystem readdir order.
        usort($languages, fn($a, $b) => strcmp($a['native_name'], $b['native_name']));

        return ApiResponse::create([
            'languages' => $languages,
        ]);
    }

    private function getPluginsInfo(): array
    {
        $plugins = [];
        $gpm = $this->grav['plugins'];

        foreach ($gpm as $plugin) {
            $name = $plugin->name;
            // Plugin::getBlueprint() asserts the plugin's metadata is in
            // the Plugins manager. On Grav 2.0-rc.2 a number of registered
            // plugin instances have no companion entry there (login, form,
            // error, several first-party + side-car plugins), and the
            // assert blows up for the whole /system/info request. Fall
            // back to a read-from-disk path so partial info still ships.
            $bpName = null;
            $bpVersion = null;
            if ($gpm->get($name) !== null) {
                try {
                    $blueprint = $plugin->getBlueprint();
                    $bpName = $blueprint->get('name');
                    $bpVersion = $blueprint->get('version');
                } catch (\Throwable $e) {
                    // Defensive: even past the null check, blueprint
                    // hydration can throw on malformed yaml. Treat as
                    // metadata-unavailable.
                }
            } else {
                // Direct file read — bypasses Plugin::loadBlueprint() entirely.
                $file = GRAV_ROOT . "/user/plugins/{$name}/blueprints.yaml";
                if (is_file($file)) {
                    try {
                        $raw = \Symfony\Component\Yaml\Yaml::parseFile($file);
                        if (is_array($raw)) {
                            $bpName = $raw['name'] ?? null;
                            $bpVersion = $raw['version'] ?? null;
                        }
                    } catch (\Throwable $e) {
                        // ignore — leave metadata blank
                    }
                }
            }

            $plugins[] = [
                'name' => $bpName ?? $name,
                'version' => $bpVersion ?? '0.0.0',
                'enabled' => $this->config->get("plugins.{$name}.enabled", false),
            ];
        }

        return $plugins;
    }

    private function getThemesInfo(): array
    {
        $themes = [];
        $activeTheme = $this->config->get('system.pages.theme');
        $themesDir = $this->grav['locator']->findResource('themes://');

        if (!$themesDir || !is_dir($themesDir)) {
            return $themes;
        }

        $iterator = new \DirectoryIterator($themesDir);
        foreach ($iterator as $item) {
            if ($item->isDot() || !$item->isDir()) {
                continue;
            }

            $blueprintFile = $item->getPathname() . '/blueprints.yaml';
            if (!file_exists($blueprintFile)) {
                continue;
            }

            $blueprint = \Grav\Common\Yaml::parse(file_get_contents($blueprintFile));
            $themeName = $item->getFilename();

            $themes[] = [
                'name' => $blueprint['name'] ?? $themeName,
                'version' => $blueprint['version'] ?? '0.0.0',
                'active' => $themeName === $activeTheme,
            ];
        }

        return $themes;
    }

    /**
     * Map a raw lang code (`en`, `fr`, `zh-hans`) to its BCP 47 canonical form
     * (`en-US`, `fr-FR`, `zh-Hans`). Admin2 + admin-next standardize on BCP 47
     * for their UI surfaces, so any short or lowercase variant arriving on the
     * wire is coerced here before disk lookup. Anything not in the alias map
     * (or already in canonical region/script casing) passes through.
     */
    /**
     * Primary language subtag of a BCP 47 code. `he-IL` → `he`, `zh-Hans` →
     * `zh`. Grav core's `LanguageCodes` table is keyed by short codes only,
     * so any lookup against it has to go through here when the input might
     * be region/script-qualified.
     */
    /**
     * Build the flat translation dictionary for a single language.
     *
     * Wraps core's `flattenByLang()` and applies the two filters admin2 relies on:
     * disabled-plugin provenance stripping, and dropping flat `<key>` entries that
     * an `ICU.<key>` already shadows. Both run per-language, before any cross-language
     * merge, so each language is filtered against its own sources.
     *
     * @return array<string, string>
     */
    private function buildTranslationDictionary(string $lang): array
    {
        /** @var \Grav\Common\Config\Languages $languages */
        $languages = $this->grav['languages'];

        try {
            $translations = $languages->flattenByLang($lang);
        } catch (\Throwable) {
            $translations = [];
        }

        if (!is_array($translations)) {
            return [];
        }

        // Strip strings contributed only by disabled plugins. Grav core's
        // `flattenByLang()` reads every plugin's lang yaml regardless of enabled
        // state — fine for the legacy admin, broken for admin2: a disabled plugin
        // would still influence what admin2 renders. The service walks each
        // plugin's lang yaml to determine provenance and returns keys unique to
        // disabled plugins. Keys also shipped by enabled sources stay.
        $disabledIndex = new DisabledPluginLangIndex($this->grav);
        foreach ($disabledIndex->disabledOnlyKeys($lang) as $key) {
            unset($translations[$key]);
        }

        // Drop flat `<key>` entries when an `ICU.<key>` shadow exists. Admin2 ships
        // the canonical PLUGIN_ADMIN.* vocabulary under ICU; if a 3rd-party plugin
        // still using the Grav 1 flat convention is also installed, its values
        // would otherwise leak into the dictionary served to the client. Keeping
        // only the ICU side guarantees admin2 is the source of truth.
        foreach (array_keys($translations) as $key) {
            if (is_string($key) && !str_starts_with($key, 'ICU.') && isset($translations['ICU.' . $key])) {
                unset($translations[$key]);
            }
        }

        return $translations;
    }

    private static function primarySubtag(string $code): string
    {
        return strtolower(explode('-', $code, 2)[0]);
    }

    private static function normalizeLangCode(string $code): string
    {
        static $aliases = [
            'en'      => 'en-US',
            'ar'      => 'ar-SA',
            'cs'      => 'cs-CZ',
            'de'      => 'de-DE',
            'es'      => 'es-ES',
            'es-mx'   => 'es-MX',
            'fi'      => 'fi-FI',
            'fr'      => 'fr-FR',
            'fr-ca'   => 'fr-CA',
            'he'      => 'he-IL',
            'it'      => 'it-IT',
            'nl'      => 'nl-NL',
            'pt'      => 'pt-PT',
            'ru'      => 'ru-RU',
            'sv'      => 'sv-SE',
            'uk'      => 'uk-UA',
            'zh-hans' => 'zh-Hans',
            'zh-hant' => 'zh-Hant',
        ];
        $key = strtolower(str_replace('_', '-', trim($code)));
        if (isset($aliases[$key])) {
            return $aliases[$key];
        }
        if (preg_match('/^([a-z]{2,3})-([a-z0-9]{2,4})$/i', $code, $m)) {
            $tag = strlen($m[2]) === 4
                ? ucfirst(strtolower($m[2]))
                : strtoupper($m[2]);
            return strtolower($m[1]) . '-' . $tag;
        }
        return $code;
    }
}
