<?php
declare(strict_types=1);

namespace Ekesto\CacheWarmup\Settings;

use Ekesto\CacheInvalidator\Settings as InvalidatorSettings;
use Ekesto\CacheWarmup\WarmupRuntime;
use Ekesto\CacheWarmup\Runner;
use Ekesto\CacheWarmup\Settings;
use Ekesto\CacheWarmup\Sitemap;
use Ekesto\CacheWarmup\Settings\Actions\SitemapRefreshAction;
use Ekesto\CacheWarmup\Support\Access;
use Ekesto\CacheWarmup\TuningStats;
use Ekesto\CacheAutopilot\PluginMetadata;
use Ekesto\CacheAutopilot\ProEntitlement;

/**
 * Admin page registration, rendering and asset bootstrapping.
 */
final class AdminPage
{
    private const NOTICE_QUERY_ARG = 'ekesto_cache_warmup_notice';
    private const NOTICE_FULL_QUEUED = 'full_queued';
    private const NOTICE_SITEMAP_REFRESH_REQUESTED = 'sitemap_refresh_requested';
    private const MISSING_SITEMAP_NOTICE_ID = 'ekesto-ecw-missing-sitemap-notice';
    private const MISSING_SITEMAP_TOGGLE_EVENT = 'ekesto-cache-warmup:sitemap-presence';
    private const SITEMAP_NOTICES_DOCS_URL = 'https://wpcacheautopilot.com/docs/troubleshooting/#sitemap-notices';

    /** @var string|null */
    private static ?string $assetFailureMessage = null;

    /** @var string|null Actual page hook returned by add_options_page/add_menu_page. */
    private static ?string $pageHook = null;

    /**
     * Add the plugin settings page.
     *
     * Admins: Settings submenu. Non-admins with access: top-level menu with logo icon.
     */
    public static function addMenuPage(): void
    {
        if (self::currentUserIsAdmin()) {
            $hook = add_options_page(
                __('Cache Warmup', 'cache-autopilot'),
                __('Cache Warmup', 'cache-autopilot'),
                'read',
                Settings::PAGE_SLUG,
                [self::class, 'renderPage']
            );
            self::$pageHook = is_string($hook) && $hook !== '' ? $hook : null;
            return;
        }

        if (!Access::userCanManage()) {
            return;
        }

        $hook = add_menu_page(
            __('Cache Warmup', 'cache-autopilot'),
            __('Cache Warmup', 'cache-autopilot'),
            'read',
            Settings::PAGE_SLUG,
            [self::class, 'renderPage'],
            'none'
        );
        self::$pageHook = is_string($hook) && $hook !== '' ? $hook : null;
    }

    /**
     * Ensure WordPress applies menu_order filtering for non-admin ECW managers.
     *
     * @param mixed $enabled Current custom menu-order flag.
     * @return bool
     */
    public static function enableCustomMenuOrder($enabled): bool
    {
        if (!self::currentUserIsAdmin() && Access::userCanManage()) {
            return true;
        }

        return (bool) $enabled;
    }

    /**
     * Move Cache Autopilot top-level menu entries together at the end for non-admin managers.
     *
     * @param mixed $menuOrder Current menu order payload.
     * @return mixed
     */
    public static function moveTopLevelMenuToEnd($menuOrder)
    {
        if (self::currentUserIsAdmin() || !Access::userCanManage() || !is_array($menuOrder)) {
            return $menuOrder;
        }

        $normalized = array_values(array_map('strval', $menuOrder));
        $pluginSlugs = [Settings::PAGE_SLUG];
        if (class_exists(InvalidatorSettings::class)) {
            array_unshift($pluginSlugs, InvalidatorSettings::SETTINGS_PAGE_SLUG);
        }

        $presentPluginSlugs = array_values(
            array_filter(
                $pluginSlugs,
                static fn(string $slug): bool => in_array($slug, $normalized, true)
            )
        );
        if (empty($presentPluginSlugs)) {
            return $menuOrder;
        }

        $withoutPluginSlugs = array_values(
            array_filter(
                $normalized,
                static fn(string $item): bool => !in_array($item, $pluginSlugs, true)
            )
        );

        return array_merge($withoutPluginSlugs, $presentPluginSlugs);
    }

