<?php
/**
 * Back-in-Stock Notifications module.
 *
 * @package Kramar
 * @since   1.0.0
 */

declare( strict_types=1 );

namespace Kramar\Modules\Marketing;

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

/**
 * Back-in-Stock Notifications module.
 *
 * GDPR-first: mandatory consent checkbox, consent IP + timestamp stored,
 * HMAC-signed unsubscribe links in every email, configurable retention,
 * WP Privacy API (exporter + eraser), no capturing without consent.
 *
 * @since 1.0.0
 */
final class Back_In_Stock extends \Kramar\Module {

	/** @since 1.0.2 */
	private const UNSUB_PAGE_OPTION = 'kramar_bis_unsub_page_id';

	/** @since 1.0.2 */
	private const UNSUB_PAGE_LOCK = 'kramar_bis_unsub_page_lock';


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

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

	/** Action Scheduler hook for batch notify. */
	const AS_NOTIFY_HOOK = 'kramar_bis_notify_batch';

	/** Action Scheduler hook for discontinued batch. */
	const AS_DISCONTINUED_HOOK = 'kramar_bis_discontinued_batch';

	/** WP Cron hook for daily retention. */
	const RETENTION_HOOK = 'kramar_bis_retention';

	/**
	 * {@inheritDoc}
	 */
	public function get_slug(): string {
		return 'back-in-stock';
	}

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

