<?php

declare(strict_types=1);

namespace SecurWP\Modules\Scanner\Queue;

use SecurWP\Modules\Scanner\Repository\ScanQueueRepository;
use SecurWP\Modules\Scanner\Repository\TaskQueueRepository;
use SecurWP\Modules\Scanner\Service\ScanStatusService;

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

/**
 * Class ScanQueue
 * 
 * Orchestrator for the SecurWP Scan Engine.
 * Delegates responsibilities to specialized services (SRP).
 * 
 */
class ScanQueue {

    public const SYSTEM_CHECKS = [ 'reputation', 'users', 'content', 'sensitive_data', 'vulnerabilities' ];
    public const SYSTEM_CHECK_PRIORITY = 90;
    private const SYSTEM_CHECK_INITIAL_DELAY_SECONDS = 5;
    
    /** @var ScanQueueRepository */
    private static $repo;

    /** @var ScanStatusService */
    private static $statusService;

    /**
     * Escape a plugin-owned table identifier for direct interpolation.
     */
    private static function table_identifier( string $table ): string {
        if ( $table === '' || strlen( $table ) > 64 || ! preg_match( '/^[A-Za-z0-9_]+$/', $table ) ) {
            throw new \InvalidArgumentException( 'Invalid table identifier.' );
        }

        return '`' . str_replace( '`', '``', $table ) . '`';
    }

    /**
     * Resolve issue types that should be reconciled for completed components.
     *
     * File scan findings now persist under the canonical `integrity` type, but
     * we still include the legacy `modified`/`core` aliases so older rows are
     * swept during the next completed file-scan publish.
     *
     * @param array<int,string> $completed_components
     * @return array<int,string>
     */
    private static function resolve_issue_types_for_completed_components( array $completed_components ): array {
        $map_component_to_types = [
            'file_scan'       => [ 'malware', 'fake_image', 'integrity', 'modified', 'core' ],
            'reputation'      => [ 'blocklist' ],
            'users'           => [ 'user' ],
            'content'         => [ 'content' ],
            'sensitive_data'  => [ 'sensitive_data' ],
            'vulnerabilities' => [ 'vulnerability' ],
        ];

        $types_to_resolve = [];
        foreach ( $completed_components as $component ) {
            if ( isset( $map_component_to_types[ $component ] ) ) {
                $types_to_resolve = array_merge( $types_to_resolve, $map_component_to_types[ $component ] );
            }
        }

        return array_values( array_unique( $types_to_resolve ) );
    }

    // Singleton / Static Initialization
    private static function init() {
        if ( ! isset( self::$repo ) ) {
            self::$repo = new ScanQueueRepository();
            self::$statusService = new ScanStatusService();
        }

    }

