<?php
/**
 * Abandoned Cart Recovery module.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Marketing;

// Prevent direct access.
defined( 'ABSPATH' ) || exit;

/**
 * Abandoned Cart Recovery — GDPR-first.
 *
 * KEY DESIGN DECISIONS (non-negotiable):
 * - Logged-in customers ONLY. Guest checkout is never tracked — no email is on
 *   file until they complete an order (at which point the cart is recovered).
 * - No JS field-blur / keyup / session-sniffing. Capture is 100% server-side
 *   via the `woocommerce_cart_updated` hook that only fires for authenticated users.
 * - Consent basis per row: existing customer (≥1 prior order) = legitimate_interest;
 *   new account (0 prior orders) = soft_optin.
 * - Opt-out: My Account preference toggle (_kramar_no_cart_recovery user meta)
 *   AND HMAC-signed unsubscribe link in every recovery email.
 * - WP Privacy API: exporter + eraser.
 * - Retention: daily job purges rows older than configurable days (default 30).
 *
 * @since 1.0.0
 */
final class Abandoned_Cart extends \Kramar\Module {

	/** DB table name without prefix. */
	const TABLE_BASENAME = 'kramar_abandoned_carts';

	/** User meta key for opt-out. */
	const OPT_OUT_META = '_kramar_no_cart_recovery';

	/** HMAC secret option key. */
	const HMAC_OPTION = 'kramar_acr_hmac_secret';

	/** AS hook: mark carts abandoned + schedule sends. */
	const AS_ABANDON_CHECK_HOOK = 'kramar_acr_abandon_check';

	/** AS hook: send one recovery email for a cart row. */
	const AS_SEND_HOOK = 'kramar_acr_send_email';

	/** WP Cron hook: daily retention purge. */
	const RETENTION_HOOK = 'kramar_acr_retention';

	// -----------------------------------------------------------------
	// Module interface
	// -----------------------------------------------------------------

