<?php
/**
 * Mersal Notifications — engine.
 *
 * Event-driven notifications. Rules (one per row) live in a single option and
 * map a trigger (WooCommerce order status, user register / login, order note)
 * to a WhatsApp / SMS / Email message sent to the customer or to admins through
 * the connected Mersal account. Messages support {{placeholders}} and can be
 * delayed with WP-cron.
 *
 * @package Mersal_Notif
 */

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

class Mersal_Notif_Core {

	/** Per-rule rotation cursor for message variants. */
	const VARIANT_CURSOR_OPTION = 'mnotif_variant_cursors';

	const RULES_OPTION = 'mnotif_rules';
	const STATS_OPTION = 'mnotif_stats';
	const LOG_OPTION   = 'mnotif_log';

	/**
	 * @var Mersal_Notif_Connect|null Sending gateway / account.
	 */
	private $connect = null;

	/**
	 * Guard against duplicate sends within one request.
	 *
	 * @var array<string,bool>
	 */
	private $done = array();

	/**
	 * @param Mersal_Notif_Connect|null $connect Connect service.
	 */
	public function __construct( $connect = null ) {
		$this->connect = $connect;
	}

	/**
	 * Wire trigger hooks + AJAX.
	 */
	public function init() {
		// WordPress triggers.
		add_action( 'user_register', array( $this, 'on_user_register' ), 20, 1 );
		add_action( 'wp_login', array( $this, 'on_wp_login' ), 20, 2 );

		// WooCommerce triggers.
		add_action( 'woocommerce_order_status_changed', array( $this, 'on_order_status_changed' ), 20, 4 );
		add_action( 'woocommerce_new_customer_note', array( $this, 'on_customer_note' ), 20, 1 );
		add_action( 'woocommerce_subscription_status_updated', array( $this, 'on_subscription_status' ), 20, 3 );
		add_action( 'woocommerce_subscription_renewal_payment_complete', array( $this, 'on_subscription_renewal' ), 20, 2 );
		add_action( 'woocommerce_checkout_order_processed', array( $this, 'on_new_order' ), 20, 1 );
		add_action( 'woocommerce_payment_complete', array( $this, 'on_payment_complete' ), 20, 1 );
		add_action( 'after_password_reset', array( $this, 'on_password_reset' ), 20, 1 );

		// Public hook so any code/integration can fire a custom event:
		// do_action( 'mnotif_trigger', 'my_slug', array( 'user_id'=>1, 'phone'=>'', 'email'=>'', 'name'=>'', 'replacements'=>array( 'foo'=>'bar' ) ) );
		add_action( 'mnotif_trigger', array( $this, 'fire_custom' ), 10, 2 );

		// Delayed dispatch.
		add_action( 'mnotif_send_scheduled', array( $this, 'dispatch_scheduled' ), 10, 1 );

		// AJAX (admin only).
		add_action( 'wp_ajax_mnotif_save_rule', array( $this, 'ajax_save_rule' ) );
		add_action( 'wp_ajax_mnotif_delete_rule', array( $this, 'ajax_delete_rule' ) );
		add_action( 'wp_ajax_mnotif_toggle_rule', array( $this, 'ajax_toggle_rule' ) );
		add_action( 'wp_ajax_mnotif_test_rule', array( $this, 'ajax_test_rule' ) );
		add_action( 'wp_ajax_mnotif_test_draft', array( $this, 'ajax_test_draft' ) );
		add_action( 'wp_ajax_mnotif_duplicate_rule', array( $this, 'ajax_duplicate_rule' ) );
		add_action( 'wp_ajax_mnotif_preview', array( $this, 'ajax_preview' ) );
		add_action( 'wp_ajax_mnotif_add_event', array( $this, 'ajax_add_event' ) );
		add_action( 'wp_ajax_mnotif_del_event', array( $this, 'ajax_del_event' ) );
	}

	/* ------------------------------------------------------------------ */
	/* Triggers                                                           */
	/* ------------------------------------------------------------------ */

	/**
	 * Available trigger keys => label.
	 *
	 * @return array<string,string>
	 */
	public function get_triggers() {
		$t = array(
			'user_register'        => __( 'User: New registration', 'mersal-notifications' ),
			'wp_login'             => __( 'User: Login', 'mersal-notifications' ),
			'user_password_reset'  => __( 'User: Password reset', 'mersal-notifications' ),
		);
		if ( class_exists( 'WooCommerce' ) && function_exists( 'wc_get_order_statuses' ) ) {
			$t['wc_new_order']        = __( 'Order: New order placed', 'mersal-notifications' );
			$t['wc_payment_complete'] = __( 'Order: Payment received', 'mersal-notifications' );
			foreach ( wc_get_order_statuses() as $slug => $label ) {
				$key       = 'wc_status_' . str_replace( 'wc-', '', $slug );
				/* translators: %s: order status label */
				$t[ $key ] = sprintf( __( 'Order status: %s', 'mersal-notifications' ), $label );
			}
			$t['wc_order_note_added'] = __( 'Order: Note to customer added', 'mersal-notifications' );

			// Cart-journey triggers (fired by the Abandoned Cart tracker).
			$t['ac_shopping']  = __( 'Cart: Customer shopping (active cart)', 'mersal-notifications' );
			$t['ac_potential'] = __( 'Cart: Potential customer (left contact, no order)', 'mersal-notifications' );
			$t['ac_abandoned'] = __( 'Cart: Abandoned cart', 'mersal-notifications' );

			if ( class_exists( 'WC_Subscriptions' ) && function_exists( 'wcs_get_subscription_statuses' ) ) {
				foreach ( wcs_get_subscription_statuses() as $slug => $label ) {
					$key       = 'wc_sub_' . str_replace( 'wc-', '', $slug );
					/* translators: %s: subscription status label */
					$t[ $key ] = sprintf( __( 'Subscription: %s', 'mersal-notifications' ), $label );
				}
				$t['wc_sub_renewal_payment_complete'] = __( 'Subscription: Renewal payment complete', 'mersal-notifications' );
			}
		}

		// Admin-defined custom events.
		foreach ( $this->get_custom_events() as $slug => $label ) {
			/* translators: %s: custom event label */
			$t[ 'custom_' . $slug ] = sprintf( __( 'Custom: %s', 'mersal-notifications' ), $label );
		}

		return $t;
	}

	/**
	 * Admin-defined custom events: slug => label.
	 *
	 * @return array<string,string>
	 */
	public function get_custom_events() {
		$ev = get_option( 'mnotif_custom_events', array() );
		return is_array( $ev ) ? $ev : array();
	}

	/**
	 * Add a custom event.
	 *
	 * @param string $label Human label.
	 * @return string The generated slug.
	 */
	public function add_custom_event( $label ) {
		$label = sanitize_text_field( $label );
		if ( '' === $label ) {
			return '';
		}
		$slug = sanitize_key( str_replace( ' ', '_', $label ) );
		if ( '' === $slug ) {
			$slug = 'event_' . wp_rand( 1000, 9999 );
		}
		$ev          = $this->get_custom_events();
		$ev[ $slug ] = $label;
		update_option( 'mnotif_custom_events', $ev, false );
		return $slug;
	}

	/**
	 * Delete a custom event.
	 *
	 * @param string $slug Event slug.
	 */
	public function delete_custom_event( $slug ) {
		$ev = $this->get_custom_events();
		unset( $ev[ sanitize_key( $slug ) ] );
		update_option( 'mnotif_custom_events', $ev, false );
	}

	/**
	 * Fire a custom event (from the `mnotif_trigger` action).
	 *
	 * @param string $slug Event slug (without the `custom_` prefix).
	 * @param array  $data { user_id, phone, email, name, replacements:array }.
	 */
	public function fire_custom( $slug, $data = array() ) {
		$slug = sanitize_key( $slug );
		if ( '' === $slug ) {
			return;
		}
		$data    = is_array( $data ) ? $data : array();
		$user_id = (int) ( $data['user_id'] ?? 0 );
		$phone   = isset( $data['phone'] ) ? Mersal_Notif_Security::normalize_phone( $data['phone'] ) : '';
		$email   = isset( $data['email'] ) ? sanitize_email( $data['email'] ) : '';
		$name    = isset( $data['name'] ) ? sanitize_text_field( $data['name'] ) : '';
		if ( $user_id ) {
			$u = get_userdata( $user_id );
			if ( $u ) {
				$phone = $phone ? $phone : $this->find_phone( $user_id );
				$email = $email ? $email : $u->user_email;
				$name  = $name ? $name : $u->display_name;
			}
		}
		// Custom placeholders: {{key}} => value.
		$rep = array( '{{name}}' => $name, '{{customer_name}}' => $name );
		if ( ! empty( $data['replacements'] ) && is_array( $data['replacements'] ) ) {
			foreach ( $data['replacements'] as $k => $v ) {
				$key         = '{{' . trim( (string) $k, '{}' ) . '}}';
				$rep[ $key ] = is_scalar( $v ) ? (string) $v : '';
			}
		}
		$recipient = array( 'user_id' => $user_id, 'phone' => $phone, 'email' => $email, 'name' => $name );
		$this->fire( 'custom_' . $slug, $rep, $recipient, 'custom-' . $slug . '-' . md5( wp_json_encode( $data ) ) );
	}

	/**
	 * New user registered.
	 *
	 * @param int $user_id User ID.
	 */
	public function on_user_register( $user_id ) {
		$user = get_userdata( $user_id );
		if ( ! $user ) {
			return;
		}
		$this->fire( 'user_register', $this->user_replacements( $user ), $this->user_recipient( $user ), 'user-' . $user_id );
	}