    /**
     * Build a URL to this plugin's settings page, routing through the correct parent
     * for the current user's admin status.
     *
     * @param array<string,string> $args Extra query args merged into the URL.
     */
    public static function settingsPageUrl(array $args = []): string
    {
        $base = self::currentUserIsAdmin()
            ? admin_url('options-general.php')
            : admin_url('admin.php');
        return add_query_arg(array_merge(['page' => Settings::PAGE_SLUG], $args), $base);
    }

    /**
     * Append a Cache Warmup settings link on the Plugins page.
     *
     * @param array<int|string,mixed> $links Existing plugin action links.
     * @return array<int|string,mixed> Updated plugin action links.
     */
    public static function addPluginSettingsLink(array $links): array
    {
        $url = self::settingsPageUrl();
        $link = sprintf('<a href="%1$s">%2$s</a>', esc_url($url), __('Warmup Settings', 'cache-autopilot'));
        array_push($links, $link);
        return $links;
    }

    private static function currentUserIsAdmin(): bool
    {
        return (function_exists('is_super_admin') && is_super_admin())
            || (function_exists('current_user_can') && current_user_can('manage_options'));
    }

    /**
     * Enqueue React settings assets and bootstrap config on the settings screen.
     *
     * @param string $hook Current admin screen hook.
     */
    public static function enqueueAdminAssets(string $hook): void
    {
        self::enqueueGlobalAdminStyles();

        if (!self::isPluginPageHook($hook)) {
            return;
        }

        $requiredAssets = [
            'build/js/settings.js',
            'build/js/settings.asset.php',
            'build/css/settings.css',
            'build/css/settings.asset.php',
        ];

        $missingAssets = [];
        foreach ($requiredAssets as $relativePath) {
            $fullPath = WarmupRuntime::path() . $relativePath;
            if (!is_file($fullPath)) {
                $missingAssets[] = $relativePath;
            }
        }

        if (!empty($missingAssets)) {
            self::recordAssetFailure($missingAssets);
            return;
        }

        $scriptAssetPath = WarmupRuntime::path() . 'build/js/settings.asset.php';
        $styleAssetPath = WarmupRuntime::path() . 'build/css/settings.asset.php';
        $scriptPath = WarmupRuntime::path() . 'build/js/settings.js';
        $stylePath = WarmupRuntime::path() . 'build/css/settings.css';

        $scriptAsset = self::loadAssetMetadata($scriptAssetPath);
        $styleAsset = self::loadAssetMetadata($styleAssetPath);

        wp_enqueue_script(
            'ekesto-cache-warmup-settings-js',
            WarmupRuntime::url() . 'build/js/settings.js',
            $scriptAsset['dependencies'],
            self::resolveAssetVersion($scriptAsset, $scriptPath),
            ['in_footer' => true]
        );
        wp_set_script_translations(
            'ekesto-cache-warmup-settings-js',
            'cache-autopilot',
            dirname((string) EKESTO_CA_PLUGIN_FILE) . '/languages'
        );

        wp_enqueue_style(
            'ekesto-cache-warmup-settings-css',
            WarmupRuntime::url() . 'build/css/settings.css',
            array_values(
                array_unique(
                    array_merge(['wp-components'], $styleAsset['dependencies'])
                )
            ),
            self::resolveAssetVersion($styleAsset, $stylePath)
        );

        $schema = Repository::schema();
        $schemaPagesMax = $schema['advanced']['fields']['pages_per_batch']['max'] ?? null;

        $maxExecTime = (int) ini_get('max_execution_time');
        if ($maxExecTime <= 0) {
            $maxExecTime = 300;
        }

        $safeBatchCap = (int) floor($maxExecTime * 0.8);
        $safeBatchCap = max(10, $safeBatchCap);

        $tuningData = TuningStats::get();
        $lastComputed = $tuningData['last_computed'] ?? null;

        $isSufficient = TuningStats::isSufficient();
        $batchPreview = Runner::computeEffectiveBatchPreview();

        $windowSamples = isset($tuningData['samples_ms']) && is_array($tuningData['samples_ms'])
            ? count($tuningData['samples_ms'])
            : 0;

        $config = [
            'pluginVersion' => PluginMetadata::VERSION,
            'isPremium' => ProEntitlement::canUseProFeatures(),
            'defaults' => Repository::defaults(),
            'runtimeDefaults' => Repository::runtimeDefaults(),
            'phpLimits' => self::phpExecutionLimits(),
            'initial' => Repository::all(),
            'tuningStats' => [
                'sample_count_window' => $windowSamples,
                'sample_count_total' => (int) ($tuningData['sample_count_total'] ?? 0),
                'p90_ms' => TuningStats::getP90Ms(),
                'is_sufficient' => $isSufficient,
                'last_computed' => $lastComputed,
            ],
            'safeCaps' => [
                'pages_per_batch' => (int) ($schemaPagesMax ?? Settings::DEFAULT_DYNAMIC_BATCH_MAX_URLS),
                'max_batch_seconds' => $safeBatchCap,
                'recommended_pages_per_batch' => isset($lastComputed['pages_per_batch'])
                    ? (int) $lastComputed['pages_per_batch'] : null,
                'recommended_max_batch_seconds' => isset($lastComputed['max_batch_seconds'])
                    ? (int) $lastComputed['max_batch_seconds'] : null,
            ],
            'batchPreview' => [
                'configured_pages_per_batch' => (int) ($batchPreview['configured_pages_per_batch'] ?? Settings::DEFAULT_PAGES_PER_BATCH),
                'effective_pages_per_batch' => (int) ($batchPreview['effective_pages_per_batch'] ?? Settings::DEFAULT_PAGES_PER_BATCH),
                'dynamic_system_cap' => (int) ($batchPreview['dynamic_system_cap'] ?? Settings::DEFAULT_DYNAMIC_BATCH_MAX_URLS),
                'basis' => (string) ($batchPreview['basis'] ?? 'manual_no_telemetry'),
            ],
        ];

        wp_add_inline_script(
            'ekesto-cache-warmup-settings-js',
            'window.EkestoCacheWarmup = ' . wp_json_encode($config) . ';',
            'before'
        );

        wp_add_inline_script(
            'ekesto-cache-warmup-settings-js',
            self::missingSitemapNoticeInlineScript(),
            'after'
        );
    }

