<?php
/**
 * Privacy-conscious background exports for Email Log.
 *
 * @package SubmissionInbox
 */

namespace SUBIN\MailLog;

use SUBIN\Mail\EmailErrorClassifier;
use SUBIN\Security\Capabilities;

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

// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Exact-value option CAS is required for a race-free short-lived worker lease.
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $wpdb->options is the trusted current-site options table.

/**
 * Builds bounded CSV exports in private temporary storage.
 *
 * Export state is deliberately kept in site-scoped user meta. The generated
 * file never receives a public URL and can only be streamed after an ownership,
 * capability, and nonce check.
 */
final class EmailLogExportService {
	const PROCESS_HOOK = 'subin_email_log_export_process';
	const CLEANUP_HOOK = 'subin_email_log_export_cleanup';
	const SWEEP_HOOK   = 'subin_email_log_export_orphan_sweep';
	const META_PREFIX  = 'subin_email_log_exports_';
	const LOCK_PREFIX  = 'subin_email_log_export_lock_';

	const STATUS_QUEUED     = 'queued';
	const STATUS_PROCESSING = 'processing';
	const STATUS_COMPLETED  = 'completed';
	const STATUS_FAILED     = 'failed';

	const DEFAULT_BATCH_SIZE = 250;
	const DEFAULT_MAX_ROWS   = 25000;
	const DEFAULT_MAX_BYTES  = 52428800;
	const DEFAULT_TTL        = 86400;
	const MAX_JOBS_PER_USER  = 5;
	const LOCK_TTL           = 180;
	const SWEEP_INTERVAL     = 43200;
	const ORPHAN_GRACE       = 3600;

	/** @var MailEventRepository */
	private $events;

	/** @var MailEventPresenter */
	private $presenter;

	/** @var EmailErrorClassifier */
	private $error_classifier;

	/**
	 * @param MailEventRepository $events Mail event repository.
	 * @param MailEventPresenter   $presenter Shared Email Log presenter.
	 * @param EmailErrorClassifier $error_classifier Privacy-safe technical error classifier.
	 */
	public function __construct( MailEventRepository $events, MailEventPresenter $presenter, ?EmailErrorClassifier $error_classifier = null ) {
		$this->events           = $events;
		$this->presenter        = $presenter;
		$this->error_classifier = $error_classifier ?: new EmailErrorClassifier();
	}

	/**
	 * Register the two single-event cron callbacks.
	 *
	 * @return void
	 */
	public function register() {
		add_action( self::PROCESS_HOOK, array( $this, 'process_batch' ), 10, 3 );
		add_action( self::CLEANUP_HOOK, array( $this, 'cleanup_scheduled' ), 10, 3 );
		add_action( self::SWEEP_HOOK, array( $this, 'sweep_orphan_files' ), 10, 0 );
		$this->schedule_orphan_sweep();
	}

	/**
	 * Create a request nonce for one current-user operation.
	 *
	 * @param string $operation start, download, or delete.
	 * @param string $job_id Optional export id.
	 * @return string|\WP_Error
	 */
	public function create_nonce( $operation, $job_id = '' ) {
		$authorized = $this->authorize_user();
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$action = $this->nonce_action( $operation, $job_id, get_current_blog_id(), get_current_user_id() );
		if ( '' === $action ) {
			return new \WP_Error( 'subin_email_log_export_action_invalid', __( 'The export action is invalid.', 'submission-inbox' ) );
		}

		return wp_create_nonce( $action );
	}

