<?php

declare(strict_types=1);

namespace SiteFort\Modules\Scanner\Queue;

use SiteFort\Modules\Scanner\Queue\ScanOrchestrator;
use SiteFort\Modules\Scanner\Queue\Handler\FinalizeHandler;
use SiteFort\Modules\Scanner\Queue\Handler\PollCloudHandler;
use SiteFort\Modules\Scanner\Queue\Handler\ScanWorkerHandler;
use SiteFort\Modules\Scanner\Queue\Handler\SystemCheckHandler;
use SiteFort\Modules\Scanner\Queue\Handler\TaskResult;
use SiteFort\Modules\Scanner\Queue\Handler\UploadBatchHandler;
use SiteFort\Modules\Scanner\Repository\CloudJobRepository;
use SiteFort\Modules\Scanner\Repository\TaskQueueRepository;

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

/**
 * Leased task executor for scanner jobs.
 */
class TaskRunner {

	private const DEFAULT_LEASE_SECONDS = 30;
	private const SCAN_WORKER_LEASE_SECONDS = 60;
	private const UPLOAD_LEASE_SECONDS = 150;
	// System checks make sequential outbound requests; a short lease re-claims them mid-run.
	private const SYSTEM_CHECK_LEASE_SECONDS = 120;

	/** @var ScanOrchestrator|null Reused across tasks in one tick; construction costs queries. */
	private $reconcile_orchestrator = null;

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

	private function get_reconcile_orchestrator( string $scan_id ): ScanOrchestrator {
		if (
			$this->reconcile_orchestrator instanceof ScanOrchestrator
			&& $this->reconcile_orchestrator_scan_id === $scan_id
		) {
			$this->reconcile_orchestrator->reset_tick_caches();
			return $this->reconcile_orchestrator;
		}

		$this->reconcile_orchestrator = new ScanOrchestrator( $scan_id );
		$this->reconcile_orchestrator_scan_id = $scan_id;
		return $this->reconcile_orchestrator;
	}

	public function run_tick( ?string $scan_id = null, ?WorkerRuntime $runtime = null ): array {
		$this->ensure_dependencies_loaded();

		$queue = $scan_id ? ScanQueue::get_job( $scan_id ) : ScanQueue::get();
		if ( ! $this->is_scan_active( $queue ) ) {
			return [
				'status'  => 'idle',
				'message' => 'No active scan',
			];
		}

		$scan_id = (string) ( $queue['id'] ?? $scan_id ?? '' );
		if ( '' === $scan_id ) {
			return [
				'status'  => 'idle',
				'message' => 'Missing scan id',
			];
		}

		$runtime = $runtime ?: WorkerRuntime::current( $scan_id );
		$repo = new TaskQueueRepository();
		$started = microtime( true );
		$processed = 0;
		$iterations = 0;
		$task_counts = [];
		$last_task_type = '';
		$last_result = [
			'status'            => 'idle',
			'available_work'    => false,
			'pending_work'      => false,
			'next_available_at' => 0,
		];

		while ( $iterations < $runtime->get_max_tasks_per_tick() ) {
			if ( $processed > 0 && ( microtime( true ) - $started ) >= $runtime->get_tick_budget_seconds() ) {
				break;
			}

			$next_task_type = $repo->get_next_ready_task_type( $scan_id );
			if ( $processed > 0 && $this->should_defer_next_task( $next_task_type, $last_task_type ) ) {
				break;
			}

			$result = $this->run( $scan_id );
			$iterations++;
			$last_result = array_merge( $last_result, $result );

			$status = (string) ( $result['status'] ?? '' );
			if ( ! in_array( $status, [ 'completed', 'reconciled' ], true ) ) {
				break;
			}

			if ( 'completed' === $status ) {
				$processed++;
				$task_type = sanitize_key( (string) ( $result['task_type'] ?? '' ) );
				if ( '' !== $task_type ) {
					$task_counts[ $task_type ] = (int) ( $task_counts[ $task_type ] ?? 0 ) + 1;
				}
				$last_task_type = $task_type;

				if ( $this->should_stop_after_task( $task_type, $started, $runtime ) ) {
					break;
				}
			}

			if ( empty( $result['available_work'] ) ) {
				break;
			}
		}

		$available_work = $repo->has_available_work( $scan_id );
		$pending_work = $repo->has_pending_work( $scan_id );
		$next_available_at = $repo->get_next_available_at( $scan_id );
		$last_status = (string) ( $last_result['status'] ?? '' );

		return [
			'status'            => 'failed' === $last_status ? 'failed' : 'tick_completed',
			'tasks_processed'   => $processed,
			'iterations'        => $iterations,
			'task_counts'       => $task_counts,
			'duration_seconds'  => round( microtime( true ) - $started, 3 ),
			'transport'         => $runtime->get_transport(),
			'available_work'    => $available_work,
			'pending_work'      => $pending_work,
			'next_available_at' => $next_available_at,
			'last_result'       => $last_result,
		];
	}