    /**
     * Enqueue shared styles used by the admin menu and toolbar.
     *
     * @return void
     */
    public static function enqueueGlobalAdminStyles(): void
    {
        $assetPath = WarmupRuntime::path() . 'build/css/admin.asset.php';
        $stylePath = WarmupRuntime::path() . 'build/css/admin.css';

        if (!is_file($assetPath) || !is_file($stylePath)) {
            return;
        }

        $asset = self::loadAssetMetadata($assetPath);
        $version = self::resolveAssetVersion($asset, $stylePath);

        wp_enqueue_style(
            'ekesto-cache-warmup-admin-css',
            WarmupRuntime::url() . 'build/css/admin.css',
            array_filter(
                $asset['dependencies'],
                static fn(string $style): bool => wp_style_is($style, 'registered')
            ),
            $version
        );
    }

    /**
     * Load and normalize build metadata from an asset file.
     *
     * @param string $assetPath Asset file path.
     * @return array{dependencies: string[], version: string}
     */
    private static function loadAssetMetadata(string $assetPath): array
    {
        if (!is_file($assetPath)) {
            return [
                'dependencies' => [],
                'version' => '',
            ];
        }

        $asset = include $assetPath;
        if (!is_array($asset)) {
            return [
                'dependencies' => [],
                'version' => '',
            ];
        }

        $dependencies = [];
        if (isset($asset['dependencies']) && is_array($asset['dependencies'])) {
            $dependencies = array_values(
                array_filter(
                    $asset['dependencies'],
                    static fn($dependency): bool => is_string($dependency) && $dependency !== ''
                )
            );
        }

        $version = '';
        if (isset($asset['version']) && is_scalar($asset['version'])) {
            $version = trim((string) $asset['version']);
        }

        return [
            'dependencies' => $dependencies,
            'version' => $version,
        ];
    }

