<?php

declare(strict_types=1);

namespace ShieldX\Security;

defined('ABSPATH') || exit;

// phpcs:disable WordPress.WP.AlternativeFunctions.file_system_operations_chmod,WordPress.WP.AlternativeFunctions.unlink_unlink,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir,WordPress.WP.AlternativeFunctions.rename_rename,WordPress.WP.AlternativeFunctions.file_system_operations_is_writable,WordPress.WP.AlternativeFunctions.file_system_operations_mkdir,WordPress.Security.EscapeOutput.ExceptionNotEscaped,WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Lockdown intentionally applies/reverts filesystem protections, probes chmod behavior, reads WordPress constants such as DISALLOW_FILE_MODS, and inspects request context after explicit admin confirmation.

use ShieldX\Storage\FileStorage;
use ShieldX\Storage\FilesystemAdapter;
use ShieldX\Support\RequestPath;
use ShieldX\Support\WordPressPaths;

final class Lockdown
{
    private const NO_EXEC_MARKER = 'SHIELD-X LOCKDOWN NO-EXEC';
    private const ROOT_MARKER = 'SHIELD-X LOCKDOWN ROOT PROTECT';
    private const EXECUTABLE_PATTERN = '/\.(php|phar|phtml|php[0-9]?|pht|phps|cgi|pl|py|asp|aspx|sh|exe)(?:$|[?#])/i';
    private const PERMISSION_FIX_BATCH_PATHS = 300;
    private const PERMISSION_FIX_BATCH_SECONDS = 6;
    private const PERMISSION_FIX_JOB_TTL = 86400;
    private const KILLSWITCH_REVERT_RETRY_TRANSIENT = 'shieldx_lockdown_killswitch_revert_retry';
    private const KILLSWITCH_REVERT_RETRY_SECONDS = 60;
    private const KILLSWITCH_REVERT_MAX_FAILURES = 3;
    private const KILLSWITCH_REVERT_RETRY_TTL = 3600;

    /** @var FileStorage */
    private $storage;

    /** @var LockdownSettings */
    private $settings;

    /** @var string */
    private $rootDir;

    /** @var string */
    private $contentDir;

    /** @var FilesystemAdapter */
    private $filesystem;

    /** @var string */
    private $lastPermissionFailure = '';

    /** @var string */
    private $permissionFixJobReadFailure = '';

    /** @var array<string,bool>|null */
    private $activeLockdownPermissionPaths;

    /** @var bool|null */
    private $activeLockdownPermissionPathsEnabled;

    public function __construct(FileStorage $storage, LockdownSettings $settings, string $rootDir, string $contentDir, ?FilesystemAdapter $filesystem = null)
    {
        $this->storage = $storage;
        $this->settings = $settings;
        $this->rootDir = $this->normalizeAbsolute($rootDir);
        $this->contentDir = $this->normalizeAbsolute($contentDir);
        $this->filesystem = $filesystem ?: FilesystemAdapter::fromWordPress();
    }

    public static function fromWordPress(?LockdownSettings $settings = null): self
    {
        $root = WordPressPaths::root();
        $content = WordPressPaths::contentDir();

        return new self(FileStorage::fromWordPress(), $settings ?: LockdownSettings::fromWordPress(), $root, $content);
    }

    public function boot(): void
    {
        if ($this->killswitchActive()) {
            $this->attemptKillswitchRevert();

            return;
        }

        $suspension = $this->suspendMarker();
        if ($suspension !== null) {
            if (!$this->settings->enabled()) {
                $this->clearSuspendMarker();

                return;
            }

            if ($this->isAdminRequest() && !$this->isUpdateOperationRequest()) {
                $this->resume($suspension);
            }

            if ($this->suspendMarker() !== null) {
                return;
            }
        }

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

        if ($this->isUpdateOperationRequest()) {
            return;
        }

        $this->defineRuntimeConstants($this->settings->policy());
    }

    /**
     * @return array<string,mixed>
     */
    public function status(): array
    {
        $manifest = $this->manifest();
        $settings = $this->settings->all();
        $suspension = $this->suspendMarker();
        $coverage = isset($manifest['coverage']) && is_array($manifest['coverage'])
            ? LockdownSettings::normalizeCoverage($manifest['coverage'])
            : LockdownSettings::normalizeCoverage((array) ($settings['last_coverage'] ?? []));
        $chmod = isset($manifest['chmod']) && is_array($manifest['chmod']) ? $manifest['chmod'] : [];
        $chmodFailures = isset($chmod['failed_total']) ? max(0, (int) $chmod['failed_total']) : 0;
        if ($chmodFailures > 0) {
            $coverage['readonly'] = 'partial';
            $coverage['readonly_failed_total'] = $chmodFailures;
            $coverage['readonly_failed_paths'] = array_slice(array_values(array_filter((array) ($chmod['failed_paths'] ?? []), 'is_string')), 0, 25);
            $coverage['message'] = sprintf('Read-only lockdown is partial: %d path(s) could not be changed.', $chmodFailures);
        }

        return [
            'enabled' => $this->settings->enabled() && !$this->killswitchActive() && $suspension === null,
            'settings' => $settings,
            'policy' => $this->settings->policy(),
            'coverage' => $coverage,
            'manifest_exists' => $manifest !== null,
            'manifest_path' => $this->manifestPath(),
            'manifest' => $manifest,
            'killswitch_active' => $this->killswitchActive(),
            'killswitch_path' => $this->killswitchPath(),
            'killswitch_marker' => $this->killswitchMarker(),
            'suspended' => $suspension !== null,
            'suspended_since' => $suspension !== null && isset($suspension['time']) && is_scalar($suspension['time']) ? max(0, (int) $suspension['time']) : 0,
            // Update orchestration asks for status frequently. Keep the
            // recursive descendant verification for the explicit dry-run UI.
            'preview' => $this->preview(null, false),
            'recovery_probe' => $this->recoveryProbe($manifest, $this->settings->policy()),
        ];
    }

    /**
     * @param array<string,mixed>|null $manifest
     * @param array<string,mixed>      $policy
     * @return array<string,mixed>
     */
    private function recoveryProbe(?array $manifest, array $policy): array
    {
        if (!$this->manifestExists()) {
            return [
                'status' => 'not_needed',
                'message' => 'No lockdown manifest is present.',
                'read_only_candidates' => 0,
                'sample_paths' => [],
            ];
        }

        $readOnly = $this->readOnlyCandidateStates($policy);
        $locked = 0;
        foreach ($readOnly as $state) {
            if (!empty($state['locked'])) {
                $locked++;
            }
        }

        if ($manifest === null) {
            return [
                'status' => 'manifest_unreadable',
                'message' => 'The lockdown manifest cannot be read or decoded. Use the confirmed force-revert recovery action to remove only known SHIELD-X lockdown artefacts.',
                'force_revert_available' => true,
                'read_only_candidates' => $locked,
                'sample_paths' => array_slice($readOnly, 0, 5),
            ];
        }

        if (!empty($manifest['hmac_valid'])) {
            return [
                'status' => 'ok',
                'message' => 'The lockdown manifest is signed and can be used for rollback.',
                'read_only_candidates' => $locked,
                'sample_paths' => array_slice($readOnly, 0, 5),
            ];
        }

        return [
            'status' => 'manifest_corrupt',
            'message' => 'The lockdown manifest signature is invalid. Use the confirmed force-revert recovery action to remove only known SHIELD-X lockdown artefacts.',
            'force_revert_available' => true,
            'read_only_candidates' => $locked,
            'sample_paths' => array_slice($readOnly, 0, 5),
        ];
    }

    /**
     * @param array<string,mixed>|null $policy
     * @return array<string,mixed>
     */
    public function preview(?array $policy = null, bool $verifyDescendants = true): array
    {
        $policy = LockdownSettings::normalizePolicy($policy ?: $this->settings->policy());
        $profile = $this->serverProfile();
        $coverage = $this->coverageForPolicy($policy, $profile);
        $readOnlyPaths = $this->readOnlyTargets($policy);
        $noExecDirs = $this->noExecDirectories($policy);
        $actions = [];

        if (!empty($policy['no_exec_enabled'])) {
            foreach ($noExecDirs as $directory) {
                $htaccess = $directory . '/.htaccess';
                $actions[] = [
                    'type' => 'write_no_exec_stub',
                    'path' => $htaccess,
                    'target' => $directory,
                    'status' => is_dir($directory) ? $this->markedBlockStatus($htaccess, self::NO_EXEC_MARKER, $this->noExecHtaccessBody()) : 'missing_directory',
                    'detail' => 'Deny direct web access to scripts in data directories.',
                ];
            }
        }

        if (!empty($policy['directory_index_off'])) {
            $actions[] = [
                'type' => 'write_root_protect_stub',
                'path' => $this->rootHtaccessPath(),
                'target' => $this->rootDir,
                'status' => $this->markedBlockStatus($this->rootHtaccessPath(), self::ROOT_MARKER, $this->rootProtectHtaccessBody($policy)),
                'detail' => 'Protect direct core access and sensitive root files according to policy.',
            ];
        }

        foreach ($this->constantsForPolicy($policy) as $constant) {
            $actions[] = [
                'type' => 'runtime_constant',
                'path' => 'runtime',
                'target' => $constant,
                'status' => defined($constant) ? 'already_defined' : 'will_define',
                'detail' => 'Aggressive update/install lock.',
            ];
        }

        foreach ($readOnlyPaths as $path) {
            $actions[] = [
                'type' => 'chmod_readonly',
                'path' => $path,
                'target' => is_dir($path) ? '0555' : '0444',
                'status' => $this->readOnlyPreviewStatus($path, $profile, $verifyDescendants),
                'detail' => 'Make core, plugins, and themes read-only until lockdown is reverted or suspended.',
            ];
        }

        return [
            'policy' => $policy,
            'server_profile' => $profile,
            'coverage' => $coverage,
            'summary' => [
                'actions' => count($actions),
                'read_only_paths' => count($readOnlyPaths),
                'no_exec_dirs' => count($noExecDirs),
                'allowlisted_paths' => count((array) ($policy['allowlist'] ?? [])),
            ],
            'actions' => $actions,
            'read_only_paths' => array_map([$this, 'pathState'], $readOnlyPaths),
            'no_exec_dirs' => array_map([$this, 'pathState'], $noExecDirs),
            'descendant_verification' => $verifyDescendants,
        ];
    }

    /**
     * @param array<string,mixed>|null $policy
     * @return array<string,mixed>
     */
    public function apply(?array $policy = null, bool $confirmed = false, string $nonceAction = '', string $nonce = ''): array
    {
        return $this->applyWithAuthorization($policy, $confirmed, $nonceAction, $nonce, false);
    }

    /**
     * Non-mutating authorization preflight for callers that must create a
     * restore point before invoking apply() or reapplyForAuthorizedRequest().
     */
    public function adminWriteAuthorizationError(string $nonceAction, string $nonce = ''): string
    {
        return $this->adminWriteGuard($nonceAction, $nonce, false);
    }