	/**
	 * Execute one task or reconcile missing work for an active scan.
	 *
	 * @param string|null $scan_id
	 * @return array<string,mixed>
	 */
	public function run( ?string $scan_id = null ): array {
		$this->ensure_dependencies_loaded();

		$queue = $scan_id ? ScanQueue::get_job( $scan_id ) : ScanQueue::get();
		if ( ! is_array( $queue ) || ! in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true ) ) {
			return [
				'status'  => 'idle',
				'message' => 'No active scan',
			];
		}

		$scan_id = (string) ( $queue['id'] ?? '' );
		if ( '' === $scan_id ) {
			return [
				'status'  => 'idle',
				'message' => 'Missing scan id',
			];
		}

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

		$lease_token = wp_generate_password( 32, false );
		if ( ! $repo->acquire_scan_lease( $scan_id, $lease_token, self::DEFAULT_LEASE_SECONDS ) ) {
			return [
				'status'  => 'skipped',
				'message' => 'Another task runner owns the scan lease',
			];
		}

		$task = null;

		try {
			$task = $repo->claim( $scan_id, self::DEFAULT_LEASE_SECONDS, self::UPLOAD_LEASE_SECONDS, self::SCAN_WORKER_LEASE_SECONDS, self::SYSTEM_CHECK_LEASE_SECONDS );

			if ( null === $task ) {
				$orchestrator = $this->get_reconcile_orchestrator( $scan_id );
				$runtime_recovery = $orchestrator->recover_task_queue_runtime_state();
				$queue = ScanQueue::get_job( $scan_id ) ?: $queue;
				$follow_ups = $this->attach_scan_id(
					$this->reconcile_follow_ups( $scan_id, $queue, $repo, $orchestrator ),
					$scan_id
				);
				$inserted = 0;
				if ( ! empty( $follow_ups ) ) {
					$inserted = $repo->enqueue_batch( $follow_ups );
				}

				ScanQueue::heartbeat();
				$queue_after_reconcile = ScanQueue::get_job( $scan_id );
				if ( is_array( $queue_after_reconcile ) && ! in_array( (string) ( $queue_after_reconcile['status'] ?? '' ), [ 'pending', 'running' ], true ) ) {
					$repo->delete_by_scan_id( $scan_id );
				}

				return [
					'status'         => ( empty( $follow_ups ) && ! $this->did_runtime_recover( $runtime_recovery ) ) ? 'idle' : 'reconciled',
					'enqueued'       => $inserted,
					'pending_work'   => $repo->has_pending_work( $scan_id ),
					'available_work' => $repo->has_available_work( $scan_id ),
					'next_available_at' => $repo->get_next_available_at( $scan_id ),
				];
			}

			$lease_seconds = $this->get_lease_seconds( (string) $task['task_type'] );
			if ( ! $repo->extend_scan_lease( $scan_id, $lease_token, $lease_seconds ) ) {
				if ( $this->is_scan_active( ScanQueue::get_job( $scan_id ) ) ) {
					$repo->fail( (int) $task['id'], 'Unable to extend scan lease before task execution', (int) ( $task['attempts'] ?? 0 ) );
				} else {
					$repo->delete_by_scan_id( $scan_id );
				}

				return [
					'status'            => 'skipped',
					'message'           => 'Lost scan lease before task execution',
					'pending_work'      => $repo->has_pending_work( $scan_id ),
					'available_work'    => $repo->has_available_work( $scan_id ),
					'next_available_at' => $repo->get_next_available_at( $scan_id ),
				];
			}

			// Any first task flips pending->running; system checks can be claimed
			// before the scan_worker task, so this can't live in its handler.
			// Re-check under the scan lease so the started action fires once.
			if ( 'pending' === (string) ( $queue['status'] ?? '' ) ) {
				$fresh = ScanQueue::get_job( $scan_id );
				if ( is_array( $fresh ) && 'pending' === (string) ( $fresh['status'] ?? '' ) ) {
					ScanQueue::start();
				}
			}

			$handler = $this->resolve_handler( (string) $task['task_type'] );
			$result = $handler ? $handler->handle( $task, $scan_id ) : TaskResult::failed( 'Unknown task type: ' . (string) $task['task_type'] );
			$follow_ups = $this->attach_scan_id( $result->enqueue, $scan_id );

			$queue_after = ScanQueue::get_job( $scan_id );
			$scan_active = is_array( $queue_after ) && in_array( (string) ( $queue_after['status'] ?? '' ), [ 'pending', 'running' ], true );
			if ( $scan_active ) {
				$reconciled_follow_ups = $this->attach_scan_id(
					$this->reconcile_follow_ups( $scan_id, $queue_after, $repo ),
					$scan_id
				);
				$follow_ups = $this->merge_follow_ups( $follow_ups, $reconciled_follow_ups );
				$queue_after = ScanQueue::get_job( $scan_id );
				$scan_active = is_array( $queue_after ) && in_array( (string) ( $queue_after['status'] ?? '' ), [ 'pending', 'running' ], true );
				if ( ! $scan_active ) {
					$follow_ups = [];
				}
			}

			if ( 'completed' === $result->status || ! $scan_active ) {
				$committed = $repo->complete_and_enqueue( (int) $task['id'], $result->result, $follow_ups, (int) ( $task['attempts'] ?? 0 ) );
				if ( ! $committed ) {
					$repo->fail( (int) $task['id'], 'Task completion transaction failed', (int) ( $task['attempts'] ?? 0 ) );
				}
			} else {
				$repo->fail( (int) $task['id'], (string) ( $result->error ?? 'Task failed' ), (int) ( $task['attempts'] ?? 0 ) );
			}

			ScanQueue::heartbeat();
			$this->cleanup_if_terminal( $scan_id, $repo );

			return [
				'status'         => 'completed',
				'task_id'        => (int) $task['id'],
				'task_type'      => (string) $task['task_type'],
				'handler_status' => $result->status,
				'task_result'    => $result->result,
				'available_work' => $repo->has_available_work( $scan_id ),
				'pending_work'   => $repo->has_pending_work( $scan_id ),
				'next_available_at' => $repo->get_next_available_at( $scan_id ),
			];
		} catch ( \Throwable $e ) {
			$message = sanitize_text_field( $e->getMessage() );
			if ( is_array( $task ) ) {
				if ( $this->is_scan_active( ScanQueue::get_job( $scan_id ) ) ) {
					$repo->fail( (int) $task['id'], $message, (int) ( $task['attempts'] ?? 0 ) );
				} else {
					$repo->complete_and_enqueue(
						(int) $task['id'],
						[
							'status'  => 'aborted',
							'message' => $message,
						],
						[],
						(int) ( $task['attempts'] ?? 0 )
					);
					$repo->delete_by_scan_id( $scan_id );
				}
			}

			return [
				'status'  => 'failed',
				'message' => $message,
			];
		} finally {
			$repo->release_scan_lease( $scan_id, $lease_token );
		}
	}

	/**
	 * Recreate missing tasks from authoritative scan state.
	 *
	 * @param string                 $scan_id
	 * @param array<string,mixed>    $queue
	 * @param TaskQueueRepository    $repo
	 * @param ScanOrchestrator|null  $orchestrator
	 * @return array<int,array<string,mixed>>
	 */
	private function reconcile_follow_ups(
		string $scan_id,
		array $queue,
		TaskQueueRepository $repo,
		?ScanOrchestrator $orchestrator = null
	): array {
		$follow_ups = [];
		$dead_tasks = $repo->get_dead_tasks( $scan_id );
		if ( $this->has_dead_task_type( $dead_tasks, 'finalize' ) ) {
			ScanQueue::fail(
				'Scanner finalization failed repeatedly. Please restart the scan.',
				[
					'error_code'        => 'finalize_task_dead_letter',
					'stage'             => 'finalize',
					'retryable'         => false,
					'status_code'       => 500,
					'technical_message' => 'Finalize task reached dead-letter state',
				]
			);

			return [];
		}

		$blocking_dead_task = $this->get_blocking_dead_pipeline_task( $dead_tasks );
		if ( is_array( $blocking_dead_task ) ) {
			$this->fail_scan_for_dead_task( $blocking_dead_task );
			return [];
		}

		$orchestrator = $orchestrator ?: $this->get_reconcile_orchestrator( $scan_id );

		if ( $orchestrator->has_file_worker_work() && ! $repo->has_task( $scan_id, 'scan_worker', [ TaskQueueRepository::STATUS_PENDING, TaskQueueRepository::STATUS_LEASED ] ) ) {
			$follow_ups[] = [
				'task_type'    => 'scan_worker',
				'payload'      => [],
				'available_at' => $orchestrator->get_next_file_worker_available_at(),
				'priority'     => 100,
			];
		}

		$upload_plan = $orchestrator->get_upload_task_plan();
		if ( ! empty( $upload_plan['has_candidates'] ) ) {
			$inflight_uploads = $repo->count_tasks(
				$scan_id,
				'upload_batch',
				[ TaskQueueRepository::STATUS_PENDING, TaskQueueRepository::STATUS_LEASED ]
			);
			if ( $inflight_uploads <= 0 ) {
				$follow_ups[] = [
					'task_type'    => 'upload_batch',
					'payload'      => $upload_plan['payload'],
					'available_at' => $upload_plan['available_at'],
					'priority'     => $upload_plan['priority'],
					'dedupe_key'   => TaskResult::build_scan_upload_dedupe_key(),
				];
			}
		}

		$job_repo = new CloudJobRepository();
		$jobs_needing_poll = $job_repo->get_jobs_needing_poll( $scan_id );
		if (
			! empty( $jobs_needing_poll ) &&
			! $this->has_dead_task_type( $dead_tasks, 'poll_cloud' ) &&
			! $repo->has_task( $scan_id, 'poll_cloud', [ TaskQueueRepository::STATUS_PENDING, TaskQueueRepository::STATUS_LEASED ] )
		) {
			$follow_ups[] = [
				'task_type'    => 'poll_cloud',
				'payload'      => [
					'poll_count' => 0,
				],
				'available_at' => time(),
				'priority'     => 60,
				'dedupe_key'   => TaskResult::build_scan_poll_dedupe_key(),
			];
		}

		$dead_system_check_slugs = [];
		foreach ( $dead_tasks as $dead_task ) {
			if ( 'system_check' !== (string) ( $dead_task['task_type'] ?? '' ) ) {
				continue;
			}
			$dead_payload = is_array( $dead_task['payload'] ?? null ) ? $dead_task['payload'] : [];
			$dead_slug = sanitize_key( (string) ( $dead_payload['check_slug'] ?? '' ) );
			if ( '' !== $dead_slug ) {
				$dead_system_check_slugs[ $dead_slug ] = true;
			}
		}

		$component_status = ScanQueue::get_component_status( $queue, false );
		$present_syscheck_keys = $repo->get_present_dedupe_keys(
			$scan_id,
			'system_check',
			[
				TaskQueueRepository::STATUS_PENDING,
				TaskQueueRepository::STATUS_LEASED,
				TaskQueueRepository::STATUS_DEAD,
			]
		);
		foreach ( ScanQueue::SYSTEM_CHECKS as $slug ) {
			$status = $component_status[ $slug ] ?? 'pending';
			if ( in_array( $status, [ 'complete', 'failed' ], true ) ) {
				continue;
			}

			// Dead-lettered checks never complete; freeze them so finalize is not blocked.
			if ( isset( $dead_system_check_slugs[ $slug ] ) ) {
				ScanQueue::set_component_status( $slug, 'failed' );
				continue;
			}

			$dedupe_key = 'syscheck:' . $slug;
			if ( ! isset( $present_syscheck_keys[ $dedupe_key ] ) ) {
				$follow_ups[] = [
					'task_type'    => 'system_check',
					'payload'      => [ 'check_slug' => $slug ],
					'available_at' => time(),
					'priority'     => ScanQueue::SYSTEM_CHECK_PRIORITY,
					'dedupe_key'   => $dedupe_key,
				];
			}
		}

		$is_ready_for_finalize = $orchestrator->is_ready_for_finalize();

		if ( $is_ready_for_finalize && ! $repo->has_task( $scan_id, 'finalize', [ TaskQueueRepository::STATUS_PENDING, TaskQueueRepository::STATUS_LEASED ], 'finalize' ) ) {
			$follow_ups[] = [
				'task_type'    => 'finalize',
				'payload'      => [ 'scan_id' => $scan_id ],
				'available_at' => time(),
				'priority'     => 10,
				'dedupe_key'   => 'finalize',
			];
		}

		return $follow_ups;
	}

	/**
	 * @param array<int,array<string,mixed>> $dead_tasks
	 * @param string                         $task_type
	 * @return bool
	 */
	private function has_dead_task_type( array $dead_tasks, string $task_type ): bool {
		foreach ( $dead_tasks as $dead_task ) {
			if ( $task_type === (string) ( $dead_task['task_type'] ?? '' ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Return a dead file-pipeline task that should fail the scan instead of finalizing.
	 *
	 * @param array<int,array<string,mixed>> $dead_tasks
	 * @return array<string,mixed>|null
	 */
	private function get_blocking_dead_pipeline_task( array $dead_tasks ): ?array {
		foreach ( $dead_tasks as $dead_task ) {
			$task_type = (string) ( $dead_task['task_type'] ?? '' );
			if ( ! in_array( $task_type, [ 'scan_worker', 'upload_batch', 'poll_cloud' ], true ) ) {
				continue;
			}

			return $dead_task;
		}

		return null;
	}

	/**
	 * Fail the scan when a critical queue task is dead and no safe recovery exists.
	 *
	 * @param array<string,mixed> $dead_task
	 * @return void
	 */
	private function fail_scan_for_dead_task( array $dead_task ): void {
		$task_type = sanitize_key( (string) ( $dead_task['task_type'] ?? 'task' ) );
		$result = is_array( $dead_task['result'] ?? null ) ? $dead_task['result'] : [];
		$error_message = sanitize_text_field( (string) ( $result['error'] ?? 'Background task failed repeatedly' ) );
		$failure_type = isset( $result['failure_type'] )
			? sanitize_key( (string) $result['failure_type'] )
			: $this->classify_dead_task_type( $task_type, $error_message );
		$failure_origin = isset( $result['failure_origin'] )
			? sanitize_key( (string) $result['failure_origin'] )
			: $this->classify_dead_task_origin( $task_type, $error_message, $failure_type );

		ScanQueue::fail(
			$this->get_dead_task_failure_message( $task_type, $failure_origin ),
			[
				'error_code'        => 'task_dead_letter',
				'stage'             => $task_type,
				'failure_origin'    => $failure_origin,
				'failure_type'      => $failure_type,
				'retryable'         => true,
				'status_code'       => $this->get_dead_task_status_code( $task_type, $failure_origin ),
				'technical_message' => $error_message,
			]
		);
	}

	private function classify_dead_task_origin( string $task_type, string $error_message, string $failure_type ): string {
		if (
			false !== strpos( $failure_type, 'lease_expired' )
			|| false !== stripos( $error_message, 'lease' )
		) {
			return 'local';
		}

		return 'poll_cloud' === $task_type ? 'cloud' : 'local';
	}

	private function classify_dead_task_type( string $task_type, string $error_message ): string {
		if ( 'scan_worker' === $task_type && false !== stripos( $error_message, 'lease expired' ) ) {
			return 'worker_lease_expired';
		}

		if ( 'scan_worker' === $task_type ) {
			return 'worker_task_failed';
		}

		if ( 'upload_batch' === $task_type ) {
			return false !== stripos( $error_message, 'lease expired' ) ? 'upload_batch_lease_expired' : 'upload_batch_task_failed';
		}

		if ( 'poll_cloud' === $task_type ) {
			return false !== stripos( $error_message, 'lease expired' ) ? 'poll_cloud_lease_expired' : 'cloud_poll_failed';
		}

		return 'task_failed';
	}

	private function get_dead_task_failure_message( string $task_type, string $failure_origin ): string {
		if ( 'scan_worker' === $task_type ) {
			return 'The scan could not complete because the server interrupted the background scanner. Retry once server load is normal.';
		}

		if ( in_array( $task_type, [ 'upload_batch', 'poll_cloud' ], true ) && 'local' === $failure_origin ) {
			return 'The scan could not complete because the server interrupted the scanner worker. Retry once server load is normal.';
		}

		if ( 'cloud' === $failure_origin ) {
			return 'SiteFort cloud analysis could not complete this scan. Please retry shortly.';
		}

		return 'A scanner background task failed repeatedly. Please restart the scan.';
	}

	private function get_dead_task_status_code( string $task_type, string $failure_origin ): int {
		return 'cloud' === $failure_origin || in_array( $task_type, [ 'scan_worker', 'upload_batch', 'poll_cloud' ], true ) ? 503 : 500;
	}

	/**
	 * @param string $task_type
	 * @return object|null
	 */
	private function resolve_handler( string $task_type ): ?object {
		switch ( $task_type ) {
			case 'scan_worker':
				return new ScanWorkerHandler();
			case 'upload_batch':
				return new UploadBatchHandler();
			case 'poll_cloud':
				return new PollCloudHandler();
			case 'system_check':
				return new SystemCheckHandler();
			case 'finalize':
				return new FinalizeHandler();
			default:
				return null;
		}
	}

	/**
	 * @param string $task_type
	 * @return int
	 */
	private function get_lease_seconds( string $task_type ): int {
		if ( 'scan_worker' === $task_type ) {
			return self::SCAN_WORKER_LEASE_SECONDS;
		}

		if ( 'system_check' === $task_type ) {
			return self::SYSTEM_CHECK_LEASE_SECONDS;
		}

		return 'upload_batch' === $task_type ? self::UPLOAD_LEASE_SECONDS : self::DEFAULT_LEASE_SECONDS;
	}

	/**
	 * @param array<int,array<string,mixed>> $tasks
	 * @param string                         $scan_id
	 * @return array<int,array<string,mixed>>
	 */
	private function attach_scan_id( array $tasks, string $scan_id ): array {
		foreach ( $tasks as $index => $task ) {
			if ( empty( $task['scan_id'] ) ) {
				$tasks[ $index ]['scan_id'] = $scan_id;
			}
		}

		return $tasks;
	}

	/**
	 * Merge handler-declared follow-ups with authoritative-state reconciliation.
	 *
	 * The durable queue is a projection of the file/cloud FSM, not the source of
	 * truth. After a task mutates the scan state, another task can become runnable
	 * even when the handler did not enqueue it directly. Collapsing both sets here
	 * prevents the runner from going idle until a later watchdog or cron wake-up.
	 *
	 * @param array<int,array<string,mixed>> $primary
	 * @param array<int,array<string,mixed>> $secondary
	 * @return array<int,array<string,mixed>>
	 */
	private function merge_follow_ups( array $primary, array $secondary ): array {
		$merged = [];

		foreach ( array_merge( $primary, $secondary ) as $task ) {
			$identity = $this->get_follow_up_identity( $task );
			if ( isset( $merged[ $identity ] ) ) {
				$existing = $merged[ $identity ];
				$existing_available_at = (int) ( $existing['available_at'] ?? time() );
				$task_available_at = (int) ( $task['available_at'] ?? $existing_available_at );
				$existing_priority = (int) ( $existing['priority'] ?? 100 );
				$task_priority = (int) ( $task['priority'] ?? $existing_priority );

				$merged[ $identity ]['available_at'] = min( $existing_available_at, $task_available_at );
				$merged[ $identity ]['priority'] = min( $existing_priority, $task_priority );

				if ( empty( $existing['dedupe_key'] ) && ! empty( $task['dedupe_key'] ) ) {
					$merged[ $identity ]['dedupe_key'] = $task['dedupe_key'];
				}

				if ( empty( $existing['scan_id'] ) && ! empty( $task['scan_id'] ) ) {
					$merged[ $identity ]['scan_id'] = $task['scan_id'];
				}

				if ( ! isset( $existing['max_attempts'] ) && isset( $task['max_attempts'] ) ) {
					$merged[ $identity ]['max_attempts'] = $task['max_attempts'];
				}

				continue;
			}

			$merged[ $identity ] = $task;
		}

		return array_values( $merged );
	}

	/**
	 * Build a stable identity for follow-up task de-duplication before enqueue.
	 *
	 * @param array<string,mixed> $task
	 * @return string
	 */
	private function get_follow_up_identity( array $task ): string {
		$task_type = (string) ( $task['task_type'] ?? '' );
		$dedupe_key = isset( $task['dedupe_key'] ) ? sanitize_text_field( (string) $task['dedupe_key'] ) : '';
		if ( '' !== $dedupe_key ) {
			return $task_type . '|dedupe:' . $dedupe_key;
		}

		$payload = is_array( $task['payload'] ?? null ) ? $task['payload'] : [];
		$payload_json = wp_json_encode( $payload );
		if ( false === $payload_json ) {
			$payload_json = '';
		}

		return $task_type . '|payload:' . $payload_json;
	}

	/**
	 * @param array<string,mixed> $runtime_recovery
	 * @return bool
	 */
	private function did_runtime_recover( array $runtime_recovery ): bool {
		return ! empty( $runtime_recovery['cloud_states_recovered'] ) || (int) ( $runtime_recovery['stale_uploads'] ?? 0 ) > 0;
	}

	/**
	 * Delete durable task rows once the scan has entered a terminal state.
	 *
	 * @param string              $scan_id
	 * @param TaskQueueRepository $repo
	 * @return bool True when the scan is terminal.
	 */
	private function cleanup_if_terminal( string $scan_id, TaskQueueRepository $repo ): bool {
		$queue = ScanQueue::get_job( $scan_id );
		if ( $this->is_scan_active( $queue ) ) {
			return false;
		}

		$repo->delete_by_scan_id( $scan_id );
		return true;
	}

	/**
	 * @param array<string,mixed>|null $queue
	 * @return bool
	 */
	private function is_scan_active( ?array $queue ): bool {
		return is_array( $queue ) && in_array( (string) ( $queue['status'] ?? '' ), [ 'pending', 'running' ], true );
	}

	private function should_defer_next_task( string $task_type, string $previous_task_type ): bool {
		if ( 'upload_batch' === $task_type && in_array( $previous_task_type, [ 'scan_worker', 'poll_cloud' ], true ) ) {
			return false;
		}

		if ( 'scan_worker' === $previous_task_type ) {
			return true;
		}

		return in_array( $task_type, [ 'scan_worker', 'upload_batch', 'finalize' ], true );
	}

	private function should_stop_after_task( string $task_type, float $started, WorkerRuntime $runtime ): bool {
		if ( in_array( $task_type, [ 'upload_batch', 'finalize' ], true ) ) {
			return true;
		}

		if ( 'scan_worker' === $task_type ) {
			$duration = microtime( true ) - $started;
			if ( $duration >= min( 3, $runtime->get_tick_budget_seconds() ) ) {
				return true;
			}

			return false;
		}

		return false;
	}

	/**
	 * Load required handler classes in environments without autoloading.
	 *
	 * @return void
	 */
	private function ensure_dependencies_loaded(): void {
		if ( ! class_exists( WorkerRuntime::class ) ) {
			require_once __DIR__ . '/WorkerRuntime.php';
		}
		if ( ! class_exists( TaskQueueRepository::class ) ) {
			require_once dirname( __DIR__ ) . '/Repository/TaskQueueRepository.php';
		}
		if ( ! class_exists( CloudJobRepository::class ) ) {
			require_once dirname( __DIR__ ) . '/Repository/CloudJobRepository.php';
		}
		if ( ! class_exists( ScanOrchestrator::class ) ) {
			require_once __DIR__ . '/ScanOrchestrator.php';
		}
		if ( ! class_exists( TaskResult::class ) ) {
			require_once __DIR__ . '/Handler/TaskResult.php';
		}
		if ( ! class_exists( ScanWorkerHandler::class ) ) {
			require_once __DIR__ . '/Handler/ScanWorkerHandler.php';
		}
		if ( ! class_exists( UploadBatchHandler::class ) ) {
			require_once __DIR__ . '/Handler/UploadBatchHandler.php';
		}
		if ( ! class_exists( PollCloudHandler::class ) ) {
			require_once __DIR__ . '/Handler/PollCloudHandler.php';
		}
		if ( ! class_exists( SystemCheckHandler::class ) ) {
			require_once __DIR__ . '/Handler/SystemCheckHandler.php';
		}
		if ( ! class_exists( FinalizeHandler::class ) ) {
			require_once __DIR__ . '/Handler/FinalizeHandler.php';
		}
	}
}