    /**
     * Resolve a stable asset version string for cache busting.
     *
     * @param array{dependencies: string[], version: string} $asset Build metadata.
     * @param string $filePath Asset file path.
     * @return string
     */
    private static function resolveAssetVersion(array $asset, string $filePath): string
    {
        if ($asset['version'] !== '') {
            return $asset['version'];
        }

        if (function_exists('hash_file') && is_file($filePath) && is_readable($filePath)) {
            $hash = hash_file('sha256', $filePath);
            if (is_string($hash) && $hash !== '') {
                return $hash;
            }
        }

        return PluginMetadata::VERSION;
    }

    /**
     * Render the settings page root element for the app mount point.
     */
    public static function renderPage(): void
    {
        if (!Access::userCanManage()) {
            return;
        }
        echo '<div id="ekesto-cache-warmup-root"></div>';
    }

    /**
     * Is $hook the hook for this plugin's settings page (submenu or top-level)?
     */
    private static function isPluginPageHook(string $hook): bool
    {
        if (self::$pageHook !== null && $hook === self::$pageHook) {
            return true;
        }
        return $hook === 'settings_page_' . Settings::PAGE_SLUG
            || $hook === 'toplevel_page_' . Settings::PAGE_SLUG;
    }

    /**
     * Render global admin notices for settings asset failures.
     *
     * @return void
     */
    public static function renderAdminNotices(): void
    {
        if (!function_exists('ekesto_ca_admin_notice_screen_is_allowed') || !\ekesto_ca_admin_notice_screen_is_allowed()) {
            return;
        }

        self::renderOperationalNotices();
        self::renderMissingSitemapNotice();
        self::renderMembershipStateNotice();

        if (self::$assetFailureMessage === null) {
            return;
        }

        echo '<div class="notice notice-error"><p>' . esc_html(self::$assetFailureMessage) . '</p></div>';
    }

    /**
     * Render a persistent admin warning while no sitemap URL is configured.
     *
     * @return void
     */
    private static function renderMissingSitemapNotice(): void
    {
        if (!Access::userCanManage()) {
            return;
        }

        if (Repository::getSitemaps() !== []) {
            return;
        }

        $settingsUrl = self::settingsPageUrl(['tab' => 'warmup']);
        $docsUrl = self::SITEMAP_NOTICES_DOCS_URL;

        echo '<div id="' . esc_attr(self::MISSING_SITEMAP_NOTICE_ID) . '" class="notice notice-warning is-dismissible ekesto-cache-autopilot-notice ekesto-ecw-missing-sitemap-notice"><p><strong>'
            . esc_html__('Cache Warmup:', 'cache-autopilot')
            . '</strong> '
            . esc_html__('No sitemap configured.', 'cache-autopilot')
            . '</p><p>'
            . esc_html__('Full warmups may fall back to published pages, but sitemap-based coverage and targeted URL checks are unavailable.', 'cache-autopilot')
            . '</p><div class="ekesto-cache-autopilot-notice__actions"><a href="' . esc_url($settingsUrl) . '" class="button button-secondary">'
            . esc_html__('Add sitemap URL(s)', 'cache-autopilot')
            . '</a><span><a href="' . esc_url($docsUrl) . '" target="_blank" rel="noopener noreferrer">'
            . esc_html__('What this means', 'cache-autopilot')
            . '</a> ↗</span></div>';

        echo '</div>';
    }