    /**
     * Reapply and resume are trusted lifecycle operations invoked only after
     * their caller has authenticated or confirmed the maintenance action.
     * Keeping that path private prevents public callers from bypassing the
     * capability and nonce checks in apply().
     *
     * @param array<string,mixed>|null $policy
     * @return array<string,mixed>
     */
    private function applyWithAuthorization(?array $policy, bool $confirmed, string $nonceAction, string $nonce, bool $trustedLifecycle): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce, $trustedLifecycle);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
            ];
        }

        if (!$confirmed) {
            return [
                'ok' => false,
                'status' => 'confirmation_required',
                'message' => 'Lockdown apply requires explicit confirmation.',
            ];
        }

        if ($this->manifestExists()) {
            return [
                'ok' => false,
                'status' => 'already_applied',
                'message' => 'Lockdown is already applied. Revert it before applying a new lockdown policy.',
            ];
        }

        $this->ensureLockdownDirectory();
        $policy = LockdownSettings::normalizePolicy($policy ?: $this->settings->policy());
        $preview = $this->preview($policy);
        $manifest = [
            'version' => 1,
            'applied_at' => time(),
            'applied_at_utc' => gmdate('c'),
            'root_dir' => $this->rootDir,
            'content_dir' => $this->contentDir,
            'server_profile' => $preview['server_profile'],
            'policy' => $policy,
            'perms' => [],
            'files_created' => [],
            'files_modified' => [],
            'managed_blocks' => $this->managedBlockPaths($policy),
            'constants_added' => $this->constantsForPolicy($policy),
            'coverage' => $preview['coverage'],
            'chmod' => [
                'attempted' => 0,
                'applied' => 0,
                'failed_total' => 0,
                'failed_paths' => [],
            ],
            'apply_state' => 'applying',
        ];

        try {
            foreach ($this->pathsToSnapshot($policy) as $path) {
                if (file_exists($path)) {
                    $manifest['perms'][$path] = $this->permission($path);
                }
            }

            // Persist the original modes and every intended marked-block path
            // before changing the filesystem. A failed request can then be
            // reverted without trusting a partially written in-memory record.
            $this->persistManifest($manifest);

            if (!empty($policy['no_exec_enabled'])) {
                foreach ($this->noExecDirectories($policy) as $directory) {
                    $this->writeMarkedBlock($directory . '/.htaccess', self::NO_EXEC_MARKER, $this->noExecHtaccessBody(), $manifest);
                    $this->persistManifest($manifest);
                }
            }

            if (!empty($policy['directory_index_off'])) {
                $this->writeMarkedBlock($this->rootHtaccessPath(), self::ROOT_MARKER, $this->rootProtectHtaccessBody($policy), $manifest);
                $this->persistManifest($manifest);
            }

            $chmodAttempted = 0;
            $chmodApplied = 0;
            $chmodFailures = [];
            if (!empty($policy['read_only_enabled']) && !empty($preview['server_profile']['chmod_ok'])) {
                foreach ($this->readOnlyPathsForApply($policy) as $path) {
                    if (!file_exists($path)) {
                        continue;
                    }

                    $chmodAttempted++;
                    $target = is_dir($path) ? 0555 : 0444;
                    $permission = $this->filesystem->chmod($path, $target, $this->rootDir);
                    if (!empty($permission['ok'])) {
                        $chmodApplied++;
                        continue;
                    }

                    $chmodFailures[] = $path;
                }
            }

            $manifest['chmod'] = [
                'attempted' => $chmodAttempted,
                'applied' => $chmodApplied,
                'failed_total' => count($chmodFailures),
                'failed_paths' => array_slice(array_values(array_unique($chmodFailures)), 0, 25),
            ];
            $manifest['coverage'] = $this->coverageForAppliedState($policy, $preview, $chmodAttempted, $chmodApplied, $chmodFailures);
            $manifest['apply_state'] = 'applied';
            $this->persistManifest($manifest);
            $this->settings->savePolicy($policy);
            $this->settings->markEnabled((array) $manifest['coverage']);
            $this->defineRuntimeConstants($policy);

            return [
                'ok' => true,
                'status' => 'enabled',
                'message' => (string) ($manifest['coverage']['message'] ?? 'Lockdown applied.'),
                'manifest' => $manifest,
                'preview' => $preview,
            ];
        } catch (\Throwable $error) {
            unset($error);

            $rollbackFailures = $this->rollbackPartialApply($manifest);
            if (empty($rollbackFailures) && !$this->removeManifestFile()) {
                $rollbackFailures[] = $this->manifestPath();
            }

            if (!empty($rollbackFailures)) {
                $manifest['apply_state'] = 'rollback_pending';
                $manifest['rollback_failures'] = array_slice(array_values(array_unique($rollbackFailures)), 0, 25);
                try {
                    $this->persistManifest($manifest);
                } catch (\Throwable $persistError) {
                    unset($persistError);
                }
            }

            $message = empty($rollbackFailures)
                ? __('Lockdown could not be applied, and its partial changes were reverted.', 'xerotact-shield-x-security')
                : __('Lockdown could not be applied completely. Its recovery manifest was retained so remaining filesystem changes can be reverted safely.', 'xerotact-shield-x-security');
            $this->settings->markDisabled(empty($rollbackFailures) ? 'apply_failed' : 'apply_rollback_partial', $message);

            return [
                'ok' => false,
                'status' => empty($rollbackFailures) ? 'failed' : 'partial_rollback',
                'message' => $message,
                'preview' => $preview,
                'failed_paths' => array_slice(array_values(array_unique($rollbackFailures)), 0, 25),
            ];
        }
    }

    /**
     * @return array<string,mixed>
     */
    public function revert(bool $disableSettings = true, string $action = 'reverted'): array
    {
        $manifest = $this->manifest();

        if ($manifest === null) {
            if ($this->manifestExists()) {
                return [
                    'ok' => false,
                    'status' => 'invalid_manifest',
                    'message' => 'Lockdown manifest integrity cannot be verified. Use the confirmed force-revert recovery action to remove only known SHIELD-X lockdown artefacts.',
                ];
            }

            $repair = $this->restoreOrphanedLockdownState($this->settings->policy());
            $failures = (array) ($repair['failed'] ?? []);
            if (empty($failures) && $this->manifestExists() && !$this->removeManifestFile()) {
                $failures[] = $this->manifestPath();
            }

            if (!empty($failures)) {
                $message = sprintf(
                    'No trusted lockdown manifest was present, and %d leftover lockdown path(s) could not be restored.',
                    count($failures)
                );

                if ($disableSettings) {
                    $this->settings->markDisabled($action . '_orphan_partial', $message);
                }

                return [
                    'ok' => false,
                    'status' => 'orphan_recovery_failed',
                    'message' => $message,
                    'failed_paths' => array_slice(array_values(array_unique($failures)), 0, 25),
                    'restored_permissions' => (int) ($repair['permissions_changed'] ?? 0),
                    'removed_blocks' => (int) ($repair['blocks_removed'] ?? 0),
                ];
            }

            if ($disableSettings) {
                $this->settings->markDisabled($action);
            }

            return [
                'ok' => true,
                'status' => 'no_manifest',
                'message' => !empty($repair['changed'])
                    ? sprintf('No trusted lockdown manifest was present. SHIELD-X restored %d leftover read-only path(s) and removed %d managed lockdown block(s).', (int) ($repair['permissions_changed'] ?? 0), (int) ($repair['blocks_removed'] ?? 0))
                    : 'No lockdown manifest was present.',
                'restored_permissions' => (int) ($repair['permissions_changed'] ?? 0),
                'removed_blocks' => (int) ($repair['blocks_removed'] ?? 0),
            ];
        }

        if (empty($manifest['hmac_valid'])) {
            return [
                'ok' => false,
                'status' => 'invalid_manifest',
                'message' => 'Lockdown manifest integrity check failed. SHIELD-X did not trust the rollback paths; use the confirmed force-revert recovery action to remove only known SHIELD-X lockdown artefacts.',
            ];
        }

        $failures = $this->restoreManifestState($manifest);

        if (!empty($failures)) {
            $message = sprintf(
                'Lockdown was disabled, but %d filesystem path(s) could not be restored. Restore them manually by FTP/SSH, then retry Lockdown revert.',
                count($failures)
            );

            if ($disableSettings) {
                $this->settings->markDisabled($action . '_partial', $message);
            }

            return [
                'ok' => false,
                'status' => 'partial_revert',
                'message' => $message,
                'failed_paths' => array_slice($failures, 0, 25),
            ];
        }

        if (!$this->removeManifestFile()) {
            $message = 'Lockdown changes were reverted, but the rollback manifest could not be removed.';
            if ($disableSettings) {
                $this->settings->markDisabled($action . '_manifest_left', $message);
            }

            return [
                'ok' => false,
                'status' => 'manifest_remove_failed',
                'message' => $message,
                'failed_paths' => [$this->manifestPath()],
            ];
        }

        if ($disableSettings) {
            $this->clearSuspendMarker();
            $this->settings->markDisabled($action);
        }

        return [
            'ok' => true,
            'status' => $action,
            'message' => 'Lockdown changes were reverted from the manifest.',
        ];
    }

    /**
     * Remove only the known SHIELD-X lockdown artefacts when a manifest is
     * unreadable or fails its HMAC check. It intentionally does not read paths
     * or modes from that manifest.
     *
     * @return array<string,mixed>
     */
    public function forceRevertWithoutManifest(bool $confirmed, string $nonceAction, string $nonce = ''): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce, false);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
            ];
        }

        if (!$confirmed) {
            return [
                'ok' => false,
                'status' => 'confirmation_required',
                'message' => 'Force revert requires explicit confirmation.',
            ];
        }

        $repair = $this->restoreOrphanedLockdownState($this->settings->policy());
        $failures = (array) ($repair['failed'] ?? []);
        if (empty($failures) && !$this->removeManifestFile()) {
            $failures[] = $this->manifestPath();
        }

        if (!empty($failures)) {
            $message = sprintf(
                'Force revert removed only known SHIELD-X lockdown artefacts, but %d path(s) still need manual recovery.',
                count($failures)
            );
            $this->settings->markDisabled('force_revert_partial', $message);

            return [
                'ok' => false,
                'status' => 'force_revert_partial',
                'message' => $message,
                'failed_paths' => array_slice(array_values(array_unique($failures)), 0, 25),
                'restored_permissions' => (int) ($repair['permissions_changed'] ?? 0),
                'removed_blocks' => (int) ($repair['blocks_removed'] ?? 0),
            ];
        }

        $this->clearSuspendMarker();
        $this->settings->markDisabled('force_reverted');

        return [
            'ok' => true,
            'status' => 'force_reverted',
            'message' => 'Known SHIELD-X lockdown changes were removed without trusting the invalid manifest.',
            'restored_permissions' => (int) ($repair['permissions_changed'] ?? 0),
            'removed_blocks' => (int) ($repair['blocks_removed'] ?? 0),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    public function reapply(): array
    {
        return $this->reapplyWithAuthorization('', '', true);
    }

    /**
     * Reapply lockdown after an explicit administrator authorization. This is
     * used by the WP-CLI command so its confirmation is checked before any
     * existing lockdown changes are reverted.
     *
     * @return array<string,mixed>
     */
    public function reapplyForAuthorizedRequest(string $nonceAction, string $nonce = ''): array
    {
        return $this->reapplyWithAuthorization($nonceAction, $nonce, false);
    }

    /**
     * @return array<string,mixed>
     */
    private function reapplyWithAuthorization(string $nonceAction, string $nonce, bool $trustedLifecycle): array
    {
        $guardError = $this->adminWriteGuard($nonceAction, $nonce, $trustedLifecycle);
        if ($guardError !== '') {
            return [
                'ok' => false,
                'status' => 'security_check_failed',
                'message' => $guardError,
            ];
        }

        if ($this->manifestExists()) {
            $revert = $this->revert(false, 'reapply_revert');
            if (empty($revert['ok'])) {
                return $revert;
            }
        }

        return $this->applyWithAuthorization($this->settings->policy(), true, $nonceAction, $nonce, $trustedLifecycle);
    }

    /**
     * @return array<string,mixed>
     */
    public function suspend(): array
    {
        $token = [
            'enabled' => $this->settings->enabled(),
            'policy' => $this->settings->policy(),
            'time' => time(),
            'suspend' => ['ok' => true, 'status' => 'not_needed'],
        ];

        if ($this->settings->enabled()) {
            try {
                $this->writeSuspendMarker($token);
            } catch (\Throwable $error) {
                unset($error);
                $token['suspend'] = [
                    'ok' => false,
                    'status' => 'suspend_marker_failed',
                    'message' => 'Lockdown could not be suspended safely because its resume marker could not be written.',
                ];

                return $token;
            }

            $token['suspend'] = $this->revert(false, 'suspended');
            if (empty($token['suspend']['ok'])) {
                $this->clearSuspendMarker();
            }
        }

        return $token;
    }

    /**
     * @param array<string,mixed> $token
     * @return array<string,mixed>
     */
    public function resume(array $token): array
    {
        if (empty($token['enabled'])) {
            return ['ok' => true, 'status' => 'not_needed'];
        }

        $policy = isset($token['policy']) && is_array($token['policy']) ? $token['policy'] : $this->settings->policy();

        $result = $this->applyWithAuthorization($policy, true, '', '', true);
        if (!empty($result['ok'])) {
            $this->clearSuspendMarker();
        }

        return $result;
    }

    /**
     * @param array<int,string> $scopeIds
     * @return array<string,mixed>
     */
    public function fixPermissions(array $scopeIds): array
    {
        $scopes = array_fill_keys($this->normalizePermissionFixScopes($scopeIds), true);

        if (empty($scopes)) {
            return [
                'ok' => false,
                'message' => 'No permission fix scopes were selected.',
                'files' => 0,
                'directories' => 0,
                'unchanged' => 0,
                'skipped' => 0,
                'failed' => [],
                'scopes' => [],
            ];
        }

        $lockdownError = $this->activeLockdownPermissionFixError();
        if ($lockdownError !== '') {
            return $this->permissionFixBlockedResult(array_keys($scopes), $lockdownError);
        }

        $stats = $this->newPermissionFixStats();

        foreach (array_keys($scopes) as $scopeId) {
            foreach ($this->permissionFixTargets($scopeId) as $target) {
                if ($scopeId === 'root') {
                    $this->applyPermissionFixPath($target, $stats);
                    continue;
                }

                if (is_dir($target) && !is_link($target)) {
                    $this->applyPermissionFixTree($target, $stats);
                    continue;
                }

                $this->applyPermissionFixPath($target, $stats);
            }
        }

        $failedEntries = $this->normalizePermissionFailures((array) $stats['failed']);
        $failed = count($failedEntries);
        $stats['failed'] = array_values(array_slice($failedEntries, 0, 100));
        $stats['failed_total'] = $failed;

        return [
            'ok' => $failed === 0,
            'message' => $this->permissionFixSummaryMessage($failed, $stats),
            'files' => (int) $stats['files'],
            'directories' => (int) $stats['directories'],
            'unchanged' => (int) $stats['unchanged'],
            'skipped' => (int) $stats['skipped'],
            'skipped_locked' => (int) $stats['skipped_locked'],
            'skipped_strict' => (int) $stats['skipped_strict'],
            'failed' => $stats['failed'],
            'failed_total' => $failed,
            'scopes' => array_keys($scopes),
        ];
    }

    /**
     * @param array<int,string> $scopeIds
     * @return array<string,mixed>
     */
    public function startProgressivePermissionFix(array $scopeIds): array
    {
        $scopes = $this->normalizePermissionFixScopes($scopeIds);
        if (empty($scopes)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => 'No permission fix scopes were selected.',
                'job_id' => '',
                'scopes' => [],
                'files' => 0,
                'directories' => 0,
                'unchanged' => 0,
                'skipped' => 0,
                'failed' => [],
                'failed_total' => 0,
                'files_processed' => 0,
                'files_total' => 0,
                'percent' => 0,
                'batches' => 0,
            ];
        }

        $lockdownError = $this->activeLockdownPermissionFixError();
        if ($lockdownError !== '') {
            return $this->permissionFixBlockedResult($scopes, $lockdownError);
        }

        $job = [
            'job_id' => $this->newPermissionFixJobId(),
            'status' => 'running',
            'created_at' => gmdate('c'),
            'updated_at' => gmdate('c'),
            'scopes' => $scopes,
            'scope_index' => 0,
            'current_scope' => '',
            'current_scope_total' => 0,
            'current_scope_processed' => 0,
            'queue' => [],
            'queue_cursor' => 0,
            'discovery' => [],
            'queued_paths' => 0,
            'processed_paths' => 0,
            'batches' => 0,
            'files' => 0,
            'directories' => 0,
            'unchanged' => 0,
            'skipped' => 0,
            'skipped_locked' => 0,
            'skipped_strict' => 0,
            'failed' => [],
            'failed_total' => 0,
        ];

        if (!$this->writePermissionFixJob($job)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => 'SHIELD-X could not create the Permission Fix batch job.',
                'job_id' => '',
                'scopes' => $scopes,
                'files' => 0,
                'directories' => 0,
                'unchanged' => 0,
                'skipped' => 0,
                'failed' => [],
                'failed_total' => 0,
                'files_processed' => 0,
                'files_total' => 0,
                'percent' => 0,
                'batches' => 0,
            ];
        }

        return $this->permissionFixJobResult($job);
    }

    /**
     * @return array<string,mixed>
     */
    public function runProgressivePermissionFixBatch(string $jobId, int $batchPaths = self::PERMISSION_FIX_BATCH_PATHS, int $maxSeconds = self::PERMISSION_FIX_BATCH_SECONDS): array
    {
        $this->permissionFixJobReadFailure = '';
        $job = $this->readPermissionFixJob($jobId);
        if (empty($job)) {
            return [
                'ok' => false,
                'status' => 'failed',
                'message' => $this->permissionFixJobReadFailure !== ''
                    ? 'The Permission Fix batch job ' . $this->permissionFixJobReadFailure . '. Start Permission Fix again.'
                    : 'The Permission Fix batch job is unavailable. Start Permission Fix again.',
                'job_id' => '',
                'scopes' => [],
                'files' => 0,
                'directories' => 0,
                'unchanged' => 0,
                'skipped' => 0,
                'failed' => [],
                'failed_total' => 0,
                'files_processed' => 0,
                'files_total' => 0,
                'percent' => 0,
                'batches' => 0,
            ];
        }

        if ((string) ($job['status'] ?? '') === 'completed') {
            return $this->permissionFixJobResult($job);
        }

        $batchPaths = max(1, $batchPaths);
        $maxSeconds = max(1, $maxSeconds);
        $started = microtime(true);
        $processed = 0;
        $batchStats = $this->newPermissionFixStats();
        $checkpointed = false;

        while ($processed < $batchPaths && microtime(true) - $started < $maxSeconds) {
            $job = $this->ensurePermissionFixQueue($job);
            if ((string) ($job['status'] ?? '') === 'completed') {
                break;
            }

            if (!$checkpointed) {
                $job['updated_at'] = gmdate('c');
                if (!$this->writePermissionFixJob($job)) {
                    $failure = $this->permissionFixBlockedResult(
                        $this->normalizePermissionFixScopes(isset($job['scopes']) && is_array($job['scopes']) ? $job['scopes'] : []),
                        'SHIELD-X could not checkpoint Permission Fix discovery safely.'
                    );
                    $failure['status'] = 'failed';

                    return $failure;
                }
                $checkpointed = true;
            }

            $queue = isset($job['queue']) && is_array($job['queue']) ? array_values($job['queue']) : [];
            $cursor = max(0, (int) ($job['queue_cursor'] ?? 0));
            if (!isset($queue[$cursor])) {
                $job['queue'] = [];
                $job['queue_cursor'] = 0;
                $checkpointed = false;
                continue;
            }

            $this->applyPermissionFixPath((string) $queue[$cursor], $batchStats);
            $processed++;
            $job['processed_paths'] = max(0, (int) ($job['processed_paths'] ?? 0)) + 1;
            $job['queue_cursor'] = $cursor + 1;
            $job['current_scope_processed'] = max(0, (int) ($job['current_scope_processed'] ?? 0)) + 1;

            if ((int) $job['queue_cursor'] >= count($queue)) {
                $job['queue'] = [];
                $job['queue_cursor'] = 0;
                $checkpointed = false;
            }
        }

        if ($processed > 0) {
            $job['files'] = max(0, (int) ($job['files'] ?? 0)) + (int) $batchStats['files'];
            $job['directories'] = max(0, (int) ($job['directories'] ?? 0)) + (int) $batchStats['directories'];
            $job['unchanged'] = max(0, (int) ($job['unchanged'] ?? 0)) + (int) $batchStats['unchanged'];
            $job['skipped'] = max(0, (int) ($job['skipped'] ?? 0)) + (int) $batchStats['skipped'];
            $job['skipped_locked'] = max(0, (int) ($job['skipped_locked'] ?? 0)) + (int) $batchStats['skipped_locked'];
            $job['skipped_strict'] = max(0, (int) ($job['skipped_strict'] ?? 0)) + (int) $batchStats['skipped_strict'];
            $job['batches'] = max(0, (int) ($job['batches'] ?? 0)) + 1;

            $batchFailures = $this->normalizePermissionFailures((array) $batchStats['failed']);
            if (!empty($batchFailures)) {
                $job['failed_total'] = max(0, (int) ($job['failed_total'] ?? 0)) + count($batchFailures);
                $job['failed'] = array_values(array_slice($this->normalizePermissionFailures(array_merge(
                    isset($job['failed']) && is_array($job['failed']) ? $job['failed'] : [],
                    $batchFailures
                )), 0, 100));
            }
        }

        $job = $this->ensurePermissionFixQueue($job);
        $job['updated_at'] = gmdate('c');
        $result = $this->permissionFixJobResult($job);
        if (in_array((string) ($job['status'] ?? ''), ['completed', 'failed'], true)) {
            $this->deletePermissionFixJob($jobId);

            return $result;
        }

        if (!$this->writePermissionFixJob($job)) {
            $result['ok'] = false;
            $result['status'] = 'failed';
            $result['message'] = 'SHIELD-X could not save Permission Fix progress safely.';
        }

        return $result;
    }

    /**
     * @param array<string,mixed> $request
     * @return array<string,mixed>
     */
    public function assessRequest(array $request): array
    {
        if (!$this->settings->enabled() || $this->killswitchActive() || $this->suspendMarker() !== null || empty($this->settings->policy()['no_exec_enabled'])) {
            return ['action' => 'allow'];
        }

        $uri = isset($request['uri']) ? (string) $request['uri'] : '';
        $path = isset($request['path']) && is_scalar($request['path']) ? (string) $request['path'] : '';
        if ($path === '') {
            $path = explode('?', explode('#', $uri, 2)[0], 2)[0];
            if (preg_match('~^https?://[^/]+(?P<path>/.*)?$~i', $path, $matches)) {
                $path = isset($matches['path']) && $matches['path'] !== '' ? $matches['path'] : '/';
            }
        }
        $path = strtolower(RequestPath::decodeTwice($path !== '' ? $path : $uri));
        $path = preg_replace('/[\x00-\x1F\x7F]+/', '', $path) ?: '';

        if (!empty($this->settings->policy()['directory_index_off']) && $this->isDirectCoreAccess($path)) {
            return [
                'action' => 'block',
                'reason' => 'lockdown_direct_core_access',
                'matched_value' => $path,
            ];
        }

        if (!preg_match(self::EXECUTABLE_PATTERN, $path)) {
            return ['action' => 'allow'];
        }

        foreach ((array) $this->settings->policy()['allowlist'] as $allowlisted) {
            $needle = strtolower(trim((string) $allowlisted, '/'));
            if ($needle !== '' && $this->matchesRelativePathPrefix($path, $needle)) {
                return ['action' => 'allow', 'reason' => 'lockdown_allowlist', 'matched_value' => $allowlisted];
            }
        }

        foreach ($this->noExecDirectories($this->settings->policy()) as $directory) {
            $directory = $this->normalizeAbsolute($directory);
            $root = rtrim($this->normalizeAbsolute($this->rootDir), '/') . '/';
            if (strpos($directory . '/', $root) !== 0) {
                continue;
            }

            $relative = trim(substr($directory, strlen($root)), '/');
            if ($relative !== '' && $this->matchesRelativePathPrefix($path, $relative)) {
                return [
                    'action' => 'block',
                    'reason' => 'lockdown_no_exec_path',
                    'matched_value' => '/' . $relative,
                ];
            }
        }

        return ['action' => 'allow'];
    }

    public function manifestPath(): string
    {
        return $this->lockdownDirectory() . '/manifest.json';
    }

    public function killswitchPath(): string
    {
        return $this->lockdownDirectory() . '/disable.json';
    }

    public function manifestExists(): bool
    {
        return is_file($this->manifestPath());
    }

    public function killswitchActive(): bool
    {
        return $this->signedKillswitchPayload() !== null;
    }

    public function killswitchMarker(): string
    {
        $payload = $this->killswitchPayload();
        $payload['hmac'] = $this->hmac($payload);
        $json = wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

        return is_string($json) ? $json . "\n" : '';
    }

    /**
     * @return array<string,mixed>|null
     */
    public function manifest(): ?array
    {
        if (!$this->manifestExists()) {
            return null;
        }

        $json = file_get_contents($this->manifestPath());
        if (!is_string($json) || $json === '') {
            return null;
        }

        $decoded = \ShieldX\Support\Json::decodeArray($json);
        if (!is_array($decoded)) {
            return null;
        }

        $decoded['hmac_valid'] = isset($decoded['hmac']) && is_scalar($decoded['hmac']) && hash_equals((string) $decoded['hmac'], $this->hmac($decoded));

        return $decoded;
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array<int,string>
     */
    private function restoreManifestPermissions(array $manifest): array
    {
        $items = [];

        foreach ((array) ($manifest['perms'] ?? []) as $path => $mode) {
            if (!is_string($path) || !is_scalar($mode) || !file_exists($path) || is_link($path)) {
                continue;
            }

            $items[] = [
                'path' => $path,
                'mode' => octdec((string) $mode),
                'dir' => is_dir($path),
                'depth' => substr_count(str_replace('\\', '/', $path), '/'),
            ];
        }

        usort($items, static function (array $a, array $b): int {
            if ($a['dir'] !== $b['dir']) {
                return $a['dir'] ? -1 : 1;
            }

            return (int) $a['depth'] <=> (int) $b['depth'];
        });

        $failures = [];
        foreach ($items as $item) {
            if (!$this->restorePermission((string) $item['path'], (int) $item['mode'])) {
                $failures[] = (string) $item['path'];
            }
        }

        return $failures;
    }

    /**
     * Restore a trusted manifest in the only safe order: remove our content
     * blocks, restore the complete permission tree once, then repair the
     * modes of the .htaccess files that the content write touched. Failures
     * are calculated from the final filesystem state instead of intermediate
     * chmod results.
     *
     * @param array<string,mixed> $manifest
     * @return array<int,string>
     */
    private function restoreManifestState(array $manifest): array
    {
        $blockPaths = $this->removeManifestBlocks($manifest);
        $this->restoreManifestPermissions($manifest);
        $this->restoreManifestBlockPermissions($manifest, $blockPaths);

        return $this->manifestFinalFailures($manifest, $blockPaths);
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array<int,string>
     */
    private function rollbackPartialApply(array $manifest): array
    {
        return $this->restoreManifestState($manifest);
    }

    /**
     * @param array<string,mixed> $manifest
     * @return array<int,string>
     */
    private function removeManifestBlocks(array $manifest): array
    {
        $paths = [];
        foreach ((array) ($manifest['files_created'] ?? []) as $path) {
            if (is_string($path) && $path !== '') {
                $paths[$path] = $path;
            }
        }

        foreach ((array) ($manifest['files_modified'] ?? []) as $item) {
            if (is_array($item) && isset($item['path']) && is_scalar($item['path'])) {
                $path = (string) $item['path'];
                if ($path !== '') {
                    $paths[$path] = $path;
                }
            }
        }

        foreach ((array) ($manifest['managed_blocks'] ?? []) as $path) {
            if (is_string($path) && $path !== '') {
                $paths[$path] = $path;
            }
        }

        foreach ($paths as $path) {
            if ($this->fileHasLockdownMarker($path)) {
                $this->removeLockdownBlock($path, true);
            }
        }

        return array_values($paths);
    }

    /**
     * @param array<string,mixed> $manifest
     * @param array<int,string>   $blockPaths
     */
    private function restoreManifestBlockPermissions(array $manifest, array $blockPaths): void
    {
        $perms = isset($manifest['perms']) && is_array($manifest['perms']) ? $manifest['perms'] : [];
        foreach ($blockPaths as $path) {
            if (!isset($perms[$path]) || !is_scalar($perms[$path]) || !file_exists($path) || is_link($path)) {
                continue;
            }

            $this->restorePermission($path, octdec((string) $perms[$path]));
        }
    }

    /**
     * @param array<string,mixed> $manifest
     * @param array<int,string>   $blockPaths
     * @return array<int,string>
     */
    private function manifestFinalFailures(array $manifest, array $blockPaths): array
    {
        $failures = [];
        foreach ((array) ($manifest['perms'] ?? []) as $path => $mode) {
            if (!is_string($path) || !is_scalar($mode) || !file_exists($path) || is_link($path)) {
                continue;
            }

            $expected = str_pad(decoct(octdec((string) $mode)), 4, '0', STR_PAD_LEFT);
            if ($this->permission($path) !== $expected) {
                $failures[] = $path;
            }
        }

        foreach ($blockPaths as $path) {
            if ($this->fileHasLockdownMarker($path)) {
                $failures[] = $path;
            }
        }

        return array_values(array_unique($failures));
    }

    private function restorePermission(string $path, int $mode): bool
    {
        if ($mode <= 0 || !file_exists($path) || is_link($path)) {
            return true;
        }

        if (!$this->canChmodPath($path)) {
            return false;
        }

        $result = $this->filesystem->chmod($path, $mode, $this->rootDir);

        return !empty($result['ok']);
    }

    private function fixHostPermission(string $path, int $mode): bool
    {
        $this->lastPermissionFailure = '';

        if ($mode <= 0 || !file_exists($path)) {
            $this->lastPermissionFailure = 'Path does not exist or the target mode is invalid.';
            return false;
        }

        if (!$this->canChmodPath($path)) {
            $this->lastPermissionFailure = 'Path is not owned by the current PHP user and is not safely writable.';
            return false;
        }

        $result = $this->filesystem->chmod($path, $mode, $this->rootDir);
        if (!empty($result['ok'])) {
            return true;
        }

        $this->lastPermissionFailure = (string) ($result['message'] ?? 'WordPress filesystem could not change this path.');

        return false;
    }

    /**
     * Repairs stale permissions from older/partial lockdown runs when the
     * rollback manifest is missing. Only exact modes SHIELD-X applies are
     * touched, so host-specific 0440/0750 policies are left alone.
     *
     * @param array<string,mixed> $policy
     * @return array{changed:int,failed:array<int,string>}
     */
    private function restoreOrphanedReadOnlyPermissions(array $policy): array
    {
        // The original policy may have been turned off after a partial apply.
        // Recovery still has to repair the exact 0444/0555 modes we manage.
        $policy['read_only_enabled'] = true;

        $changed = 0;
        $failed = [];

        foreach ($this->readOnlyPathsForApply($policy) as $path) {
            if (!file_exists($path)) {
                continue;
            }

            $current = $this->permission($path);
            $target = null;
            if (is_dir($path) && $current === '0555') {
                $target = 0755;
            } elseif (is_file($path) && $current === '0444') {
                $target = 0644;
            }

            if ($target === null) {
                continue;
            }

            if ($this->restorePermission($path, $target)) {
                $changed++;
                continue;
            }

            $failed[] = $path;
        }

        return [
            'changed' => $changed,
            'failed' => array_values(array_unique($failed)),
        ];
    }

    /**
     * @param array<string,mixed> $policy
     * @return array{changed:bool,permissions_changed:int,blocks_removed:int,failed:array<int,string>}
     */
    private function restoreOrphanedLockdownState(array $policy): array
    {
        $permissions = $this->restoreOrphanedReadOnlyPermissions($policy);
        $blocks = $this->removeOrphanedLockdownBlocks();
        $failed = array_values(array_unique(array_merge(
            (array) ($permissions['failed'] ?? []),
            (array) ($blocks['failed'] ?? [])
        )));

        return [
            'changed' => (int) ($permissions['changed'] ?? 0) > 0 || (int) ($blocks['changed'] ?? 0) > 0,
            'permissions_changed' => (int) ($permissions['changed'] ?? 0),
            'blocks_removed' => (int) ($blocks['changed'] ?? 0),
            'failed' => $failed,
        ];
    }

    /**
     * @return array{changed:int,failed:array<int,string>}
     */
    private function removeOrphanedLockdownBlocks(): array
    {
        $changed = 0;
        $failed = [];
        foreach ($this->orphanLockdownBlockPaths() as $path) {
            if (!$this->fileHasLockdownMarker($path)) {
                continue;
            }

            if ($this->removeLockdownBlock($path, true) && !$this->fileHasLockdownMarker($path)) {
                $changed++;
                continue;
            }

            $failed[] = $path;
        }

        return [
            'changed' => $changed,
            'failed' => array_values(array_unique($failed)),
        ];
    }

    /**
     * @return array<int,string>
     */
    private function orphanLockdownBlockPaths(): array
    {
        $paths = [$this->rootHtaccessPath()];
        $directories = [
            $this->contentDir . '/uploads',
            $this->contentDir . '/cache',
            $this->contentDir . '/upgrade',
            $this->storage->baseDir(),
        ];

        foreach ($directories as $directory) {
            if (is_dir($directory) && !is_link($directory)) {
                $paths[] = rtrim($directory, '/\\') . '/.htaccess';
            }
        }

        return array_values(array_unique($paths));
    }

    private function canChmodPath(string $path): bool
    {
        if (!file_exists($path)) {
            return false;
        }

        $owner = fileowner($path);
        $currentUser = function_exists('posix_geteuid') ? posix_geteuid() : getmyuid();
        if (is_int($owner) && is_int($currentUser)) {
            return $currentUser === 0 || $owner === $currentUser;
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Lockdown must inspect the existing target before attempting a WordPress filesystem chmod.
        // A writable parent may permit replacement of the target, but it does
        // not establish that changing this file's mode is safe.
        return is_writable($path);
    }

    /**
     * @param array<string,mixed> $policy
     */
    private function defineRuntimeConstants(array $policy): void
    {
        if (!empty($policy['disable_file_mods']) && !defined('DISALLOW_FILE_MODS')) {
            define('DISALLOW_FILE_MODS', true);
        }
    }

    private function isUpdateOperationRequest(): bool
    {
        if (defined('WP_CLI') && WP_CLI) {
            return true;
        }

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

        $actions = [];
        foreach (['action', 'action2'] as $key) {
            $value = $this->requestText($key);
            if ($value !== '') {
                $actions[] = function_exists('sanitize_key') ? sanitize_key($value) : strtolower((string) preg_replace('/[^a-z0-9_-]+/', '', $value));
            }
        }

        $updateCapabilities = [
            'upgrade-plugin' => 'update_plugins',
            'update-plugin' => 'update_plugins',
            'update-selected' => 'update_plugins',
            'install-plugin' => 'install_plugins',
            'upload-plugin' => 'install_plugins',
            'activate-plugin' => 'activate_plugins',
            'upgrade-theme' => 'update_themes',
            'update-theme' => 'update_themes',
            'update-selected-themes' => 'update_themes',
            'install-theme' => 'install_themes',
            'upload-theme' => 'install_themes',
            'do-core-upgrade' => 'update_core',
            'upgrade-core' => 'update_core',
            'update-core' => 'update_core',
        ];

        foreach ($actions as $action) {
            $capability = (string) ($updateCapabilities[$action] ?? '');
            if ($capability !== '' && current_user_can($capability)) {
                return true;
            }
        }

        $script = basename($this->serverText('SCRIPT_NAME'));
        if (in_array($script, ['update.php', 'update-core.php'], true)) {
            return true;
        }

        return false;
    }

    private function currentUserCanPerformWordPressUpdates(): bool
    {
        if (!function_exists('current_user_can')) {
            return false;
        }

        foreach (['update_plugins', 'install_plugins', 'activate_plugins', 'update_themes', 'install_themes', 'update_core'] as $capability) {
            if (current_user_can($capability)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,string>
     */
    private function constantsForPolicy(array $policy): array
    {
        $constants = [];

        if (!empty($policy['disable_file_mods'])) {
            $constants[] = 'DISALLOW_FILE_MODS';
        }

        return $constants;
    }

    /**
     * @return array<string,mixed>
     */
    private function serverProfile(): array
    {
        $server = strtolower($this->serverText('SERVER_SOFTWARE'));
        $sapi = strtolower(PHP_SAPI);
        $isApacheLike = strpos($server, 'apache') !== false
            || strpos($server, 'litespeed') !== false
            || strpos($sapi, 'apache') !== false
            || strpos($sapi, 'litespeed') !== false;

        return [
            'server' => $server !== '' ? $server : 'unknown',
            'sapi' => $sapi,
            'chmod_ok' => $this->chmodProbe(),
            // A server banner cannot prove that AllowOverride permits these
            // rules, so this is only a writing capability hint.
            'htaccess_detected' => $isApacheLike,
        ];
    }

    private function requestText(string $key): string
    {
        $value = '';

        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Read-only update-route detection after isUpdateOperationRequest() verifies an update capability.
        if (isset($_POST[$key]) && is_scalar($_POST[$key])) {
            // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Authorized read-only route detection; unslashed and sanitized below.
            $value = function_exists('wp_unslash') ? wp_unslash($_POST[$key]) : $_POST[$key];
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Authorized read-only fallback used only when POST did not supply the route action.
        if ($value === '' && isset($_GET[$key]) && is_scalar($_GET[$key])) {
            // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Authorized read-only route detection; unslashed and sanitized below.
            $value = function_exists('wp_unslash') ? wp_unslash($_GET[$key]) : $_GET[$key];
        }

        return $this->sanitizeText($value);
    }

    private function postText(string $key): string
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- This helper only extracts POSTed nonce/action context; callers verify nonces before writes.
        if (!isset($_POST[$key]) || !is_scalar($_POST[$key])) {
            return '';
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- The value is unslashed here, sanitized below, and verified by the caller.
        $value = function_exists('wp_unslash') ? wp_unslash($_POST[$key]) : $_POST[$key];

        return $this->sanitizeText($value);
    }

    private function serverText(string $key): string
    {
        if (!isset($_SERVER[$key]) || !is_scalar($_SERVER[$key])) {
            return '';
        }

        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Dynamic server value is unslashed here and sanitized before return.
        $value = function_exists('wp_unslash') ? wp_unslash($_SERVER[$key]) : $_SERVER[$key];

        return $this->sanitizeText($value);
    }

    /**
     * @param mixed $value
     */
    private function sanitizeText($value): string
    {
        $value = is_scalar($value) ? (string) $value : '';
        $value = preg_replace('/[\r\n\x00-\x1F\x7F]+/', '', $value);
        $value = is_string($value) ? trim($value) : '';

        return function_exists('sanitize_text_field') ? sanitize_text_field($value) : $value;
    }

    /**
     * @param array<string,mixed> $policy
     * @param array<string,mixed> $profile
     * @return array<string,string>
     */
    private function coverageForPolicy(array $policy, array $profile): array
    {
        $exec = empty($policy['no_exec_enabled'])
            ? 'disabled'
            : (!empty($profile['htaccess_detected']) ? 'htaccess_unverified' : 'waf_php_block');
        $readonly = empty($policy['read_only_enabled'])
            ? 'disabled'
            : (!empty($profile['chmod_ok']) ? 'available' : 'unavailable');
        $editor = empty($policy['disable_file_mods']) ? 'disabled' : 'runtime_constant';

        return LockdownSettings::normalizeCoverage([
            'exec_block' => $exec,
            'readonly' => $readonly,
            'editor_lock' => $editor,
            'message' => $this->coverageMessage($exec, $readonly),
        ]);
    }

    /**
     * @param array<string,mixed> $policy
     * @param array<string,mixed> $preview
     * @return array<string,string>
     */
    private function coverageForAppliedState(array $policy, array $preview, int $chmodAttempted, int $chmodApplied, array $chmodFailures): array
    {
        $coverage = isset($preview['coverage']) && is_array($preview['coverage'])
            ? $preview['coverage']
            : [];
        if (empty($policy['read_only_enabled'])) {
            $readonly = 'disabled';
        } elseif (!empty($chmodFailures)) {
            $readonly = 'partial';
        } elseif ($chmodAttempted > 0 && $chmodApplied === $chmodAttempted) {
            $readonly = 'enforced';
        } else {
            $readonly = (string) ($coverage['readonly'] ?? 'unavailable');
        }

        return LockdownSettings::normalizeCoverage([
            'exec_block' => (string) ($coverage['exec_block'] ?? 'not_applied'),
            'readonly' => $readonly,
            'editor_lock' => (string) ($coverage['editor_lock'] ?? 'not_applied'),
            'message' => $this->coverageMessage((string) ($coverage['exec_block'] ?? 'not_applied'), $readonly),
        ]);
    }

    private function coverageMessage(string $execBlock, string $readonly): string
    {
        if ($execBlock === 'htaccess_unverified') {
            return 'SHIELD-X can write .htaccess protections, but their web-server effectiveness is unverified. Confirm AllowOverride handling or add server-level denies.';
        }

        if ($execBlock === 'waf_php_block') {
            return 'Exec-block will be enforced by SHIELD-X PHP/WAF fallback; add server-level Nginx denies for full coverage.';
        }

        if ($readonly === 'unavailable') {
            return 'Host does not permit permission changes; relying on exec-block and runtime locks.';
        }

        return 'Reduced lockdown coverage. Review host limitations before relying on this mode.';
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,string>
     */
    private function readOnlyTargets(array $policy): array
    {
        if (empty($policy['read_only_enabled'])) {
            return [];
        }

        $targets = [
            [$this->rootDir . '/wp-admin', $this->rootDir, true],
            [$this->rootDir . '/wp-includes', $this->rootDir, true],
            [$this->configuredPluginDirectory(), '', true],
            [$this->configuredThemeDirectory(), '', true],
            [$this->contentDir . '/mu-plugins', $this->contentDir, true],
            [$this->rootDir . '/wp-config.php', $this->rootDir, false],
        ];
        $clean = [];

        foreach ($targets as $target) {
            $path = $this->safeExistingPath((string) $target[0], (string) $target[1], (bool) $target[2]);
            if ($path !== '') {
                $clean[$path] = $path;
            }
        }

        return array_values($clean);
    }

    private function configuredPluginDirectory(): string
    {
        if (defined('WP_PLUGIN_DIR') && is_string(WP_PLUGIN_DIR) && WP_PLUGIN_DIR !== '') {
            $configured = $this->normalizeAbsolute(WP_PLUGIN_DIR);
            if ($configured !== $this->rootDir && $this->isPermissionPathUnder($configured, $this->rootDir)) {
                return $configured;
            }
        }

        return $this->contentDir . '/plugins';
    }

    private function configuredThemeDirectory(): string
    {
        if (function_exists('get_theme_root')) {
            $themeRoot = get_theme_root();
            if (is_string($themeRoot) && $themeRoot !== '') {
                $configured = $this->normalizeAbsolute($themeRoot);
                if ($configured !== $this->rootDir && $this->isPermissionPathUnder($configured, $this->rootDir)) {
                    return $configured;
                }
            }
        }

        return $this->contentDir . '/themes';
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,string>
     */
    private function noExecDirectories(array $policy): array
    {
        if (empty($policy['no_exec_enabled'])) {
            return [];
        }

        $directories = [
            [$this->contentDir . '/uploads', $this->contentDir],
            [$this->contentDir . '/cache', $this->contentDir],
            [$this->contentDir . '/upgrade', $this->contentDir],
            [$this->storage->baseDir(), ''],
        ];
        $allowlist = (array) ($policy['allowlist'] ?? []);
        $clean = [];

        foreach ($directories as $entry) {
            $directory = $this->safeExistingPath((string) $entry[0], (string) $entry[1], true);
            if ($directory === '') {
                continue;
            }

            if ($this->isPathAllowlisted($directory, $allowlist)) {
                continue;
            }

            $clean[$directory] = $directory;
        }

        return array_values($clean);
    }

    private function safeExistingPath(string $path, string $base, bool $directoryOnly): string
    {
        $path = $this->normalizeAbsolute($path);
        if ($path === '' || is_link($path)) {
            return '';
        }

        if ($directoryOnly) {
            if (!is_dir($path)) {
                return '';
            }
        } elseif (!file_exists($path)) {
            return '';
        }

        $realPath = realpath($path);
        if (!is_string($realPath)) {
            return '';
        }

        $realPath = $this->normalizeAbsolute($realPath);
        if ($base === '') {
            return $realPath;
        }

        $realBase = realpath($base);
        if (!is_string($realBase)) {
            return '';
        }

        $realBase = $this->normalizeAbsolute($realBase);

        return $this->isPermissionPathUnder($realPath, $realBase) ? $realPath : '';
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,string>
     */
    private function pathsToSnapshot(array $policy): array
    {
        $paths = array_merge($this->readOnlyPathsForApply($policy), [$this->rootHtaccessPath()]);

        foreach ($this->noExecDirectories($policy) as $directory) {
            $paths[] = $directory;
            $paths[] = $directory . '/.htaccess';
        }

        return array_values(array_unique($paths));
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,string>
     */
    private function managedBlockPaths(array $policy): array
    {
        $paths = [];
        foreach ($this->noExecDirectories($policy) as $directory) {
            $paths[] = rtrim($directory, '/\\') . '/.htaccess';
        }

        if (!empty($policy['directory_index_off'])) {
            $paths[] = $this->rootHtaccessPath();
        }

        return array_values(array_unique($paths));
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,string>
     */
    private function readOnlyPathsForApply(array $policy): array
    {
        $paths = [];

        foreach ($this->readOnlyTargets($policy) as $target) {
            if (!file_exists($target)) {
                continue;
            }

            if (is_file($target) || is_link($target)) {
                $paths[] = $target;
                continue;
            }

            $children = $this->recursiveChildren($target);
            foreach ($children as $child) {
                $paths[] = $child;
            }
            $paths[] = $target;
        }

        return array_values(array_unique($paths));
    }

    /**
     * @return array<int,string>
     */
    private function recursiveChildren(string $directory): array
    {
        if (!is_dir($directory)) {
            return [];
        }

        $paths = [];

        try {
            $iterator = new \RecursiveIteratorIterator(
                new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS),
                \RecursiveIteratorIterator::CHILD_FIRST
            );

            foreach ($iterator as $item) {
                if ($item instanceof \SplFileInfo && !$item->isLink()) {
                    $paths[] = $this->normalizeAbsolute($item->getPathname());
                }
            }
        } catch (\Throwable $error) {
            return $paths;
        }

        return $paths;
    }

    /**
     * @param array<int,mixed> $scopeIds
     * @return array<int,string>
     */
    private function normalizePermissionFixScopes(array $scopeIds): array
    {
        $allowed = [
            'root' => true,
            'core-admin' => true,
            'core-includes' => true,
            'plugins' => true,
            'themes' => true,
            'uploads' => true,
            'mu-plugins' => true,
            'content-other' => true,
        ];
        $scopes = [];

        foreach ($scopeIds as $scopeId) {
            $scopeId = is_scalar($scopeId) ? (string) $scopeId : '';
            $scopeId = function_exists('sanitize_key')
                ? sanitize_key($scopeId)
                : strtolower((string) preg_replace('/[^a-z0-9_-]+/', '', $scopeId));

            if ($scopeId !== '' && isset($allowed[$scopeId])) {
                $scopes[$scopeId] = true;
            }
        }

        return array_keys($scopes);
    }

    /**
     * @return array{files:int,directories:int,unchanged:int,skipped:int,skipped_locked:int,skipped_strict:int,failed:array<int,mixed>}
     */
    private function newPermissionFixStats(): array
    {
        return [
            'files' => 0,
            'directories' => 0,
            'unchanged' => 0,
            'skipped' => 0,
            'skipped_locked' => 0,
            'skipped_strict' => 0,
            'failed' => [],
        ];
    }

    /**
     * Permission Fix is not a Lockdown override. A currently enabled but
     * unreadable manifest is especially unsafe because there is no trusted
     * list of paths to preserve.
     */
    private function activeLockdownPermissionFixError(): string
    {
        if (!$this->settings->enabled()) {
            return '';
        }

        $manifest = $this->manifest();
        if (!is_array($manifest) || empty($manifest['hmac_valid'])) {
            return 'Permission Fix is unavailable while active Lockdown needs manifest recovery. Revert or force-revert Lockdown first.';
        }

        return '';
    }

    /**
     * @param array<int,string> $scopes
     * @return array<string,mixed>
     */
    private function permissionFixBlockedResult(array $scopes, string $message): array
    {
        return [
            'ok' => false,
            'status' => 'blocked_by_lockdown',
            'message' => $message,
            'job_id' => '',
            'scopes' => array_values($scopes),
            'files' => 0,
            'directories' => 0,
            'unchanged' => 0,
            'skipped' => 0,
            'skipped_locked' => 0,
            'skipped_strict' => 0,
            'failed' => [],
            'failed_total' => 0,
            'files_processed' => 0,
            'files_total' => 0,
            'percent' => 0,
            'batches' => 0,
        ];
    }

    /**
     * @param array<string,mixed> $stats
     */
    private function permissionFixSummaryMessage(int $failed, array $stats): string
    {
        if ($failed > 0) {
            return 'Some paths could not be changed. Review host ownership or filesystem permissions.';
        }

        $changed = max(0, (int) ($stats['files'] ?? 0)) + max(0, (int) ($stats['directories'] ?? 0));
        $unchanged = max(0, (int) ($stats['unchanged'] ?? 0));
        $skipped = max(0, (int) ($stats['skipped'] ?? 0));
        $locked = max(0, (int) ($stats['skipped_locked'] ?? 0));
        $strict = max(0, (int) ($stats['skipped_strict'] ?? 0));

        if ($changed === 0 && $unchanged === 0 && $skipped > 0) {
            return 'The selected scope had no eligible paths to normalize; protected or stricter paths were left unchanged.';
        }

        if ($locked > 0 || $strict > 0) {
            return sprintf('Selected WordPress permissions were normalized. %d active Lockdown path(s) and %d stricter host path(s) were left unchanged.', $locked, $strict);
        }

        return 'Selected WordPress permissions were normalized.';
    }

    private function newPermissionFixJobId(): string
    {
        try {
            return 'pf-' . bin2hex(random_bytes(12));
        } catch (\Throwable $error) {
            throw new \RuntimeException('Unable to create a secure permission-fix job ID.');
        }
    }

    private function permissionFixJobPath(string $jobId): string
    {
        $jobId = function_exists('sanitize_key')
            ? sanitize_key($jobId)
            : strtolower((string) preg_replace('/[^a-z0-9_-]+/', '', $jobId));

        if (preg_match('/^pf-[a-f0-9]{16,40}$/', $jobId) !== 1) {
            return '';
        }

        return rtrim($this->storage->baseDir(), '/\\') . '/jobs/' . $jobId . '.json';
    }

    /**
     * @return array<string,mixed>
     */
    private function readPermissionFixJob(string $jobId): array
    {
        $checkpoint = $this->readPermissionFixJobCheckpoint($jobId);
        if ($checkpoint !== []) {
            return $checkpoint;
        }

        $path = $this->permissionFixJobPath($jobId);
        if ($path === '') {
            $this->permissionFixJobReadFailure = 'has an invalid identifier';

            return [];
        }

        if (!is_file($path)) {
            $this->permissionFixJobReadFailure = 'could not be found';

            return [];
        }

        if (is_link($path)) {
            $this->permissionFixJobReadFailure = 'is not a safe regular file';

            return [];
        }

        if (!is_readable($path)) {
            $this->permissionFixJobReadFailure = 'is not readable by PHP';

            return [];
        }

        $raw = @file_get_contents($path);
        if (!is_string($raw)) {
            $this->permissionFixJobReadFailure = 'could not be read';

            return [];
        }

        $decoded = \ShieldX\Support\Json::decodeArray($raw);

        if (!is_array($decoded)) {
            $this->permissionFixJobReadFailure = 'contains invalid data';
            $this->deletePermissionFixJob($jobId);

            return [];
        }

        if ($this->permissionFixJobExpired($decoded)) {
            $this->permissionFixJobReadFailure = $this->permissionFixJobExpiryFailure($decoded);
            $this->deletePermissionFixJob($jobId);

            return [];
        }

        return $decoded;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function writePermissionFixJob(array $job): bool
    {
        $jobId = isset($job['job_id']) && is_scalar($job['job_id']) ? (string) $job['job_id'] : '';
        $checkpointed = $this->writePermissionFixJobCheckpoint($job);
        $path = $this->permissionFixJobPath($jobId);
        if ($path === '') {
            return $checkpointed;
        }

        try {
            $this->storage->ensure();
            $directory = dirname($path);
            if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
                return $checkpointed;
            }

            $json = wp_json_encode($job, JSON_UNESCAPED_SLASHES);
            if (!is_string($json) || is_link($path)) {
                return $checkpointed;
            }

            $this->writeAtomicJson($path, $json . "\n", 'Permission Fix batch job');
        } catch (\Throwable $error) {
            return $checkpointed;
        }

        return true;
    }

    /**
     * @param array<string,mixed> $job
     */
    private function permissionFixJobExpired(array $job): bool
    {
        $status = isset($job['status']) && is_scalar($job['status']) ? (string) $job['status'] : '';
        if (in_array($status, ['completed', 'failed'], true)) {
            return true;
        }

        $updatedAt = isset($job['updated_at']) && is_scalar($job['updated_at'])
            ? (string) $job['updated_at']
            : (isset($job['created_at']) && is_scalar($job['created_at']) ? (string) $job['created_at'] : '');
        $timestamp = strtotime($updatedAt);

        return $timestamp === false || $timestamp < time() - self::PERMISSION_FIX_JOB_TTL;
    }

    private function deletePermissionFixJob(string $jobId): void
    {
        $optionName = $this->permissionFixJobOptionName($jobId);
        if ($optionName !== '' && function_exists('delete_option')) {
            delete_option($optionName);
        }

        $path = $this->permissionFixJobPath($jobId);
        if ($path === '' || !is_file($path) || is_link($path)) {
            return;
        }

        $this->filesystem->delete($path);
    }

    /**
     * @return array<string,mixed>
     */
    private function readPermissionFixJobCheckpoint(string $jobId): array
    {
        $optionName = $this->permissionFixJobOptionName($jobId);
        if ($optionName === '' || !function_exists('get_option')) {
            return [];
        }

        $job = get_option($optionName, null);
        if ($job === null) {
            return [];
        }

        if (!is_array($job)) {
            $this->permissionFixJobReadFailure = 'contains invalid data';
            if (function_exists('delete_option')) {
                delete_option($optionName);
            }

            return [];
        }

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

        $this->permissionFixJobReadFailure = $this->permissionFixJobExpiryFailure($job);
        if (function_exists('delete_option')) {
            delete_option($optionName);
        }

        return [];
    }

    /**
     * @param array<string,mixed> $job
     */
    private function writePermissionFixJobCheckpoint(array $job): bool
    {
        $jobId = isset($job['job_id']) && is_scalar($job['job_id']) ? (string) $job['job_id'] : '';
        $optionName = $this->permissionFixJobOptionName($jobId);
        if ($optionName === '' || !function_exists('get_option') || !function_exists('add_option') || !function_exists('update_option')) {
            return false;
        }

        $existing = get_option($optionName, null);
        if ($existing === null) {
            add_option($optionName, $job, '', 'no');
        } else {
            update_option($optionName, $job, false);
        }

        $stored = get_option($optionName, null);

        return is_array($stored)
            && isset($stored['job_id'])
            && hash_equals($jobId, (string) $stored['job_id']);
    }

    private function permissionFixJobOptionName(string $jobId): string
    {
        $jobId = function_exists('sanitize_key')
            ? sanitize_key($jobId)
            : strtolower((string) preg_replace('/[^a-z0-9_-]+/', '', $jobId));

        return preg_match('/^pf-[a-f0-9]{16,40}$/', $jobId) === 1
            ? 'shieldx_permission_fix_job_' . $jobId
            : '';
    }

    /**
     * @param array<string,mixed> $job
     */
    private function permissionFixJobExpiryFailure(array $job): string
    {
        $status = isset($job['status']) && is_scalar($job['status']) ? (string) $job['status'] : '';
        if (in_array($status, ['completed', 'failed'], true)) {
            return 'has already completed or failed';
        }

        $updatedAt = isset($job['updated_at']) && is_scalar($job['updated_at'])
            ? (string) $job['updated_at']
            : (isset($job['created_at']) && is_scalar($job['created_at']) ? (string) $job['created_at'] : '');

        return strtotime($updatedAt) === false ? 'has invalid timing data' : 'has expired';
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function ensurePermissionFixQueue(array $job): array
    {
        $scopes = $this->normalizePermissionFixScopes(isset($job['scopes']) && is_array($job['scopes']) ? $job['scopes'] : []);
        $scopeIndex = max(0, (int) ($job['scope_index'] ?? 0));

        while ($scopeIndex < count($scopes)) {
            $queue = isset($job['queue']) && is_array($job['queue']) ? array_values($job['queue']) : [];
            $cursor = max(0, (int) ($job['queue_cursor'] ?? 0));
            if (!empty($queue) && $cursor < count($queue)) {
                $job['scopes'] = $scopes;
                $job['scope_index'] = $scopeIndex;

                return $job;
            }

            $scopeId = $scopes[$scopeIndex];
            $discovery = isset($job['discovery']) && is_array($job['discovery']) ? $job['discovery'] : [];
            if ((string) ($discovery['scope'] ?? '') !== $scopeId) {
                $discovery = $this->startPermissionFixDiscovery($scopeId);
                $job['current_scope'] = $scopeId;
                $job['current_scope_total'] = 0;
                $job['current_scope_processed'] = 0;
            }

            $job['discovery'] = $discovery;
            $job = $this->discoverPermissionFixQueue($job);
            $queue = isset($job['queue']) && is_array($job['queue']) ? array_values($job['queue']) : [];
            if (!empty($queue)) {
                $job['scopes'] = $scopes;
                $job['scope_index'] = $scopeIndex;

                return $job;
            }

            $discovery = isset($job['discovery']) && is_array($job['discovery']) ? $job['discovery'] : [];
            if (!empty($discovery['pending']) && is_array($discovery['pending'])) {
                $job['scopes'] = $scopes;
                $job['scope_index'] = $scopeIndex;

                return $job;
            }

            $scopeIndex++;
            $job['scope_index'] = $scopeIndex;
            $job['current_scope'] = '';
            $job['current_scope_total'] = 0;
            $job['current_scope_processed'] = 0;
            $job['queue'] = [];
            $job['queue_cursor'] = 0;
            $job['discovery'] = [];
        }

        $job['scopes'] = $scopes;
        $job['scope_index'] = count($scopes);
        $job['status'] = 'completed';
        $job['queue'] = [];
        $job['queue_cursor'] = 0;
        $job['discovery'] = [];
        $job['current_scope'] = '';
        $job['current_scope_total'] = 0;
        $job['current_scope_processed'] = 0;

        return $job;
    }

    /**
     * @return array<string,mixed>
     */
    private function startPermissionFixDiscovery(string $scopeId): array
    {
        $pending = [];
        $walkDirectories = $scopeId !== 'root';
        foreach ($this->permissionFixTargets($scopeId) as $path) {
            $node = $this->permissionFixDiscoveryNode($path, $walkDirectories);
            if ($node !== []) {
                $pending[] = $node;
            }
        }

        return [
            'scope' => $scopeId,
            'pending' => $pending,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function permissionFixDiscoveryNode(string $path, bool $walkDirectories): array
    {
        $path = $this->normalizeAbsolute($path);
        if ($path === '' || !$this->isPermissionFixPath($path) || is_link($path) || $this->isPrivateShieldxStoragePath($path)) {
            return [];
        }

        return [
            'path' => $path,
            'walk' => $walkDirectories && is_dir($path),
            'reported' => false,
            'offset' => 0,
        ];
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function discoverPermissionFixQueue(array $job): array
    {
        $discovery = isset($job['discovery']) && is_array($job['discovery']) ? $job['discovery'] : [];
        $pending = isset($discovery['pending']) && is_array($discovery['pending']) ? array_values($discovery['pending']) : [];
        $queue = [];
        $limit = max(1, min(self::PERMISSION_FIX_BATCH_PATHS, 100));

        while (!empty($pending) && count($queue) < $limit) {
            $node = array_shift($pending);
            if (!is_array($node) || !isset($node['path']) || !is_scalar($node['path'])) {
                continue;
            }

            $path = $this->normalizeAbsolute((string) $node['path']);
            if ($path === '' || !$this->isPermissionFixPath($path) || is_link($path) || $this->isPrivateShieldxStoragePath($path)) {
                continue;
            }

            if (empty($node['reported'])) {
                $queue[] = $path;
                $job['queued_paths'] = max(0, (int) ($job['queued_paths'] ?? 0)) + 1;
                $job['current_scope_total'] = max(0, (int) ($job['current_scope_total'] ?? 0)) + 1;
                $node['reported'] = true;
            }

            if (empty($node['walk']) || !is_dir($path)) {
                continue;
            }

            $remaining = max(1, $limit - count($queue));
            $slice = $this->permissionFixDirectorySlice($path, max(0, (int) ($node['offset'] ?? 0)), $remaining);
            foreach ($slice['nodes'] as $child) {
                $pending[] = $child;
            }

            if (!$slice['done']) {
                $node['offset'] = (int) $slice['next_offset'];
                array_unshift($pending, $node);
            }
        }

        $discovery['pending'] = $pending;
        $job['discovery'] = $discovery;
        $job['queue'] = $queue;
        $job['queue_cursor'] = 0;

        return $job;
    }

    /**
     * @return array{nodes:array<int,array<string,mixed>>,next_offset:int,done:bool}
     */
    private function permissionFixDirectorySlice(string $directory, int $offset, int $limit): array
    {
        $nodes = [];
        $position = 0;
        $nextOffset = $offset;
        $done = true;

        try {
            $iterator = new \FilesystemIterator($directory, \FilesystemIterator::SKIP_DOTS);
            foreach ($iterator as $item) {
                if ($position++ < $offset) {
                    continue;
                }

                $nextOffset = $position;
                if (!$item instanceof \SplFileInfo) {
                    continue;
                }

                $node = $this->permissionFixDiscoveryNode($item->getPathname(), true);
                if ($node !== []) {
                    $nodes[] = $node;
                }

                if (count($nodes) >= $limit) {
                    $done = false;
                    break;
                }
            }
        } catch (\Throwable $error) {
            return [
                'nodes' => $nodes,
                'next_offset' => $nextOffset,
                'done' => true,
            ];
        }

        return [
            'nodes' => $nodes,
            'next_offset' => $nextOffset,
            'done' => $done,
        ];
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function permissionFixJobResult(array $job): array
    {
        $scopes = $this->normalizePermissionFixScopes(isset($job['scopes']) && is_array($job['scopes']) ? $job['scopes'] : []);
        $status = (string) ($job['status'] ?? 'running');
        $scopeCount = count($scopes);
        $scopeIndex = min($scopeCount, max(0, (int) ($job['scope_index'] ?? 0)));
        $currentScopeTotal = max(0, (int) ($job['current_scope_total'] ?? 0));
        $currentScopeProcessed = min($currentScopeTotal, max(0, (int) ($job['current_scope_processed'] ?? 0)));
        $scopeProgress = $currentScopeTotal > 0 ? ($currentScopeProcessed / max(1, $currentScopeTotal)) : 0.0;
        $percent = $status === 'completed'
            ? 100
            : max(0, min(99, (int) floor((($scopeIndex + $scopeProgress) / max(1, $scopeCount)) * 100)));
        $processedPaths = max(0, (int) ($job['processed_paths'] ?? 0));
        $currentRemaining = max(0, $currentScopeTotal - $currentScopeProcessed);
        $failedTotal = max(0, (int) ($job['failed_total'] ?? 0));
        $summaryStats = [
            'files' => (int) ($job['files'] ?? 0),
            'directories' => (int) ($job['directories'] ?? 0),
            'unchanged' => (int) ($job['unchanged'] ?? 0),
            'skipped' => (int) ($job['skipped'] ?? 0),
            'skipped_locked' => (int) ($job['skipped_locked'] ?? 0),
            'skipped_strict' => (int) ($job['skipped_strict'] ?? 0),
        ];

        return [
            'ok' => $status === 'completed' ? $failedTotal === 0 : $status !== 'failed',
            'status' => $status,
            'message' => $status === 'completed'
                ? $this->permissionFixSummaryMessage($failedTotal, $summaryStats)
                : 'Permission Fix is running.',
            'job_id' => (string) ($job['job_id'] ?? ''),
            'scopes' => $scopes,
            'current_scope' => (string) ($job['current_scope'] ?? ''),
            'current_scope_index' => $scopeIndex,
            'scope_count' => $scopeCount,
            'current_scope_processed' => $currentScopeProcessed,
            'current_scope_total' => $currentScopeTotal,
            'files' => max(0, (int) ($job['files'] ?? 0)),
            'directories' => max(0, (int) ($job['directories'] ?? 0)),
            'unchanged' => max(0, (int) ($job['unchanged'] ?? 0)),
            'skipped' => max(0, (int) ($job['skipped'] ?? 0)),
            'skipped_locked' => max(0, (int) ($job['skipped_locked'] ?? 0)),
            'skipped_strict' => max(0, (int) ($job['skipped_strict'] ?? 0)),
            'failed' => isset($job['failed']) && is_array($job['failed']) ? array_values($job['failed']) : [],
            'failed_total' => $failedTotal,
            'files_processed' => $processedPaths,
            'files_total' => max($processedPaths + $currentRemaining, max(0, (int) ($job['queued_paths'] ?? 0))),
            'percent' => $percent,
            'batches' => max(0, (int) ($job['batches'] ?? 0)),
        ];
    }

    /**
     * @return array<int,string>
     */
    private function permissionFixTargets(string $scopeId): array
    {
        $content = $this->contentDir;
        $map = [
            'core-admin' => [$this->rootDir . '/wp-admin'],
            'core-includes' => [$this->rootDir . '/wp-includes'],
            'plugins' => [$content . '/plugins'],
            'themes' => [$content . '/themes'],
            'uploads' => [$content . '/uploads'],
            'mu-plugins' => [$content . '/mu-plugins'],
        ];

        if ($scopeId === 'root') {
            return $this->permissionFixRootTargets();
        }

        if ($scopeId === 'content-other') {
            return $this->permissionFixContentOtherTargets();
        }

        return isset($map[$scopeId]) ? $map[$scopeId] : [];
    }

    /**
     * @return array<int,string>
     */
    private function permissionFixRootTargets(): array
    {
        $targets = [];

        try {
            $iterator = new \DirectoryIterator($this->rootDir);
            foreach ($iterator as $item) {
                if ($item->isDot()) {
                    continue;
                }

                if ($item->isFile() || $item->isLink()) {
                    $path = $this->normalizeAbsolute($item->getPathname());
                    // wp-config.php is commonly hardened to 0600/0640. The
                    // default repair scope must never make it world-readable.
                    if (strtolower(basename($path)) === 'wp-config.php') {
                        continue;
                    }

                    $targets[] = $path;
                }
            }
        } catch (\Throwable $error) {
            return array_values(array_unique($targets));
        }

        return array_values(array_unique($targets));
    }

    /**
     * @return array<int,string>
     */
    private function permissionFixContentOtherTargets(): array
    {
        $targets = [];
        $excluded = [
            $this->contentDir . '/plugins',
            $this->contentDir . '/themes',
            $this->contentDir . '/uploads',
            $this->contentDir . '/mu-plugins',
        ];

        try {
            $iterator = new \DirectoryIterator($this->contentDir);
            foreach ($iterator as $item) {
                if ($item->isDot()) {
                    continue;
                }

                $path = $this->normalizeAbsolute($item->getPathname());
                foreach ($excluded as $exclude) {
                    if ($this->isPermissionPathUnder($path, $exclude)) {
                        continue 2;
                    }
                }

                $targets[] = $path;
            }
        } catch (\Throwable $error) {
            return $targets;
        }

        return array_values(array_unique($targets));
    }

    /**
     * @param array<string,mixed> $stats
     */
    private function applyPermissionFixTree(string $directory, array &$stats): void
    {
        $this->applyPermissionFixPath($directory, $stats);

        foreach ($this->permissionFixRecursiveChildren($directory) as $path) {
            $this->applyPermissionFixPath($path, $stats);
        }
    }

    /**
     * @param array<string,mixed> $stats
     */
    private function applyPermissionFixPath(string $path, array &$stats): void
    {
        $path = $this->normalizeAbsolute($path);

        if ((file_exists($path) === false && !is_link($path)) || !$this->isPermissionFixPath($path)) {
            $stats['skipped'] = (int) $stats['skipped'] + 1;
            return;
        }

        if (is_link($path) || $this->isPrivateShieldxStoragePath($path)) {
            $stats['skipped'] = (int) $stats['skipped'] + 1;
            return;
        }

        $lockedPaths = $this->activeLockdownPermissionPaths();
        if (isset($lockedPaths[$path])) {
            $stats['skipped'] = (int) $stats['skipped'] + 1;
            $stats['skipped_locked'] = (int) $stats['skipped_locked'] + 1;
            return;
        }

        if (!is_dir($path) && !is_file($path)) {
            $stats['skipped'] = (int) $stats['skipped'] + 1;
            return;
        }

        $mode = is_dir($path) ? 0755 : 0644;
        $currentMode = octdec($this->permission($path));
        if ($currentMode === $mode) {
            $stats['unchanged'] = (int) $stats['unchanged'] + 1;
            return;
        }

        // Never use a repair target to add a permission bit. For example,
        // 0600 wp-config.php and 0750 directories are intentionally stricter
        // than the conventional WordPress modes and must stay that way.
        if (($mode & (~$currentMode & 0777)) !== 0) {
            $stats['skipped'] = (int) $stats['skipped'] + 1;
            $stats['skipped_strict'] = (int) $stats['skipped_strict'] + 1;
            return;
        }

        if ($this->fixHostPermission($path, $mode)) {
            if (is_dir($path)) {
                $stats['directories'] = (int) $stats['directories'] + 1;
            } else {
                $stats['files'] = (int) $stats['files'] + 1;
            }
            return;
        }

        $failed = isset($stats['failed']) && is_array($stats['failed']) ? $stats['failed'] : [];
        $failed[] = [
            'path' => $this->relativeToRoot($path),
            'type' => is_dir($path) ? 'folder' : 'file',
            'target_mode' => sprintf('%04o', $mode),
            'reason' => $this->lastPermissionFailure !== '' ? $this->lastPermissionFailure : 'Permission change failed.',
        ];
        $stats['failed'] = $failed;
    }

    private function isPermissionFixPath(string $path): bool
    {
        return $this->isPermissionPathUnder($path, $this->rootDir)
            || $this->isPermissionPathUnder($path, $this->contentDir);
    }

    /**
     * @return array<string,bool>
     */
    private function activeLockdownPermissionPaths(): array
    {
        $enabled = $this->settings->enabled();
        if ($this->activeLockdownPermissionPaths !== null
            && $this->activeLockdownPermissionPathsEnabled === $enabled) {
            return $this->activeLockdownPermissionPaths;
        }

        $this->activeLockdownPermissionPathsEnabled = $enabled;
        $this->activeLockdownPermissionPaths = [];
        if (!$enabled) {
            return $this->activeLockdownPermissionPaths;
        }

        $manifest = $this->manifest();
        if (!is_array($manifest) || empty($manifest['hmac_valid'])) {
            return $this->activeLockdownPermissionPaths;
        }

        foreach ((array) ($manifest['perms'] ?? []) as $path => $mode) {
            if (!is_string($path) || !is_scalar($mode) || $path === '') {
                continue;
            }

            $this->activeLockdownPermissionPaths[$this->normalizeAbsolute($path)] = true;
        }

        return $this->activeLockdownPermissionPaths;
    }

    /**
     * @param array<int,mixed> $failures
     * @return array<int,array{path:string,type:string,target_mode:string,reason:string}>
     */
    private function normalizePermissionFailures(array $failures): array
    {
        $normalized = [];
        $seen = [];

        foreach ($failures as $failure) {
            if (is_array($failure)) {
                $path = isset($failure['path']) && is_scalar($failure['path']) ? (string) $failure['path'] : '';
                $type = isset($failure['type']) && is_scalar($failure['type']) ? (string) $failure['type'] : 'path';
                $mode = isset($failure['target_mode']) && is_scalar($failure['target_mode']) ? (string) $failure['target_mode'] : '';
                $reason = isset($failure['reason']) && is_scalar($failure['reason']) ? (string) $failure['reason'] : '';
            } else {
                $path = is_scalar($failure) ? (string) $failure : '';
                $type = 'path';
                $mode = '';
                $reason = '';
            }

            $path = trim($path);
            if ($path === '') {
                continue;
            }

            $type = in_array($type, ['file', 'folder', 'path'], true) ? $type : 'path';
            $mode = preg_match('/^0?[0-7]{3,4}$/', $mode) ? sprintf('%04o', octdec($mode)) : '';
            $reason = trim($reason) !== '' ? trim($reason) : 'Permission change failed.';
            $key = $path . '|' . $mode;

            if (isset($seen[$key])) {
                continue;
            }

            $seen[$key] = true;
            $normalized[] = [
                'path' => $path,
                'type' => $type,
                'target_mode' => $mode,
                'reason' => $reason,
            ];
        }

        return $normalized;
    }

    /**
     * @return array<int,string>
     */
    private function permissionFixRecursiveChildren(string $directory): array
    {
        if (!is_dir($directory) || is_link($directory)) {
            return [];
        }

        $paths = [];

        try {
            $directoryIterator = new \RecursiveDirectoryIterator($directory, \FilesystemIterator::SKIP_DOTS);
            $filter = new \RecursiveCallbackFilterIterator(
                $directoryIterator,
                function (\SplFileInfo $item): bool {
                    $path = $this->normalizeAbsolute($item->getPathname());

                    return !$item->isLink() && !$this->isPrivateShieldxStoragePath($path);
                }
            );
            $iterator = new \RecursiveIteratorIterator($filter, \RecursiveIteratorIterator::SELF_FIRST);

            foreach ($iterator as $item) {
                if ($item instanceof \SplFileInfo) {
                    $paths[] = $this->normalizeAbsolute($item->getPathname());
                }
            }
        } catch (\Throwable $error) {
            return $paths;
        }

        return $paths;
    }

    private function isPrivateShieldxStoragePath(string $path): bool
    {
        return $this->isPermissionPathUnder($path, $this->contentDir . '/uploads/.shield-x')
            || $this->isPermissionPathUnder($path, $this->contentDir . '/uploads/.' . 'shieldx')
            || $this->isPermissionPathUnder($path, $this->contentDir . '/.shield-x')
            || $this->isPermissionPathUnder($path, $this->contentDir . '/.' . 'shieldx');
    }

    private function isPermissionPathUnder(string $path, string $base): bool
    {
        $path = rtrim(str_replace('\\', '/', $this->normalizeAbsolute($path)), '/');
        $base = rtrim(str_replace('\\', '/', $this->normalizeAbsolute($base)), '/');

        return $path === $base || strpos($path . '/', $base . '/') === 0;
    }

    /**
     * @param array<int,string> $allowlist
     */
    private function isPathAllowlisted(string $path, array $allowlist): bool
    {
        $relativeRoot = strtolower(trim($this->relativeToRoot($path), '/'));
        $relativeContent = strtolower(trim($this->relativeToContent($path), '/'));

        foreach ($allowlist as $item) {
            $item = strtolower(trim((string) $item, '/'));
            if ($item !== '' && ($relativeRoot === $item || $relativeContent === $item)) {
                return true;
            }
        }

        return false;
    }

    /**
     * @return array<string,mixed>
     */
    private function pathState(string $path): array
    {
        return [
            'path' => $path,
            'exists' => file_exists($path),
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Status panel reports host filesystem writability without changing files.
            'writable' => is_writable($path),
            'permission' => file_exists($path) ? $this->permission($path) : '',
        ];
    }

    /**
     * @param array<string,mixed> $policy
     * @return array<int,array<string,mixed>>
     */
    private function readOnlyCandidateStates(array $policy): array
    {
        $states = [];

        foreach ($this->readOnlyTargets(LockdownSettings::normalizePolicy($policy)) as $path) {
            $permission = $this->permission($path);
            $target = is_dir($path) ? '0555' : '0444';
            $states[] = [
                'path' => $path,
                'permission' => $permission,
                'target' => $target,
                'locked' => $permission === $target,
            ];
        }

        return $states;
    }

    private function chmodProbe(): bool
    {
        /** @var array<string,bool> $results */
        static $results = [];

        $directory = is_dir($this->lockdownDirectory()) ? $this->lockdownDirectory() : sys_get_temp_dir();
        $directory = rtrim($directory, '/\\');
        if (array_key_exists($directory, $results)) {
            return $results[$directory];
        }

        try {
            $suffix = bin2hex(random_bytes(4));
        } catch (\Throwable $error) {
            return $results[$directory] = false;
        }

        $probe = $directory . '/shieldx-chmod-' . $suffix;
        $directoryProbe = $directory . '/shieldx-chmod-dir-' . $suffix;

        if (file_put_contents($probe, 'probe') === false) {
            return $results[$directory] = false;
        }

        $fileOk = FilesystemAdapter::nativeChmod($probe, 0444, $this->rootDir) && $this->permission($probe) === '0444';
        FilesystemAdapter::nativeChmod($probe, 0644, $this->rootDir);
        unlink($probe);

        // Windows commonly accepts chmod on files while representing directory
        // write protection differently. Verify both before advertising
        // directory hardening capability.
        $directoryOk = mkdir($directoryProbe, 0755)
            && FilesystemAdapter::nativeChmod($directoryProbe, 0555, $this->rootDir)
            && $this->permission($directoryProbe) === '0555';
        if (is_dir($directoryProbe)) {
            FilesystemAdapter::nativeChmod($directoryProbe, 0755, $this->rootDir);
            rmdir($directoryProbe);
        }

        return $results[$directory] = $fileOk && $directoryOk;
    }

    /**
     * @param array<string,mixed> $profile
     */
    private function readOnlyPreviewStatus(string $path, array $profile, bool $verifyDescendants): string
    {
        if (!file_exists($path)) {
            return 'missing_path';
        }

        if (empty($profile['chmod_ok'])) {
            return 'host_unavailable';
        }

        // A disabled policy has no managed descendant state to verify. Avoid an
        // unbounded filesystem walk merely to render its prospective preview.
        if (!$this->settings->enabled() || !$this->manifestExists()) {
            return 'will_apply';
        }

        $target = is_dir($path) ? '0555' : '0444';
        if ($this->permission($path) !== $target) {
            return 'needs_reapply';
        }

        if (!$verifyDescendants) {
            return 'applied_unverified';
        }

        if (!$this->hasWritableDescendants($path)) {
            return 'applied';
        }

        return $this->hasPermissionBlockedWritableDescendant($path) ? 'permission_blocked' : 'needs_reapply';
    }

    private function hasWritableDescendants(string $path): bool
    {
        if (!is_dir($path)) {
            return false;
        }

        foreach ($this->recursiveChildren($path) as $child) {
            if ($this->hasWriteBit($child)) {
                return true;
            }
        }

        return false;
    }

    private function hasPermissionBlockedWritableDescendant(string $path): bool
    {
        if (!is_dir($path)) {
            return false;
        }

        foreach ($this->recursiveChildren($path) as $child) {
            if ($this->hasWriteBit($child) && !$this->canCurrentProcessChmod($child)) {
                return true;
            }
        }

        return false;
    }

    private function canCurrentProcessChmod(string $path): bool
    {
        if (!function_exists('posix_geteuid')) {
            return true;
        }

        $uid = posix_geteuid();
        if ($uid === 0) {
            return true;
        }

        $owner = file_exists($path) ? fileowner($path) : false;

        return $owner === false || $owner === $uid;
    }

    private function hasWriteBit(string $path): bool
    {
        $perms = file_exists($path) ? fileperms($path) : false;

        return $perms !== false && ($perms & 0222) !== 0;
    }

    private function adminWriteGuard(string $nonceAction, string $nonce = '', bool $trustedLifecycle = false): string
    {
        if ($trustedLifecycle) {
            return '';
        }

        if (defined('WP_CLI') && WP_CLI) {
            // WP-CLI has no browser nonce. Require a command-scoped confirmation and
            // an administrator context instead, so a direct wp eval stays opt-in.
            if (getenv('SHIELDX_CLI_CONFIRM') === 'yes') {
                if (!function_exists('current_user_can')) {
                    return 'Administrator permission could not be verified for lockdown.';
                }

                $capability = defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';

                return current_user_can($capability)
                    ? ''
                    : 'WP-CLI lockdown changes require an administrator context. Re-run with --user=<admin>.';
            }

            return 'WP-CLI lockdown changes require SHIELDX_CLI_CONFIRM=yes and an administrator-gated command.';
        }

        if (!function_exists('current_user_can')) {
            return 'Administrator permission could not be verified for lockdown.';
        }

        $capability = defined('SHIELDX_MANAGE_CAPABILITY') ? (string) SHIELDX_MANAGE_CAPABILITY : 'manage_options';
        if (!current_user_can($capability)) {
            return 'Administrator permission is required to apply lockdown.';
        }

        if ($nonceAction === '') {
            return 'Missing security token context.';
        }

        if (!function_exists('wp_verify_nonce')) {
            return 'WordPress nonce verification is not available.';
        }

        if ($nonce === '') {
            foreach (['_wpnonce', '_ajax_nonce'] as $key) {
                if (isset($_POST[$key]) && is_scalar($_POST[$key])) {
                    $nonce = $this->postText($key);
                    break;
                }
            }
        }

        return $nonce !== '' && wp_verify_nonce($nonce, $nonceAction) !== false
            ? ''
            : 'Security check failed. Refresh the page and try again.';
    }

    private function ensureLockdownDirectory(): void
    {
        if (is_dir($this->lockdownDirectory())) {
            return;
        }

        if (function_exists('wp_mkdir_p')) {
            wp_mkdir_p($this->lockdownDirectory());
            return;
        }

        mkdir($this->lockdownDirectory(), 0755, true);
    }

    private function lockdownDirectory(): string
    {
        return $this->storage->baseDir() . '/lockdown';
    }

    private function suspendMarkerPath(): string
    {
        return $this->lockdownDirectory() . '/suspended.json';
    }

    /**
     * @param array<string,mixed> $token
     */
    private function writeSuspendMarker(array $token): void
    {
        $payload = [
            'version' => 1,
            'enabled' => !empty($token['enabled']),
            'policy' => isset($token['policy']) && is_array($token['policy']) ? $token['policy'] : [],
            'time' => isset($token['time']) && is_scalar($token['time']) ? (int) $token['time'] : time(),
        ];
        $payload['hmac'] = $this->hmac($payload);
        $json = wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if (!is_string($json)) {
            throw new \RuntimeException('Unable to encode the lockdown suspend marker.');
        }

        $this->writeAtomicJson($this->suspendMarkerPath(), $json . "\n", 'lockdown suspend marker');
    }

    /**
     * @return array<string,mixed>|null
     */
    private function suspendMarker(): ?array
    {
        $path = $this->suspendMarkerPath();
        if (!is_file($path)) {
            return null;
        }

        $json = file_get_contents($path);
        $payload = is_string($json) ? \ShieldX\Support\Json::decodeArray($json) : null;
        if (!is_array($payload)
            || !isset($payload['hmac']) || !is_scalar($payload['hmac'])
            || !isset($payload['policy']) || !is_array($payload['policy'])
            || !isset($payload['time']) || !is_scalar($payload['time'])) {
            return null;
        }

        try {
            $expected = $this->hmac($payload);
        } catch (\Throwable $error) {
            return null;
        }

        return hash_equals($expected, (string) $payload['hmac']) ? $payload : null;
    }

    private function clearSuspendMarker(): void
    {
        $path = $this->suspendMarkerPath();
        if (!is_file($path)) {
            return;
        }

        $this->filesystem->delete($path);
    }

    private function isAdminRequest(): bool
    {
        if (function_exists('is_admin') && is_admin()) {
            return true;
        }

        return in_array(basename($this->serverText('SCRIPT_NAME')), ['admin.php', 'admin-ajax.php', 'admin-post.php'], true);
    }

    private function attemptKillswitchRevert(): void
    {
        if (!$this->manifestExists()) {
            $this->clearKillswitchRetry();

            return;
        }

        $state = $this->killswitchRetryState();
        $now = time();
        if ((int) $state['failures'] >= self::KILLSWITCH_REVERT_MAX_FAILURES
            || (int) $state['retry_after'] > $now) {
            return;
        }

        $result = $this->revert(true, 'killswitch_revert');
        if (!empty($result['ok'])) {
            $this->clearKillswitchRetry();

            return;
        }

        $this->recordKillswitchRetry($state, $now);
    }

    /**
     * @return array{failures:int,retry_after:int}
     */
    private function killswitchRetryState(): array
    {
        if (!function_exists('get_transient')) {
            return ['failures' => 0, 'retry_after' => 0];
        }

        $state = get_transient(self::KILLSWITCH_REVERT_RETRY_TRANSIENT);
        if (!is_array($state)) {
            return ['failures' => 0, 'retry_after' => 0];
        }

        return [
            'failures' => isset($state['failures']) && is_scalar($state['failures']) ? max(0, (int) $state['failures']) : 0,
            'retry_after' => isset($state['retry_after']) && is_scalar($state['retry_after']) ? max(0, (int) $state['retry_after']) : 0,
        ];
    }

    /**
     * @param array{failures:int,retry_after:int} $state
     */
    private function recordKillswitchRetry(array $state, int $now): void
    {
        $failures = min(self::KILLSWITCH_REVERT_MAX_FAILURES, (int) $state['failures'] + 1);
        $next = [
            'failures' => $failures,
            'retry_after' => $now + self::KILLSWITCH_REVERT_RETRY_SECONDS,
        ];
        if (function_exists('set_transient')) {
            set_transient(self::KILLSWITCH_REVERT_RETRY_TRANSIENT, $next, self::KILLSWITCH_REVERT_RETRY_TTL);
        }

        if ($failures === self::KILLSWITCH_REVERT_MAX_FAILURES) {
            // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- A one-time generic operational error avoids retrying a failed emergency recovery on every request.
            \ShieldX\Support\ErrorLog::write('SHIELD-X Lockdown emergency revert stopped after repeated failures. Use the recovery action in the admin interface.');
        }
    }

    private function clearKillswitchRetry(): void
    {
        if (function_exists('delete_transient')) {
            delete_transient(self::KILLSWITCH_REVERT_RETRY_TRANSIENT);
        }
    }

    /**
     * @return array<string,mixed>
     */
    private function killswitchPayload(): array
    {
        return [
            'version' => 1,
            'action' => 'disable_lockdown',
            'root' => str_replace('\\', '/', $this->rootDir),
            'content_dir' => str_replace('\\', '/', $this->contentDir),
            'storage_dir' => str_replace('\\', '/', $this->storage->baseDir()),
        ];
    }

    /**
     * @return array<string,mixed>|null
     */
    private function signedKillswitchPayload(): ?array
    {
        $path = $this->killswitchPath();
        if (!is_file($path)) {
            return null;
        }

        $json = file_get_contents($path);
        if (!is_string($json) || $json === '') {
            return null;
        }

        $payload = \ShieldX\Support\Json::decodeArray($json);
        if (!is_array($payload)) {
            return null;
        }

        $hmac = isset($payload['hmac']) && is_scalar($payload['hmac']) ? (string) $payload['hmac'] : '';
        $action = isset($payload['action']) && is_scalar($payload['action']) ? (string) $payload['action'] : '';
        if ($hmac === '' || $action !== 'disable_lockdown') {
            return null;
        }

        try {
            $expected = $this->hmac($payload);
        } catch (\RuntimeException $error) {
            unset($error);
            return null;
        }

        return hash_equals($expected, $hmac) ? $payload : null;
    }

    private function rootHtaccessPath(): string
    {
        return $this->rootDir . '/.htaccess';
    }

    private function noExecHtaccessBody(): string
    {
        return <<<'HTACCESS'
# Block direct serving/execution of script files. Safe for Apache, LiteSpeed, PHP-FPM, suPHP, and LSAPI.
<IfModule mod_authz_core.c>
  <FilesMatch "\.(?i:php|phar|phtml|php[0-9]?|pht|phps|cgi|pl|py|asp|aspx|sh|exe)$">
    Require all denied
  </FilesMatch>
</IfModule>
<IfModule !mod_authz_core.c>
  <FilesMatch "\.(?i:php|phar|phtml|php[0-9]?|pht|phps|cgi|pl|py|asp|aspx|sh|exe)$">
    Order allow,deny
    Deny from all
  </FilesMatch>
</IfModule>
<IfModule mod_mime.c>
  RemoveHandler .php .phar .phtml .php3 .php4 .php5 .php7 .pht .phps .cgi .pl .py
  RemoveType    .php .phar .phtml .php3 .php4 .php5 .php7 .pht .phps
</IfModule>
HTACCESS;
    }

    /**
     * @param array<string,mixed> $policy
     */
    private function rootProtectHtaccessBody(array $policy): string
    {
        $parts = [];

        if (!empty($policy['directory_index_off'])) {
            $parts[] = <<<'HTACCESS'
# Protect sensitive WordPress internals and root files from direct web access.
<IfModule mod_rewrite.c>
  RewriteEngine On
  RewriteBase /
  RewriteRule ^wp-admin/includes/ - [F,L]
  RewriteRule !^wp-includes/ - [S=3]
  RewriteRule ^wp-includes/[^/]+\.php$ - [F,L]
  RewriteRule ^wp-includes/js/tinymce/langs/.+\.php$ - [F,L]
  RewriteRule ^wp-includes/theme-compat/ - [F,L]
</IfModule>
<FilesMatch "^(wp-config\.php|\.htaccess|\.user\.ini|debug\.log|.*\.bak|.*\.sql)$">
  <IfModule mod_authz_core.c>
    Require all denied
  </IfModule>
  <IfModule !mod_authz_core.c>
    Order allow,deny
    Deny from all
  </IfModule>
</FilesMatch>
HTACCESS;
        }

        return implode("\n\n", $parts);
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function writeMarkedBlock(string $path, string $marker, string $body, array &$manifest): void
    {
        $directory = dirname($path);
        if (!is_dir($directory)) {
            return;
        }

        if (is_link($path)) {
            throw new \RuntimeException('Refusing to write lockdown stub through symlink: ' . $path);
        }

        $block = $this->markedBlock($marker, $body);
        $exists = is_file($path);
        $content = $exists ? file_get_contents($path) : '';
        $content = is_string($content) ? $content : '';

        if (strpos($content, '# BEGIN ' . $marker) !== false) {
            if (trim($this->removeMarkerFromContent($content, $marker)) === '') {
                $manifest['files_created'][] = $path;
            } else {
                $manifest['files_modified'][] = [
                    'path' => $path,
                    'marker' => $marker,
                    'before_hash' => hash('sha256', $content),
                    'marker_preexisting' => true,
                ];
            }
            return;
        }

        $next = rtrim($content) . ($content !== '' ? "\n\n" : '') . $block . "\n";
        $write = $this->filesystem->putContents($path, $next, 0644);
        if (empty($write['ok'])) {
            throw new \RuntimeException('Unable to write lockdown stub: ' . $path);
        }

        if (!$exists) {
            $manifest['files_created'][] = $path;
            return;
        }

        $manifest['files_modified'][] = [
            'path' => $path,
            'marker' => $marker,
            'before_hash' => hash('sha256', $content),
        ];
    }

    private function markedBlock(string $marker, string $body): string
    {
        return '# BEGIN ' . $marker . "\n" . trim($body) . "\n# END " . $marker;
    }

    private function fileHasMarker(string $path, string $marker): bool
    {
        if (!is_file($path)) {
            return false;
        }

        $content = file_get_contents($path);

        return is_string($content) && strpos($content, '# BEGIN ' . $marker) !== false;
    }

    private function fileHasLockdownMarker(string $path): bool
    {
        return $this->fileHasMarker($path, self::NO_EXEC_MARKER)
            || $this->fileHasMarker($path, self::ROOT_MARKER);
    }

    private function markedBlockStatus(string $path, string $marker, string $body): string
    {
        if (!$this->fileHasMarker($path, $marker)) {
            return 'will_write';
        }

        $content = file_get_contents($path);
        if (!is_string($content)) {
            return 'will_write';
        }

        return strpos($content, $this->markedBlock($marker, $body)) !== false ? 'already_applied' : 'needs_reapply';
    }

    private function removeLockdownBlock(string $path, bool $deleteIfEmpty): bool
    {
        if (!is_file($path)) {
            return true;
        }

        if (is_link($path)) {
            return false;
        }

        $content = file_get_contents($path);
        if (!is_string($content)) {
            return false;
        }

        $content = $this->removeMarkerFromContent($content, self::NO_EXEC_MARKER);
        $content = $this->removeMarkerFromContent($content, self::ROOT_MARKER);

        if ($deleteIfEmpty && trim($content) === '') {
            $delete = $this->filesystem->delete($path);

            return !empty($delete['ok']);
        }

        $content = trim($content) === '' ? '' : rtrim(ltrim($content, "\n")) . "\n";
        $write = $this->filesystem->putContents($path, $content, 0644);

        return !empty($write['ok']);
    }

    private function removeMarkerFromContent(string $content, string $marker): string
    {
        $pattern = '/\n?# BEGIN ' . preg_quote($marker, '/') . '\R.*?# END ' . preg_quote($marker, '/') . '\R?/s';

        return (string) preg_replace($pattern, "\n", $content);
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function writeManifest(array $manifest): void
    {
        $json = wp_json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        if (!is_string($json)) {
            throw new \RuntimeException('Unable to write lockdown manifest.');
        }

        $this->writeAtomicJson($this->manifestPath(), $json . "\n", 'lockdown manifest');
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function persistManifest(array &$manifest): void
    {
        $manifest['hmac'] = $this->hmac($manifest);
        $this->writeManifest($manifest);
    }

    private function removeManifestFile(): bool
    {
        if (!$this->manifestExists()) {
            return true;
        }

        $result = $this->filesystem->delete($this->manifestPath());

        return !empty($result['ok']) && !$this->manifestExists();
    }

    private function writeAtomicJson(string $path, string $contents, string $label): void
    {
        $this->ensureLockdownDirectory();
        try {
            $suffix = bin2hex(random_bytes(8));
        } catch (\Throwable $error) {
            throw new \RuntimeException('Unable to create a temporary ' . $label . ' path.');
        }

        $temporary = $path . '.tmp-' . $suffix;
        $written = file_put_contents($temporary, $contents, LOCK_EX);
        if ($written === false || !rename($temporary, $path)) {
            if (is_file($temporary)) {
                @unlink($temporary);
            }

            throw new \RuntimeException('Unable to write ' . $label . '.');
        }

        clearstatcache(true, $path);
    }

    /**
     * @param array<string,mixed> $manifest
     */
    private function hmac(array $manifest): string
    {
        unset($manifest['hmac'], $manifest['hmac_valid']);
        $json = wp_json_encode($manifest, JSON_UNESCAPED_SLASHES);
        $secret = function_exists('wp_salt') ? (string) wp_salt('auth') : '';
        if ($secret === '') {
            throw new \RuntimeException('WordPress authentication salts are required for SHIELD-X lockdown manifests.');
        }

        return hash_hmac('sha256', is_string($json) ? $json : '', $secret);
    }

    private function permission(string $path): string
    {
        $perms = file_exists($path) ? fileperms($path) : false;

        return $perms === false ? '' : substr(sprintf('%o', $perms), -4);
    }

    private function isDirectCoreAccess(string $path): bool
    {
        return strpos($path, '/wp-admin/includes/') !== false
            || preg_match('#/wp-includes/[^/]+\.php$#i', $path) === 1
            || preg_match('#/wp-includes/js/tinymce/langs/.+\.php$#i', $path) === 1
            || strpos($path, '/wp-includes/theme-compat/') !== false;
    }

    private function matchesRelativePathPrefix(string $path, string $relativePrefix): bool
    {
        $path = ltrim(strtolower(str_replace('\\', '/', $path)), '/');
        $relativePrefix = trim(strtolower(str_replace('\\', '/', $relativePrefix)), '/');
        if ($relativePrefix === '') {
            return false;
        }

        $base = $this->wordPressBasePath();
        if ($base !== '' && strpos($path, $base . '/') === 0) {
            $path = substr($path, strlen($base) + 1);
        }

        return $path === $relativePrefix || strpos($path, $relativePrefix . '/') === 0;
    }

    private function wordPressBasePath(): string
    {
        if (!function_exists('home_url')) {
            return '';
        }

        $parts = function_exists('wp_parse_url') ? wp_parse_url((string) home_url('/')) : [];
        $path = is_array($parts) && isset($parts['path']) ? $parts['path'] : '';
        if (!is_string($path) || $path === '' || $path === '/') {
            return '';
        }

        return trim(strtolower(str_replace('\\', '/', $path)), '/');
    }

    private function relativeToRoot(string $path): string
    {
        return $this->relativeTo($path, $this->rootDir);
    }

    private function relativeToContent(string $path): string
    {
        return $this->relativeTo($path, $this->contentDir);
    }

    private function relativeTo(string $path, string $base): string
    {
        $path = $this->normalizeAbsolute($path);
        $base = rtrim($this->normalizeAbsolute($base), '/') . '/';

        return strpos($path . '/', $base) === 0 ? substr($path, strlen($base)) : basename($path);
    }

    private function normalizeAbsolute(string $path): string
    {
        $path = str_replace('\\', '/', $path);
        $path = rtrim($path, '/');

        return $path !== '' ? $path : '/';
    }
}