	/**
	 * Queue an export for the current site and current user.
	 *
	 * @param array<string,mixed> $filters Current Email Log filters.
	 * @param string              $nonce Start nonce from create_nonce().
	 * @return array<string,mixed>|\WP_Error Public job state; never a path or URL.
	 */
	public function start_export( array $filters, $nonce ) {
		$authorized = $this->authorize_request( 'start', '', $nonce );
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$blog_id = absint( get_current_blog_id() );
		$user_id = absint( get_current_user_id() );
		$lock    = $this->acquire_registry_lock( $blog_id, $user_id );
		if ( is_wp_error( $lock ) ) {
			return $lock;
		}

		try {
			$jobs = $this->load_jobs( $blog_id, $user_id );
			$this->remove_expired_jobs( $jobs, time() );
			$this->make_room_for_job( $jobs );

			if ( count( $jobs ) >= self::MAX_JOBS_PER_USER ) {
				return new \WP_Error(
					'subin_email_log_export_limit_reached',
					__( 'Wait for an existing export to finish or remove one before starting another.', 'submission-inbox' )
				);
			}

			$filters = $this->sanitize_filters( $filters );
			try {
				$boundary_rows = $this->events->get_paginated_events_checked(
					array_merge(
						$filters,
						array(
							'keyset_order' => 'id_desc',
							'per_page'     => 1,
							'offset'       => 0,
						)
					)
				);
				if ( is_wp_error( $boundary_rows ) ) {
					return $boundary_rows;
				}
				$boundary_rows   = is_array( $boundary_rows ) ? $boundary_rows : array();
				$snapshot_max_id = ! empty( $boundary_rows ) ? absint( $boundary_rows[0]->id ?? 0 ) : 0;
				$count_filters    = $filters;
				if ( $snapshot_max_id > 0 ) {
					$count_filters['snapshot_max_id'] = $snapshot_max_id;
				}
				$matching_rows = $snapshot_max_id > 0 ? $this->events->count_events_checked( $count_filters ) : 0;
				if ( is_wp_error( $matching_rows ) ) {
					return $matching_rows;
				}
				$matching_rows = max( 0, absint( $matching_rows ) );
			} catch ( \Throwable $exception ) {
				return new \WP_Error(
					'subin_email_log_export_count_failed',
					__( 'InboxMend could not count the matching Email Log events.', 'submission-inbox' )
				);
			}

			$job_id = $this->random_hex( 16 );
			if ( '' === $job_id ) {
				return new \WP_Error( 'subin_email_log_export_random_failed', __( 'InboxMend could not create a secure export identifier.', 'submission-inbox' ) );
			}
			$max_rows = $this->max_rows();
			$file      = $this->create_private_file( $blog_id );
			if ( is_wp_error( $file ) ) {
				return $file;
			}

			$now = time();
			$job = array(
				'id'              => $job_id,
				'blog_id'         => $blog_id,
				'user_id'         => $user_id,
				'status'          => 0 === $matching_rows ? self::STATUS_COMPLETED : self::STATUS_QUEUED,
				'filters'         => $filters,
				'total'           => min( $matching_rows, $max_rows ),
				'processed'       => 0,
				'snapshot_max_id' => $snapshot_max_id,
				'cursor_id'       => 0,
				'row_limit'       => $max_rows,
				'truncated'       => $matching_rows > $max_rows,
				'truncated_reason' => $matching_rows > $max_rows ? 'row_limit' : '',
				'created_at'      => $now,
				'updated_at'      => $now,
				'started_at'      => 0,
				'completed_at'    => 0 === $matching_rows ? $now : 0,
				'expires_at'      => $now + $this->ttl(),
				'error_code'      => '',
				'error'           => '',
				'filename'        => sanitize_file_name( 'inboxmend-email-log-' . gmdate( 'Y-m-d', $now ) . '-' . substr( $job_id, 0, 8 ) . '.csv' ),
				'path'            => $file['path'],
				'file_token'      => $file['token'],
				'path_signature'  => $this->path_signature( $file['path'], $job_id, $blog_id, $user_id ),
				'file_size'       => $this->safe_file_size( $file['path'] ),
			);

			$jobs[ $job_id ] = $job;
			if ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
				$cleanup = $this->delete_job_file( $job );
				if ( is_wp_error( $cleanup ) ) {
					return $cleanup;
				}
				return new \WP_Error( 'subin_email_log_export_state_failed', __( 'InboxMend could not save the export job.', 'submission-inbox' ) );
			}

			$cleanup_scheduled = $this->schedule_cleanup( $blog_id, $user_id, $job_id, $job['expires_at'] );
			if ( is_wp_error( $cleanup_scheduled ) ) {
				$cleanup = $this->delete_job_file( $jobs[ $job_id ] );
				if ( is_wp_error( $cleanup ) ) {
					$this->mark_cleanup_failure( $jobs[ $job_id ], $cleanup->get_error_message() );
					$this->save_jobs( $blog_id, $user_id, $jobs );
					return $cleanup;
				}
				unset( $jobs[ $job_id ] );
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return $cleanup_scheduled;
			}

			if ( $matching_rows > 0 ) {
				$scheduled = $this->schedule_process( $blog_id, $user_id, $job_id, 1 );
				if ( is_wp_error( $scheduled ) ) {
					$cleanup = $this->delete_job_file( $jobs[ $job_id ] );
					if ( is_wp_error( $cleanup ) ) {
						$this->mark_cleanup_failure( $jobs[ $job_id ], $cleanup->get_error_message() );
						$this->save_jobs( $blog_id, $user_id, $jobs );
						$this->clear_schedules( $blog_id, $user_id, $job_id );
						return $cleanup;
					}
					unset( $jobs[ $job_id ] );
					$this->save_jobs( $blog_id, $user_id, $jobs );
					$this->clear_schedules( $blog_id, $user_id, $job_id );
					return $scheduled;
				}
			}

			do_action( 'subin_email_log_export_started', $this->public_job( $job ) );

			return $this->public_job( $job );
		} finally {
			$this->release_registry_lock( $blog_id, $user_id, $lock );
		}
	}

	/**
	 * List short-lived exports owned by the current user on the current site.
	 *
	 * @return array<int,array<string,mixed>>|\WP_Error
	 */
	public function list_exports() {
		$authorized = $this->authorize_user();
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$blog_id = absint( get_current_blog_id() );
		$user_id = absint( get_current_user_id() );
		$lock    = $this->acquire_registry_lock( $blog_id, $user_id );
		if ( is_wp_error( $lock ) ) {
			return $lock;
		}

		try {
			$jobs    = $this->load_jobs( $blog_id, $user_id );
			$changed = $this->remove_expired_jobs( $jobs, time() );
			if ( $changed ) {
				$this->save_jobs( $blog_id, $user_id, $jobs );
			}

			uasort(
				$jobs,
				static function ( $left, $right ) {
					return absint( $right['created_at'] ?? 0 ) <=> absint( $left['created_at'] ?? 0 );
				}
			);

			return array_values( array_map( array( $this, 'public_job' ), $jobs ) );
		} finally {
			$this->release_registry_lock( $blog_id, $user_id, $lock );
		}
	}

	/**
	 * Get one current-user export without exposing its server-side path.
	 *
	 * @param string $job_id Export id.
	 * @return array<string,mixed>|\WP_Error
	 */
	public function get_export( $job_id ) {
		$authorized = $this->authorize_user();
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$blog_id = absint( get_current_blog_id() );
		$user_id = absint( get_current_user_id() );
		$job     = $this->owned_job( $job_id, $blog_id, $user_id );
		if ( is_wp_error( $job ) ) {
			return $job;
		}

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

	/**
	 * Delete one export and its temporary file.
	 *
	 * @param string $job_id Export id.
	 * @param string $nonce Delete nonce.
	 * @return true|\WP_Error
	 */
	public function delete_export( $job_id, $nonce ) {
		$job_id    = $this->sanitize_job_id( $job_id );
		$authorized = $this->authorize_request( 'delete', $job_id, $nonce );
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$blog_id = absint( get_current_blog_id() );
		$user_id = absint( get_current_user_id() );
		$lock    = $this->acquire_registry_lock( $blog_id, $user_id );
		if ( is_wp_error( $lock ) ) {
			return $lock;
		}

		try {
			$jobs = $this->load_jobs( $blog_id, $user_id );
			if ( ! isset( $jobs[ $job_id ] ) || ! $this->job_is_owned( $jobs[ $job_id ], $blog_id, $user_id ) ) {
				return new \WP_Error( 'subin_email_log_export_not_found', __( 'The export could not be found.', 'submission-inbox' ) );
			}

			$cleanup = $this->delete_job_file( $jobs[ $job_id ] );
			if ( is_wp_error( $cleanup ) ) {
				return $cleanup;
			}
			unset( $jobs[ $job_id ] );
			$this->save_jobs( $blog_id, $user_id, $jobs );
			$this->clear_schedules( $blog_id, $user_id, $job_id );

			return true;
		} finally {
			$this->release_registry_lock( $blog_id, $user_id, $lock );
		}
	}

	/**
	 * Stream one completed export. This method exits after a successful stream.
	 *
	 * @param string $job_id Export id.
	 * @param string $nonce Download nonce.
	 * @return \WP_Error Never returns after a successful download.
	 */
	public function download_export( $job_id, $nonce ) {
		$job_id    = $this->sanitize_job_id( $job_id );
		$authorized = $this->authorize_request( 'download', $job_id, $nonce );
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$blog_id = absint( get_current_blog_id() );
		$user_id = absint( get_current_user_id() );
		$job     = $this->owned_job( $job_id, $blog_id, $user_id );
		if ( is_wp_error( $job ) ) {
			return $job;
		}
		if ( self::STATUS_COMPLETED !== ( $job['status'] ?? '' ) ) {
			return new \WP_Error( 'subin_email_log_export_not_ready', __( 'This export is not ready to download yet.', 'submission-inbox' ) );
		}
		if ( absint( $job['expires_at'] ?? 0 ) <= time() ) {
			return new \WP_Error( 'subin_email_log_export_expired', __( 'This export has expired. Start a new export to continue.', 'submission-inbox' ) );
		}

		$path = $this->verified_job_path( $job );
		if ( is_wp_error( $path ) || ! is_readable( $path ) ) {
			return new \WP_Error( 'subin_email_log_export_file_missing', __( 'The temporary export file is no longer available.', 'submission-inbox' ) );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- A verified private CSV must be streamed in bounded chunks without loading it into memory.
		$handle = @fopen( $path, 'rb' );
		if ( false === $handle ) {
			return new \WP_Error( 'subin_email_log_export_file_open_failed', __( 'The temporary export file could not be opened.', 'submission-inbox' ) );
		}

		while ( ob_get_level() > 0 ) {
			ob_end_clean();
		}
		nocache_headers();
		header( 'Content-Type: text/csv; charset=utf-8' );
		header( 'Content-Disposition: attachment; filename="' . sanitize_file_name( $job['filename'] ?? 'inboxmend-email-log.csv' ) . '"' );
		header( 'Content-Length: ' . (string) $this->safe_file_size( $path ) );
		header( 'X-Content-Type-Options: nosniff' );
		header( 'Cache-Control: private, no-store, no-cache, must-revalidate, max-age=0' );

		while ( ! feof( $handle ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Bounded streaming is required for large private exports.
			$chunk = fread( $handle, 1048576 );
			if ( false === $chunk ) {
				break;
			}
			echo $chunk; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Authenticated binary CSV stream.
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the authenticated CSV stream opened above.
		fclose( $handle );
		exit;
	}

	/**
	 * Process one bounded batch for a site/user/job tuple.
	 *
	 * @param int    $blog_id Site id recorded in the cron event.
	 * @param int    $user_id Owner id recorded in the cron event.
	 * @param string $job_id Export id.
	 * @return void
	 */
	public function process_batch( $blog_id, $user_id, $job_id ) {
		$blog_id = absint( $blog_id );
		$user_id = absint( $user_id );
		$job_id  = $this->sanitize_job_id( $job_id );
		if ( $blog_id < 1 || $user_id < 1 || '' === $job_id || $blog_id !== absint( get_current_blog_id() ) ) {
			return;
		}

		$lock = $this->acquire_registry_lock( $blog_id, $user_id );
		if ( is_wp_error( $lock ) ) {
			$this->schedule_process( $blog_id, $user_id, $job_id, 15 );
			return;
		}

		try {
			$jobs = $this->load_jobs( $blog_id, $user_id );
			if ( ! isset( $jobs[ $job_id ] ) || ! $this->job_is_owned( $jobs[ $job_id ], $blog_id, $user_id ) ) {
				return;
			}

			$job = $jobs[ $job_id ];
			if ( ! Capabilities::user_can( $user_id, Capabilities::EXPORT_EMAIL_LOG ) ) {
				$cleanup = $this->delete_job_file( $job );
				if ( is_wp_error( $cleanup ) ) {
					$this->mark_cleanup_failure( $job, $cleanup->get_error_message() );
					$jobs[ $job_id ] = $job;
				} else {
					unset( $jobs[ $job_id ] );
				}
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return;
			}
			if ( in_array( $job['status'] ?? '', array( self::STATUS_COMPLETED, self::STATUS_FAILED ), true ) ) {
				return;
			}
			if ( absint( $job['expires_at'] ?? 0 ) <= time() ) {
				$cleanup = $this->delete_job_file( $job );
				if ( is_wp_error( $cleanup ) ) {
					$this->mark_cleanup_failure( $job, $cleanup->get_error_message() );
					$jobs[ $job_id ] = $job;
				} else {
					unset( $jobs[ $job_id ] );
				}
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return;
			}

			$path = $this->verified_job_path( $job );
			if ( is_wp_error( $path ) ) {
				$this->fail_job( $job, 'file_missing', __( 'The private temporary export file is unavailable.', 'submission-inbox' ) );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return;
			}
			if ( isset( $job['write_checkpoint'] ) ) {
				$recovered = $this->truncate_to_checkpoint( $path, absint( $job['write_checkpoint'] ) );
				if ( is_wp_error( $recovered ) ) {
					$this->fail_job( $job, 'checkpoint_recovery_failed', $recovered->get_error_message() );
					$jobs[ $job_id ] = $job;
					$this->save_jobs( $blog_id, $user_id, $jobs );
					$this->clear_process_schedule( $blog_id, $user_id, $job_id );
					return;
				}
				unset( $job['write_checkpoint'] );
				$jobs[ $job_id ] = $job;
				if ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
					$this->quarantine_state_failure( $blog_id, $user_id, $job_id, $job, $jobs );
					return;
				}
			}

			$job['status']     = self::STATUS_PROCESSING;
			$job['started_at'] = absint( $job['started_at'] ?? 0 ) ?: time();
			$job['updated_at'] = time();

			$remaining = max( 0, min( absint( $job['total'] ?? 0 ), absint( $job['row_limit'] ?? $this->max_rows() ) ) - absint( $job['processed'] ?? 0 ) );
			if ( 0 === $remaining ) {
				$this->complete_job( $job );
				$jobs[ $job_id ] = $job;
				if ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
					$this->quarantine_state_failure( $blog_id, $user_id, $job_id, $job, $jobs );
					return;
				}
				$this->clear_process_schedule( $blog_id, $user_id, $job_id );
				$cleanup = $this->schedule_cleanup( $blog_id, $user_id, $job_id, $job['expires_at'] );
				if ( is_wp_error( $cleanup ) ) {
					do_action( 'subin_email_log_export_cleanup_schedule_failed', $this->public_job( $job ), $cleanup );
				}
				return;
			}

			$batch_size = min( $remaining, $this->batch_size() );
			$query      = array_merge(
				is_array( $job['filters'] ?? null ) ? $job['filters'] : array(),
				array(
					'snapshot_max_id' => absint( $job['snapshot_max_id'] ?? 0 ),
					'cursor_id'       => absint( $job['cursor_id'] ?? 0 ),
					'keyset_order'    => 'id_asc',
					'per_page'        => $batch_size,
					'offset'          => 0,
				)
			);

			try {
				$rows = $this->events->get_paginated_events_checked( $query );
			} catch ( \Throwable $exception ) {
				$this->fail_job( $job, 'query_failed', __( 'InboxMend could not read the next Email Log export batch.', 'submission-inbox' ) );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return;
			}
			if ( is_wp_error( $rows ) ) {
				$this->fail_job( $job, 'query_failed', $rows->get_error_message() );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return;
			}
			$rows = is_array( $rows ) ? array_slice( $rows, 0, $batch_size ) : array();
			$watchdog = $this->schedule_process( $blog_id, $user_id, $job_id, 300 );
			if ( is_wp_error( $watchdog ) ) {
				$this->fail_job( $job, 'schedule_failed', $watchdog->get_error_message() );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
				return;
			}

			$job['write_checkpoint'] = $this->safe_file_size( $path );
			if ( $job['write_checkpoint'] < 1 ) {
				$this->fail_job( $job, 'checkpoint_failed', __( 'The export file size could not be verified before writing.', 'submission-inbox' ) );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
				$this->clear_process_schedule( $blog_id, $user_id, $job_id );
				return;
			}
			$jobs[ $job_id ]          = $job;
			if ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
				$this->quarantine_state_failure( $blog_id, $user_id, $job_id, $job, $jobs );
				return;
			}

			$written = $this->append_rows( $path, $rows );
			if ( is_wp_error( $written ) ) {
				$this->fail_job( $job, 'write_failed', __( 'InboxMend could not write the Email Log export batch.', 'submission-inbox' ) );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
				$this->clear_process_schedule( $blog_id, $user_id, $job_id );
				return;
			}
			unset( $job['write_checkpoint'] );

			$job['processed'] += absint( $written['rows'] );
			if ( ! empty( $rows ) ) {
				$last_event       = end( $rows );
				$job['cursor_id'] = max( absint( $job['cursor_id'] ?? 0 ), absint( $last_event->id ?? 0 ) );
			}
			$job['file_size']  = $this->safe_file_size( $path );
			$job['updated_at'] = time();
			if ( ! empty( $written['size_limited'] ) ) {
				$job['truncated']        = true;
				$job['truncated_reason'] = 'file_size';
			}

			$finished = ! empty( $written['size_limited'] )
				|| count( $rows ) < $batch_size
				|| absint( $job['processed'] ) >= absint( $job['total'] );
			if ( $finished ) {
				if ( count( $rows ) < $batch_size && absint( $job['processed'] ) < absint( $job['total'] ) ) {
					$job['total'] = absint( $job['processed'] );
				}
				$this->complete_job( $job );
				$jobs[ $job_id ] = $job;
				if ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
					$this->quarantine_state_failure( $blog_id, $user_id, $job_id, $job, $jobs );
					return;
				}
				$this->clear_process_schedule( $blog_id, $user_id, $job_id );
				$cleanup = $this->schedule_cleanup( $blog_id, $user_id, $job_id, $job['expires_at'] );
				if ( is_wp_error( $cleanup ) ) {
					do_action( 'subin_email_log_export_cleanup_schedule_failed', $this->public_job( $job ), $cleanup );
				}
				do_action( 'subin_email_log_export_completed', $this->public_job( $job ) );
				return;
			}

			$jobs[ $job_id ] = $job;
			if ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
				$this->quarantine_state_failure( $blog_id, $user_id, $job_id, $job, $jobs );
				return;
			}
			$this->clear_process_schedule( $blog_id, $user_id, $job_id );
			$scheduled = $this->schedule_process( $blog_id, $user_id, $job_id, 5 );
			if ( is_wp_error( $scheduled ) ) {
				$this->fail_job( $job, 'schedule_failed', $scheduled->get_error_message() );
				$jobs[ $job_id ] = $job;
				$this->save_jobs( $blog_id, $user_id, $jobs );
			}
		} finally {
			$this->release_registry_lock( $blog_id, $user_id, $lock );
		}
	}

	/**
	 * Delete an expired export from its scheduled site/user scope.
	 *
	 * @param int    $blog_id Site id.
	 * @param int    $user_id Owner id.
	 * @param string $job_id Export id.
	 * @return void
	 */
	public function cleanup_scheduled( $blog_id, $user_id, $job_id ) {
		$blog_id = absint( $blog_id );
		$user_id = absint( $user_id );
		$job_id  = $this->sanitize_job_id( $job_id );
		if ( $blog_id < 1 || $user_id < 1 || '' === $job_id || $blog_id !== absint( get_current_blog_id() ) ) {
			return;
		}

		$lock = $this->acquire_registry_lock( $blog_id, $user_id );
		if ( is_wp_error( $lock ) ) {
			$this->schedule_cleanup( $blog_id, $user_id, $job_id, time() + 60 );
			return;
		}

		try {
			$jobs = $this->load_jobs( $blog_id, $user_id );
			if ( ! isset( $jobs[ $job_id ] ) || ! $this->job_is_owned( $jobs[ $job_id ], $blog_id, $user_id ) ) {
				return;
			}

			if ( absint( $jobs[ $job_id ]['expires_at'] ?? 0 ) > time() ) {
				$this->schedule_cleanup( $blog_id, $user_id, $job_id, absint( $jobs[ $job_id ]['expires_at'] ) );
				return;
			}

			$cleanup = $this->delete_job_file( $jobs[ $job_id ] );
			if ( is_wp_error( $cleanup ) ) {
				$this->mark_cleanup_failure( $jobs[ $job_id ], $cleanup->get_error_message() );
				$this->save_jobs( $blog_id, $user_id, $jobs );
				$this->schedule_cleanup( $blog_id, $user_id, $job_id, time() + 3600 );
				return;
			}
			unset( $jobs[ $job_id ] );
			$this->save_jobs( $blog_id, $user_id, $jobs );
		} finally {
			$this->release_registry_lock( $blog_id, $user_id, $lock );
		}
	}

	/**
	 * Remove old InboxMend export files even when their user-meta job vanished.
	 *
	 * Only exact filenames for this WordPress installation and current site
	 * inside an approved temporary root are considered. Recent files receive a
	 * full TTL plus grace period.
	 *
	 * @param bool $reschedule Whether to queue the next maintenance pass.
	 * @return int Number of files removed.
	 */
	public function sweep_orphan_files( $reschedule = true ) {
		$deleted = 0;
		$cutoff  = time() - $this->ttl() - self::ORPHAN_GRACE;
		$prefix  = $this->export_file_prefix( get_current_blog_id() );
		foreach ( $this->allowed_temp_roots() as $root ) {
			$paths = glob( rtrim( $root, '/\\' ) . DIRECTORY_SEPARATOR . $prefix . '*.csv' );
			if ( ! is_array( $paths ) ) {
				continue;
			}
			foreach ( array_slice( $paths, 0, 5000 ) as $path ) {
				$name = basename( $path );
				if ( 1 !== preg_match( '/^' . preg_quote( $prefix, '/' ) . '[a-f0-9]{32}\.csv$/D', $name ) || is_link( $path ) || ! is_file( $path ) ) {
					continue;
				}
				$modified = filemtime( $path );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Exact namespaced temp path was validated immediately above; cleanup must report actual deletion success.
				if ( false !== $modified && $modified <= $cutoff && @unlink( $path ) ) {
					++$deleted;
				}
			}
		}

		if ( $reschedule && ! wp_next_scheduled( self::SWEEP_HOOK ) ) {
			wp_schedule_single_event( time() + self::SWEEP_INTERVAL, self::SWEEP_HOOK, array(), true );
		}

		return $deleted;
	}

	/**
	 * Remove every exact temp filename owned by this plugin installation.
	 *
	 * The install namespace prevents one WordPress installation from touching
	 * another installation's exports in a shared system temporary directory.
	 * This method spans every blog id in the current multisite network and is only
	 * intended for full plugin uninstall.
	 *
	 * @return int|\WP_Error Number of files removed.
	 */
	public function purge_all_orphan_files() {
		$deleted = 0;
		$failed  = 0;
		$prefix  = 'subin-email-log-' . $this->installation_namespace() . '-';
		foreach ( $this->allowed_temp_roots() as $root ) {
			$paths = glob( rtrim( $root, '/\\' ) . DIRECTORY_SEPARATOR . $prefix . '*.csv' );
			foreach ( is_array( $paths ) ? $paths : array() as $path ) {
				if ( 1 !== preg_match( '/^' . preg_quote( $prefix, '/' ) . '[1-9][0-9]*-[a-f0-9]{32}\.csv$/D', basename( $path ) ) || is_link( $path ) || ! is_file( $path ) ) {
					continue;
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Exact installation-namespaced temp path was validated immediately above.
				if ( @unlink( $path ) || ( ! is_file( $path ) && ! is_link( $path ) ) ) {
					++$deleted;
				} else {
					++$failed;
				}
			}
		}

		if ( $failed > 0 ) {
			return new \WP_Error(
				'subin_email_log_export_uninstall_cleanup_failed',
				__( 'One or more private Email Log export files could not be removed. Check the server temporary directory permissions and retry uninstall.', 'submission-inbox' ),
				array( 'deleted' => $deleted, 'failed' => $failed )
			);
		}

		return $deleted;
	}

	/**
	 * Remove every ephemeral export owned by the current WordPress site.
	 *
	 * Lifecycle code should call this before site deletion or plugin uninstall.
	 * The method never deletes a path that fails the signed-job validation.
	 *
	 * @param int $blog_id Current switched site id, or zero for current site.
	 * @return int|\WP_Error Number of verified files removed.
	 */
	public function purge_site_exports( $blog_id = 0 ) {
		global $wpdb;

		$blog_id = absint( $blog_id ) ?: absint( get_current_blog_id() );
		if ( $blog_id !== absint( get_current_blog_id() ) ) {
			return new \WP_Error( 'subin_email_log_export_site_context_invalid', __( 'Switch to the export site before purging its temporary files.', 'submission-inbox' ) );
		}

		$meta_key = $this->meta_key( $blog_id );
		$wpdb->last_error = '';
		$user_ids = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT DISTINCT user_id FROM {$wpdb->usermeta} WHERE meta_key = %s",
				$meta_key
			)
		);
		if ( '' !== (string) $wpdb->last_error ) {
			return new \WP_Error( 'subin_email_log_export_purge_lookup_failed', __( 'Temporary export owners could not be read.', 'submission-inbox' ) );
		}

		$deleted = 0;
		$errors  = array();
		foreach ( array_values( array_unique( array_filter( array_map( 'absint', is_array( $user_ids ) ? $user_ids : array() ) ) ) ) as $user_id ) {
			$jobs = $this->load_jobs( $blog_id, $user_id );
			foreach ( array_keys( $jobs ) as $job_id ) {
				$cleanup = $this->delete_job_file( $jobs[ $job_id ] );
				if ( is_wp_error( $cleanup ) ) {
					$this->mark_cleanup_failure( $jobs[ $job_id ], $cleanup->get_error_message() );
					$errors[] = array(
						'user_id' => $user_id,
						'job_id'  => $job_id,
						'code'    => $cleanup->get_error_code(),
					);
				} else {
					$deleted += absint( $cleanup );
					unset( $jobs[ $job_id ] );
				}
				$this->clear_schedules( $blog_id, $user_id, $job_id );
			}
			if ( empty( $jobs ) ) {
				delete_user_meta( $user_id, $meta_key );
				delete_option( $this->lock_key( $blog_id, $user_id ) );
			} elseif ( ! $this->save_jobs( $blog_id, $user_id, $jobs ) ) {
				$errors[] = array( 'user_id' => $user_id, 'code' => 'state_persistence_failed' );
			}
		}

		wp_clear_scheduled_hook( self::SWEEP_HOOK );
		$deleted += $this->sweep_orphan_files( false );
		if ( ! empty( $errors ) ) {
			return new \WP_Error(
				'subin_email_log_export_site_cleanup_failed',
				__( 'One or more private Email Log export files could not be removed. Check the server temporary directory permissions.', 'submission-inbox' ),
				array( 'deleted' => $deleted, 'errors' => $errors )
			);
		}

		return $deleted;
	}

	/** @return true|\WP_Error */
	private function authorize_user() {
		if ( get_current_user_id() < 1 || ! Capabilities::can_current_user( Capabilities::EXPORT_EMAIL_LOG ) ) {
			return new \WP_Error( 'subin_email_log_export_forbidden', __( 'You do not have permission to export the Email Log.', 'submission-inbox' ) );
		}

		return true;
	}

	/** @return true|\WP_Error */
	private function authorize_request( $operation, $job_id, $nonce ) {
		$authorized = $this->authorize_user();
		if ( is_wp_error( $authorized ) ) {
			return $authorized;
		}

		$action = $this->nonce_action( $operation, $job_id, get_current_blog_id(), get_current_user_id() );
		if ( '' === $action || ! is_string( $nonce ) || ! wp_verify_nonce( $nonce, $action ) ) {
			return new \WP_Error( 'subin_email_log_export_nonce_invalid', __( 'The export security check failed. Refresh the page and try again.', 'submission-inbox' ) );
		}

		return true;
	}

	/** @return string */
	private function nonce_action( $operation, $job_id, $blog_id, $user_id ) {
		$operation = sanitize_key( (string) $operation );
		if ( ! in_array( $operation, array( 'start', 'download', 'delete' ), true ) ) {
			return '';
		}
		$job_id = 'start' === $operation ? 'new' : $this->sanitize_job_id( $job_id );
		if ( '' === $job_id ) {
			return '';
		}

		return 'subin_email_log_export_' . $operation . '_' . absint( $blog_id ) . '_' . absint( $user_id ) . '_' . $job_id;
	}

	/** @return array<string,mixed>|\WP_Error */
	private function owned_job( $job_id, $blog_id, $user_id ) {
		$job_id = $this->sanitize_job_id( $job_id );
		$jobs   = $this->load_jobs( $blog_id, $user_id );
		if ( '' === $job_id || ! isset( $jobs[ $job_id ] ) || ! $this->job_is_owned( $jobs[ $job_id ], $blog_id, $user_id ) ) {
			return new \WP_Error( 'subin_email_log_export_not_found', __( 'The export could not be found.', 'submission-inbox' ) );
		}

		return $jobs[ $job_id ];
	}

	/** @return bool */
	private function job_is_owned( array $job, $blog_id, $user_id ) {
		return absint( $job['blog_id'] ?? 0 ) === absint( $blog_id )
			&& absint( $job['user_id'] ?? 0 ) === absint( $user_id )
			&& '' !== $this->sanitize_job_id( $job['id'] ?? '' );
	}

	/** @return array<string,array<string,mixed>> */
	private function load_jobs( $blog_id, $user_id ) {
		$stored = get_user_meta( absint( $user_id ), $this->meta_key( $blog_id ), true );
		$stored = is_array( $stored ) ? $stored : array();
		$jobs   = array();
		foreach ( $stored as $key => $job ) {
			$job_id = $this->sanitize_job_id( $key );
			if ( '' === $job_id || ! is_array( $job ) || $job_id !== $this->sanitize_job_id( $job['id'] ?? '' ) || ! $this->job_is_owned( $job, $blog_id, $user_id ) ) {
				continue;
			}
			$jobs[ $job_id ] = $job;
		}

		return $jobs;
	}

	/** @return bool */
	private function save_jobs( $blog_id, $user_id, array $jobs ) {
		if ( empty( $jobs ) ) {
			return false !== delete_user_meta( absint( $user_id ), $this->meta_key( $blog_id ) );
		}

		return false !== update_user_meta( absint( $user_id ), $this->meta_key( $blog_id ), $jobs );
	}

	/** @return string */
	private function meta_key( $blog_id ) {
		return self::META_PREFIX . absint( $blog_id );
	}

	/** @return string|\WP_Error */
	private function acquire_registry_lock( $blog_id, $user_id ) {
		$key   = $this->lock_key( $blog_id, $user_id );
		$token = $this->random_hex( 16 );
		if ( '' === $token ) {
			return new \WP_Error( 'subin_email_log_export_lock_failed', __( 'The export service is temporarily unavailable.', 'submission-inbox' ) );
		}

		$value = array( 'token' => $token, 'expires_at' => time() + self::LOCK_TTL );
		if ( add_option( $key, $value, '', false ) ) {
			return $token;
		}

		$current = get_option( $key, array() );
		if ( is_array( $current ) && absint( $current['expires_at'] ?? 0 ) < time() ) {
			if ( $this->compare_and_swap_option( $key, $current, $value ) ) {
				return $token;
			}
		}

		return new \WP_Error( 'subin_email_log_export_busy', __( 'Another export action is already running. Try again in a moment.', 'submission-inbox' ) );
	}

	/** @return void */
	private function release_registry_lock( $blog_id, $user_id, $token ) {
		$key     = $this->lock_key( $blog_id, $user_id );
		$current = get_option( $key, array() );
		if ( is_array( $current ) && isset( $current['token'] ) && hash_equals( (string) $current['token'], (string) $token ) ) {
			$this->compare_and_delete_option( $key, $current );
		}
	}

	/**
	 * Replace an expired lease only when the database still contains the exact
	 * value that was inspected. A delete/add takeover can delete a contender's
	 * fresh lease between those two operations.
	 *
	 * @param string              $key Option name.
	 * @param array<string,mixed> $expected Exact current value.
	 * @param array<string,mixed> $replacement New lease value.
	 * @return bool
	 */
	private function compare_and_swap_option( $key, array $expected, array $replacement ) {
		global $wpdb;

		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE {$wpdb->options}
				SET option_value = %s
				WHERE option_name = %s AND BINARY option_value = %s",
				maybe_serialize( $replacement ),
				(string) $key,
				maybe_serialize( $expected )
			)
		);
		if ( 1 === $updated ) {
			wp_cache_delete( (string) $key, 'options' );
			return true;
		}

		return false;
	}

	/**
	 * Release only the exact lease owned by this worker.
	 *
	 * @param string              $key Option name.
	 * @param array<string,mixed> $expected Exact owned lease.
	 * @return bool
	 */
	private function compare_and_delete_option( $key, array $expected ) {
		global $wpdb;

		$deleted = $wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options}
				WHERE option_name = %s AND BINARY option_value = %s",
				(string) $key,
				maybe_serialize( $expected )
			)
		);
		if ( 1 === $deleted ) {
			wp_cache_delete( (string) $key, 'options' );
			return true;
		}

		return false;
	}

	/** @return string */
	private function lock_key( $blog_id, $user_id ) {
		return self::LOCK_PREFIX . substr( hash( 'sha256', absint( $blog_id ) . ':' . absint( $user_id ) ), 0, 24 );
	}

	/**
	 * @param int $blog_id Owning site id.
	 * @return array<string,mixed>|\WP_Error
	 */
	private function create_private_file( $blog_id ) {
		$roots = array( sys_get_temp_dir() );
		if ( function_exists( 'get_temp_dir' ) ) {
			$roots[] = get_temp_dir();
		}
		$roots = array_values( array_unique( array_filter( array_map( 'strval', $roots ) ) ) );

		foreach ( $roots as $root ) {
			$root = realpath( $root );
			if ( false === $root || ! is_dir( $root ) ) {
				continue;
			}
			if ( function_exists( 'wp_is_writable' ) ) {
				$root_is_writable = wp_is_writable( $root );
			} else {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable -- Standalone unit-test fallback; WordPress runtime always uses wp_is_writable().
				$root_is_writable = is_writable( $root );
			}
			if ( ! $root_is_writable ) {
				continue;
			}

			for ( $attempt = 0; $attempt < 3; $attempt++ ) {
				$token = $this->random_hex( 16 );
				if ( '' === $token ) {
					continue;
				}
				$path   = rtrim( $root, '/\\' ) . DIRECTORY_SEPARATOR . $this->export_file_prefix( $blog_id ) . $token . '.csv';
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Exclusive x+b creation prevents races for a private temp export.
				$handle = @fopen( $path, 'x+b' );
				if ( false === $handle ) {
					continue;
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_chmod -- Private 0600 permissions must be applied and verified on the exclusively created file.
				$mode = @chmod( $path, 0600 ) ? @fileperms( $path ) : false;
				if ( '\\' !== DIRECTORY_SEPARATOR && ( false === $mode || 0 !== ( $mode & 0077 ) ) ) {
					// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the exclusive temp-file handle before cleanup.
					fclose( $handle );
					// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes only the exact private temp file created by this method.
					@unlink( $path );
					continue;
				}

				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- Writes the UTF-8 BOM to the locked private CSV stream.
				$ok = false !== fwrite( $handle, "\xEF\xBB\xBF" )
					&& false !== fputcsv( $handle, array_map( array( $this, 'csv_cell' ), $this->csv_header() ), ',', '"', '' )
					&& fflush( $handle );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the exclusive temp-file handle after the header write.
				fclose( $handle );
				if ( $ok ) {
					return array( 'path' => $path, 'token' => $token );
				}
				// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- Removes only the exact private temp file created by this method.
				@unlink( $path );
			}
		}

		return new \WP_Error(
			'subin_email_log_export_temp_unavailable',
			__( 'InboxMend could not create a private temporary export file. Check the server temporary directory permissions.', 'submission-inbox' )
		);
	}

	/** @return string|\WP_Error */
	private function verified_job_path( array $job ) {
		$path      = isset( $job['path'] ) && is_string( $job['path'] ) ? $job['path'] : '';
		$token     = isset( $job['file_token'] ) ? strtolower( (string) $job['file_token'] ) : '';
		$job_id    = $this->sanitize_job_id( $job['id'] ?? '' );
		$blog_id   = absint( $job['blog_id'] ?? 0 );
		$user_id   = absint( $job['user_id'] ?? 0 );
		$signature = isset( $job['path_signature'] ) ? (string) $job['path_signature'] : '';
		if ( '' === $path || ! preg_match( '/^[a-f0-9]{32}$/', $token ) || '' === $job_id || '' === $signature ) {
			return new \WP_Error( 'subin_email_log_export_path_invalid', __( 'The temporary export path is invalid.', 'submission-inbox' ) );
		}
		if ( ! hash_equals( $this->path_signature( $path, $job_id, $blog_id, $user_id ), $signature ) ) {
			return new \WP_Error( 'subin_email_log_export_path_invalid', __( 'The temporary export path could not be verified.', 'submission-inbox' ) );
		}
		$basename       = basename( $path );
		$current_name   = $this->export_file_prefix( $blog_id ) . $token . '.csv';
		$legacy_name    = 'subin-email-log-' . $token . '.csv';
		if ( ! in_array( $basename, array( $current_name, $legacy_name ), true ) || is_link( $path ) || ! is_file( $path ) ) {
			return new \WP_Error( 'subin_email_log_export_path_invalid', __( 'The temporary export file could not be verified.', 'submission-inbox' ) );
		}

		$real_path = realpath( $path );
		$real_dir  = false !== $real_path ? realpath( dirname( $real_path ) ) : false;
		if ( false === $real_path || false === $real_dir || ! in_array( $real_dir, $this->allowed_temp_roots(), true ) ) {
			return new \WP_Error( 'subin_email_log_export_path_invalid', __( 'The temporary export location could not be verified.', 'submission-inbox' ) );
		}

		return $real_path;
	}

	/** @return array<int,string> */
	private function allowed_temp_roots() {
		$roots = array( sys_get_temp_dir() );
		if ( function_exists( 'get_temp_dir' ) ) {
			$roots[] = get_temp_dir();
		}

		return array_values( array_unique( array_filter( array_map( 'realpath', $roots ) ) ) );
	}

	/** @return string */
	private function installation_namespace() {
		global $wpdb;

		$db_host = defined( 'DB_HOST' ) ? (string) DB_HOST : '';
		$db_name = defined( 'DB_NAME' ) ? (string) DB_NAME : '';
		$parts   = array(
			$db_host,
			$db_name,
			isset( $wpdb->base_prefix ) ? (string) $wpdb->base_prefix : (string) ( $wpdb->prefix ?? '' ),
			// Package-free tests and unusual bootstraps may not expose DB constants.
			'' === $db_host && '' === $db_name && defined( 'ABSPATH' ) ? ABSPATH : '',
		);
		$parts = array_map(
			static function ( $part ) {
				return str_replace( '\\', '/', (string) $part );
			},
			$parts
		);

		return substr( hash( 'sha256', implode( '|', $parts ) ), 0, 16 );
	}

	/** @return string */
	private function export_file_prefix( $blog_id ) {
		$blog_id = max( 1, absint( $blog_id ) );

		return 'subin-email-log-' . $this->installation_namespace() . '-' . $blog_id . '-';
	}

	/** @return string */
	private function path_signature( $path, $job_id, $blog_id, $user_id ) {
		return hash_hmac( 'sha256', absint( $blog_id ) . '|' . absint( $user_id ) . '|' . $this->sanitize_job_id( $job_id ) . '|' . (string) $path, wp_salt( 'auth' ) );
	}

	/** @return int */
	private function safe_file_size( $path ) {
		clearstatcache( true, (string) $path );
		$size = is_file( $path ) ? filesize( $path ) : false;

		return false === $size ? 0 : absint( $size );
	}

	/** @return int|\WP_Error One when removed, zero when already absent. */
	private function delete_job_file( array &$job ) {
		$path = $this->verified_job_path( $job );
		if ( is_wp_error( $path ) ) {
			$stored_path = isset( $job['path'] ) && is_string( $job['path'] ) ? $job['path'] : '';
			if ( '' !== $stored_path && ( is_link( $stored_path ) || file_exists( $stored_path ) ) ) {
				return new \WP_Error(
					'subin_email_log_export_file_cleanup_failed',
					__( 'The private temporary export file could not be verified for deletion.', 'submission-inbox' )
				);
			}
			$removed = 0;
		} else {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink -- verified_job_path() restricts deletion to this job's signed private temp path.
			$removed = @unlink( $path ) || ( ! is_file( $path ) && ! is_link( $path ) );
			if ( ! $removed ) {
				return new \WP_Error(
					'subin_email_log_export_file_cleanup_failed',
					__( 'The private temporary export file could not be removed. Check the server temporary directory permissions.', 'submission-inbox' )
				);
			}
			$removed = 1;
		}
		unset( $job['path'], $job['path_signature'], $job['file_token'] );
		$job['file_size'] = 0;

		return $removed;
	}

	/**
	 * Roll an interrupted batch back to its acknowledged file boundary.
	 *
	 * @param string $path Verified temporary file path.
	 * @param int    $checkpoint Previously persisted byte offset.
	 * @return true|\WP_Error
	 */
	private function truncate_to_checkpoint( $path, $checkpoint ) {
		$checkpoint = absint( $checkpoint );
		$file_size  = is_file( $path ) ? $this->safe_file_size( $path ) : 0;
		if ( $file_size < 1 || $checkpoint < 1 || $checkpoint > $file_size ) {
			return new \WP_Error( 'subin_email_log_export_checkpoint_invalid', __( 'The export write checkpoint is invalid.', 'submission-inbox' ) );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Recovery requires an in-place locked truncate of the verified private CSV.
		$handle = @fopen( $path, 'c+b' );
		if ( false === $handle || ! flock( $handle, LOCK_EX ) ) {
			if ( is_resource( $handle ) ) {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the recovery stream after a lock failure.
				fclose( $handle );
			}
			return new \WP_Error( 'subin_email_log_export_checkpoint_failed', __( 'The interrupted export could not be recovered safely.', 'submission-inbox' ) );
		}
		$truncated = ftruncate( $handle, $checkpoint ) && fflush( $handle );
		flock( $handle, LOCK_UN );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the locked recovery stream after truncation.
		fclose( $handle );

		return $truncated ? true : new \WP_Error( 'subin_email_log_export_checkpoint_failed', __( 'The interrupted export could not be recovered safely.', 'submission-inbox' ) );
	}

	/**
	 * Remove a CSV whose post-write state could not be acknowledged.
	 *
	 * A second best-effort metadata write exposes a failed state when the first
	 * failure was transient. A persistent user-meta outage still leaves no CSV to
	 * duplicate or disclose, and the already scheduled cleanup owns stale state.
	 *
	 * @param int                              $blog_id Site id.
	 * @param int                              $user_id Owner id.
	 * @param string                           $job_id Job id.
	 * @param array<string,mixed>              $job Current job.
	 * @param array<string,array<string,mixed>> $jobs Registry.
	 * @return void
	 */
	private function quarantine_state_failure( $blog_id, $user_id, $job_id, array $job, array &$jobs ) {
		$this->fail_job(
			$job,
			'state_persistence_failed',
			__( 'Export progress could not be saved, so the temporary CSV was removed to prevent duplicate or partial data.', 'submission-inbox' )
		);
		unset( $job['write_checkpoint'] );
		$jobs[ $job_id ] = $job;
		$this->save_jobs( $blog_id, $user_id, $jobs );
		$this->clear_process_schedule( $blog_id, $user_id, $job_id );
	}

	/** @return array<string,mixed>|\WP_Error */
	private function append_rows( $path, array $events ) {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Incremental export appends require a locked stream and cannot use WP_Filesystem atomically.
		$handle = @fopen( $path, 'ab' );
		if ( false === $handle ) {
			return new \WP_Error( 'subin_email_log_export_write_failed', __( 'The temporary export file could not be opened for writing.', 'submission-inbox' ) );
		}
		if ( ! flock( $handle, LOCK_EX ) ) {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the append stream after a lock failure.
			fclose( $handle );
			return new \WP_Error( 'subin_email_log_export_write_failed', __( 'The temporary export file is busy.', 'submission-inbox' ) );
		}

		$written      = 0;
		$size_limited = false;
		foreach ( $events as $event ) {
			$row = $this->csv_row( $event );
			if ( false === fputcsv( $handle, array_map( array( $this, 'csv_cell' ), $row ), ',', '"', '' ) ) {
				flock( $handle, LOCK_UN );
				// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the append stream after a CSV write failure.
				fclose( $handle );
				return new \WP_Error( 'subin_email_log_export_write_failed', __( 'A row could not be written to the temporary export file.', 'submission-inbox' ) );
			}
			++$written;
			if ( ftell( $handle ) >= $this->max_bytes() ) {
				$size_limited = true;
				break;
			}
		}
		fflush( $handle );
		flock( $handle, LOCK_UN );
		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closes the locked append stream after the batch is flushed.
		fclose( $handle );

		return array( 'rows' => $written, 'size_limited' => $size_limited );
	}

	/** @return array<int,string> */
	private function csv_header() {
		return array(
			__( 'Event ID', 'submission-inbox' ),
			__( 'Created', 'submission-inbox' ),
			__( 'Outcome', 'submission-inbox' ),
			__( 'Outcome explanation', 'submission-inbox' ),
			__( 'Recipient', 'submission-inbox' ),
			__( 'Sender', 'submission-inbox' ),
			__( 'Subject', 'submission-inbox' ),
			__( 'Email type', 'submission-inbox' ),
			__( 'Origin', 'submission-inbox' ),
			__( 'Form ID', 'submission-inbox' ),
			__( 'Lead ID', 'submission-inbox' ),
			__( 'Rule ID', 'submission-inbox' ),
			__( 'Rule', 'submission-inbox' ),
			__( 'Saved design ID', 'submission-inbox' ),
			__( 'Saved design', 'submission-inbox' ),
			__( 'Rule version', 'submission-inbox' ),
			__( 'Job ID', 'submission-inbox' ),
			__( 'Correlation ID', 'submission-inbox' ),
			__( 'Scheduled', 'submission-inbox' ),
			__( 'Completed', 'submission-inbox' ),
			__( 'Provider evidence', 'submission-inbox' ),
			__( 'Reason or error', 'submission-inbox' ),
		);
	}

	/** @return array<int,mixed> */
	private function csv_row( $event ) {
		$presented = $this->presenter->present( $event );
		$outcome    = is_array( $presented['outcome'] ?? null ) ? $presented['outcome'] : array();
		$type       = is_array( $presented['email_type'] ?? null ) ? $presented['email_type'] : array();
		$origin     = is_array( $presented['origin'] ?? null ) ? $presented['origin'] : array();
		$evidence   = is_array( $presented['evidence'] ?? null ) ? $presented['evidence'] : array();
		$evidence_text = '';
		if ( ! empty( $evidence ) ) {
			$evidence_text = sanitize_text_field( $evidence['status'] ?? '' ) . ' · '
				. sanitize_text_field( $evidence['source'] ?? '' ) . ' · '
				. sanitize_text_field( $evidence['occurred_at'] ?? '' ) . ' · '
				. sanitize_text_field( $evidence['message_id'] ?? '' );
		}
		$raw_error = ! empty( $event->error_message ) ? (string) $event->error_message : (string) ( $event->reason_code ?? '' );

		return array(
			absint( $event->id ?? 0 ),
			$event->created_at ?? '',
			$outcome['label'] ?? '',
			$outcome['description'] ?? '',
			$event->to_email ?? '',
			$event->from_email ?? '',
			$event->subject ?? '',
			$type['label'] ?? '',
			$origin['label'] ?? '',
			$origin['form_id'] ?? ( $event->effective_form_id ?? '' ),
			$event->lead_id ?? '',
			$event->rule_id ?? '',
			$event->rule_name ?? '',
			$event->template_id ?? '',
			$event->template_name ?? '',
			$event->rule_version ?? '',
			$event->job_id ?? '',
			$event->correlation_id ?? '',
			$event->scheduled_at ?? '',
			$event->completed_at ?? '',
			$evidence_text,
			$this->error_classifier->sanitize_technical_error( $raw_error ),
		);
	}

	/** @return string */
	private function csv_cell( $value ) {
		$value = is_scalar( $value ) ? (string) $value : '';
		if ( function_exists( 'wp_check_invalid_utf8' ) ) {
			$value = wp_check_invalid_utf8( $value, true );
		}
		$value = sanitize_textarea_field( $value );
		if ( function_exists( 'mb_substr' ) ) {
			$value = mb_substr( $value, 0, 32767 );
		} else {
			$value = substr( $value, 0, 32767 );
		}
		if ( preg_match( '/^[\x00-\x20\x{FEFF}]*[=+\-@]/u', $value ) ) {
			$value = "'" . $value;
		}

		return $value;
	}

	/** @return array<string,mixed> */
	private function sanitize_filters( array $filters ) {
		$clean = array();
		$key_allowlists = array(
			'outcome'    => array(
				'accepted', 'sent', 'handled', 'failed', 'not_generated', 'no_email_generated',
				'awaiting', 'processing', 'stalled', 'needs_attention', 'queued',
				'delivered', 'bounced', 'deferred', 'dropped', 'complained', 'opened',
			),
			'status'     => array( 'sent', 'failed', 'pending', 'unknown', 'no_email_generated' ),
			'email_type' => array(
				'admin_notification', 'visitor_auto_reply', 'visitor_confirmation', 'auto_reply_test',
				'health_verification', 'wordpress_email', 'auto_reply', 'auto_replies',
			),
			'event_kind' => array( 'generic', 'admin_notification', 'visitor_confirmation', 'auto_reply_test', 'health_verification' ),
			'date_range' => array( 'today', 'yesterday', '7', '30', '90' ),
			'sort'       => array( 'newest', 'oldest' ),
		);
		foreach ( $key_allowlists as $key => $allowed ) {
			$value = isset( $filters[ $key ] ) && is_scalar( $filters[ $key ] ) ? sanitize_key( (string) $filters[ $key ] ) : '';
			if ( in_array( $value, $allowed, true ) ) {
				$clean[ $key ] = $value;
			}
		}

		foreach ( array( 'source', 'provider' ) as $key ) {
			$value = isset( $filters[ $key ] ) && is_scalar( $filters[ $key ] ) ? sanitize_key( (string) $filters[ $key ] ) : '';
			if ( '' !== $value ) {
				$clean[ $key ] = substr( $value, 0, 100 );
			}
		}
		foreach ( array( 'form_id' => 190, 'search' => 200 ) as $key => $limit ) {
			$value = isset( $filters[ $key ] ) && is_scalar( $filters[ $key ] ) ? trim( sanitize_text_field( (string) $filters[ $key ] ) ) : '';
			if ( '' !== $value ) {
				$clean[ $key ] = substr( $value, 0, $limit );
			}
		}
		$rule_id = absint( $filters['rule_id'] ?? 0 );
		if ( $rule_id > 0 ) {
			$clean['rule_id'] = $rule_id;
		}
		foreach ( array( 'date_from', 'date_to' ) as $key ) {
			$value = isset( $filters[ $key ] ) && is_scalar( $filters[ $key ] ) ? (string) $filters[ $key ] : '';
			if ( preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches ) && checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ) ) {
				$clean[ $key ] = $value;
			}
		}
		if ( empty( $clean['sort'] ) ) {
			$clean['sort'] = 'newest';
		}

		return $clean;
	}

	/** @return bool */
	private function remove_expired_jobs( array &$jobs, $now ) {
		$changed = false;
		foreach ( $jobs as $job_id => &$job ) {
			if ( absint( $job['expires_at'] ?? 0 ) <= absint( $now ) ) {
				$cleanup = $this->delete_job_file( $job );
				if ( is_wp_error( $cleanup ) ) {
					$this->mark_cleanup_failure( $job, $cleanup->get_error_message() );
				} else {
					unset( $jobs[ $job_id ] );
				}
				$changed = true;
			}
		}
		unset( $job );

		return $changed;
	}

	/** @return void */
	private function make_room_for_job( array &$jobs ) {
		if ( count( $jobs ) < self::MAX_JOBS_PER_USER ) {
			return;
		}
		uasort(
			$jobs,
			static function ( $left, $right ) {
				return absint( $left['created_at'] ?? 0 ) <=> absint( $right['created_at'] ?? 0 );
			}
		);
		foreach ( $jobs as $job_id => &$job ) {
			if ( in_array( $job['status'] ?? '', array( self::STATUS_COMPLETED, self::STATUS_FAILED ), true ) ) {
				$cleanup = $this->delete_job_file( $job );
				if ( is_wp_error( $cleanup ) ) {
					$this->mark_cleanup_failure( $job, $cleanup->get_error_message() );
					continue;
				}
				unset( $jobs[ $job_id ] );
				break;
			}
		}
		unset( $job );
	}

	/** @return void */
	private function complete_job( array &$job ) {
		$now                 = time();
		$job['status']       = self::STATUS_COMPLETED;
		$job['completed_at'] = $now;
		$job['updated_at']   = $now;
		$job['expires_at']   = $now + $this->ttl();
		$job['error_code']   = '';
		$job['error']        = '';
	}

	/** @return void */
	private function fail_job( array &$job, $code, $message ) {
		$cleanup = $this->delete_job_file( $job );
		$job['status']       = self::STATUS_FAILED;
		$job['updated_at']   = time();
		$job['completed_at'] = time();
		$job['expires_at']   = time() + $this->ttl();
		$job['error_code']   = sanitize_key( (string) $code );
		$job['error']        = substr( sanitize_text_field( (string) $message ), 0, 300 );
		if ( is_wp_error( $cleanup ) ) {
			$job['error'] = substr(
				$job['error'] . ' ' . __( 'The private temporary file also needs manual cleanup.', 'submission-inbox' ),
				0,
				300
			);
		}
		do_action( 'subin_email_log_export_failed', $this->public_job( $job ) );
	}

	/** @return void */
	private function mark_cleanup_failure( array &$job, $message ) {
		$now                 = time();
		$job['status']       = self::STATUS_FAILED;
		$job['updated_at']   = $now;
		$job['completed_at'] = $now;
		$job['error_code']   = 'file_cleanup_failed';
		$job['error']        = substr( sanitize_text_field( (string) $message ), 0, 300 );
	}

	/** @return array<string,mixed> */
	private function public_job( array $job ) {
		$total     = absint( $job['total'] ?? 0 );
		$processed = absint( $job['processed'] ?? 0 );
		$progress  = $total > 0 ? min( 100, (int) floor( ( $processed / $total ) * 100 ) ) : 100;
		if ( self::STATUS_COMPLETED === ( $job['status'] ?? '' ) ) {
			$progress = 100;
		}

		return array(
			'id'               => $this->sanitize_job_id( $job['id'] ?? '' ),
			'status'           => sanitize_key( (string) ( $job['status'] ?? '' ) ),
			'filters'          => $this->sanitize_filters( is_array( $job['filters'] ?? null ) ? $job['filters'] : array() ),
			'total'            => $total,
			'processed'        => $processed,
			'progress'         => $progress,
			'truncated'        => ! empty( $job['truncated'] ),
			'truncated_reason' => sanitize_key( (string) ( $job['truncated_reason'] ?? '' ) ),
			'created_at'       => absint( $job['created_at'] ?? 0 ),
			'updated_at'       => absint( $job['updated_at'] ?? 0 ),
			'started_at'       => absint( $job['started_at'] ?? 0 ),
			'completed_at'     => absint( $job['completed_at'] ?? 0 ),
			'expires_at'       => absint( $job['expires_at'] ?? 0 ),
			'file_size'        => absint( $job['file_size'] ?? 0 ),
			'filename'         => sanitize_file_name( $job['filename'] ?? '' ),
			'error_code'       => sanitize_key( (string) ( $job['error_code'] ?? '' ) ),
			'error'            => substr( sanitize_text_field( (string) ( $job['error'] ?? '' ) ), 0, 300 ),
		);
	}

	/** @return true|\WP_Error */
	private function schedule_process( $blog_id, $user_id, $job_id, $delay ) {
		$result = wp_schedule_single_event(
			time() + max( 1, absint( $delay ) ),
			self::PROCESS_HOOK,
			array( absint( $blog_id ), absint( $user_id ), $this->sanitize_job_id( $job_id ) ),
			true
		);
		if ( is_wp_error( $result ) || false === $result ) {
			return new \WP_Error( 'subin_email_log_export_schedule_failed', __( 'WordPress could not schedule the background export. Check WP-Cron and try again.', 'submission-inbox' ) );
		}

		return true;
	}

	/** @return true|\WP_Error */
	private function schedule_cleanup( $blog_id, $user_id, $job_id, $timestamp ) {
		$result = wp_schedule_single_event(
			max( time() + 60, absint( $timestamp ) + 60 ),
			self::CLEANUP_HOOK,
			array( absint( $blog_id ), absint( $user_id ), $this->sanitize_job_id( $job_id ) ),
			true
		);
		if ( is_wp_error( $result ) || false === $result ) {
			return new \WP_Error(
				'subin_email_log_export_cleanup_schedule_failed',
				__( 'WordPress could not schedule private export cleanup. No export file was retained.', 'submission-inbox' )
			);
		}

		return true;
	}

	/** @return true|\WP_Error */
	private function schedule_orphan_sweep() {
		if ( wp_next_scheduled( self::SWEEP_HOOK ) ) {
			return true;
		}

		$result = wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::SWEEP_HOOK, array(), true );
		if ( is_wp_error( $result ) || false === $result ) {
			return new \WP_Error( 'subin_email_log_export_sweep_schedule_failed', __( 'WordPress could not schedule temporary export maintenance.', 'submission-inbox' ) );
		}

		return true;
	}

	/** @return void */
	private function clear_schedules( $blog_id, $user_id, $job_id ) {
		$args = array( absint( $blog_id ), absint( $user_id ), $this->sanitize_job_id( $job_id ) );
		wp_clear_scheduled_hook( self::PROCESS_HOOK, $args );
		wp_clear_scheduled_hook( self::CLEANUP_HOOK, $args );
	}

	/** @return void */
	private function clear_process_schedule( $blog_id, $user_id, $job_id ) {
		wp_clear_scheduled_hook(
			self::PROCESS_HOOK,
			array( absint( $blog_id ), absint( $user_id ), $this->sanitize_job_id( $job_id ) )
		);
	}

	/** @return int */
	private function batch_size() {
		return min( 1000, max( 25, absint( apply_filters( 'subin_email_log_export_batch_size', self::DEFAULT_BATCH_SIZE ) ) ) );
	}

	/** @return int */
	private function max_rows() {
		return min( 100000, max( 100, absint( apply_filters( 'subin_email_log_export_max_rows', self::DEFAULT_MAX_ROWS ) ) ) );
	}

	/** @return int */
	private function max_bytes() {
		return min( 209715200, max( 1048576, absint( apply_filters( 'subin_email_log_export_max_bytes', self::DEFAULT_MAX_BYTES ) ) ) );
	}

	/** @return int */
	private function ttl() {
		return min( 172800, max( HOUR_IN_SECONDS, absint( apply_filters( 'subin_email_log_export_ttl', self::DEFAULT_TTL ) ) ) );
	}

	/** @return string */
	private function sanitize_job_id( $job_id ) {
		$job_id = strtolower( trim( (string) $job_id ) );

		return preg_match( '/^[a-f0-9]{32}$/', $job_id ) ? $job_id : '';
	}

	/** @return string */
	private function random_hex( $bytes ) {
		try {
			return bin2hex( random_bytes( max( 16, absint( $bytes ) ) ) );
		} catch ( \Throwable $exception ) {
			return '';
		}
	}
}