	/**
	 * {@inheritDoc}
	 */
	public function get_name(): string {
		return __( 'Back-in-Stock Notifications', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_description(): string {
		return __( 'Notify customers when out-of-stock products become available again. GDPR-first: mandatory consent, stored consent IP & timestamp, one-click unsubscribe in every email, and automatic data retention.', 'kramar' );
	}

	/**
	 * {@inheritDoc}
	 */
	public function get_default_settings(): array {
		return [
			'enabled'         => false,
			'retention_days'  => 90,
			'pending_days'    => 180,
			'batch_size'      => 50,
			'captcha'         => true,
			'form_classes'    => '',
			'unsubscribe_page_id' => 0,
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function sanitize_settings( array $input ): array {
		$retention = absint( $input['retention_days'] ?? 90 );
		if ( $retention < 1 ) {
			$retention = 90;
		}

		$batch = absint( $input['batch_size'] ?? 50 );
		if ( $batch < 1 ) {
			$batch = 50;
		}

		$pending = absint( $input['pending_days'] ?? 180 );
		if ( $pending < 7 ) {
			$pending = 180;
		}

		return [
			'enabled'        => ! empty( $input['enabled'] ),
			'retention_days' => $retention,
			'pending_days'   => $pending,
			'batch_size'     => $batch,
			'captcha'        => ! empty( $input['captcha'] ),
			'form_classes'   => sanitize_text_field( $input['form_classes'] ?? '' ),
			'unsubscribe_page_id' => absint( $input['unsubscribe_page_id'] ?? 0 ),
		];
	}

	/**
	 * {@inheritDoc}
	 */
	public function render_extra_settings( string $option_name ): void {
		$slug      = $this->get_slug();
		$cat       = $this->get_category();
		$retention = (int) $this->get_setting( 'retention_days' );
		$pending   = (int) $this->get_setting( 'pending_days' );
		$batch     = (int) $this->get_setting( 'batch_size' );
		$captcha   = (bool) $this->get_setting( 'captcha' );
		$classes   = (string) $this->get_setting( 'form_classes' );
		$unsub_pid = (int) $this->get_setting( 'unsubscribe_page_id' );
		?>
		<div class="kramar-module__extra">
			<?php
			// Preserve the auto-created unsubscribe page ID across saves. Without a
			// field here, sanitize_settings() would reset it to 0 on every save and
			// maybe_create_unsubscribe_page() would spawn a brand-new duplicate page.
			?>
			<input
				type="hidden"
				name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][unsubscribe_page_id]"
				value="<?php echo esc_attr( (string) $unsub_pid ); ?>"
			>
			<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( 'Shows a “Notify me when available” email-capture form on out-of-stock product pages. When the product is restocked, every subscriber is emailed (in batches) using the editable “Back in Stock Notification” email in the Emails tab. Subscribers are stored privately and can one-click unsubscribe; records are auto-deleted per the retention setting below.', 'kramar' ); ?>
			</div>
			<div class="kramar-settings-stack">
				<p>
					<label>
						<input
							type="checkbox"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][captcha]"
							value="1"
							<?php checked( $captcha ); ?>
						>
						<?php esc_html_e( 'Show a math spam-check on the form', 'kramar' ); ?>
					</label>
					<span class="description"><?php esc_html_e( 'Adds a simple “what is X + Y?” question to block bots. Recommended.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<?php esc_html_e( 'Extra CSS classes', 'kramar' ); ?><br>
						<input
							type="text"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][form_classes]"
							value="<?php echo esc_attr( $classes ); ?>"
							class="regular-text"
							placeholder="e.g. my-theme-form"
						>
					</label>
					<span class="description"><?php esc_html_e( 'Added to the form wrapper. The form already uses Bootstrap 5 classes (form-control, btn btn-primary, form-check) alongside the WooCommerce classes; add your own here if your theme needs them.', '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) $retention ); ?>"
							min="1"
							step="1"
							class="small-text"
						>
					</label>
					<span class="description"><?php esc_html_e( 'Notified and unsubscribed records older than this many days are automatically deleted.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<?php esc_html_e( 'Auto-close pending requests after (days)', 'kramar' ); ?><br>
						<input
							type="number"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][pending_days]"
							value="<?php echo esc_attr( (string) $pending ); ?>"
							min="7"
							step="1"
							class="small-text"
						>
					</label>
					<span class="description"><?php esc_html_e( 'If a product never comes back in stock within this many days, the subscriber is emailed the “Back-in-Stock Request Closed” notice and their record is deleted (GDPR/AVG). Default 180.', 'kramar' ); ?></span>
				</p>
				<p>
					<label>
						<?php esc_html_e( 'Notification batch size', 'kramar' ); ?><br>
						<input
							type="number"
							name="<?php echo esc_attr( $option_name ); ?>[<?php echo esc_attr( $cat ); ?>][<?php echo esc_attr( $slug ); ?>][batch_size]"
							value="<?php echo esc_attr( (string) $batch ); ?>"
							min="1"
							step="1"
							class="small-text"
						>
					</label>
					<span class="description"><?php esc_html_e( 'Maximum emails sent per batch when a product comes back in stock (uses Action Scheduler for large lists).', 'kramar' ); ?></span>
				</p>
			</div>
		</div>
		<?php
	}

	/**
	 * Create the subscribers database 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,
				email varchar(200) NOT NULL,
				product_id bigint(20) unsigned NOT NULL,
				variation_id bigint(20) unsigned NOT NULL DEFAULT 0,
				subscribed_at datetime NOT NULL,
				consent_ip varchar(100) NOT NULL DEFAULT '',
				notified_at datetime DEFAULT NULL,
				status varchar(20) NOT NULL DEFAULT 'subscribed',
				PRIMARY KEY (id),
				UNIQUE KEY email_product (email(191), product_id, variation_id),
				KEY product_status (product_id, status),
				KEY status_notified (status, notified_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 back-in-stock 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', [] );
		$changed   = false;

		if ( empty( $overrides['kramar_back_in_stock']['blocks'] ) ) {
			$overrides['kramar_back_in_stock']['blocks'] = self::default_email_blocks();
			$changed = true;
		}

		if ( empty( $overrides['kramar_discontinued']['blocks'] ) ) {
			$overrides['kramar_discontinued']['blocks'] = self::default_discontinued_blocks();
			$changed = true;
		}

		if ( empty( $overrides['kramar_bis_confirmed']['blocks'] ) ) {
			$overrides['kramar_bis_confirmed']['blocks'] = self::default_confirmed_blocks();
			$changed = true;
		}

		if ( $changed ) {
			update_option( 'kramar_email_overrides', $overrides );
		}
	}

	/**
	 * Default block layout for the back-in-stock signup confirmation email.
	 *
	 * @since 1.0.0
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function default_confirmed_blocks(): array {
		return [
			[ 'type' => 'heading', 'id' => 'bisc_heading', 'settings' => [ 'text' => '{email_heading}', 'level' => 'h2', 'align' => 'left', 'color' => '' ] ],
			[
				'type'     => 'text',
				'id'       => 'bisc_intro',
				'settings' => [
					'content' => sprintf(
						/* translators: %s: product name merge field */
						__( 'Thanks! We will email you as soon as %s is back in stock. No action is needed from you.', 'kramar' ),
						'{product_name}'
					),
					'color' => '',
					'bg'    => '',
				],
			],
			[ 'type' => 'button', 'id' => 'bisc_cancel', 'settings' => [ 'text' => __( 'Cancel this alert', 'kramar' ), 'url' => '{unsubscribe_url}', 'color' => '#ffffff', 'bg' => '', 'align' => 'center', 'radius' => '4' ] ],
		];
	}

	/**
	 * Default block layout for the "Back-in-Stock Request Closed" email
	 * (used for both discontinued products and auto-closed pending requests).
	 *
	 * @since 1.0.0
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function default_discontinued_blocks(): array {
		return [
			[ 'type' => 'heading', 'id' => 'dis_heading', 'settings' => [ 'text' => '{email_heading}', 'level' => 'h2', 'align' => 'left', 'color' => '' ] ],
			[
				'type'     => 'text',
				'id'       => 'dis_intro',
				'settings' => [
					'content' => sprintf(
						/* translators: %s: product name merge field */
						__( 'We are no longer able to send you a back-in-stock notification for %s.', 'kramar' ),
						'{product_name}'
					),
					'color' => '',
					'bg'    => '',
				],
			],
			[ 'type' => 'text', 'id' => 'dis_gdpr', 'settings' => [ 'content' => __( 'Because of this, we have closed your request and deleted the email address you gave us, in line with the GDPR / AVG. You do not need to do anything — there is nothing left to unsubscribe from.', 'kramar' ), 'color' => '', 'bg' => '' ] ],
			[ 'type' => 'button', 'id' => 'dis_cta', 'settings' => [ 'text' => __( 'Browse our shop', 'kramar' ), 'url' => '{shop_url}', 'color' => '#ffffff', 'bg' => '', 'align' => 'center', 'radius' => '4' ] ],
		];
	}

	/**
	 * Default block layout for the back-in-stock email.
	 *
	 * @since 1.0.0
	 *
	 * @return array<int, array<string, mixed>>
	 */
	private static function default_email_blocks(): array {
		return [
			[ 'type' => 'heading', 'id' => 'bis_heading', 'settings' => [ 'text' => '{email_heading}', 'level' => 'h2', 'align' => 'left', 'color' => '' ] ],
			[ 'type' => 'text', 'id' => 'bis_intro', 'settings' => [ 'content' => __( 'Good news — an item you were waiting for is back in stock!', 'kramar' ), 'color' => '', 'bg' => '' ] ],
			[ 'type' => 'text', 'id' => 'bis_product', 'settings' => [ 'content' => __( '{product_name} is available again for {product_price}.', 'kramar' ), 'color' => '', 'bg' => '' ] ],
			[ 'type' => 'button', 'id' => 'bis_cta', 'settings' => [ 'text' => __( 'Shop now', 'kramar' ), 'url' => '{product_url}', 'color' => '#ffffff', 'bg' => '', 'align' => 'center', 'radius' => '4' ] ],
			[ 'type' => 'divider', 'id' => 'bis_divider', 'settings' => [ 'color' => '#e5e5e5', 'width' => '100%', 'margin' => '8' ] ],
			[
				'type'     => 'text',
				'id'       => 'bis_unsub',
				'settings' => [
					'content' => sprintf(
						/* translators: %s: unsubscribe link */
						__( 'You are receiving this because you signed up for back-in-stock alerts. <a href="%s">Unsubscribe</a>', 'kramar' ),
						'{unsubscribe_url}'
					),
					'color' => '#a0aec0',
					'bg'    => '',
					'size'  => 'small',
				],
			],
		];
	}

	/**
	 * {@inheritDoc}
	 */
	protected function run(): void {
		// Register the WC email class.
		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();

		// Front-end: subscribe form on out-of-stock products.
		add_action( 'woocommerce_single_product_summary', [ $this, 'render_subscribe_form' ], 31 );
		add_action( 'wp_ajax_kramar_bis_subscribe', [ $this, 'handle_subscribe' ] );
		add_action( 'wp_ajax_nopriv_kramar_bis_subscribe', [ $this, 'handle_subscribe' ] );

		// Enqueue front-end inline styles (no separate file needed — no .org violation).
		add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_frontend_assets' ] );

		// Restock detection.
		add_action( 'woocommerce_product_set_stock_status', [ $this, 'on_stock_status_change' ], 10, 3 );

		// Discontinued (trashed / deleted).
		add_action( 'wp_trash_post', [ $this, 'on_product_trashed' ] );
		add_action( 'before_delete_post', [ $this, 'on_product_trashed' ] );

		// Editable unsubscribe page + shortcode (preferred), plus a raw-link
		// fallback handler for any legacy /?kramar-bis-unsub links.
		add_action( 'admin_init', [ $this, 'maybe_create_unsubscribe_page' ] );
		add_shortcode( 'kramar_bis_unsubscribe', [ $this, 'render_unsubscribe_shortcode' ] );
		add_action( 'template_redirect', [ $this, 'handle_unsubscribe' ] );

		// Action Scheduler batch send callbacks.
		add_action( self::AS_NOTIFY_HOOK, [ $this, 'as_send_notify_batch' ], 10, 3 );
		add_action( self::AS_DISCONTINUED_HOOK, [ $this, 'as_send_discontinued_batch' ], 10, 2 );

		// Daily retention cron.
		$this->schedule_retention();
		add_action( self::RETENTION_HOOK, [ $this, 'run_retention' ] );

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

	// -------------------------------------------------------------------------
	// Email class registration
	// -------------------------------------------------------------------------

	/**
	 * Register Back-in-Stock and Discontinued WC email classes.
	 *
	 * Email class files extend \WC_Email which is only available after WooCommerce
	 * loads its autoloader. We require them here (inside the filter that fires
	 * after WC is fully loaded) rather than at plugin boot time.
	 *
	 * @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/back-in-stock/class-email-back-in-stock.php';
		require_once KRAMAR_DIR . 'modules/marketing/back-in-stock/class-email-discontinued.php';
		require_once KRAMAR_DIR . 'modules/marketing/back-in-stock/class-email-subscription-confirmed.php';

		$emails['Kramar_Email_Back_In_Stock']       = new \Kramar\Modules\Marketing\Email_Back_In_Stock();
		$emails['Kramar_Email_Discontinued']        = new \Kramar\Modules\Marketing\Email_Discontinued();
		$emails['Kramar_Email_Subscription_Confirmed'] = new \Kramar\Modules\Marketing\Email_Subscription_Confirmed();
		return $emails;
	}

	// -------------------------------------------------------------------------
	// Front-end subscribe form
	// -------------------------------------------------------------------------

	/**
	 * Enqueue the front-end stylesheet and form-handler script on product pages.
	 *
	 * @since 1.0.0
	 */
	public function enqueue_frontend_assets(): void {
		if ( ! is_product() ) {
			return;
		}

		wp_register_style( 'kramar-bis', false, [], KRAMAR_VERSION );
		wp_enqueue_style( 'kramar-bis' );
		wp_add_inline_style(
			'kramar-bis',
			'.kramar-bis-form{margin:1.5em 0;padding:1em;border:1px solid #ddd;border-radius:4px;background:#fafafa}
.kramar-bis-form__title{font-weight:600;margin:0 0 .75em}
.kramar-bis-form__row{display:flex;gap:.5em;flex-wrap:wrap;align-items:flex-end}
.kramar-bis-form__email{flex:1;min-width:180px}
.kramar-bis-form__consent{display:flex;align-items:flex-start;gap:.5em;margin:.75em 0 .5em;font-size:.9em}
.kramar-bis-form__consent input{margin-top:.2em;flex-shrink:0}
.kramar-bis-form__captcha{display:flex;align-items:center;gap:.5em;margin:.75em 0 0;font-size:.9em;flex-wrap:wrap}
.kramar-bis-form__captcha-input{max-width:80px}
.kramar-bis-message{margin-top:.5em;font-size:.9em}
.kramar-bis-message--ok{color:#007a00}
.kramar-bis-message--err{color:#c00}'
		);

		wp_enqueue_script(
			'kramar-back-in-stock',
			KRAMAR_URL . 'assets/js/back-in-stock.js',
			[],
			KRAMAR_VERSION,
			true
		);
		wp_localize_script(
			'kramar-back-in-stock',
			'kramarBackInStock',
			[
				'ajaxUrl'       => admin_url( 'admin-ajax.php' ),
				'nonce'         => wp_create_nonce( 'kramar_bis_subscribe' ),
				'i18nNoEmail'   => __( 'Please enter your email address.', 'kramar' ),
				'i18nNoConsent' => __( 'Please tick the consent checkbox to continue.', 'kramar' ),
				'i18nNoCaptcha' => __( 'Please answer the spam-check question.', 'kramar' ),
				'i18nError'     => __( 'An error occurred. Please try again.', 'kramar' ),
			]
		);
	}

	/**
	 * Render the subscribe form on an out-of-stock single product page.
	 *
	 * @since 1.0.0
	 */
	public function render_subscribe_form(): void {
		global $product;

		if ( ! ( $product instanceof \WC_Product ) || $product->is_in_stock() ) {
			return;
		}

		$variation_id = 0;
		if ( $product->is_type( 'variation' ) ) {
			$variation_id = $product->get_id();
			$product_id   = $product->get_parent_id();
		} else {
			$product_id = $product->get_id();
		}
		$extra_classes   = trim( (string) $this->get_setting( 'form_classes' ) );
		$captcha_enabled = (bool) $this->get_setting( 'captcha' );

		$captcha_a = 0;
		$captcha_b = 0;
		$captcha_token = '';
		if ( $captcha_enabled ) {
			$captcha_a     = wp_rand( 1, 9 );
			$captcha_b     = wp_rand( 1, 9 );
			$captcha_token = hash_hmac( 'sha256', (string) ( $captcha_a + $captcha_b ), (string) get_option( self::HMAC_OPTION, '' ) );
		}
		?>
		<div
			class="kramar-bis-form<?php echo '' !== $extra_classes ? ' ' . esc_attr( $extra_classes ) : ''; ?>"
			id="kramar-bis-form"
			data-product-id="<?php echo esc_attr( (string) $product_id ); ?>"
			data-variation-id="<?php echo esc_attr( (string) $variation_id ); ?>"
		>
			<p class="kramar-bis-form__title"><?php esc_html_e( 'Notify me when available', 'kramar' ); ?></p>

			<div class="kramar-bis-form__row">
				<input
					type="email"
					id="kramar-bis-email"
					class="kramar-bis-form__email input-text form-control"
					placeholder="<?php esc_attr_e( 'your@email.com', 'kramar' ); ?>"
					required
					autocomplete="email"
				>
				<button type="button" id="kramar-bis-submit" class="button alt btn btn-primary">
					<?php esc_html_e( 'Notify me', 'kramar' ); ?>
				</button>
			</div>

			<?php if ( $captcha_enabled ) : ?>
			<p class="kramar-bis-form__captcha">
				<label for="kramar-bis-captcha">
					<?php
					printf(
						/* translators: 1: first number, 2: second number in the math question */
						esc_html__( 'Spam check — what is %1$d + %2$d?', 'kramar' ),
						(int) $captcha_a,
						(int) $captcha_b
					);
					?>
				</label>
				<input
					type="text"
					id="kramar-bis-captcha"
					class="kramar-bis-form__captcha-input input-text form-control"
					inputmode="numeric"
					autocomplete="off"
					required
				>
				<input type="hidden" id="kramar-bis-captcha-token" value="<?php echo esc_attr( $captcha_token ); ?>">
			</p>
			<?php endif; ?>

			<label class="kramar-bis-form__consent form-check">
				<input
					type="checkbox"
					id="kramar-bis-consent"
					class="form-check-input"
					required
				>
				<span>
					<?php
					printf(
						/* translators: %s: shop name */
						esc_html__( 'I agree that %s may email me when this product is back in stock. I can withdraw my consent at any time via the unsubscribe link in the email.', 'kramar' ),
						esc_html( get_bloginfo( 'name' ) )
					);
					?>
				</span>
			</label>

			<div class="kramar-bis-message" id="kramar-bis-message" aria-live="polite"></div>
		</div>
		<?php
	}

	// -------------------------------------------------------------------------
	// AJAX: subscribe
	// -------------------------------------------------------------------------

	/**
	 * Handle subscribe AJAX request.
	 *
	 * @since 1.0.0
	 */
	public function handle_subscribe(): void {
		// Nonce.
		$nonce = isset( $_POST['nonce'] ) ? sanitize_text_field( wp_unslash( $_POST['nonce'] ) ) : '';
		if ( ! wp_verify_nonce( $nonce, 'kramar_bis_subscribe' ) ) {
			wp_send_json_error( [ 'message' => __( 'Security check failed.', 'kramar' ) ], 403 );
		}

		// Consent is MANDATORY — reject without it.
		$consent = isset( $_POST['consent'] ) ? sanitize_key( wp_unslash( $_POST['consent'] ) ) : '';
		if ( '1' !== $consent ) {
			wp_send_json_error( [ 'message' => __( 'Consent is required to subscribe.', 'kramar' ) ], 400 );
		}

		// Math captcha (stateless: the token is an HMAC of the correct answer).
		if ( (bool) $this->get_setting( 'captcha' ) ) {
			$captcha_answer = isset( $_POST['captcha'] ) ? sanitize_text_field( wp_unslash( $_POST['captcha'] ) ) : '';
			$captcha_token  = isset( $_POST['captcha_token'] ) ? sanitize_text_field( wp_unslash( $_POST['captcha_token'] ) ) : '';
			$expected       = hash_hmac( 'sha256', $captcha_answer, (string) get_option( self::HMAC_OPTION, '' ) );
			if ( '' === $captcha_answer || '' === $captcha_token || ! hash_equals( $expected, $captcha_token ) ) {
				wp_send_json_error( [ 'message' => __( 'Incorrect answer to the verification question. Please try again.', 'kramar' ) ], 400 );
			}
		}

		$email      = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
		$product_id = isset( $_POST['product_id'] ) ? absint( $_POST['product_id'] ) : 0;
		$variation_id = isset( $_POST['variation_id'] ) ? absint( $_POST['variation_id'] ) : 0;

		if ( ! is_email( $email ) ) {
			wp_send_json_error( [ 'message' => __( 'Please enter a valid email address.', 'kramar' ) ], 400 );
		}

		if ( $product_id <= 0 ) {
			wp_send_json_error( [ 'message' => __( 'Invalid product.', 'kramar' ) ], 400 );
		}

		// Verify product exists and is out of stock.
		$product = wc_get_product( $product_id );
		if ( ! $product ) {
			wp_send_json_error( [ 'message' => __( 'Product not found.', 'kramar' ) ], 400 );
		}

		$target = $variation_id > 0 ? wc_get_product( $variation_id ) : $product;
		if ( $target && $target->is_in_stock() ) {
			wp_send_json_error( [ 'message' => __( 'This product is already in stock.', 'kramar' ) ], 400 );
		}

		$consent_ip = \WC_Geolocation::get_ip_address();

		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
		$existing = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT id FROM {$table} WHERE email = %s AND product_id = %d AND variation_id = %d",
				$email,
				$product_id,
				$variation_id
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( $existing ) {
			// Already subscribed — silently succeed (no info leak on re-subscribe vs. first subscribe).
			wp_send_json_success( [ 'message' => __( "You're on the list! We'll notify you when this product is back in stock.", 'kramar' ) ] );
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery -- write to plugin's own table.
		$inserted = $wpdb->insert(
			$table,
			[
				'email'        => $email,
				'product_id'   => $product_id,
				'variation_id' => $variation_id,
				'subscribed_at' => current_time( 'mysql', true ),
				'consent_ip'   => $consent_ip,
				'status'       => 'subscribed',
			],
			[ '%s', '%d', '%d', '%s', '%s', '%s' ]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery

		if ( false === $inserted ) {
			wp_send_json_error( [ 'message' => __( 'Could not save your subscription. Please try again.', 'kramar' ) ], 500 );
		}

		// Send the signup confirmation email (with one-click cancel link).
		$confirm = WC()->mailer()->emails['Kramar_Email_Subscription_Confirmed'] ?? null;
		if ( $confirm && $target instanceof \WC_Product ) {
			$unsub_link = $this->get_unsubscribe_url( $email, $product_id, $variation_id );
			$confirm->trigger( $email, $target, $unsub_link );
		}

		wp_send_json_success( [ 'message' => __( "You're on the list! We've emailed you a confirmation — we'll notify you when this product is back in stock.", 'kramar' ) ] );
	}

	// -------------------------------------------------------------------------
	// Restock detection
	// -------------------------------------------------------------------------

	/**
	 * Fires when WooCommerce sets a product's stock status.
	 *
	 * @since 1.0.0
	 *
	 * @param int    $product_id Product (or variation) ID being updated.
	 * @param string $status     New stock status ('instock', 'outofstock', 'onbackorder').
	 * @param \WC_Product $product Product object.
	 */
	public function on_stock_status_change( int $product_id, string $status, \WC_Product $product ): void {
		if ( 'instock' !== $status ) {
			return;
		}

		// Resolve parent + variation.
		if ( $product->is_type( 'variation' ) ) {
			$real_product_id  = $product->get_parent_id();
			$real_variation_id = $product->get_id();
		} else {
			$real_product_id  = $product->get_id();
			$real_variation_id = 0;
		}

		$this->dispatch_notify_batch( $real_product_id, $real_variation_id );
	}

	/**
	 * Dispatch an Action Scheduler batch (or direct send for small lists).
	 *
	 * @since 1.0.0
	 *
	 * @param int $product_id   Product ID.
	 * @param int $variation_id Variation ID (0 for simple products).
	 */
	private function dispatch_notify_batch( int $product_id, int $variation_id ): void {
		if ( function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action(
				self::AS_NOTIFY_HOOK,
				[
					'product_id'   => $product_id,
					'variation_id' => $variation_id,
					'offset'       => 0,
				],
				'kramar-bis'
			);
		} else {
			// Fallback: send synchronously in the current request.
			$this->as_send_notify_batch( $product_id, $variation_id, 0 );
		}
	}

	/**
	 * Action Scheduler callback — send one batch of back-in-stock emails.
	 *
	 * @since 1.0.0
	 *
	 * @param int $product_id   Product ID.
	 * @param int $variation_id Variation ID (0 for simple products).
	 * @param int $offset       Pagination offset.
	 */
	public function as_send_notify_batch( int $product_id, int $variation_id, int $offset ): void {
		global $wpdb;

		$batch_size = (int) $this->get_setting( 'batch_size' );
		$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 id, email FROM {$table}
				 WHERE product_id = %d AND variation_id = %d AND status = 'subscribed'
				 ORDER BY id ASC
				 LIMIT %d OFFSET %d",
				$product_id,
				$variation_id,
				$batch_size,
				$offset
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

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

		$product = wc_get_product( $variation_id > 0 ? $variation_id : $product_id );

		/** @var \Kramar\Modules\Marketing\Email_Back_In_Stock|null $email_obj */
		$email_obj = WC()->mailer()->emails['Kramar_Email_Back_In_Stock'] ?? null;

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

			// Guard the product too: wc_get_product() returns false if the
			// product/variation was trashed or deleted between the restock event
			// and this async batch draining. Email_Back_In_Stock::trigger() takes a
			// non-nullable WC_Product, so an unguarded false would fatal (and kill
			// the scheduled action). Rows are still marked notified below to clear
			// the queue.
			if ( $email_obj && $product instanceof \WC_Product ) {
				$unsub_link = $this->get_unsubscribe_url( $recipient, $product_id, $variation_id );
				$email_obj->trigger( $recipient, $product, $unsub_link );
			}

			// Mark as notified.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- write to plugin table; no caching for writes.
			$wpdb->update(
				$table,
				[
					'status'      => 'notified',
					'notified_at' => current_time( 'mysql', true ),
				],
				[ 'id' => $row_id ],
				[ '%s', '%s' ],
				[ '%d' ]
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		}

		// Schedule next batch if we got a full page.
		if ( count( $rows ) === $batch_size && function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action(
				self::AS_NOTIFY_HOOK,
				[
					'product_id'   => $product_id,
					'variation_id' => $variation_id,
					'offset'       => $offset + $batch_size,
				],
				'kramar-bis'
			);
		}
	}

	// -------------------------------------------------------------------------
	// Discontinued (trashed / deleted)
	// -------------------------------------------------------------------------

	/**
	 * When a product is trashed or hard-deleted, notify subscribers.
	 *
	 * @since 1.0.0
	 *
	 * @param int $post_id Post ID.
	 */
	public function on_product_trashed( int $post_id ): void {
		if ( 'product' !== get_post_type( $post_id ) ) {
			return;
		}

		if ( function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action(
				self::AS_DISCONTINUED_HOOK,
				[
					'product_id' => $post_id,
					'offset'     => 0,
				],
				'kramar-bis'
			);
		} else {
			$this->as_send_discontinued_batch( $post_id, 0 );
		}
	}

	/**
	 * Action Scheduler callback — send one batch of discontinued emails.
	 *
	 * @since 1.0.0
	 *
	 * @param int $product_id Product ID.
	 * @param int $offset     Pagination offset.
	 */
	public function as_send_discontinued_batch( int $product_id, int $offset ): void {
		global $wpdb;

		$batch_size = (int) $this->get_setting( 'batch_size' );
		$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 id, email, variation_id FROM {$table}
				 WHERE product_id = %d AND status = 'subscribed'
				 ORDER BY id ASC
				 LIMIT %d OFFSET %d",
				$product_id,
				$batch_size,
				$offset
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

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

		$product_name = get_the_title( $product_id );
		if ( ! $product_name ) {
			$product_name = sprintf(
				/* translators: %d: product ID */
				__( 'Product #%d', 'kramar' ),
				$product_id
			);
		}

		$shop_url = wc_get_page_permalink( 'shop' );

		/** @var \Kramar\Modules\Marketing\Email_Discontinued|null $email_obj */
		$email_obj = WC()->mailer()->emails['Kramar_Email_Discontinued'] ?? null;

		$ids_to_delete = [];

		foreach ( $rows as $row ) {
			$row_id       = (int) $row['id'];
			$recipient    = (string) $row['email'];
			$variation_id = (int) $row['variation_id'];

			if ( $email_obj ) {
				$unsub_link = $this->get_unsubscribe_url( $recipient, $product_id, $variation_id );
				$email_obj->trigger( $recipient, $product_name, $shop_url, $unsub_link );
			}

			$ids_to_delete[] = $row_id;
		}

		// GDPR: the product is gone, so delete the subscriber rows after notifying.
		if ( ! empty( $ids_to_delete ) ) {
			$placeholders = implode( ',', array_fill( 0, count( $ids_to_delete ), '%d' ) );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->query(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- %d placeholders are built dynamically for a variable-length IN() list.
				$wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids_to_delete )
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		// More rows remain? Re-run from offset 0 (the processed rows were deleted).
		if ( count( $rows ) === $batch_size && function_exists( 'as_enqueue_async_action' ) ) {
			as_enqueue_async_action(
				self::AS_DISCONTINUED_HOOK,
				[
					'product_id' => $product_id,
					'offset'     => 0,
				],
				'kramar-bis'
			);
		}
	}

	// -------------------------------------------------------------------------
	// Unsubscribe
	// -------------------------------------------------------------------------

	/**
	 * Build an HMAC-signed unsubscribe URL.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email        Subscriber email.
	 * @param int    $product_id   Product ID.
	 * @param int    $variation_id Variation ID (0 for simple).
	 * @return string URL.
	 */
	public function get_unsubscribe_url( string $email, int $product_id, int $variation_id ): string {
		$secret = (string) get_option( self::HMAC_OPTION, '' );
		$sig    = hash_hmac( 'sha256', $email . '|' . $product_id . '|' . $variation_id, $secret );

		return $this->build_unsub_url(
			[
				'email' => rawurlencode( $email ),
				'pid'   => $product_id,
				'vid'   => $variation_id,
				'sig'   => $sig,
			]
		);
	}

	/**
	 * Build an HMAC-signed "unsubscribe from ALL back-in-stock alerts" URL.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email Subscriber email.
	 * @return string URL.
	 */
	public function get_unsubscribe_all_url( string $email ): string {
		$secret = (string) get_option( self::HMAC_OPTION, '' );
		$sig    = hash_hmac( 'sha256', $email . '|all', $secret );

		return $this->build_unsub_url(
			[
				'scope' => 'all',
				'email' => rawurlencode( $email ),
				'sig'   => $sig,
			]
		);
	}

	/**
	 * Build an unsubscribe URL pointing at the editable unsubscribe page when one
	 * exists; otherwise fall back to a raw home_url link handled on
	 * template_redirect.
	 *
	 * @since 1.0.0
	 *
	 * @param array<string, mixed> $args Query args (already-signed).
	 * @return string URL.
	 */
	private function build_unsub_url( array $args ): string {
		// Read the option directly (not cached plugin settings) so a freshly
		// created page is picked up immediately.
		$settings = get_option( \Kramar\Settings::OPTION_NAME, [] );
		$page_id  = (int) ( $settings['marketing']['back-in-stock']['unsubscribe_page_id'] ?? 0 );
		if ( $page_id > 0 && get_post( $page_id ) ) {
			return add_query_arg( $args, (string) get_permalink( $page_id ) );
		}

		// Raw fallback — handle_unsubscribe() keys on this flag.
		$args['kramar-bis-unsub'] = '1';
		return add_query_arg( $args, home_url( '/' ) );
	}

	/**
	 * Validate a signed unsubscribe request and mark the matching rows
	 * unsubscribed. Returns true on success.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email Subscriber email.
	 * @param int    $pid   Product ID.
	 * @param int    $vid   Variation ID.
	 * @param string $scope 'single' or 'all'.
	 * @param string $sig   HMAC signature.
	 * @return bool
	 */
	private function do_unsubscribe( string $email, int $pid, int $vid, string $scope, string $sig ): bool {
		if ( ! is_email( $email ) || '' === $sig ) {
			return false;
		}

		$secret = (string) get_option( self::HMAC_OPTION, '' );

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

		if ( 'all' === $scope ) {
			if ( ! hash_equals( hash_hmac( 'sha256', $email . '|all', $secret ), $sig ) ) {
				return false;
			}
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->update( $table, [ 'status' => 'unsubscribed' ], [ 'email' => $email ], [ '%s' ], [ '%s' ] );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			return true;
		}

		if ( $pid <= 0 || ! hash_equals( hash_hmac( 'sha256', $email . '|' . $pid . '|' . $vid, $secret ), $sig ) ) {
			return false;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
		$wpdb->update(
			$table,
			[ 'status' => 'unsubscribed' ],
			[ 'email' => $email, 'product_id' => $pid, 'variation_id' => $vid ],
			[ '%s' ],
			[ '%s', '%d', '%d' ]
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		return true;
	}

	/**
	 * Read the signed unsubscribe params from the request.
	 *
	 * @since 1.0.0
	 *
	 * @return array{email: string, pid: int, vid: int, scope: string, sig: string}
	 */
	private function read_unsub_request(): array {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- public link authenticated by HMAC signature, not a nonce.
		return [
			'email' => isset( $_GET['email'] ) ? sanitize_email( wp_unslash( (string) $_GET['email'] ) ) : '',
			'pid'   => isset( $_GET['pid'] ) ? absint( $_GET['pid'] ) : 0,
			'vid'   => isset( $_GET['vid'] ) ? absint( $_GET['vid'] ) : 0,
			'scope' => ( isset( $_GET['scope'] ) && 'all' === $_GET['scope'] ) ? 'all' : 'single',
			'sig'   => isset( $_GET['sig'] ) ? sanitize_text_field( wp_unslash( (string) $_GET['sig'] ) ) : '',
		];
		// phpcs:enable WordPress.Security.NonceVerification.Recommended
	}

	/**
	 * Build the inner confirmation HTML shared by the shortcode and the raw
	 * fallback handler.
	 *
	 * @since 1.0.0
	 *
	 * @param bool   $success Whether the unsubscribe succeeded.
	 * @param string $email   Subscriber email (for the "unsubscribe from all" CTA).
	 * @param bool   $all     Whether this was an unsubscribe-from-all action.
	 * @return string HTML.
	 */
	private function unsub_content_html( bool $success, string $email, bool $all ): string {
		ob_start();
		echo '<div class="kramar-bis-unsub" style="max-width:600px;margin:0 auto;text-align:center;">';

		if ( ! $success ) {
			echo '<p>' . esc_html__( 'This unsubscribe link is invalid or has expired. Please use the link from a recent back-in-stock email.', 'kramar' ) . '</p>';
		} elseif ( $all ) {
			echo '<p>' . esc_html__( 'Done — you will no longer receive any back-in-stock notifications from us.', 'kramar' ) . '</p>';
		} else {
			echo '<p>' . esc_html__( 'Done — you will no longer receive back-in-stock notifications for this product.', 'kramar' ) . '</p>';
			if ( '' !== $email ) {
				echo '<p style="margin-top:24px;"><a class="button" href="' . esc_url( $this->get_unsubscribe_all_url( $email ) ) . '">'
					. esc_html__( 'Unsubscribe from all back-in-stock alerts', 'kramar' )
					. '</a></p>';
			}
		}

		echo '</div>';
		return (string) ob_get_clean();
	}

	/**
	 * Shortcode `[kramar_bis_unsubscribe]` — processes the signed link on the
	 * editable unsubscribe page and renders the confirmation inline.
	 *
	 * @since 1.0.0
	 *
	 * @return string HTML.
	 */
	public function render_unsubscribe_shortcode(): string {
		$r = $this->read_unsub_request();

		if ( '' === $r['email'] && '' === $r['sig'] ) {
			return '<p>' . esc_html__( 'Use the cancel link from a back-in-stock email to manage your alerts.', 'kramar' ) . '</p>';
		}

		$ok = $this->do_unsubscribe( $r['email'], $r['pid'], $r['vid'], $r['scope'], $r['sig'] );
		return $this->unsub_content_html( $ok, $r['email'], 'all' === $r['scope'] && $ok );
	}

	/**
	 * Raw-link fallback handler (template_redirect) for legacy
	 * /?kramar-bis-unsub links when no editable page exists. Renders a themed
	 * confirmation and exits.
	 *
	 * @since 1.0.0
	 */
	public function handle_unsubscribe(): void {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- public link authenticated by HMAC signature (see read_unsub_request()).
		if ( ! isset( $_GET['kramar-bis-unsub'] ) ) {
			return;
		}
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$r  = $this->read_unsub_request();
		$ok = $this->do_unsubscribe( $r['email'], $r['pid'], $r['vid'], $r['scope'], $r['sig'] );

		get_header();
		echo '<div style="max-width:600px;margin:48px auto;padding:0 16px;">';
		echo wp_kses_post( $this->unsub_content_html( $ok, $r['email'], 'all' === $r['scope'] && $ok ) );
		echo '</div>';
		get_footer();
		exit;
	}

	/**
	 * Create the editable "Back-in-Stock Unsubscribe" page on first admin load
	 * if it doesn't exist yet. Stores its ID in settings.
	 *
	 * @since 1.0.0
	 */
	public function maybe_create_unsubscribe_page(): void {
		if ( ! is_admin() || wp_doing_ajax() || ! current_user_can( 'manage_woocommerce' ) ) {
			return;
		}

		$page_id = (int) $this->get_setting( 'unsubscribe_page_id' );
		if ( $page_id > 0 && self::page_is_usable( (int) $page_id ) ) {
			// Mirror it into the dedicated option now, while the settings copy is
			// still intact — that copy is the one another module can clobber.
			$this->remember_unsubscribe_page( $page_id );
			return;
		}

		// Third copy of the same check-then-act race, after the order-tracking and
		// withdrawal pages. The id lived only in the shared kramar_settings option,
		// which other modules rewrite wholesale; this runs on every admin page load,
		// so a stale read makes another page. This site accumulated forty of them,
		// twelve still published and showing in the primary navigation.
		$known = (int) get_option( self::UNSUB_PAGE_OPTION, 0 );
		if ( $known > 0 && self::page_is_usable( (int) $known ) ) {
			$this->remember_unsubscribe_page( $known );
			return;
		}

		$existing = get_posts(
			[
				'post_type'      => 'page',
				'post_status'    => [ 'publish', 'draft', 'pending', 'private' ],
				'posts_per_page' => 1,
				'fields'         => 'ids',
				's'              => '[kramar_bis_unsubscribe]',
			]
		);
		if ( ! empty( $existing[0] ) ) {
			$this->remember_unsubscribe_page( (int) $existing[0] );
			return;
		}

		// The lock covers a few lines, but a request that dies in between —
		// timeout, fatal, out of memory — would leave it set forever, and the
		// page could then never be created again with nothing to tell an admin
		// why. A stale lock silently disabling the feature is worse than the
		// duplicate pages it exists to prevent, so it expires.
		$lock = get_option( self::UNSUB_PAGE_LOCK, false );
		if ( false !== $lock && ( time() - (int) $lock ) > MINUTE_IN_SECONDS ) {
			delete_option( self::UNSUB_PAGE_LOCK );
			$lock = false;
		}

		if ( false !== $lock || false === add_option( self::UNSUB_PAGE_LOCK, time(), '', false ) ) {
			return;
		}

		$page_id = wp_insert_post(
			[
				'post_title'   => __( 'Back-in-Stock Unsubscribe', 'kramar' ),
				'post_content' => '<!-- wp:shortcode -->[kramar_bis_unsubscribe]<!-- /wp:shortcode -->',
				'post_status'  => 'publish',
				'post_type'    => 'page',
			]
		);

		delete_option( self::UNSUB_PAGE_LOCK );

		if ( $page_id && ! is_wp_error( $page_id ) ) {
			$this->remember_unsubscribe_page( (int) $page_id );
		}
	}

	/**
	 * Record the unsubscribe page id in both places it is read from.
	 *
	 * @since 1.0.2
	 */
	private function remember_unsubscribe_page( int $page_id ): void {
		if ( $page_id <= 0 ) {
			return;
		}

		update_option( self::UNSUB_PAGE_OPTION, $page_id, false );

		$settings = get_option( \Kramar\Settings::OPTION_NAME, [] );
		if ( ! is_array( $settings ) ) {
			$settings = [];
		}

		if ( (int) ( $settings['marketing']['back-in-stock']['unsubscribe_page_id'] ?? 0 ) === $page_id ) {
			return;
		}

		$settings['marketing']['back-in-stock']['unsubscribe_page_id'] = $page_id;
		update_option( \Kramar\Settings::OPTION_NAME, $settings );
	}

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

	/**
	 * Schedule daily retention if not already scheduled.
	 *
	 * @since 1.0.0
	 */
	private function schedule_retention(): void {
		if ( ! wp_next_scheduled( self::RETENTION_HOOK ) ) {
			wp_schedule_event( time(), 'daily', self::RETENTION_HOOK );
		}
	}

	/**
	 * Delete old notified/unsubscribed rows beyond the retention period.
	 *
	 * @since 1.0.0
	 */
	public function run_retention(): void {
		global $wpdb;

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

		// 1. Auto-close pending requests that have waited too long (emails + deletes).
		$this->process_pending_expiry();

		// 2. Purge notified + unsubscribed records past the retention window. Use
		//    COALESCE so unsubscribed rows that were never notified (notified_at
		//    is NULL) are still purged based on their subscription date.
		$days   = (int) $this->get_setting( 'retention_days' );
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( $days * DAY_IN_SECONDS ) );

		// 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 status IN ('notified','unsubscribed')
				 AND COALESCE(notified_at, subscribed_at) < %s",
				$cutoff
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
	}

	/**
	 * Auto-close pending back-in-stock requests that have waited longer than the
	 * configured "pending days". Sends the "request closed" email, then deletes
	 * the record (GDPR/AVG storage-limitation). Processes one batch per run;
	 * the daily cron drains the rest.
	 *
	 * @since 1.0.0
	 */
	private function process_pending_expiry(): void {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_BASENAME;

		$pending_days = (int) $this->get_setting( 'pending_days' );
		$batch        = (int) $this->get_setting( 'batch_size' );
		$cutoff       = gmdate( 'Y-m-d H:i:s', time() - ( $pending_days * DAY_IN_SECONDS ) );

		// 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, email, product_id, variation_id FROM {$table}
				 WHERE status = 'subscribed' AND subscribed_at < %s
				 ORDER BY id ASC
				 LIMIT %d",
				$cutoff,
				$batch
			),
			ARRAY_A
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

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

		/** @var \Kramar\Modules\Marketing\Email_Discontinued|null $email_obj */
		$email_obj = WC()->mailer()->emails['Kramar_Email_Discontinued'] ?? null;
		$shop_url  = (string) wc_get_page_permalink( 'shop' );

		$ids_to_delete = [];

		foreach ( $rows as $row ) {
			$recipient    = (string) $row['email'];
			$product_id   = (int) $row['product_id'];
			$variation_id = (int) $row['variation_id'];

			$product_name = get_the_title( $product_id );
			if ( ! $product_name ) {
				$product_name = __( 'the product you were watching', 'kramar' );
			}

			if ( $email_obj ) {
				$unsub_link = $this->get_unsubscribe_url( $recipient, $product_id, $variation_id );
				$email_obj->trigger( $recipient, $product_name, $shop_url, $unsub_link );
			}

			$ids_to_delete[] = (int) $row['id'];
		}

		// GDPR: delete the expired pending records.
		if ( ! empty( $ids_to_delete ) ) {
			$placeholders = implode( ',', array_fill( 0, count( $ids_to_delete ), '%d' ) );
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
			$wpdb->query(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- %d placeholders are built dynamically for a variable-length IN() list.
				$wpdb->prepare( "DELETE FROM {$table} WHERE id IN ({$placeholders})", $ids_to_delete )
			);
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, 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 Back-in-Stock Subscriptions', 'kramar' ),
			'callback'               => [ $this, 'privacy_exporter' ],
		];
		return $exporters;
	}

	/**
	 * Export personal data for a given email address.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email_address Email to export for.
	 * @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 ) {
				$product = wc_get_product( (int) $row['product_id'] );

				$data[] = [
					'group_id'    => 'kramar-back-in-stock',
					'group_label' => __( 'Back-in-Stock Subscriptions', 'kramar' ),
					'item_id'     => 'bis-' . (int) $row['id'],
					'data'        => [
						[
							'name'  => __( 'Email address', 'kramar' ),
							'value' => esc_html( (string) $row['email'] ),
						],
						[
							'name'  => __( 'Product', 'kramar' ),
							'value' => $product ? esc_html( $product->get_name() ) : '#' . (int) $row['product_id'],
						],
						[
							'name'  => __( 'Subscribed at', 'kramar' ),
							'value' => esc_html( (string) $row['subscribed_at'] ),
						],
						[
							'name'  => __( 'Consent IP', 'kramar' ),
							'value' => esc_html( (string) $row['consent_ip'] ),
						],
						[
							'name'  => __( 'Status', 'kramar' ),
							'value' => esc_html( (string) $row['status'] ),
						],
					],
				];
			}
		}

		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 Back-in-Stock Subscriptions', 'kramar' ),
			'callback'             => [ $this, 'privacy_eraser' ],
		];
		return $erasers;
	}

	/**
	 * Erase personal data for a given email address.
	 *
	 * @since 1.0.0
	 *
	 * @param string $email_address Email to erase.
	 * @param int    $page          Unused (we delete all at once).
	 * @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,
		];
	}

	// -------------------------------------------------------------------------
	// Helper
	// -------------------------------------------------------------------------

	/**
	 * 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 );
	}
}