	/**
	 * User logged in.
	 *
	 * @param string  $login User login.
	 * @param WP_User $user  User object.
	 */
	public function on_wp_login( $login, $user ) {
		if ( ! ( $user instanceof WP_User ) ) {
			$user = get_user_by( 'login', $login );
		}
		if ( ! $user ) {
			return;
		}
		$this->fire( 'wp_login', $this->user_replacements( $user ), $this->user_recipient( $user ), 'login-' . $user->ID );
	}

	/**
	 * WooCommerce order status changed.
	 *
	 * @param int      $order_id Order ID.
	 * @param string   $old      Old status.
	 * @param string   $new      New status.
	 * @param WC_Order $order    Order object.
	 */
	public function on_order_status_changed( $order_id, $old, $new, $order ) {
		if ( ! ( $order instanceof WC_Order ) ) {
			$order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null;
		}
		if ( ! $order ) {
			return;
		}
		// Skip child / sub orders to avoid duplicates.
		if ( method_exists( $order, 'get_parent_id' ) && $order->get_parent_id() > 0 ) {
			return;
		}
		$this->fire( 'wc_status_' . $new, $this->order_replacements( $order, $old, $new ), $this->order_recipient( $order ), 'order-' . $order_id . '-' . $new, $this->order_context( $order ) );
	}

	/**
	 * New WooCommerce order placed (at checkout).
	 *
	 * @param int $order_id Order ID.
	 */
	public function on_new_order( $order_id ) {
		$order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null;
		if ( ! $order || ( method_exists( $order, 'get_parent_id' ) && $order->get_parent_id() > 0 ) ) {
			return;
		}
		$this->fire( 'wc_new_order', $this->order_replacements( $order, '', $order->get_status() ), $this->order_recipient( $order ), 'neworder-' . $order_id, $this->order_context( $order ) );
	}

	/**
	 * Payment received for an order.
	 *
	 * @param int $order_id Order ID.
	 */
	public function on_payment_complete( $order_id ) {
		$order = function_exists( 'wc_get_order' ) ? wc_get_order( $order_id ) : null;
		if ( ! $order ) {
			return;
		}
		$this->fire( 'wc_payment_complete', $this->order_replacements( $order, '', $order->get_status() ), $this->order_recipient( $order ), 'paid-' . $order_id, $this->order_context( $order ) );
	}

	/**
	 * User completed a password reset.
	 *
	 * @param WP_User $user User.
	 */
	public function on_password_reset( $user ) {
		if ( ! ( $user instanceof WP_User ) ) {
			return;
		}
		$this->fire( 'user_password_reset', $this->user_replacements( $user ), $this->user_recipient( $user ), 'pwreset-' . $user->ID . '-' . time() );
	}

	/**
	 * WooCommerce subscription status changed.
	 *
	 * @param object $subscription Subscription (extends WC_Order).
	 * @param string $new          New status.
	 * @param string $old          Old status.
	 */
	public function on_subscription_status( $subscription, $new, $old ) {
		if ( ! is_object( $subscription ) || ! method_exists( $subscription, 'get_id' ) ) {
			return;
		}
		$rep                          = $this->order_replacements( $subscription, $old, $new );
		$rep['{{subscription_id}}']   = $subscription->get_id();
		$this->fire( 'wc_sub_' . $new, $rep, $this->order_recipient( $subscription ), 'sub-' . $subscription->get_id() . '-' . $new, $this->order_context( $subscription ) );
	}

	/**
	 * WooCommerce subscription renewal payment completed.
	 *
	 * @param WC_Subscription $subscription Subscription object.
	 * @param WC_Order        $renewal_order Renewal order object.
	 */
	public function on_subscription_renewal( $subscription, $renewal_order ) {
		if ( ! is_object( $subscription ) || ! method_exists( $subscription, 'get_id' ) ) {
			return;
		}
		$rep = $this->order_replacements( $renewal_order, '', 'active' );
		$rep['{{subscription_id}}']    = $subscription->get_id();
		$rep['{{subscription_total}}'] = function_exists( 'wc_price' ) ? wc_price( $subscription->get_total() ) : $subscription->get_total();
		$rep['{{renewal_order_id}}']   = is_object( $renewal_order ) ? $renewal_order->get_id() : '';
		$this->fire( 'wc_sub_renewal_payment_complete', $rep, $this->order_recipient( $subscription ), 'sub-renewal-' . $subscription->get_id(), $this->order_context( $renewal_order ) );
	}

	/**
	 * WooCommerce customer note added.
	 *
	 * @param array $note_data { order_id, customer_note }.
	 */
	public function on_customer_note( $note_data ) {
		if ( empty( $note_data['order_id'] ) || empty( $note_data['customer_note'] ) ) {
			return;
		}
		$order = function_exists( 'wc_get_order' ) ? wc_get_order( $note_data['order_id'] ) : null;
		if ( ! $order ) {
			return;
		}
		$rep                 = $this->order_replacements( $order, '', $order->get_status() );
		$rep['{{cust_note}}'] = wp_strip_all_tags( $note_data['customer_note'] );
		$this->fire( 'wc_order_note_added', $rep, $this->order_recipient( $order ), 'note-' . $note_data['order_id'] . '-' . md5( $note_data['customer_note'] ), $this->order_context( $order ) );
	}

	/* ------------------------------------------------------------------ */
	/* Rule matching + dispatch                                           */
	/* ------------------------------------------------------------------ */

	/**
	 * Find matching enabled rules for a trigger and queue/send them.
	 *
	 * @param string $trigger_key  Trigger key.
	 * @param array  $replacements Placeholder map.
	 * @param array  $recipient    { user_id, phone, email, name }.
	 * @param string $dedupe       Per-request dedupe key.
	 * @param array  $context      Optional object context for advanced filters.
	 */
	private function fire( $trigger_key, $replacements, $recipient, $dedupe = '', $context = array() ) {
		$user_locale = $this->get_recipient_locale( $recipient );
		foreach ( $this->get_rules() as $rule ) {
			if ( empty( $rule['enabled'] ) || $rule['trigger_key'] !== $trigger_key ) {
				continue;
			}
			// Multi-language: skip rules that don't match the recipient's locale.
			$rule_lang = $rule['lang'] ?? '';
			if ( '' !== $rule_lang && $rule_lang !== $user_locale ) {
				continue;
			}
			if ( ! $this->passes_filters( $rule, $context ) ) {
				continue;
			}
			$key = $rule['id'] . '|' . $dedupe;
			if ( isset( $this->done[ $key ] ) ) {
				continue;
			}
			$this->done[ $key ] = true;

			// Per-rule cooldown: don't re-notify the same customer within the window.
			$cooldown = max( 0, (int) ( $rule['cooldown_hours'] ?? 0 ) );
			if ( $cooldown > 0 ) {
				$rkey = ! empty( $recipient['user_id'] ) ? 'u' . $recipient['user_id'] : ( $recipient['phone'] ?? '' );
				if ( '' === $rkey ) {
					$rkey = $recipient['email'] ?? '';
				}
				if ( '' !== $rkey ) {
					$tk = 'mnotif_cd_' . $rule['id'] . '_' . md5( $rkey );
					if ( get_transient( $tk ) ) {
						continue;
					}
					set_transient( $tk, 1, $cooldown * HOUR_IN_SECONDS );
				}
			}

			$delay = $this->delay_to_minutes( $rule );
			$jobs  = $this->build_jobs( $rule, $replacements, $recipient, $context );
			foreach ( $jobs as $job ) {
				if ( $delay > 0 ) {
					wp_schedule_single_event( time() + $delay * MINUTE_IN_SECONDS, 'mnotif_send_scheduled', array( $job ) );
				} else {
					$this->dispatch( $job );
				}
			}
		}
	}

	/**
	 * Public entry so companion modules (abandoned cart tracker, integrations)
	 * can run recipients through the full rule pipeline: locale matching,
	 * filters, dedupe, cooldown, delay scheduling and delivery.
	 *
	 * @param string $trigger_key  Trigger key (must exist in get_triggers()).
	 * @param array  $replacements Placeholder map.
	 * @param array  $recipient    { user_id, phone, email, name }.
	 * @param string $dedupe       Dedupe token for this event instance.
	 * @param array  $context      Filter/media context (product_ids, country, ac_cart_id …).
	 */
	public function fire_event( $trigger_key, $replacements, $recipient, $dedupe = '', $context = array() ) {
		$this->fire( $trigger_key, $replacements, $recipient, $dedupe, $context );
	}