    /**
     * Create a new scan job.
     * 
     * @param string $scan_type
     * @param string $source
     * @param string|null $force_id Optional pre-generated ID (e.g. from preflight)
     */
    public static function create( $scan_type, $source = 'local', $force_id = null ) {
        self::init();

        $job_id = $force_id ? $force_id : 'scan_' . wp_generate_uuid4();
        $now = time();

        $data = [
            'job_id'      => $job_id,
            'scan_type'   => $scan_type,
            'source'      => $source,
            'status'      => 'pending',
            'total_count' => 0,
            'processed_count' => 0,
            'cloud_stats' => wp_json_encode( [ 'files' => [ 'pending' => 0, 'complete' => 0, 'failed' => 0 ] ] ),
            'component_status' => wp_json_encode( [] ),
            'next_run_at' => $now,
            'last_dispatch_at' => 0,
            'cloud_error_count' => 0,
            'created_at'  => $now,
            'updated_at'  => $now,
            'last_ping'   => $now, // Initialize to prevent false "stuck" detection
        ];

        $insert_id = self::$repo->create_if_no_active( $data );
        if ( $insert_id ) {
            // Winner-only reset. These must only run after we own queue creation.
            delete_transient( 'securwp_cloud_fail_count' );
            delete_transient( 'securwp_last_scan_alert' );
            \SecurWP\Includes\SettingsStore::delete_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_failure_context' );

            if ( class_exists( 'SecurWP\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
                \SecurWP\Modules\Scanner\Service\ScanLogger::clear();
            }
            self::reset_scan_runtime_tables();

            if ( class_exists( 'SecurWP\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
                \SecurWP\Modules\Scanner\Service\ScanLogger::log( "QUEUE_CREATE: Success." );
            }
            $queue = self::get_job( $job_id );

            // Create the scan-run history row up front so terminal status updates always have a target.
            if ( $queue && ! class_exists( '\SecurWP\Modules\Scanner\Repository\IssueRepository' ) ) {
                require_once dirname( __DIR__ ) . '/Repository/IssueRepository.php';
            }
            if ( $queue ) {
                $config_snapshot = [
                    'type' => $scan_type,
                    'mode' => 'standard',
                    'items_count' => 0
                ];
                \SecurWP\Modules\Scanner\Repository\IssueRepository::createScanRun( 
                    $queue['id'], 
                    $config_snapshot, 
                    $now 
                );

                do_action( 'securwp_scan_started', [
                    'site_url'  => home_url(),
                    'scan_id'   => $queue['job_id'] ?? $queue['id'],
                    'scan_type' => $scan_type,
                    'status'    => $queue['status'] ?? 'pending',
                ] );
            }

            if ( ! $queue || ! self::seed_initial_tasks( (string) $queue['id'] ) ) {
                if ( $queue ) {
                    self::$repo->update( $queue['id'], [
                        'status'     => 'failed',
                        'updated_at' => $now,
                    ] );
                }
                return false;
            }

            return $queue;
        }
        
        if ( class_exists( 'SecurWP\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
            \SecurWP\Modules\Scanner\Service\ScanLogger::log( "QUEUE_CREATE: blocked (active scan exists or insert failed).", 3, 'error' );
        }
        return false;
    }

    /**
     * Reset runtime scan artifacts from previous runs.
     *
     * Runs only after atomic queue creation wins to avoid deleting artifacts
     * while another active scan exists.
     */
    private static function reset_scan_runtime_tables(): void {
        global $wpdb;

        $cloud_jobs_table  = $wpdb->prefix . 'securwp_cloud_jobs';
        $cloud_jobs_identifier = self::table_identifier( $cloud_jobs_table );
        $file_states_table = $wpdb->prefix . 'securwp_file_states';
        $file_states_identifier = self::table_identifier( $file_states_table );

        $deleted_jobs = 0;
        $deleted_files = 0;

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of a plugin-owned cloud jobs table before clearing runtime artifacts.
        $cloud_jobs_exists = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $cloud_jobs_table ) );
        if ( $cloud_jobs_exists === $cloud_jobs_table ) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            $deleted_jobs = (int) $wpdb->query( "DELETE FROM {$cloud_jobs_identifier}" );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Checks existence of a plugin-owned file-states table before clearing runtime artifacts.
        $file_states_exists = (string) $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $file_states_table ) );
        if ( $file_states_exists === $file_states_table ) {
            // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
            $deleted_files = (int) $wpdb->query( "DELETE FROM {$file_states_identifier}" );
            // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter
        }