    /**
     * Register removable query args used by one-time admin notices.
     *
     * @param array<int,string> $queryArgs Existing removable query args.
     * @return array<int,string> Updated removable query args.
     */
    public static function addRemovableNoticeQueryArgs(array $queryArgs): array
    {
        $queryArgs[] = self::NOTICE_QUERY_ARG;
        return array_values(array_unique($queryArgs));
    }

    /**
     * Render admin notices related to operational actions.
     *
     * @return void
     */
    private static function renderOperationalNotices(): void
    {
        if (!Access::userCanManage()) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice display after nonce-protected action redirect.
        if (!isset($_GET[self::NOTICE_QUERY_ARG])) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only admin notice display after nonce-protected action redirect.
        $notice = sanitize_key(wp_unslash((string) $_GET[self::NOTICE_QUERY_ARG]));
        if ($notice === self::NOTICE_FULL_QUEUED) {
            $message = __('Full warmup queued.', 'cache-autopilot');
        } elseif ($notice === self::NOTICE_SITEMAP_REFRESH_REQUESTED) {
            $message = __('Sitemap refresh requested. Cache Warmup will retry it in the background.', 'cache-autopilot');
        } else {
            return;
        }

        echo '<div class="notice notice-success is-dismissible"><p>'
            . '<strong>' . esc_html__('Cache Warmup', 'cache-autopilot') . ':</strong> '
            . esc_html($message)
            . '</p></div>';
    }

    /**
     * Render an admin warning when sitemap membership state is degraded, failed, or missing/corrupt.
     *
     * @return void
     */
    private static function renderMembershipStateNotice(): void
    {
        if (!Access::userCanManage()) {
            return;
        }

        $sitemaps = Repository::getSitemaps();
        $saved = get_option(Settings::MEMBERSHIP_OPTION, false);
        $state = self::buildMembershipNoticeState($saved, $sitemaps);
        if ($state === null) {
            return;
        }

        $docsUrl = self::SITEMAP_NOTICES_DOCS_URL;
        $settingsUrl = self::settingsPageUrl(['tab' => 'warmup']);
        $noticeId = 'ekesto-ecw-membership-notice';

        echo '<div id="' . esc_attr($noticeId) . '" class="notice notice-warning is-dismissible ekesto-cache-autopilot-notice"><p><strong>'
            . esc_html__('Cache Warmup:', 'cache-autopilot')
            . '</strong> '
            . esc_html($state['intro'])
            . '</p><p>'
            . esc_html($state['reason'])
            . '</p><p><em>'
            . esc_html($state['last_build'])
            . '</em></p>';

        if ($state['can_retry']) {
            echo '<form id="ekesto-ecw-membership-retry-form" method="post" action="'
                . esc_url(admin_url('admin-post.php'))
                . '" hidden>'
                . '<input type="hidden" name="action" value="' . esc_attr(SitemapRefreshAction::ACTION) . '">'
                . wp_nonce_field(SitemapRefreshAction::ACTION, '_wpnonce', true, false)
                . '</form>';
        }

        echo '<div class="ekesto-cache-autopilot-notice__actions">';

        if ($state['can_retry']) {
            echo '<button type="submit" form="ekesto-ecw-membership-retry-form" class="button button-secondary">'
                . esc_html__('Retry now', 'cache-autopilot')
                . '</button>';
        }

        echo '<a href="' . esc_url($settingsUrl) . '" class="button button-secondary">'
            . esc_html__('Review sitemap settings', 'cache-autopilot')
            . '</a><span><a href="' . esc_url($docsUrl) . '" target="_blank" rel="noopener noreferrer">'
            . esc_html__('What this means', 'cache-autopilot')
            . '</a> ↗</span></div>';

        echo '</div>';
    }

