<?php
/**
 * Abandoned Cart Recovery — track and recover abandoned WooCommerce carts.
 *
 * Tracks the whole cart journey (browsing → shopping → abandoned → purchased),
 * captures visitor contact details early (checkout keystrokes + a configurable
 * phone-capture popup), and drives recovery through the notification rule
 * builder: three dedicated triggers (ac_shopping / ac_potential / ac_abandoned)
 * let admins compose multi-step reminder sequences with per-rule delays.
 * A legacy single-message fallback and auto-generated coupons are included.
 *
 * @package Mersal_Notif
 */

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

class Mersal_Notif_Abandoned_Cart {

	const TABLE       = 'mnotif_abandoned_carts';
	const QUEUE_TABLE = 'mnotif_ac_notifications';
	const CRON_HOOK   = 'mnotif_ac_process';
	const COOKIE      = 'mnotif_ac_session';
	const PHONE_COOKIE = 'mnotif_ac_phone';
	const DB_VER      = 2;

	/**
	 * @var Mersal_Notif_Core
	 */
	private $core;

	/**
	 * @param Mersal_Notif_Core $core Notification engine.
	 */
	public function __construct( $core ) {
		$this->core = $core;
	}

	/**
	 * Initialize hooks.
	 */
	public function init() {
		add_filter( 'cron_schedules', array( $this, 'cron_schedules' ) ); // phpcs:ignore WordPress.WP.CronInterval.ChangeDetected
		add_action( 'init', array( $this, 'maybe_upgrade_db' ) );

		if ( ! $this->is_enabled() || ! class_exists( 'WooCommerce' ) ) {
			return;
		}

		// Track cart activity.
		add_action( 'woocommerce_add_to_cart', array( $this, 'on_cart_update' ), 20 );
		add_action( 'woocommerce_cart_updated', array( $this, 'on_cart_update' ), 20 );
		add_action( 'woocommerce_remove_cart_item', array( $this, 'on_cart_update' ), 20 );

		// Capture billing fields while the customer types at checkout.
		if ( (int) Mersal_Notif_Settings::get( 'ac_capture_checkout', 1 ) === 1 ) {
			add_action( 'woocommerce_checkout_update_order_review', array( $this, 'capture_checkout_fields' ), 10, 1 );
		}
		// Prefill checkout fields for returning visitors.
		if ( (int) Mersal_Notif_Settings::get( 'ac_prefill_checkout', 1 ) === 1 ) {
			add_filter( 'woocommerce_checkout_get_value', array( $this, 'prefill_checkout' ), 10, 2 );
		}

		// Mark as purchased on order.
		add_action( 'woocommerce_checkout_order_processed', array( $this, 'on_order_created' ), 20, 1 );
		add_action( 'woocommerce_thankyou', array( $this, 'on_order_created' ), 20, 1 );

		// Process abandoned carts cron.
		add_action( self::CRON_HOOK, array( $this, 'process_abandoned' ) );
		add_action( 'init', array( $this, 'maybe_schedule_cron' ) );

		// Cart restoration.
		add_action( 'template_redirect', array( $this, 'handle_restore' ) );

		// Front-end: session-reclaim + page tracker + phone-capture popup.
		add_action( 'wp_footer', array( $this, 'render_frontend' ) );

		// Coupon max-discount cap.
		add_filter( 'woocommerce_coupon_get_discount_amount', array( $this, 'cap_coupon_discount' ), 10, 5 );

		// Public AJAX (visitors).
		add_action( 'wp_ajax_nopriv_mnotif_ac_save_phone', array( $this, 'ajax_save_phone' ) );
		add_action( 'wp_ajax_mnotif_ac_save_phone', array( $this, 'ajax_save_phone' ) );
		add_action( 'wp_ajax_nopriv_mnotif_ac_reclaim', array( $this, 'ajax_reclaim' ) );
		add_action( 'wp_ajax_mnotif_ac_reclaim', array( $this, 'ajax_reclaim' ) );
		add_action( 'wp_ajax_nopriv_mnotif_ac_track', array( $this, 'ajax_track' ) );
		add_action( 'wp_ajax_mnotif_ac_track', array( $this, 'ajax_track' ) );

		// Admin AJAX.
		add_action( 'wp_ajax_mnotif_ac_stats', array( $this, 'ajax_stats' ) );
		add_action( 'wp_ajax_mnotif_ac_delete', array( $this, 'ajax_delete_cart' ) );
	}

	/**
	 * Check if abandoned cart is enabled.
	 *
	 * @return bool
	 */
	private function is_enabled() {
		return (int) Mersal_Notif_Settings::get( 'abandoned_cart_enabled', 0 ) === 1;
	}

	/**
	 * Register the 15-minute interval the processor runs on.
	 *
	 * @param array $schedules Cron schedules.
	 * @return array
	 */
	public function cron_schedules( $schedules ) {
		if ( ! isset( $schedules['mnotif_15min'] ) ) {
			$schedules['mnotif_15min'] = array(
				'interval' => 15 * MINUTE_IN_SECONDS,
				'display'  => __( 'Every 15 minutes (Mersal)', 'mersal-notifications' ),
			);
		}
		return $schedules;
	}

	/**
	 * Schedule cron if not already scheduled (re-anchors legacy events that
	 * were registered against an unregistered interval and never recurred).
	 */
	public function maybe_schedule_cron() {
		$event = wp_get_scheduled_event( self::CRON_HOOK );
		if ( $event && 'mnotif_15min' !== $event->schedule ) {
			wp_clear_scheduled_hook( self::CRON_HOOK );
			$event = false;
		}
		if ( ! $event && ! wp_next_scheduled( self::CRON_HOOK ) ) {
			wp_schedule_event( time() + 5 * MINUTE_IN_SECONDS, 'mnotif_15min', self::CRON_HOOK );
		}
	}

	/**
	 * Run dbDelta when the schema version bumps.
	 */
	public function maybe_upgrade_db() {
		if ( (int) get_option( 'mnotif_ac_db_ver', 0 ) < self::DB_VER ) {
			self::install_tables();
			update_option( 'mnotif_ac_db_ver', self::DB_VER, false );
		}
	}

	/* ------------------------------------------------------------------ */
	/* Session + telemetry                                                */
	/* ------------------------------------------------------------------ */

	/**
	 * Get the session ID for the current visitor.
	 *
	 * @return string
	 */
	private function get_session_id() {
		if ( is_user_logged_in() ) {
			return 'user_' . get_current_user_id();
		}
		if ( isset( $_COOKIE[ self::COOKIE ] ) ) {
			return sanitize_text_field( wp_unslash( $_COOKIE[ self::COOKIE ] ) );
		}
		$session_id = wp_generate_uuid4();
		if ( ! headers_sent() ) {
			// Not HttpOnly on purpose: the front-end mirrors it into
			// localStorage so the session survives cleared cookies.
			setcookie( self::COOKIE, $session_id, time() + YEAR_IN_SECONDS, '/', '', is_ssl(), false );
		}
		$_COOKIE[ self::COOKIE ] = $session_id;
		return $session_id;
	}