        if ( class_exists( 'SecurWP\\Modules\\Scanner\\Service\\ScanLogger' ) ) {
            \SecurWP\Modules\Scanner\Service\ScanLogger::log(
                sprintf( 'Scan reset: cleared %d cloud jobs and %d file states', $deleted_jobs, $deleted_files )
            );
        }
    }

    /**
     * Seed the durable task queue for a new scan.
     *
     * @param string $scan_id
     * @return bool
     */
    private static function seed_initial_tasks( string $scan_id ): bool {
        if ( ! class_exists( TaskQueueRepository::class ) ) {
            require_once dirname( __DIR__ ) . '/Repository/TaskQueueRepository.php';
        }

        $repo = new TaskQueueRepository();
        $worker_seeded = $repo->enqueue(
            $scan_id,
            'scan_worker',
            [ 'budget_seconds' => 20 ],
            time(),
            100
        );

        if ( false === $worker_seeded || 0 === (int) $worker_seeded ) {
            return false;
        }

        $tasks = [];
        $system_check_available_at = time() + self::SYSTEM_CHECK_INITIAL_DELAY_SECONDS;

        foreach ( self::SYSTEM_CHECKS as $slug ) {
            $tasks[] = [
                'scan_id'      => $scan_id,
                'task_type'    => 'system_check',
                'payload'      => [ 'check_slug' => $slug ],
                'available_at' => $system_check_available_at,
                'priority'     => self::SYSTEM_CHECK_PRIORITY,
                'dedupe_key'   => 'syscheck:' . $slug,
            ];
        }

        $repo->enqueue_batch( $tasks );
        return true;
    }

    public static function get_job( $job_id ) {
        self::init();
        $row = self::$repo->find_by_job_id( $job_id );
        return $row ? self::map_row( $row ) : null;
    }

    public static function get() {
        self::init();
        $row = self::$repo->find_active();
        return $row ? self::map_row( $row ) : null;
    }

    public static function update( $data ) {
        self::init();
        $current = self::get();
        if ( ! $current ) return false;
        
        // Prepare partial update for DB
        $db_update = [];
        
        // Map Queue Keys -> DB Columns
        if ( isset( $data['status'] ) ) $db_update['status'] = $data['status'];
        if ( isset( $data['processed'] ) ) $db_update['processed_count'] = $data['processed'];
        if ( isset( $data['total_items'] ) ) $db_update['total_count'] = $data['total_items'];
        if ( isset( $data['last_ping'] ) ) $db_update['last_ping'] = $data['last_ping'];
        
        if ( isset( $data['cloud_stats'] ) ) {
            $db_update['cloud_stats'] = json_encode( $data['cloud_stats'] );
        }
        if ( isset( $data['next_run_at'] ) ) {
            $db_update['next_run_at'] = (int) $data['next_run_at'];
        }
        if ( isset( $data['last_dispatch_at'] ) ) {
            $db_update['last_dispatch_at'] = (int) $data['last_dispatch_at'];
        }
        if ( isset( $data['cloud_error_count'] ) ) {
            $db_update['cloud_error_count'] = (int) $data['cloud_error_count'];
        }
        if ( isset( $data['component_status'] ) ) {
            $db_update['component_status'] = is_string( $data['component_status'] )
                ? $data['component_status']
                : wp_json_encode( $data['component_status'] );
        }

        return self::$repo->update( $current['id'], $db_update );
    }

    /**
     * Record a cloud error and return the current count.
     *
     * @param string $error_message
     * @return int
     */
    public static function record_cloud_error( string $error_message ): int {
        self::init();
        $queue = self::get();
        if ( ! $queue ) return 0;

        $count = (int) ( $queue['cloud_error_count'] ?? 0 );
        $count++;
        self::update( [
            'cloud_error_count' => $count,
            'last_ping' => time(),
        ] );

        if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
            \SecurWP\Modules\Scanner\Service\ScanLogger::log( 'Cloud error: ' . $error_message, 3, 'error' );
        }

        return $count;
    }

    /**
     * Reset cloud error counter after a successful cloud operation.
     *
     * @return void
     */
    public static function reset_cloud_errors(): void {
        self::init();
        $queue = self::get();
        if ( ! $queue ) return;
        if ( (int) ( $queue['cloud_error_count'] ?? 0 ) === 0 ) return;
        self::update( [ 'cloud_error_count' => 0 ] );
    }

    /**
     * Start the scan (Transition Pending -> Running).
     */
    public static function start() {
        $updated = self::update( [ 'status' => 'running' ] );
        if ( $updated ) {
            $queue = self::get();
            if ( $queue ) {
                do_action( 'securwp_scan_started', [
                    'site_url'  => home_url(),
                    'scan_id'   => $queue['job_id'] ?? $queue['id'],
                    'scan_type' => $queue['scan_type'] ?? ( $queue['type'] ?? 'standard' ),
                    'status'    => 'running',
                ] );
            }
        }
        return $updated;
    }

    /**
     * Mark the scan as complete.
     */
    public static function complete() {
        self::init();
        $queue = self::get();
        if ( ! $queue ) return [];

        $completed_at = time();
        self::$repo->update( $queue['id'], [
            'status'     => 'complete',
            'updated_at' => $completed_at,
        ] );
        \SecurWP\Includes\SettingsStore::set_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_status', 'complete' );
        \SecurWP\Includes\SettingsStore::set_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_time', $completed_at );
        \SecurWP\Includes\SettingsStore::delete_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_failure_context' );
        delete_transient( 'securwp_last_scan_alert' );

        // Track total completed scans for setup widget.
        $completed_count = (int) \SecurWP\Includes\SettingsStore::get_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'scans_completed', 0 );
        \SecurWP\Includes\SettingsStore::set_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'scans_completed', (string) ( $completed_count + 1 ) );

        // Update local queue snapshot to terminal status.
        $queue['status'] = 'complete';

        // Only resolve issues for components that actually completed.

        // 1. Identify Completed Components
        $completed_components = [];
        $component_statuses = self::get_component_status( $queue, false );
        if ( empty( $component_statuses ) ) {
            // Fallback: Assume standard full scan if status map is unavailable.
            $component_statuses = self::$statusService->get_all_complete_status();
        }

        foreach ( $component_statuses as $slug => $status ) {
            if ( $status === 'complete' ) {
                $completed_components[] = $slug;
            }
        }

        // 2. Map completed components to the issue types they own so stale rows
        // from prior scans are removed in the same publish transaction.
        $types_to_resolve = self::resolve_issue_types_for_completed_components( $completed_components );

        $notify_new_vulnerabilities = in_array( 'vulnerabilities', $completed_components, true );
        $previous_vulnerability_issues = [];
        if ( $notify_new_vulnerabilities ) {
            if ( ! class_exists( '\SecurWP\Modules\Vulnerability\Services\VulnerabilityRefreshService' ) ) {
                $path = dirname( dirname( __DIR__ ) ) . '/Vulnerability/Services/VulnerabilityRefreshService.php';
                if ( file_exists( $path ) ) {
                    require_once $path;
                }
            }

            if ( class_exists( '\SecurWP\Modules\Vulnerability\Services\VulnerabilityRefreshService' ) ) {
                $previous_vulnerability_issues = \SecurWP\Modules\Vulnerability\Services\VulnerabilityRefreshService::getActiveVulnerabilityIssues();
            }
        }

        // 3. Atomic Swap: Publish staged findings → active issues
        $published = \SecurWP\Modules\Scanner\Repository\IssueRepository::publish( $queue['job_id'], $types_to_resolve );
        if ( ! $published ) {
            securwp_log( sprintf(
                '[SecurWP] ScanQueue::complete() publish failed for scan_id=%s, scope=%s',
                $queue['job_id'],
                wp_json_encode( $types_to_resolve )
            ) );
        }

        if ( $published && $notify_new_vulnerabilities && class_exists( '\SecurWP\Modules\Vulnerability\Services\VulnerabilityRefreshService' ) ) {
            $current_vulnerability_issues = \SecurWP\Modules\Vulnerability\Services\VulnerabilityRefreshService::getActiveVulnerabilityIssues();
            \SecurWP\Modules\Vulnerability\Services\VulnerabilityRefreshService::dispatchNewVulnerabilityNotifications(
                $previous_vulnerability_issues,
                $current_vulnerability_issues
            );
        }
        $resolved_count = 0;

        // Persist the final per-component status so reloads do not flatten partial failures.
        \SecurWP\Modules\Scanner\Repository\IssueRepository::updateScanRunStatus( $queue['job_id'], $component_statuses, 'complete' );
        
        if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ResourcePressure' ) ) {
            require_once dirname( __DIR__ ) . '/Service/ResourcePressure.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\ResourcePressure' ) ) {
            \SecurWP\Modules\Scanner\Service\ResourcePressure::clear_state( $queue['id'] );
        }

        if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\AIMDController' ) ) {
            require_once dirname( __DIR__ ) . '/Service/AIMDController.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\AIMDController' ) ) {
            \SecurWP\Modules\Scanner\Service\AIMDController::clear_state( $queue['id'] );
        }

        // Fire notification hook for scan completion
        $final_counts = class_exists( '\SecurWP\Modules\Scanner\Repository\IssueRepository' )
            ? \SecurWP\Modules\Scanner\Repository\IssueRepository::getCounts()
            : [ 'active' => 0 ];
        $issues_total = (int) ( $final_counts['active'] ?? 0 );
        \SecurWP\Includes\SettingsStore::set_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'scan_issues_count', $issues_total );

        $max_severity = \SecurWP\Modules\Scanner\Repository\IssueRepository::getMaxSeverity();
        $by_type = $final_counts['by_type'] ?? [];

        if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
            require_once dirname( __DIR__ ) . '/Service/ScanLogger.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
            \SecurWP\Modules\Scanner\Service\ScanLogger::log_for_scan(
                (string) $queue['job_id'],
                sprintf(
                    'Scan completed. Active issues: %d%s',
                    $issues_total,
                    $max_severity ? ' • Highest severity: ' . strtoupper( (string) $max_severity ) : ''
                )
            );
            \SecurWP\Modules\Scanner\Service\ScanLogger::log_memory_usage( (string) $queue['job_id'] );
        }

        do_action( 'securwp_scan_completed', [
            'site_url'     => home_url(),
            'issues_count' => $issues_total,
            'scan_id'      => $queue['job_id'] ?? $queue['id'],
            'severity'     => $max_severity,
            'by_type'      => $by_type,
        ] );

        // Ensure Console receives terminal scan state immediately.
        // The hook-based push (push_terminal_scan_state at priority 20) should
        // have already fired above, but if it failed silently the dirty flag
        // is still set. Force one more synchronous attempt so the console UI
        // stops spinning without waiting for WP-Cron to fire the deferred job.
        if ( class_exists( '\SecurWP\Modules\Cloud\Services\StatePush' ) ) {
            \SecurWP\Modules\Cloud\Services\StatePush::ensure_terminal_scan_pushed();
        }

        return $queue;
    }

    /**
     * Mark scan as failed.
     */
    public static function fail( $error, array $context = [] ) {
        self::init();
        $queue = self::get();
        if ( ! $queue ) return false;

        $error = is_string( $error ) && $error !== '' ? $error : 'Scan failed due to an unexpected error.';
        $failure_context = self::normalize_failure_context( $queue, $error, $context );

        self::$repo->update( $queue['id'], [
            'status' => 'failed',
        ]);
        \SecurWP\Includes\SettingsStore::set_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_status', 'failed' );
        \SecurWP\Includes\SettingsStore::set_field( \SecurWP\Includes\SettingsStore::COL_STATE, 'last_scan_failure_context', $failure_context );

        // Stage a failure issue so it appears in the final scan report.
        if ( ! class_exists( '\SecurWP\Modules\Scanner\Repository\IssueRepository' ) ) {
            require_once dirname( __DIR__ ) . '/Repository/IssueRepository.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Repository\IssueRepository' ) ) {
            \SecurWP\Modules\Scanner\Repository\IssueRepository::stage( [
                'scan_id'   => $queue['job_id'],
                'hash'      => \SecurWP\Modules\Scanner\Repository\IssueRepository::generateHash( 'system_error', 'scan:' . $queue['job_id'] ),
                'type'      => 'system_error',
                'status'    => 'failed',
                'severity'  => 'high',
                'message'   => $error,
                'file'      => '(scan)',
                'data'      => [
                    'source'            => 'scan_queue_fail',
                    'error_code'        => $failure_context['error_code'] ?? '',
                    'status_code'       => $failure_context['status_code'] ?? 0,
                    'stage'             => $failure_context['stage'] ?? '',
                    'incident_ref'      => $failure_context['incident_ref'] ?? '',
                    'request_id'        => $failure_context['request_id'] ?? '',
                    'occurred_at'       => $failure_context['occurred_at'] ?? time(),
                    'retryable'         => $failure_context['retryable'] ?? true,
                    'technical_message' => $failure_context['technical_message'] ?? '',
                ],
            ] );
        }

        if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
            require_once dirname( __DIR__ ) . '/Service/ScanLogger.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanLogger' ) ) {
            \SecurWP\Modules\Scanner\Service\ScanLogger::log_for_scan( (string) $queue['job_id'], 'Scan failed: ' . $error, 3, 'error' );
            \SecurWP\Modules\Scanner\Service\ScanLogger::log_memory_usage( (string) $queue['job_id'] );
        }

        // If we have ANY staged findings, replace the last published report
        // with this scan's partial results. If there are no findings, keep the previous report and
        // clean up staging artifacts.
        if ( ! class_exists( '\SecurWP\Modules\Scanner\Repository\IssueRepository' ) ) {
            require_once dirname( __DIR__ ) . '/Repository/IssueRepository.php';
        }

        $scan_id = $queue['job_id'];
        $staged_count = \SecurWP\Modules\Scanner\Repository\IssueRepository::countStaged( $scan_id );

        // Freeze component status before the queue row is discarded so stopped scans reload accurately.
        $frozen_status = self::get_component_status( $queue, false );
        // Mark any 'active' components as 'stopped' since the scan was interrupted
        foreach ( $frozen_status as $slug => $status ) {
            if ( $status === 'active' ) {
                $frozen_status[$slug] = 'stopped';
            }
        }

        if ( $staged_count > 0 ) {
            $published = \SecurWP\Modules\Scanner\Repository\IssueRepository::publish( $scan_id, [] );
            if ( ! $published ) {
                securwp_log( sprintf(
                    '[SecurWP] ScanQueue::fail() publish failed for scan_id=%s',
                    $scan_id
                ) );
            }
            // Persist the frozen status for reload
            \SecurWP\Modules\Scanner\Repository\IssueRepository::updateScanRunStatus( $scan_id, $frozen_status, 'stopped' );
        } else {
            \SecurWP\Modules\Scanner\Repository\IssueRepository::discardStaged( $scan_id );
            \SecurWP\Modules\Scanner\Repository\IssueRepository::discardScanRun( $scan_id );
        }
        
        // Do not emit failure alerts for controlled stop conditions.
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\ScanAlertService' ) ) {
            $scan_error_code = sanitize_key( (string) ( $failure_context['error_code'] ?? 'scan_failed' ) );
            $suppressed_alert_codes = [ 'user_stopped', 'console_stopped', 'plugin_deactivated', 'credits_exhausted' ];
            $is_controlled_stop = in_array( $scan_error_code, $suppressed_alert_codes, true )
                || stripos( $error, 'stopped by user' ) !== false;

            if ( ! $is_controlled_stop ) {
                \SecurWP\Modules\Scanner\Service\ScanAlertService::send_alert( 'scan_failed', $error, $failure_context );
            }
        }

        if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\ResourcePressure' ) ) {
            require_once dirname( __DIR__ ) . '/Service/ResourcePressure.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\ResourcePressure' ) ) {
            \SecurWP\Modules\Scanner\Service\ResourcePressure::clear_state( $queue['job_id'] );
        }

        if ( ! class_exists( '\SecurWP\Modules\Scanner\Service\AIMDController' ) ) {
            require_once dirname( __DIR__ ) . '/Service/AIMDController.php';
        }
        if ( class_exists( '\SecurWP\Modules\Scanner\Service\AIMDController' ) ) {
            \SecurWP\Modules\Scanner\Service\AIMDController::clear_state( $queue['job_id'] );
        }

        $scan_error_code = sanitize_key( (string) ( $failure_context['error_code'] ?? 'scan_failed' ) );
        $is_stopped = in_array( $scan_error_code, [ 'user_stopped', 'console_stopped', 'plugin_deactivated', 'credits_exhausted' ], true )
            || stripos( $error, 'stopped' ) !== false;
        $scan_event = [
            'site_url'   => home_url(),
            'scan_id'    => $queue['job_id'] ?? $queue['id'],
            'error'      => $error,
            'error_code' => $scan_error_code,
            'context'    => $failure_context,
        ];
        // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.DynamicHooknameFound -- Both branches are plugin-prefixed hooks; the dynamic ternary only selects between them.
        do_action( $is_stopped ? 'securwp_scan_stopped' : 'securwp_scan_failed', $scan_event );

        // Same safety net as complete(): force synchronous push if the
        // hook-based push did not clear the dirty flags.
        if ( class_exists( '\SecurWP\Modules\Cloud\Services\StatePush' ) ) {
            \SecurWP\Modules\Cloud\Services\StatePush::ensure_terminal_scan_pushed();
        }

        return true;
    }

    /**
     * Normalize and sanitize failure context for persistence and UI reporting.
     *
     * @param array  $queue
     * @param string $message
     * @param array  $context
     * @return array<string,mixed>
     */
    private static function normalize_failure_context( array $queue, string $message, array $context ): array {
        $occurred_at = isset( $context['occurred_at'] ) ? (int) $context['occurred_at'] : time();
        $scan_id = sanitize_text_field( (string) ( $queue['job_id'] ?? '' ) );
        $error_code = isset( $context['error_code'] ) ? sanitize_key( (string) $context['error_code'] ) : 'scan_failed';
        $stage = isset( $context['stage'] ) ? sanitize_key( (string) $context['stage'] ) : 'scanner';
        $status_code = isset( $context['status_code'] ) ? (int) $context['status_code'] : 0;
        $request_id = isset( $context['request_id'] ) ? sanitize_text_field( (string) $context['request_id'] ) : '';
        $host_id = isset( $context['host_id'] ) ? sanitize_text_field( (string) $context['host_id'] ) : '';
        $technical_message = isset( $context['technical_message'] ) ? sanitize_text_field( (string) $context['technical_message'] ) : '';

        $non_retryable_codes = [ 'user_stopped', 'console_stopped', 'plugin_deactivated' ];
        $retryable = array_key_exists( 'retryable', $context )
            ? (bool) $context['retryable']
            : ! in_array( $error_code, $non_retryable_codes, true );

        $seed = $scan_id . '|' . $occurred_at . '|' . $error_code . '|' . $message;
        $incident_ref = isset( $context['incident_ref'] )
            ? sanitize_text_field( (string) $context['incident_ref'] )
            : strtoupper( substr( hash( 'sha256', $seed ), 0, 12 ) );

        return [
            'scan_id'            => $scan_id,
            'message'            => sanitize_text_field( $message ),
            'error_code'         => $error_code,
            'stage'              => $stage,
            'status_code'        => $status_code,
            'request_id'         => $request_id,
            'host_id'            => $host_id,
            'retryable'          => $retryable,
            'occurred_at'        => $occurred_at,
            'incident_ref'       => $incident_ref,
            'cloud_error_count'  => isset( $context['cloud_error_count'] ) ? (int) $context['cloud_error_count'] : (int) ( $queue['cloud_error_count'] ?? 0 ),
            'technical_message'  => $technical_message,
        ];
    }

    // --- Delegated Methods ---

    /**
     * Heartbeat: refresh the durable queue heartbeat.
     * Best-effort and safe to call frequently.
     */
    public static function heartbeat() {
        self::init();
        self::update( [ 'last_ping' => time() ] );
    }

    /**
     * Delete durable task rows for a scan.
     *
     * Use this only from out-of-band terminal paths. The task-runner path
     * performs its own post-commit cleanup so it does not delete the currently
     * leased task row before TaskRunner finishes bookkeeping.
     *
     * @param string $scan_id
     * @return void
     */
    public static function cleanup_task_queue( string $scan_id = '' ): void {
        self::init();

        if ( '' === $scan_id ) {
            $queue = self::get();
            $scan_id = is_array( $queue ) ? (string) ( $queue['id'] ?? '' ) : '';
        }

        if ( '' === $scan_id ) {
            return;
        }

        if ( ! class_exists( TaskQueueRepository::class ) ) {
            require_once dirname( __DIR__ ) . '/Repository/TaskQueueRepository.php';
        }

        ( new TaskQueueRepository() )->delete_by_scan_id( $scan_id );
    }

    /**
     * Persist component status transitions for UI consistency.
     *
     * @param string $slug Component slug
     * @param string $status pending|active|complete|failed|stopped
     * @param int $active_for Seconds to keep "active" visible after transition
     * @return bool|int
     */
    public static function set_component_status( $slug, $status, $active_for = 0 ) {
        self::init();
        if ( empty( $slug ) ) return false;

        $queue = self::get();
        if ( ! $queue ) return false;

        $current = $queue['component_status'] ?? [];
        $normalized = self::normalize_component_status_map( $current );
        $now = time();

        $active_until = 0;
        if ( isset( $normalized[ $slug ]['active_until'] ) ) {
            $active_until = (int) $normalized[ $slug ]['active_until'];
        }

        if ( $status === 'active' && $active_for > 0 ) {
            $active_until = $now + (int) $active_for;
        }

        // Clear the active grace window when transitioning to a terminal state.
        // Preserving it would cause the frontend to show stale "scanning" states
        // when multiple checks complete back-to-back within the grace period.
        if ( in_array( $status, [ 'complete', 'failed', 'stopped' ], true ) ) {
            $active_until = 0;
        }

        $normalized[ $slug ] = [
            'status'     => $status,
            'updated_at' => $now,
        ];
        if ( $active_until > 0 ) {
            $normalized[ $slug ]['active_until'] = $active_until;
        }

        return self::update( [ 'component_status' => $normalized ] );
    }

    public static function get_component_status( $queue, $allow_active_grace = true ) {
        self::init();
        $base = self::$statusService->get_component_status( $queue );
        $stored = $queue['component_status'] ?? [];
        $normalized = self::normalize_component_status_map( $stored );

        if ( empty( $normalized ) ) {
            return $base;
        }

        $now = time();
        foreach ( $normalized as $slug => $entry ) {
            $status = $entry['status'] ?? null;
            if ( ! $status ) continue;

            // Computed statuses are authoritative; do not overwrite them with stale DB values.
            if ( in_array( $slug, ['file_scan', 'file_changes', 'cloud_ai'], true ) ) {
                continue;
            }

            $active_until = (int) ( $entry['active_until'] ?? 0 );
            if ( $allow_active_grace && $active_until > 0 && $now <= $active_until && ! in_array( $status, [ 'failed', 'stopped' ], true ) ) {
                $status = 'active';
            }

            $base[ $slug ] = $status;
        }

        return $base;
    }

    public static function get_progress() {
        self::init();
        $queue = self::get();
        return self::$statusService->get_progress( $queue );
    }

    public static function is_stuck( $threshold = 45 ) {
        self::init();
        $queue = self::get();
        return self::$statusService->is_stuck( $queue, $threshold );
    }
    
    public static function is_active() {
        $queue = self::get();
        return $queue && in_array( $queue['status'], [ 'pending', 'running' ], true );
    }
    
    /**
     * Get the pinned malware signature version for a scan.
     *
     * The scan_queue record is the source of truth; a transient is used as cache.
     *
     * @param string $scan_id Scan UUID.
     * @return string|null Signature version or null if not yet pinned.
     */
    public static function get_pinned_signature_version( string $scan_id ): ?string {
        global $wpdb;
        $table = self::table_identifier( $wpdb->prefix . 'securwp_scan_queue' );

        // phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $val = (string) $wpdb->get_var(
            $wpdb->prepare(
                "SELECT malware_signature_version FROM {$table} WHERE job_id = %s",
                $scan_id
            )
        );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,PluginCheck.Security.DirectDB.UnescapedDBParameter,WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $val = trim( $val );
        if ( $val !== '' ) {
            set_transient( 'securwp_scan_sig_version_' . $scan_id, $val, DAY_IN_SECONDS );
            return $val;
        }

        $cached = get_transient( 'securwp_scan_sig_version_' . $scan_id );
        return is_string( $cached ) && $cached !== '' ? $cached : null;
    }

    // --- Inflight methods removed; scan_cloud_jobs is the canonical store. ---

    // --- Mappers ---

    private static function map_row( $row ) {
        $component_status = [];
        if ( isset( $row['component_status'] ) ) {
            if ( is_array( $row['component_status'] ) ) {
                $component_status = $row['component_status'];
            } elseif ( is_string( $row['component_status'] ) && $row['component_status'] !== '' ) {
                $decoded = json_decode( $row['component_status'], true );
                if ( is_array( $decoded ) ) {
                    $component_status = $decoded;
                }
            }
        }

        return [
            'id'          => $row['job_id'],
            'job_id'      => $row['job_id'],
            'db_id'       => $row['id'],
            'status'      => $row['status'],
            'source'      => $row['source'],
            'type'        => $row['scan_type'],
            'scan_type'   => $row['scan_type'],
            'total_items' => (int)$row['total_count'],
            'processed'   => (int)$row['processed_count'],
            'cloud_stats' => !empty($row['cloud_stats']) ? json_decode( $row['cloud_stats'], true ) : [ 'files' => [ 'pending' => 0, 'complete' => 0, 'failed' => 0 ] ],
            'created_at'  => (int)$row['created_at'],
            'last_ping'   => (int)($row['last_ping'] ?? $row['updated_at']), // Use dedicated column, fallback to updated_at
            'next_run_at' => (int)($row['next_run_at'] ?? 0),
            'last_dispatch_at' => (int)($row['last_dispatch_at'] ?? 0),
            'cloud_error_count' => (int)($row['cloud_error_count'] ?? 0),
            'issues'      => [],
            'component_status' => $component_status,
        ];
    }

    private static function normalize_component_status_map( $raw ) {
        if ( is_string( $raw ) ) {
            $raw = json_decode( $raw, true );
        }
        if ( ! is_array( $raw ) ) {
            return [];
        }

        $normalized = [];
        foreach ( $raw as $slug => $entry ) {
            if ( is_array( $entry ) ) {
                $normalized[ $slug ] = [
                    'status'       => $entry['status'] ?? null,
                    'active_until' => isset( $entry['active_until'] ) ? (int) $entry['active_until'] : 0,
                    'updated_at'   => isset( $entry['updated_at'] ) ? (int) $entry['updated_at'] : 0,
                ];
            } elseif ( is_string( $entry ) ) {
                $normalized[ $slug ] = [ 'status' => $entry ];
            }
        }

        return $normalized;
    }
}