	/**
	 * Whether any enabled rule exists for a trigger.
	 *
	 * @param string $trigger_key Trigger key.
	 * @return bool
	 */
	public function has_enabled_rules( $trigger_key ) {
		foreach ( $this->get_rules() as $rule ) {
			if ( ! empty( $rule['enabled'] ) && $rule['trigger_key'] === $trigger_key ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Effective delay of a rule in minutes (new value+unit model, legacy fallback).
	 *
	 * @param array $rule Rule.
	 * @return int
	 */
	private function delay_to_minutes( $rule ) {
		$value = (int) ( $rule['delay_value'] ?? 0 );
		$unit  = $rule['delay_unit'] ?? '';
		if ( $value > 0 && in_array( $unit, array( 'minutes', 'hours', 'days' ), true ) ) {
			$mult = array( 'minutes' => 1, 'hours' => 60, 'days' => 1440 );
			return $value * $mult[ $unit ];
		}
		return max( 0, (int) ( $rule['delay_minutes'] ?? 0 ) );
	}

	/**
	 * Sanitize per-language content overrides.
	 * Stored as [ lang(2) => { message, email_subject, admin_message, admin_subject } ].
	 *
	 * @param mixed $in Raw translations input.
	 * @return array
	 */
	private function sanitize_translations( $in ) {
		$out = array();
		if ( ! is_array( $in ) ) {
			return $out;
		}
		foreach ( $in as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$lang = substr( sanitize_key( $row['lang'] ?? '' ), 0, 2 );
			$msg  = isset( $row['message'] ) ? wp_kses_post( $row['message'] ) : '';
			if ( '' === $lang || '' === trim( wp_strip_all_tags( $msg ) ) ) {
				continue;
			}
			$out[ $lang ] = array(
				'message'       => $msg,
				'email_subject' => sanitize_text_field( $row['email_subject'] ?? '' ),
				'admin_message' => isset( $row['admin_message'] ) ? wp_kses_post( $row['admin_message'] ) : '',
				'admin_subject' => sanitize_text_field( $row['admin_subject'] ?? '' ),
			);
		}
		return $out;
	}

	/**
	 * Clean a rule's message variants.
	 *
	 * A rule keeps several wordings of the same message so WhatsApp does not see
	 * one identical text pushed to hundreds of numbers — that pattern is what
	 * gets a sender banned. The first variant is always the main message, so the
	 * two can never drift apart.
	 *
	 * @param mixed  $in   Raw variants.
	 * @param string $main The rule's main message.
	 * @return array<int,string>
	 */
	private function sanitize_variants( $in, $main ) {
		$out = array();
		// The shared list editor posts a marker alongside the rows so that
		// deleting every wording still reaches us as "an empty list" rather than
		// as "field absent". It is not a wording.
		if ( is_array( $in ) ) {
			unset( $in['__present'] );
		}
		foreach ( (array) $in as $v ) {
			if ( ! is_scalar( $v ) ) {
				continue;
			}
			$v = wp_kses_post( (string) $v );
			if ( '' === trim( wp_strip_all_tags( $v ) ) ) {
				continue;
			}
			$out[] = $v;
		}
		array_unshift( $out, $main );

		// Two identical wordings defeat the whole point, so collapse duplicates.
		$seen = array();
		$uniq = array();
		foreach ( $out as $v ) {
			$key = md5( trim( $v ) );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$uniq[]       = $v;
		}
		return array_slice( $uniq, 0, 20 );
	}

	/**
	 * All wordings a rule can send, main message first.
	 *
	 * @param array $rule Rule.
	 * @return array<int,string>
	 */
	public function rule_variants( $rule ) {
		$v = isset( $rule['variants'] ) && is_array( $rule['variants'] ) ? array_values( array_filter( $rule['variants'] ) ) : array();
		if ( empty( $v ) ) {
			$v = array( (string) ( $rule['message'] ?? '' ) );
		}
		return $v;
	}

	/**
	 * Choose the wording for this particular send.
	 *
	 * rotate  — walk the list in order, one step per send, so a run of messages
	 *           never repeats until every wording has been used.
	 * random  — pick one at random, but never the one used last time.
	 * fixed   — always the wording the admin pinned.
	 *
	 * The rotation cursor is per rule and per site, kept in its own option so a
	 * rule edit cannot reset it.
	 *
	 * @param array $rule Rule.
	 * @return string
	 */
	public function pick_variant( $rule ) {
		$variants = $this->rule_variants( $rule );
		$count    = count( $variants );
		if ( $count < 2 ) {
			return (string) $variants[0];
		}

		$mode = (string) ( $rule['variant_mode'] ?? 'rotate' );
		$id   = (string) ( $rule['id'] ?? '' );

		if ( 'fixed' === $mode ) {
			$i = (int) ( $rule['variant_pick'] ?? 0 );
			return (string) ( $variants[ $i ] ?? $variants[0] );
		}

		$cursors = get_option( self::VARIANT_CURSOR_OPTION, array() );
		$cursors = is_array( $cursors ) ? $cursors : array();
		$last    = isset( $cursors[ $id ] ) ? (int) $cursors[ $id ] : -1;

		if ( 'random' === $mode ) {
			$i = wp_rand( 0, $count - 1 );
			if ( $i === $last ) {
				$i = ( $i + 1 ) % $count;
			}
		} else {
			$i = ( $last + 1 ) % $count;
		}

		$cursors[ $id ] = $i;
		update_option( self::VARIANT_CURSOR_OPTION, $cursors, false );

		return (string) $variants[ $i ];
	}

	/**
	 * Swap in the recipient-locale translation of a rule's content when one exists.
	 *
	 * @param array $rule      Rule.
	 * @param array $recipient Recipient.
	 * @return array Localised rule.
	 */
	private function localize_rule( $rule, $recipient ) {
		if ( empty( $rule['translations'] ) || ! is_array( $rule['translations'] ) ) {
			return $rule;
		}
		$locale = $this->get_recipient_locale( $recipient );
		$tr     = $rule['translations'][ $locale ] ?? null;
		if ( ! $tr ) {
			return $rule;
		}
		foreach ( array( 'message', 'email_subject', 'admin_message', 'admin_subject' ) as $k ) {
			if ( ! empty( $tr[ $k ] ) ) {
				$rule[ $k ] = $tr[ $k ];
			}
		}
		// A translation carries its own wording, so the main-language variants no
		// longer apply — otherwise a French recipient would get Arabic copy the
		// moment the rotation cursor moved past the first entry.
		if ( ! empty( $tr['message'] ) ) {
			$rule['variants'] = ! empty( $tr['variants'] ) && is_array( $tr['variants'] )
				? array_values( $tr['variants'] )
				: array( $tr['message'] );
		}
		return $rule;
	}

	/**
	 * Build an order context for advanced filters.
	 *
	 * @param WC_Order $order Order.
	 * @return array{product_ids:int[],category_ids:int[],country:string,payment_method:string}
	 */
	private function order_context( $order ) {
		$pids = array();
		$cids = array();
		if ( method_exists( $order, 'get_items' ) ) {
			foreach ( $order->get_items() as $item ) {
				$pid = (int) $item->get_product_id();
				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'        => $order->get_billing_country(),
			'payment_method' => $order->get_payment_method(),
		);
	}

	/**
	 * Determine the locale of a notification recipient.
	 *
	 * @param array $recipient { user_id, phone, email, name }.
	 * @return string Two-letter locale code (e.g. 'ar', 'en') or '' for default.
	 */
	private function get_recipient_locale( $recipient ) {
		$user_id = (int) ( $recipient['user_id'] ?? 0 );
		if ( $user_id ) {
			$locale = get_user_locale( $user_id );
			if ( $locale ) {
				return substr( $locale, 0, 2 );
			}
		}
		return substr( get_locale(), 0, 2 );
	}

	/**
	 * Split a CSV / space-separated string into a clean array.
	 *
	 * @param string $val   Raw value.
	 * @param bool   $is_int Cast to integers.
	 * @return array
	 */
	private function csv( $val, $is_int = false ) {
		if ( is_array( $val ) ) {
			$arr = $val;
		} else {
			$arr = preg_split( '/[\s,]+/', (string) $val );
		}
		$arr = array_filter( array_map( 'trim', (array) $arr ), 'strlen' );
		if ( $is_int ) {
			$arr = array_filter( array_map( 'intval', $arr ) );
		}
		return array_values( $arr );
	}

	/**
	 * Normalise a submitted multiselect/CSV value into a clean CSV string.
	 *
	 * @param mixed $val Array or string.
	 * @return string
	 */
	private function to_csv( $val ) {
		if ( is_array( $val ) ) {
			$arr = array_map( 'sanitize_text_field', $val );
		} else {
			$arr = preg_split( '/[\s,]+/', (string) $val );
			$arr = array_map( 'sanitize_text_field', (array) $arr );
		}
		$arr = array_filter( array_map( 'trim', $arr ), 'strlen' );
		return implode( ',', array_values( array_unique( $arr ) ) );
	}

	/**
	 * Evaluate a rule's advanced filters against the object context.
	 * Filters only apply when both the filter is enabled AND the context carries
	 * the relevant data — so non-order triggers always pass.
	 *
	 * @param array $rule    Rule.
	 * @param array $context Object context.
	 * @return bool
	 */
	private function passes_filters( $rule, $context ) {
		if ( ! empty( $rule['filter_country'] ) && ! empty( $context['country'] ) ) {
			$wl = $this->csv( $rule['countries_whitelist'] ?? '' );
			$bl = $this->csv( $rule['countries_blocklist'] ?? '' );
			if ( $wl && ! in_array( $context['country'], $wl, true ) ) {
				return false;
			}
			if ( $bl && in_array( $context['country'], $bl, true ) ) {
				return false;
			}
		}
		if ( ! empty( $rule['filter_product'] ) && ! empty( $context['product_ids'] ) ) {
			$ids = $this->csv( $rule['product_ids'] ?? '', true );
			if ( $ids && ! array_intersect( $context['product_ids'], $ids ) ) {
				return false;
			}
		}
		if ( ! empty( $rule['filter_category'] ) && ! empty( $context['category_ids'] ) ) {
			$ids = $this->csv( $rule['category_ids'] ?? '', true );
			if ( $ids && ! array_intersect( $context['category_ids'], $ids ) ) {
				return false;
			}
		}
		if ( ! empty( $rule['filter_payment'] ) && ! empty( $context['payment_method'] ) ) {
			$g = $this->csv( $rule['payment_gateways'] ?? '' );
			if ( $g && ! in_array( $context['payment_method'], $g, true ) ) {
				return false;
			}
		}
		return true;
	}

	/**
	 * Expand a rule + recipient into concrete send jobs.
	 *
	 * @param array $rule         Rule.
	 * @param array $replacements Placeholder map.
	 * @param array $recipient    Customer recipient.
	 * @return array<int,array>
	 */
	private function build_jobs( $rule, $replacements, $recipient, $context = array() ) {
		$rule     = $this->localize_rule( $rule, $recipient );
		$channels = $this->rule_channels( $rule );
		$modes    = $this->rule_recipients( $rule );
		$name     = $rule['name'];
		$jobs     = array();

		// Customer-facing content. The wording is chosen per send, so a rule with
		// several variants spreads them across recipients instead of repeating one.
		$cust_msg = $this->parse( $this->pick_variant( $rule ), $replacements );
		$cust_sub = $this->parse( $rule['email_subject'] ?? '', $replacements );
		// Admin content (falls back to the customer content when not set separately).
		$admin_msg = ! empty( $rule['admin_message'] ) ? $this->parse( $rule['admin_message'], $replacements ) : $cust_msg;
		$admin_sub = ! empty( $rule['admin_subject'] ) ? $this->parse( $rule['admin_subject'], $replacements ) : $cust_sub;

		$media = $this->resolve_media( $rule, $context );

		// Concrete targets, tagged with their mode so we pick the right content.
		$targets = array();
		if ( in_array( 'customer', $modes, true ) ) {
			$targets[] = array( 'mode' => 'customer', 'phone' => $recipient['phone'] ?? '', 'email' => $recipient['email'] ?? '' );
		}
		if ( in_array( 'admin', $modes, true ) ) {
			foreach ( $this->admin_recipients( $rule ) as $r ) {
				$targets[] = array( 'mode' => 'admin', 'phone' => $r['phone'], 'email' => $r['email'] );
			}
		}

		foreach ( $channels as $channel ) {
			foreach ( $targets as $t ) {
				$to = ( 'email' === $channel ) ? ( $t['email'] ?? '' ) : ( $t['phone'] ?? '' );
				if ( ! $to ) {
					continue;
				}
				$message = ( 'admin' === $t['mode'] ) ? $admin_msg : $cust_msg;
				$subject = ( 'admin' === $t['mode'] ) ? $admin_sub : $cust_sub;
				$job     = compact( 'channel', 'to', 'message', 'subject', 'media' ) + array( 'rule' => $name, 'rule_id' => $rule['id'] ?? '' );
				if ( 'email' === $channel && ! empty( $rule['email_template'] ) ) {
					$job['template_id'] = (int) $rule['email_template'];
					// Resolved items list for the template's {{product}} block.
					$job['items_text']  = isset( $replacements['{{product}}'] ) ? (string) $replacements['{{product}}'] : '';
				}
				// Cart-journey jobs carry the cart id so delayed sends can be
				// voided if the customer completes the purchase in the meantime.
				if ( ! empty( $context['ac_cart_id'] ) ) {
					$job['guard_cart'] = (int) $context['ac_cart_id'];
				}
				$jobs[] = $job;
			}
		}
		return $jobs;
	}

	/**
	 * Resolve the media URL for a rule based on its media mode.
	 * Modes: none | custom (media_url) | featured (context featured image) |
	 * product (first product image). Legacy rules without media_mode keep the
	 * old behaviour (media_url + optional send_product_image override).
	 *
	 * @param array $rule    Rule.
	 * @param array $context Context.
	 * @return string
	 */
	private function resolve_media( $rule, $context ) {
		$mode = $rule['media_mode'] ?? '';
		if ( '' === $mode ) {
			$media = $rule['media_url'] ?? '';
			if ( ! empty( $rule['send_product_image'] ) && ! empty( $context['product_ids'] ) ) {
				$img = $this->first_product_image( $context['product_ids'] );
				if ( $img ) {
					$media = $img;
				}
			}
			return $media;
		}
		switch ( $mode ) {
			case 'none':
				return '';
			case 'product':
				$img = ! empty( $context['product_ids'] ) ? $this->first_product_image( $context['product_ids'] ) : '';
				return $img ? $img : (string) ( $rule['media_url'] ?? '' );
			case 'featured':
				if ( ! empty( $context['featured_image'] ) ) {
					return (string) $context['featured_image'];
				}
				$img = ! empty( $context['product_ids'] ) ? $this->first_product_image( $context['product_ids'] ) : '';
				return $img ? $img : (string) ( $rule['media_url'] ?? '' );
			case 'custom':
			default:
				return (string) ( $rule['media_url'] ?? '' );
		}
	}

	/**
	 * Resolve the image URL of the first product in a list.
	 *
	 * @param array $product_ids Product IDs.
	 * @return string
	 */
	private function first_product_image( $product_ids ) {
		foreach ( (array) $product_ids as $pid ) {
			$img = get_the_post_thumbnail_url( (int) $pid, 'large' );
			if ( $img ) {
				return $img;
			}
		}
		return '';
	}

	/**
	 * Resolve a rule's channels (new array model with legacy single-channel fallback).
	 *
	 * @param array $rule Rule.
	 * @return string[]
	 */
	private function rule_channels( $rule ) {
		$valid = array( 'whatsapp', 'sms', 'email' );
		if ( ! empty( $rule['channels'] ) && is_array( $rule['channels'] ) ) {
			$ch = array_values( array_intersect( $valid, $rule['channels'] ) );
			if ( $ch ) {
				return $ch;
			}
		}
		$single = in_array( $rule['channel'] ?? '', $valid, true ) ? $rule['channel'] : 'whatsapp';
		return array( $single );
	}

	/**
	 * Resolve a rule's recipient modes (new array model with legacy fallback).
	 *
	 * @param array $rule Rule.
	 * @return string[] Subset of customer|admin.
	 */
	private function rule_recipients( $rule ) {
		$valid = array( 'customer', 'admin' );
		if ( ! empty( $rule['recipients'] ) && is_array( $rule['recipients'] ) ) {
			$r = array_values( array_intersect( $valid, $rule['recipients'] ) );
			if ( $r ) {
				return $r;
			}
		}
		$single = ( ( $rule['send_to'] ?? 'customer' ) === 'admin' ) ? 'admin' : 'customer';
		return array( $single );
	}

	/**
	 * Dispatch a scheduled job (cron callback).
	 *
	 * @param array $job Job payload.
	 */
	public function dispatch_scheduled( $job ) {
		if ( ! is_array( $job ) ) {
			return;
		}
		// Cart-journey guard: drop the reminder when the cart was purchased
		// (or recovered) between scheduling and delivery.
		if ( ! empty( $job['guard_cart'] ) && class_exists( 'Mersal_Notif_Abandoned_Cart' )
			&& Mersal_Notif_Abandoned_Cart::cart_is_closed( (int) $job['guard_cart'] ) ) {
			return;
		}
		$this->dispatch( $job );
	}

	/**
	 * Deliver one job through the Mersal account.
	 *
	 * @param array $job { channel, to, message, subject, media, rule }.
	 */
	private function dispatch( $job ) {
		$channel = $job['channel'];
		$to      = $job['to'];
		$message = $job['message'];
		$media   = isset( $job['media'] ) ? (string) $job['media'] : '';
		$ok      = false;

		// Blacklist: never contact blocked numbers / emails.
		if ( $this->is_blocked_recipient( $channel, $to ) ) {
			$this->log( $channel, $to, 'blocked', $job['rule'] ?? '' );
			return;
		}

		switch ( $channel ) {
			case 'email':
				$subject = $job['subject'] ? $job['subject'] : get_bloginfo( 'name' );
				$headers = array( 'Content-Type: text/html; charset=UTF-8' );
				$html    = wpautop( $message );
				if ( $media ) {
					$html = '<p><img src="' . esc_url( $media ) . '" alt="" style="max-width:100%;height:auto;border-radius:8px;"></p>' . $html;
				}
				// Designed HTML template: the {{content}} region receives the message.
				$tpl_id = (int) ( $job['template_id'] ?? 0 );
				if ( $tpl_id && class_exists( 'Mersal_Notif_Email_Templates' ) ) {
					$tpl = Mersal_Notif_Email_Templates::get( $tpl_id );
					if ( $tpl && ! empty( $tpl['content_html'] ) ) {
						$inner = (string) $tpl['content_html'];
						$inner = ( false !== strpos( $inner, '{{content}}' ) )
							? str_replace( '{{content}}', $html, $inner )
							: $inner . $html;
						$html  = Mersal_Notif_Email_Templates::parse( $inner, array( '{{product}}' => (string) ( $job['items_text'] ?? '' ) ) );
					}
				}
				if ( class_exists( 'Mersal_Notif_Email_Templates' ) ) {
					$html = Mersal_Notif_Email_Templates::wrap( $html );
				}
				$gateway = apply_filters( 'mnotif_send_email', null, $to, $subject, $html, $headers );
				$ok      = ( is_array( $gateway ) && isset( $gateway['ok'] ) ) ? (bool) $gateway['ok'] : (bool) wp_mail( $to, $subject, $html, $headers );
				break;
			case 'sms':
				$res = apply_filters( 'mnotif_send_sms', null, $to, $message, '' );
				$ok  = is_array( $res ) && ! empty( $res['ok'] );
				break;
			case 'push':
				$res = apply_filters( 'mnotif_send_push', null, $to, $message, '' );
				$ok  = is_array( $res ) && ! empty( $res['ok'] );
				break;
			case 'whatsapp':
			default:
				// Gateway sends text; append the media URL so WhatsApp shows a preview.
				$wa  = $media ? ( $message . "\n" . $media ) : $message;
				$res = apply_filters( 'mnotif_send_whatsapp', null, $to, $wa, '' );
				$ok  = is_array( $res ) && ! empty( $res['ok'] );
		}

		$this->log( $channel, $to, $ok ? 'sent' : 'failed', $job['rule'] ?? '' );
		if ( $ok ) {
			$this->bump_stats();
			if ( ! empty( $job['rule_id'] ) ) {
				$counts = get_option( 'mnotif_rule_sent', array() );
				$counts = is_array( $counts ) ? $counts : array();
				$key    = $job['rule_id'];
				$counts[ $key ] = (int) ( $counts[ $key ] ?? 0 ) + 1;
				update_option( 'mnotif_rule_sent', $counts, false );
			}
		}
	}

	/**
	 * Whether a recipient is on the notification blacklist.
	 * Emails support full addresses and `@domain.com` wildcards; numbers are
	 * compared normalized (suffix match tolerates missing country codes).
	 *
	 * @param string $channel Channel.
	 * @param string $to      Recipient.
	 * @return bool
	 */
	private function is_blocked_recipient( $channel, $to ) {
		if ( 'email' === $channel ) {
			$list = (string) Mersal_Notif_Settings::get( 'notif_blocked_emails', '' );
			if ( '' === trim( $list ) ) {
				return false;
			}
			$to = strtolower( trim( $to ) );
			foreach ( preg_split( '/[\r\n]+/', $list ) as $line ) {
				$line = strtolower( trim( $line ) );
				if ( '' === $line ) {
					continue;
				}
				if ( $line === $to || ( '@' === $line[0] && substr( $to, -strlen( $line ) ) === $line ) ) {
					return true;
				}
			}
			return false;
		}
		$list = (string) Mersal_Notif_Settings::get( 'notif_blocked_numbers', '' );
		if ( '' === trim( $list ) ) {
			return false;
		}
		$to_n = preg_replace( '/\D/', '', Mersal_Notif_Security::normalize_phone( $to ) );
		if ( '' === $to_n ) {
			return false;
		}
		foreach ( preg_split( '/[\r\n]+/', $list ) as $line ) {
			$n = preg_replace( '/\D/', '', (string) $line );
			if ( strlen( $n ) >= 8 && ( substr( $to_n, -strlen( $n ) ) === $n || substr( $n, -strlen( $to_n ) ) === $to_n ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Sent counts per rule name (for the rules list analytics).
	 *
	 * @return array<string,int>
	 */
	public function get_rule_sent_counts() {
		$c = get_option( 'mnotif_rule_sent', array() );
		return is_array( $c ) ? $c : array();
	}

	/* ------------------------------------------------------------------ */
	/* Recipients + placeholders                                          */
	/* ------------------------------------------------------------------ */

	/**
	 * Best-effort phone for a user (normalised).
	 *
	 * @param int $uid User ID.
	 * @return string
	 */
	private function find_phone( $uid ) {
		$keys = apply_filters( 'mnotif_phone_meta_keys', array( 'mnotif_phone', 'motp_phone', 'mersal_user_phone', 'billing_phone', 'phone' ) );
		foreach ( $keys as $k ) {
			$v = get_user_meta( $uid, $k, true );
			if ( $v ) {
				return Mersal_Notif_Security::normalize_phone( $v );
			}
		}
		return '';
	}

	/**
	 * Customer recipient from a WP user.
	 *
	 * @param WP_User $user User.
	 * @return array
	 */
	private function user_recipient( $user ) {
		return array(
			'user_id' => $user->ID,
			'phone'   => $this->find_phone( $user->ID ),
			'email'   => $user->user_email,
			'name'    => $user->display_name,
		);
	}

	/**
	 * Customer recipient from a WooCommerce order.
	 *
	 * @param WC_Order $order Order.
	 * @return array
	 */
	private function order_recipient( $order ) {
		$uid   = $order->get_customer_id();
		$phone = $order->get_billing_phone();
		if ( ! $phone && $uid ) {
			$phone = $this->find_phone( $uid );
		}
		$phone = $phone ? Mersal_Notif_Security::normalize_phone( $phone ) : '';
		return array(
			'user_id' => $uid,
			'phone'   => $phone,
			'email'   => $order->get_billing_email(),
			'name'    => $order->get_formatted_billing_full_name(),
		);
	}

	/**
	 * Resolve admin recipients for a rule (CSV of user IDs, else all admins).
	 *
	 * @param array $rule Rule.
	 * @return array<int,array{phone:string,email:string}>
	 */
	private function admin_recipients( $rule ) {
		$ids = array();
		if ( ! empty( $rule['admin_users'] ) ) {
			foreach ( preg_split( '/[\s,]+/', (string) $rule['admin_users'] ) as $id ) {
				$id = (int) $id;
				if ( $id > 0 ) {
					$ids[] = $id;
				}
			}
		}
		if ( empty( $ids ) ) {
			$admins = get_users( array( 'role' => 'administrator', 'fields' => 'ID' ) );
			$ids    = array_map( 'intval', (array) $admins );
		}
		$out = array();
		foreach ( array_unique( $ids ) as $id ) {
			$u = get_userdata( $id );
			if ( $u ) {
				$out[] = array( 'phone' => $this->find_phone( $id ), 'email' => $u->user_email );
			}
		}
		return $out;
	}

	/**
	 * User placeholder map.
	 *
	 * @param WP_User $user User.
	 * @return array
	 */
	private function user_replacements( $user ) {
		$first = get_user_meta( $user->ID, 'first_name', true );
		$last  = get_user_meta( $user->ID, 'last_name', true );
		return array(
			'{{wp-display-name}}' => $user->display_name,
			'{{wp-first-name}}'   => $first,
			'{{wp-last-name}}'    => $last,
			'{{wp-username}}'     => $user->user_login,
			'{{wp-email}}'        => $user->user_email,
			'{{wp-nickname}}'     => $user->display_name,
			'{{user_email}}'      => $user->user_email,
			'{{user_name}}'       => $user->display_name,
			'{{display_name}}'    => $user->display_name,
			'{{name}}'            => trim( $first . ' ' . $last ) ?: $user->display_name,
			'{{first_name}}'      => $first,
			'{{last_name}}'       => $last,
		);
	}

	/**
	 * Order placeholder map.
	 *
	 * @param WC_Order $order Order.
	 * @param string   $old   Old status.
	 * @param string   $new   New status.
	 * @return array
	 */
	private function order_replacements( $order, $old, $new ) {
		$statuses  = function_exists( 'wc_get_order_statuses' ) ? wc_get_order_statuses() : array();
		$new_label = $statuses[ 'wc-' . $new ] ?? $new;
		$old_label = $statuses[ 'wc-' . $old ] ?? $old;
		$country   = '';
		if ( function_exists( 'WC' ) && $order->get_billing_country() ) {
			$countries = WC()->countries->get_countries();
			$country   = $countries[ $order->get_billing_country() ] ?? $order->get_billing_country();
		}
		return array(
			'{{order_id}}'               => $order->get_id(),
			'{{wc-order}}'               => $order->get_order_number(),
			'{{order_date}}'             => $order->get_date_created() ? $order->get_date_created()->date_i18n( get_option( 'date_format' ) ) : '',
			'{{status}}'                 => $new_label,
			'{{order_status_label}}'     => $new_label,
			'{{old_order_status_label}}' => $old_label,
			'{{order_total}}'            => html_entity_decode( wp_strip_all_tags( $order->get_formatted_order_total() ), ENT_QUOTES, 'UTF-8' ),
			'{{order_link}}'             => $order->get_checkout_order_received_url(),
			'{{admin_order_link}}'       => admin_url( 'post.php?post=' . $order->get_id() . '&action=edit' ),
			'{{payment_method_title}}'   => $order->get_payment_method_title(),
			'{{customer_name}}'          => $order->get_formatted_billing_full_name(),
			'{{billing_first_name}}'     => $order->get_billing_first_name(),
			'{{billing_last_name}}'      => $order->get_billing_last_name(),
			'{{billing_phone}}'          => $order->get_billing_phone(),
			'{{billing_email}}'          => $order->get_billing_email(),
			'{{billing_city}}'           => $order->get_billing_city(),
			'{{billing_country}}'        => $country,
			'{{cust_note}}'              => $order->get_customer_note(),
			'{{user_email}}'             => $order->get_billing_email(),
		) + $this->order_item_placeholders( $order );
	}

	/**
	 * Rich product / line-item placeholders for an order.
	 *
	 * @param WC_Order $order Order.
	 * @return array<string,string>
	 */
	private function order_item_placeholders( $order ) {
		$names = array();
		$links = array();
		$skus  = array();
		$lines = array();
		$qty   = 0;
		$i     = 0;
		$sym   = function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( get_woocommerce_currency_symbol( $order->get_currency() ) ) : '';
		foreach ( $order->get_items() as $item ) {
			$p = $item->get_product();
			if ( ! $p ) {
				continue;
			}
			++$i;
			$q       = (int) $item->get_quantity();
			$qty    += $q;
			$names[] = $p->get_name();
			$link    = get_permalink( $p->get_id() );
			if ( $link ) {
				$links[] = $link;
			}
			$sku = $p->get_sku();
			if ( $sku ) {
				$skus[] = $sku;
			}
			$lines[] = $i . '. ' . $p->get_name() . ' × ' . $q . ' = ' . wc_format_decimal( $item->get_total(), wc_get_price_decimals() ) . ' ' . $sym;
		}
		$first_two = array_slice( $names, 0, 2 );
		$summary   = implode( ', ', $first_two );
		if ( count( $names ) > 2 ) {
			/* translators: %d: number of additional products */
			$summary .= ' ' . sprintf( __( 'and %d more', 'mersal-notifications' ), count( $names ) - 2 );
		}
		return array(
			'{{product}}'               => implode( "\n", $lines ),
			'{{wc-order-items-br}}'     => implode( "\n", $lines ),
			'{{wc-product-names}}'      => implode( ', ', $names ),
			'{{wc-product-names-br}}'   => implode( "\n", $names ),
			'{{wc-product-name-count}}' => $summary,
			'{{wc-product-link}}'       => implode( ', ', $links ),
			'{{wc-sku}}'                => implode( ', ', $skus ),
			'{{wc-total-products}}'     => (string) count( $names ),
			'{{wc-total-items}}'        => (string) $qty,
			'{{order_subtotal}}'        => html_entity_decode( wp_strip_all_tags( wc_price( $order->get_subtotal(), array( 'currency' => $order->get_currency() ) ) ) ),
			'{{order_tax}}'             => html_entity_decode( wp_strip_all_tags( wc_price( $order->get_total_tax(), array( 'currency' => $order->get_currency() ) ) ) ),
			'{{order_shipping}}'        => html_entity_decode( wp_strip_all_tags( wc_price( $order->get_shipping_total(), array( 'currency' => $order->get_currency() ) ) ) ),
			'{{order_discount}}'        => html_entity_decode( wp_strip_all_tags( wc_price( $order->get_total_discount(), array( 'currency' => $order->get_currency() ) ) ) ),
			'{{shipping_method_title}}' => $order->get_shipping_method(),
			'{{transaction_id}}'        => $order->get_transaction_id(),
			'{{billing_address_1}}'     => $order->get_billing_address_1(),
			'{{billing_address_2}}'     => $order->get_billing_address_2(),
			'{{billing_postcode}}'      => $order->get_billing_postcode(),
			'{{billing_state}}'         => $order->get_billing_state(),
			'{{shipping_first_name}}'   => $order->get_shipping_first_name(),
			'{{shipping_last_name}}'    => $order->get_shipping_last_name(),
			'{{shipping_address_1}}'    => $order->get_shipping_address_1(),
			'{{shipping_address_2}}'    => $order->get_shipping_address_2(),
			'{{shipping_city}}'         => $order->get_shipping_city(),
			'{{shipping_state}}'        => $order->get_shipping_state(),
			'{{shipping_postcode}}'     => $order->get_shipping_postcode(),
			'{{shipping_country}}'      => $order->get_shipping_country(),
			'{{currency}}'              => $order->get_currency(),
			'{{_tracking_number}}'      => $order->get_meta( '_tracking_number' ),
			'{{_tracking_provider}}'    => $order->get_meta( '_tracking_provider' ),
			'{{_tracking_link}}'        => $order->get_meta( '_tracking_link' ),
			'{{_date_shipped}}'         => $order->get_meta( '_date_shipped' ),
			'{{subscription_id}}'       => $order->get_meta( '_subscription_id' ) ?? '',
			'{{subscription_total}}'    => function_exists( 'wc_price' ) && ! empty( $order->get_meta( '_subscription_id' ) ) ? wc_price( $order->get_total() ) : '',
			'{{next_payment_date}}'     => '',
		);
	}

	/**
	 * Replace placeholders + always-available site tokens.
	 *
	 * @param string $template     Template.
	 * @param array  $replacements Map.
	 * @return string
	 */
	public function parse( $template, $replacements = array() ) {
		$base = array(
			'{{site_name}}'         => get_bloginfo( 'name' ),
			'{{sitename}}'          => get_bloginfo( 'name' ),
			'{{site_url}}'          => home_url(),
			'{{siteurl}}'           => home_url(),
			'{{current_date_time}}' => wp_date( 'Y-m-d H:i' ),
			'{{current_date}}'      => wp_date( get_option( 'date_format' ) ),
			'{{current_time}}'      => wp_date( get_option( 'time_format' ) ),
			'{{currency}}'          => function_exists( 'get_woocommerce_currency' ) ? get_woocommerce_currency() : '',
			'{{tagline}}'           => get_bloginfo( 'description' ),
		);
		return strtr( (string) $template, array_merge( $base, is_array( $replacements ) ? $replacements : array() ) );
	}

	/**
	 * Placeholder reference for the admin UI.
	 *
	 * @return array<string,string>
	 */
	public function get_placeholders() {
		return array(
			'{{site_name}}'          => __( 'Site name', 'mersal-notifications' ),
			'{{customer_name}}'      => __( 'Customer name', 'mersal-notifications' ),
			'{{wp-display-name}}'    => __( 'User display name', 'mersal-notifications' ),
			'{{order_id}}'           => __( 'Order ID', 'mersal-notifications' ),
			'{{order_status_label}}' => __( 'Order status', 'mersal-notifications' ),
			'{{order_total}}'        => __( 'Order total', 'mersal-notifications' ),
			'{{order_subtotal}}'     => __( 'Order subtotal', 'mersal-notifications' ),
			'{{order_tax}}'          => __( 'Tax', 'mersal-notifications' ),
			'{{order_shipping}}'     => __( 'Shipping cost', 'mersal-notifications' ),
			'{{order_discount}}'     => __( 'Discount', 'mersal-notifications' ),
			'{{order_link}}'         => __( 'Order link', 'mersal-notifications' ),
			'{{product}}'            => __( 'Items list (name × qty = price)', 'mersal-notifications' ),
			'{{wc-product-names}}'   => __( 'Product names (comma)', 'mersal-notifications' ),
			'{{wc-product-names-br}}' => __( 'Product names (new line)', 'mersal-notifications' ),
			'{{wc-product-name-count}}' => __( 'Names summary (A, B and N more)', 'mersal-notifications' ),
			'{{wc-product-link}}'    => __( 'Product links', 'mersal-notifications' ),
			'{{wc-sku}}'             => __( 'Product SKUs', 'mersal-notifications' ),
			'{{wc-total-items}}'     => __( 'Total items qty', 'mersal-notifications' ),
			'{{wc-total-products}}'  => __( 'Unique products count', 'mersal-notifications' ),
			'{{payment_method_title}}' => __( 'Payment method', 'mersal-notifications' ),
			'{{shipping_method_title}}' => __( 'Shipping method', 'mersal-notifications' ),
			'{{transaction_id}}'     => __( 'Transaction ID', 'mersal-notifications' ),
			'{{billing_phone}}'      => __( 'Billing phone', 'mersal-notifications' ),
			'{{billing_email}}'      => __( 'Billing email', 'mersal-notifications' ),
			'{{billing_city}}'       => __( 'Billing city', 'mersal-notifications' ),
			'{{billing_country}}'    => __( 'Billing country', 'mersal-notifications' ),
			'{{cust_note}}'          => __( 'Customer note', 'mersal-notifications' ),
			'{{billing_address_1}}'  => __( 'Billing address line 1', 'mersal-notifications' ),
			'{{billing_address_2}}'  => __( 'Billing address line 2', 'mersal-notifications' ),
			'{{billing_postcode}}'   => __( 'Billing postcode', 'mersal-notifications' ),
			'{{billing_state}}'      => __( 'Billing state', 'mersal-notifications' ),
			'{{shipping_first_name}}' => __( 'Shipping first name', 'mersal-notifications' ),
			'{{shipping_last_name}}' => __( 'Shipping last name', 'mersal-notifications' ),
			'{{shipping_address_1}}' => __( 'Shipping address line 1', 'mersal-notifications' ),
			'{{shipping_address_2}}' => __( 'Shipping address line 2', 'mersal-notifications' ),
			'{{shipping_city}}'      => __( 'Shipping city', 'mersal-notifications' ),
			'{{shipping_state}}'     => __( 'Shipping state', 'mersal-notifications' ),
			'{{shipping_postcode}}'  => __( 'Shipping postcode', 'mersal-notifications' ),
			'{{shipping_country}}'   => __( 'Shipping country', 'mersal-notifications' ),
			'{{currency}}'           => __( 'Currency code', 'mersal-notifications' ),
			'{{_tracking_number}}'   => __( 'Tracking number', 'mersal-notifications' ),
			'{{_tracking_provider}}' => __( 'Tracking provider', 'mersal-notifications' ),
			'{{_tracking_link}}'     => __( 'Tracking link', 'mersal-notifications' ),
			'{{_date_shipped}}'      => __( 'Ship date', 'mersal-notifications' ),
			'{{subscription_id}}'    => __( 'Subscription ID', 'mersal-notifications' ),
			'{{subscription_total}}' => __( 'Subscription total', 'mersal-notifications' ),
			'{{next_payment_date}}'  => __( 'Next payment date', 'mersal-notifications' ),
			'{{current_date}}'       => __( 'Current date', 'mersal-notifications' ),
			'{{current_time}}'       => __( 'Current time', 'mersal-notifications' ),
			'{{tagline}}'            => __( 'Site tagline', 'mersal-notifications' ),
			'{{site_url}}'           => __( 'Site URL', 'mersal-notifications' ),
			'{{name}}'               => __( 'Contact full name', 'mersal-notifications' ),
			'{{first_name}}'         => __( 'Contact first name', 'mersal-notifications' ),
			'{{cart_total}}'         => __( 'Cart total (cart triggers)', 'mersal-notifications' ),
			'{{cart_items}}'         => __( 'Cart items list (cart triggers)', 'mersal-notifications' ),
			'{{coupon_code}}'        => __( 'Recovery coupon code (cart triggers)', 'mersal-notifications' ),
			'{{cart_recovery_url}}'  => __( 'Cart restore link (cart triggers)', 'mersal-notifications' ),
		);
	}

	/* ------------------------------------------------------------------ */
	/* Rule CRUD                                                          */
	/* ------------------------------------------------------------------ */

	/**
	 * All rules.
	 *
	 * @return array<int,array>
	 */
	public function get_rules() {
		$rules = get_option( self::RULES_OPTION, array() );
		return is_array( $rules ) ? $rules : array();
	}

	/**
	 * One rule by id.
	 *
	 * @param string $id Rule id.
	 * @return array|null
	 */
	public function get_rule( $id ) {
		foreach ( $this->get_rules() as $r ) {
			if ( $r['id'] === $id ) {
				return $r;
			}
		}
		return null;
	}

	/**
	 * Validate + sanitize raw rule input into a rule array (no persistence).
	 *
	 * @param array $in Raw input.
	 * @return array { ok:bool, rule?:array, message?:string }
	 */
	public function sanitize_rule( array $in ) {
		$id      = isset( $in['id'] ) ? sanitize_text_field( $in['id'] ) : '';
		$channel = isset( $in['channel'] ) ? sanitize_key( $in['channel'] ) : 'whatsapp';
		if ( ! in_array( $channel, array( 'whatsapp', 'sms', 'email' ), true ) ) {
			$channel = 'whatsapp';
		}
		$trigger = isset( $in['trigger_key'] ) ? sanitize_text_field( $in['trigger_key'] ) : '';
		if ( ! array_key_exists( $trigger, $this->get_triggers() ) ) {
			return array( 'ok' => false, 'message' => __( 'Please choose a valid trigger.', 'mersal-notifications' ) );
		}
		$message = isset( $in['message'] ) ? wp_kses_post( $in['message'] ) : '';
		if ( '' === trim( wp_strip_all_tags( $message ) ) ) {
			return array( 'ok' => false, 'message' => __( 'The message body is required.', 'mersal-notifications' ) );
		}

		// Multi-channel (new) with legacy single-channel fallback.
		$valid_ch = array( 'whatsapp', 'sms', 'email', 'push' );
		$channels = isset( $in['channels'] ) ? array_values( array_intersect( $valid_ch, array_map( 'sanitize_key', (array) $in['channels'] ) ) ) : array();
		if ( empty( $channels ) ) {
			$channels = array( $channel );
		}
		// Multi-recipient (new) with legacy send_to fallback.
		$valid_rc   = array( 'customer', 'admin' );
		$recipients = isset( $in['recipients'] ) ? array_values( array_intersect( $valid_rc, array_map( 'sanitize_key', (array) $in['recipients'] ) ) ) : array();
		if ( empty( $recipients ) ) {
			$recipients = array( ( ( $in['send_to'] ?? 'customer' ) === 'admin' ) ? 'admin' : 'customer' );
		}

		$rule = array(
			'id'            => $id ? $id : uniqid( 'rule_', false ),
			'name'          => sanitize_text_field( $in['name'] ?? '' ),
			'trigger_key'   => $trigger,
			'enabled'       => empty( $in['enabled'] ) ? 0 : 1,
			'channel'       => $channels[0],
			'channels'      => $channels,
			'recipients'    => $recipients,
			'message'       => $message,
			'variants'      => $this->sanitize_variants( $in['variants'] ?? array(), $message ),
			'variant_mode'  => in_array( (string) ( $in['variant_mode'] ?? 'rotate' ), array( 'rotate', 'random', 'fixed' ), true ) ? (string) ( $in['variant_mode'] ?? 'rotate' ) : 'rotate',
			'variant_pick'  => max( 0, (int) ( $in['variant_pick'] ?? 0 ) ),
			'email_subject' => sanitize_text_field( $in['email_subject'] ?? '' ),
			'email_template' => absint( $in['email_template'] ?? 0 ),
			'admin_message' => isset( $in['admin_message'] ) ? wp_kses_post( $in['admin_message'] ) : '',
			'admin_subject' => sanitize_text_field( $in['admin_subject'] ?? '' ),
			'send_product_image' => empty( $in['send_product_image'] ) ? 0 : 1,
			'media_url'     => esc_url_raw( $in['media_url'] ?? '' ),
			'send_to'       => $recipients[0],
			'admin_users'   => sanitize_text_field( $in['admin_users'] ?? '' ),
			'delay_minutes' => max( 0, (int) ( $in['delay_minutes'] ?? 0 ) ),
			'delay_value'   => max( 0, (int) ( $in['delay_value'] ?? ( $in['delay_minutes'] ?? 0 ) ) ),
			'delay_unit'    => in_array( (string) ( $in['delay_unit'] ?? 'minutes' ), array( 'minutes', 'hours', 'days' ), true ) ? (string) ( $in['delay_unit'] ?? 'minutes' ) : 'minutes',
			'media_mode'    => in_array( $in['media_mode'] ?? '', array( 'none', 'custom', 'featured', 'product' ), true ) ? ( $in['media_mode'] ?? '' ) : '',
			'translations'  => $this->sanitize_translations( $in['translations'] ?? array() ),
			'cooldown_hours' => max( 0, (int) ( $in['cooldown_hours'] ?? 0 ) ),
			// Advanced WooCommerce filters.
			'filter_country'      => empty( $in['filter_country'] ) ? 0 : 1,
			'countries_whitelist' => $this->to_csv( $in['countries_whitelist'] ?? '' ),
			'countries_blocklist' => $this->to_csv( $in['countries_blocklist'] ?? '' ),
			'filter_product'      => empty( $in['filter_product'] ) ? 0 : 1,
			'product_ids'         => $this->to_csv( $in['product_ids'] ?? '' ),
			'filter_category'     => empty( $in['filter_category'] ) ? 0 : 1,
			'category_ids'        => $this->to_csv( $in['category_ids'] ?? '' ),
			'filter_payment'      => empty( $in['filter_payment'] ) ? 0 : 1,
			'payment_gateways'    => $this->to_csv( $in['payment_gateways'] ?? '' ),
			'lang'                => sanitize_key( $in['lang'] ?? '' ),
		);
		if ( '' === $rule['name'] ) {
			$rule['name'] = $this->get_triggers()[ $trigger ];
		}
		// Keep the legacy minutes field in sync so older code paths keep working.
		$rule['delay_minutes'] = $this->delay_to_minutes( $rule );

		return array( 'ok' => true, 'rule' => $rule );
	}

	/**
	 * Sanitize + persist a rule (insert or update).
	 *
	 * @param array $in Raw input.
	 * @return array { ok:bool, id?:string, message?:string }
	 */
	public function save_rule( array $in ) {
		$res = $this->sanitize_rule( $in );
		if ( empty( $res['ok'] ) ) {
			return $res;
		}
		$rule  = $res['rule'];
		$rules = $this->get_rules();
		$found = false;
		foreach ( $rules as $i => $r ) {
			if ( $r['id'] === $rule['id'] ) {
				$rules[ $i ] = $rule;
				$found       = true;
				break;
			}
		}
		if ( ! $found ) {
			$rules[] = $rule;
		}
		update_option( self::RULES_OPTION, array_values( $rules ), false );

		return array( 'ok' => true, 'id' => $rule['id'] );
	}

	/**
	 * Delete a rule.
	 *
	 * @param string $id Rule id.
	 */
	public function delete_rule( $id ) {
		$rules = array_values(
			array_filter(
				$this->get_rules(),
				function ( $r ) use ( $id ) {
					return $r['id'] !== $id;
				}
			)
		);
		update_option( self::RULES_OPTION, $rules, false );
	}

	/**
	 * Enable / disable a rule.
	 *
	 * @param string $id      Rule id.
	 * @param int    $enabled 1/0.
	 */
	public function toggle_rule( $id, $enabled ) {
		$rules = $this->get_rules();
		foreach ( $rules as $i => $r ) {
			if ( $r['id'] === $id ) {
				$rules[ $i ]['enabled'] = $enabled ? 1 : 0;
				break;
			}
		}
		update_option( self::RULES_OPTION, $rules, false );
	}

	/**
	 * Duplicate a rule (created disabled so it can be edited before going live).
	 *
	 * @param string $id Source rule id.
	 * @return string New rule id, or '' when the source is missing.
	 */
	public function duplicate_rule( $id ) {
		$rule = $this->get_rule( $id );
		if ( ! $rule ) {
			return '';
		}
		$rule['id']      = uniqid( 'rule_', false );
		$rule['name']    = $rule['name'] . ' — ' . __( 'copy', 'mersal-notifications' );
		$rule['enabled'] = 0;
		$rules           = $this->get_rules();
		$rules[]         = $rule;
		update_option( self::RULES_OPTION, array_values( $rules ), false );
		return $rule['id'];
	}

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

	/**
	 * Guard nonce + capability.
	 */
	private function guard() {
		check_ajax_referer( 'mnotif_rule', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Not allowed.', 'mersal-notifications' ) ), 403 );
		}
	}

	/**
	 * AJAX: save a rule.
	 */
	public function ajax_save_rule() {
		$this->guard();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified.
		$res = $this->save_rule( wp_unslash( $_POST ) );
		if ( ! empty( $res['ok'] ) ) {
			wp_send_json_success( $res );
		}
		wp_send_json_error( array( 'message' => $res['message'] ?? __( 'Could not save the rule.', 'mersal-notifications' ) ) );
	}

	/**
	 * AJAX: delete a rule.
	 */
	public function ajax_delete_rule() {
		$this->guard();
		$id = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
		if ( $id ) {
			$this->delete_rule( $id );
		}
		wp_send_json_success();
	}

	/**
	 * AJAX: toggle a rule.
	 */
	public function ajax_toggle_rule() {
		$this->guard();
		$id      = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
		$enabled = isset( $_POST['enabled'] ) ? absint( $_POST['enabled'] ) : 0;
		if ( $id ) {
			$this->toggle_rule( $id, $enabled );
		}
		wp_send_json_success();
	}

	/**
	 * AJAX: add a custom event.
	 */
	public function ajax_add_event() {
		$this->guard();
		$label = isset( $_POST['label'] ) ? sanitize_text_field( wp_unslash( $_POST['label'] ) ) : '';
		$slug  = $this->add_custom_event( $label );
		if ( $slug ) {
			wp_send_json_success( array( 'slug' => $slug, 'label' => $label ) );
		}
		wp_send_json_error( array( 'message' => __( 'Enter a name for the event.', 'mersal-notifications' ) ) );
	}

	/**
	 * AJAX: delete a custom event.
	 */
	public function ajax_del_event() {
		$this->guard();
		$slug = isset( $_POST['slug'] ) ? sanitize_key( wp_unslash( $_POST['slug'] ) ) : '';
		if ( $slug ) {
			$this->delete_custom_event( $slug );
		}
		wp_send_json_success();
	}

	/**
	 * AJAX: live message preview with sample data.
	 */
	public function ajax_preview() {
		$this->guard();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified.
		$msg  = isset( $_POST['message'] ) ? wp_kses_post( wp_unslash( $_POST['message'] ) ) : '';
		$user = wp_get_current_user();
		$rep  = $this->user_replacements( $user ) + array(
			'{{customer_name}}'      => $user->display_name,
			'{{order_id}}'           => 'TEST-001',
			'{{order_total}}'        => '99.00',
			'{{order_status_label}}' => __( 'Processing', 'mersal-notifications' ),
		);
		wp_send_json_success( array( 'preview' => $this->parse( $msg, $rep ) ) );
	}

	/**
	 * AJAX: send a test message for a rule to the current admin.
	 */
	public function ajax_test_rule() {
		$this->guard();
		$id   = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
		$rule = $this->get_rule( $id );
		if ( ! $rule ) {
			wp_send_json_error( array( 'message' => __( 'Rule not found.', 'mersal-notifications' ) ) );
		}
		$user = wp_get_current_user();
		$rep  = array(
			'{{customer_name}}'      => $user->display_name,
			'{{wp-display-name}}'    => $user->display_name,
			'{{order_id}}'           => 'TEST-001',
			'{{order_total}}'        => '99.00',
			'{{order_status_label}}' => __( 'Processing (test)', 'mersal-notifications' ),
		);
		$recipient        = $this->user_recipient( $user );
		$rule_now         = $rule;
		$rule_now['delay_minutes'] = 0;
		$jobs             = $this->build_jobs( $rule_now, $rep, $recipient );
		if ( empty( $jobs ) ) {
			wp_send_json_error( array( 'message' => __( 'No phone/email found for your account to receive the test.', 'mersal-notifications' ) ) );
		}
		foreach ( $jobs as $job ) {
			$this->dispatch( $job );
		}
		wp_send_json_success( array( 'message' => __( 'Test sent.', 'mersal-notifications' ) ) );
	}

	/**
	 * AJAX: duplicate a rule.
	 */
	public function ajax_duplicate_rule() {
		$this->guard();
		$id  = isset( $_POST['id'] ) ? sanitize_text_field( wp_unslash( $_POST['id'] ) ) : '';
		$new = $id ? $this->duplicate_rule( $id ) : '';
		if ( $new ) {
			wp_send_json_success( array( 'id' => $new ) );
		}
		wp_send_json_error( array( 'message' => __( 'Rule not found.', 'mersal-notifications' ) ) );
	}

	/**
	 * AJAX: wizard simulator — fire the (unsaved) rule being edited against a
	 * real order/user context and deliver every copy to the current admin.
	 */
	public function ajax_test_draft() {
		$this->guard();
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() verified.
		$in  = wp_unslash( $_POST );
		$res = $this->sanitize_rule( $in );
		if ( empty( $res['ok'] ) ) {
			wp_send_json_error( array( 'message' => $res['message'] ?? __( 'Invalid rule.', 'mersal-notifications' ) ) );
		}
		$rule                  = $res['rule'];
		$rule['enabled']       = 1;
		$rule['delay_value']   = 0;
		$rule['delay_minutes'] = 0;

		$user   = wp_get_current_user();
		$tester = $this->user_recipient( $user );
		if ( empty( $tester['phone'] ) && empty( $tester['email'] ) ) {
			wp_send_json_error( array( 'message' => __( 'No phone/email found for your account to receive the test.', 'mersal-notifications' ) ) );
		}

		// Real context: a chosen WooCommerce order, else the current user.
		$rep      = array();
		$context  = array();
		$order_id = absint( $in['sim_order'] ?? 0 );
		if ( $order_id && function_exists( 'wc_get_order' ) ) {
			$order = wc_get_order( $order_id );
			if ( $order ) {
				$status  = (string) $order->get_status();
				$rep     = $this->order_replacements( $order, $status, $status );
				$context = $this->order_context( $order );
			}
		}
		if ( empty( $rep ) ) {
			$rep = $this->user_replacements( $user ) + array(
				'{{customer_name}}'      => $user->display_name,
				'{{order_id}}'           => 'TEST-001',
				'{{order_total}}'        => '99.00',
				'{{order_status_label}}' => __( 'Processing (test)', 'mersal-notifications' ),
			);
		}
		// Sample cart placeholders when simulating a cart-journey trigger.
		if ( 0 === strpos( (string) $rule['trigger_key'], 'ac_' ) ) {
			$rep += array(
				'{{cart_total}}'        => '250',
				'{{cart_items}}'        => __( 'Sample product', 'mersal-notifications' ) . ' × 1',
				'{{coupon_code}}'       => 'REC-TEST-' . strtoupper( wp_generate_password( 4, false ) ),
				'{{cart_recovery_url}}' => home_url( '/?mnotif_ac_restore=test' ),
			);
		}

		// Deliver the admin copy to the tester too.
		$rule['admin_users'] = (string) $user->ID;

		$jobs = $this->build_jobs( $rule, $rep, $tester, $context );
		if ( empty( $jobs ) ) {
			wp_send_json_error( array( 'message' => __( 'Nothing to send — check channels and your account phone/email.', 'mersal-notifications' ) ) );
		}
		foreach ( $jobs as $job ) {
			$this->dispatch( $job );
		}
		wp_send_json_success(
			array(
				/* translators: %d: number of test messages sent */
				'message' => sprintf( __( 'Test fired — %d message(s) sent to you.', 'mersal-notifications' ), count( $jobs ) ),
			)
		);
	}

	/* ------------------------------------------------------------------ */
	/* Stats + log                                                        */
	/* ------------------------------------------------------------------ */

	/**
	 * Dashboard snapshot.
	 *
	 * @return array
	 */
	public function get_overview() {
		$rules   = $this->get_rules();
		$enabled = 0;
		foreach ( $rules as $r ) {
			if ( ! empty( $r['enabled'] ) ) {
				$enabled++;
			}
		}
		$stats = $this->get_stats();
		return array(
			'rules'   => count( $rules ),
			'enabled' => $enabled,
			'sent'    => $stats['total'],
			'today'   => $stats['today'],
		);
	}

	/**
	 * Increment sent counters.
	 */
	private function bump_stats() {
		$stats = get_option( self::STATS_OPTION, array() );
		$stats = is_array( $stats ) ? $stats : array();
		$today = current_time( 'Y-m-d' );
		if ( ( $stats['today_date'] ?? '' ) !== $today ) {
			$stats['today_date']  = $today;
			$stats['today_count'] = 0;
		}
		$stats['today_count'] = (int) ( $stats['today_count'] ?? 0 ) + 1;
		$stats['total']       = (int) ( $stats['total'] ?? 0 ) + 1;
		update_option( self::STATS_OPTION, $stats, false );
	}

	/**
	 * Read counters.
	 *
	 * @return array{total:int,today:int}
	 */
	public function get_stats() {
		$stats = get_option( self::STATS_OPTION, array() );
		$stats = is_array( $stats ) ? $stats : array();
		$today = current_time( 'Y-m-d' );
		return array(
			'total' => (int) ( $stats['total'] ?? 0 ),
			'today' => ( ( $stats['today_date'] ?? '' ) === $today ) ? (int) ( $stats['today_count'] ?? 0 ) : 0,
		);
	}

	/**
	 * Append to the rolling log (capped at 50).
	 *
	 * @param string $channel Channel.
	 * @param string $to      Recipient.
	 * @param string $status  sent|failed.
	 * @param string $rule    Rule name.
	 */
	private function log( $channel, $to, $status, $rule = '' ) {
		$log = get_option( self::LOG_OPTION, array() );
		$log = is_array( $log ) ? $log : array();
		array_unshift(
			$log,
			array(
				'time'    => time(),
				'channel' => $channel,
				'to'      => $this->mask( $to ),
				'status'  => $status,
				'rule'    => $rule,
			)
		);
		update_option( self::LOG_OPTION, array_slice( $log, 0, 50 ), false );
	}

	/**
	 * Read the recent log.
	 *
	 * @param int $limit Max rows.
	 * @return array
	 */
	public function get_log( $limit = 8 ) {
		$log = get_option( self::LOG_OPTION, array() );
		$log = is_array( $log ) ? $log : array();
		return array_slice( $log, 0, $limit );
	}

	/**
	 * Mask a recipient for display.
	 *
	 * @param string $v Phone / email.
	 * @return string
	 */
	private function mask( $v ) {
		if ( false !== strpos( $v, '@' ) ) {
			list( $l, $d ) = explode( '@', $v, 2 );
			$l = strlen( $l ) > 2 ? substr( $l, 0, 2 ) . '***' : $l;
			return $l . '@' . $d;
		}
		$len = strlen( $v );
		return $len <= 4 ? $v : substr( $v, 0, 3 ) . str_repeat( '*', max( 0, $len - 6 ) ) . substr( $v, -3 );
	}
}
