<?php
/**
 * Email Log Service
 *
 * Handles email logging, database operations, and settings.
 *
 * @package EmailLogs
 * @since 1.0.0
 */

namespace EmailLogs\Includes;

use WP_PowerSuite\Email\MailHeaders;

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

class EmailLogService {

	private const OPTION_KEY        = 'wp_powersuite_email_logs_settings';
	private const SCHEMA_OPTION     = 'wp_powersuite_email_logs_db_version';
	private const TABLE_NAME        = 'wpps_email_logs';
	public const CRON_PRUNE         = 'wpps_email_logs_prune';
	public const CRON_RETRY_FAILED  = 'wpps_email_logs_retry_failed';
	public const RETRY_LOCK_OPTION  = 'wpps_email_logs_retry_lock';
	public const SCHEMA_VERSION     = 7;
	private const MAX_CRON_RESENDS  = 10;
	private const MAX_CRON_ATTEMPTS = 2;
	private const RETRY_LOCK_TTL    = 10 * MINUTE_IN_SECONDS;
	private const MAX_LOG_COUNT     = 5000;
	private const MIN_LOG_COUNT     = 50;
	private const DEFAULT_LOG_COUNT = 1000;
	private const DEFAULT_RETENTION_DAYS = 30;
	/** @var list<int> */
	private const RETENTION_DAY_OPTIONS = [ 7, 14, 30, 60, 90 ];
	private const MAX_MESSAGE_BYTES = 65536;
	private const MAX_HEADERS_BYTES = 16384;
	private const MAX_ATTACHMENTS   = 25;
	private const MAX_TOTAL_BYTES   = 52428800; // 50 MB safety cap.
	/**
	 * Stable, non-translated sentinel stored when body logging is off.
	 * Must never be localized — locale changes would otherwise make resend treat it as a real body.
	 */
	private const CONTENT_DISABLED_SENTINEL = '(Content logging disabled)';

	/** Stable machine marker prepended for locale-safe detection. */
	private const CONTENT_DISABLED_MARKER = '[[WPPS_NO_BODY]]';

	/** @var int[] Pending log IDs for the current request (stack). */
	private static array $pending_log_ids = [];

	private static bool $schema_ready = false;

	/** @var array<string, bool>|null */
	private static ?array $column_cache = null;

	public static function get_table_name(): string {
		global $wpdb;
		return $wpdb->prefix . self::TABLE_NAME;
	}