	/**
	 * Whether the current request comes from a bot / crawler / monitor.
	 *
	 * @return bool
	 */
	private function is_bot() {
		if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) {
			return true;
		}
		$ua   = strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) );
		$bots = array(
			'bot', 'crawl', 'slurp', 'spider', 'mediapartners', 'facebookexternalhit',
			'twitterbot', 'bingpreview', 'curl/', 'libcurl', 'wget/', 'python-requests',
			'python-urllib', 'java/', 'okhttp', 'axios/', 'go-http-client', 'zgrab',
			'masscan', 'nmap', 'nikto', 'nuclei', 'sqlmap', 'pingdom', 'uptimerobot',
			'statuscake', 'freshping', 'hetrixtools', 'newrelic', 'datadog', 'zabbix',
			'jmeter', 'gatling', 'headlesschrome', 'phantomjs', 'lighthouse',
		);
		foreach ( $bots as $b ) {
			if ( false !== strpos( $ua, $b ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Parse the user agent into browser / OS / device.
	 *
	 * @return array{0:string,1:string,2:string}
	 */
	private function parse_user_agent() {
		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		$os = 'Unknown';
		$dv = 'Desktop';
		if ( preg_match( '/android/i', $ua ) ) {
			$os = 'Android';
			$dv = 'Mobile';
		} elseif ( preg_match( '/iphone/i', $ua ) ) {
			$os = 'iOS';
			$dv = 'Mobile';
		} elseif ( preg_match( '/ipad/i', $ua ) ) {
			$os = 'iOS';
			$dv = 'Tablet';
		} elseif ( preg_match( '/windows/i', $ua ) ) {
			$os = 'Windows';
		} elseif ( preg_match( '/macintosh|mac os x/i', $ua ) ) {
			$os = 'Mac';
		} elseif ( preg_match( '/linux/i', $ua ) ) {
			$os = 'Linux';
		}
		$br = 'Unknown';
		if ( preg_match( '/Edg/i', $ua ) ) {
			$br = 'Edge';
		} elseif ( preg_match( '/OPR|Opera/i', $ua ) ) {
			$br = 'Opera';
		} elseif ( preg_match( '/Firefox/i', $ua ) ) {
			$br = 'Firefox';
		} elseif ( preg_match( '/Chrome/i', $ua ) ) {
			$br = 'Chrome';
		} elseif ( preg_match( '/Safari/i', $ua ) ) {
			$br = 'Safari';
		}
		return array( $br, $os, $dv );
	}

	/**
	 * Best-effort visitor country code (WooCommerce geolocation).
	 *
	 * @return string
	 */
	private function get_country() {
		if ( class_exists( 'WC_Geolocation' ) ) {
			$geo = WC_Geolocation::geolocate_ip();
			if ( ! empty( $geo['country'] ) ) {
				return substr( sanitize_text_field( $geo['country'] ), 0, 8 );
			}
		}
		return '';
	}

	/* ------------------------------------------------------------------ */
	/* Cart tracking                                                      */
	/* ------------------------------------------------------------------ */

	/**
	 * Track cart activity.
	 */
	public function on_cart_update() {
		if ( ( is_admin() && ! wp_doing_ajax() ) || $this->is_bot() ) {
			return;
		}

		$session_id = $this->get_session_id();
		$cart       = WC()->cart;
		if ( ! $cart ) {
			return;
		}

		$items = array();
		foreach ( $cart->get_cart() as $cart_item ) {
			$product = $cart_item['data'] ?? null;
			if ( ! $product ) {
				continue;
			}
			$items[] = array(
				'product_id' => $cart_item['product_id'],
				'quantity'   => $cart_item['quantity'],
				'name'       => $product->get_name(),
				'price'      => $product->get_price(),
			);
		}

		$user_id    = get_current_user_id();
		$cart_total = (float) $cart->get_cart_contents_total();
		$phone      = '';
		$email      = '';
		$name       = '';

		if ( $user_id ) {
			$user = get_userdata( $user_id );
			if ( $user ) {
				$email = $user->user_email;
				$name  = $user->display_name;
				$phone = get_user_meta( $user_id, 'billing_phone', true );
			}
		}
		if ( empty( $phone ) && WC()->session ) {
			$phone = WC()->session->get( 'billing_phone', '' );
		}
		// Phone captured earlier by the popup / checkout keystrokes.
		if ( empty( $phone ) && isset( $_COOKIE[ self::PHONE_COOKIE ] ) ) {
			$phone = sanitize_text_field( wp_unslash( $_COOKIE[ self::PHONE_COOKIE ] ) );
		}

		$data = array(
			'session_id'    => $session_id,
			'user_id'       => $user_id,
			'cart_items'    => wp_json_encode( $items ),
			'cart_total'    => $cart_total,
			'status'        => 'active',
			'last_activity' => current_time( 'mysql' ),
		);
		// Never blank out contact details captured earlier.
		if ( $phone ) {
			$data['phone'] = Mersal_Notif_Security::normalize_phone( $phone );
		}
		if ( $email ) {
			$data['email'] = $email;
		}
		if ( $name ) {
			$data['name'] = $name;
		}

		global $wpdb;
		$table  = $this->table_name();
		$row    = $wpdb->get_row( $wpdb->prepare( "SELECT id, status, phone, email FROM {$table} WHERE session_id = %s ORDER BY id DESC LIMIT 1", $session_id ) ); // phpcs:ignore WordPress.DB

		if ( $row ) {
			// A completed purchase closes the row; new activity starts a fresh cycle on the same row.
			$wpdb->update( $table, $data, array( 'id' => $row->id ) ); // phpcs:ignore WordPress.DB
			$cart_id = (int) $row->id;
		} else {
			list( $br, $os, $dv )  = $this->parse_user_agent();
			$data['browser']       = $br;
			$data['os']            = $os;
			$data['device']        = $dv;
			$data['country']       = $this->get_country();
			$data['created_at']    = current_time( 'mysql' );
			$wpdb->insert( $table, $data ); // phpcs:ignore WordPress.DB
			$cart_id = (int) $wpdb->insert_id;
		}

		// Journey trigger: an active cart with a reachable contact.
		$has_contact = ! empty( $data['phone'] ) || ! empty( $data['email'] ) || ( $row && ( ! empty( $row->phone ) || ! empty( $row->email ) ) );
		if ( ! empty( $items ) && $cart_id && $has_contact ) {
			$this->fire_journey( 'ac_shopping', $cart_id );
		}
	}

	/**
	 * Capture billing fields while the customer types on the checkout page
	 * (WooCommerce posts the whole form to update_order_review on each change).
	 *
	 * @param string $post_data URL-encoded checkout form data.
	 */
	public function capture_checkout_fields( $post_data ) {
		if ( $this->is_bot() ) {
			return;
		}
		$fields = array();
		wp_parse_str( (string) $post_data, $fields );

		$phone = isset( $fields['billing_phone'] ) ? sanitize_text_field( $fields['billing_phone'] ) : '';
		$email = isset( $fields['billing_email'] ) ? sanitize_email( $fields['billing_email'] ) : '';
		$first = isset( $fields['billing_first_name'] ) ? sanitize_text_field( $fields['billing_first_name'] ) : '';
		$last  = isset( $fields['billing_last_name'] ) ? sanitize_text_field( $fields['billing_last_name'] ) : '';

		if ( '' === $phone && '' === $email ) {
			return;
		}
		$this->save_contact( $phone, $email, trim( $first . ' ' . $last ) );
	}

	/**
	 * Prefill checkout fields from the saved cart row for returning visitors.
	 *
	 * @param mixed  $value Current value.
	 * @param string $input Field name.
	 * @return mixed
	 */
	public function prefill_checkout( $value, $input ) {
		if ( $value || ! in_array( $input, array( 'billing_phone', 'billing_email', 'billing_first_name' ), true ) ) {
			return $value;
		}
		$row = $this->get_session_row();
		if ( ! $row ) {
			return $value;
		}
		if ( 'billing_phone' === $input && ! empty( $row->phone ) ) {
			return $row->phone;
		}
		if ( 'billing_email' === $input && ! empty( $row->email ) ) {
			return $row->email;
		}
		if ( 'billing_first_name' === $input && ! empty( $row->name ) ) {
			$parts = explode( ' ', $row->name );
			return $parts[0];
		}
		return $value;
	}

	/**
	 * Persist contact details onto the visitor's cart row and fire the
	 * matching journey trigger (shopping when a cart exists, else potential).
	 *
	 * @param string $phone Raw phone.
	 * @param string $email Email.
	 * @param string $name  Full name.
	 * @return int Cart row id (0 when nothing was saved).
	 */
	private function save_contact( $phone, $email = '', $name = '' ) {
		$phone = $phone ? Mersal_Notif_Security::normalize_phone( $phone ) : '';
		if ( '' === $phone && '' === $email ) {
			return 0;
		}

		$session_id = $this->get_session_id();
		global $wpdb;
		$table = $this->table_name();
		$row   = $wpdb->get_row( $wpdb->prepare( "SELECT id, cart_items FROM {$table} WHERE session_id = %s ORDER BY id DESC LIMIT 1", $session_id ) ); // phpcs:ignore WordPress.DB

		$data = array( 'last_activity' => current_time( 'mysql' ) );
		if ( $phone ) {
			$data['phone'] = $phone;
			if ( ! headers_sent() ) {
				setcookie( self::PHONE_COOKIE, $phone, time() + YEAR_IN_SECONDS, '/', '', is_ssl(), false );
			}
		}
		if ( $email ) {
			$data['email'] = $email;
		}
		if ( $name ) {
			$data['name'] = $name;
		}

		if ( $row ) {
			$wpdb->update( $table, $data, array( 'id' => $row->id ) ); // phpcs:ignore WordPress.DB
			$cart_id   = (int) $row->id;
			$has_items = ! empty( $row->cart_items ) && '[]' !== $row->cart_items;
		} else {
			list( $br, $os, $dv ) = $this->parse_user_agent();
			$data += array(
				'session_id' => $session_id,
				'user_id'    => get_current_user_id(),
				'cart_items' => '[]',
				'status'     => 'active',
				'browser'    => $br,
				'os'         => $os,
				'device'     => $dv,
				'country'    => $this->get_country(),
				'created_at' => current_time( 'mysql' ),
			);
			$wpdb->insert( $table, $data ); // phpcs:ignore WordPress.DB
			$cart_id   = (int) $wpdb->insert_id;
			$has_items = false;
		}

		if ( $cart_id ) {
			$this->fire_journey( $has_items ? 'ac_shopping' : 'ac_potential', $cart_id );
		}
		return $cart_id;
	}

	/**
	 * Current visitor's cart row.
	 *
	 * @return object|null
	 */
	private function get_session_row() {
		global $wpdb;
		$table = $this->table_name();
		return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE session_id = %s ORDER BY id DESC LIMIT 1", $this->get_session_id() ) ); // phpcs:ignore WordPress.DB
	}

	/* ------------------------------------------------------------------ */
	/* Journey triggers → rule builder                                    */
	/* ------------------------------------------------------------------ */

	/**
	 * Fire a cart-journey trigger through the notification rule engine.
	 * A 30-minute per-cart/per-trigger cooldown prevents repeat storms; the
	 * per-rule cooldown/delay settings still apply on top.
	 *
	 * @param string $trigger ac_shopping | ac_potential | ac_abandoned.
	 * @param int    $cart_id Cart row id.
	 */
	private function fire_journey( $trigger, $cart_id ) {
		if ( ! $this->core || ! $this->core->has_enabled_rules( $trigger ) ) {
			return;
		}
		$cooldown_key = 'mnotif_ac_' . $trigger . '_' . $cart_id;
		if ( get_transient( $cooldown_key ) ) {
			return;
		}

		global $wpdb;
		$table = $this->table_name();
		$cart  = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE id = %d", $cart_id ) ); // phpcs:ignore WordPress.DB
		if ( ! $cart || in_array( $cart->status, array( 'purchased', 'recovered' ), true ) ) {
			return;
		}
		if ( empty( $cart->phone ) && empty( $cart->email ) ) {
			return;
		}

		set_transient( $cooldown_key, 1, 30 * MINUTE_IN_SECONDS );

		$recipient = array(
			'user_id' => (int) $cart->user_id,
			'phone'   => (string) $cart->phone,
			'email'   => (string) $cart->email,
			'name'    => (string) $cart->name,
		);

		$this->core->fire_event(
			$trigger,
			$this->cart_replacements( $cart ),
			$recipient,
			'ac' . $cart_id . '|' . $trigger . '|' . gmdate( 'YmdH' ),
			$this->cart_context( $cart )
		);
	}

	/**
	 * Placeholder map for a cart row.
	 *
	 * @param object $cart Cart row.
	 * @return array<string,string>
	 */
	private function cart_replacements( $cart ) {
		$items = json_decode( (string) $cart->cart_items, true );
		$items = is_array( $items ) ? $items : array();

		$list = '';
		foreach ( $items as $i => $item ) {
			$list .= ( $i + 1 ) . '. ' . ( $item['name'] ?? '' ) . ' × ' . (int) ( $item['quantity'] ?? 1 ) . "\n";
		}
		$list = trim( $list );

		$total = function_exists( 'wc_price' )
			? html_entity_decode( wp_strip_all_tags( wc_price( (float) $cart->cart_total ) ), ENT_QUOTES, 'UTF-8' )
			: (string) $cart->cart_total;

		$name  = $cart->name ? $cart->name : __( 'Customer', 'mersal-notifications' );
		$first = explode( ' ', $name )[0];

		$coupon = $this->ensure_coupon( $cart );

		return array(
			'{{name}}'              => $name,
			'{{first_name}}'        => $first,
			'{{customer_name}}'     => $name,
			'{{email}}'             => (string) $cart->email,
			'{{phone_number}}'      => (string) $cart->phone,
			'{{cart_total}}'        => $total,
			'{{cart_items}}'        => $list,
			'{{product_list}}'      => $list,
			'{{product_list_html}}' => $list,
			'{{coupon_code}}'       => $coupon,
			'{{cart_recovery_url}}' => home_url( '?mnotif_ac_restore=' . rawurlencode( (string) $cart->session_id ) ),
			'{{restore_url}}'       => home_url( '?mnotif_ac_restore=' . rawurlencode( (string) $cart->session_id ) ),
			'{{site_name}}'         => get_bloginfo( 'name' ),
			'{{site_url}}'          => home_url(),
		);
	}

	/**
	 * Filter/media context for a cart row (products, country, cart guard).
	 *
	 * @param object $cart Cart row.
	 * @return array
	 */
	private function cart_context( $cart ) {
		$items = json_decode( (string) $cart->cart_items, true );
		$pids  = array();
		$cids  = array();
		foreach ( (array) $items as $item ) {
			$pid = (int) ( $item['product_id'] ?? 0 );
			if ( $pid ) {
				$pids[] = $pid;
				$terms  = get_the_terms( $pid, 'product_cat' );
				if ( $terms && ! is_wp_error( $terms ) ) {
					foreach ( $terms as $t ) {
						$cids[] = (int) $t->term_id;
					}
				}
			}
		}
		return array(
			'product_ids'  => array_values( array_unique( $pids ) ),
			'category_ids' => array_values( array_unique( $cids ) ),
			'country'      => (string) ( $cart->country ?? '' ),
			'ac_cart_id'   => (int) $cart->id,
		);
	}

	/**
	 * Whether a cart is closed (purchased/recovered/gone) — used to void
	 * scheduled reminders at delivery time.
	 *
	 * @param int $cart_id Cart row id.
	 * @return bool
	 */
	public static function cart_is_closed( $cart_id ) {
		global $wpdb;
		$table  = $wpdb->prefix . self::TABLE;
		$status = $wpdb->get_var( $wpdb->prepare( "SELECT status FROM {$table} WHERE id = %d", $cart_id ) ); // phpcs:ignore WordPress.DB
		return ( null === $status ) || in_array( $status, array( 'purchased', 'recovered' ), true );
	}

	/* ------------------------------------------------------------------ */
	/* Purchase + abandonment processing                                  */
	/* ------------------------------------------------------------------ */

	/**
	 * Mark cart as purchased when an order is created.
	 *
	 * @param int $order_id Order ID.
	 */
	public function on_order_created( $order_id ) {
		if ( ! $order_id ) {
			return;
		}
		$session_id = $this->get_session_id();
		global $wpdb;
		$table = $this->table_name();
		$wpdb->query( $wpdb->prepare( "UPDATE {$table} SET status = 'purchased', order_id = %d WHERE session_id = %s AND status != 'purchased'", $order_id, $session_id ) ); // phpcs:ignore WordPress.DB

		// Cancel any pending legacy notifications for this cart.
		$this->cancel_notifications( $session_id );
	}

	/**
	 * Process abandoned carts: inactive active carts with a contact become
	 * 'abandoned' and enter the rule-builder journey (with legacy fallback).
	 */
	public function process_abandoned() {
		$threshold = max( 5, (int) Mersal_Notif_Settings::get( 'ac_threshold_minutes', 60 ) );
		$cutoff    = wp_date( 'Y-m-d H:i:s', time() - ( $threshold * MINUTE_IN_SECONDS ) );
		global $wpdb;
		$table = $this->table_name();

		$carts = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore WordPress.DB
			"SELECT * FROM {$table} WHERE status = 'active' AND cart_items != '[]' AND cart_items != '' AND last_activity < %s AND (phone != '' OR email != '') ORDER BY id ASC LIMIT 50",
			$cutoff
		) );

		$has_rules = $this->core && $this->core->has_enabled_rules( 'ac_abandoned' );

		foreach ( (array) $carts as $cart ) {
			$wpdb->update( $table, array( 'status' => 'abandoned' ), array( 'id' => $cart->id ) ); // phpcs:ignore WordPress.DB
			$cart->status = 'abandoned';

			if ( $has_rules ) {
				$this->fire_journey( 'ac_abandoned', (int) $cart->id );
			} elseif ( ! empty( $cart->phone ) ) {
				// Legacy single-message fallback (kept for sites without rules).
				$wpdb->update( $table, array( 'status' => 'pending' ), array( 'id' => $cart->id ) ); // phpcs:ignore WordPress.DB
				$this->send_recovery( $cart );
			}
		}

		// Housekeeping: drop stale contactless empty rows.
		$wpdb->query( $wpdb->prepare( // phpcs:ignore WordPress.DB
			"DELETE FROM {$table} WHERE phone = '' AND email = '' AND (cart_items = '[]' OR cart_items = '') AND last_activity < %s LIMIT 200",
			wp_date( 'Y-m-d H:i:s', time() - ( 7 * DAY_IN_SECONDS ) )
		) );
	}

	/**
	 * Legacy fallback: send a single recovery notification for an abandoned cart.
	 *
	 * @param object $cart Cart record.
	 */
	private function send_recovery( $cart ) {
		$items   = json_decode( $cart->cart_items, true );
		$name    = $cart->name ? $cart->name : __( 'Customer', 'mersal-notifications' );
		$phone   = $cart->phone;
		$total   = function_exists( 'wc_price' ) ? html_entity_decode( wp_strip_all_tags( wc_price( $cart->cart_total ) ), ENT_QUOTES, 'UTF-8' ) : $cart->cart_total;
		$site    = get_bloginfo( 'name' );
		$restore = home_url( '?mnotif_ac_restore=' . rawurlencode( $cart->session_id ) );

		$product_html = '';
		foreach ( (array) $items as $item ) {
			$product_html .= '- ' . ( $item['name'] ?? '' ) . ' × ' . (int) ( $item['quantity'] ?? 1 ) . "\n";
		}

		$coupon_code = $this->ensure_coupon( $cart );

		$replacements = array(
			'{{name}}'              => $name,
			'{{first_name}}'        => explode( ' ', $name )[0],
			'{{cart_total}}'        => $total,
			'{{product_list}}'      => trim( str_replace( "\n", ', ', $product_html ) ),
			'{{product_list_html}}' => $product_html,
			'{{cart_items}}'        => trim( $product_html ),
			'{{coupon_code}}'       => $coupon_code,
			'{{restore_url}}'       => $restore,
			'{{cart_recovery_url}}' => $restore,
			'{{site_name}}'         => $site,
			'{{site_url}}'          => home_url(),
		);

		$template = trim( (string) Mersal_Notif_Settings::get( 'ac_message_template', '' ) );
		if ( '' === $template ) {
			$lang     = Mersal_Notif_Settings::get( 'lang', 'ar' );
			$template = ( 'en' === $lang )
				? "Hi {{first_name}},\n\nYou left items in your cart at {{site_name}}:\n\n{{product_list_html}}\nTotal: {{cart_total}}\n\n{{coupon_code}}\n\nComplete your purchase: {{restore_url}}"
				: "مرحباً {{first_name}},\n\nلقد تركت منتجات في سلة التسوق على {{site_name}}:\n\n{{product_list_html}}\nالمجموع: {{cart_total}}\n\n{{coupon_code}}\n\nأكمل عملية الشراء: {{restore_url}}";
		}

		$message = strtr( $template, $replacements );

		if ( ! empty( $phone ) && $this->core ) {
			$job = array(
				'channel' => 'whatsapp',
				'to'      => $phone,
				'message' => $message,
				'subject' => '',
				'media'   => '',
				'rule'    => 'abandoned_cart',
				'rule_id' => 'abandoned_cart',
			);

			try {
				$ref    = new ReflectionClass( $this->core );
				$method = $ref->getMethod( 'dispatch' );
				$method->setAccessible( true );
				$method->invoke( $this->core, $job );
			} catch ( ReflectionException $e ) {
				$this->log_notification( $cart->id, $phone, 'ERROR: ' . $e->getMessage() );
			}
		}

		$this->log_notification( $cart->id, $phone, $message );

		global $wpdb;
		$wpdb->update( $this->table_name(), array( 'status' => 'notified' ), array( 'id' => $cart->id ) ); // phpcs:ignore WordPress.DB
	}

	/* ------------------------------------------------------------------ */
	/* Coupons                                                            */
	/* ------------------------------------------------------------------ */

	/**
	 * Return the cart's recovery coupon, generating (and persisting) it once
	 * when coupon generation is enabled.
	 *
	 * @param object $cart Cart row.
	 * @return string
	 */
	private function ensure_coupon( $cart ) {
		if ( (int) Mersal_Notif_Settings::get( 'ac_generate_coupon', 0 ) !== 1 || ! class_exists( 'WC_Coupon' ) ) {
			return '';
		}
		if ( ! empty( $cart->coupon_code ) ) {
			return (string) $cart->coupon_code;
		}
		$code = $this->generate_coupon( $cart );
		if ( $code ) {
			global $wpdb;
			$wpdb->update( $this->table_name(), array( 'coupon_code' => $code ), array( 'id' => $cart->id ) ); // phpcs:ignore WordPress.DB
			$cart->coupon_code = $code;
		}
		return $code;
	}

	/**
	 * Generate a restricted single-use recovery coupon for a cart.
	 *
	 * @param object $cart Cart row.
	 * @return string Coupon code.
	 */
	private function generate_coupon( $cart ) {
		$prefix = strtoupper( sanitize_text_field( Mersal_Notif_Settings::get( 'ac_coupon_prefix', 'REC' ) ) );
		$code   = $prefix . '-' . $cart->id . '-' . strtoupper( wp_generate_password( 5, false ) );

		$discount = (float) Mersal_Notif_Settings::get( 'ac_coupon_discount', 10 );
		$type     = Mersal_Notif_Settings::get( 'ac_coupon_type', 'percent' );
		$expiry   = max( 1, (int) Mersal_Notif_Settings::get( 'ac_coupon_expiry_days', 7 ) );

		$coupon = new WC_Coupon();
		$coupon->set_code( $code );
		$coupon->set_discount_type( 'percent' === $type ? 'percent' : 'fixed_cart' );
		$coupon->set_amount( $discount );
		$coupon->set_individual_use( true );
		$coupon->set_usage_limit( 1 );
		$coupon->set_date_expires( time() + ( $expiry * DAY_IN_SECONDS ) );
		$coupon->set_description( __( 'Generated by Mersal Abandoned Cart Recovery', 'mersal-notifications' ) );

		$min_spend = (float) Mersal_Notif_Settings::get( 'ac_coupon_min_spend', 0 );
		if ( $min_spend > 0 ) {
			$coupon->set_minimum_amount( $min_spend );
		}
		if ( (int) Mersal_Notif_Settings::get( 'ac_coupon_free_shipping', 0 ) === 1 ) {
			$coupon->set_free_shipping( true );
		}
		// Lock the coupon to the cart owner's email.
		if ( (int) Mersal_Notif_Settings::get( 'ac_coupon_email_restrict', 0 ) === 1 && ! empty( $cart->email ) ) {
			$coupon->set_email_restrictions( array( $cart->email ) );
		}
		$products = array_filter( array_map( 'absint', explode( ',', (string) Mersal_Notif_Settings::get( 'ac_coupon_products', '' ) ) ) );
		if ( $products ) {
			$coupon->set_product_ids( $products );
		}
		$cats = array_filter( array_map( 'absint', explode( ',', (string) Mersal_Notif_Settings::get( 'ac_coupon_cats', '' ) ) ) );
		if ( $cats ) {
			$coupon->set_product_categories( $cats );
		}

		$coupon->save();
		return $code;
	}

	/**
	 * Cap the discount of generated recovery coupons at a configured maximum.
	 *
	 * @param float     $discount           Calculated discount.
	 * @param float     $discounting_amount Amount being discounted.
	 * @param array     $cart_item          Cart item.
	 * @param bool      $single             Single.
	 * @param WC_Coupon $coupon             Coupon.
	 * @return float
	 */
	public function cap_coupon_discount( $discount, $discounting_amount, $cart_item, $single, $coupon ) {
		$max = (float) Mersal_Notif_Settings::get( 'ac_coupon_max_discount', 0 );
		if ( $max <= 0 ) {
			return $discount;
		}
		$prefix = strtoupper( sanitize_text_field( Mersal_Notif_Settings::get( 'ac_coupon_prefix', 'REC' ) ) ) . '-';
		if ( 0 === strpos( strtoupper( $coupon->get_code() ), $prefix ) && $discount > $max ) {
			return $max;
		}
		return $discount;
	}

	/* ------------------------------------------------------------------ */
	/* Cart restoration                                                   */
	/* ------------------------------------------------------------------ */

	/**
	 * Handle cart restoration from recovery link.
	 */
	public function handle_restore() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$token = isset( $_GET['mnotif_ac_restore'] ) ? sanitize_text_field( wp_unslash( $_GET['mnotif_ac_restore'] ) ) : '';
		if ( '' === $token ) {
			return;
		}

		global $wpdb;
		$table = $this->table_name();
		$cart  = $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$table} WHERE session_id = %s ORDER BY id DESC LIMIT 1", $token ) ); // phpcs:ignore WordPress.DB

		if ( ! $cart || 'purchased' === $cart->status ) {
			return;
		}

		$items = json_decode( $cart->cart_items, true );
		if ( ! is_array( $items ) || empty( $items ) ) {
			return;
		}

		if ( ! WC()->cart ) {
			return;
		}
		WC()->cart->empty_cart();
		foreach ( $items as $item ) {
			$product_id = (int) $item['product_id'];
			$quantity   = (int) $item['quantity'];
			if ( $product_id && $quantity > 0 && wc_get_product( $product_id ) ) {
				WC()->cart->add_to_cart( $product_id, $quantity );
			}
		}

		// Adopt the recovered session so the purchase closes this row.
		if ( ! headers_sent() && ! is_user_logged_in() ) {
			setcookie( self::COOKIE, $cart->session_id, time() + YEAR_IN_SECONDS, '/', '', is_ssl(), false );
			$_COOKIE[ self::COOKIE ] = $cart->session_id;
		}

		// Apply the recovery coupon (existing one, or generate on the fly).
		$code = $this->ensure_coupon( $cart );
		if ( $code && WC()->cart ) {
			WC()->cart->apply_coupon( $code );
		}

		// Returning shopper: void still-scheduled reminders, restart the cycle.
		$wpdb->update( $table, array( 'status' => 'recovered', 'last_activity' => current_time( 'mysql' ) ), array( 'id' => $cart->id ) ); // phpcs:ignore WordPress.DB

		wp_safe_redirect( wc_get_cart_url() );
		exit;
	}

	/* ------------------------------------------------------------------ */
	/* Front-end: reclaim + tracker + phone-capture popup                 */
	/* ------------------------------------------------------------------ */

	/**
	 * Render the session-reclaim/tracker script and the phone-capture popup.
	 */
	public function render_frontend() {
		if ( is_admin() || $this->is_bot() ) {
			return;
		}

		$ajax  = admin_url( 'admin-ajax.php' );
		$nonce = wp_create_nonce( 'mnotif_ac_public' );
		$track = (int) Mersal_Notif_Settings::get( 'ac_track_pages', 1 ) === 1;

		// --- Session reclaim (localStorage mirror) + page tracker ---
		echo '<script>(function(){' .
			'var C="' . esc_js( self::COOKIE ) . '",A=' . wp_json_encode( $ajax ) . ',N=' . wp_json_encode( $nonce ) . ';' .
			'function gc(n){var m=document.cookie.match(new RegExp("(^| )"+n+"=([^;]+)"));return m?m[2]:null}' .
			'function sc(n,v){var d=new Date();d.setTime(d.getTime()+31536e6);document.cookie=n+"="+v+"; expires="+d.toUTCString()+"; path=/"}' .
			'try{var c=gc(C),s=localStorage.getItem(C);' .
			'if(c&&s!==c){localStorage.setItem(C,c);}' .
			'else if(!c&&s){sc(C,s);var f=new FormData();f.append("action","mnotif_ac_reclaim");f.append("nonce",N);f.append("session_id",s);fetch(A,{method:"POST",body:f,credentials:"same-origin"});}' .
			'}catch(e){}' .
			( $track ?
			'try{var k="mnotif_tr_"+location.pathname;if(!sessionStorage.getItem(k)){sessionStorage.setItem(k,"1");' .
			'var f2=new FormData();f2.append("action","mnotif_ac_track");f2.append("nonce",N);f2.append("url",location.pathname+location.search);f2.append("title",document.title||"");' .
			'fetch(A,{method:"POST",body:f2,credentials:"same-origin"});}}catch(e){}' : '' ) .
			'})();</script>';

		$this->render_popup( $ajax, $nonce );
	}

	/**
	 * Render the phone-capture popup when enabled + targeted at this view.
	 *
	 * @param string $ajax  admin-ajax URL.
	 * @param string $nonce Public nonce.
	 */
	private function render_popup( $ajax, $nonce ) {
		if ( (int) Mersal_Notif_Settings::get( 'ac_popup_enabled', 0 ) !== 1 ) {
			return;
		}
		// Already captured — never nag again.
		if ( isset( $_COOKIE[ self::PHONE_COOKIE ] ) && '' !== $_COOKIE[ self::PHONE_COOKIE ] ) {
			return;
		}
		if ( is_user_logged_in() && get_user_meta( get_current_user_id(), 'billing_phone', true ) ) {
			return;
		}
		// Page exclusions (IDs CSV).
		$exclude = array_filter( array_map( 'absint', explode( ',', (string) Mersal_Notif_Settings::get( 'ac_popup_exclude', '' ) ) ) );
		if ( $exclude && is_page( $exclude ) ) {
			return;
		}
		// Device targeting.
		$display   = Mersal_Notif_Settings::get( 'ac_popup_display', 'all' );
		$is_mobile = (bool) wp_is_mobile();
		if ( ( 'desktop' === $display && $is_mobile ) || ( 'mobile' === $display && ! $is_mobile ) ) {
			return;
		}
		// Minimum cart value gate (0 = show regardless of cart).
		$min_cart = (float) Mersal_Notif_Settings::get( 'ac_popup_min_cart', 0 );
		if ( $min_cart > 0 ) {
			$cart_total = ( function_exists( 'WC' ) && WC()->cart ) ? (float) WC()->cart->get_cart_contents_total() : 0;
			if ( $cart_total < $min_cart ) {
				return;
			}
		}

		$title    = Mersal_Notif_Settings::get( 'ac_popup_title', __( 'أضف رقم الواتساب الخاص بك', 'mersal-notifications' ) );
		$desc     = Mersal_Notif_Settings::get( 'ac_popup_desc', __( 'لتحصل على تجربة أفضل وعروض حصرية.', 'mersal-notifications' ) );
		$btn      = Mersal_Notif_Settings::get( 'ac_popup_btn', __( 'حفظ', 'mersal-notifications' ) );
		$ok_title = Mersal_Notif_Settings::get( 'ac_popup_success_title', __( 'ممتاز!', 'mersal-notifications' ) );
		$ok_desc  = Mersal_Notif_Settings::get( 'ac_popup_success_desc', __( 'يمكنك الآن متابعة التصفح.', 'mersal-notifications' ) );
		$color    = Mersal_Notif_Settings::get( 'ac_popup_color', '#0f8f6f' );
		$image    = Mersal_Notif_Settings::get( 'ac_popup_image', '' );

		$cfg = array(
			'ajax'         => $ajax,
			'nonce'        => $nonce,
			'delay'        => max( 0, (int) Mersal_Notif_Settings::get( 'ac_popup_delay', 4 ) ),
			'triggerCart'  => (int) Mersal_Notif_Settings::get( 'ac_popup_trigger_cart', 1 ),
			'triggerExit'  => (int) Mersal_Notif_Settings::get( 'ac_popup_trigger_exit', 1 ),
			'frequency'    => Mersal_Notif_Settings::get( 'ac_popup_frequency', 'days' ),
			'freqDays'     => max( 1, (int) Mersal_Notif_Settings::get( 'ac_popup_frequency_days', 7 ) ),
			'invalidPhone' => __( 'من فضلك أدخل رقمًا صحيحًا بكود الدولة.', 'mersal-notifications' ),
		);

		?>
		<div id="mnotif-ac-popup" hidden dir="<?php echo is_rtl() ? 'rtl' : 'ltr'; ?>" style="position:fixed;inset:0;z-index:999999;display:flex;align-items:center;justify-content:center;">
			<div data-mnotif-pop-overlay style="position:absolute;inset:0;background:rgba(15,23,42,.55);backdrop-filter:blur(2px);"></div>
			<div style="position:relative;width:380px;max-width:calc(100vw - 32px);background:#fff;border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,.25);overflow:hidden;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Tahoma,sans-serif;">
				<button type="button" data-mnotif-pop-close aria-label="close" style="position:absolute;top:10px;<?php echo is_rtl() ? 'left' : 'right'; ?>:10px;width:30px;height:30px;border:none;border-radius:50%;background:#f1f5f9;color:#475569;font-size:16px;cursor:pointer;line-height:1;">×</button>
				<?php if ( $image ) : ?>
					<img src="<?php echo esc_url( $image ); ?>" alt="" style="width:100%;height:130px;object-fit:cover;display:block;">
				<?php endif; ?>
				<div data-mnotif-pop-form style="padding:22px;">
					<div style="font-size:17px;font-weight:700;color:#0f172a;margin-bottom:6px;"><?php echo esc_html( $title ); ?></div>
					<div style="font-size:13px;color:#64748b;margin-bottom:14px;"><?php echo esc_html( $desc ); ?></div>
					<input data-mnotif-pop-phone type="tel" inputmode="tel" placeholder="+20 10 0000 0000" style="width:100%;box-sizing:border-box;padding:11px 14px;border:1.5px solid #e2e8f0;border-radius:10px;font-size:15px;direction:ltr;text-align:start;outline:none;">
					<div data-mnotif-pop-err style="display:none;color:#dc2626;font-size:12px;margin-top:6px;"></div>
					<button type="button" data-mnotif-pop-save style="width:100%;margin-top:12px;padding:12px;border:none;border-radius:10px;background:<?php echo esc_attr( $color ); ?>;color:#fff;font-size:15px;font-weight:700;cursor:pointer;"><?php echo esc_html( $btn ); ?></button>
				</div>
				<div data-mnotif-pop-ok hidden style="padding:34px 22px;text-align:center;">
					<div style="width:56px;height:56px;margin:0 auto 12px;border-radius:50%;background:<?php echo esc_attr( $color ); ?>1a;display:flex;align-items:center;justify-content:center;font-size:26px;color:<?php echo esc_attr( $color ); ?>;">✓</div>
					<div style="font-size:17px;font-weight:700;color:#0f172a;margin-bottom:6px;"><?php echo esc_html( $ok_title ); ?></div>
					<div style="font-size:13px;color:#64748b;"><?php echo esc_html( $ok_desc ); ?></div>
				</div>
			</div>
		</div>
		<script>
		(function(){
			var cfg = <?php echo wp_json_encode( $cfg ); ?>;
			var pop = document.getElementById('mnotif-ac-popup');
			if (!pop) return;
			var KEY = 'mnotif_pop_last', shown = false, saved = false;
			function allowed(){
				try{
					var last = localStorage.getItem(KEY);
					if (!last) return true;
					if (cfg.frequency === 'always') return true;
					if (cfg.frequency === 'once') return false;
					return (Date.now() - (+last)) > cfg.freqDays * 864e5;
				}catch(e){ return true; }
			}
			function show(){
				if (shown || saved || !allowed()) return;
				shown = true;
				pop.hidden = false;
				try{ localStorage.setItem(KEY, String(Date.now())); }catch(e){}
			}
			function hide(){ pop.hidden = true; }
			// Triggers.
			if (cfg.delay > 0) setTimeout(show, cfg.delay * 1000);
			if (cfg.triggerCart && window.jQuery) jQuery(document.body).on('added_to_cart', function(){ setTimeout(show, 800); });
			if (cfg.triggerExit) document.addEventListener('mouseout', function(e){
				if (!e.toElement && !e.relatedTarget && e.clientY < 12) show();
			});
			pop.querySelector('[data-mnotif-pop-close]').addEventListener('click', hide);
			pop.querySelector('[data-mnotif-pop-overlay]').addEventListener('click', hide);
			// Save.
			var input = pop.querySelector('[data-mnotif-pop-phone]');
			var err   = pop.querySelector('[data-mnotif-pop-err]');
			pop.querySelector('[data-mnotif-pop-save]').addEventListener('click', function(){
				var v = (input.value || '').replace(/[^0-9+]/g, '');
				if (v.replace(/\D/g,'').length < 8){ err.textContent = cfg.invalidPhone; err.style.display='block'; return; }
				err.style.display='none';
				var btn = this; btn.disabled = true; btn.style.opacity = .7;
				var fd = new FormData();
				fd.append('action','mnotif_ac_save_phone'); fd.append('nonce',cfg.nonce); fd.append('phone',v);
				fetch(cfg.ajax,{method:'POST',body:fd,credentials:'same-origin'}).then(function(r){return r.json();}).then(function(res){
					btn.disabled = false; btn.style.opacity = 1;
					if (res && res.success){
						saved = true;
						pop.querySelector('[data-mnotif-pop-form]').hidden = true;
						pop.querySelector('[data-mnotif-pop-ok]').hidden = false;
						try{ localStorage.setItem(KEY, String(Date.now() + 315e8)); }catch(e){}
						setTimeout(hide, 2400);
					} else {
						err.textContent = (res && res.data && res.data.message) || cfg.invalidPhone;
						err.style.display = 'block';
					}
				}).catch(function(){ btn.disabled = false; btn.style.opacity = 1; });
			});
		})();
		</script>
		<?php
	}

	/* ------------------------------------------------------------------ */
	/* Public AJAX                                                        */
	/* ------------------------------------------------------------------ */

	/**
	 * AJAX: save a visitor's phone (popup submit).
	 */
	public function ajax_save_phone() {
		check_ajax_referer( 'mnotif_ac_public', 'nonce' );
		$phone = isset( $_POST['phone'] ) ? sanitize_text_field( wp_unslash( $_POST['phone'] ) ) : '';
		$phone = Mersal_Notif_Security::normalize_phone( $phone );
		if ( strlen( preg_replace( '/\D/', '', $phone ) ) < 8 ) {
			wp_send_json_error( array( 'message' => __( 'رقم غير صحيح.', 'mersal-notifications' ) ) );
		}
		$name  = isset( $_POST['name'] ) ? sanitize_text_field( wp_unslash( $_POST['name'] ) ) : '';
		$email = isset( $_POST['email'] ) ? sanitize_email( wp_unslash( $_POST['email'] ) ) : '';
		$this->save_contact( $phone, $email, $name );
		wp_send_json_success();
	}

	/**
	 * AJAX: reclaim a session id mirrored in localStorage (cookie was cleared).
	 */
	public function ajax_reclaim() {
		check_ajax_referer( 'mnotif_ac_public', 'nonce' );
		$session = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
		if ( '' === $session || strlen( $session ) > 64 || is_user_logged_in() ) {
			wp_send_json_error();
		}
		global $wpdb;
		$table  = $this->table_name();
		$exists = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE session_id = %s LIMIT 1", $session ) ); // phpcs:ignore WordPress.DB
		if ( $exists && ! headers_sent() ) {
			setcookie( self::COOKIE, $session, time() + YEAR_IN_SECONDS, '/', '', is_ssl(), false );
		}
		wp_send_json_success();
	}

	/**
	 * AJAX: record a page visit into the cart row's history (last 20 events).
	 */
	public function ajax_track() {
		check_ajax_referer( 'mnotif_ac_public', 'nonce' );
		if ( (int) Mersal_Notif_Settings::get( 'ac_track_pages', 1 ) !== 1 || $this->is_bot() ) {
			wp_send_json_success();
		}
		$url   = isset( $_POST['url'] ) ? esc_url_raw( wp_unslash( $_POST['url'] ) ) : '';
		$title = isset( $_POST['title'] ) ? sanitize_text_field( wp_unslash( $_POST['title'] ) ) : '';
		if ( '' === $url ) {
			wp_send_json_success();
		}

		$row = $this->get_session_row();
		global $wpdb;
		$table = $this->table_name();

		if ( ! $row ) {
			list( $br, $os, $dv ) = $this->parse_user_agent();
			$wpdb->insert( $table, array( // phpcs:ignore WordPress.DB
				'session_id'    => $this->get_session_id(),
				'user_id'       => get_current_user_id(),
				'cart_items'    => '[]',
				'status'        => 'active',
				'browser'       => $br,
				'os'            => $os,
				'device'        => $dv,
				'country'       => $this->get_country(),
				'page_history'  => wp_json_encode( array( array( 'u' => $url, 't' => $title, 'ts' => time() ) ) ),
				'last_activity' => current_time( 'mysql' ),
				'created_at'    => current_time( 'mysql' ),
			) );
			wp_send_json_success();
		}

		$history = json_decode( (string) ( $row->page_history ?? '' ), true );
		$history = is_array( $history ) ? $history : array();
		$last    = end( $history );
		if ( ! $last || ( $last['u'] ?? '' ) !== $url ) {
			$history[] = array( 'u' => $url, 't' => $title, 'ts' => time() );
			$history   = array_slice( $history, -20 );
		} else {
			$history[ key( $history ) ]['ts'] = time();
		}
		$wpdb->update( $table, array( 'page_history' => wp_json_encode( $history ), 'last_activity' => current_time( 'mysql' ) ), array( 'id' => $row->id ) ); // phpcs:ignore WordPress.DB
		wp_send_json_success();
	}

	/* ------------------------------------------------------------------ */
	/* Logging + admin data                                               */
	/* ------------------------------------------------------------------ */

	/**
	 * Log a notification.
	 *
	 * @param int    $cart_id Cart ID.
	 * @param string $to      Recipient.
	 * @param string $message Message sent.
	 */
	private function log_notification( $cart_id, $to, $message ) {
		global $wpdb;
		$wpdb->insert( $this->queue_table(), array( // phpcs:ignore WordPress.DB
			'cart_id'   => $cart_id,
			'recipient' => $to,
			'message'   => $message,
			'status'    => 'sent',
			'sent_at'   => current_time( 'mysql' ),
		) );
	}

	/**
	 * Cancel pending notifications for a cart.
	 *
	 * @param string $session_id Session ID.
	 */
	private function cancel_notifications( $session_id ) {
		global $wpdb;
		$table = $this->table_name();
		$cart  = $wpdb->get_var( $wpdb->prepare( "SELECT id FROM {$table} WHERE session_id = %s ORDER BY id DESC LIMIT 1", $session_id ) ); // phpcs:ignore WordPress.DB
		if ( $cart ) {
			$wpdb->update( $this->queue_table(), array( 'status' => 'cancelled' ), array( 'cart_id' => $cart, 'status' => 'pending' ) ); // phpcs:ignore WordPress.DB
		}
	}

	/**
	 * Get table name.
	 *
	 * @return string
	 */
	private function table_name() {
		global $wpdb;
		return $wpdb->prefix . self::TABLE;
	}

	/**
	 * Get queue table name.
	 *
	 * @return string
	 */
	private function queue_table() {
		global $wpdb;
		return $wpdb->prefix . self::QUEUE_TABLE;
	}

	/**
	 * Aggregated stats for the admin dashboard/cards.
	 *
	 * @return array
	 */
	public static function get_admin_stats() {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;
		// phpcs:disable WordPress.DB
		$rows = $wpdb->get_results( "SELECT status, COUNT(*) c, COALESCE(SUM(cart_total),0) t FROM {$table} GROUP BY status", ARRAY_A );
		// phpcs:enable WordPress.DB
		$out = array(
			'total'          => 0,
			'active'         => 0,
			'abandoned'      => 0,
			'notified'       => 0,
			'purchased'      => 0,
			'recovered'      => 0,
			'potential_rev'  => 0.0,
			'recovered_rev'  => 0.0,
		);
		foreach ( (array) $rows as $r ) {
			$out['total'] += (int) $r['c'];
			if ( isset( $out[ $r['status'] ] ) ) {
				$out[ $r['status'] ] += (int) $r['c'];
			}
			if ( in_array( $r['status'], array( 'active', 'abandoned', 'notified' ), true ) ) {
				$out['potential_rev'] += (float) $r['t'];
			}
			if ( 'purchased' === $r['status'] ) {
				$out['recovered_rev'] += (float) $r['t'];
			}
		}
		return $out;
	}

	/**
	 * Recent cart rows for the admin table.
	 *
	 * @param int $limit Max rows.
	 * @return array
	 */
	public static function get_recent_carts( $limit = 30 ) {
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE;
		return (array) $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$table} ORDER BY last_activity DESC LIMIT %d", $limit ) ); // phpcs:ignore WordPress.DB
	}

	/**
	 * AJAX: Get abandoned cart stats.
	 */
	public function ajax_stats() {
		check_ajax_referer( 'mnotif_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error();
		}
		wp_send_json_success( self::get_admin_stats() );
	}

	/**
	 * AJAX: delete a cart row.
	 */
	public function ajax_delete_cart() {
		check_ajax_referer( 'mnotif_rule', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error();
		}
		$id = isset( $_POST['id'] ) ? absint( $_POST['id'] ) : 0;
		if ( $id ) {
			global $wpdb;
			$wpdb->delete( $this->table_name(), array( 'id' => $id ) ); // phpcs:ignore WordPress.DB
		}
		wp_send_json_success();
	}

	/**
	 * Create tables on activation / upgrade (dbDelta handles new columns).
	 */
	public static function install_tables() {
		global $wpdb;
		$charset = $wpdb->get_charset_collate();

		$sql = "CREATE TABLE {$wpdb->prefix}" . self::TABLE . " (
			id bigint(20) NOT NULL AUTO_INCREMENT,
			session_id varchar(64) NOT NULL DEFAULT '',
			user_id bigint(20) NOT NULL DEFAULT 0,
			phone varchar(32) NOT NULL DEFAULT '',
			email varchar(100) NOT NULL DEFAULT '',
			name varchar(100) NOT NULL DEFAULT '',
			cart_items longtext,
			cart_total decimal(12,2) NOT NULL DEFAULT 0,
			status varchar(20) NOT NULL DEFAULT 'active',
			order_id bigint(20) NOT NULL DEFAULT 0,
			country varchar(8) NOT NULL DEFAULT '',
			browser varchar(32) NOT NULL DEFAULT '',
			os varchar(32) NOT NULL DEFAULT '',
			device varchar(16) NOT NULL DEFAULT '',
			page_history longtext,
			coupon_code varchar(64) NOT NULL DEFAULT '',
			last_activity datetime NOT NULL,
			created_at datetime NOT NULL,
			PRIMARY KEY  (id),
			KEY session_id (session_id),
			KEY status (status),
			KEY last_activity (last_activity)
		) {$charset};";

		$sql2 = "CREATE TABLE {$wpdb->prefix}" . self::QUEUE_TABLE . " (
			id bigint(20) NOT NULL AUTO_INCREMENT,
			cart_id bigint(20) NOT NULL DEFAULT 0,
			recipient varchar(100) NOT NULL DEFAULT '',
			message text,
			status varchar(20) NOT NULL DEFAULT 'pending',
			sent_at datetime DEFAULT NULL,
			PRIMARY KEY  (id),
			KEY cart_id (cart_id),
			KEY status (status)
		) {$charset};";

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