    /**
     * Return footer JS for hiding the sitemap notice immediately after React saves.
     *
     * @return string
     */
    private static function missingSitemapNoticeInlineScript(): string
    {
        return sprintf(
            '(function(){var notice=document.getElementById(%1$s);if(!notice){return;}window.addEventListener(%2$s,function(event){var hasSitemap=!!(event&&event.detail&&event.detail.hasSitemap===true);notice.style.display=hasSitemap?"none":"";notice.classList.toggle("is-hidden",hasSitemap);});})();',
            wp_json_encode(self::MISSING_SITEMAP_NOTICE_ID),
            wp_json_encode(self::MISSING_SITEMAP_TOGGLE_EVENT)
        );
    }

    /**
     * Build membership warning notice data from stored option state.
     *
     * @param mixed $saved Raw membership option payload.
     * @param array<int,string> $sitemaps Configured sitemap URLs.
     * @return array{intro:string,reason:string,last_build:string,can_retry:bool}|null Notice data or null when healthy.
     */
    private static function buildMembershipNoticeState(mixed $saved, array $sitemaps): ?array
    {
        $hasSitemaps = !empty($sitemaps);
        $builtAt = is_array($saved) && isset($saved['built_at']) ? (string) $saved['built_at'] : '';
        $builtAtLabel = self::formatBuiltAtForNotice($builtAt);
        $lastBuild = sprintf(
            /* translators: %s: last build timestamp label. */
            __('Last build: %s.', 'cache-autopilot'),
            $builtAtLabel
        );

        if (self::isValidMembershipOption($saved)) {
            $degraded = (($saved['degraded'] ?? false) === true);
            $lastError = trim((string) ($saved['last_error'] ?? ''));
            $lastErrorCode = sanitize_key((string) ($saved['last_error_code'] ?? ''));

            if (!$degraded && $lastError === '') {
                return null;
            }

            $notFound = ($lastErrorCode === Sitemap::MEMBERSHIP_ERROR_NOT_FOUND);
            $partialNotFound = ($lastErrorCode === Sitemap::MEMBERSHIP_ERROR_PARTIAL_NOT_FOUND);
            if ($partialNotFound) {
                $intro = __('Some configured sitemaps could not be found.', 'cache-autopilot');
                $reason = __('Cache Warmup is using the sitemaps it could read. Pages listed only in unavailable sitemaps may be missed.', 'cache-autopilot');
            } elseif ($notFound) {
                $intro = __('Configured sitemap could not be found.', 'cache-autopilot');
                $reason = $degraded
                    ? __('Cache Warmup built a temporary index from published pages. Full warmups still run, but sitemap-only pages may be missed. Check that the sitemap URL is correct and its provider is active.', 'cache-autopilot')
                    : __('Cache Warmup is using the last usable sitemap index. Full warmups still run, but recent sitemap changes may not be included. Check that the sitemap URL is correct and its provider is active.', 'cache-autopilot');
            } elseif ($degraded) {
                $intro = __('Sitemap index is using fallback data.', 'cache-autopilot');
                $reason = __('Cache Warmup could not read the sitemap, so it built a temporary index from published pages. Full warmups still run, but sitemap-only pages may be missed.', 'cache-autopilot');
            } else {
                $intro = __('Sitemap index could not refresh.', 'cache-autopilot');
                $reason = __('Cache Warmup is using the last usable sitemap index. Full warmups still run, but recent sitemap changes may not be included yet.', 'cache-autopilot');
            }

            return [
                'intro' => $intro,
                'reason' => $reason,
                'last_build' => $lastBuild,
                'can_retry' => !$notFound && !$partialNotFound,
            ];
        }

        if (!$hasSitemaps) {
            return null;
        }

        $isMissing = ($saved === false);
        if ($isMissing) {
            $intro = __('Sitemap index is being prepared.', 'cache-autopilot');
            $reason = __('Cache Warmup has sitemap URL(s), but the page index is not available yet. Targeted warmups may skip unverified URLs until the index is ready.', 'cache-autopilot');
        } else {
            $intro = __('Sitemap index is invalid.', 'cache-autopilot');
            $reason = __('Cache Warmup cannot read the stored sitemap index and will try to rebuild it in the background. Targeted warmups may skip unverified URLs until the rebuild succeeds.', 'cache-autopilot');
        }

        return [
            'intro' => $intro,
            'reason' => $reason,
            'last_build' => $lastBuild,
            'can_retry' => false,
        ];
    }

