<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner;

use SiteFort\Core\Module;
use SiteFort\Includes\Capabilities;
use SiteFort\Includes\FilesystemMutation;

if ( ! defined( 'ABSPATH' ) ) { exit; }


/**
 * Main module class for the Scanner feature.
 */
class Scanner extends Module {
    private const INLINE_DIFF_MAX_BYTES = 1048576; // 1MB
    private const VIEWER_MAX_BYTES = 8388608; // 8MB
    private const ACCESS_STATE_TRANSIENT = 'sitefort_scanner_access_state';
    private const INLINE_SCAN_STALE_SECONDS = 45;

    /**
     * @var ScanController
     */
    private $controller;

    /**
     * Lazily build and memoize the scan controller so front-end requests
     * that never scan do not pay for it.
     *
     * @return ScanController
     */
    private function get_controller(): ScanController {
        if ( null === $this->controller ) {
            $this->controller = ScanController::build_default_controller();
        }
        return $this->controller;
    }

    public function run(): void {
        require_once __DIR__ . '/ScanController.php';
        require_once __DIR__ . '/ScanTypes.php';

        require_once __DIR__ . '/Queue/ScanQueue.php';
        require_once __DIR__ . '/Queue/ScanWorker.php';
        require_once __DIR__ . '/Queue/ScanCron.php';
        require_once __DIR__ . '/Queue/WatchdogService.php';
        require_once __DIR__ . '/Queue/TaskScheduler.php';
        require_once __DIR__ . '/Queue/TaskRunner.php';

        Queue\ScanWorker::register();
        Queue\ScanCron::init();
        Queue\WatchdogService::init();
        require_once __DIR__ . '/API/SiteKeyReceiver.php';
        API\SiteKeyReceiver::register();
        require_once __DIR__ . '/API/ScannerWakeupReceiver.php';
        API\ScannerWakeupReceiver::register();

        if ( ! defined( 'SITEFORT_ENABLE_INBOUND_C2' ) || SITEFORT_ENABLE_INBOUND_C2 ) {
            require_once __DIR__ . '/API/CommandReceiver.php';
            API\CommandReceiver::register();
        }

        // Register Logger
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
             require_once __DIR__ . '/Service/ScanLogger.php';
        }
        Service\ScanLogger::register();

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\QuarantineCleanupService' ) ) {
            require_once __DIR__ . '/Service/QuarantineCleanupService.php';
        }
        Service\QuarantineCleanupService::register();

        // Cache invalidation hooks for checksum updates
        // When a plugin/theme is updated, its checksum cache must be cleared to prevent false positives
        add_action( 'upgrader_process_complete', [ $this, 'invalidate_checksum_caches' ], 10, 2 );
        add_action( 'activated_plugin', [ $this, 'invalidate_plugin_checksum' ] );
        add_action( 'switch_theme', [ $this, 'invalidate_theme_checksum' ] );

        add_action( 'wp_ajax_sitefort_start_async_scan', [ $this, 'handle_start_async_scan' ] );
        add_action( 'wp_ajax_sitefort_get_scan_status', [ $this, 'handle_get_scan_status' ] );
        add_action( 'wp_ajax_sitefort_get_status_events', [ $this, 'handle_get_status_events' ] );
        add_action( 'wp_ajax_sitefort_stop_scan', [ $this, 'handle_stop_scan' ] );
        add_action( 'wp_ajax_sitefort_get_scan_types', [ $this, 'handle_get_scan_types' ] );
        add_action( 'wp_ajax_sitefort_save_scanner_settings', [ $this, 'handle_save_scanner_settings' ] );
        add_action( 'wp_ajax_sitefort_get_scanner_settings', [ $this, 'handle_get_scanner_settings' ] );
        add_action( 'wp_ajax_sitefort_check_scanner_access', [ $this, 'handle_check_scanner_access' ] );
        add_action( 'wp_ajax_sitefort_handle_scan_action', [ $this, 'handle_scan_action' ] );
        add_action( 'wp_ajax_sitefort_handle_scan_bulk_action', [ $this, 'handle_scan_bulk_action' ] );
        add_action( 'wp_ajax_sitefort_get_quarantine_items', [ $this, 'handle_get_quarantine_items' ] );
        add_action( 'wp_ajax_sitefort_restore_quarantine_item', [ $this, 'handle_restore_quarantine_item' ] );
        add_action( 'wp_ajax_sitefort_purge_quarantine_item', [ $this, 'handle_purge_quarantine_item' ] );
        add_action( 'wp_ajax_sitefort_purge_all_quarantine_items', [ $this, 'handle_purge_all_quarantine_items' ] );
        add_action( 'wp_ajax_sitefort_download_quarantine_item', [ $this, 'handle_download_quarantine_item' ] );
        add_action( 'wp_ajax_sitefort_get_file_diff', [ $this, 'handle_get_file_diff' ] );
        add_action( 'wp_ajax_sitefort_view_file_content', [ $this, 'handle_view_file_content' ] );

        add_action( 'admin_init', [ $this, 'scanner_page_heartbeat' ] );
    }

    /**
     * Scanner page heartbeat.
     */
    public function scanner_page_heartbeat() {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only current page routing check.
        $page = sanitize_text_field( $this->read_input_scalar_payload( INPUT_GET, 'page' ) );
        if ( $page !== 'sitefort-scanner' ) {
            return;
        }

        if ( wp_doing_ajax() ) {
            return;
        }

        $this->nudge_active_scan_scheduler();

        if ( Queue\ScanQueue::is_active() && Queue\ScanQueue::is_stuck( self::INLINE_SCAN_STALE_SECONDS ) ) {
            $stamp_key = 'sitefort_scanner_page_stuck_observed';
            if ( ! get_transient( $stamp_key ) ) {
                if ( class_exists( '\\SiteFort\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
                    Service\ScanLogger::log( 'Watchdog telemetry: scanner page observed stalled scan; awaiting background recovery.' );
                }
                set_transient( $stamp_key, time(), MINUTE_IN_SECONDS );
            }
        }
    }

    /**
     * Wake scanner queue processing from admin traffic.
     */
    private function nudge_active_scan_scheduler(): void {
        $queue = Queue\ScanQueue::get();
        if ( ! is_array( $queue ) || ! in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true ) ) {
            return;
        }

        $scan_id = (string) ( $queue['id'] ?? '' );
        if ( $scan_id === '' ) {
            return;
        }

        if ( $this->should_run_inline_scan_task( $queue, $scan_id ) ) {
            $this->run_inline_scan_task( $scan_id );
            return;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Queue\\TaskScheduler' ) ) {
            require_once __DIR__ . '/Queue/TaskScheduler.php';
        }

        Queue\TaskScheduler::ensure_progress( $scan_id );
    }

    /**
     * Clear or resume a stuck active scan before start attempts.
     *
     * @return array<string,mixed>|null Active queue after recovery, or null when no active queue remains.
     */
    private function recover_start_blocking_scan(): ?array {
        $queue = Queue\ScanQueue::get();
        if ( ! is_array( $queue ) || ! in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true ) ) {
            return null;
        }

        $scan_id = (string) ( $queue['id'] ?? '' );
        if ( $scan_id === '' ) {
            return null;
        }

        $this->nudge_active_scan_scheduler();

        $queue = Queue\ScanQueue::get();
        if ( ! is_array( $queue ) || ! in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true ) ) {
            return null;
        }

        if ( (string) ( $queue['id'] ?? '' ) !== $scan_id ) {
            return $queue;
        }

        if ( ! Queue\ScanQueue::is_stuck( 120 ) ) {
            return $queue;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\TaskQueueRepository' ) ) {
            require_once __DIR__ . '/Repository/TaskQueueRepository.php';
        }

        $repo = new Repository\TaskQueueRepository();
        $repo->release_expired_leases( $scan_id );

        if ( $repo->has_available_work( $scan_id ) ) {
            $this->run_inline_scan_task( $scan_id, false );
            $queue = Queue\ScanQueue::get();
            if ( is_array( $queue ) && in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true ) ) {
                return $queue;
            }

            return null;
        }

        if ( $repo->has_pending_work( $scan_id ) ) {
            return $queue;
        }

        Queue\ScanQueue::fail(
            'The previous scan stalled before background processing could continue. Start a new scan to retry.',
            [
                'error_code'        => 'scan_stalled',
                'stage'             => 'queue_recovery',
                'retryable'         => true,
                'status_code'       => 409,
                'technical_message' => 'Active scan row had no runnable or pending tasks after stale-lease recovery.',
            ]
        );
        Queue\ScanQueue::cleanup_task_queue( $scan_id );

        return null;
    }

    /**
     * Determine whether admin traffic should execute one queued task inline.
     *
     * @param array<string,mixed> $queue
     * @param string              $scan_id
     * @return bool
     */
    private function should_run_inline_scan_task( array $queue, string $scan_id ): bool {
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\TaskQueueRepository' ) ) {
            require_once __DIR__ . '/Repository/TaskQueueRepository.php';
        }

        $repo = new Repository\TaskQueueRepository();
        if ( ! $repo->has_available_work( $scan_id ) ) {
            return false;
        }

        $now = time();
        $last_ping = (int) ( $queue['last_ping'] ?? 0 );
        $last_dispatch = (int) ( $queue['last_dispatch_at'] ?? 0 );
        $stale_after = $this->inline_scan_stale_seconds();

        if ( $last_dispatch > 0 && $last_dispatch >= $last_ping && ( $now - $last_dispatch ) >= $stale_after ) {
            return true;
        }

        return $last_ping <= 0 || ( $now - $last_ping ) >= $stale_after;
    }

    /**
     * Shorter stale gate once the worker is known dead; polling is the only
     * engine left at that point.
     */
    private function inline_scan_stale_seconds(): int {
        return $this->is_worker_not_responding() ? 15 : self::INLINE_SCAN_STALE_SECONDS;
    }

    private function is_worker_not_responding(): bool {
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Queue\\WorkerLiveness' ) ) {
            require_once __DIR__ . '/Queue/WorkerLiveness.php';
        }

        return Queue\WorkerLiveness::is_tripped();
    }

    /**
     * Lightweight worker transport/health block for the scan status payload.
     *
     * @param string $scan_id
     * @return array<string,mixed>
     */
    private function get_worker_health_payload( string $scan_id ): array {
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Queue\\WorkerRuntime' ) ) {
            require_once __DIR__ . '/Queue/WorkerRuntime.php';
        }

        return [
            'transport'              => Queue\WorkerRuntime::current( $scan_id )->get_transport(),
            'not_responding'         => $this->is_worker_not_responding(),
            'inline_fallback_active' => false !== get_transient( 'sitefort_inline_fallback_active_' . md5( $scan_id ) ),
        ];
    }

    /**
     * Run one leased scanner task from an admin request.
     *
     * @param string $scan_id
     * @return void
     */
    private function run_inline_scan_task( string $scan_id, bool $log_fallback_message = true ): void {
        $lock_key = 'sitefort_inline_scan_task_' . md5( $scan_id );
        if ( get_transient( $lock_key ) ) {
            return;
        }

        // Once the worker is confirmed not responding, run a short bounded
        // tick instead of a single task.
        $boosted   = $this->is_worker_not_responding();
        $max_tasks = $boosted ? 3 : 1;
        $budget    = $boosted ? 8.0 : 0.0;

        set_transient( $lock_key, 1, $boosted ? 15 : 5 );

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Queue\\TaskRunner' ) ) {
            require_once __DIR__ . '/Queue/TaskRunner.php';
        }

        try {
            $runner    = new Queue\TaskRunner();
            $started   = microtime( true );
            $completed = 0;

            do {
                $result = $runner->run( $scan_id );
                if ( ! is_array( $result ) || (string) ( $result['status'] ?? '' ) !== 'completed' ) {
                    break;
                }

                $completed++;
            } while (
                $completed < $max_tasks &&
                ( microtime( true ) - $started ) < $budget &&
                ! empty( $result['available_work'] )
            );

            if ( $completed > 0 ) {
                set_transient( 'sitefort_inline_fallback_active_' . md5( $scan_id ), time(), 2 * MINUTE_IN_SECONDS );
            }

            if (
                $log_fallback_message &&
                $completed > 0 &&
                class_exists( '\\SiteFort\\Modules\\Scanner\\Service\\ScanLogger' )
            ) {
                $log_key = 'sitefort_inline_scan_task_logged_' . md5( $scan_id );
                if ( ! get_transient( $log_key ) ) {
                    Service\ScanLogger::log(
                        1 === $completed
                            ? 'Background scanner worker did not heartbeat; admin status polling executed one scanner task.'
                            : sprintf( 'Background scanner worker did not heartbeat; admin status polling executed %d scanner tasks.', $completed )
                    );
                    set_transient( $log_key, 1, MINUTE_IN_SECONDS );
                }
            }
        } catch ( \Throwable $e ) {
            if ( class_exists( '\\SiteFort\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
                Service\ScanLogger::log(
                    'Inline scanner task fallback failed: ' . sanitize_text_field( $e->getMessage() ),
                    3,
                    'error'
                );
            }
        } finally {
            delete_transient( $lock_key );
        }
    }

    /**
     * Get file diff (local file vs clean baseline content).
     */
    public function handle_get_file_diff() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $item_id      = sanitize_text_field( $this->read_post_scalar_payload( 'item_id' ) );
        $item_hash    = sanitize_text_field( $this->read_post_scalar_payload( 'item_hash' ) );
        $item_scan_id = sanitize_text_field( $this->read_post_scalar_payload( 'item_scan_id' ) );
        $type         = sanitize_text_field( $this->read_post_scalar_payload( 'item_type' ) );
        $slug         = sanitize_text_field( $this->read_post_scalar_payload( 'slug' ) );
        $version      = sanitize_text_field( $this->read_post_scalar_payload( 'version' ) );

        if ( ! $item_id && ! $item_hash ) {
            wp_send_json_error( [ 'message' => 'Missing file target' ] );
            return;
        }

        $resolved = $this->resolve_scan_issue_file_target( $item_id, $item_hash, $item_scan_id, true );
        if ( is_wp_error( $resolved ) ) {
            wp_send_json_error( [ 'message' => $resolved->get_error_message() ] );
            return;
        }
        $tracked_issue_row = isset( $resolved['issue_row'] ) && is_array( $resolved['issue_row'] ) ? $resolved['issue_row'] : null;

        $local_path = $resolved['absolute_path'];
        $relative_path = $resolved['relative_path'];

        // Redact wp-config.php contents (contains secrets).
        if ( basename( $local_path ) === 'wp-config.php' ) {
            wp_send_json_error( [ 'message' => 'Viewing wp-config.php content is not allowed for security reasons.' ] );
            return;
        }

        if ( ! is_readable( $local_path ) ) {
            wp_send_json_error( [ 'message' => 'Local file is not readable.' ] );
            return;
        }

        $file_size = filesize( $local_path );
        if ( $file_size !== false && $file_size > self::INLINE_DIFF_MAX_BYTES ) {
            wp_send_json_success( [
                'mode'         => 'fallback',
                'too_large'    => true,
                'message'      => 'File is too large for in-app diff. Open it in a browser tab.',
                'fallback_url' => $this->build_file_viewer_fallback_url( $relative_path, $item_hash, $item_scan_id ),
            ] );
            return;
        }

        $local_content = file_get_contents( $local_path );
        if ( $local_content === false ) {
            wp_send_json_error( [ 'message' => 'Failed to read local file content.' ] );
            return;
        }
        if ( function_exists( 'wp_check_invalid_utf8' ) ) {
            $local_content = wp_check_invalid_utf8( $local_content, true );
        }

        require_once dirname( __FILE__ ) . '/Service/RepairService.php';
        $repair_service = new \SiteFort\Modules\Scanner\Service\RepairService();
        $stored_locale = '';
        $stored_version = '';
        if ( is_array( $tracked_issue_row ) && isset( $tracked_issue_row['data'] ) && is_array( $tracked_issue_row['data'] ) ) {
            $stored_locale = (string) ( $tracked_issue_row['data']['locale'] ?? '' );
            $stored_version = (string) ( $tracked_issue_row['data']['version'] ?? '' );
        }
        $original_content = $repair_service->get_clean_content(
            $relative_path,
            $type,
            $slug,
            $stored_version !== '' ? $stored_version : $version,
            $stored_locale
        );

        if ( is_wp_error( $original_content ) ) {
            $view_only_error_codes = [ 'not_supported', 'invalid_type', 'invalid_context', 'cloud_source_missing' ];
            $error_code = (string) $original_content->get_error_code();

            if ( in_array( $error_code, $view_only_error_codes, true ) ) {
                $original_content = '';
            } else {
                $error_message = (string) $original_content->get_error_message();
                if ( $error_message === '' ) {
                    $error_message = 'Could not fetch original version. Please try again later.';
                }
                wp_send_json_error( [ 'message' => $error_message ] );
                return;
            }
        }

        if ( strlen( $local_content ) > self::INLINE_DIFF_MAX_BYTES || strlen( $original_content ) > self::INLINE_DIFF_MAX_BYTES ) {
            wp_send_json_success( [
                'mode'         => 'fallback',
                'too_large'    => true,
                'message'      => 'Diff payload is too large for in-app rendering. Open file in browser tab.',
                'fallback_url' => $this->build_file_viewer_fallback_url( $relative_path, $item_hash, $item_scan_id ),
            ] );
            return;
        }

        wp_send_json_success( [
            'local'    => $local_content,
            'original' => $original_content,
        ] );
    }

    /**
     * Secure fallback viewer for large files.
     * Streams plain text in a new browser tab for authenticated admins.
     */
    public function handle_view_file_content() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_die( 'Unauthorized', 403 );
        }

        $item_id      = sanitize_text_field( $this->read_input_scalar_payload( INPUT_GET, 'item_id' ) );
        $item_hash    = sanitize_text_field( $this->read_input_scalar_payload( INPUT_GET, 'item_hash' ) );
        $item_scan_id = sanitize_text_field( $this->read_input_scalar_payload( INPUT_GET, 'item_scan_id' ) );
        if ( $item_id === '' && $item_hash === '' ) {
            wp_die( 'Missing file target.', 400 );
        }

        $resolved = $this->resolve_scan_issue_file_target( $item_id, $item_hash, $item_scan_id, true );
        if ( is_wp_error( $resolved ) ) {
            wp_die( esc_html( $resolved->get_error_message() ), 400 );
        }

        $absolute_path = $resolved['absolute_path'];
        $relative_path = $resolved['relative_path'];

        if ( basename( $absolute_path ) === 'wp-config.php' ) {
            wp_die( 'Viewing wp-config.php is blocked for security reasons.', 403 );
        }
        if ( ! is_readable( $absolute_path ) ) {
            wp_die( 'File is not readable.', 404 );
        }

        $file_size = filesize( $absolute_path );
        if ( $file_size !== false && $file_size > self::VIEWER_MAX_BYTES ) {
            wp_die(
                sprintf(
                    'File exceeds viewer limit (%d MB).',
                    (int) ( self::VIEWER_MAX_BYTES / ( 1024 * 1024 ) )
                ),
                413
            );
        }

        nocache_headers();
        header( 'Content-Type: text/plain; charset=utf-8' );
        header( 'X-Content-Type-Options: nosniff' );
        header( 'Content-Disposition: inline; filename="' . sanitize_file_name( basename( $relative_path ) ) . '"' );

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a verified local file requires a binary read handle.
        $handle = fopen( $absolute_path, 'rb' );
        if ( ! $handle ) {
            wp_die( 'Failed to open file stream.', 500 );
        }

        while ( ! feof( $handle ) ) {
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Streaming a verified local file requires incremental reads.
            $chunk = fread( $handle, 8192 );
            if ( $chunk === false ) {
                break;
            }
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Streaming raw file contents to a text/plain response.
            echo $chunk;
            if ( connection_aborted() ) {
                break;
            }
        }
        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing the streaming read handle after response completion.
        fclose( $handle );
        exit;
    }

    /**
     * Handle actions on scan findings (delete, ignore, repair, etc.)
     */
    public function handle_scan_action() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $request = $this->parse_scan_action_request( $this->read_scan_action_post_source() );
        if ( $request['action'] === '' || ( $request['item_hash'] === '' && $request['item_id'] === '' ) ) {
            wp_send_json_error( [ 'message' => 'Missing parameters' ] );
            return;
        }

        $result = $this->execute_scan_action_request( $request );
        if ( ! empty( $result['success'] ) ) {
            $extra = isset( $result['extra'] ) && is_array( $result['extra'] ) ? $result['extra'] : [];
            wp_send_json_success( array_merge( [ 'message' => (string) ( $result['message'] ?? 'Action completed successfully.' ) ], $extra ) );
            return;
        }

        $message = (string) ( $result['message'] ?? '' );
        if ( $message === '' ) {
            $message = $request['action'] === 'delete'
                ? 'Delete failed due to an unknown file-system error.'
                : 'Action failed. Please retry.';
        }

        wp_send_json_error( [ 'message' => $message ] );
    }

    public function handle_scan_bulk_action() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $common = $this->parse_scan_action_request( $this->read_scan_action_post_source() );
        if ( $common['action'] === '' ) {
            wp_send_json_error( [ 'message' => 'Missing action.' ] );
            return;
        }

        $raw_items = $this->read_post_json_payload( 'items', '[]' );
        $items     = $this->decode_json_array_payload( $raw_items );
        if ( null === $items || empty( $items ) ) {
            wp_send_json_error( [ 'message' => 'No remediation targets were supplied.' ] );
            return;
        }

        $results = [];
        $success_count = 0;
        $fail_count = 0;
        $issue_state_changed = false;

        foreach ( array_slice( array_values( $items ), 0, 100 ) as $raw_item ) {
            if ( ! is_array( $raw_item ) ) {
                continue;
            }

            $request = array_merge(
                $this->parse_scan_action_request( $raw_item ),
                [
                    'action'           => $common['action'],
                    'quarantine_copy'  => $common['quarantine_copy'],
                ]
            );

            $result = $this->execute_scan_action_request( $request, true );
            $results[] = [
                'item_hash' => (string) ( $result['item_hash'] ?? $request['item_hash'] ),
                'success'   => ! empty( $result['success'] ),
                'message'   => (string) ( $result['message'] ?? '' ),
                'data'      => isset( $result['extra'] ) && is_array( $result['extra'] ) ? $result['extra'] : [],
            ];

            if ( ! empty( $result['success'] ) ) {
                $success_count++;
            } else {
                $fail_count++;
            }

            if ( ! empty( $result['issue_state_changed'] ) ) {
                $issue_state_changed = true;
            }
        }

        if ( $issue_state_changed ) {
            ScanController::emit_issue_state_change( $common['action'], '' );
        }

        wp_send_json_success(
            [
                'message'       => sprintf(
                    'Bulk remediation completed: %d succeeded, %d failed.',
                    $success_count,
                    $fail_count
                ),
                'success_count' => $success_count,
                'fail_count'    => $fail_count,
                'results'       => $results,
            ]
        );
    }

    private function parse_scan_action_request( array $source ): array {
        return [
            'action'           => $this->normalize_scan_action( sanitize_text_field( (string) ( $source['scan_action'] ?? '' ) ) ),
            'item_id'          => sanitize_text_field( (string) ( $source['item_id'] ?? '' ) ),
            'item_type'        => sanitize_text_field( (string) ( $source['item_type'] ?? '' ) ),
            'item_hash'        => sanitize_text_field( (string) ( $source['item_hash'] ?? '' ) ),
            'item_scan_id'     => sanitize_text_field( (string) ( $source['item_scan_id'] ?? '' ) ),
            'slug'             => sanitize_text_field( (string) ( $source['slug'] ?? '' ) ),
            'version'          => sanitize_text_field( (string) ( $source['version'] ?? '' ) ),
            'quarantine_copy'  => $this->normalize_request_boolean( $source['quarantine_copy'] ?? false ),
        ];
    }

    /**
     * @return array<string,string>
     */
    private function read_scan_action_post_source(): array {
        $source = [];
        foreach ( [ 'scan_action', 'item_id', 'item_type', 'item_hash', 'item_scan_id', 'slug', 'version', 'quarantine_copy' ] as $key ) {
            $source[ $key ] = $this->read_post_scalar_payload( $key );
        }

        return $source;
    }

    private function read_post_scalar_payload( string $key ): string {
        return $this->read_input_scalar_payload( INPUT_POST, $key );
    }

    private function read_request_scalar_payload( string $key ): string {
        if ( filter_has_var( INPUT_POST, $key ) ) {
            return $this->read_input_scalar_payload( INPUT_POST, $key );
        }

        return $this->read_input_scalar_payload( INPUT_GET, $key );
    }

    private function read_input_scalar_payload( int $type, string $key ): string {
        $value = filter_input(
            $type,
            $key,
            FILTER_CALLBACK,
            [
                'options' => [ self::class, 'sanitize_ajax_scalar_payload' ],
                'flags'   => FILTER_REQUIRE_SCALAR,
            ]
        );

        return is_string( $value ) ? $value : '';
    }

    private function read_post_json_payload( string $key, string $default ): string {
        if ( ! filter_has_var( INPUT_POST, $key ) ) {
            return $default;
        }

        $value = filter_input(
            INPUT_POST,
            $key,
            FILTER_CALLBACK,
            [
                'options' => [ self::class, 'sanitize_json_payload' ],
                'flags'   => FILTER_REQUIRE_SCALAR,
            ]
        );

        return is_string( $value ) ? $value : '';
    }

    /**
     * @param mixed $value
     */
    public static function sanitize_ajax_scalar_payload( $value ): string {
        return is_scalar( $value ) ? str_replace( "\0", '', (string) $value ) : '';
    }

    /**
     * JSON is decoded and schema-normalized after this read; do not text-sanitize
     * here because that would corrupt quotes, slashes, and escaped characters.
     *
     * @param mixed $value
     */
    public static function sanitize_json_payload( $value ): string {
        return is_scalar( $value ) ? str_replace( "\0", '', (string) $value ) : '';
    }

    /**
     * Decode an AJAX JSON object/list and sanitize the decoded structure before
     * field-specific scanners/settings normalization runs.
     *
     * @return array<mixed>|null
     */
    private function decode_json_array_payload( string $raw ): ?array {
        $decoded = json_decode( $raw, true );
        if ( ! is_array( $decoded ) ) {
            return null;
        }

        return self::sanitize_decoded_json_payload( $decoded );
    }

    /**
     * Recursively sanitize decoded JSON keys and scalar string values without
     * corrupting paths, URLs, or multiline scanner exclusions.
     *
     * @param mixed $value
     * @return mixed
     */
    private static function sanitize_decoded_json_payload( $value ) {
        if ( is_array( $value ) ) {
            $sanitized = [];
            foreach ( $value as $key => $item ) {
                $clean_key = is_int( $key )
                    ? $key
                    : (string) preg_replace( '/[\x00-\x1F\x7F]/', '', sanitize_text_field( wp_check_invalid_utf8( (string) $key ) ) );
                if ( ! is_int( $key ) && '' === $clean_key ) {
                    continue;
                }

                $sanitized[ $clean_key ] = self::sanitize_decoded_json_payload( $item );
            }

            return $sanitized;
        }

        if ( is_string( $value ) ) {
            return str_replace( "\0", '', wp_check_invalid_utf8( $value ) );
        }

        return is_scalar( $value ) || null === $value ? $value : null;
    }

    private function normalize_scan_action( string $action ): string {
        $action = sanitize_key( $action );
        $action_aliases = [
            'update_plugin'  => 'update',
            'update_theme'   => 'update',
            'protect_access' => 'protect',
            'mark_fixed'     => 'fixed',
            'fix'            => 'fixed',
        ];

        return $action_aliases[ $action ] ?? $action;
    }

    private function normalize_request_boolean( $value ): bool {
        if ( is_bool( $value ) ) {
            return $value;
        }

        if ( is_numeric( $value ) ) {
            return (int) $value === 1;
        }

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

        return in_array( $normalized, [ '1', 'true', 'yes', 'on' ], true );
    }

    /**
     * @param array<string,mixed> $request
     * @return array<string,mixed>
     */
    private function execute_scan_action_request( array $request, bool $defer_state_broadcast = false ): array {
        $action = (string) ( $request['action'] ?? '' );
        $item_id = (string) ( $request['item_id'] ?? '' );
        $item_type = (string) ( $request['item_type'] ?? '' );
        $item_hash = (string) ( $request['item_hash'] ?? '' );
        $item_scan_id = (string) ( $request['item_scan_id'] ?? '' );
        $slug = (string) ( $request['slug'] ?? '' );
        $version = (string) ( $request['version'] ?? '' );
        $quarantine_copy = ! empty( $request['quarantine_copy'] );

        $success = false;
        $message = '';
        $extra = [];
        $issue_state_changed = false;
        $tracked_issue_row = null;
        $resolved_item_hash = $item_hash;
        $audit_event = '';
        $audit_details = [];
        $audit_severity = 'info';

        if ( $action === '' || ( $item_hash === '' && $item_id === '' ) ) {
            return [
                'success'             => false,
                'message'             => 'Missing parameters',
                'extra'               => [],
                'item_hash'           => $item_hash,
                'issue_state_changed' => false,
            ];
        }

        switch ( $action ) {
            case 'protect':
                $resolved = $this->resolve_scan_issue_file_target( $item_id, $item_hash, $item_scan_id, true );
                if ( is_wp_error( $resolved ) ) {
                    if ( $resolved->get_error_code() === 'file_not_found' ) {
                        $success = true;
                        $message = 'File not found. Finding removed.';
                    } else {
                        $message = $resolved->get_error_message();
                    }
                    break;
                }

                $tracked_issue_row = isset( $resolved['issue_row'] ) && is_array( $resolved['issue_row'] ) ? $resolved['issue_row'] : null;
                $resolved_item_hash = $this->resolve_issue_hash( $item_hash, $tracked_issue_row );

                $protect = $this->protect_sensitive_file_access( (string) $resolved['absolute_path'], (string) $resolved['relative_path'] );
                if ( is_wp_error( $protect ) ) {
                    if ( $protect->get_error_code() === 'file_missing' ) {
                        $success = true;
                        $message = 'File not found. Finding removed.';
                    } else {
                        $message = $protect->get_error_message();
                    }
                    break;
                }

                $success = true;
                $message = sprintf( 'Public access blocked for %s.', (string) $resolved['relative_path'] );
                break;

            case 'delete':
                $resolved = $this->resolve_scan_issue_file_target( $item_id, $item_hash, $item_scan_id, false );
                if ( is_wp_error( $resolved ) ) {
                    $message = $resolved->get_error_message();
                    break;
                }

                $tracked_issue_row = isset( $resolved['issue_row'] ) && is_array( $resolved['issue_row'] ) ? $resolved['issue_row'] : null;
                $resolved_item_hash = $this->resolve_issue_hash( $item_hash, $tracked_issue_row );

                if ( is_array( $tracked_issue_row ) && ! $this->allowsDeleteActionForIssue( $tracked_issue_row ) ) {
                    $message = 'Delete is not available for this finding. Review the file manually or use an allowed remediation action.';
                    break;
                }

                $relative_path = ltrim( wp_normalize_path( (string) $resolved['relative_path'] ), '/' );
                $protection_tier = $this->get_file_protection_tier( $relative_path );
                if ( $protection_tier === 'locked' ) {
                    $message = 'This file contains your database credentials and cannot be deleted here. Please clean it manually using your hosting file manager or an FTP/SSH client.';
                    break;
                }
                if ( ! Capabilities::can_modify_scanner_file( $relative_path ) ) {
                    $message = $this->get_file_mutation_denied_message( $relative_path, '', 'delete' );
                    break;
                }
                if ( is_array( $tracked_issue_row ) && $this->requires_quarantine_for_delete_issue( $tracked_issue_row ) ) {
                    $quarantine_copy = true;
                }

                $file_path = (string) $resolved['absolute_path'];
                if ( ! file_exists( $file_path ) ) {
                    $success = true;
                    $message = 'File not found. Finding removed.';
                    $audit_event = 'scan_action_delete';
                    $audit_details = [ 'result' => 'missing' ];
                    break;
                }

                $real_path = realpath( $file_path );
                $wp_root = realpath( ABSPATH );

                if ( false === $real_path ) {
                    $message = 'Delete failed: unable to resolve file path.';
                    break;
                }
                if ( false === $wp_root ) {
                    $message = 'Delete failed: WordPress root path could not be resolved.';
                    break;
                }
                if ( strpos( $real_path, $wp_root ) !== 0 ) {
                    $message = 'Security Check Failed: File is outside WordPress directory.';
                    break;
                }

                $quarantine_item = null;
                if ( $quarantine_copy && is_array( $tracked_issue_row ) && $this->allows_quarantine_option_for_issue( $tracked_issue_row, 'delete' ) ) {
                    $quarantine_item = $this->get_quarantine_service()->quarantine_for_delete(
                        $tracked_issue_row,
                        [
                            'absolute_path' => wp_normalize_path( $real_path ),
                            'relative_path' => $relative_path,
                        ],
                        get_current_user_id()
                    );
                    if ( is_wp_error( $quarantine_item ) ) {
                        $message = $quarantine_item->get_error_message();
                        break;
                    }
                }

                if ( ! $this->delete_verified_file( $real_path ) ) {
                    if ( is_array( $quarantine_item ) ) {
                        $this->get_quarantine_service()->discard_item( (int) ( $quarantine_item['id'] ?? 0 ) );
                    }
                    $message = 'Failed to delete file. Please check file permissions.';
                    break;
                }

                if ( is_array( $quarantine_item ) ) {
                    $activated_item = $this->get_quarantine_service()->activate_item( (int) ( $quarantine_item['id'] ?? 0 ) );
                    if ( is_wp_error( $activated_item ) ) {
                        $this->get_quarantine_service()->discard_item( (int) ( $quarantine_item['id'] ?? 0 ) );
                        $quarantine_item = null;
                    } else {
                        $quarantine_item = $activated_item;
                    }
                }

                $success = true;
                if ( is_array( $quarantine_item ) ) {
                    $message = 'File quarantined and deleted successfully.';
                    $extra = [
                        'quarantine_item_id' => (int) ( $quarantine_item['id'] ?? 0 ),
                        'quarantine_mode'    => 'delete_quarantine',
                    ];
                    $audit_event = 'scan_action_quarantine';
                    $audit_details = [
                        'quarantine_item_id' => (int) ( $quarantine_item['id'] ?? 0 ),
                        'quarantine_mode'    => 'delete_quarantine',
                        'expires_at'         => (int) ( $quarantine_item['expires_at'] ?? 0 ),
                    ];
                } else {
                    $message = 'File deleted successfully.';
                    $audit_event = 'scan_action_delete';
                }
                break;

            case 'ignore':
                $success = true;
                $message = 'Item ignored. It will not appear in active issues.';
                $issue_state_changed = $item_hash !== '';
                break;

            case 'unignore':
                $success = true;
                $message = 'Item unignored. It has been returned to active issues.';
                $issue_state_changed = $item_hash !== '';
                break;

            case 'fixed':
                $success = true;
                $message = 'Issue marked as fixed.';
                $issue_state_changed = $item_hash !== '';
                break;

            case 'deactivate':
                if ( ! current_user_can( 'activate_plugins' ) ) {
                    $message = 'You do not have permission to deactivate plugins.';
                    break;
                }
                if ( ! function_exists( 'deactivate_plugins' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/plugin.php';
                }
                deactivate_plugins( $item_id );
                if ( is_plugin_active( $item_id ) ) {
                    $message = 'Failed to deactivate plugin.';
                } else {
                    $success = true;
                    $message = 'Plugin deactivated.';
                }
                break;

            case 'update':
                if ( ! current_user_can( 'update_plugins' ) ) {
                    $message = 'You do not have permission to update plugins.';
                    break;
                }
                if ( ! class_exists( 'Plugin_Upgrader' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader.php';
                }
                if ( ! class_exists( 'Plugin_Upgrader_Skin' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/class-wp-upgrader-skin.php';
                }

                ob_start();
                $skin = new \Automatic_Upgrader_Skin();
                $upgrader = new \Plugin_Upgrader( $skin );
                $result = $upgrader->upgrade( $item_id );
                $output = ob_get_clean();

                if ( is_wp_error( $result ) ) {
                    $message = $result->get_error_message();
                } elseif ( $result === true ) {
                    $success = true;
                    $message = 'Plugin updated successfully.';
                } elseif ( $result === false ) {
                    $message = 'Update failed.';
                    if ( stripos( (string) $output, 'Could not create directory' ) !== false ) {
                        $message .= ' (Filesystem Permission Error)';
                    }
                } else {
                    $message = 'Update result unknown.';
                }
                break;

            case 'repair':
                if ( ! $this->allowsRepair() ) {
                    $message = 'Repair is available on Pro and Managed plans.';
                    break;
                }

                $resolved = $this->resolve_scan_issue_file_target( $item_id, $item_hash, $item_scan_id, true );
                if ( is_wp_error( $resolved ) ) {
                    $message = $resolved->get_error_message();
                    break;
                }

                $tracked_issue_row = isset( $resolved['issue_row'] ) && is_array( $resolved['issue_row'] ) ? $resolved['issue_row'] : null;
                $resolved_item_hash = $this->resolve_issue_hash( $item_hash, $tracked_issue_row );

                require_once dirname( __FILE__ ) . '/Service/RepairService.php';
                $repair_service = new \SiteFort\Modules\Scanner\Service\RepairService();

                $resolved_path = ltrim( wp_normalize_path( (string) $resolved['relative_path'] ), '/' );
                $resolved_type = $item_type;
                $resolved_slug = $slug;
                $resolved_version = $version;
                $resolved_locale = '';
                $expected_hash = '';
                $repair_allowed = null;

                $stored_version = '';
                $stored_locale = '';
                if ( is_array( $tracked_issue_row ) && isset( $tracked_issue_row['data'] ) && is_array( $tracked_issue_row['data'] ) ) {
                    if ( array_key_exists( 'repair_available', $tracked_issue_row['data'] ) ) {
                        $repair_allowed = wp_validate_boolean( $tracked_issue_row['data']['repair_available'] );
                    }
                    $expected_hash  = (string) ( $tracked_issue_row['data']['expected_hash'] ?? '' );
                    $stored_version = (string) ( $tracked_issue_row['data']['version'] ?? '' );
                    $stored_locale  = (string) ( $tracked_issue_row['data']['locale'] ?? '' );
                }

                $repair_tier = $this->get_file_protection_tier( $resolved_path );
                if ( $repair_tier === 'locked' ) {
                    $message = 'This file contains your site configuration and cannot be automatically repaired. Please edit it manually using your hosting file manager or an FTP/SSH client.';
                    break;
                }
                if ( $repair_tier === 'download_first' ) {
                    $message = sprintf( 'Automatic repair is not available for %s because each site has a unique version of this file. Download a backup copy first, then delete and recreate it if needed.', basename( $resolved_path ) );
                    break;
                }

                if ( $repair_allowed !== true ) {
                    if ( $repair_allowed === false ) {
                        $message = 'Repair is not available for this file in Intel Cloud Vault.';
                    } else {
                        $message = 'Repair metadata unavailable for this finding. Refresh findings and try again.';
                    }
                    break;
                }

                if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\FileIntegrity\\FileCollector' ) ) {
                    require_once __DIR__ . '/FileIntegrity/FileCollector.php';
                }

                $file_collector = new \SiteFort\Modules\Scanner\FileIntegrity\FileCollector();
                $file_info = $file_collector->get_file_info_for_hash_check( $resolved_path );
                $resolved_type    = $file_info['type'] ?? $resolved_type;
                $resolved_slug    = $file_info['slug'] ?? $resolved_slug;
                $resolved_locale  = $stored_locale !== '' ? $stored_locale : (string) ( $file_info['locale'] ?? '' );
                // Prefer the version stored at scan time. If a plugin was updated between scan
                // and repair, using the current version would cause IntelEngine to return the
                // new version's file while $expected_hash still references the old version,
                // causing a client-side integrity mismatch. Stored version is empty for issues
                // created before this field was added; fall back to the current resolved version.
                $resolved_version = $stored_version !== '' ? $stored_version : ( $file_info['version'] ?? $resolved_version );

                if ( $resolved_type === 'plugin' && ! $resolved_version && ! function_exists( 'get_plugins' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/plugin.php';
                }
                if ( ! Capabilities::can_modify_scanner_file( $resolved_path, (string) $resolved_type ) ) {
                    $message = $this->get_file_mutation_denied_message( $resolved_path, (string) $resolved_type, 'repair' );
                    break;
                }

                $repair_snapshot = null;
                if ( $quarantine_copy && is_array( $tracked_issue_row ) && $this->allows_quarantine_option_for_issue( $tracked_issue_row, 'repair' ) ) {
                    $repair_snapshot = $this->get_quarantine_service()->snapshot_for_repair(
                        $tracked_issue_row,
                        [
                            'absolute_path' => (string) $resolved['absolute_path'],
                            'relative_path' => $resolved_path,
                        ],
                        get_current_user_id()
                    );
                    if ( is_wp_error( $repair_snapshot ) ) {
                        $message = $repair_snapshot->get_error_message();
                        break;
                    }
                }

                $result = $repair_service->repair_file( $resolved_path, $resolved_type, $resolved_slug, $resolved_version, $expected_hash, $resolved_locale );
                if ( is_wp_error( $result ) ) {
                    if ( is_array( $repair_snapshot ) ) {
                        $this->get_quarantine_service()->discard_item( (int) ( $repair_snapshot['id'] ?? 0 ) );
                    }
                    $message = 'Repair Failed: ' . $result->get_error_message();
                    break;
                }

                if ( is_array( $repair_snapshot ) ) {
                    $activated_snapshot = $this->get_quarantine_service()->activate_item( (int) ( $repair_snapshot['id'] ?? 0 ) );
                    if ( is_wp_error( $activated_snapshot ) ) {
                        $this->get_quarantine_service()->discard_item( (int) ( $repair_snapshot['id'] ?? 0 ) );
                        $repair_snapshot = null;
                    } else {
                        $repair_snapshot = $activated_snapshot;
                    }
                }

                $success = true;
                if ( is_array( $repair_snapshot ) ) {
                    $message = 'File repaired successfully. Original file saved to quarantine.';
                    $extra = [
                        'quarantine_item_id' => (int) ( $repair_snapshot['id'] ?? 0 ),
                        'quarantine_mode'    => 'repair_snapshot',
                    ];
                    $audit_details = [
                        'repair_snapshot_id' => (int) ( $repair_snapshot['id'] ?? 0 ),
                        'quarantine_mode'    => 'repair_snapshot',
                        'expires_at'         => (int) ( $repair_snapshot['expires_at'] ?? 0 ),
                    ];
                } else {
                    $message = 'File repaired successfully (restored from repository).';
                }
                $audit_event = 'scan_action_repair';
                break;

            case 'delete_user':
                if ( ! function_exists( 'wp_delete_user' ) ) {
                    require_once ABSPATH . 'wp-admin/includes/user.php';
                }

                $target_user_id = absint( $item_id );
                if ( $target_user_id <= 0 ) {
                    $message = 'Invalid user account.';
                } elseif ( $target_user_id === get_current_user_id() ) {
                    $message = 'Cannot delete your own account.';
                } elseif ( ! Capabilities::can_delete_user_account( $target_user_id ) ) {
                    $message = 'You do not have permission to delete this user account.';
                } else {
                    $target_user = get_userdata( $target_user_id );
                    if ( ! $target_user ) {
                        $success = true;
                        $message = 'User account not found. Finding removed.';
                        break;
                    }

                    $reassign_id = get_current_user_id();
                    $result = wp_delete_user( $target_user_id, $reassign_id );
                    if ( $result ) {
                        $success = true;
                        $message = 'User account deleted successfully.';
                    } else {
                        $message = 'Failed to delete user account.';
                    }
                }
                break;

            case 'download':
                $resolved = $this->resolve_scan_issue_file_target( $item_id, $item_hash, $item_scan_id, true );
                if ( is_wp_error( $resolved ) ) {
                    $message = $resolved->get_error_message();
                    break;
                }

                $dl_relative = ltrim( wp_normalize_path( (string) $resolved['relative_path'] ), '/' );
                $dl_tier = $this->get_file_protection_tier( $dl_relative );
                if ( $dl_tier === 'locked' ) {
                    $message = 'This file cannot be downloaded here because it contains sensitive credentials. Use your hosting file manager or FTP/SSH client to access it directly.';
                    break;
                }

                $dl_path = (string) $resolved['absolute_path'];
                if ( ! is_readable( $dl_path ) ) {
                    $message = 'Unable to read the file. Please check file permissions on your server.';
                    break;
                }

                $dl_size = @filesize( $dl_path );
                if ( false === $dl_size || $dl_size > 2 * 1024 * 1024 ) {
                    $message = 'This file is too large to download here. Please use your hosting file manager or an FTP/SSH client instead.';
                    break;
                }

                $dl_content = @file_get_contents( $dl_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
                if ( false === $dl_content ) {
                    $message = 'Unable to read file contents. Please check file permissions on your server.';
                    break;
                }

                $success = true;
                $message = 'File ready for download.';
                $extra = [
                    'filename' => basename( $dl_relative ),
                    'content'  => base64_encode( $dl_content ),
                ];
                break;

            default:
                $message = 'Invalid action.';
                break;
        }

        if ( ! $success ) {
            return [
                'success'            => false,
                'message'            => $message,
                'extra'              => [],
                'item_hash'          => $resolved_item_hash,
                'issue_state_changed'=> false,
            ];
        }

        if ( $action === 'ignore' ) {
            if ( $resolved_item_hash !== '' ) {
                $this->get_controller()->ignore_item( $resolved_item_hash );
            }
        } elseif ( $action === 'unignore' ) {
            if ( $resolved_item_hash !== '' ) {
                $this->get_controller()->unignore_item( $resolved_item_hash );
            }
        } elseif ( $this->requires_issue_cleanup( $action ) && $resolved_item_hash !== '' ) {
            $issue_state_changed = $this->cleanup_issue_records( $resolved_item_hash, $item_scan_id );
        }

        if ( $audit_event !== '' ) {
            $audit_severity = $this->resolve_issue_audit_severity( $tracked_issue_row );
            $audit_payload = $this->build_issue_audit_details(
                $tracked_issue_row,
                is_array( $tracked_issue_row ) ? $this->extract_issue_relative_file_path( $tracked_issue_row ) : '',
                $audit_details
            );
            $this->log_scan_action_event( $audit_event, $audit_payload, $audit_severity );
        }

        if ( $issue_state_changed && ! $defer_state_broadcast ) {
            ScanController::emit_issue_state_change( $action, $resolved_item_hash );
        }

        return [
            'success'            => true,
            'message'            => $message,
            'extra'              => $extra,
            'item_hash'          => $resolved_item_hash,
            'issue_state_changed'=> $issue_state_changed,
        ];
    }

    private function requires_issue_cleanup( string $action ): bool {
        return in_array( $action, [ 'fixed', 'delete', 'repair', 'protect', 'delete_user' ], true );
    }

    private function cleanup_issue_records( string $item_hash, string $item_scan_id = '' ): bool {
        if ( $item_hash === '' ) {
            return false;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
            require_once __DIR__ . '/Repository/IssueRepository.php';
        }

        \SiteFort\Modules\Scanner\Repository\IssueRepository::delete( $item_hash );

        $staged_scan_ids = [];
        if ( $item_scan_id !== '' ) {
            $staged_scan_ids[] = $item_scan_id;
        }

        $active_queue = Queue\ScanQueue::get();
        if ( $active_queue && ! empty( $active_queue['id'] ) ) {
            $staged_scan_ids[] = (string) $active_queue['id'];
        }

        $staged_scan_ids = array_values( array_unique( array_filter( $staged_scan_ids ) ) );
        foreach ( $staged_scan_ids as $staged_scan_id ) {
            \SiteFort\Modules\Scanner\Repository\IssueRepository::deleteStagedByHash( $staged_scan_id, $item_hash );
        }

        return true;
    }

    /**
     * @param array<string,mixed> $restore_result
     */
    private function apply_quarantine_restore_issue_state( array $restore_result ): void {
        $effect = sanitize_key( (string) ( $restore_result['issue_state_effect'] ?? '' ) );
        if ( $effect === '' || $effect === 'none' ) {
            return;
        }

        $issue_hash = sanitize_text_field( (string) ( $restore_result['issue_hash'] ?? '' ) );

        if ( $effect === 'reopen' ) {
            $issue_snapshot = isset( $restore_result['issue_snapshot'] ) && is_array( $restore_result['issue_snapshot'] )
                ? $restore_result['issue_snapshot']
                : null;
            $this->restore_issue_snapshot_record( $issue_snapshot, $issue_hash );
        }

        ScanController::emit_issue_state_change( 'quarantine_restore', $issue_hash );
    }

    /**
     * @param array<string,mixed>|null $issue_snapshot
     */
    private function restore_issue_snapshot_record( ?array $issue_snapshot, string $fallback_hash ): bool {
        if ( ! is_array( $issue_snapshot ) ) {
            return false;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
            require_once __DIR__ . '/Repository/IssueRepository.php';
        }

        $issue_hash = sanitize_text_field( (string) ( $issue_snapshot['issue_hash'] ?? $issue_snapshot['hash'] ?? $fallback_hash ) );
        if ( $issue_hash === '' ) {
            return false;
        }

        $data = isset( $issue_snapshot['data'] ) && is_array( $issue_snapshot['data'] ) ? $issue_snapshot['data'] : [];
        $file_path = ltrim(
            wp_normalize_path( (string) ( $issue_snapshot['file_path'] ?? $issue_snapshot['file'] ?? '' ) ),
            '/'
        );

        return \SiteFort\Modules\Scanner\Repository\IssueRepository::save(
            [
                'hash'       => $issue_hash,
                'scan_id'    => sanitize_text_field( (string) ( $issue_snapshot['scan_id'] ?? $issue_snapshot['scan_run_id'] ?? 'quarantine_restore' ) ),
                'type'       => sanitize_key( (string) ( $issue_snapshot['type'] ?? 'unknown' ) ),
                'severity'   => sanitize_key( (string) ( $issue_snapshot['severity'] ?? 'medium' ) ),
                'status'     => sanitize_key( (string) ( $issue_snapshot['status'] ?? 'active' ) ),
                'file'       => $file_path,
                'message'    => sanitize_text_field( (string) ( $issue_snapshot['message'] ?? 'Restored from quarantine.' ) ),
                'data'       => $data,
                'created_at' => max( 0, (int) ( $issue_snapshot['created_at'] ?? time() ) ),
            ]
        );
    }

    /**
     * @param array<string,mixed>|null $issue_row
     */
    private function resolve_issue_hash( string $item_hash, ?array $issue_row ): string {
        if ( is_array( $issue_row ) && ! empty( $issue_row['issue_hash'] ) ) {
            return sanitize_text_field( (string) $issue_row['issue_hash'] );
        }

        return sanitize_text_field( $item_hash );
    }

    private function delete_verified_file( string $real_path ): bool {
        return FilesystemMutation::delete_path( $real_path );
    }

    /**
     * @param array<string,mixed>|null $issue_row
     */
    private function resolve_issue_audit_severity( ?array $issue_row ): string {
        $severity = is_array( $issue_row ) ? sanitize_key( (string) ( $issue_row['severity'] ?? 'low' ) ) : 'low';

        return in_array( $severity, [ 'critical', 'high' ], true ) ? 'warning' : 'info';
    }

    /**
     * @param array<string,mixed>|null $issue_row
     * @param array<string,mixed>      $extra
     * @return array<string,mixed>
     */
    private function build_issue_audit_details( ?array $issue_row, string $fallback_file = '', array $extra = [] ): array {
        $details = [];

        if ( is_array( $issue_row ) ) {
            $details['severity'] = sanitize_key( (string) ( $issue_row['severity'] ?? 'low' ) );
            $details['type'] = sanitize_key( (string) ( $issue_row['type'] ?? 'unknown' ) );
            $details['detail'] = sanitize_text_field( (string) ( $issue_row['message'] ?? '' ) );
            $details['issue_hash'] = sanitize_text_field( (string) ( $issue_row['issue_hash'] ?? '' ) );
        }

        $file = $fallback_file;
        if ( is_array( $issue_row ) ) {
            $candidate = $this->extract_issue_relative_file_path( $issue_row );
            if ( $candidate !== '' ) {
                $file = $candidate;
            }
        }
        if ( $file !== '' ) {
            $details['file'] = ltrim( wp_normalize_path( $file ), '/' );
        }

        return array_merge( $details, $extra );
    }

    /**
     * @param array<string,mixed> $details
     */
	    private function log_scan_action_event( string $event, array $details, string $severity = 'info' ): void {
	        \SiteFort\Modules\AuditLog\AuditLog::add_log( $event, $details, $severity, 'scanner' );
	    }

	    private function get_file_mutation_denied_message( string $file_path, string $component_type, string $action ): string {
	        $component = Capabilities::infer_scanner_file_component_type( $file_path, $component_type );

	        switch ( $component ) {
	            case 'plugin':
	                return sprintf( 'You do not have permission to %s plugin files.', $action );
	            case 'theme':
	                return sprintf( 'You do not have permission to %s theme files.', $action );
	            case 'core':
	                return sprintf( 'You do not have permission to %s WordPress core files.', $action );
	            default:
	                return sprintf( 'You do not have permission to %s this file.', $action );
	        }
	    }

    /**
     * @param array<string,mixed> $issue_row
     */
    private function allows_quarantine_option_for_issue( array $issue_row, string $action ): bool {
        if ( ! in_array( $action, [ 'delete', 'repair' ], true ) ) {
            return false;
        }

        $type = sanitize_key( (string) ( $issue_row['type'] ?? '' ) );
        $category = in_array( $type, [ 'integrity', 'fake_image', 'core', 'modified' ], true ) ? 'integrity' : $type;
        if ( ! in_array( $category, [ 'malware', 'integrity' ], true ) ) {
            return false;
        }

        $relative_path = $this->extract_issue_relative_file_path( $issue_row );
        if ( $relative_path === '' ) {
            return false;
        }

        return $this->get_file_protection_tier( $relative_path ) === null;
    }

    /**
     * Unresolved core malware deletes require a recoverable encrypted copy
     * when repair is unavailable and no trusted clean source was verified.
     *
     * @param array<string,mixed> $issue_row
     */
    private function requires_quarantine_for_delete_issue( array $issue_row ): bool {
        if ( ! class_exists( '\SiteFort\Modules\Scanner\Repository\IssueDisplayContractBuilder' ) ) {
            $path = __DIR__ . '/Repository/IssueDisplayContractBuilder.php';
            if ( file_exists( $path ) ) {
                require_once $path;
            }
        }

        return class_exists( '\SiteFort\Modules\Scanner\Repository\IssueDisplayContractBuilder' )
            && \SiteFort\Modules\Scanner\Repository\IssueDisplayContractBuilder::requires_delete_quarantine( $issue_row );
    }

    private function get_quarantine_service(): \SiteFort\Modules\Scanner\Service\QuarantineService {
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\QuarantineService' ) ) {
            require_once __DIR__ . '/Service/QuarantineService.php';
        }

        return new \SiteFort\Modules\Scanner\Service\QuarantineService();
    }

    public function handle_get_quarantine_items() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $limit_raw = $this->read_request_scalar_payload( 'limit' );
        $limit = $limit_raw !== '' ? absint( $limit_raw ) : 50;
        $offset = absint( $this->read_request_scalar_payload( 'offset' ) );
        $service = $this->get_quarantine_service();
        $items = $service->list_items( $limit, $offset );
        $total = $service->count_items();

        wp_send_json_success(
            [
                'items'    => $items,
                'total'    => $total,
                'limit'    => $limit,
                'offset'   => $offset,
                'has_more' => ( $offset + count( $items ) ) < $total,
            ]
        );
    }

    public function handle_restore_quarantine_item() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $item_id = absint( $this->read_post_scalar_payload( 'item_id' ) );
        if ( $item_id <= 0 ) {
            wp_send_json_error( [ 'message' => 'Missing quarantine item identifier.' ] );
            return;
        }

        $result = $this->get_quarantine_service()->restore_item( $item_id, get_current_user_id() );
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( [ 'message' => $result->get_error_message() ] );
            return;
        }

        $this->apply_quarantine_restore_issue_state( $result );
        $details = isset( $result['details'] ) && is_array( $result['details'] ) ? $result['details'] : [];
        $mode = sanitize_key( (string) ( $details['mode'] ?? '' ) );
        $audit_event = $mode === 'repair_snapshot'
            ? 'scan_action_quarantine_snapshot_restore'
            : 'scan_action_quarantine_restore';
        $this->log_scan_action_event( $audit_event, $details, 'warning' );

        wp_send_json_success(
            [
                'message' => (string) ( $result['message'] ?? 'File restored from quarantine.' ),
                'item'    => $result['item'] ?? null,
            ]
        );
    }

    public function handle_purge_quarantine_item() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $item_id = absint( $this->read_post_scalar_payload( 'item_id' ) );
        if ( $item_id <= 0 ) {
            wp_send_json_error( [ 'message' => 'Missing quarantine item identifier.' ] );
            return;
        }

        $result = $this->get_quarantine_service()->purge_item( $item_id, get_current_user_id(), false );
        if ( is_wp_error( $result ) ) {
            wp_send_json_error( [ 'message' => $result->get_error_message() ] );
            return;
        }

        $details = isset( $result['details'] ) && is_array( $result['details'] ) ? $result['details'] : [];
        $this->log_scan_action_event( 'scan_action_quarantine_purge', $details, 'warning' );

        wp_send_json_success(
            [
                'message' => (string) ( $result['message'] ?? 'Quarantine item purged.' ),
                'item'    => $result['item'] ?? null,
            ]
        );
    }

    public function handle_purge_all_quarantine_items() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $result = $this->get_quarantine_service()->purge_all_items( get_current_user_id() );
        $details = isset( $result['details'] ) && is_array( $result['details'] ) ? $result['details'] : [];
        $purged_count = max( 0, (int) ( $result['purged_count'] ?? 0 ) );
        $failed_count = max( 0, (int) ( $result['failed_count'] ?? 0 ) );

        if ( $purged_count > 0 || $failed_count > 0 ) {
            $this->log_scan_action_event( 'scan_action_quarantine_purge', $details, $failed_count > 0 ? 'warning' : 'info' );
        }

        wp_send_json_success(
            [
                'message'      => (string) ( $result['message'] ?? 'Quarantine purge completed.' ),
                'purged_count' => $purged_count,
                'failed_count' => $failed_count,
                'remaining'    => max( 0, (int) ( $result['remaining'] ?? 0 ) ),
                'errors'       => isset( $result['errors'] ) && is_array( $result['errors'] ) ? array_slice( $result['errors'], 0, 10 ) : [],
            ]
        );
    }

    public function handle_download_quarantine_item() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_die( 'Unauthorized', 403 );
        }

        $item_id = absint( $this->read_request_scalar_payload( 'item_id' ) );
        if ( $item_id <= 0 ) {
            wp_die( 'Missing quarantine item identifier.', 400 );
        }

        $service = $this->get_quarantine_service();
        $download = $service->prepare_download( $item_id );
        if ( is_wp_error( $download ) ) {
            wp_die( esc_html( $download->get_error_message() ), 400 );
        }

        $temp_file = (string) ( $download['temp_file'] ?? '' );
        $item = isset( $download['item'] ) && is_array( $download['item'] ) ? $download['item'] : [];
        $relative_path = ltrim( (string) ( $item['original_relative_path'] ?? 'quarantine-file' ), '/' );
        $download_name = sanitize_file_name( basename( $relative_path ) );

        $file_size = @filesize( $temp_file );
        nocache_headers();
        header( 'Content-Type: application/octet-stream' );
        header( 'X-Content-Type-Options: nosniff' );
        header( 'Content-Disposition: attachment; filename="' . $download_name . '"' );
        if ( false !== $file_size ) {
            header( 'Content-Length: ' . (int) $file_size );
        }

        $handle = @fopen( $temp_file, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Streaming a temporary decrypted download requires a binary read handle.
        if ( false === $handle ) {
            $service->cleanup_download_temp( $temp_file );
            wp_die( 'Failed to open the temporary download file.', 500 );
        }

        try {
            while ( ! feof( $handle ) ) {
                $chunk = fread( $handle, 8192 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Streaming a temporary decrypted download requires incremental reads.
                if ( false === $chunk ) {
                    break;
                }
                echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Streaming a verified temporary file as a binary download.
                if ( connection_aborted() ) {
                    break;
                }
            }
        } finally {
            @fclose( $handle ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose
            $service->cleanup_download_temp( $temp_file );
        }

        exit;
    }

    private function loadEntitlements(): bool {
        if ( ! class_exists( '\SiteFort\Modules\Cloud\Service\Entitlements' ) ) {
            $path = dirname( __DIR__ ) . '/Cloud/Service/Entitlements.php';
            if ( file_exists( $path ) ) {
                require_once $path;
            }
        }

        return class_exists( '\SiteFort\Modules\Cloud\Service\Entitlements' );
    }

    private function allowsScanning(): bool {
        return $this->loadEntitlements()
            && \SiteFort\Modules\Cloud\Service\Entitlements::allowsScanning();
    }

    private function allowsOnDemandScans(): bool {
        return $this->loadEntitlements()
            && \SiteFort\Modules\Cloud\Service\Entitlements::allowsOnDemandScans();
    }

    private function allowsScheduledScans(): bool {
        return $this->loadEntitlements()
            && \SiteFort\Modules\Cloud\Service\Entitlements::allowsScheduledScans();
    }

    private function allowsRepair(): bool {
        return $this->loadEntitlements()
            && \SiteFort\Modules\Cloud\Service\Entitlements::allowsRepair();
    }

    private function allowsDeleteActionForIssue( array $issue_row ): bool {
        if ( ! class_exists( '\SiteFort\Modules\Scanner\Repository\IssueDisplayContractBuilder' ) ) {
            $path = __DIR__ . '/Repository/IssueDisplayContractBuilder.php';
            if ( file_exists( $path ) ) {
                require_once $path;
            }
        }

        if ( ! class_exists( '\SiteFort\Modules\Scanner\Repository\IssueDisplayContractBuilder' ) ) {
            return false;
        }

        $policy = \SiteFort\Modules\Scanner\Repository\IssueDisplayContractBuilder::build_action_policy( $issue_row );
        $actions = isset( $policy['actions'] ) && is_array( $policy['actions'] ) ? $policy['actions'] : [];

        if ( ! array_key_exists( 'delete', $actions ) ) {
            return false;
        }

        $delete_action = $actions['delete'];
        return ! is_array( $delete_action )
            || ! wp_validate_boolean( $delete_action['disabled'] ?? false );
    }

    /**
     * Present scanner settings as the current entitlements allow.
     *
     * Coercion is display-only by default. The stored schedule is the customer's intent and
     * must survive a lapse: suspending means stopping the cron event, not erasing the setting,
     * so re-entitlement restores their cadence without them reconfiguring it. Only an explicit
     * save ($persist = true) may write the coerced value back.
     */
    private function normalizeScannerSettingsForEntitlements( array $settings, bool $persist = false ): array {
        $schedule = isset( $settings['schedule'] ) ? (string) $settings['schedule'] : 'none';
        if ( ! $this->allowsScheduledScans() ) {
            if ( $schedule !== 'none' ) {
                $settings['schedule'] = 'none';
                if ( $persist && class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
                    Service\ScannerSettings::save( $settings );
                }
            }
            Queue\ScanCron::reconcile_scheduled_scan( $settings, false, true );
        }

        return $settings;
    }

    private function buildScannerSettingsResponse( array $settings ): array {
        // Read path: never persist. Reading the Scanner tab while suspended used to write
        // schedule => 'none', so whether a customer's cadence survived a lapse depended on
        // whether an admin happened to open the page, and a renewal could not restore it.
        $settings = $this->normalizeScannerSettingsForEntitlements( $settings, false );
        $access_state = $this->get_scanner_access_state();

        $cloud = \SiteFort\Modules\Cloud\Service\CloudSettings::get_all();
        $settings['cloud_connected'] = ! empty( $cloud['site_token'] ) && ! empty( $cloud['site_secret'] );
        $settings['cloud_key_error'] = ! empty( $cloud['_key_error'] );
        $settings['license_status'] = $settings['cloud_connected']
            ? sanitize_text_field( (string) ( $cloud['license_status'] ?? '' ) )
            : '';
        $settings['license_plan'] = sanitize_text_field( (string) ( $cloud['license_plan'] ?? 'free' ) );
        $settings['scanner_access_status'] = (string) $access_state['status'];
        $settings['scanner_access_message'] = (string) $access_state['message'];
        $settings['scanner_access_retry_after'] = (int) ( $access_state['retry_after'] ?? 0 );
        $settings['scanner_access_checked_at'] = (int) $access_state['checked_at'];
        $settings['scan_credits_remaining'] = isset( $cloud['scan_credits_remaining'] ) ? (int) $cloud['scan_credits_remaining'] : -1;
        $settings['scan_credits_total'] = isset( $cloud['scan_credits_total'] ) ? (int) $cloud['scan_credits_total'] : -1;
        $settings['scan_credit_reset_at'] = $cloud['scan_credit_reset_at'] ?? null;
        $settings['scan_credit_limit_reached'] = (bool) ( $cloud['scan_credit_limit_reached'] ?? false );
        $settings['scan_credit_block_reason'] = is_string( $cloud['scan_credit_block_reason'] ?? null ) ? $cloud['scan_credit_block_reason'] : null;
        $settings['scan_enabled'] = $this->allowsScanning();
        $settings['scan_on_demand_enabled'] = $this->allowsOnDemandScans();
        $settings['scheduled_scan_enabled'] = $this->allowsScheduledScans();
        unset( $settings['hash_cache_keys'] );

        return $settings;
    }

    /**
     * Get scanner settings.
     */
    public function handle_get_scanner_settings() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
            require_once __DIR__ . '/Service/ScannerSettings.php';
        }

        wp_send_json_success( $this->buildScannerSettingsResponse( Service\ScannerSettings::get_all() ) );
    }

    /**
     * Validate current scanner access without mutating license assignment state.
     */
    public function handle_check_scanner_access() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
            require_once __DIR__ . '/Service/ScannerSettings.php';
        }
        if ( ! class_exists( 'SiteFort\\Modules\\Cloud\\Services\\CloudScanner' ) ) {
            require_once dirname( __DIR__ ) . '/Cloud/Services/CloudScanner.php';
        }
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Queue\\ScanWorker' ) ) {
            require_once __DIR__ . '/Queue/ScanWorker.php';
        }

        $cloud = new \SiteFort\Modules\Cloud\Services\CloudScanner();
        $access_state = $this->resolve_scanner_access_state( Queue\ScanWorker::evaluate_preflight( $cloud, '', false ) );
        $this->persist_scanner_access_state( $access_state['status'], $access_state['message'], (int) ( $access_state['retry_after'] ?? 0 ) );

        wp_send_json_success( [
            'status'   => $access_state['status'],
            'message'  => $access_state['message'],
            'settings' => $this->buildScannerSettingsResponse( Service\ScannerSettings::get_all() ),
        ] );
    }

    /**
     * Save scanner settings and manage schedule.
     */
    public function handle_save_scanner_settings() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }
        
        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Service\\ScannerSettings' ) ) {
            require_once __DIR__ . '/Service/ScannerSettings.php';
        }

        $raw_settings = $this->read_post_json_payload( 'settings', '{}' );
        $input        = $this->decode_json_array_payload( $raw_settings );

        if ( null === $input ) {
            wp_send_json_error( [ 'message' => 'Invalid settings payload.' ] );
            return;
        }

        if ( array_key_exists( 'scanExclusions', $input ) ) {
            $input['scanExclusions'] = Service\ScannerSettings::normalize_scan_exclusions( $input['scanExclusions'] );
        }

        // Validate notificationEmail when email notifications are enabled.
        $email_on = filter_var( $input['emailNotifications'] ?? false, FILTER_VALIDATE_BOOLEAN );
        if ( $email_on && ! empty( $input['notificationEmail'] ) ) {
            if ( ! is_email( $input['notificationEmail'] ) ) {
                wp_send_json_error( [ 'message' => 'Invalid notification email address.' ] );
                return;
            }
        }

        $requested_schedule = isset( $input['schedule'] ) ? sanitize_key( (string) $input['schedule'] ) : '';
        $coerced_schedule = ! $this->allowsScheduledScans() && $requested_schedule !== '' && $requested_schedule !== 'none';
        if ( $coerced_schedule ) {
            $input['schedule'] = 'none';
        }

        if ( ! Service\ScannerSettings::save( $input ) ) {
            wp_send_json_error( [ 'message' => 'Failed to save scanner settings.' ] );
            return;
        }

        // A schedule the admin actively requested was already coerced into $input above. Do not
        // persist a coercion here as well: a suspended site saving an unrelated field (say scan
        // exclusions) would otherwise have its stored cadence erased and lose it for good.
        $settings = $this->normalizeScannerSettingsForEntitlements( Service\ScannerSettings::get_all(), false );

        $schedule_result = Queue\ScanCron::reconcile_scheduled_scan( $settings, $this->allowsScheduledScans(), true );
        if ( is_wp_error( $schedule_result ) ) {
            $settings['schedule'] = 'none';
            Service\ScannerSettings::save( $settings );
            Queue\ScanCron::reconcile_scheduled_scan( $settings, false, true );

            wp_send_json_error( [
                'message'  => 'Settings saved, but automated scan scheduling failed: ' . $schedule_result->get_error_message(),
                'settings' => $this->buildScannerSettingsResponse( Service\ScannerSettings::get_all() ),
            ] );
            return;
        }

        wp_send_json_success( [
            'message' => $coerced_schedule
                ? 'Settings saved. Scheduled scans are not available on this license, so the schedule was not applied.'
                : 'Settings saved successfully',
            'settings' => $this->buildScannerSettingsResponse( $settings ),
        ] );
    }

    /**
     * Get available scan types.
     */
    public function handle_get_scan_types() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        wp_send_json_success( ScanTypes::get_available_types() );
    }


    /**
     * Start an async scan using the Command Queue.
     */
	    public function handle_start_async_scan() {
	        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
	        if ( ! Capabilities::can_manage_plugin() ) {
	            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
	            return;
	        }

	        $key_error = \SiteFort\Modules\Cloud\Service\CloudSettings::get_key_error();
	        if ( $key_error ) {
	            wp_send_json_error( [
                'code'    => 'key_error',
                'message' => 'Encryption key issue detected. Visit Settings to resolve.',
            ] );
            return;
        }
        $cloud_settings = \SiteFort\Modules\Cloud\Service\CloudSettings::get_all();
	        if ( empty( $cloud_settings['site_token'] ) || empty( $cloud_settings['site_secret'] ) ) {
	            wp_send_json_error( [
	                'code'    => 'not_connected',
	                'message' => 'This site is not connected to SiteFort Console. Go to Settings to connect.',
	            ] );
	            return;
	        }

	        if ( ! $this->allowsScanning() ) {
	            wp_send_json_error( [ 'message' => 'Scanning is currently unavailable for this site.' ] );
	            return;
	        }

	        if ( ! $this->allowsOnDemandScans() ) {
	            wp_send_json_error( [ 'message' => 'On-demand scans are currently unavailable for this site.' ] );
	            return;
	        }

        if ( class_exists( '\\SiteFort\\Modules\\Scanner\\Repository\\ScanStatusRepository' ) ) {
            \SiteFort\Modules\Scanner\Repository\ScanStatusRepository::trim( 2000 ); // Keep last 2000 events
        }

        $queue = $this->recover_start_blocking_scan();
        if ( is_array( $queue ) ) {
            wp_send_json_error( [
                'code'     => 'scan_in_progress',
                'message'  => 'Scan already in progress',
                'scan_id'  => $queue['id'],
                'progress' => Queue\ScanQueue::get_progress(),
                'active'   => true,
            ] );
            return;
        }

        $raw_type = sanitize_text_field( $this->read_post_scalar_payload( 'scan_type' ) ?: 'standard' );
        $type = ( 'deep' === $raw_type ) ? 'deep' : 'standard';
        
        if ( ! class_exists( 'SiteFort\\Modules\\Cloud\\Services\\CloudScanner' ) ) {
            require_once dirname( __DIR__ ) . '/Cloud/Services/CloudScanner.php';
        }
        $cloud = new \SiteFort\Modules\Cloud\Services\CloudScanner();
        
        $scan_id = 'scan_' . wp_generate_uuid4();

        // Run the gating check with transient check to prevent duplicate calls
        if ( ! get_transient( 'sitefort_scan_preflight_' . $scan_id ) ) {
            $server_state = Queue\ScanWorker::run_preflight_check( $scan_id, $cloud );

            if ( is_wp_error( $server_state ) ) {
                $access_state = $this->resolve_scanner_access_state( $server_state );
                $error_code = $access_state['status'];
                $raw_msg    = $server_state->get_error_message();
                if ( is_array( $raw_msg ) ) {
                    $encoded = wp_json_encode( $raw_msg );
                    $raw_msg = $encoded !== false ? $encoded : 'Unknown error';
                }
                $raw_msg = (string) $raw_msg;

                if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
                    \SiteFort\Modules\Scanner\Service\ScanLogger::log( "Pre-scan check failed [{$error_code}]: " . $raw_msg, 3, 'error' );
                }

                $this->persist_scanner_access_state( $access_state['status'], $access_state['message'], (int) ( $access_state['retry_after'] ?? 0 ) );

                wp_send_json_error( [
                    'message'     => $access_state['message'],
                    'code'        => $access_state['status'],
                    'retry_after' => (int) ( $access_state['retry_after'] ?? 0 ),
                ] );
                return;
            }

            // Set transient to prevent duplicate preflight check in ScanWorker
            set_transient( 'sitefort_scan_preflight_' . $scan_id, true, HOUR_IN_SECONDS );
            $this->persist_scanner_access_state( 'ready', '' );

            if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
                \SiteFort\Modules\Scanner\Service\ScanLogger::log( "Environment verified. Starting security scan..." );
            }
        }

        if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
             \SiteFort\Modules\Scanner\Service\ScanLogger::log( "Initializing file scanner..." );
        }

        $queue = Queue\ScanQueue::create( $type, 'local', $scan_id );
        if ( ! is_array( $queue ) || empty( $queue['id'] ) ) {
            wp_send_json_error( [
                'message' => 'Unable to create scan queue. Another scan may already be active.',
                'code'    => 'queue_create_failed',
            ] );
            return;
        }

        Queue\ScanWorker::dispatch_next( $scan_id, true );
        
        wp_send_json_success( [
            'message'        => 'Scan started',
            'scan_id'        => $queue['id'],
            'total_items'    => $queue['total_items'],
            'credit_context' => $this->get_scan_credit_context( (string) $queue['id'] ),
        ] );
    }

    /**
     * Read the last known scanner access state.
     *
     * @return array{status:string,message:string,checked_at:int}
     */
    private function get_scanner_access_state(): array {
        $stored = get_transient( self::ACCESS_STATE_TRANSIENT );
        if ( ! is_array( $stored ) ) {
            return [
                'status'      => 'unknown',
                'message'     => '',
                'retry_after' => 0,
                'checked_at'  => 0,
            ];
        }

        return [
            'status'      => sanitize_key( (string) ( $stored['status'] ?? 'unknown' ) ),
            'message'     => sanitize_text_field( (string) ( $stored['message'] ?? '' ) ),
            'retry_after' => absint( $stored['retry_after'] ?? 0 ),
            'checked_at'  => absint( $stored['checked_at'] ?? 0 ),
        ];
    }

    /**
     * Persist the last scanner preflight access state.
     */
    private function persist_scanner_access_state( string $status, string $message = '', int $retry_after = 0 ): void {
        set_transient(
            self::ACCESS_STATE_TRANSIENT,
            [
                'status'      => sanitize_key( $status ),
                'message'     => sanitize_text_field( $message ),
                'retry_after' => max( 0, $retry_after ),
                'checked_at'  => time(),
            ],
            DAY_IN_SECONDS
        );
    }

    /**
     * Convert a preflight result into a canonical scanner-access state payload.
     *
     * @param array|\WP_Error $preflight_result
     * @return array{status:string,message:string}
     */
    private function resolve_scanner_access_state( $preflight_result ): array {
        if ( is_wp_error( $preflight_result ) ) {
            $error_code = sanitize_key( (string) $preflight_result->get_error_code() );
            if ( $error_code === '' ) {
                $error_code = 'preflight_failed';
            }

            $raw_message = $preflight_result->get_error_message();
            if ( is_array( $raw_message ) ) {
                $encoded = wp_json_encode( $raw_message );
                $raw_message = $encoded !== false ? $encoded : 'Unknown error';
            }

            $error_data  = $preflight_result->get_error_data();
            $retry_after = ( is_array( $error_data ) && isset( $error_data['retry_after'] ) ) ? (int) $error_data['retry_after'] : 0;

            return [
                'status'      => $error_code,
                'message'     => $this->map_scanner_access_message( $error_code, (string) $raw_message ),
                'retry_after' => $retry_after,
            ];
        }

        $status = sanitize_key( (string) ( $preflight_result['status'] ?? 'ready' ) );
        if ( $status === '' ) {
            $status = 'ready';
        }

        return [
            'status'      => $status,
            'message'     => $status === 'ready'
                ? ''
                : $this->map_scanner_access_message(
                    $status,
                    sanitize_text_field( (string) ( $preflight_result['message'] ?? '' ) )
                ),
            'retry_after' => $status === 'ready' ? 0 : (int) ( $preflight_result['retry_after'] ?? 0 ),
        ];
    }

    /**
     * Map scanner access statuses to stable user-facing messages.
     */
    private function map_scanner_access_message( string $status, string $fallback = '' ): string {
        $messages = [
            'api_server_error'  => 'Cloud service is temporarily unavailable. Please try again in a few minutes.',
            'api_parse_error'   => 'Cloud service returned an unexpected response. Please try again in a few minutes.',
            'rate_limited'      => 'Too many requests. Please wait a moment before starting another scan.',
            'http_exception'    => 'Unable to reach the cloud service. Please check your server\'s internet connectivity.',
            'credits_exhausted' => 'Deep threat analysis credits are exhausted for this period. Hash checks and other scanner checks can continue.',
            'service_inactive'  => 'Cloud services are disabled for this site. Contact support to restore access.',
        ];

        $message = $messages[ $status ] ?? $fallback;
        return sanitize_text_field( $message );
    }

    /**
     * Build scan notices for completed scan (warnings that are safe for end-user display).
     *
     * Internal scanner telemetry, including upstream indexing state, must not be
     * exposed through the UI notice channel.
     *
     * @param string $scan_id Scan UUID.
     * @return array[] Array of notice objects with 'type', 'code', 'message'.
     */
    private function get_scan_notices( string $scan_id ): array {
        if ( $scan_id === '' ) {
            return [];
        }

        return [];
    }

    private function load_file_state_repository(): bool {
        if ( ! class_exists( '\SiteFort\Modules\Scanner\Repository\FileStateRepository' ) ) {
            $repository_path = __DIR__ . '/Repository/FileStateRepository.php';
            if ( file_exists( $repository_path ) ) {
                require_once $repository_path;
            }
        }

        return class_exists( '\SiteFort\Modules\Scanner\Repository\FileStateRepository' );
    }

    private function file_state_repository(): ?\SiteFort\Modules\Scanner\Repository\FileStateRepository {
        if ( ! $this->load_file_state_repository() ) {
            return null;
        }

        return new \SiteFort\Modules\Scanner\Repository\FileStateRepository( false );
    }

    private function deep_analysis_credit_skip_reason(): string {
        if ( $this->load_file_state_repository() ) {
            return \SiteFort\Modules\Scanner\Repository\FileStateRepository::SKIP_REASON_DEEP_ANALYSIS_CREDIT_EXHAUSTED;
        }

        return 'deep_analysis_credit_exhausted';
    }

    /**
     * @param string $scan_id Scan UUID.
     * @param bool   $include_historical_skips Include terminal-scan skip counts.
     * @return array<string,mixed>|null
     */
    private function get_scan_credit_context( string $scan_id, bool $include_historical_skips = false ): ?array {
        if ( $scan_id === '' ) {
            return null;
        }

        if ( ! class_exists( '\SiteFort\Modules\Cloud\Service\CloudSettings' ) ) {
            $settings_path = dirname( __DIR__ ) . '/Cloud/Service/CloudSettings.php';
            if ( file_exists( $settings_path ) ) {
                require_once $settings_path;
            }
        }

        $cloud = class_exists( '\SiteFort\Modules\Cloud\Service\CloudSettings' )
            ? \SiteFort\Modules\Cloud\Service\CloudSettings::get_all()
            : [];

        $credit_limit_reached = (bool) ( $cloud['scan_credit_limit_reached'] ?? false )
            || (bool) get_transient( 'sitefort_deep_analysis_credit_exhausted_' . $scan_id );

        if ( ! $credit_limit_reached && ! $include_historical_skips ) {
            return null;
        }

        $pending_deep_analysis = 0;
        $deep_analysis_skipped = 0;
        $file_states = $this->file_state_repository();
        if ( $file_states ) {
            $deep_analysis_skipped = $file_states->count_by_scan_and_skip_reason(
                $scan_id,
                $this->deep_analysis_credit_skip_reason()
            );
            if ( $credit_limit_reached ) {
                $pending_deep_analysis = $file_states->count_by_scan_and_state( $scan_id, 'pending_upload' );
            }
        }

        $deep_analysis_required = $deep_analysis_skipped + $pending_deep_analysis;
        $credit_limit_reached = $credit_limit_reached || $deep_analysis_required > 0;

        if ( ! $credit_limit_reached && $deep_analysis_required <= 0 ) {
            return null;
        }

        return [
            'credit_limit_reached'          => $credit_limit_reached,
            'deep_analysis_required_count'  => $deep_analysis_required,
            'deep_analysis_skipped_count'   => $deep_analysis_skipped,
            'credits_remaining'             => isset( $cloud['scan_credits_remaining'] ) ? (int) $cloud['scan_credits_remaining'] : -1,
            'credits_total'                 => isset( $cloud['scan_credits_total'] ) ? (int) $cloud['scan_credits_total'] : -1,
            'credit_reset_at'               => is_string( $cloud['scan_credit_reset_at'] ?? null ) ? $cloud['scan_credit_reset_at'] : null,
            'credit_block_reason'           => is_string( $cloud['scan_credit_block_reason'] ?? null ) ? $cloud['scan_credit_block_reason'] : null,
        ];
    }

    /**
     * Get current async scan status.
     */
    private function get_scan_failure_context( string $scan_id = '' ): ?array {
        $stored = \SiteFort\Includes\SettingsStore::get_field(
            \SiteFort\Includes\SettingsStore::COL_STATE,
            'last_scan_failure_context',
            []
        );

        $context = [];
        if ( is_array( $stored ) && ! empty( $stored ) ) {
            $context = $this->sanitize_failure_context( $stored );
        }

        // Fall back to the transient alert when stored failure context is unavailable.
        if ( empty( $context ) ) {
            $alert = get_transient( 'sitefort_last_scan_alert' );
            if ( is_array( $alert ) ) {
                $fallback = [];
                if ( isset( $alert['context'] ) && is_array( $alert['context'] ) ) {
                    $fallback = $alert['context'];
                }
                if ( isset( $alert['message'] ) ) {
                    $fallback['message'] = $alert['message'];
                }
                if ( isset( $alert['error_code'] ) ) {
                    $fallback['error_code'] = $alert['error_code'];
                }
                if ( isset( $alert['stage'] ) ) {
                    $fallback['stage'] = $alert['stage'];
                }
                if ( isset( $alert['status_code'] ) ) {
                    $fallback['status_code'] = $alert['status_code'];
                }
                if ( isset( $alert['request_id'] ) ) {
                    $fallback['request_id'] = $alert['request_id'];
                }
                if ( isset( $alert['incident_ref'] ) ) {
                    $fallback['incident_ref'] = $alert['incident_ref'];
                }
                if ( isset( $alert['retryable'] ) ) {
                    $fallback['retryable'] = (bool) $alert['retryable'];
                }
                if ( isset( $alert['timestamp'] ) ) {
                    $fallback['occurred_at'] = (int) $alert['timestamp'];
                }
                if ( isset( $alert['scan_id'] ) ) {
                    $fallback['scan_id'] = $alert['scan_id'];
                }

                $context = $this->sanitize_failure_context( $fallback );
            }
        }

        if ( empty( $context ) ) {
            return null;
        }

        if ( $scan_id !== '' ) {
            if ( ! empty( $context['scan_id'] ) && $context['scan_id'] !== $scan_id ) {
                return null;
            }
            if ( empty( $context['scan_id'] ) ) {
                $context['scan_id'] = $scan_id;
            }
        }

        return $context;
    }

    /**
     * Sanitize stored failure context payload.
     *
     * @param array $raw
     * @return array<string,mixed>
     */
    private function sanitize_failure_context( array $raw ): array {
        $context = [
            'scan_id'           => sanitize_text_field( (string) ( $raw['scan_id'] ?? '' ) ),
            'message'           => sanitize_text_field( (string) ( $raw['message'] ?? '' ) ),
            'error_code'        => sanitize_key( (string) ( $raw['error_code'] ?? 'scan_failed' ) ),
            'stage'             => sanitize_key( (string) ( $raw['stage'] ?? 'scanner' ) ),
            'status_code'       => isset( $raw['status_code'] ) ? (int) $raw['status_code'] : 0,
            'request_id'        => sanitize_text_field( (string) ( $raw['request_id'] ?? '' ) ),
            'host_id'           => sanitize_text_field( (string) ( $raw['host_id'] ?? '' ) ),
            'retryable'         => array_key_exists( 'retryable', $raw ) ? (bool) $raw['retryable'] : true,
            'occurred_at'       => isset( $raw['occurred_at'] ) ? (int) $raw['occurred_at'] : 0,
            'incident_ref'      => sanitize_text_field( (string) ( $raw['incident_ref'] ?? '' ) ),
            'cloud_error_count' => isset( $raw['cloud_error_count'] ) ? (int) $raw['cloud_error_count'] : 0,
        ];

        if ( isset( $raw['technical_message'] ) ) {
            $context['technical_message'] = sanitize_text_field( (string) $raw['technical_message'] );
        }

        return $context;
    }

    /**
     * Trim lookahead findings before sending the scanner status response.
     *
     * @param array<int,array<string,mixed>> $issues
     * @param int                            $limit
     * @return array{issues:array<int,array<string,mixed>>,has_more:bool}
     */
    private function prepare_issue_page_for_response( array $issues, int $limit ): array {
        $limit = max( 1, $limit );
        $has_more = count( $issues ) > $limit;
        if ( $has_more ) {
            $issues = array_slice( $issues, 0, $limit );
        }

        return [
            'issues'   => array_values( $issues ),
            'has_more' => $has_more,
        ];
    }

    public function handle_get_scan_status() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $limit_raw = $this->read_request_scalar_payload( 'limit' );
        $limit  = $limit_raw !== '' ? absint( $limit_raw ) : 50;
        $offset = absint( $this->read_request_scalar_payload( 'offset' ) );
        if ( $limit <= 0 ) {
            $limit = 50;
        }
        if ( $limit > 50 ) {
            $limit = 50;
        }

        $requested_scan_id = sanitize_text_field( $this->read_request_scalar_payload( 'scan_id' ) );

        $this->nudge_active_scan_scheduler();

        $queue = Queue\ScanQueue::get();

        if ( ! empty( $requested_scan_id ) ) {
            $requested_queue = Queue\ScanQueue::get_job( $requested_scan_id );
            if ( is_array( $requested_queue ) ) {
                $queue = $requested_queue;
            }
        }

        if ( $queue && $queue['status'] === 'failed' ) {
            $progress = Queue\ScanQueue::get_progress();
            $component_status = Queue\ScanQueue::get_component_status( $queue );
            $progress_total = $progress['total'] ?? null;
            $fsm_total = $queue['cloud_stats']['fsm']['total'] ?? null;
            if ( is_numeric( $progress_total ) && (int) $progress_total > 0 ) {
                $total_items = (int) $progress_total;
            } elseif ( is_numeric( $fsm_total ) && (int) $fsm_total > 0 ) {
                $total_items = (int) $fsm_total;
            } else {
                $total_items = (int) ( $queue['total_items'] ?? 0 );
            }
            if ( class_exists( '\SiteFort\Modules\Scanner\Repository\FileIndexRepository' ) ) {
                $file_index_total = ( new \SiteFort\Modules\Scanner\Repository\FileIndexRepository() )->get_total_files( $queue['id'] );
                if ( $file_index_total > 0 ) {
                    $total_items = max( $total_items, $file_index_total );
                }
            }

            $live_issues = [];
            $issues_has_more = false;
            if ( class_exists( '\SiteFort\Modules\Scanner\Repository\IssueRepository' ) ) {
                $issue_page = $this->prepare_issue_page_for_response(
                    \SiteFort\Modules\Scanner\Repository\IssueRepository::getStagedIssues( $queue['id'], $limit + 1, $offset, true ),
                    $limit
                );
                $live_issues = $issue_page['issues'];
                $issues_has_more = $issue_page['has_more'];
            }

            $processed = (int) ( $progress['processed'] ?? 0 );
            $total_items = max( $total_items, $processed );

            wp_send_json_success( [
                'active'       => false,
                'scan_id'      => $queue['id'],
                'status'       => 'failed',
                'type'         => $queue['scan_type'] ?? 'standard',
                'progress'     => $progress['percentage'] ?? 0,
                'processed'    => $processed,
                'total_items'  => $total_items,
                'is_analyzing' => $progress['is_analyzing'] ?? false,
                'issues'       => $live_issues,
                'issues_has_more' => $issues_has_more,
                'component_status' => $component_status,
                'cloud_stats'      => $queue['cloud_stats'] ?? [],
                'last_alert'       => get_transient( 'sitefort_last_scan_alert' ),
                'failure_context'  => $this->get_scan_failure_context( (string) $queue['id'] ),
                'credit_context'   => $this->get_scan_credit_context( (string) $queue['id'], true ),
            ] );
        }

        $published_id = \SiteFort\Includes\SettingsStore::get_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'published_scan_id', '' );
        $requested_scan_is_published = $requested_scan_id !== '' && $published_id !== '' && hash_equals( (string) $published_id, (string) $requested_scan_id );
        $queue_is_active = is_array( $queue ) && in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true );
        if ( ! $queue || ! $queue_is_active ) {
            $last_status = \SiteFort\Includes\SettingsStore::get_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'last_scan_status', '' );

            if ( ( $requested_scan_id === '' || $requested_scan_is_published ) && ( $published_id || ( $queue && $queue['status'] === 'complete' ) ) ) {
                
                if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
                    require_once __DIR__ . '/Repository/IssueRepository.php';
                }
                
                // Scoped publishes (e.g., vulnerability-only refresh) must not hide other module findings.
                $published_page = $this->prepare_issue_page_for_response(
                    \SiteFort\Modules\Scanner\Repository\IssueRepository::getCurrentIssues( $limit + 1, $offset, true ),
                    $limit
                );
                $published_issues = $published_page['issues'];
                $published_issues_has_more = $published_page['has_more'];
                $counts = \SiteFort\Modules\Scanner\Repository\IssueRepository::getCounts();
                $total_active = $counts['active'] ?? 0;

                // Retrieve frozen component_status from scan_runs table.
                $frozen_status = [];
                $scan_run = \SiteFort\Modules\Scanner\Repository\IssueRepository::getScanRun( $published_id );
                if ( $scan_run && ! empty( $scan_run['component_status'] ) ) {
                    $frozen_status = $scan_run['component_status'];
                }
                
                wp_send_json_success( [
                    'active'       => false,
                    'scan_id'      => $published_id,
                    'status'       => ( $last_status === 'failed' ) ? 'failed' : 'complete',
                    'type'         => 'standard',
                    'progress'     => [
                        'processed'  => $total_active,
                        'total'      => $total_active,
                        'percentage' => 100,
                    ],
                    'total_items'      => $total_active,
                    'issues'           => $published_issues,
                    'issues_has_more'  => $published_issues_has_more,
                    'component_status' => $frozen_status,
                    'failure_context'  => ( $last_status === 'failed' ) ? $this->get_scan_failure_context( (string) $published_id ) : null,
                    'credit_context'   => $this->get_scan_credit_context( (string) $published_id, true ),
                    'scan_notices'     => $this->get_scan_notices( (string) $published_id ),
                    'data'             => [
                        'counts' => $counts,
                    ],
                ] );
            }

            if ( $last_status === 'failed' ) {
                $fallback_scan_id = $published_id ?: $requested_scan_id;
                wp_send_json_success( [
                    'active'          => false,
                    'scan_id'         => $fallback_scan_id,
                    'status'          => 'failed',
                    'issues'          => [],
                    'component_status'=> [],
                    'last_alert'      => get_transient( 'sitefort_last_scan_alert' ),
                    'failure_context' => $this->get_scan_failure_context( (string) $fallback_scan_id ),
                    'credit_context'  => $this->get_scan_credit_context( (string) $fallback_scan_id, true ),
                ] );
            }

            if ( $requested_scan_id !== '' ) {
                wp_send_json_success( [
                    'active'  => false,
                    'scan_id' => $requested_scan_id,
                    'status'  => 'scan_not_active',
                    'message' => 'Requested scan is no longer active.',
                ] );
            }
            
            wp_send_json_success( [
                'active'  => false,
                'message' => 'No active scan',
            ] );
        }

        // Return current status (queue or published result)
        // If queue exists, use its status. If published exists, use complete.
        // If neither, active=false.
        
        if ( $queue ) {
             // Calculate percentage for active scan
             $progress = Queue\ScanQueue::get_progress( $queue );

             // Calculate component-level status for the active scan.
            $component_status = Queue\ScanQueue::get_component_status( $queue );

            // If we have issues in the queue, fetch just the active ones for the UI "live list"
            // (Limited to 50 to avoid payload bloat)
            $live_issues = [];
            $issues_has_more = false;
            // Fetch directly from IssueRepository (staged issues for active scan)
            if ( class_exists( '\SiteFort\Modules\Scanner\Repository\IssueRepository' ) ) {
                $issue_page = $this->prepare_issue_page_for_response(
                    \SiteFort\Modules\Scanner\Repository\IssueRepository::getStagedIssues( $queue['id'], $limit + 1, $offset, true ),
                    $limit
                );
                $live_issues = $issue_page['issues'];
                $issues_has_more = $issue_page['has_more'];
            }

            // Use FSM-based total if available, otherwise fall back to queue
            $progress_total = $progress['total'] ?? null;
            $fsm_total = $queue['cloud_stats']['fsm']['total'] ?? null;
            if ( is_numeric( $progress_total ) && (int) $progress_total > 0 ) {
                $total_items = (int) $progress_total;
            } elseif ( is_numeric( $fsm_total ) && (int) $fsm_total > 0 ) {
                $total_items = (int) $fsm_total;
            } else {
                $total_items = (int) ( $queue['total_items'] ?? 0 );
            }
            if ( class_exists( '\SiteFort\Modules\Scanner\Repository\FileIndexRepository' ) ) {
                $file_index_total = ( new \SiteFort\Modules\Scanner\Repository\FileIndexRepository() )->get_total_files( $queue['id'] );
                if ( $file_index_total > 0 ) {
                    $total_items = max( $total_items, $file_index_total );
                }
            }
            $processed = (int) ( $progress['processed'] ?? 0 );
            $total_items = max( $total_items, $processed );
            $is_analyzing = $progress['is_analyzing'] ?? false;

            wp_send_json_success( [
                'active'       => Queue\ScanQueue::is_active(),
                'scan_id'      => $queue['id'],
                'status'       => $queue['status'],
                'type'         => $queue['scan_type'] ?? 'standard',
                'progress'     => $progress['percentage'] ?? 0,
                'processed'    => $processed,
                'total_items'  => $total_items,
                'is_analyzing' => $is_analyzing,
                'issues'       => $live_issues, // Send live issues so UI can update the list in real-time
                'issues_has_more' => $issues_has_more,
                'component_status' => $component_status,
                'cloud_stats'      => $queue['cloud_stats'] ?? [],
                'last_alert'       => get_transient( 'sitefort_last_scan_alert' ),
                'failure_context'  => null,
                'credit_context'   => $this->get_scan_credit_context( (string) $queue['id'] ),
                'worker_health'    => $this->get_worker_health_payload( (string) $queue['id'] ),
            ] );
        }
    }

    /**
     * Get scanner log status events.
     */
    public function handle_get_status_events() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        $this->nudge_active_scan_scheduler();

        $scan_id    = sanitize_text_field( $this->read_request_scalar_payload( 'scan_id' ) );
        $last_ctime = (float) sanitize_text_field( $this->read_request_scalar_payload( 'last_ctime' ) );

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\ScanStatusRepository' ) ) {
            require_once __DIR__ . '/Repository/ScanStatusRepository.php';
        }

        // If no scan_id provided, try to find active one or published one
        if ( empty( $scan_id ) ) {
             if ( Queue\ScanQueue::is_active() ) {
                 $queue = Queue\ScanQueue::get();
                 $scan_id = $queue['id'];
             } else {
                 $scan_id = \SiteFort\Includes\SettingsStore::get_field( \SiteFort\Includes\SettingsStore::COL_STATE, 'published_scan_id', '' );
             }
        }

        // If still empty, we can't fetch specific events, return empty
        if ( empty( $scan_id ) ) {
            wp_send_json_success( [ 'events' => [] ] );
        }

        $events = \SiteFort\Modules\Scanner\Repository\ScanStatusRepository::get_events( $scan_id, $last_ctime );

        $normalized = array_map( function( $event ) {
            $ctime = is_object( $event ) ? (float) ( $event->ctime ?? 0 ) : (float) ( $event['ctime'] ?? 0 );
            $msg = is_object( $event ) ? ( $event->message ?? '' ) : ( $event['message'] ?? '' );
            $type = is_object( $event ) ? ( $event->type ?? 'info' ) : ( $event['type'] ?? 'info' );
            $level = is_object( $event ) ? ( $event->level ?? 1 ) : ( $event['level'] ?? 1 );
            $id = is_object( $event ) ? ( $event->id ?? null ) : ( $event['id'] ?? null );

	            $time = $ctime > 0 ? gmdate( 'H:i:s', (int) floor( $ctime ) ) : null;

            return [
                'id' => $id,
                'ctime' => $ctime,
                'level' => $level,
                'type' => $type,
                'message' => $msg,
                'time' => $time,
            ];
        }, $events );
        
        wp_send_json_success( [ 'events' => $normalized ] );
    }

    /**
     * Stop a running scan.
     */
    public function handle_stop_scan() {
        check_ajax_referer( 'sitefort_nonce', '_ajax_nonce' );
        if ( ! Capabilities::can_manage_plugin() ) {
            wp_send_json_error( [ 'message' => 'Unauthorized' ] );
            return;
        }

        if ( ! Queue\ScanQueue::is_active() ) {
            wp_send_json_error( [ 'message' => 'No active scan' ] );
            return;
        }

        if ( class_exists( '\SiteFort\Modules\Scanner\Service\ScanLogger' ) ) {
             \SiteFort\Modules\Scanner\Service\ScanLogger::log( "Scan stopped by user." );
        }
        Queue\ScanQueue::fail(
            'Stopped by user',
            [
                'error_code' => 'user_stopped',
                'stage'      => 'user_action',
                'retryable'  => false,
                'status_code'=> 409,
            ]
        );
        Queue\ScanQueue::cleanup_task_queue();
        
        wp_send_json_success( [ 'message' => 'Scan stopped' ] );
    }

    /**
     * Invalidate checksum caches when plugins/themes are updated.
     * Hooks into upgrader_process_complete.
     * 
     * @param WP_Upgrader $upgrader Upgrader instance
     * @param array $options Update options containing type and items
     */
    public function invalidate_checksum_caches( $upgrader, $options ) {
        if ( ! is_array( $options ) ) {
            return;
        }

        $type = $options['type'] ?? '';
        $items = $options['plugins'] ?? $options['themes'] ?? [];

        if ( $type === 'plugin' && ! empty( $items ) ) {
            foreach ( $items as $plugin_file ) {
                // Plugin file is like "plugin-slug/plugin.php"
                $slug = dirname( $plugin_file );
                if ( $slug === '.' ) {
                    $slug = basename( $plugin_file, '.php' );
                }
                $this->clear_checksum_transients( 'plugin', $slug );
            }
        } elseif ( $type === 'theme' && ! empty( $items ) ) {
            foreach ( $items as $theme_slug ) {
                $this->clear_checksum_transients( 'theme', $theme_slug );
            }
        } elseif ( $type === 'core' ) {
            global $wp_version;
            delete_transient( "sitefort_core_chk_{$wp_version}" );
        }
    }

    /**
     * Invalidate plugin checksum on activation (new plugin scenario).
     * 
     * @param string $plugin Plugin file path
     */
    public function invalidate_plugin_checksum( $plugin ) {
        $slug = dirname( $plugin );
        if ( $slug === '.' ) {
            $slug = basename( $plugin, '.php' );
        }
        $this->clear_checksum_transients( 'plugin', $slug );
    }

    /**
     * Invalidate theme checksum on switch.
     * 
     * @param string $new_name New theme name
     */
    public function invalidate_theme_checksum( $new_name ) {
        $theme = wp_get_theme( $new_name );
        if ( $theme->exists() ) {
            $this->clear_checksum_transients( 'theme', $theme->get_stylesheet() );
        }
    }

    /**
     * Clear all version-specific checksum transients for a plugin/theme.
     * Since we cache by version, we need to clear all potential cached versions.
     * 
     * @param string $type 'plugin' or 'theme'
     * @param string $slug Plugin/theme slug
     */
    private function clear_checksum_transients( $type, $slug ) {
        global $wpdb;
        
        // Find and delete all transients matching the pattern
        $pattern = "sitefort_chk_{$type}_{$slug}_%";
        
        // Delete from options table (transients are stored there)
	        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Bulk transient cleanup is an intentional direct delete against the options table.
	        $wpdb->query( 
	            $wpdb->prepare( 
                "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s",
                '_transient_' . $pattern,
                '_transient_timeout_' . $pattern
            ) 
        );
        
        // Also clear from object cache if available
        if ( wp_using_ext_object_cache() ) {
            wp_cache_delete( "sitefort_chk_{$type}_{$slug}", 'transient' );
        }
    }

    /**
     * Resolve scan action file identifier into normalized absolute + relative paths.
     *
     * @param string $item_id
     * @param bool   $require_exists Whether the file must already exist on disk.
     * @return array|\WP_Error
     */
    private function resolve_scan_item_path( $item_id, bool $require_exists = true ) {
        $raw = trim( (string) $item_id );
        if ( $raw === '' ) {
            return new \WP_Error( 'missing_file', 'Missing file path for this action.' );
        }

        $wp_root   = wp_normalize_path( trailingslashit( ABSPATH ) );
        $candidate = wp_normalize_path( $raw );
        $relative_path = ( strpos( $candidate, $wp_root ) === 0 )
            ? substr( $candidate, strlen( $wp_root ) )
            : ltrim( $candidate, '/' );
        $relative_path = ltrim( wp_normalize_path( $relative_path ), '/' );

        if ( $relative_path === '' ) {
            return new \WP_Error( 'invalid_relative_path', 'Could not derive relative file path.' );
        }
        if ( preg_match( '#(?:^|/)\.\.(?:/|$)#', $relative_path ) || preg_match( '#(?:^|/)\.(?:/|$)#', $relative_path ) ) {
            return new \WP_Error( 'invalid_path', 'Invalid file path.' );
        }

        $candidate = wp_normalize_path( $wp_root . $relative_path );

        if ( $require_exists && ( ! file_exists( $candidate ) || ! is_file( $candidate ) ) ) {
            return new \WP_Error( 'file_not_found', 'File not found on disk.' );
        }

        $absolute_path = $candidate;
        if ( file_exists( $candidate ) ) {
            $real = realpath( $candidate );
            if ( ! $real ) {
                return new \WP_Error( 'invalid_path', 'Could not resolve file path.' );
            }
            $absolute_path = wp_normalize_path( $real );
        }

        if ( strpos( $absolute_path, $wp_root ) !== 0 ) {
            return new \WP_Error( 'outside_wp_root', 'Target file is outside WordPress root.' );
        }

        return [
            'absolute_path' => $absolute_path,
            'relative_path' => ltrim( substr( $absolute_path, strlen( $wp_root ) ), '/' ),
        ];
    }

    /**
     * Resolve a file target for scanner actions and require a matching stored finding.
     *
     * @param string $item_id
     * @param string $item_hash
     * @param string $item_scan_id
     * @param bool   $require_exists
     * @return array|\WP_Error
     */
    private function resolve_scan_issue_file_target( string $item_id, string $item_hash = '', string $item_scan_id = '', bool $require_exists = true ) {
        $issue_row = $item_hash !== ''
            ? $this->load_issue_record( $item_hash, $item_scan_id )
            : null;

        if ( ! $issue_row && $item_id === '' ) {
            return new \WP_Error( 'missing_file', 'Missing file path for this action.' );
        }

        if ( $issue_row ) {
            $issue_relative_path = $this->extract_issue_relative_file_path( $issue_row );
            if ( $issue_relative_path === '' ) {
                return new \WP_Error( 'invalid_issue_target', 'This finding is not file-backed and cannot be opened or remediated as a file.' );
            }

            if ( $item_id !== '' ) {
                $requested = $this->resolve_scan_item_path( $item_id, $require_exists );
                if ( is_wp_error( $requested ) ) {
                    return $requested;
                }
                if ( ! hash_equals( $issue_relative_path, $requested['relative_path'] ) ) {
                    return new \WP_Error( 'file_target_mismatch', 'Requested file does not match the recorded finding.' );
                }
            }

            $resolved = $this->resolve_scan_item_path( $issue_relative_path, $require_exists );
            if ( is_wp_error( $resolved ) ) {
                return $resolved;
            }
            $resolved['issue_row'] = $issue_row;
            return $resolved;
        }

        $resolved = $this->resolve_scan_item_path( $item_id, $require_exists );
        if ( is_wp_error( $resolved ) ) {
            return $resolved;
        }

        $issue_row = $this->find_issue_record_by_path( $resolved['relative_path'], $item_scan_id );
        if ( ! $issue_row ) {
            return new \WP_Error( 'untracked_file', 'This file is not associated with a recorded scanner finding.' );
        }

        $resolved['issue_row'] = $issue_row;
        return $resolved;
    }

    /**
     * Load a published or staged issue row by hash.
     *
     * @param string $item_hash
     * @param string $item_scan_id
     * @return array|null
     */
    private function load_issue_record( string $item_hash, string $item_scan_id = '' ): ?array {
        $item_hash = sanitize_text_field( $item_hash );
        $item_scan_id = sanitize_text_field( $item_scan_id );
        if ( $item_hash === '' ) {
            return null;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
            require_once __DIR__ . '/Repository/IssueRepository.php';
        }

        $issue_row = \SiteFort\Modules\Scanner\Repository\IssueRepository::find( $item_hash );
        if ( ! $issue_row && $item_scan_id !== '' ) {
            $issue_row = \SiteFort\Modules\Scanner\Repository\IssueRepository::findStagedByHash( $item_scan_id, $item_hash );
        }
        if ( ! $issue_row && \SiteFort\Modules\Scanner\Queue\ScanQueue::is_active() ) {
            $queue = \SiteFort\Modules\Scanner\Queue\ScanQueue::get();
            if ( $queue && ! empty( $queue['id'] ) ) {
                $issue_row = \SiteFort\Modules\Scanner\Repository\IssueRepository::findStagedByHash( $queue['id'], $item_hash );
            }
        }

        return is_array( $issue_row ) ? $issue_row : null;
    }

    /**
     * Find a file-backed issue row by normalized path.
     *
     * @param string $relative_path
     * @param string $item_scan_id
     * @return array|null
     */
    private function find_issue_record_by_path( string $relative_path, string $item_scan_id = '' ): ?array {
        $relative_path = ltrim( wp_normalize_path( $relative_path ), '/' );
        $item_scan_id = sanitize_text_field( $item_scan_id );
        if ( $relative_path === '' ) {
            return null;
        }

        if ( ! class_exists( 'SiteFort\\Modules\\Scanner\\Repository\\IssueRepository' ) ) {
            require_once __DIR__ . '/Repository/IssueRepository.php';
        }

        if ( $item_scan_id !== '' ) {
            $staged = \SiteFort\Modules\Scanner\Repository\IssueRepository::findStagedByFilePath( $item_scan_id, $relative_path );
            if ( is_array( $staged ) ) {
                return $staged;
            }
        }

        if ( \SiteFort\Modules\Scanner\Queue\ScanQueue::is_active() ) {
            $queue = \SiteFort\Modules\Scanner\Queue\ScanQueue::get();
            if ( $queue && ! empty( $queue['id'] ) ) {
                $staged = \SiteFort\Modules\Scanner\Repository\IssueRepository::findStagedByFilePath( (string) $queue['id'], $relative_path );
                if ( is_array( $staged ) ) {
                    return $staged;
                }
            }
        }

        $active = \SiteFort\Modules\Scanner\Repository\IssueRepository::findByFilePath( $relative_path );
        return is_array( $active ) ? $active : null;
    }

    /**
     * Extract a normalized relative file path from an issue row.
     *
     * @param array $issue_row
     * @return string
     */
    private function extract_issue_relative_file_path( array $issue_row ): string {
        $candidates = [];
        if ( ! empty( $issue_row['file_path'] ) ) {
            $candidates[] = (string) $issue_row['file_path'];
        }
        if ( ! empty( $issue_row['file'] ) ) {
            $candidates[] = (string) $issue_row['file'];
        }

        foreach ( $candidates as $candidate ) {
            $normalized = $this->normalize_issue_relative_path( $candidate );
            if ( $normalized !== '' ) {
                return $normalized;
            }
        }

        return '';
    }

    /**
     * Normalize a stored issue file reference into an ABSPATH-relative path.
     *
     * @param string $path
     * @return string
     */
    private function normalize_issue_relative_path( string $path ): string {
        $path = trim( wp_normalize_path( $path ) );
        if ( $path === '' ) {
            return '';
        }

        $wp_root = wp_normalize_path( trailingslashit( ABSPATH ) );
        if ( strpos( $path, $wp_root ) === 0 ) {
            $path = substr( $path, strlen( $wp_root ) );
        }

        $path = ltrim( $path, '/' );
        if ( $path === '' ) {
            return '';
        }
        if ( preg_match( '#(?:^|/)\.\.(?:/|$)#', $path ) || preg_match( '#(?:^|/)\.(?:/|$)#', $path ) ) {
            return '';
        }

        return $path;
    }

    /**
     * Determine the protection tier for a root-level file.
     *
     * @param string $relative_path ABSPATH-relative normalized file path.
     * @return string|null 'locked', 'download_first', or null.
     */
    private function get_file_protection_tier( string $relative_path ): ?string {
        $relative_path = ltrim( wp_normalize_path( $relative_path ), '/' );
        if ( $relative_path === '' ) {
            return null;
        }

        if ( $relative_path === 'wp-config.php' ) {
            return 'locked';
        }

        $download_first_files = [ '.htaccess', '.user.ini' ];
        $user_ini_filename = \SiteFort\Includes\SitePathResolver::get_user_ini_filename();
        if ( $user_ini_filename !== '' ) {
            $download_first_files[] = $user_ini_filename;
        }

        if ( in_array( $relative_path, $download_first_files, true ) ) {
            return 'download_first';
        }

        return null;
    }

    /**
     * Build secure fallback URL for large-file browser tab viewer.
     *
     * @param string $relative_path
     * @param string $item_hash
     * @param string $item_scan_id
     * @return string
     */
    private function build_file_viewer_fallback_url( $relative_path, string $item_hash = '', string $item_scan_id = '' ) {
        return add_query_arg(
            [
                'action'      => 'sitefort_view_file_content',
                'item_id'     => ltrim( (string) $relative_path, '/' ),
                'item_hash'   => $item_hash,
                'item_scan_id'=> $item_scan_id,
                '_ajax_nonce' => wp_create_nonce( 'sitefort_nonce' ),
            ],
            admin_url( 'admin-ajax.php' )
        );
    }

    /**
     * Protect sensitive file by blocking direct web access via .htaccess rules.
     * Works for Apache/LiteSpeed. Nginx requires manual server config by design.
     *
     * @param string $absolute_path
     * @param string $relative_path
     * @return true|\WP_Error
     */
    private function protect_sensitive_file_access( $absolute_path, $relative_path ) {
        if ( ! file_exists( $absolute_path ) || ! is_file( $absolute_path ) ) {
            return new \WP_Error( 'file_missing', 'Cannot protect a file that does not exist.' );
        }

        $public_path = \SiteFort\Includes\SitePathResolver::get_public_path_from_local_path( (string) $absolute_path );
        if ( ! is_string( $public_path ) || $public_path === '' ) {
            $public_path = \SiteFort\Includes\SitePathResolver::get_site_root_file_path( ltrim( (string) $relative_path, '/' ) );
        }
        $public_path = \SiteFort\Includes\SitePathResolver::normalize_request_path( $public_path );

        $server_type = \SiteFort\Includes\ServerConfigWriter::detect_server();
        $is_apache_like = in_array( $server_type, [ 'apache', 'litespeed' ], true );

        if ( ! $is_apache_like ) {
            return new \WP_Error(
                'manual_nginx_rule_required',
                sprintf(
                    'Automatic protection is available on Apache/LiteSpeed. For Nginx, add: location = /%s { deny all; }',
                    ltrim( $public_path, '/' )
                )
            );
        }

        if ( ! function_exists( 'insert_with_markers' ) ) {
            require_once ABSPATH . 'wp-admin/includes/misc.php';
        }

        $htaccess = \SiteFort\Includes\ServerConfigWriter::get_config_file_path( 'apache' );
        $htaccess_dir = dirname( $htaccess );
        if ( ! file_exists( $htaccess ) ) {
            if ( ! wp_is_writable( $htaccess_dir ) ) {
                return new \WP_Error( 'htaccess_not_writable', 'Web root is not writable. Create .htaccess and add deny rules manually.' );
            }
            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Creating an empty .htaccess bootstrap file before insert_with_markers() manages the contents.
            $created = @file_put_contents( $htaccess, '' );
            if ( $created === false ) {
                return new \WP_Error( 'htaccess_create_failed', 'Could not create .htaccess for protection rules.' );
            }
        }

        if ( ! wp_is_writable( $htaccess ) ) {
            return new \WP_Error( 'htaccess_not_writable', '.htaccess is not writable. Update permissions and retry.' );
        }

        $safe_path = preg_quote( ltrim( $public_path, '/' ), '#' );
        $rule = 'RewriteRule ^' . $safe_path . '$ - [F,L,NC]';
        $marker = 'SITEFORT_PROTECT_' . md5( $relative_path );
        $lines = [
            '<IfModule mod_rewrite.c>',
            'RewriteEngine On',
            $rule,
            '</IfModule>',
        ];

        $ok = insert_with_markers( $htaccess, $marker, $lines );
        if ( ! $ok ) {
            return new \WP_Error( 'htaccess_write_failed', 'Failed to persist protection rule to .htaccess.' );
        }

        return true;
    }

    public function get_name(): string {
        return 'Scanner';
    }
}