	public static function create_table(): void {
		global $wpdb;
		$table   = self::get_table_name();
		$charset = $wpdb->get_charset_collate();

		// dbDelta requires CREATE TABLE (no IF NOT EXISTS) to ADD missing columns on upgrade.
		$sql = "CREATE TABLE $table (
			id bigint(20) NOT NULL AUTO_INCREMENT,
			to_email varchar(500) NOT NULL DEFAULT '',
			subject varchar(500) NOT NULL DEFAULT '',
			message longtext NOT NULL,
			headers text,
			status varchar(20) NOT NULL DEFAULT 'pending',
			error_message text,
			sent_date datetime DEFAULT CURRENT_TIMESTAMP,
			provider varchar(50) NOT NULL DEFAULT '',
			provider_message_id varchar(191) NOT NULL DEFAULT '',
			duration_ms int(11) NOT NULL DEFAULT 0,
			trigger_source varchar(100) NOT NULL DEFAULT '',
			retry_count smallint(6) NOT NULL DEFAULT 0,
			fallback_used tinyint(1) NOT NULL DEFAULT 0,
			delivery_status varchar(32) NOT NULL DEFAULT '',
			transport varchar(20) NOT NULL DEFAULT '',
			connection_id varchar(64) NOT NULL DEFAULT '',
			envelope_sender varchar(191) NOT NULL DEFAULT '',
			routing_reason varchar(32) NOT NULL DEFAULT '',
			from_email varchar(191) NOT NULL DEFAULT '',
			attachments longtext NULL,
			PRIMARY KEY  (id),
			KEY sent_date (sent_date),
			KEY status (status),
			KEY to_email (to_email(191)),
			KEY subject (subject(191)),
			KEY provider (provider),
			KEY provider_message_id (provider_message_id),
			KEY delivery_status (delivery_status),
			KEY transport (transport),
			KEY from_email (from_email)
		) $charset;";

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';
		dbDelta( $sql );
		self::ensure_schema();
	}

	/**
	 * Ensure Phase 4+ columns exist on sites that created the table before the upgrade.
	 */
	public static function ensure_schema(): void {
		global $wpdb;
		$table = self::get_table_name();

		$current = (int) get_option( self::SCHEMA_OPTION, 0 );
		if ( $current >= self::SCHEMA_VERSION && self::$column_cache !== null ) {
			return;
		}

		// Table may not exist yet on first boot before CREATE finishes.
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists !== $table ) {
			return;
		}

		if ( $current >= self::SCHEMA_VERSION ) {
			self::warm_column_cache();
			return;
		}

		$needed = [
			'provider'            => "ALTER TABLE `$table` ADD COLUMN provider varchar(50) NOT NULL DEFAULT ''",
			'provider_message_id' => "ALTER TABLE `$table` ADD COLUMN provider_message_id varchar(191) NOT NULL DEFAULT ''",
			'duration_ms'         => "ALTER TABLE `$table` ADD COLUMN duration_ms int(11) NOT NULL DEFAULT 0",
			'trigger_source'      => "ALTER TABLE `$table` ADD COLUMN trigger_source varchar(100) NOT NULL DEFAULT ''",
			'retry_count'         => "ALTER TABLE `$table` ADD COLUMN retry_count smallint(6) NOT NULL DEFAULT 0",
			'fallback_used'       => "ALTER TABLE `$table` ADD COLUMN fallback_used tinyint(1) NOT NULL DEFAULT 0",
			'delivery_status'     => "ALTER TABLE `$table` ADD COLUMN delivery_status varchar(32) NOT NULL DEFAULT ''",
			'transport'           => "ALTER TABLE `$table` ADD COLUMN transport varchar(20) NOT NULL DEFAULT ''",
			'connection_id'       => "ALTER TABLE `$table` ADD COLUMN connection_id varchar(64) NOT NULL DEFAULT ''",
			'envelope_sender'     => "ALTER TABLE `$table` ADD COLUMN envelope_sender varchar(191) NOT NULL DEFAULT ''",
			'routing_reason'      => "ALTER TABLE `$table` ADD COLUMN routing_reason varchar(32) NOT NULL DEFAULT ''",
			'from_email'          => "ALTER TABLE `$table` ADD COLUMN from_email varchar(191) NOT NULL DEFAULT ''",
			'attachments'         => "ALTER TABLE `$table` ADD COLUMN attachments longtext NULL",
		];

		$failed = false;
		foreach ( $needed as $column => $alter_sql ) {
			if ( ! self::table_has_column( $column ) ) {
				// phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
				$result = $wpdb->query( $alter_sql );
				if ( false === $result ) {
					$failed = true;
				}
			}
		}

		self::$column_cache = null;
		self::warm_column_cache();

		if ( ! $failed ) {
			update_option( self::SCHEMA_OPTION, self::SCHEMA_VERSION, false );
		}
	}

	private static function warm_column_cache(): void {
		$names = [
			'provider',
			'provider_message_id',
			'duration_ms',
			'trigger_source',
			'retry_count',
			'fallback_used',
			'delivery_status',
			'transport',
			'connection_id',
			'envelope_sender',
			'routing_reason',
			'from_email',
			'attachments',
		];
		self::$column_cache = [];
		foreach ( $names as $column ) {
			self::$column_cache[ $column ] = self::table_has_column_uncached( $column );
		}
	}

	private static function table_has_column_uncached( string $column ): bool {
		global $wpdb;
		$table = self::get_table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$found = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM `$table` LIKE %s", $column ) );
		return ! empty( $found );
	}

	private static function table_has_column( string $column ): bool {
		if ( self::$column_cache !== null && array_key_exists( $column, self::$column_cache ) ) {
			return self::$column_cache[ $column ];
		}
		global $wpdb;
		$table = self::get_table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$found = $wpdb->get_results( $wpdb->prepare( "SHOW COLUMNS FROM `$table` LIKE %s", $column ) );
		return ! empty( $found );
	}

	/**
	 * @return list<int>
	 */
	public static function retention_day_options(): array {
		return self::RETENTION_DAY_OPTIONS;
	}

	public static function get_settings(): array {
		$defaults = [
			'limit'             => self::DEFAULT_LOG_COUNT,
			'retention_days'    => self::DEFAULT_RETENTION_DAYS,
			'log_content'       => 0,
			'dashboard_widget'  => 1,
		];
		$raw      = get_option( self::OPTION_KEY, [] );
		$settings = wp_parse_args( is_array( $raw ) ? $raw : [], $defaults );
		$settings['limit'] = max( self::MIN_LOG_COUNT, min( self::MAX_LOG_COUNT, (int) $settings['limit'] ) );
		$days = (int) ( $settings['retention_days'] ?? self::DEFAULT_RETENTION_DAYS );
		if ( ! in_array( $days, self::RETENTION_DAY_OPTIONS, true ) ) {
			$days = self::DEFAULT_RETENTION_DAYS;
		}
		$settings['retention_days']   = $days;
		$settings['log_content']      = ! empty( $settings['log_content'] ) ? 1 : 0;
		$settings['dashboard_widget'] = ! isset( $settings['dashboard_widget'] ) || (string) $settings['dashboard_widget'] !== '0' ? 1 : 0;
		return $settings;
	}

	public static function save_settings( array $settings ): void {
		$current = self::get_settings();
		$limit   = isset( $settings['limit'] )
			? max( self::MIN_LOG_COUNT, min( self::MAX_LOG_COUNT, (int) $settings['limit'] ) )
			: $current['limit'];
		$days = isset( $settings['retention_days'] )
			? (int) $settings['retention_days']
			: (int) $current['retention_days'];
		if ( ! in_array( $days, self::RETENTION_DAY_OPTIONS, true ) ) {
			$days = self::DEFAULT_RETENTION_DAYS;
		}
		$log_content = array_key_exists( 'log_content', $settings )
			? ( ! empty( $settings['log_content'] ) ? 1 : 0 )
			: (int) $current['log_content'];
		$dashboard = array_key_exists( 'dashboard_widget', $settings )
			? ( ! empty( $settings['dashboard_widget'] ) && (string) $settings['dashboard_widget'] !== '0' ? 1 : 0 )
			: (int) $current['dashboard_widget'];

		update_option(
			self::OPTION_KEY,
			[
				'limit'            => $limit,
				'retention_days'   => $days,
				'log_content'      => $log_content,
				'dashboard_widget' => $dashboard,
			],
			false
		);
		// Immediately drop older rows so the list reflects the new policy.
		self::prune_logs();
	}

	public static function is_dashboard_widget_enabled(): bool {
		return ! empty( self::get_settings()['dashboard_widget'] );
	}

	public static function schedule_prune(): void {
		if ( ! wp_next_scheduled( self::CRON_PRUNE ) ) {
			wp_schedule_event( time(), 'daily', self::CRON_PRUNE );
		}
	}

	public static function clear_prune_schedule(): void {
		wp_clear_scheduled_hook( self::CRON_PRUNE );
	}

	public static function schedule_retry_failed(): void {
		if ( ! wp_next_scheduled( self::CRON_RETRY_FAILED ) ) {
			wp_schedule_event( time() + HOUR_IN_SECONDS, 'hourly', self::CRON_RETRY_FAILED );
		}
	}

	public static function clear_retry_failed_schedule(): void {
		wp_clear_scheduled_hook( self::CRON_RETRY_FAILED );
	}

	/**
	 * Whether Email Delivery opted into hourly resend of failed logs.
	 */
	public static function is_auto_retry_enabled(): bool {
		if ( ! class_exists( \WP_PowerSuite\Core\OptionStoreModuleSettingsRepository::class ) ) {
			return false;
		}
		$settings = \WP_PowerSuite\Core\OptionStoreModuleSettingsRepository::forModule( 'email-smtp' )->all( [] );
		return ! empty( $settings['auto_retry_failed'] ) && (string) $settings['auto_retry_failed'] !== '0';
	}

	/**
	 * Cron: resend recent failed logs that still have a body (capped attempts).
	 */
	public static function cron_retry_failed(): void {
		if ( ! self::is_auto_retry_enabled() ) {
			return;
		}

		if ( ! self::acquire_retry_lock() ) {
			return;
		}

		global $wpdb;
		$table = self::get_table_name();
		// Match site-local sent_date storage (current_time('mysql')).
		$since = wp_date( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ), wp_timezone() );

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, retry_count FROM $table
				WHERE status = %s
					AND sent_date >= %s
					AND retry_count < %d
					AND message != ''
					AND message != %s
					AND message NOT LIKE %s
				ORDER BY id ASC
				LIMIT %d",
				'failed',
				$since,
				self::MAX_CRON_ATTEMPTS,
				self::CONTENT_DISABLED_SENTINEL,
				self::CONTENT_DISABLED_MARKER . '%',
				self::MAX_CRON_RESENDS
			)
		);

		if ( ! is_array( $rows ) || $rows === [] ) {
			self::release_retry_lock();
			return;
		}

		try {
			foreach ( $rows as $row ) {
				$id = (int) ( $row->id ?? 0 );
				if ( $id < 1 ) {
					continue;
				}

				$expected_retry = (int) ( $row->retry_count ?? 0 );
				if ( ! self::claim_failed_log_for_retry( $id, $expected_retry ) ) {
					continue;
				}

				$log = self::get_log_by_id( $id );
				if ( ! $log || ! self::can_resend_log( $log ) ) {
					// Claim already burned an attempt so unusable rows are not retried forever.
					continue;
				}

				$result = self::resend_log( $id );

				// Keep the original row as failed so charts are not double-counted.
				// Cap retry_count so the same message is not retried again; the new
				// send is logged separately via wp_mail.
				if ( ! is_wp_error( $result ) ) {
					$wpdb->update(
						$table,
						[
							'retry_count'     => self::MAX_CRON_ATTEMPTS,
							'delivery_status' => 'retried',
						],
						[ 'id' => $id, 'status' => 'failed' ],
						[ '%d', '%s' ],
						[ '%d', '%s' ]
					);
				}

				/**
				 * @param int               $id
				 * @param true|\WP_Error    $result
				 */
				do_action( 'wp_powersuite_email_logs_cron_resend', $id, $result );
			}
		} finally {
			self::release_retry_lock();
		}
	}

	/**
	 * Atomic worker lock. add_option is unique on option_name — two overlapping
	 * cron spawns cannot both proceed. Value is acquired-at unix time.
	 */
	private static function acquire_retry_lock(): bool {
		$now = time();
		if ( add_option( self::RETRY_LOCK_OPTION, (string) $now, '', 'no' ) ) {
			return true;
		}

		$acquired = (int) get_option( self::RETRY_LOCK_OPTION, 0 );
		if ( $acquired > 0 && ( $now - $acquired ) < self::RETRY_LOCK_TTL ) {
			return false;
		}

		delete_option( self::RETRY_LOCK_OPTION );
		return (bool) add_option( self::RETRY_LOCK_OPTION, (string) $now, '', 'no' );
	}

	private static function release_retry_lock(): void {
		delete_option( self::RETRY_LOCK_OPTION );
	}

	/**
	 * Claim one failed row before wp_mail so overlapping workers cannot both send.
	 *
	 * @return bool True when this worker owns the row (exactly one row updated).
	 */
	private static function claim_failed_log_for_retry( int $id, int $expected_retry_count ): bool {
		if ( $id < 1 || $expected_retry_count < 0 ) {
			return false;
		}

		global $wpdb;
		$table = self::get_table_name();

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$updated = $wpdb->query(
			$wpdb->prepare(
				"UPDATE $table
				SET retry_count = retry_count + 1
				WHERE id = %d
					AND status = %s
					AND retry_count = %d
					AND retry_count < %d",
				$id,
				'failed',
				$expected_retry_count,
				self::MAX_CRON_ATTEMPTS
			)
		);

		return (int) $updated === 1;
	}

	/**
	 * Remove log rows beyond age, count, and total byte caps.
	 */
	public static function prune_logs(): void {
		$lock_key = 'wpps_email_logs_prune_lock';
		if ( get_transient( $lock_key ) ) {
			return;
		}
		set_transient( $lock_key, '1', 15 * MINUTE_IN_SECONDS );

		try {
			$table    = self::get_table_name();
			$settings = self::get_settings();
			$limit    = max( self::MIN_LOG_COUNT, min( self::MAX_LOG_COUNT, (int) ( $settings['limit'] ?? self::DEFAULT_LOG_COUNT ) ) );
			$days     = (int) ( $settings['retention_days'] ?? self::DEFAULT_RETENTION_DAYS );

			self::prune_older_than_days( $table, $days );
			self::prune_count_over_limit( $table, $limit );
			self::prune_by_total_bytes( $table );
		} finally {
			delete_transient( $lock_key );
		}
	}

	/**
	 * Drop rows older than the retention window (newest always kept within count cap).
	 */
	private static function prune_older_than_days( string $table, int $days ): void {
		global $wpdb;
		if ( $days < 1 ) {
			return;
		}
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE sent_date < %s",
				$cutoff
			)
		);
	}

	/**
	 * Keep only the newest rows up to the configured count.
	 */
	private static function prune_count_over_limit( string $table, int $limit ): void {
		global $wpdb;

		// Oldest id among the rows we keep; delete everything below it (avoids huge NOT IN lists).
		$cutoff_id = (int) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM $table ORDER BY id DESC LIMIT 1 OFFSET %d",
				max( 0, $limit - 1 )
			)
		);

		if ( $cutoff_id < 1 ) {
			return;
		}

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM $table WHERE id < %d",
				$cutoff_id
			)
		);
	}

	/**
	 * Delete oldest rows until stored content is under the byte cap.
	 */
	private static function prune_by_total_bytes( string $table ): void {
		global $wpdb;

		$total_bytes = (int) $wpdb->get_var(
			"SELECT COALESCE(SUM(LENGTH(message) + LENGTH(COALESCE(headers, '')) + LENGTH(COALESCE(error_message, ''))), 0) FROM $table"
		);

		if ( $total_bytes <= self::MAX_TOTAL_BYTES ) {
			return;
		}

		$batch = 25;
		while ( $total_bytes > self::MAX_TOTAL_BYTES ) {
			$oldest_ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT id FROM $table ORDER BY id ASC LIMIT %d",
					$batch
				)
			);

			if ( empty( $oldest_ids ) ) {
				break;
			}

			$ids_placeholder = implode( ',', array_map( 'absint', $oldest_ids ) );
			$wpdb->query( "DELETE FROM $table WHERE id IN ($ids_placeholder)" );

			$total_bytes = (int) $wpdb->get_var(
				"SELECT COALESCE(SUM(LENGTH(message) + LENGTH(COALESCE(headers, '')) + LENGTH(COALESCE(error_message, ''))), 0) FROM $table"
			);
		}
	}

	/**
	 * Log an email (called from wp_mail filter).
	 *
	 * @param array{to?:mixed,subject?:string,message?:string,headers?:string|array,attachments?:array} $args
	 * @return array The original args (unchanged)
	 */
	public static function log_email( array $args ): array {
		if ( apply_filters( 'wp_powersuite_skip_email_log', false, $args ) ) {
			return $args;
		}

		if ( ! self::$schema_ready ) {
			self::create_table();
			self::$schema_ready = true;
		}

		global $wpdb;
		$table = self::get_table_name();

		$to      = self::normalize_recipients( $args['to'] ?? '' );
		$subject = isset( $args['subject'] ) ? substr( sanitize_text_field( $args['subject'] ), 0, 500 ) : '';

		$headers = $args['headers'] ?? '';
		if ( is_array( $headers ) ) {
			$headers = implode( "\n", $headers );
		}
		$headers = self::sanitize_headers_for_storage( (string) $headers );

		$settings = self::get_settings();
		if ( ! empty( $settings['log_content'] ) ) {
			$message = is_string( $args['message'] ?? '' ) ? $args['message'] : '';
			if ( strlen( $message ) > self::MAX_MESSAGE_BYTES ) {
				$message = substr( $message, 0, self::MAX_MESSAGE_BYTES ) . "\n\n" . __( '[Content truncated - exceeded size limit]', 'wppowersuite-lite' );
			}
			// Redact reset/magic-login URLs, OTP codes, and credentials before persistence.
			$message = self::mask_sensitive_text( $message );
		} else {
			$message = self::CONTENT_DISABLED_MARKER . self::CONTENT_DISABLED_SENTINEL;
		}

		$from_email  = self::extract_from_email( $headers );
		$provider    = '';
		$connection  = '';
		$routing     = '';
		if ( class_exists( '\EmailSmtp\Includes\EmailRouter' ) && class_exists( '\WP_PowerSuite\Core\OptionStoreModuleSettingsRepository' ) ) {
			if ( \WP_PowerSuite\Core\OptionStoreModuleSettingsRepository::isModuleEnabled( 'email-smtp' ) ) {
				$ed_settings = \EmailSmtp\Includes\EmailRouter::get_settings();
				$from_guess  = $from_email;
				if ( class_exists( '\EmailSmtp\Includes\ConnectionStore' ) ) {
					$connection = \EmailSmtp\Includes\ConnectionStore::resolve_connection_id( $ed_settings, $from_guess );
					$routing    = \EmailSmtp\Includes\ConnectionStore::routing_reason( $ed_settings, $from_guess, $connection );
					$slice      = $connection !== ''
						? \EmailSmtp\Includes\ConnectionStore::settings_for_connection( $ed_settings, $connection )
						: $ed_settings;
					$provider = sanitize_key( (string) ( $slice['provider'] ?? $ed_settings['provider'] ?? '' ) );
				} else {
					$provider = sanitize_key( (string) ( $ed_settings['provider'] ?? '' ) );
				}
			}
		}

		$attachments_json = self::encode_attachments_meta( self::normalize_attachments_meta( $args['attachments'] ?? [] ) );

		$row = [
			'to_email'  => $to,
			'subject'   => $subject,
			'message'   => $message,
			'headers'   => $headers,
			'status'    => 'pending',
			'provider'  => $provider,
			// Site-local WP time so DATE(sent_date) aligns with overview/dashboard day bounds.
			'sent_date' => current_time( 'mysql' ),
		];
		$formats = [ '%s', '%s', '%s', '%s', '%s', '%s', '%s' ];
		if ( self::table_has_column( 'from_email' ) ) {
			$row['from_email'] = substr( $from_email, 0, 191 );
			$formats[]         = '%s';
		}
		if ( self::table_has_column( 'attachments' ) ) {
			$row['attachments'] = $attachments_json;
			$formats[]          = '%s';
		}
		if ( self::table_has_column( 'connection_id' ) ) {
			$row['connection_id'] = substr( sanitize_key( $connection ), 0, 64 );
			$formats[]            = '%s';
		}
		if ( self::table_has_column( 'routing_reason' ) ) {
			$row['routing_reason'] = substr( sanitize_key( $routing ), 0, 32 );
			$formats[]             = '%s';
		}

		$inserted = $wpdb->insert( $table, $row, $formats );

		if ( $inserted ) {
			self::$pending_log_ids[] = (int) $wpdb->insert_id;
			$limit = max( self::MIN_LOG_COUNT, min( self::MAX_LOG_COUNT, (int) ( self::get_settings()['limit'] ?? self::DEFAULT_LOG_COUNT ) ) );
			$count = (int) $wpdb->get_var( "SELECT COUNT(*) FROM $table" );
			if ( $count > $limit ) {
				self::prune_count_over_limit( $table, $limit );
			}
		}

		return $args;
	}

	/**
	 * Enrich the pending log row with delivery metadata (provider message id, duration, etc.).
	 *
	 * @param array<string, mixed> $data
	 */
	public static function enrich_pending( array $data ): void {
		self::ensure_schema();
		if ( empty( self::$pending_log_ids ) ) {
			return;
		}
		$log_id = end( self::$pending_log_ids );
		if ( ! $log_id ) {
			return;
		}

		global $wpdb;
		$table  = self::get_table_name();
		$update = [];
		$format = [];

		if ( isset( $data['provider'] ) ) {
			$update['provider'] = sanitize_key( (string) $data['provider'] );
			$format[]           = '%s';
		}
		if ( isset( $data['provider_message_id'] ) ) {
			$update['provider_message_id'] = substr( sanitize_text_field( (string) $data['provider_message_id'] ), 0, 191 );
			$format[]                     = '%s';
		}
		if ( isset( $data['duration_ms'] ) ) {
			$update['duration_ms'] = max( 0, (int) $data['duration_ms'] );
			$format[]             = '%d';
		}
		if ( isset( $data['trigger_source'] ) ) {
			$update['trigger_source'] = substr( sanitize_key( (string) $data['trigger_source'] ), 0, 100 );
			$format[]                = '%s';
		}
		if ( isset( $data['retry_count'] ) ) {
			$update['retry_count'] = max( 0, (int) $data['retry_count'] );
			$format[]             = '%d';
		}
		if ( isset( $data['fallback_used'] ) ) {
			$update['fallback_used'] = ! empty( $data['fallback_used'] ) ? 1 : 0;
			$format[]               = '%d';
		}
		if ( isset( $data['delivery_status'] ) ) {
			$update['delivery_status'] = self::sanitize_delivery_status( (string) $data['delivery_status'] );
			$format[]                  = '%s';
		}
		if ( isset( $data['transport'] ) && self::table_has_column( 'transport' ) ) {
			$update['transport'] = substr( sanitize_key( (string) $data['transport'] ), 0, 20 );
			$format[]            = '%s';
		}
		if ( isset( $data['connection_id'] ) && self::table_has_column( 'connection_id' ) ) {
			$update['connection_id'] = substr( sanitize_key( (string) $data['connection_id'] ), 0, 64 );
			$format[]                = '%s';
		}
		if ( isset( $data['envelope_sender'] ) && self::table_has_column( 'envelope_sender' ) ) {
			$envelope = sanitize_email( (string) $data['envelope_sender'] );
			if ( is_email( $envelope ) ) {
				$update['envelope_sender'] = substr( $envelope, 0, 191 );
				$format[]                  = '%s';
			}
		}
		if ( isset( $data['routing_reason'] ) && self::table_has_column( 'routing_reason' ) ) {
			$update['routing_reason'] = substr( sanitize_key( (string) $data['routing_reason'] ), 0, 32 );
			$format[]                 = '%s';
		}
		if ( isset( $data['from_email'] ) && self::table_has_column( 'from_email' ) ) {
			$from = sanitize_email( (string) $data['from_email'] );
			// Never wipe a previously captured From with an empty enrich payload.
			if ( is_email( $from ) ) {
				$update['from_email'] = substr( $from, 0, 191 );
				$format[]             = '%s';
			}
		}
		if ( isset( $data['headers'] ) ) {
			$existing_headers = (string) $wpdb->get_var(
				$wpdb->prepare( "SELECT headers FROM $table WHERE id = %d", (int) $log_id )
			);
			$merged            = MailHeaders::mergeLines( $existing_headers, (string) $data['headers'] );
			$update['headers'] = self::sanitize_headers_for_storage( implode( "\n", $merged ) );
			$format[]          = '%s';
		}
		if ( isset( $data['attachments'] ) && self::table_has_column( 'attachments' ) ) {
			if ( is_string( $data['attachments'] ) ) {
				$update['attachments'] = self::encode_attachments_meta( self::decode_attachments_meta( $data['attachments'] ) );
			} else {
				$update['attachments'] = self::encode_attachments_meta( self::normalize_attachments_meta( $data['attachments'] ) );
			}
			$format[] = '%s';
		}

		if ( $update === [] ) {
			return;
		}

		$wpdb->update( $table, $update, [ 'id' => (int) $log_id ], $format, [ '%d' ] );
	}

	/**
	 * Capture final PHPMailer state into the pending log row.
	 * Works with WP PowerSuite Email Delivery and third-party SMTP plugins that configure PHPMailer.
	 *
	 * @param object $phpmailer PHPMailer instance.
	 */
	public static function capture_phpmailer( object $phpmailer ): void {
		if ( empty( self::$pending_log_ids ) ) {
			return;
		}

		// Never touch credential properties; snapshot helpers only read public identity/transport fields.
		$snap = MailHeaders::snapshotFromPhpMailer( $phpmailer );
		$log_id = (int) end( self::$pending_log_ids );
		if ( $log_id < 1 ) {
			return;
		}

		global $wpdb;
		$table = self::get_table_name();
		$select = 'headers, provider, transport, envelope_sender, provider_message_id, from_email';
		if ( self::table_has_column( 'attachments' ) ) {
			$select .= ', attachments';
		}
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- column list is fixed.
		$current = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT $select FROM $table WHERE id = %d",
				$log_id
			)
		);
		if ( ! $current ) {
			return;
		}

		$merged_lines = MailHeaders::mergeLines( (string) ( $current->headers ?? '' ), $snap['lines'] );
		$headers      = self::sanitize_headers_for_storage( implode( "\n", $merged_lines ) );

		$data = [
			'headers' => $headers,
		];

		$from = $snap['from_email'] !== '' ? $snap['from_email'] : self::extract_from_email( $headers );
		if ( $from !== '' ) {
			$data['from_email'] = $from;
		}

		// Never overwrite Email Delivery / prior provider labels — only fill gaps for third-party SMTP.
		$existing_provider = sanitize_key( (string) ( $current->provider ?? '' ) );
		if ( $existing_provider === '' && $snap['provider'] !== '' ) {
			$data['provider'] = $snap['provider'];
		}

		$existing_transport = sanitize_key( (string) ( $current->transport ?? '' ) );
		if ( $existing_transport === '' && $snap['transport'] !== '' ) {
			$data['transport'] = $snap['transport'];
		}

		$existing_envelope = sanitize_email( (string) ( $current->envelope_sender ?? '' ) );
		if ( ( $existing_envelope === '' || ! is_email( $existing_envelope ) ) && $snap['envelope_sender'] !== '' ) {
			$data['envelope_sender'] = $snap['envelope_sender'];
		}

		$existing_msgid = trim( (string) ( $current->provider_message_id ?? '' ) );
		if ( $existing_msgid === '' && $snap['message_id'] !== '' ) {
			$data['provider_message_id'] = $snap['message_id'];
		}

		// Fill attachment names if wp_mail args were empty but PHPMailer has them.
		$existing_atts = self::decode_attachments_meta( (string) ( $current->attachments ?? '' ) );
		if ( $existing_atts === [] ) {
			$from_mailer = self::attachments_meta_from_phpmailer( $phpmailer );
			if ( $from_mailer !== [] ) {
				$data['attachments'] = $from_mailer;
			}
		}

		/**
		 * Filter enrichment captured from PHPMailer for Email Logs.
		 *
		 * @param array<string, mixed> $data
		 * @param object               $phpmailer
		 * @param int                  $log_id
		 */
		$data = apply_filters( 'wp_powersuite_email_logs_phpmailer_capture', $data, $phpmailer, $log_id );
		if ( ! is_array( $data ) || $data === [] ) {
			return;
		}

		self::enrich_pending( $data );
	}

	/**
	 * Best-effort capture using the global PHPMailer instance after send completes.
	 */
	public static function capture_global_phpmailer(): void {
		global $phpmailer;
		if ( isset( $phpmailer ) && is_object( $phpmailer ) ) {
			self::capture_phpmailer( $phpmailer );
		}
	}

	public static function extract_from_email( string $headers ): string {
		$from = MailHeaders::getHeader( $headers, 'From' );
		if ( $from === '' ) {
			return '';
		}
		$email = MailHeaders::extractAddress( $from );

		return is_email( $email ) ? $email : '';
	}

	/**
	 * Structured header fields for the Email Logs detail UI.
	 *
	 * @return array{from:string,reply_to:string,cc:string,bcc:string,content_type:string,message_id:string,raw:string,parsed:array<string,string>}
	 */
	public static function headers_for_display( string $headers ): array {
		$map = MailHeaders::toMap( $headers );

		return [
			'from'         => $map['from'] ?? '',
			'reply_to'     => $map['reply-to'] ?? '',
			'cc'           => $map['cc'] ?? '',
			'bcc'          => $map['bcc'] ?? '',
			'content_type' => $map['content-type'] ?? '',
			'message_id'   => $map['message-id'] ?? '',
			'raw'          => $headers,
			'parsed'       => $map,
		];
	}

	private static function sanitize_headers_for_storage( string $headers ): string {
		$headers = MailHeaders::sanitizeForStorage( $headers );
		$headers = self::mask_sensitive_text( $headers );
		if ( strlen( $headers ) > self::MAX_HEADERS_BYTES ) {
			$headers = substr( $headers, 0, self::MAX_HEADERS_BYTES ) . "\n\n[Headers truncated]";
		}

		return $headers;
	}

	/**
	 * Build attachment metadata for storage.
	 * Stores basename + size only — never file contents or absolute server paths.
	 *
	 * @param mixed $attachments wp_mail attachments argument.
	 * @return list<array{name:string,size:int}>
	 */
	public static function normalize_attachments_meta( $attachments ): array {
		if ( is_string( $attachments ) && $attachments !== '' ) {
			$attachments = [ $attachments ];
		}
		if ( ! is_array( $attachments ) ) {
			return [];
		}

		$out = [];
		foreach ( $attachments as $entry ) {
			if ( count( $out ) >= self::MAX_ATTACHMENTS ) {
				break;
			}

			$name = '';
			$path = '';
			$size = 0;

			if ( is_string( $entry ) ) {
				$path = $entry;
				$name = wp_basename( $entry );
			} elseif ( is_array( $entry ) ) {
				// Common shapes: [path], [path, name], ['path'=>..., 'name'=>...], or PHPMailer rows.
				$path = (string) ( $entry['path'] ?? $entry[0] ?? '' );
				$name = (string) ( $entry['name'] ?? $entry['filename'] ?? $entry[2] ?? $entry[1] ?? '' );
				if ( $name === '' && $path !== '' ) {
					$name = wp_basename( $path );
				}
				if ( isset( $entry['size'] ) ) {
					$size = max( 0, (int) $entry['size'] );
				}
			} else {
				continue;
			}

			$name = self::sanitize_attachment_name( $name !== '' ? $name : ( $path !== '' ? wp_basename( $path ) : '' ) );
			if ( $name === '' ) {
				continue;
			}

			if ( $size <= 0 && $path !== '' && ! preg_match( '/[\r\n\0]/', $path ) && is_readable( $path ) ) {
				$filesize = @filesize( $path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
				if ( is_int( $filesize ) || is_float( $filesize ) ) {
					$size = max( 0, (int) $filesize );
				}
			}

			$out[] = [
				'name' => $name,
				'size' => $size,
			];
		}

		return $out;
	}

	/**
	 * @param object $phpmailer
	 * @return list<array{name:string,size:int}>
	 */
	public static function attachments_meta_from_phpmailer( object $phpmailer ): array {
		$list = [];
		if ( method_exists( $phpmailer, 'getAttachments' ) ) {
			$raw = $phpmailer->getAttachments();
			if ( is_array( $raw ) ) {
				$list = $raw;
			}
		}

		return self::normalize_attachments_meta( $list );
	}

	/**
	 * @param list<array{name:string,size:int}> $items
	 */
	public static function encode_attachments_meta( array $items ): string {
		if ( $items === [] ) {
			return '';
		}
		$encoded = wp_json_encode( array_values( $items ) );
		return is_string( $encoded ) ? $encoded : '';
	}

	/**
	 * @return list<array{name:string,size:int}>
	 */
	public static function decode_attachments_meta( string $json ): array {
		$json = trim( $json );
		if ( $json === '' || $json === '[]' || $json === 'null' ) {
			return [];
		}
		$decoded = json_decode( $json, true );
		if ( ! is_array( $decoded ) ) {
			return [];
		}

		return self::normalize_attachments_meta( $decoded );
	}

	public static function sanitize_attachment_name( string $name ): string {
		$name = wp_basename( str_replace( [ "\0", "\r", "\n" ], '', $name ) );
		$name = sanitize_file_name( $name );
		$name = trim( $name );
		if ( $name === '' || $name === '.' || $name === '..' ) {
			return '';
		}

		return substr( $name, 0, 191 );
	}

	/**
	 * Human-readable size for admin UI.
	 */
	public static function format_attachment_size( int $bytes ): string {
		if ( $bytes <= 0 ) {
			return '';
		}
		return size_format( $bytes );
	}

	/**
	 * Allowed post-accept delivery statuses from provider webhooks.
	 *
	 * @return list<string>
	 */
	public static function delivery_statuses(): array {
		return [ 'accepted', 'delivered', 'bounced', 'complained', 'deferred', 'rejected', 'retried' ];
	}

	public static function sanitize_delivery_status( string $status ): string {
		$status = sanitize_key( $status );
		return in_array( $status, self::delivery_statuses(), true ) ? $status : '';
	}

	/**
	 * Whether a webhook may move delivery_status from $current to $next.
	 * Uses an explicit matrix so deferred can follow accepted (soft deferrals).
	 */
	public static function can_transition_delivery_status( string $current, string $next ): bool {
		$current = self::sanitize_delivery_status( $current );
		$next    = self::sanitize_delivery_status( $next );
		if ( $next === '' ) {
			return false;
		}
		if ( $current === '' || $current === $next ) {
			return true;
		}

		// Terminal / ops statuses never move backward to softer states.
		$allowed = [
			'accepted'   => [ 'deferred', 'delivered', 'bounced', 'complained', 'rejected', 'retried' ],
			'deferred'   => [ 'accepted', 'delivered', 'bounced', 'complained', 'rejected', 'retried' ],
			'delivered'  => [ 'bounced', 'complained', 'rejected' ],
			'bounced'    => [ 'complained' ],
			'complained' => [],
			'rejected'   => [],
			'retried'    => [ 'accepted', 'deferred', 'delivered', 'bounced', 'complained', 'rejected' ],
		];

		return in_array( $next, $allowed[ $current ] ?? [], true );
	}

	/**
	 * Update a log row matched by provider message id (webhook path).
	 * Provider slug is required so one provider cannot update another's rows.
	 */
	public static function update_delivery_status( string $message_id, string $delivery_status, string $detail = '', string $provider = '' ): bool {
		$message_id      = substr( sanitize_text_field( $message_id ), 0, 191 );
		$delivery_status = self::sanitize_delivery_status( $delivery_status );
		$provider        = sanitize_key( $provider );
		if ( $message_id === '' || $delivery_status === '' || $provider === '' ) {
			return false;
		}

		global $wpdb;
		$table = self::get_table_name();

		// Prefer exact match; also try without surrounding angle brackets.
		$candidates = array_unique(
			array_filter(
				[
					$message_id,
					trim( $message_id, '<>' ),
					'<' . trim( $message_id, '<>' ) . '>',
				]
			)
		);

		$row = null;
		foreach ( $candidates as $candidate ) {
			// Prefer exact provider match.
			$sql = "SELECT id, delivery_status, provider FROM $table
				WHERE provider_message_id = %s AND provider = %s
				ORDER BY id DESC LIMIT 1";
			$row = $wpdb->get_row( $wpdb->prepare( $sql, $candidate, $provider ) );
			if ( $row ) {
				break;
			}

			// Legacy empty-provider rows: only when exactly one candidate exists for this message id.
			$count = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM $table WHERE provider_message_id = %s AND provider = ''",
					$candidate
				)
			);
			if ( $count !== 1 ) {
				continue;
			}
			$row = $wpdb->get_row(
				$wpdb->prepare(
					"SELECT id, delivery_status, provider FROM $table
					WHERE provider_message_id = %s AND provider = ''
					ORDER BY id DESC LIMIT 1",
					$candidate
				)
			);
			if ( $row ) {
				break;
			}
		}

		if ( ! $row ) {
			/**
			 * Fires when a webhook event could not be matched to a log row.
			 *
			 * @param string $message_id
			 * @param string $delivery_status
			 * @param string $provider
			 */
			do_action( 'wp_powersuite_email_logs_webhook_unmatched', $message_id, $delivery_status, $provider );
			return false;
		}

		$current = (string) ( $row->delivery_status ?? '' );
		if ( ! self::can_transition_delivery_status( $current, $delivery_status ) ) {
			return false;
		}

		$update = [
			'delivery_status' => $delivery_status,
		];
		$format = [ '%s' ];

		if ( (string) ( $row->provider ?? '' ) === '' ) {
			$update['provider'] = $provider;
			$format[]           = '%s';
		}

		// Bounce/complaint details are useful; keep short and sanitized.
		if ( $detail !== '' && in_array( $delivery_status, [ 'bounced', 'complained', 'rejected' ], true ) ) {
			$update['error_message'] = substr( sanitize_textarea_field( $detail ), 0, 2000 );
			$format[]                = '%s';
		}

		$updated = $wpdb->update( $table, $update, [ 'id' => (int) $row->id ], $format, [ '%d' ] );
		return false !== $updated;
	}

	/**
	 * Mark the current mail attempt as sent.
	 */
	public static function mark_sent(): void {
		$log_id = array_pop( self::$pending_log_ids );
		if ( ! $log_id ) {
			return;
		}

		global $wpdb;
		$table = self::get_table_name();
		$wpdb->update(
			$table,
			[
				'status'          => 'sent',
				'delivery_status' => 'accepted',
			],
			[ 'id' => $log_id ],
			[ '%s', '%s' ],
			[ '%d' ]
		);
	}

	/**
	 * Mark the current mail attempt as failed.
	 */
	public static function mark_failed( \WP_Error $error ): void {
		$log_id = array_pop( self::$pending_log_ids );
		if ( ! $log_id ) {
			return;
		}

		global $wpdb;
		$table = self::get_table_name();
		$wpdb->update(
			$table,
			[
				'status'        => 'failed',
				'error_message' => substr( sanitize_textarea_field( $error->get_error_message() ), 0, 2000 ),
			],
			[ 'id' => $log_id ],
			[ '%s', '%s' ],
			[ '%d' ]
		);
	}

	/**
	 * Parse wp_mail-style recipients into validated email addresses.
	 *
	 * @param mixed $to String, list, or associative array from wp_mail().
	 * @return list<string>
	 */
	public static function parse_recipient_addresses( $to ): array {
		return MailHeaders::parseAddresses( $to );
	}

	/**
	 * Extract a single email address from a plain or "Name <email>" string.
	 */
	private static function extract_email_address( string $address ): string {
		return MailHeaders::extractAddress( $address );
	}

	/**
	 * @param object $log Log row from the database.
	 * @return list<string>
	 */
	private static function resolve_log_recipients( object $log ): array {
		$recipients = self::parse_recipient_addresses( $log->to_email ?? '' );
		if ( $recipients !== [] ) {
			return $recipients;
		}

		if ( ! empty( $log->headers ) ) {
			return self::parse_recipients_from_headers( (string) $log->headers );
		}

		return [];
	}

	/**
	 * @return list<string>
	 */
	private static function parse_recipients_from_headers( string $headers ): array {
		$emails = [];
		$lines  = preg_split( '/\r\n|\r|\n/', $headers ) ?: [];

		foreach ( $lines as $line ) {
			$line = trim( (string) $line );
			if ( $line === '' || ! str_contains( $line, ':' ) ) {
				continue;
			}

			[ $name, $value ] = array_pad( explode( ':', $line, 2 ), 2, '' );
			if ( strtolower( trim( $name ) ) !== 'to' ) {
				continue;
			}

			$emails = array_merge( $emails, self::parse_recipient_addresses( trim( $value ) ) );
		}

		return array_values( array_unique( $emails ) );
	}

	public static function can_resend_log( object $log ): bool {
		if ( ! self::log_has_resendable_body( $log ) ) {
			return false;
		}
		// Attachment file paths are not stored — body-only resend would be misleading.
		if ( self::log_has_attachments( $log ) ) {
			return false;
		}

		return self::resolve_log_recipients( $log ) !== [];
	}

	/**
	 * Whether the log row recorded one or more attachments (metadata only).
	 */
	public static function log_has_attachments( object $log ): bool {
		if ( isset( $log->has_attachments ) ) {
			return (int) $log->has_attachments === 1;
		}
		if ( ! isset( $log->attachments ) ) {
			return false;
		}
		$meta = self::decode_attachments_meta( (string) $log->attachments );
		return $meta !== [];
	}

	/**
	 * Whether a log row has a stored body that can be resent.
	 * Accepts full rows (message) or list rows (has_body flag from get_logs()).
	 */
	public static function log_has_resendable_body( object $log ): bool {
		if ( isset( $log->has_body ) ) {
			return (int) $log->has_body === 1;
		}

		$message = self::log_message( $log );
		if ( $message === '' || self::is_content_disabled_message( $message ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Whether a stored body should offer HTML preview.
	 */
	public static function message_is_html( string $message, string $headers = '' ): bool {
		if ( $message === '' || self::is_content_disabled_message( $message ) ) {
			return false;
		}

		$type = strtolower( (string) ( self::headers_for_display( $headers )['content_type'] ?? '' ) );
		if ( str_contains( $type, 'text/html' ) ) {
			return true;
		}

		return (bool) preg_match( '/<(?:html|body|table|div|p|br|a|img|span|h[1-6]|!DOCTYPE)\b/i', $message );
	}

	/**
	 * Whether a stored message is the "body logging off" placeholder (any locale / legacy).
	 */
	public static function is_content_disabled_message( string $message ): bool {
		if ( $message === '' ) {
			return false;
		}
		if ( str_starts_with( $message, self::CONTENT_DISABLED_MARKER ) ) {
			return true;
		}
		if ( $message === self::CONTENT_DISABLED_SENTINEL ) {
			return true;
		}

		// Legacy rows stored a translated placeholder before the sentinel was fixed.
		$legacy = __( '(Content logging disabled)', 'wppowersuite-lite' );
		return $legacy !== self::CONTENT_DISABLED_SENTINEL && $message === $legacy;
	}

	/**
	 * Read message body without warning when the property was not selected.
	 */
	private static function log_message( object $log ): string {
		if ( ! isset( $log->message ) || ! is_string( $log->message ) ) {
			return '';
		}

		return $log->message;
	}

	/**
	 * @param mixed $to wp_mail "to" argument.
	 */
	private static function normalize_recipients( $to ): string {
		$emails = self::parse_recipient_addresses( $to );
		if ( $emails === [] ) {
			return '';
		}

		// Fit within varchar(500) without slicing mid-address (which corrupts the last
		// recipient and silently drops every address after the cut).
		$joined = '';
		foreach ( $emails as $email ) {
			$candidate = $joined === '' ? $email : $joined . ', ' . $email;
			if ( strlen( $candidate ) > 500 ) {
				break;
			}
			$joined = $candidate;
		}

		return $joined !== '' ? $joined : substr( $emails[0], 0, 500 );
	}

	/**
	 * Mask secrets, one-time login/reset URLs, and auth codes (store time and before display/resend).
	 *
	 * Digit patterns stay contextual so invoices and order numbers are not blanked.
	 * Codes found in first-party HTML blocks are also wiped from hidden/translated preheaders.
	 */
	public static function mask_sensitive_text( string $text ): string {
		if ( $text === '' ) {
			return $text;
		}

		$codes = [];

		$text = (string) preg_replace_callback(
			'/(<p\b[^>]{0,400}\bwpps-email-code\b[^>]{0,400}>)\s*(\d{6,10})(\s*<\/p>)/i',
			static function ( array $m ) use ( &$codes ): string {
				$codes[] = $m[2];
				return $m[1] . '[REDACTED CODE]' . $m[3];
			},
			$text
		);

		$text = (string) preg_replace_callback(
			'/(<(?:strong|span)[^>]{0,400}(?:font-family:[^;"\']{0,80}monospace|letter-spacing\s*:\s*[1-9][0-9.]{0,8}px)[^>]{0,200}>)\s*(\d{6,10})(\s*<\/(?:strong|span)>)/i',
			static function ( array $m ) use ( &$codes ): string {
				$codes[] = $m[2];
				return $m[1] . '[REDACTED CODE]' . $m[3];
			},
			$text
		);

		$text = (string) preg_replace(
			'/((?:sign[-\s]?in|verification|one-time|login|security)\s+code\s+is\s+)(?:<[^>]{1,80}>\s*)?\d{6,10}/i',
			'$1[REDACTED CODE]',
			$text
		);

		$patterns = [
			// WordPress password-reset / lost-password / Magic Login code-page query strings.
			'/https?:\/\/[^\s<>"\']*[?&]action=(?:rp|resetpass|lostpassword|wpps_ml_code)[^\s<>"\']*/i' => '[REDACTED URL]',
			'/https?:\/\/[^\s<>"\']+[?&](?:key|token|otp|code|reset_key|login_token|magic_login|wpps_magic)=[^\s<>"\'&]+/i' => '[REDACTED URL]',
			// Assignment-style and JSON-style credential values.
			'/(?:password|passwd|pwd|secret|api[_-]?key)[\s:=]+["\']?[^\s"\']+/i' => '[REDACTED]',
			'/"((?:password|passwd|pwd|secret|api[_-]?key|access_token|refresh_token))"\s*:\s*"[^"]*"/i' => '"$1":"[REDACTED]"',
			// Sensitive header names.
			'/^(Authorization|Proxy-Authorization|X-Api-Key|Api-Key|X-Auth-Token|X-Access-Token)\s*:\s*.+$/im' => '$1: [REDACTED]',
			'/\bBearer\s+[A-Za-z0-9\-\._~\+\/]+=*/i' => 'Bearer [REDACTED]',
			'/\bBasic\s+[A-Za-z0-9\+\/=]+/i' => 'Basic [REDACTED]',
		];

		foreach ( $patterns as $pattern => $replacement ) {
			$text = (string) preg_replace( $pattern, $replacement, $text );
		}

		foreach ( array_unique( $codes ) as $code ) {
			if ( $code === '' ) {
				continue;
			}
			$text = (string) preg_replace(
				'/(?<!\d)' . preg_quote( $code, '/' ) . '(?!\d)/',
				'[REDACTED CODE]',
				$text
			);
		}

		return apply_filters( 'wp_powersuite_email_log_mask_text', $text );
	}

	/**
	 * Remask a stored body for UI / AJAX. Leaves the content-disabled sentinel alone.
	 */
	public static function message_for_display( string $message ): string {
		if ( $message === '' || self::is_content_disabled_message( $message ) ) {
			return $message;
		}

		return self::mask_sensitive_text( $message );
	}

	/**
	 * Whether stored content still looks like it contains one-time login/reset material.
	 */
	public static function contains_sensitive_replay_material( string $text ): bool {
		if ( $text === '' ) {
			return false;
		}

		return (bool) preg_match(
			'/(?:[?&]action=(?:rp|resetpass|lostpassword|wpps_ml_code)|[?&](?:key|token|otp|code|cs|reset_key|login_token|magic_login|wpps_magic)=|wpps-email-code[^>]{0,400}>\s*\d{6,10}|(?:sign[-\s]?in|verification|one-time|login|security)\s+code\s+is\s+(?:<[^>]{1,80}>\s*)?\d{6,10}|<(?:strong|span)[^>]{0,400}(?:font-family:[^;"\']{0,80}monospace|letter-spacing\s*:\s*[1-9])[^>]{0,200}>\s*\d{6,10})/i',
			$text
		);
	}

	/**
	 * Human-readable label for a provider delivery_status value.
	 */
	public static function label_for_delivery_status( string $status ): string {
		$status = sanitize_key( $status );
		if ( $status === '' ) {
			return '';
		}

		return match ( $status ) {
			'complained' => __( 'Spam complaint', 'wppowersuite-lite' ),
			'accepted'   => __( 'Accepted', 'wppowersuite-lite' ),
			'delivered'  => __( 'Delivered', 'wppowersuite-lite' ),
			'bounced'    => __( 'Bounced', 'wppowersuite-lite' ),
			'deferred'   => __( 'Deferred', 'wppowersuite-lite' ),
			'rejected'   => __( 'Rejected', 'wppowersuite-lite' ),
			'retried'    => __( 'Retried', 'wppowersuite-lite' ),
			default      => ucfirst( str_replace( '_', ' ', $status ) ),
		};
	}

	public static function get_log_by_id( int $id ): ?object {
		if ( $id < 1 ) {
			return null;
		}

		global $wpdb;
		$table = self::get_table_name();
		$row   = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM $table WHERE id = %d", $id ) );
		if ( ! $row ) {
			return null;
		}

		if ( isset( $row->message ) && is_string( $row->message ) ) {
			$row->message = self::message_for_display( $row->message );
		}
		if ( isset( $row->headers ) && is_string( $row->headers ) ) {
			$row->headers = self::mask_sensitive_text( $row->headers );
		}

		return $row;
	}

	/**
	 * Resend a logged email using stored recipients, subject, body, and headers.
	 *
	 * @return true|\WP_Error
	 */
	public static function resend_log( int $id ) {
		$log = self::get_log_by_id( $id );
		if ( ! $log ) {
			return new \WP_Error( 'not_found', __( 'Log entry not found.', 'wppowersuite-lite' ) );
		}

		$message = self::log_message( $log );
		if ( $message === '' || self::is_content_disabled_message( $message ) ) {
			return new \WP_Error(
				'no_content',
				__( 'This log has no stored message body. Enable “Store email body content in logs” and send a new test email to resend from logs.', 'wppowersuite-lite' )
			);
		}

		// Remask at send time so legacy rows (pre-hardening) cannot replay reset/magic links.
		$message = self::mask_sensitive_text( $message );
		if ( self::contains_sensitive_replay_material( $message ) ) {
			return new \WP_Error(
				'sensitive_content',
				__( 'This log still appears to contain a password-reset or magic-login link and cannot be resent.', 'wppowersuite-lite' )
			);
		}

		$recipients = self::resolve_log_recipients( $log );
		if ( $recipients === [] ) {
			return new \WP_Error( 'invalid_recipient', __( 'No valid recipient address is stored for this log.', 'wppowersuite-lite' ) );
		}

		$headers = MailHeaders::normalizeLines( self::mask_sensitive_text( (string) ( $log->headers ?? '' ) ) );
		$headers = self::ensure_resend_content_type( $headers, $message );
		$headers = MailHeaders::applyIdentityHeaders( $headers );

		$sent = wp_mail( $recipients, (string) $log->subject, $message, $headers );

		if ( ! $sent ) {
			return new \WP_Error( 'send_failed', __( 'Failed to resend email.', 'wppowersuite-lite' ) );
		}

		return true;
	}

	/**
	 * Ensure HTML bodies keep a Content-Type on resend when the original send relied on
	 * the wp_mail_content_type filter (not present in stored headers).
	 *
	 * @param list<string> $headers
	 * @return list<string>
	 */
	private static function ensure_resend_content_type( array $headers, string $message ): array {
		foreach ( $headers as $header ) {
			if ( stripos( (string) $header, 'content-type:' ) === 0 ) {
				return $headers;
			}
		}

		// Prefer explicit HTML markup; avoid flipping plain-text that happens to contain "<".
		if ( preg_match( '/<(?:html|body|table|div|p|br|a|img|span|h[1-6])\b/i', $message ) ) {
			$headers[] = 'Content-Type: text/html; charset=UTF-8';
		}

		return $headers;
	}

	/**
	 * Get logs with optional search and pagination.
	 *
	 * @param array{search?:string,status?:string,provider?:string,limit?:int,offset?:int,orderby?:string,order?:string} $args
	 * @return array{items:array,total:int}
	 */
	public static function get_logs( array $args = [] ): array {
		self::ensure_schema();
		global $wpdb;
		$table = self::get_table_name();

		$search   = isset( $args['search'] ) ? trim( sanitize_text_field( $args['search'] ) ) : '';
		$status   = isset( $args['status'] ) ? sanitize_key( $args['status'] ) : '';
		$provider = isset( $args['provider'] ) ? sanitize_key( $args['provider'] ) : '';
		$limit    = isset( $args['limit'] ) ? max( 1, min( self::MAX_LOG_COUNT, (int) $args['limit'] ) ) : 50;
		$offset   = isset( $args['offset'] ) ? max( 0, (int) $args['offset'] ) : 0;
		$orderby  = isset( $args['orderby'] ) ? sanitize_key( (string) $args['orderby'] ) : 'id';
		$order    = isset( $args['order'] ) ? strtoupper( (string) $args['order'] ) : 'DESC';
		$allowed_orderby = [ 'id', 'subject', 'to_email', 'from_email', 'status', 'provider', 'sent_date', 'delivery_status' ];
		if ( ! in_array( $orderby, $allowed_orderby, true ) ) {
			$orderby = 'id';
		}
		$order = $order === 'ASC' ? 'ASC' : 'DESC';

		$where  = [ '1=1' ];
		$values = [];

		if ( $search !== '' ) {
			$like     = '%' . $wpdb->esc_like( $search ) . '%';
			$has_from = self::table_has_column( 'from_email' );
			if ( ! empty( self::get_settings()['log_content'] ) ) {
				if ( $has_from ) {
					$where[]  = '(subject LIKE %s OR to_email LIKE %s OR from_email LIKE %s OR message LIKE %s OR provider_message_id LIKE %s OR headers LIKE %s)';
					array_push( $values, $like, $like, $like, $like, $like, $like );
				} else {
					$where[]  = '(subject LIKE %s OR to_email LIKE %s OR message LIKE %s OR provider_message_id LIKE %s OR headers LIKE %s)';
					array_push( $values, $like, $like, $like, $like, $like );
				}
			} elseif ( $has_from ) {
				$where[] = '(subject LIKE %s OR to_email LIKE %s OR from_email LIKE %s OR provider_message_id LIKE %s OR headers LIKE %s)';
				array_push( $values, $like, $like, $like, $like, $like );
			} else {
				$where[] = '(subject LIKE %s OR to_email LIKE %s OR provider_message_id LIKE %s OR headers LIKE %s)';
				array_push( $values, $like, $like, $like, $like );
			}
		}

		$valid_statuses = [ 'sent', 'failed', 'pending' ];
		if ( in_array( $status, $valid_statuses, true ) ) {
			$where[]  = 'status = %s';
			$values[] = $status;
		} elseif ( in_array( $status, self::delivery_statuses(), true ) ) {
			$where[]  = 'delivery_status = %s';
			$values[] = $status;
		}

		if ( $provider !== '' ) {
			$where[]  = 'provider = %s';
			$values[] = $provider;
		}

		$where_sql = implode( ' AND ', $where );

		$total = (int) $wpdb->get_var(
			$values ? $wpdb->prepare( "SELECT COUNT(*) FROM $table WHERE $where_sql", $values ) : "SELECT COUNT(*) FROM $table WHERE $where_sql"
		);

		$values[] = $limit;
		$values[] = $offset;
		// List/export skip longtext body; has_body supports Resend without loading message into PHP.
		$disabled_labels = array_values(
			array_unique(
				array_filter(
					[
						self::CONTENT_DISABLED_SENTINEL,
						self::CONTENT_DISABLED_MARKER . self::CONTENT_DISABLED_SENTINEL,
						__( '(Content logging disabled)', 'wppowersuite-lite' ),
					]
				)
			)
		);
		$disabled_placeholders = implode( ', ', array_fill( 0, count( $disabled_labels ), '%s' ) );
		$from_select = self::table_has_column( 'from_email' ) ? 'from_email,' : "'' AS from_email,";
		$att_case    = self::table_has_column( 'attachments' )
			? "CASE WHEN attachments IS NULL OR attachments = '' OR attachments = '[]' THEN 0 ELSE 1 END AS has_attachments"
			: '0 AS has_attachments';
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $orderby/$order are allowlisted; placeholders are generated.
		$items = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, to_email, $from_select subject, headers, status, error_message, sent_date, provider, provider_message_id, duration_ms, trigger_source, retry_count, fallback_used, delivery_status, transport, connection_id, envelope_sender, routing_reason,
					CASE
						WHEN message = '' OR message IN ($disabled_placeholders) OR message LIKE %s THEN 0
						ELSE 1
					END AS has_body,
					$att_case
				FROM $table WHERE $where_sql ORDER BY $orderby $order LIMIT %d OFFSET %d",
				array_merge( $disabled_labels, [ self::CONTENT_DISABLED_MARKER . '%' ], $values )
			),
			OBJECT
		);

		return [ 'items' => $items ?: [], 'total' => $total ];
	}

	/**
	 * Export logs as CSV (formula-injection safe).
	 *
	 * @return string CSV contents.
	 */
	public static function export_csv(): string {
		$result = self::get_logs( [ 'limit' => self::MAX_LOG_COUNT, 'offset' => 0 ] );
		$lines  = [
			implode(
				',',
				array_map(
					static fn( string $h ) => self::csv_cell( $h ),
					[ 'ID', 'To', 'From', 'Subject', 'Status', 'Delivery Status', 'Provider', 'Transport', 'Connection', 'Envelope', 'Routing', 'Retries', 'Fallback', 'Date', 'Error' ]
				)
			),
		];

		foreach ( $result['items'] as $log ) {
			$from = (string) ( $log->from_email ?? '' );
			if ( $from === '' && ! empty( $log->headers ) ) {
				$from = self::extract_from_email( (string) $log->headers );
			}
			$lines[] = implode(
				',',
				[
					self::csv_cell( (string) $log->id ),
					self::csv_cell( (string) $log->to_email ),
					self::csv_cell( $from ),
					self::csv_cell( (string) $log->subject ),
					self::csv_cell( (string) $log->status ),
					self::csv_cell( (string) ( $log->delivery_status ?? '' ) ),
					self::csv_cell( (string) ( $log->provider ?? '' ) ),
					self::csv_cell( (string) ( $log->transport ?? '' ) ),
					self::csv_cell( (string) ( $log->connection_id ?? '' ) ),
					self::csv_cell( (string) ( $log->envelope_sender ?? '' ) ),
					self::csv_cell( (string) ( $log->routing_reason ?? '' ) ),
					self::csv_cell( (string) (int) ( $log->retry_count ?? 0 ) ),
					self::csv_cell( ! empty( $log->fallback_used ) ? '1' : '0' ),
					self::csv_cell( (string) $log->sent_date ),
					self::csv_cell( (string) ( $log->error_message ?? '' ) ),
				]
			);
		}

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

	private static function csv_cell( string $value ): string {
		$value = str_replace( [ "\r", "\n" ], ' ', $value );
		if ( preg_match( '/^[=+\-@]/', $value ) ) {
			$value = "'" . $value;
		}
		return '"' . str_replace( '"', '""', $value ) . '"';
	}

	public static function delete_log( int $id ): bool {
		global $wpdb;
		$table = self::get_table_name();
		return (bool) $wpdb->delete( $table, [ 'id' => $id ], [ '%d' ] );
	}

	/**
	 * @param list<int> $ids
	 */
	public static function delete_logs( array $ids ): int {
		$ids = array_values( array_filter( array_map( 'absint', $ids ) ) );
		if ( $ids === [] ) {
			return 0;
		}

		global $wpdb;
		$table       = self::get_table_name();
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
		$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM $table WHERE id IN ($placeholders)", $ids ) );
		return max( 0, (int) $deleted );
	}

	public static function delete_all_logs(): int {
		global $wpdb;
		$table = self::get_table_name();
		$result = $wpdb->query( "TRUNCATE TABLE $table" );
		if ( $result === false ) {
			return (int) $wpdb->query( "DELETE FROM $table" );
		}
		return (int) $result;
	}

	/**
	 * Count logs by send status for list-table views.
	 *
	 * @return array{all:int,sent:int,failed:int,pending:int,delivered:int,bounced:int,complained:int,accepted:int,deferred:int}
	 */
	public static function count_by_status(): array {
		global $wpdb;
		$table = self::get_table_name();
		$out   = [
			'all'        => 0,
			'sent'       => 0,
			'failed'     => 0,
			'pending'    => 0,
			'delivered'  => 0,
			'bounced'    => 0,
			'complained' => 0,
			'accepted'   => 0,
			'deferred'   => 0,
		];

		$rows = $wpdb->get_results( "SELECT status, COUNT(*) AS c FROM $table GROUP BY status", OBJECT_K );
		if ( is_array( $rows ) ) {
			foreach ( $rows as $status => $row ) {
				$count       = (int) ( $row->c ?? 0 );
				$out['all'] += $count;
				$key         = sanitize_key( (string) $status );
				if ( isset( $out[ $key ] ) ) {
					$out[ $key ] = $count;
				}
			}
		}

		$delivery_rows = $wpdb->get_results( "SELECT delivery_status, COUNT(*) AS c FROM $table WHERE delivery_status != '' GROUP BY delivery_status", OBJECT_K );
		if ( is_array( $delivery_rows ) ) {
			foreach ( $delivery_rows as $status => $row ) {
				$key = sanitize_key( (string) $status );
				if ( isset( $out[ $key ] ) && in_array( $key, self::delivery_statuses(), true ) ) {
					$out[ $key ] = (int) ( $row->c ?? 0 );
				}
			}
		}

		return $out;
	}

	/**
	 * Daily sent/failed series for Email Delivery overview chart.
	 *
	 * @return array{
	 *   start:string,
	 *   end:string,
	 *   labels:list<string>,
	 *   sent:list<int>,
	 *   failed:list<int>,
	 *   totals:array{sent:int,failed:int}
	 * }
	 */
	public static function get_daily_status_series( string $start_date = '', string $end_date = '' ): array {
		self::ensure_schema();

		$tz    = wp_timezone();
		$today = new \DateTimeImmutable( 'today', $tz );
		$end   = $end_date !== ''
			? \DateTimeImmutable::createFromFormat( '!Y-m-d', $end_date, $tz )
			: $today;
		$start = $start_date !== ''
			? \DateTimeImmutable::createFromFormat( '!Y-m-d', $start_date, $tz )
			: $today->modify( '-29 days' );

		if ( ! $end instanceof \DateTimeImmutable ) {
			$end = $today;
		} else {
			$end = $end->setTime( 0, 0, 0 );
		}
		if ( ! $start instanceof \DateTimeImmutable ) {
			$start = $end->modify( '-29 days' );
		} else {
			$start = $start->setTime( 0, 0, 0 );
		}
		if ( $end > $today ) {
			$end = $today;
		}
		if ( $start > $end ) {
			[ $start, $end ] = [ $end, $start ];
		}

		// Cap to 90 days for query cost / chart density.
		$max_start = $end->modify( '-89 days' );
		if ( $start < $max_start ) {
			$start = $max_start;
		}

		$labels = [];
		$sent   = [];
		$failed = [];
		$cursor = $start;
		while ( $cursor <= $end ) {
			$key            = $cursor->format( 'Y-m-d' );
			$labels[]       = $key;
			$sent[ $key ]   = 0;
			$failed[ $key ] = 0;
			$cursor         = $cursor->modify( '+1 day' );
		}

		global $wpdb;
		$table = self::get_table_name();
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		$exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
		if ( $exists === $table && $labels !== [] ) {
			$start_sql = $start->format( 'Y-m-d 00:00:00' );
			$end_sql   = $end->modify( '+1 day' )->format( 'Y-m-d 00:00:00' );
			$has_transport = self::table_has_column( 'transport' );
			$sql           = $has_transport
				? "SELECT DATE(sent_date) AS day_key, status, transport, COUNT(*) AS c
					FROM $table
					WHERE sent_date >= %s AND sent_date < %s
					GROUP BY DATE(sent_date), status, transport"
				: "SELECT DATE(sent_date) AS day_key, status, COUNT(*) AS c
					FROM $table
					WHERE sent_date >= %s AND sent_date < %s
					GROUP BY DATE(sent_date), status";
			$rows = $wpdb->get_results(
				$wpdb->prepare( $sql, $start_sql, $end_sql ),
				ARRAY_A
			);
			if ( is_array( $rows ) ) {
				foreach ( $rows as $row ) {
					$day       = (string) ( $row['day_key'] ?? '' );
					$status    = sanitize_key( (string) ( $row['status'] ?? '' ) );
					$transport = sanitize_key( (string) ( $row['transport'] ?? '' ) );
					$count     = (int) ( $row['c'] ?? 0 );
					if ( ! isset( $sent[ $day ] ) ) {
						continue;
					}
					// Simulation is logged as sent but must not inflate delivery charts.
					if ( $transport === 'simulation' ) {
						continue;
					}
					if ( $status === 'sent' ) {
						$sent[ $day ] += $count;
					} elseif ( $status === 'failed' ) {
						$failed[ $day ] += $count;
					}
				}
			}
		}

		$sent_series   = [];
		$failed_series = [];
		foreach ( $labels as $day ) {
			$sent_series[]   = (int) ( $sent[ $day ] ?? 0 );
			$failed_series[] = (int) ( $failed[ $day ] ?? 0 );
		}

		return [
			'start'  => $start->format( 'Y-m-d' ),
			'end'    => $end->format( 'Y-m-d' ),
			'labels' => $labels,
			'sent'   => $sent_series,
			'failed' => $failed_series,
			'totals' => [
				'sent'   => array_sum( $sent_series ),
				'failed' => array_sum( $failed_series ),
			],
		];
	}

	/**
	 * Recent log rows for Email Delivery overview widget.
	 *
	 * @return list<object>
	 */
	public static function get_recent_logs( int $limit = 6 ): array {
		$result = self::get_logs(
			[
				'limit'   => max( 1, min( 20, $limit ) ),
				'offset'  => 0,
				'orderby' => 'sent_date',
				'order'   => 'DESC',
			]
		);

		return is_array( $result['items'] ?? null ) ? $result['items'] : [];
	}

	/**
	 * Hero / overview counters.
	 *
	 * @return array{total:int,sent:int,failed:int,bounced:int,delivered:int,today:int}
	 */
	public static function get_dashboard_stats(): array {
		global $wpdb;
		$table  = self::get_table_name();
		$counts = self::count_by_status();
		$today  = 0;
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table ) {
			$today_start = ( new \DateTimeImmutable( 'today', wp_timezone() ) )->format( 'Y-m-d 00:00:00' );
			$today       = (int) $wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM $table WHERE sent_date >= %s",
					$today_start
				)
			);
		}

		return [
			'total'     => (int) ( $counts['all'] ?? 0 ),
			'sent'      => (int) ( $counts['sent'] ?? 0 ),
			'failed'    => (int) ( $counts['failed'] ?? 0 ),
			'bounced'   => (int) ( $counts['bounced'] ?? 0 ),
			'delivered' => (int) ( $counts['delivered'] ?? 0 ),
			'today'     => $today,
		];
	}

	/**
	 * Distinct provider slugs present in the log table.
	 *
	 * @return list<string>
	 */
	public static function distinct_providers(): array {
		global $wpdb;
		$table = self::get_table_name();
		if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) !== $table ) {
			return [];
		}
		$rows = $wpdb->get_col( "SELECT DISTINCT provider FROM $table WHERE provider != '' ORDER BY provider ASC" );
		if ( ! is_array( $rows ) ) {
			return [];
		}
		$out = [];
		foreach ( $rows as $provider ) {
			$provider = sanitize_key( (string) $provider );
			if ( $provider !== '' ) {
				$out[] = $provider;
			}
		}
		return $out;
	}

	public static function get_option_key(): string {
		return self::OPTION_KEY;
	}

	/**
	 * Register WordPress privacy exporter/eraser hooks.
	 */
	public static function register_privacy_hooks(): void {
		add_filter( 'wp_privacy_personal_data_exporters', [ self::class, 'register_exporter' ] );
		add_filter( 'wp_privacy_personal_data_erasers', [ self::class, 'register_eraser' ] );
	}

	/**
	 * @param array<string, array<string, mixed>> $exporters
	 * @return array<string, array<string, mixed>>
	 */
	public static function register_exporter( array $exporters ): array {
		$exporters['wp-powersuite-email-logs'] = [
			'exporter_friendly_name' => self::privacyToolLabel(),
			'callback'               => [ self::class, 'export_personal_data' ],
		];
		return $exporters;
	}

	/**
	 * @param array<string, array<string, mixed>> $erasers
	 * @return array<string, array<string, mixed>>
	 */
	public static function register_eraser( array $erasers ): array {
		$erasers['wp-powersuite-email-logs'] = [
			'eraser_friendly_name' => self::privacyToolLabel(),
			'callback'             => [ self::class, 'erase_personal_data' ],
		];
		return $erasers;
	}

	/**
	 * GDPR tools label using white-label product name when available.
	 */
	private static function privacyToolLabel(): string {
		$brand = function_exists( 'wp_powersuite_get_plugin_name' )
			? wp_powersuite_get_plugin_name()
			: __( 'WP PowerSuite', 'wppowersuite-lite' );

		return sprintf(
			/* translators: %s: plugin / white-label product name */
			__( '%s Email Logs', 'wppowersuite-lite' ),
			$brand
		);
	}

	/**
	 * @param string $email_address
	 * @param int    $page
	 * @return array<string, mixed>
	 */
	public static function export_personal_data( string $email_address, int $page ): array {
		global $wpdb;
		$table    = self::get_table_name();
		$per_page = 50;
		$offset   = ( max( 1, $page ) - 1 ) * $per_page;
		$built    = self::build_personal_data_query(
			"SELECT id, to_email, subject, status, sent_date FROM $table",
			$email_address,
			$per_page,
			$offset,
			true
		);

		$rows = $wpdb->get_results( $built ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		$export_items = [];
		foreach ( $rows ?: [] as $row ) {
			$export_items[] = [
				'group_id'    => 'wp-powersuite-email-logs',
				'group_label' => __( 'Email Logs', 'wppowersuite-lite' ),
				'item_id'     => 'email-log-' . $row->id,
				'data'        => [
					[
						'name'  => __( 'Recipient', 'wppowersuite-lite' ),
						'value' => $row->to_email,
					],
					[
						'name'  => __( 'Subject', 'wppowersuite-lite' ),
						'value' => $row->subject,
					],
					[
						'name'  => __( 'Status', 'wppowersuite-lite' ),
						'value' => $row->status,
					],
					[
						'name'  => __( 'Date', 'wppowersuite-lite' ),
						'value' => $row->sent_date,
					],
				],
			];
		}

		return [
			'data' => $export_items,
			'done' => count( $export_items ) < $per_page,
		];
	}

	/**
	 * @param string $email_address
	 * @param int    $page
	 * @return array<string, mixed>
	 */
	public static function erase_personal_data( string $email_address, int $page ): array {
		global $wpdb;
		$table = self::get_table_name();
		$built = self::build_personal_data_query(
			"SELECT id FROM $table",
			$email_address,
			50,
			0,
			false
		);

		$ids = $wpdb->get_col( $built ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared

		$removed = 0;
		foreach ( $ids ?: [] as $id ) {
			if ( self::delete_log( (int) $id ) ) {
				++$removed;
			}
		}

		return [
			'items_removed'  => $removed > 0,
			'items_retained' => false,
			'messages'       => [],
			'done'           => count( $ids ?: [] ) < 50,
		];
	}

	/**
	 * Build a prepared SELECT for privacy export/erase matching requester email broadly.
	 */
	private static function build_personal_data_query(
		string $select_sql,
		string $email_address,
		int $limit,
		int $offset,
		bool $with_offset
	): string {
		global $wpdb;

		$email = sanitize_email( $email_address );
		$like  = '%' . $wpdb->esc_like( $email !== '' ? $email : $email_address ) . '%';
		// Prefer exact/address-token matches on structured columns; LIKE only for body/headers.
		$parts = [
			'to_email = %s',
			'to_email LIKE %s',
			'headers LIKE %s',
			'message LIKE %s',
		];
		$args  = [
			$email !== '' ? $email : $email_address,
			$like,
			$like,
			$like,
		];

		if ( self::table_has_column( 'from_email' ) ) {
			$parts[] = 'from_email = %s';
			$args[]  = $email !== '' ? $email : $email_address;
			$parts[] = 'from_email LIKE %s';
			$args[]  = $like;
		}
		if ( self::table_has_column( 'envelope_sender' ) ) {
			$parts[] = 'envelope_sender = %s';
			$args[]  = $email !== '' ? $email : $email_address;
		}

		$sql = $select_sql . ' WHERE (' . implode( ' OR ', $parts ) . ')';
		if ( $with_offset ) {
			$sql   .= ' ORDER BY id DESC LIMIT %d OFFSET %d';
			$args[] = $limit;
			$args[] = $offset;
		} else {
			$sql   .= ' LIMIT %d';
			$args[] = $limit;
		}

		return (string) $wpdb->prepare( $sql, ...$args );
	}
}