	/**
	 * {@inheritDoc}
	 */
	public function get_slug(): string {
		return 'abandoned-cart';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_category(): string {
		return 'marketing';
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return __( 'Abandoned Cart Recovery', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_description(): string {
		return __( 'Send automated recovery emails when logged-in customers leave items in their cart. GDPR-first: tracks logged-in users only (never guests), server-side capture (no field-blur JS), consent basis per customer, one-click unsubscribe, and automatic data retention.', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_default_settings(): array {
		return [
			'enabled'         => false,
			'abandon_minutes' => 60,
			'delays'          => [ 60, 1440, 4320 ], // 1h, 24h, 72h in minutes.
			'max_emails'      => 3,
			'retention_days'  => 30,
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function sanitize_settings( array $input ): array {
		$abandon = absint( $input['abandon_minutes'] ?? 60 );
		if ( $abandon < 5 ) {
			$abandon = 60;
		}

		$max = absint( $input['max_emails'] ?? 3 );
		if ( $max < 1 || $max > 3 ) {
			$max = 3;
		}

		$retention = absint( $input['retention_days'] ?? 30 );
		if ( $retention < 1 ) {
			$retention = 30;
		}

		// Delays: parse from textarea/hidden field if present, else keep default.
		$raw_delays = $input['delays'] ?? [ 60, 1440, 4320 ];
		if ( is_string( $raw_delays ) ) {
			$raw_delays = array_map( 'intval', explode( ',', $raw_delays ) );
		}
		$delays = [];
		foreach ( (array) $raw_delays as $d ) {
			$d = absint( $d );
			if ( $d >= 5 ) {
				$delays[] = $d;
			}
		}
		if ( empty( $delays ) ) {
			$delays = [ 60, 1440, 4320 ];
		}
		// Cap to max_emails count.
		$delays = array_slice( $delays, 0, $max );

		return [
			'enabled'         => ! empty( $input['enabled'] ),
			'abandon_minutes' => $abandon,
			'delays'          => $delays,
			'max_emails'      => $max,
			'retention_days'  => $retention,
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function render_extra_settings( string $option_name ): void {
		$slug    = $this->get_slug();
		$cat     = $this->get_category();
		$abandon = (int) $this->get_setting( 'abandon_minutes' );
		$max     = (int) $this->get_setting( 'max_emails' );
		$days    = (int) $this->get_setting( 'retention_days' );
		$delays  = (array) $this->get_setting( 'delays' );
		?>
		<div class="kramar-module__extra">
			<div style="padding:12px 14px;margin-bottom:12px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:4px;font-size:13px;line-height:1.6;color:#1e3a5f;">
				<?php esc_html_e( 'Recovers abandoned carts for logged-in customers only (GDPR-first — guests are never tracked). When a customer leaves items in their cart and does not check out within the cutoff below, they receive the editable “Abandoned Cart Recovery” email (in the Emails tab) on the schedule you set. Sending stops automatically once they complete an order or unsubscribe.', 'kramar' ); ?>
			</div>
			<div class="kramar-settings-stack">
				<p>
					<label>
						<?php esc_html_e( 'Abandon cutoff (minutes)', 'kramar' ); ?><br>
						<input
							type="number"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][abandon_minutes]"
							value="<?php echo esc_attr( (string) $abandon ); ?>"
							min="5"
							step="1"
							class="small-text"
						>
					</label>
					<span class="description"><?php esc_html_e( 'Minutes of cart inactivity before a cart is considered abandoned.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<?php esc_html_e( 'Recovery email delays (minutes, comma-separated)', 'kramar' ); ?><br>
						<input
							type="text"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][delays]"
							value="<?php echo esc_attr( implode( ',', $delays ) ); ?>"
							class="regular-text"
						>
					</label>
					<span class="description">
						<?php
						esc_html_e( 'Comma-separated list of delays (in minutes) from the moment a cart is marked abandoned. E.g. "60,1440,4320" sends at 1 h, 24 h, 72 h.', 'kramar' );
						?>
					</span>
				</p>
				<p>
					<label>
						<?php esc_html_e( 'Maximum recovery emails per cart', 'kramar' ); ?><br>
						<input
							type="number"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][max_emails]"
							value="<?php echo esc_attr( (string) $max ); ?>"
							min="1"
							max="3"
							step="1"
							class="small-text"
						>
					</label>
					<span class="description"><?php esc_html_e( 'Maximum 3 recovery emails per cart (GDPR proportionality).', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<?php esc_html_e( 'Data retention (days)', 'kramar' ); ?><br>
						<input
							type="number"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][retention_days]"
							value="<?php echo esc_attr( (string) $days ); ?>"
							min="1"
							step="1"
							class="small-text"
						>
					</label>
					<span class="description"><?php esc_html_e( 'Recovered and unsubscribed records older than this many days are automatically deleted.', 'kramar' ); ?></span>
				</p>
			</div>
		</div>
		<?php
	}

	// -----------------------------------------------------------------
	// Table install
	// -----------------------------------------------------------------

	/**
	 * Create (or upgrade) the abandoned-carts table.
	 *
	 * @since 1.0.0
	 */
	public static function install(): void {
		global $wpdb;

		$table   = $wpdb->prefix . self::TABLE_BASENAME;
		$charset = $wpdb->get_charset_collate();

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		dbDelta(
			"CREATE TABLE {$table} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				user_id bigint(20) unsigned NOT NULL,
				email varchar(200) NOT NULL,
				cart_contents longtext NOT NULL,
				cart_total varchar(20) NOT NULL DEFAULT '',
				currency varchar(10) NOT NULL DEFAULT '',
				created_at datetime NOT NULL,
				updated_at datetime NOT NULL,
				abandoned_at datetime DEFAULT NULL,
				recovered_at datetime DEFAULT NULL,
				consent_basis varchar(30) NOT NULL DEFAULT 'soft_optin',
				emails_sent tinyint(3) unsigned NOT NULL DEFAULT 0,
				unsubscribed tinyint(1) NOT NULL DEFAULT 0,
				PRIMARY KEY (id),
				UNIQUE KEY user_id (user_id),
				KEY abandoned_at (abandoned_at),
				KEY recovered_at (recovered_at)
			) {$charset};"
		);

		// Ensure HMAC secret exists.
		if ( ! get_option( self::HMAC_OPTION ) ) {
			add_option( self::HMAC_OPTION, bin2hex( random_bytes( 32 ) ), '', false );
		}

		// Seed the editable default email template for the Email Manager builder.
		self::maybe_seed_email_blocks();
	}

	/**
	 * Seed the default Email Manager block layout for the abandoned-cart email so
	 * it shows an editable default in the Emails tab. Idempotent.
	 *
	 * @since 1.0.0
	 */
	public static function maybe_seed_email_blocks(): void {
		$overrides = get_option( 'kramar_email_overrides', [] );
		if ( ! empty( $overrides['kramar_abandoned_cart']['blocks'] ) ) {
			return;
		}
		$overrides['kramar_abandoned_cart']['blocks'] = self::default_email_blocks();
		update_option( 'kramar_email_overrides', $overrides );
	}

	/**
	 * Default block layout for the abandoned-cart email.
	 *
	 * @since 1.0.0
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function default_email_blocks(): array {
		return [
			[ 'type' => 'heading', 'id' => 'acr_heading', 'settings' => [ 'text' => '{email_heading}', 'level' => 'h2', 'align' => 'left', 'color' => '' ] ],
			[ 'type' => 'text', 'id' => 'acr_intro', 'settings' => [ 'content' => __( 'Hi there! You left some items in your cart at {site_title}. They are still available — pick up right where you left off.', 'kramar' ), 'color' => '', 'bg' => '' ] ],
			[ 'type' => 'items-table', 'id' => 'acr_items', 'settings' => [] ],
			[ 'type' => 'button', 'id' => 'acr_cta', 'settings' => [ 'text' => __( 'Complete your purchase', 'kramar' ), 'url' => '{checkout_url}', 'color' => '#ffffff', 'bg' => '', 'align' => 'center', 'radius' => '4' ] ],
			[ 'type' => 'divider', 'id' => 'acr_divider', 'settings' => [ 'color' => '#e5e5e5', 'width' => '100%', 'margin' => '8' ] ],
			[
				'type'     => 'text',
				'id'       => 'acr_unsub',
				'settings' => [
					'content' => sprintf(
						/* translators: %s: unsubscribe link */
						__( 'You are receiving this because you left items in your cart. <a href="%s">Unsubscribe from cart reminders</a>', 'kramar' ),
						'{unsubscribe_url}'
					),
					'color' => '#a0aec0',
					'bg'    => '',
					'size'  => 'small',
				],
			],
		];
	}

	// -----------------------------------------------------------------
	// run()
	// -----------------------------------------------------------------

	/**
	 * {@inheritDoc}
	 */
	protected function run(): void {
		// Register WC email class (lazy-required inside filter).
		add_filter( 'woocommerce_email_classes', [ $this, 'register_email_classes' ] );

		// Ensure the editable email template is seeded (covers sites activated
		// before this template existed).
		self::maybe_seed_email_blocks();

		// Server-side cart capture (logged-in only — core GDPR guarantee).
		add_action( 'woocommerce_cart_updated', [ $this, 'on_cart_updated' ] );

		// Order placed → mark recovered (stop further sends).
		add_action( 'woocommerce_checkout_order_processed', [ $this, 'on_order_placed' ], 10, 3 );
		add_action( 'woocommerce_thankyou', [ $this, 'on_thankyou' ] );

		// Action Scheduler callbacks.
		add_action( self::AS_ABANDON_CHECK_HOOK, [ $this, 'as_abandon_check' ] );
		add_action( self::AS_SEND_HOOK, [ $this, 'as_send_recovery_email' ], 10, 2 );

		// Schedule periodic abandon-check if not already queued. Deferred to
		// init so Action Scheduler's data store is initialized first (it boots
		// on init:1); calling as_*() earlier triggers a "called incorrectly"
		// notice.
		add_action( 'init', [ $this, 'maybe_schedule_abandon_check' ], 20 );

		// Daily retention.
		if ( ! wp_next_scheduled( self::RETENTION_HOOK ) ) {
			wp_schedule_event( time(), 'daily', self::RETENTION_HOOK );
		}
		add_action( self::RETENTION_HOOK, [ $this, 'run_retention' ] );

		// Unsubscribe endpoint (HMAC link in emails).
		add_action( 'init', [ $this, 'handle_unsubscribe' ] );

		// My Account opt-out preference.
		add_action( 'woocommerce_account_dashboard', [ $this, 'render_optout_toggle' ] );
		add_action( 'init', [ $this, 'handle_optout_save' ] );
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_account_assets' ] );

		// WP Privacy API.
		add_filter( 'wp_privacy_personal_data_exporters', [ $this, 'register_exporter' ] );
		add_filter( 'wp_privacy_personal_data_erasers', [ $this, 'register_eraser' ] );
	}

	// -----------------------------------------------------------------
	// WC email registration
	// -----------------------------------------------------------------

	/**
	 * Register the Abandoned Cart Recovery WC email class.
	 *
	 * Lazy-required here because \WC_Email is only available after WC loads.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, \WC_Email> $emails Existing email classes.
	 * @return array<string, \WC_Email> Modified email classes.
	 */
	public function register_email_classes( array $emails ): array {
		require_once KRAMAR_DIR . 'modules/marketing/abandoned-cart/class-email-abandoned-cart.php';
		$emails['Kramar_Email_Abandoned_Cart'] = new \Kramar\Modules\Marketing\Email_Abandoned_Cart();
		return $emails;
	}

	// -----------------------------------------------------------------
	// Cart capture (server-side, logged-in only)
	// -----------------------------------------------------------------

	/**
	 * Upsert the current user's cart when WC fires `woocommerce_cart_updated`.
	 *
	 * GDPR GUARANTEE: We immediately bail if the user is not logged in.
	 * No email field-blur, no JS capture, no session sniffing.
	 *
	 * @since 1.0.0
	 */
	public function on_cart_updated(): void {
		// GDPR: guests are never tracked.
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id = get_current_user_id();
		if ( $user_id <= 0 ) {
			return;
		}

		// Opt-out check.
		if ( $this->is_user_opted_out( $user_id ) ) {
			return;
		}

		$cart = WC()->cart;
		if ( ! $cart || $cart->is_empty() ) {
			// Empty cart — remove any existing row (nothing to recover).
			$this->delete_cart_row( $user_id );
			return;
		}

		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return;
		}

		$email        = $user->user_email;
		// Store the NUMERIC grand total ('edit' context) — get_cart_total() returns
		// the HTML-formatted price (~135 chars) which overflows the varchar(20)
		// column and makes every insert silently fail. The recovery email formats
		// this back into a localized price via wc_price().
		$cart_total   = (string) $cart->get_total( 'edit' );
		$currency     = get_woocommerce_currency();
		$cart_contents = wp_json_encode( $this->serialize_cart( $cart ) );
		if ( false === $cart_contents ) {
			$cart_contents = '[]';
		}

		// Determine consent basis.
		$consent_basis = $this->determine_consent_basis( $user_id );

		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;
		$now   = current_time( 'mysql', true );

		// Check existing row.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$existing_id = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE user_id = %d",
				$user_id
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $existing_id ) {
			// Update cart; reset abandoned_at (they're active again).
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table.
			$result = $wpdb->update(
				$table,
				[
					'email'         => $email,
					'cart_contents' => $cart_contents,
					'cart_total'    => $cart_total,
					'currency'      => $currency,
					'updated_at'    => $now,
					'abandoned_at'  => null,
					'recovered_at'  => null,
					'consent_basis' => $consent_basis,
				],
				[ 'id' => (int) $existing_id ],
				[ '%s', '%s', '%s', '%s', '%s', null, null, '%s' ],
				[ '%d' ]
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		} else {
			// Insert new row.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- write to plugin's own table.
			$result = $wpdb->insert(
				$table,
				[
					'user_id'       => $user_id,
					'email'         => $email,
					'cart_contents' => $cart_contents,
					'cart_total'    => $cart_total,
					'currency'      => $currency,
					'created_at'    => $now,
					'updated_at'    => $now,
					'consent_basis' => $consent_basis,
					'emails_sent'   => 0,
					'unsubscribed'  => 0,
				],
				[ '%d', '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%d', '%d' ]
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery
		}

		// A false return means the write was rejected (e.g. column overflow); log
		// it (only when WP_DEBUG is on, so production error logs stay quiet) so the
		// failure is not silent as it was before.
		if ( false === $result && '' !== (string) $wpdb->last_error && defined( 'WP_DEBUG' ) && WP_DEBUG ) {
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
			error_log( 'Kramar Abandoned Cart: cart capture write failed for user ' . $user_id . ' — ' . $wpdb->last_error );
		}
	}

	// -----------------------------------------------------------------
	// Order placed → recovered
	// -----------------------------------------------------------------

	/**
	 * Mark the cart as recovered when an order is processed at checkout.
	 *
	 * @since 1.0.0
	 *
	 * @param int            $order_id   Order ID.
	 * @param array<string, mixed> $posted_data Posted checkout data (unused).
	 * @param \WC_Order      $order      Order object.
	 */
	public function on_order_placed( int $order_id, array $posted_data, \WC_Order $order ): void {
		$user_id = (int) $order->get_customer_id();
		if ( $user_id <= 0 ) {
			return;
		}
		$this->mark_recovered( $user_id );
	}

	/**
	 * Fallback: mark recovered on thank-you page (covers guest-upgraded or direct orders).
	 *
	 * @since 1.0.0
	 *
	 * @param int $order_id Order ID.
	 */
	public function on_thankyou( int $order_id ): void {
		$order = wc_get_order( $order_id );
		if ( ! $order ) {
			return;
		}
		$user_id = (int) $order->get_customer_id();
		if ( $user_id <= 0 ) {
			return;
		}
		$this->mark_recovered( $user_id );
	}

	/**
	 * Mark a user's cart row as recovered (stops any further sends).
	 *
	 * @since 1.0.0
	 *
	 * @param int $user_id WordPress user ID.
	 */
	private function mark_recovered( int $user_id ): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;
		$now   = current_time( 'mysql', true );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table.
		$wpdb->update(
			$table,
			[ 'recovered_at' => $now ],
			[
				'user_id'      => $user_id,
				'recovered_at' => null,
			],
			[ '%s' ],
			[ '%d', null ]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	// -----------------------------------------------------------------
	// Action Scheduler: abandon check
	// -----------------------------------------------------------------

	/**
	 * Schedule the abandon check (hourly via AS if available, else WP-Cron).
	 *
	 * @since 1.0.0
	 */
	public function maybe_schedule_abandon_check(): void {
		if ( function_exists( 'as_has_scheduled_action' ) ) {
			if ( ! as_has_scheduled_action( self::AS_ABANDON_CHECK_HOOK, [], 'kramar-acr' ) ) {
				as_schedule_recurring_action(
					time() + 300,
					300, // every 5 minutes.
					self::AS_ABANDON_CHECK_HOOK,
					[],
					'kramar-acr'
				);
			}
		} else {
			if ( ! wp_next_scheduled( self::AS_ABANDON_CHECK_HOOK ) ) {
				wp_schedule_event( time() + 300, 'hourly', self::AS_ABANDON_CHECK_HOOK );
			}
		}
	}

	/**
	 * Scan for carts that have been idle past the abandon cutoff and mark them
	 * abandoned, then schedule the first recovery email send.
	 *
	 * @since 1.0.0
	 */
	public function as_abandon_check(): void {
		global $wpdb;

		$table          = $wpdb->prefix . self::TABLE_BASENAME;
		$abandon_minutes = (int) $this->get_setting( 'abandon_minutes' );
		$cutoff         = gmdate( 'Y-m-d H:i:s', time() - ( $abandon_minutes * MINUTE_IN_SECONDS ) );

		// Find carts not yet abandoned, not recovered, not unsubscribed, idle past cutoff.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT id, user_id, email FROM {$table}
				 WHERE abandoned_at IS NULL
				   AND recovered_at IS NULL
				   AND unsubscribed = 0
				   AND updated_at < %s",
				$cutoff
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $rows ) || ! is_array( $rows ) ) {
			return;
		}

		$now    = current_time( 'mysql', true );
		$delays = (array) $this->get_setting( 'delays' );

		foreach ( $rows as $row ) {
			$row_id  = (int) $row['id'];
			$user_id = (int) $row['user_id'];

			// Double-check opt-out (user may have opted out since cart was captured).
			if ( $this->is_user_opted_out( $user_id ) ) {
				$this->delete_cart_row_by_id( $row_id );
				continue;
			}

			// Mark abandoned.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table.
			$wpdb->update(
				$table,
				[ 'abandoned_at' => $now ],
				[ 'id' => $row_id ],
				[ '%s' ],
				[ '%d' ]
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

			// Schedule first recovery email (email index 0 = first delay).
			if ( ! empty( $delays ) ) {
				$first_delay_seconds = (int) $delays[0] * MINUTE_IN_SECONDS;
				if ( function_exists( 'as_schedule_single_action' ) ) {
					as_schedule_single_action(
						time() + $first_delay_seconds,
						self::AS_SEND_HOOK,
						[
							'cart_id'    => $row_id,
							'email_index' => 0,
						],
						'kramar-acr'
					);
				}
			}
		}
	}

	// -----------------------------------------------------------------
	// Action Scheduler: send recovery email
	// -----------------------------------------------------------------

	/**
	 * AS callback — send one recovery email for a cart row.
	 *
	 * @since 1.0.0
	 *
	 * @param int $cart_id     Row ID in kramar_abandoned_carts.
	 * @param int $email_index Which delay index (0 = first email, 1 = second, etc.).
	 */
	public function as_send_recovery_email( int $cart_id, int $email_index ): void {
		global $wpdb;

		$table   = $wpdb->prefix . self::TABLE_BASENAME;
		$max     = (int) $this->get_setting( 'max_emails' );
		$delays  = (array) $this->get_setting( 'delays' );

		// Bail if over max.
		if ( $email_index >= $max ) {
			return;
		}

		// Fetch row.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$row = $wpdb->get_row(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE id = %d",
				$cart_id
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( ! $row ) {
			return;
		}

		// Stop if recovered or unsubscribed.
		if ( ! empty( $row['recovered_at'] ) || ! empty( $row['unsubscribed'] ) ) {
			return;
		}

		// Stop if abandoned_at is not set (cart was updated after scheduling).
		if ( empty( $row['abandoned_at'] ) ) {
			return;
		}

		// Don't send the same reminder twice. Action Scheduler's own claim
		// locking normally prevents a duplicate dispatch, but a timed-out or
		// retried action — or an admin re-running a pending action from the
		// Scheduled Actions screen — would otherwise send this cart's reminder
		// again and push emails_sent past the module's documented maximum of
		// three per cart. Back_In_Stock and Review_Reminder both guard their
		// own callbacks this way; this one did not.
		if ( (int) $row['emails_sent'] > $email_index ) {
			return;
		}

		$user_id = (int) $row['user_id'];

		// Opt-out check at send time.
		if ( $this->is_user_opted_out( $user_id ) ) {
			$this->delete_cart_row_by_id( $cart_id );
			return;
		}

		$email_address = (string) $row['email'];
		if ( ! is_email( $email_address ) ) {
			return;
		}

		// Build unsubscribe URL.
		$unsub_url = $this->get_unsubscribe_url( $user_id, $email_address );

		// Send via WC email.
		/** @var \Kramar\Modules\Marketing\Email_Abandoned_Cart|null $email_obj */
		$email_obj = WC()->mailer()->emails['Kramar_Email_Abandoned_Cart'] ?? null;
		if ( $email_obj ) {
			$cart_data = [
				'cart_contents' => (string) $row['cart_contents'],
				'cart_total'    => (string) $row['cart_total'],
				'currency'      => (string) $row['currency'],
			];
			$email_obj->trigger( $email_address, $cart_data, $unsub_url );
		}

		// Increment emails_sent.
		$new_count = (int) $row['emails_sent'] + 1;
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table.
		$wpdb->update(
			$table,
			[ 'emails_sent' => $new_count ],
			[ 'id' => $cart_id ],
			[ '%d' ],
			[ '%d' ]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching

		// Schedule next email if within limits.
		$next_index = $email_index + 1;
		if ( $next_index < $max && isset( $delays[ $next_index ] ) ) {
			// Delay for next email is relative to abandoned_at.
			$abandoned_ts       = strtotime( (string) $row['abandoned_at'] );
			$next_delay_seconds = (int) $delays[ $next_index ] * MINUTE_IN_SECONDS;
			$next_send_at       = ( $abandoned_ts ? $abandoned_ts : time() ) + $next_delay_seconds;

			// Don't schedule in the past.
			if ( $next_send_at <= time() ) {
				$next_send_at = time() + 60;
			}

			if ( function_exists( 'as_schedule_single_action' ) ) {
				as_schedule_single_action(
					$next_send_at,
					self::AS_SEND_HOOK,
					[
						'cart_id'     => $cart_id,
						'email_index' => $next_index,
					],
					'kramar-acr'
				);
			}
		}
	}

	// -----------------------------------------------------------------
	// Opt-out: My Account preference
	// -----------------------------------------------------------------

	/**
	 * Enqueue inline styles on the My Account dashboard page.
	 *
	 * @since 1.0.0
	 */
	public function enqueue_account_assets(): void {
		if ( ! is_account_page() ) {
			return;
		}

		wp_register_style( 'kramar-acr-account', false, [], KRAMAR_VERSION );
		wp_enqueue_style( 'kramar-acr-account' );
		wp_add_inline_style(
			'kramar-acr-account',
			'.kramar-acr-optout{margin:1.5em 0;padding:1em;border:1px solid #ddd;border-radius:4px;background:#fafafa}
.kramar-acr-optout__label{display:flex;align-items:flex-start;gap:.5em;font-size:.95em;cursor:pointer}
.kramar-acr-optout__label input{margin-top:.2em;flex-shrink:0}
.kramar-acr-optout__desc{display:block;font-size:.85em;color:#666;margin-top:.4em}'
		);
	}

	/**
	 * Render the opt-out preference toggle on the My Account dashboard.
	 *
	 * @since 1.0.0
	 */
	public function render_optout_toggle(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$user_id   = get_current_user_id();
		$opted_out = $this->is_user_opted_out( $user_id );
		$nonce     = wp_create_nonce( 'kramar_acr_optout' );
		?>
		<div class="kramar-acr-optout">
			<form method="post">
				<input type="hidden" name="kramar_acr_optout_nonce" value="<?php echo esc_attr( $nonce ); ?>">
				<label class="kramar-acr-optout__label">
					<input
						type="checkbox"
						name="kramar_acr_no_recovery"
						value="1"
						<?php checked( $opted_out ); ?>
						onchange="this.form.submit()"
					>
					<span>
						<?php esc_html_e( 'Do not send me cart recovery emails', 'kramar' ); ?>
						<span class="kramar-acr-optout__desc">
							<?php esc_html_e( 'When checked, we will not send you email reminders if you leave items in your cart.', 'kramar' ); ?>
						</span>
					</span>
				</label>
			</form>
		</div>
		<?php
	}

	/**
	 * Handle opt-out form save on `init`.
	 *
	 * @since 1.0.0
	 */
	public function handle_optout_save(): void {
		if ( ! is_user_logged_in() ) {
			return;
		}

		$nonce = isset( $_POST['kramar_acr_optout_nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['kramar_acr_optout_nonce'] ) ) : '';
		if ( ! $nonce || ! wp_verify_nonce( $nonce, 'kramar_acr_optout' ) ) {
			return;
		}

		$user_id    = get_current_user_id();
		$no_recovery = isset( $_POST['kramar_acr_no_recovery'] ) ? (bool) absint( $_POST['kramar_acr_no_recovery'] ) : false;

		if ( $no_recovery ) {
			update_user_meta( $user_id, self::OPT_OUT_META, '1' );
			// Immediately delete any tracked cart for this user.
			$this->delete_cart_row( $user_id );
		} else {
			delete_user_meta( $user_id, self::OPT_OUT_META );
		}

		// PRG redirect to avoid resubmit.
		wp_safe_redirect( wc_get_account_endpoint_url( 'dashboard' ) );
		exit;
	}

	// -----------------------------------------------------------------
	// Unsubscribe (HMAC link in recovery emails)
	// -----------------------------------------------------------------

	/**
	 * Build an HMAC-signed unsubscribe URL for a user.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $user_id User ID.
	 * @param string $email   User email.
	 * @return string Signed URL.
	 */
	public function get_unsubscribe_url( int $user_id, string $email ): string {
		$secret  = (string) get_option( self::HMAC_OPTION, '' );
		$payload = $user_id . '|' . $email;
		$sig     = hash_hmac( 'sha256', $payload, $secret );

		return add_query_arg(
			[
				'kramar-acr-unsub' => '1',
				'uid'            => $user_id,
				'email'          => rawurlencode( $email ),
				'sig'            => $sig,
			],
			home_url( '/' )
		);
	}

	/**
	 * Handle unsubscribe request on `init`.
	 *
	 * @since 1.0.0
	 */
	public function handle_unsubscribe(): void {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( ! isset( $_GET['kramar-acr-unsub'] ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$user_id = isset( $_GET['uid'] ) ? absint( $_GET['uid'] ) : 0;
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$email   = isset( $_GET['email'] ) ? sanitize_email( wp_unslash( (string) $_GET['email'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$sig     = isset( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['sig'] ) ) : '';

		$secret   = (string) get_option( self::HMAC_OPTION, '' );
		$payload  = $user_id . '|' . $email;
		$expected = hash_hmac( 'sha256', $payload, $secret );

		if ( ! hash_equals( $expected, $sig ) || $user_id <= 0 || ! is_email( $email ) ) {
			wp_die( esc_html__( 'Invalid unsubscribe link.', 'kramar' ), '', [ 'response' => 400 ] );
		}

		// Set opt-out meta and mark row unsubscribed.
		update_user_meta( $user_id, self::OPT_OUT_META, '1' );

		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$wpdb->update(
			$table,
			[ 'unsubscribed' => 1 ],
			[ 'user_id' => $user_id ],
			[ '%d' ],
			[ '%d' ]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		wp_safe_redirect(
			add_query_arg( 'kramar-acr-unsubscribed', '1', home_url( '/' ) )
		);
		exit;
	}

	// -----------------------------------------------------------------
	// Retention
	// -----------------------------------------------------------------

	/**
	 * Delete old recovered/unsubscribed cart rows past the retention window.
	 *
	 * @since 1.0.0
	 */
	public function run_retention(): void {
		global $wpdb;

		$days   = (int) $this->get_setting( 'retention_days' );
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );
		$table  = $wpdb->prefix . self::TABLE_BASENAME;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$table}
				 WHERE (
					 recovered_at IS NOT NULL AND recovered_at < %s
				 ) OR (
					 unsubscribed = 1 AND updated_at < %s
				 )",
				$cutoff,
				$cutoff
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	// -----------------------------------------------------------------
	// WP Privacy API
	// -----------------------------------------------------------------

	/**
	 * Register our personal data exporter.
	 *
	 * @since 1.0.0
	 *
	 * @param array<int, array<string, mixed>> $exporters Existing exporters.
	 * @return array<int, array<string, mixed>> Updated exporters.
	 */
	public function register_exporter( array $exporters ): array {
		$exporters[] = [
			'exporter_friendly_name' => __( 'Kramar Abandoned Cart Recovery', 'kramar' ),
			'callback'               => [ $this, 'privacy_exporter' ],
		];
		return $exporters;
	}

	/**
	 * Export personal data for a given email.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email_address Email to export.
	 * @param int    $page          Pagination page.
	 * @return array<string, mixed> Export result.
	 */
	public function privacy_exporter( string $email_address, int $page = 1 ): array {
		global $wpdb;

		$per_page = 20;
		$offset   = ( $page - 1 ) * $per_page;
		$table    = $wpdb->prefix . self::TABLE_BASENAME;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE email = %s ORDER BY id ASC LIMIT %d OFFSET %d",
				$email_address,
				$per_page,
				$offset
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		$data = [];

		if ( is_array( $rows ) ) {
			foreach ( $rows as $row ) {
				$data[] = [
					'group_id'    => 'kramar-abandoned-cart',
					'group_label' => __( 'Abandoned Cart Recovery', 'kramar' ),
					'item_id'     => 'acr-' . (int) $row['id'],
					'data'        => [
						[
							'name'  => __( 'Email address', 'kramar' ),
							'value' => esc_html( (string) $row['email'] ),
						],
						[
							'name'  => __( 'Cart total', 'kramar' ),
							'value' => esc_html( (string) $row['cart_total'] ),
						],
						[
							'name'  => __( 'Consent basis', 'kramar' ),
							'value' => esc_html( (string) $row['consent_basis'] ),
						],
						[
							'name'  => __( 'Created at', 'kramar' ),
							'value' => esc_html( (string) $row['created_at'] ),
						],
						[
							'name'  => __( 'Abandoned at', 'kramar' ),
							'value' => esc_html( (string) ( $row['abandoned_at'] ?? '' ) ),
						],
						[
							'name'  => __( 'Recovered at', 'kramar' ),
							'value' => esc_html( (string) ( $row['recovered_at'] ?? '' ) ),
						],
						[
							'name'  => __( 'Recovery emails sent', 'kramar' ),
							'value' => esc_html( (string) $row['emails_sent'] ),
						],
						[
							'name'  => __( 'Unsubscribed', 'kramar' ),
							'value' => ! empty( $row['unsubscribed'] ) ? __( 'Yes', 'kramar' ) : __( 'No', 'kramar' ),
						],
					],
				];
			}
		}

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

	/**
	 * Register our personal data eraser.
	 *
	 * @since 1.0.0
	 *
	 * @param array<int, array<string, mixed>> $erasers Existing erasers.
	 * @return array<int, array<string, mixed>> Updated erasers.
	 */
	public function register_eraser( array $erasers ): array {
		$erasers[] = [
			'eraser_friendly_name' => __( 'Kramar Abandoned Cart Recovery', 'kramar' ),
			'callback'             => [ $this, 'privacy_eraser' ],
		];
		return $erasers;
	}

	/**
	 * Erase all personal data for a given email.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email_address Email to erase.
	 * @param int    $page          Unused.
	 * @return array<string, mixed> Eraser result.
	 */
	public function privacy_eraser( string $email_address, int $page = 1 ): array {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$deleted = $wpdb->delete(
			$table,
			[ 'email' => $email_address ],
			[ '%s' ]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		return [
			'items_removed'  => (int) $deleted > 0,
			'items_retained' => false,
			'messages'       => [],
			'done'           => true,
		];
	}

	// -----------------------------------------------------------------
	// Helpers
	// -----------------------------------------------------------------

	/**
	 * Check if a user has opted out of cart recovery.
	 *
	 * @since 1.0.0
	 *
	 * @param int $user_id User ID.
	 * @return bool True if opted out.
	 */
	private function is_user_opted_out( int $user_id ): bool {
		return '1' === (string) get_user_meta( $user_id, self::OPT_OUT_META, true );
	}

	/**
	 * Determine consent basis for a user.
	 *
	 * Existing customer (≥1 completed order) = legitimate_interest.
	 * New account (0 prior orders) = soft_optin.
	 *
	 * @since 1.0.0
	 *
	 * @param int $user_id User ID.
	 * @return string 'legitimate_interest' or 'soft_optin'.
	 */
	private function determine_consent_basis( int $user_id ): string {
		$order_count = wc_get_customer_order_count( $user_id );
		return $order_count > 0 ? 'legitimate_interest' : 'soft_optin';
	}

	/**
	 * Serialize the WC cart to a compact array for storage.
	 *
	 * @since 1.0.0
	 *
	 * @param \WC_Cart $cart WooCommerce cart object.
	 * @return array<int, array<string, mixed>> Compact cart items array.
	 */
	private function serialize_cart( \WC_Cart $cart ): array {
		$items = [];

		foreach ( $cart->get_cart() as $item ) {
			$product = $item['data'] ?? null;
			if ( ! ( $product instanceof \WC_Product ) ) {
				continue;
			}

			$items[] = [
				'product_id'   => (int) ( $item['product_id'] ?? 0 ),
				'variation_id' => (int) ( $item['variation_id'] ?? 0 ),
				'quantity'     => (int) ( $item['quantity'] ?? 1 ),
				'name'         => $product->get_name(),
				'price'        => (float) $product->get_price(),
				'url'          => $product->get_permalink(),
				'image'        => wp_get_attachment_url( $product->get_image_id() ) ?: '',
			];
		}

		return $items;
	}

	/**
	 * Delete a cart row by user ID.
	 *
	 * @since 1.0.0
	 *
	 * @param int $user_id User ID.
	 */
	private function delete_cart_row( int $user_id ): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table.
		$wpdb->delete( $table, [ 'user_id' => $user_id ], [ '%d' ] );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Delete a cart row by primary key.
	 *
	 * @since 1.0.0
	 *
	 * @param int $row_id Row primary key.
	 */
	private function delete_cart_row_by_id( int $row_id ): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table.
		$wpdb->delete( $table, [ 'id' => $row_id ], [ '%d' ] );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
	}

	/**
	 * Read a setting for this module.
	 *
	 * @since 1.0.0
	 *
	 * @param string $key Setting key.
	 * @return mixed Setting value.
	 */
	private function get_setting( string $key ): mixed {
		return $this->plugin->get_setting( $this->get_category(), $this->get_slug(), $key );
	}
}