    /**
     * Format membership built timestamp for admin notice rendering.
     *
     * @param string $builtAt Raw ISO-8601 UTC timestamp.
     * @return string
     */
    private static function formatBuiltAtForNotice(string $builtAt): string
    {
        if ($builtAt === '') {
            return __('unknown', 'cache-autopilot');
        }

        $timestamp = strtotime($builtAt);
        if ($timestamp === false) {
            return __('unknown', 'cache-autopilot');
        }

        return wp_date('Y-m-d H:i:s T', $timestamp);
    }

    /**
     * Validate membership option shape for admin notice reads.
     *
     * @param mixed $saved Raw membership option payload.
     * @return bool
     */
    private static function isValidMembershipOption(mixed $saved): bool
    {
        if (!is_array($saved)) {
            return false;
        }

        if (!array_key_exists('version', $saved) || (int) $saved['version'] !== 1) {
            return false;
        }

        if (!isset($saved['set']) || !is_array($saved['set'])) {
            return false;
        }

        if (!isset($saved['urls']) || !is_array($saved['urls'])) {
            return false;
        }

        return true;
    }

    /**
     * Return PHP runtime execution limits relevant for admin diagnostics.
     *
     * @return array<string,int|bool> Runtime limit information.
     */
    private static function phpExecutionLimits(): array
    {
        $maxExecution = (int) ini_get('max_execution_time');

        $setTimeLimitAllowed = function_exists('set_time_limit')
            && !in_array('set_time_limit', array_map('trim', explode(',', (string) ini_get('disable_functions'))), true);

        return [
            'max_execution_time' => $maxExecution,
        ];
    }

    /**
     * Convert ini memory shorthand values to bytes.
     *
     * @param string|false $value INI-style memory value.
     * @return int Bytes, or -1 when unlimited/invalid.
     */
    private static function memoryToBytes(string|false $value): int
    {
        if ($value === false) {
            return -1;
        }

        $value = trim((string) $value);

        if ($value === '' || $value === '-1') {
            return -1;
        }

        if (ctype_digit($value)) {
            return (int) $value;
        }

        $unit = strtolower(substr($value, -1));
        $num = (float) substr($value, 0, -1);

        if ($num <= 0) {
            return -1;
        }

        return match ($unit) {
            'g' => (int) round($num * 1024 * 1024 * 1024),
            'm' => (int) round($num * 1024 * 1024),
            'k' => (int) round($num * 1024),
            default => -1,
        };
    }

    /**
     * Persist a concise admin-safe asset failure message.
     *
     * @param array<int,string> $missingAssets Missing asset paths.
     * @return void
     */
    private static function recordAssetFailure(array $missingAssets): void
    {
        self::$assetFailureMessage = sprintf(
            /* translators: %s: comma-separated asset paths */
            __('Cache Warmup settings assets are missing from this release package: %s', 'cache-autopilot'),
            implode(', ', $missingAssets)
        );

        if (defined('WP_DEBUG') && WP_DEBUG) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Debug-only admin asset package diagnostic; user-facing notice is stored separately.
            error_log(
                sprintf(
                    'Cache Warmup admin assets skipped: missing files (%s)',
                    implode(', ', $missingAssets)
                )
            );
        }
    }
}
