<?php
/**
 * Mersal account connection (SSO with mersal.it).
 *
 * Reproduces the original plugin's mechanism:
 *  1. "Login by Mersal" sends the admin to {site}/wordpress/sso?redirect_to=<connect page>.
 *  2. mersal.it authenticates and redirects back with ?token=...
 *  3. We capture the token, validate it at {site}/api/wordpress/user/me (Bearer),
 *     store api_key + account data, and sync gateways — fully standalone (no old plugin).
 *
 * @package Mersal_Notif
 */

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

class Mersal_Notif_Connect {

	/**
	 * Which product of the Mersal suite this plugin is, as mersal.it knows it.
	 * Sent on SSO (?app=) and on every API call (X-Mersal-App) so the account
	 * tracks this plugin's connected sites — and its add-on allowance —
	 * separately from its siblings.
	 */
	const MERSAL_APP = 'notifications';

	const OPT_TOKEN    = 'mnotif_sso_token';
	const OPT_APIKEY   = 'mnotif_api_key';
	const OPT_USER     = 'mnotif_mersal_user';
	const OPT_SITE     = 'mnotif_site_url';
	const OPT_GATEWAYS = 'mnotif_gateways';
	const OPT_UNLINKED = 'mnotif_sso_unlinked';

	/**
	 * Hooks.
	 */
	public function init() {
		add_filter( 'mnotif_send_whatsapp', array( $this, 'send_whatsapp' ), 10, 4 );
		add_filter( 'mnotif_send_sms', array( $this, 'send_sms' ), 10, 4 );
		add_filter( 'mnotif_send_email', array( $this, 'send_email' ), 10, 5 );

		add_action( 'admin_init', array( $this, 'handle_sso_launch' ), 1 );
		add_action( 'admin_init', array( $this, 'handle_sso_error' ), 1 );
		add_action( 'admin_init', array( $this, 'handle_token' ), 1 );
		add_action( 'admin_init', array( $this, 'handle_disconnect' ) );
		add_action( 'admin_init', array( $this, 'handle_sync_gateways' ) );
		add_action( 'wp_ajax_mnotif_refresh_account', array( $this, 'ajax_refresh' ) );
		add_action( 'init', array( $this, 'maybe_migrate_legacy_api_key' ), 5 );
		add_action( 'init', array( $this, 'maybe_refresh_api_key_lazy' ), 20 );
		add_action( 'admin_init', array( $this, 'maybe_refresh_api_key' ), 25 );
	}

	/**
	 * Send an Email message via mersal.it (/api/email/send) when connected.
	 *
	 * @param mixed    $pre     Pre-empt value.
	 * @param string   $email   Recipient email.
	 * @param string   $subject Subject.
	 * @param string   $message Plain text body.
	 * @param string[] $headers wp_mail headers (unused; kept for compatibility).
	 * @return array|null
	 */
	public function send_email( $pre, $email, $subject, $message, $headers ) {
		if ( 'mersal' !== Mersal_Notif_Settings::get( 'email_provider', 'wordpress' ) ) {
			return null;
		}
		if ( ! $this->is_connected() && ! $this->get_api_key() ) {
			return null;
		}
		if ( ! is_email( $email ) ) {
			return array( 'ok' => false, 'message' => __( 'Invalid email address.', 'mersal-notifications' ) );
		}

		if ( empty( $this->gateways() ) ) {
			$this->sync_gateways();
		}

		$api_key = $this->resolve_message_api_key_for_send();
		if ( ! $api_key ) {
			return array(
				'ok'      => false,
				'message' => __( 'Message API key missing. Open Mersal → Connect, click Sync now, then try again.', 'mersal-notifications' ),
			);
		}

		$gateway = (string) Mersal_Notif_Settings::get( 'email_gateway', '' );
		if ( '' === $gateway ) {
			$gateway = $this->first_gateway_uid( 'email' );
		}

		return $this->email_send_request( $api_key, $email, $subject, $message, $gateway );
	}

	/**
	 * Send a WhatsApp message via mersal.it (/api/whatsapp/send) when connected.
	 *
	 * @param mixed  $pre     Pre-empt value (null = not handled).
	 * @param string $phone   Digits-only phone.
	 * @param string $message Message text.
	 * @param string $code    Raw OTP (unused; included in message).
	 * @return array|null { ok, message } or null to let core decide.
	 */
	public function send_whatsapp( $pre, $phone, $message, $code ) {
		if ( ! $this->is_connected() && ! $this->get_api_key() ) {
			return null; // Not connected — let core handle (test mode / generic message).
		}

		if ( empty( $this->gateways() ) ) {
			$this->sync_gateways();
		}

		$gateway = Mersal_Notif_Settings::get( 'wa_gateway' );
		if ( empty( $gateway ) ) {
			$gateway = $this->first_gateway_uid( 'whatsapp' );
		}
		if ( empty( $gateway ) ) {
			return array(
				'ok'      => false,
				'message' => __( 'No WhatsApp gateway found. Add one on mersal.it, then sync gateways under Mersal.', 'mersal-notifications' ),
			);
		}

		$api_key = $this->resolve_message_api_key_for_send();
		if ( ! $api_key ) {
			return array(
				'ok'      => false,
				'message' => __( 'Message API key missing. Open Mersal → Connect, click Sync now, then try again.', 'mersal-notifications' ),
			);
		}

		// Poll style (Approve / Reject) — a vote logs the user in.
		// Falls back to the standard text message if the poll send fails.
		$wa_style = Mersal_Notif_Settings::wa_style();
		if ( 'poll' === $wa_style || 'list' === $wa_style ) {
			$list_result = $this->whatsapp_list_request( $api_key, $phone, $code, $gateway, $message );
			if ( ! empty( $list_result['ok'] ) ) {
				return $list_result;
			}
		}

		// Confirm-card style (wawp-style "Login Confirmation" with a tappable
		// Confirm button) — a tap logs the user in, captured via webhook. Falls
		// back to the standard text message if the interactive send fails.
		if ( 'button' === $wa_style ) {
			$btn_result = $this->whatsapp_button_request( $api_key, $phone, $code, $gateway );
			if ( ! empty( $btn_result['ok'] ) ) {
				return $btn_result;
			}
		}

		$result = $this->whatsapp_send_request( $api_key, $phone, $message, $gateway );
		if ( $result['ok'] ) {
			return $result;
		}

		if ( $this->is_invalid_api_key_message( $result['message'] ) ) {
			$token = (string) get_option( self::OPT_TOKEN, '' );
			if ( $token ) {
				$this->validate( $token );
				$api_key = $this->request_message_api_key( $token );
				if ( $api_key ) {
					$result = $this->whatsapp_send_request( $api_key, $phone, $message, $gateway );
					if ( $result['ok'] ) {
						return $result;
					}
				}
			}
		}

		if ( ! $this->get_api_key() && ! get_option( self::OPT_TOKEN ) ) {
			return array(
				'ok'      => false,
				'message' => __( 'Connect your Mersal account under Mersal → Connect before sending WhatsApp codes.', 'mersal-notifications' ),
			);
		}

		$detail = $this->last_connect_error();
		$msg    = __( 'Could not send via WhatsApp. Open Mersal → Connect, click Sync now, and confirm WhatsApp is enabled with an active gateway on mersal.it.', 'mersal-notifications' );
		if ( $detail ) {
			$msg .= ' ' . sprintf(
				/* translators: %s: API error from mersal.it */
				__( 'Server response: %s', 'mersal-notifications' ),
				$detail
			);
		}

		return array(
			'ok'      => false,
			'message' => $msg,
		);
	}

	/**
	 * Send an SMS message via mersal.it (/api/sms/send) when connected.
	 *
	 * @param mixed  $pre     Pre-empt value.
	 * @param string $phone   Digits-only phone.
	 * @param string $message Message text.
	 * @param string $code    Raw OTP (unused; included in message).
	 * @return array|null { ok, message } or null to let core decide.
	 */
	public function send_sms( $pre, $phone, $message, $code ) {
		if ( null !== $pre ) {
			return $pre;
		}
		if ( ! $this->is_connected() && ! $this->get_api_key() ) {
			return null;
		}

		if ( empty( $this->gateways() ) ) {
			$this->sync_gateways();
		}

		$candidates = $this->gateway_candidates( 'sms', (string) Mersal_Notif_Settings::get( 'sms_gateway', '' ) );
		if ( empty( $candidates ) ) {
			return array(
				'ok'      => false,
				'message' => __( 'No SMS gateway found. Add one on mersal.it, then sync gateways.', 'mersal-notifications' ),
			);
		}

		$api_key = $this->resolve_message_api_key_for_send();
		if ( ! $api_key ) {
			return array(
				'ok'      => false,
				'message' => __( 'Message API key missing. Open Mersal → Connect, click Sync now, then try again.', 'mersal-notifications' ),
			);
		}

		$result = array( 'ok' => false, 'message' => __( 'Could not send via SMS.', 'mersal-notifications' ) );
		foreach ( $candidates as $gateway ) {
			$result = $this->sms_send_request( $api_key, $phone, $message, $gateway );
			if ( ! empty( $result['ok'] ) ) {
				return $result;
			}
			if ( ! $this->is_gateway_retryable_error( $result['message'] ?? '' ) ) {
				break;
			}
		}

		// Let mersal.it pick the default connected Android SIM (same as the dashboard).
		if ( empty( $result['ok'] ) && $this->is_gateway_retryable_error( $result['message'] ?? '' ) ) {
			$auto = $this->sms_send_request( $api_key, $phone, $message, '' );
			if ( ! empty( $auto['ok'] ) ) {
				return $auto;
			}
			$result = $auto;
		}

		if ( $this->is_invalid_api_key_message( $result['message'] ?? '' ) ) {
			$token = (string) get_option( self::OPT_TOKEN, '' );
			if ( $token ) {
				$this->validate( $token );
				$api_key = $this->request_message_api_key( $token );
				if ( $api_key ) {
					foreach ( $candidates as $gateway ) {
						$retry = $this->sms_send_request( $api_key, $phone, $message, $gateway );
						if ( ! empty( $retry['ok'] ) ) {
							return $retry;
						}
					}
					$retry = $this->sms_send_request( $api_key, $phone, $message, '' );
					if ( ! empty( $retry['ok'] ) ) {
						return $retry;
					}
					$result = $retry;
				}
			}
		}

		return $result;
	}

	/**
	 * Whether an SMS gateway is available for WhatsApp → SMS fallback.
	 *
	 * @return bool
	 */
	public function has_sms_gateway() {
		if ( ! $this->is_connected() && ! $this->get_api_key() ) {
			return false;
		}
		if ( empty( $this->gateways() ) ) {
			$this->sync_gateways();
		}
		$gateway = (string) Mersal_Notif_Settings::get( 'sms_gateway', '' );
		if ( '' === $gateway ) {
			$gateway = $this->first_gateway_uid( 'sms' );
		}
		return '' !== $gateway;
	}

	/**
	 * Whether a WhatsApp OTP message was read (read receipt).
	 *
	 * @param string $message_id Gateway message id.
	 * @return bool True when the API reports a read/seen state.
	 */
	public function check_message_read_status( $message_id ) {
		$message_id = (string) $message_id;
		if ( '' === $message_id ) {
			return false;
		}
		$api_key = $this->resolve_message_api_key_for_send();
		if ( '' === $api_key ) {
			return false;
		}

		$paths = array(
			'/api/whatsapp/message-status',
			'/api/whatsapp/status',
		);
		foreach ( $paths as $path ) {
			$url  = add_query_arg( 'message_id', rawurlencode( $message_id ), $this->site_url() . $path );
			$resp = wp_remote_get(
				$url,
				array(
					'timeout' => 12,
					'headers' => $this->whatsapp_send_headers( $api_key ),
				)
			);
			if ( is_wp_error( $resp ) ) {
				continue;
			}
			$body = json_decode( wp_remote_retrieve_body( $resp ), true );
			if ( ! is_array( $body ) ) {
				continue;
			}
			$status = strtolower( (string) ( $body['data']['status'] ?? $body['status'] ?? $body['data']['read'] ?? '' ) );
			if ( in_array( $status, array( 'read', 'seen', 'read_receipt', 'played', 'true', '1' ), true ) ) {
				return true;
			}
			if ( ! empty( $body['data']['read'] ) || ! empty( $body['read'] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Poll the vote status of an Interactive-List auth message.
	 *
	 * @param string $message_id Node message id of the sent poll/list.
	 * @return string approved|rejected|pending|error
	 */
	public function check_list_status( $message_id ) {
		$message_id = (string) $message_id;
		if ( '' === $message_id ) {
			return 'error';
		}
		$api_key = $this->resolve_message_api_key_for_send();
		if ( '' === $api_key ) {
			return 'error';
		}
		$url  = add_query_arg(
			array( 'message_id' => rawurlencode( $message_id ) ),
			$this->site_url() . '/api/whatsapp/list-status'
		);
		$resp = wp_remote_get(
			$url,
			array(
				'timeout' => 12,
				'headers' => $this->whatsapp_send_headers( $api_key ),
			)
		);
		if ( is_wp_error( $resp ) ) {
			return 'error';
		}
		$body = json_decode( wp_remote_retrieve_body( $resp ), true );
		$status = is_array( $body ) ? (string) ( $body['data']['status'] ?? $body['status'] ?? '' ) : '';
		if ( in_array( $status, array( 'approved', 'rejected', 'pending', 'voted' ), true ) ) {
			return ( 'voted' === $status ) ? 'approved' : $status;
		}
		return 'pending';
	}

	/**
	 * Send a NATIVE interactive WhatsApp list (Login-Confirmation card) via
	 * mersal.it /api/whatsapp/send-list. Built from the List Interface Design
	 * settings; the OTP code is kept in the body so login still works.
	 *
	 * @param string $api_key Message Api-key.
	 * @param string $phone   Digits-only phone.
	 * @param string $code    Raw OTP code.
	 * @param string $gateway Gateway uid.
	 * @param string $message Already-rendered text (used to recover any sign-in link).
	 * @return array{ok:bool,message:string}
	 */
	private function whatsapp_list_request( $api_key, $phone, $code, $gateway, $message = '' ) {
		$api_key = $this->normalize_stored_api_key( $api_key );
		if ( '' === $api_key ) {
			return array( 'ok' => false, 'message' => __( 'Message API key is empty.', 'mersal-notifications' ) );
		}

		$cfg    = Mersal_Notif_Settings::whatsapp_list_config();
		$site   = get_bloginfo( 'name' );
		$expiry = (string) (int) Mersal_Notif_Settings::get( 'otp_expiry', 10 );
		$rep    = array(
			'{{otp}}'       => $code,
			'{{site_name}}' => $site,
			'{{site}}'      => $site,
			'{{expiry}}'    => $expiry,
			'{{minutes}}'   => $expiry,
		);
		$f = array();
		foreach ( $cfg as $k => $v ) {
			$f[ $k ] = strtr( (string) $v, $rep );
		}

		// Body keeps the OTP code (no reply-capture wired, so the code/link are
		// what actually log the user in). Recover a sign-in link if present.
		// Clean poll body — just the question. The Approve/Reject vote logs the user in.
			$body = $f['desc'];
			unset( $message );

		$sections = array(
			array(
				'title' => $f['title'],
				'rows'  => array(
					array(
						'title'       => $f['item1_title'],
						'description' => $f['item1_desc'],
						'rowId'       => 'mnotif_approve',
					),
					array(
						'title'       => $f['item2_title'],
						'description' => $f['item2_desc'],
						'rowId'       => 'mnotif_reject',
					),
				),
			),
		);

		$payload = array(
			'gateway_identifier' => $gateway,
			'number'             => $phone,
			'title'              => $f['title'],
			'text'               => $body,
			'buttonText'         => $f['button'],
			'footer'             => $f['footer'],
			'sections'           => $sections,
		);

		$attempts = array(
			array(
				'url'     => $this->site_url() . '/api/whatsapp/send-list',
				'headers' => $this->whatsapp_send_headers( $api_key ),
			),
			array(
				'url'     => add_query_arg( 'api_key', rawurlencode( $api_key ), $this->site_url() . '/api/whatsapp/send-list' ),
				'headers' => $this->whatsapp_send_headers( $api_key, false ),
			),
		);

		$last_err = '';
		foreach ( $attempts as $attempt ) {
			$resp = wp_remote_post(
				$attempt['url'],
				array(
					'timeout' => 20,
					'headers' => $attempt['headers'],
					'body'    => wp_json_encode( $payload ),
				)
			);
			if ( is_wp_error( $resp ) ) {
				$last_err = $resp->get_error_message();
				continue;
			}
			$rcode = (int) wp_remote_retrieve_response_code( $resp );
			$rbody = json_decode( wp_remote_retrieve_body( $resp ), true );
			if ( $rcode >= 200 && $rcode < 300 ) {
				$mid = '';
				if ( is_array( $rbody ) ) {
					$mid = (string) ( $rbody['data']['messageId'] ?? $rbody['messageId'] ?? '' );
				}
				return array(
					'ok'         => true,
					'message'    => __( 'Verification list sent via WhatsApp.', 'mersal-notifications' ),
					'message_id' => $mid,
					'gateway'    => $gateway,
				);
			}
			$last_err = is_array( $rbody ) ? (string) ( $rbody['message'] ?? $rbody['error'] ?? ( 'HTTP ' . $rcode ) ) : ( 'HTTP ' . $rcode );
		}

		return array(
			'ok'      => false,
			'message' => $last_err ? $last_err : __( 'Could not send the verification list.', 'mersal-notifications' ),
		);
	}

	/**
	 * Send a wawp-style "Login Confirmation" card with a real tappable Confirm
	 * button via mersal.it /api/whatsapp/send-list (style=button). Works on both
	 * node/Baileys and Cloud-API gateways. The tap is captured server-side via
	 * webhook and resolved by the confirm-status poll (check_list_status).
	 *
	 * @param string $api_key Message Api-key.
	 * @param string $phone   Digits-only phone.
	 * @param string $code    Raw OTP code (kept in the body as a fallback).
	 * @param string $gateway Gateway uid.
	 * @return array{ok:bool,message:string,message_id?:string}
	 */
	private function whatsapp_button_request( $api_key, $phone, $code, $gateway ) {
		$api_key = $this->normalize_stored_api_key( $api_key );
		if ( '' === $api_key ) {
			return array( 'ok' => false, 'message' => __( 'Message API key is empty.', 'mersal-notifications' ) );
		}

		$cfg    = Mersal_Notif_Settings::whatsapp_list_config();
		$site   = get_bloginfo( 'name' );
		$expiry = (string) (int) Mersal_Notif_Settings::get( 'otp_expiry', 10 );
		$rep    = array(
			'{{otp}}'       => $code,
			'{{site_name}}' => $site,
			'{{site}}'      => $site,
			'{{expiry}}'    => $expiry,
			'{{minutes}}'   => $expiry,
		);
		$f = array();
		foreach ( $cfg as $k => $v ) {
			$f[ $k ] = strtr( (string) $v, $rep );
		}

		// Compose the "Login Confirmation" body with device context (like wawp).
		$body = $this->confirm_card_body( $f );

		// First section row → buttons. rowId carries approve/reject intent so the
		// node/Cloud capture can classify the tap.
		$sections = array(
			array(
				'title' => $f['title'],
				'rows'  => array(
					array(
						'title'       => $f['item1_title'],
						'description' => $f['item1_desc'],
						'rowId'       => 'mnotif_approve',
					),
					array(
						'title'       => $f['item2_title'],
						'description' => $f['item2_desc'],
						'rowId'       => 'mnotif_reject',
					),
				),
			),
		);

		$payload = array(
			'gateway_identifier' => $gateway,
			'number'             => $phone,
			'style'              => 'button',
			'title'              => $f['title'],
			'text'               => $body,
			'buttonText'         => $f['button'],
			'footer'             => $f['footer'],
			'sections'           => $sections,
		);

		$attempts = array(
			array(
				'url'     => $this->site_url() . '/api/whatsapp/send-list',
				'headers' => $this->whatsapp_send_headers( $api_key ),
			),
			array(
				'url'     => add_query_arg( 'api_key', rawurlencode( $api_key ), $this->site_url() . '/api/whatsapp/send-list' ),
				'headers' => $this->whatsapp_send_headers( $api_key, false ),
			),
		);

		$last_err = '';
		foreach ( $attempts as $attempt ) {
			$resp = wp_remote_post(
				$attempt['url'],
				array(
					'timeout' => 20,
					'headers' => $attempt['headers'],
					'body'    => wp_json_encode( $payload ),
				)
			);
			if ( is_wp_error( $resp ) ) {
				$last_err = $resp->get_error_message();
				continue;
			}
			$rcode = (int) wp_remote_retrieve_response_code( $resp );
			$rbody = json_decode( wp_remote_retrieve_body( $resp ), true );
			if ( $rcode >= 200 && $rcode < 300 ) {
				$mid = '';
				if ( is_array( $rbody ) ) {
					$mid = (string) ( $rbody['data']['messageId'] ?? $rbody['messageId'] ?? '' );
				}
				return array(
					'ok'         => true,
					'message'    => __( 'Login confirmation sent via WhatsApp.', 'mersal-notifications' ),
					'message_id' => $mid,
					'gateway'    => $gateway,
				);
			}
			$last_err = is_array( $rbody ) ? (string) ( $rbody['message'] ?? $rbody['error'] ?? ( 'HTTP ' . $rcode ) ) : ( 'HTTP ' . $rcode );
		}

		return array(
			'ok'      => false,
			'message' => $last_err ? $last_err : __( 'Could not send the login confirmation.', 'mersal-notifications' ),
		);
	}

	/**
	 * Build the Confirm-card body: the configured description plus the device /
	 * browser context of the browser that started the login (wawp-style).
	 *
	 * @param array $f Resolved List-Interface fields (title, desc, …).
	 * @return string
	 */
	private function confirm_card_body( array $f ) {
		$lines = array();
		if ( ! empty( $f['desc'] ) ) {
			$lines[] = trim( (string) $f['desc'] );
			$lines[] = '';
		}

		$ua = isset( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : '';
		$ip = class_exists( 'Mersal_Notif_Security' ) ? Mersal_Notif_Security::client_ip() : '';

		$device  = ( $ua && preg_match( '/Mobile|Android|iPhone|iPad/i', $ua ) ) ? __( 'Mobile', 'mersal-notifications' ) : __( 'Computer', 'mersal-notifications' );
		$browser = $this->ua_browser( $ua );

		if ( $ip ) {
			$lines[] = '📍 ' . __( 'IP', 'mersal-notifications' ) . ': ' . $ip;
		}
		$lines[] = '💻 ' . __( 'Device', 'mersal-notifications' ) . ': ' . $device;
		if ( $browser ) {
			$lines[] = '🌐 ' . __( 'Browser', 'mersal-notifications' ) . ': ' . $browser;
		}

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

	/**
	 * Best-effort browser label from a User-Agent string.
	 *
	 * @param string $ua User-Agent.
	 * @return string
	 */
	private function ua_browser( $ua ) {
		if ( '' === (string) $ua ) {
			return '';
		}
		if ( preg_match( '/Edg/i', $ua ) ) { return 'Edge'; }
		if ( preg_match( '/OPR|Opera/i', $ua ) ) { return 'Opera'; }
		if ( preg_match( '/Chrome/i', $ua ) ) { return 'Chrome'; }
		if ( preg_match( '/Firefox/i', $ua ) ) { return 'Firefox'; }
		if ( preg_match( '/Safari/i', $ua ) ) { return 'Safari'; }
		return '';
	}

	/**
	 * POST /api/whatsapp/send (Api-key header — same as the legacy Mersal plugin).
	 *
	 * @param string $api_key  Message Api-key.
	 * @param string $phone    Phone digits.
	 * @param string $message  Text.
	 * @param string $gateway  Gateway uid.
	 * @return array{ok:bool,message:string}
	 */
	private function whatsapp_send_request( $api_key, $phone, $message, $gateway ) {
		$api_key = $this->normalize_stored_api_key( $api_key );
		if ( '' === $api_key ) {
			return array(
				'ok'      => false,
				'message' => __( 'Message API key is empty.', 'mersal-notifications' ),
			);
		}

		$payload = array(
			'contact' => array(
				array(
					'number'             => $phone,
					'message'            => $message,
					'gateway_identifier' => $gateway,
				),
			),
		);

		$attempts = array(
			array(
				'url'     => $this->site_url() . '/api/whatsapp/send',
				'headers' => $this->whatsapp_send_headers( $api_key ),
			),
			array(
				'url'     => add_query_arg( 'api_key', rawurlencode( $api_key ), $this->site_url() . '/api/whatsapp/send' ),
				'headers' => $this->whatsapp_send_headers( $api_key, false ),
			),
		);

		$last_err = '';
		foreach ( $attempts as $attempt ) {
			$resp = wp_remote_post(
				$attempt['url'],
				array(
					'timeout' => 20,
					'headers' => $attempt['headers'],
					'body'    => wp_json_encode( $payload ),
				)
			);

			if ( is_wp_error( $resp ) ) {
				$last_err = $resp->get_error_message();
				continue;
			}

			$code = wp_remote_retrieve_response_code( $resp );
			$ok   = $code >= 200 && $code < 300;
			if ( $ok ) {
				delete_option( 'mnotif_connect_last_error' );
				delete_option( 'mnotif_connect_error_code' );
				$rbody = json_decode( wp_remote_retrieve_body( $resp ), true );
				$mid   = $this->extract_message_id_from_body( $rbody );
				return array(
					'ok'         => true,
					'message'    => __( 'Verification code sent via WhatsApp.', 'mersal-notifications' ),
					'message_id' => $mid,
				);
			}

			$last_err = $this->parse_api_error_message( $resp );
			if ( $last_err && ! $this->is_api_key_required_message( $last_err ) ) {
				break;
			}
		}

		if ( $last_err ) {
			$this->record_connect_error( $last_err );
		}

		return array(
			'ok'      => false,
			'message' => $last_err ? $last_err : __( 'WhatsApp gateway rejected the request.', 'mersal-notifications' ),
		);
	}

	/**
	 * @param string $message API error text.
	 * @return bool
	 */
	private function is_api_key_required_message( $message ) {
		$message = strtolower( (string) $message );
		return false !== strpos( $message, 'api key is required' )
			|| false !== strpos( $message, 'api-key is required' )
			|| false !== strpos( $message, 'api_key' ) && false !== strpos( $message, 'required' );
	}

	/**
	 * @param array|WP_Error $resp HTTP response.
	 * @return string
	 */
	private function parse_api_error_message( $resp ) {
		if ( is_wp_error( $resp ) ) {
			return $resp->get_error_message();
		}
		$body = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( ! is_array( $body ) ) {
			return '';
		}
		foreach ( array( 'message', 'error', 'detail' ) as $k ) {
			if ( ! empty( $body[ $k ] ) && is_string( $body[ $k ] ) ) {
				return $body[ $k ];
			}
		}
		if ( ! empty( $body['errors'] ) && is_array( $body['errors'] ) ) {
			$first = reset( $body['errors'] );
			if ( is_string( $first ) ) {
				return $first;
			}
			if ( is_array( $first ) && ! empty( $first[0] ) ) {
				return (string) $first[0];
			}
		}
		return '';
	}

	/**
	 * @param string $message Error detail.
	 * @param string $code    Machine-readable reason from mersal.it, when known.
	 */
	private function record_connect_error( $message, $code = '' ) {
		$message = sanitize_text_field( (string) $message );
		if ( '' !== $message ) {
			update_option( 'mnotif_connect_last_error', $message, false );
		}
		// The sentence alone is not enough to decide what to offer: a refusal
		// for want of a subscription needs a Subscribe button, a used-up site
		// allowance needs "free a slot", and everything else needs Try again.
		if ( '' !== (string) $code ) {
			update_option( 'mnotif_connect_error_code', sanitize_key( $code ), false );
		} else {
			delete_option( 'mnotif_connect_error_code' );
		}
	}

	/**
	 * Last mersal.it error (for admin hints).
	 *
	 * @return string
	 */
	public function last_connect_error() {
		return (string) get_option( 'mnotif_connect_last_error', '' );
	}

	/**
	 * Why the last link attempt was refused: 'plan_upgrade_required',
	 * 'site_limit_reached', or '' when it was an ordinary failure.
	 *
	 * @return string
	 */
	public function last_connect_error_code() {
		return (string) get_option( 'mnotif_connect_error_code', '' );
	}

	/**
	 * Does the account need a subscription before this plugin can be linked?
	 *
	 * @return bool
	 */
	public function needs_subscription() {
		return 'plan_upgrade_required' === $this->last_connect_error_code();
	}

	/**
	 * @param string $message Error text from API.
	 * @return bool
	 */
	private function is_invalid_api_key_message( $message ) {
		$message = strtolower( (string) $message );
		return false !== strpos( $message, 'invalid api key' )
			|| false !== strpos( $message, 'invalid api-key' )
			|| false !== strpos( $message, 'api key' ) && false !== strpos( $message, 'invalid' );
	}

	/**
	 * One-time import of SSO token / site URL if the store was empty (upgrade path only).
	 */
	public function maybe_migrate_legacy_api_key() {
		// One-time import from the legacy shared options. The flag keeps a token
		// written by another Mersal plugin from silently re-linking this one —
		// each suite plugin holds its own independent mersal.it account.
		if ( get_option( 'mnotif_legacy_import_done' ) ) {
			return;
		}
		if ( get_option( self::OPT_UNLINKED ) ) {
			update_option( 'mnotif_legacy_import_done', 1, false );
			return;
		}

		if ( ! get_option( self::OPT_TOKEN ) ) {
			$legacy_token = get_option( 'mersal_sso_token', '' );
			if ( $legacy_token ) {
				update_option( self::OPT_TOKEN, $legacy_token, false );
			}
		}
		if ( ! get_option( self::OPT_SITE ) ) {
			$legacy_site = get_option( 'mersal_site_url', '' );
			if ( $legacy_site ) {
				update_option( self::OPT_SITE, $legacy_site, false );
			}
		}
		if ( ! get_option( self::OPT_USER ) ) {
			$legacy_user = get_option( 'mersal_site_b_user_data_persistent', array() );
			if ( is_array( $legacy_user ) && ! empty( $legacy_user['user_email'] ) ) {
				update_option(
					self::OPT_USER,
					array(
						'email'     => $legacy_user['user_email'],
						'name'      => $legacy_user['user_name'] ?? '',
						'plan'      => $legacy_user['plan_name'] ?? '',
						'plan_name' => $legacy_user['plan_name'] ?? '',
					),
					false
				);
			}
		}
		$this->import_legacy_message_api_key();
		update_option( 'mnotif_legacy_import_done', 1, false );
		$token = (string) get_option( self::OPT_TOKEN, '' );
		if ( $token && ! $this->get_api_key() ) {
			$this->validate( $token );
			$this->request_message_api_key( $token );
		}
	}

	/**
	 * Reuse message Api-key saved by the main Mersal plugin (mersal_access_token).
	 *
	 * @return string Stored key or empty.
	 */
	public function import_legacy_message_api_key() {
		$current = $this->get_api_key();
		if ( $current ) {
			return $current;
		}
		// Only during the one-time legacy migration — afterwards the shared
		// mersal_access_token belongs to whichever plugin wrote it last and must
		// never leak into this plugin's account.
		if ( get_option( self::OPT_UNLINKED ) || get_option( 'mnotif_legacy_import_done' ) ) {
			return '';
		}
		$legacy = get_option( 'mersal_access_token', '' );
		if ( ! is_string( $legacy ) || '' === trim( $legacy ) ) {
			return '';
		}
		$key = $this->normalize_stored_api_key( $legacy );
		if ( '' === $key ) {
			return '';
		}
		update_option( self::OPT_APIKEY, $key, false );
		return $key;
	}

	/**
	 * @deprecated No-op; kept for backward compatibility.
	 * @param bool $force Unused.
	 */
	public function purge_stale_api_keys( $force = false ) {}

	/**
	 * @deprecated No-op; kept for backward compatibility.
	 */
	public function purge_sso_token_mistaken_as_api_key() {}

	/**
	 * Re-fetch /user/me when connected but the WhatsApp Api-key is missing (admin).
	 */
	public function maybe_refresh_api_key() {
		if ( ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( ! get_option( self::OPT_TOKEN ) || $this->get_api_key() ) {
			return;
		}
		$token = (string) get_option( self::OPT_TOKEN );
		$this->validate( $token );
		$this->ensure_api_key();
	}

	/**
	 * Throttled refresh on front-end / AJAX when the key is missing.
	 */
	public function maybe_refresh_api_key_lazy() {
		if ( $this->get_api_key() || ! get_option( self::OPT_TOKEN ) ) {
			return;
		}
		if ( get_transient( 'mnotif_api_key_refresh' ) ) {
			return;
		}
		set_transient( 'mnotif_api_key_refresh', 1, 15 * MINUTE_IN_SECONDS );
		$this->ensure_api_key();
	}

	/**
	 * Whether WhatsApp can be sent through the linked mersal.it account.
	 *
	 * @return bool
	 */
	public function has_message_api_key() {
		return $this->whatsapp_send_ready();
	}

	/**
	 * True when a message Api-key exists or the account can send via SSO Bearer + gateway.
	 *
	 * @return bool
	 */
	public function whatsapp_send_ready() {
		return '' !== $this->resolve_message_api_key_for_send();
	}

	/**
	 * Load the message Api-key immediately before send (validate + legacy import + probes).
	 *
	 * @return string
	 */
	public function resolve_message_api_key_for_send() {
		$key = $this->get_api_key();
		if ( $key ) {
			return $key;
		}

		$key = $this->import_legacy_message_api_key();
		if ( $key ) {
			return $key;
		}

		$token = (string) get_option( self::OPT_TOKEN, '' );
		if ( $token ) {
			$this->validate( $token );
			$key = $this->ensure_api_key();
			if ( $key ) {
				return $key;
			}
		}

		return '';
	}

	/**
	 * Resolve the WhatsApp message Api-key from mersal.it (standalone).
	 *
	 * @return string
	 */
	public function ensure_api_key() {
		$key = $this->get_api_key();
		if ( $key ) {
			return $key;
		}

		$key = $this->import_legacy_message_api_key();
		if ( $key ) {
			return $key;
		}

		$token = get_option( self::OPT_TOKEN );
		if ( $token ) {
			return $this->request_message_api_key( $token );
		}

		return '';
	}

	/**
	 * Fetch and persist the message Api-key from mersal.it after SSO.
	 *
	 * @param string $token Bearer SSO token.
	 * @return string Stored key or empty string.
	 */
	public function request_message_api_key( $token ) {
		if ( empty( $token ) ) {
			return '';
		}

		$key = $this->get_api_key();
		if ( $key ) {
			return $key;
		}

		// Mirror the legacy Mersal connector exactly: a single GET /api/wordpress/user/me
		// returns { ok, user: { ..., api_key } }; store user.api_key as the message key.
		// No multi-endpoint probing — that fan-out is what triggered HTTP 429 on mersal.it.
		$key = $this->fetch_api_key_from_user_me_request( $token, 'GET', array() );
		if ( $key ) {
			$this->store_api_key( $key );
			return $key;
		}

		return '';
	}

	/**
	 * GET /api/wordpress/user/me (Bearer) and extract user.api_key — the exact request
	 * the legacy Mersal connector used. A single call, no probing.
	 *
	 * @param string $token  Bearer token.
	 * @param string $method GET (kept for signature compatibility).
	 * @param array  $body   Unused.
	 * @return string
	 */
	private function fetch_api_key_from_user_me_request( $token, $method = 'GET', array $body = array() ) {
		$resp = wp_remote_get(
			$this->site_url() . '/api/wordpress/user/me',
			array(
				'timeout' => 15,
				'headers' => $this->user_me_headers( $token ),
			)
		);
		if ( is_wp_error( $resp ) ) {
			return '';
		}
		$code = wp_remote_retrieve_response_code( $resp );
		if ( $code < 200 || $code >= 300 ) {
			return '';
		}
		$json = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( ! is_array( $json ) ) {
			return '';
		}
		$this->ingest_user_me_json( $json );
		return $this->get_api_key();
	}

	/**
	 * Extract and store api_key from a /user/me JSON document.
	 *
	 * @param array $json Decoded response.
	 */
	private function ingest_user_me_json( array $json ) {
		$user = $this->parse_user_from_me_json( $json );
		if ( is_array( $user ) ) {
			$this->apply_user_api_key_from_me( $user );
		}
		if ( $this->get_api_key() ) {
			return;
		}
		$extracted = $this->extract_message_api_key( $json );
		if ( $extracted ) {
			$this->store_api_key( $extracted );
			return;
		}
		$deep = $this->deep_find_message_api_key( $json );
		if ( $deep ) {
			$this->store_api_key( $deep );
		}
	}

	/**
	 * Walk a decoded API payload and return the first plausible message Api-key.
	 *
	 * @param array $data JSON tree.
	 * @param int   $depth Recursion guard.
	 * @return string
	 */
	private function deep_find_message_api_key( array $data, $depth = 0 ) {
		if ( $depth > 8 ) {
			return '';
		}

		$key_names = array( 'api_key', 'apiKey', 'message_api_key', 'whatsapp_api_key' );
		foreach ( $key_names as $name ) {
			if ( ! isset( $data[ $name ] ) ) {
				continue;
			}
			if ( is_string( $data[ $name ] ) || is_numeric( $data[ $name ] ) ) {
				$val = trim( (string) $data[ $name ] );
				if ( '' !== $val && strlen( $val ) >= 8 ) {
					return $val;
				}
			}
		}

		$skip_branches = array( 'gateways', 'instances', 'gateway', 'items' );
		foreach ( $data as $branch_key => $value ) {
			if ( ! is_array( $value ) ) {
				continue;
			}
			if ( 0 === $depth && in_array( (string) $branch_key, $skip_branches, true ) ) {
				continue;
			}
			$found = $this->deep_find_message_api_key( $value, $depth + 1 );
			if ( $found ) {
				return $found;
			}
		}

		return '';
	}

	/**
	 * Api-key used for /api/whatsapp/send (legacy option name: mersal_access_token).
	 *
	 * @return string
	 */
	public function get_api_key() {
		// Per-plugin key only — the shared legacy option is read once during
		// migration (import_legacy_message_api_key), never at send time.
		$key = get_option( self::OPT_APIKEY, '' );
		if ( is_string( $key ) && '' !== trim( $key ) ) {
			return trim( $key );
		}

		$u = $this->get_user();
		$key = $this->resolve_api_key_from_user( $u );
		if ( $key ) {
			$this->save_message_api_key( $key );
			return $this->get_api_key();
		}

		return '';
	}

	/**
	 * Read message Api-key fields from a mersal.it user object (same as legacy plugin).
	 *
	 * @param array $user User node.
	 * @return string
	 */
	private function resolve_api_key_from_user( array $user ) {
		foreach ( array( 'api_key', 'apiKey', 'message_api_key', 'whatsapp_api_key', 'access_key', 'wordpress_api_key', 'key' ) as $field ) {
			if ( ! isset( $user[ $field ] ) ) {
				continue;
			}
			if ( is_string( $user[ $field ] ) || is_numeric( $user[ $field ] ) ) {
				$key = trim( (string) $user[ $field ] );
				if ( '' !== $key ) {
					return $key;
				}
			}
		}
		return '';
	}

	/**
	 * Persist message Api-key like the legacy Mersal plugin (mersal_access_token + mnotif_api_key).
	 *
	 * @param string $key Api-key from user.api_key on mersal.it.
	 */
	private function save_message_api_key( $key ) {
		$key = $this->normalize_stored_api_key( $key );
		if ( '' === $key ) {
			return;
		}
		update_option( self::OPT_APIKEY, $key, false );
	}

	/**
	 * Preserve full Api-key strings (do not use sanitize_text_field — it can alter keys).
	 *
	 * @param mixed $key Raw key.
	 * @return string
	 */
	private function normalize_stored_api_key( $key ) {
		$key = trim( (string) $key );
		if ( '' === $key ) {
			return '';
		}
		// wp_check_invalid_utf8() returns the cleaned string when valid — never use it as a boolean.
		$clean = wp_check_invalid_utf8( $key, true );
		return is_string( $clean ) ? trim( $clean ) : '';
	}

	/**
	 * @param string $key Api-key.
	 */
	private function store_api_key( $key ) {
		$this->save_message_api_key( $key );
	}

	/**
	 * Apply user.api_key from /api/wordpress/user/me (OLD Mersal connector behaviour).
	 *
	 * @param array $user User node from API.
	 */
	private function apply_user_api_key_from_me( array $user ) {
		$key = $this->resolve_api_key_from_user( $user );
		if ( '' === $key ) {
			return;
		}
		// OLD Mersal: always trust user.api_key from /api/wordpress/user/me.
		$key = $this->normalize_stored_api_key( $key );
		if ( '' === $key ) {
			return;
		}
		update_option( self::OPT_APIKEY, $key, false );
	}

	/**
	 * User object from /api/wordpress/user/me JSON.
	 *
	 * @param array $json Decoded response.
	 * @return array|null
	 */
	private function parse_user_from_me_json( array $json ) {
		if ( isset( $json['user'] ) && is_array( $json['user'] ) ) {
			return $json['user'];
		}
		if ( isset( $json['data']['user'] ) && is_array( $json['data']['user'] ) ) {
			return $json['data']['user'];
		}
		if ( isset( $json['data'] ) && is_array( $json['data'] ) && ! empty( $json['data']['email'] ) ) {
			return $json['data'];
		}
		if ( ! empty( $json['email'] ) ) {
			return array(
				'email' => $json['email'],
				'name'  => $json['name'] ?? ( $json['user_name'] ?? '' ),
				'plan'  => $json['plan'] ?? '',
			);
		}
		return null;
	}

	/**
	 * Extract a message Api-key from a /user/me JSON payload.
	 *
	 * @param array $json Decoded response.
	 * @return string
	 */
	private function extract_message_api_key( array $json ) {
		$paths = array(
			array( 'api_key' ),
			array( 'apiKey' ),
			array( 'data', 'api_key' ),
			array( 'data', 'apiKey' ),
			array( 'user', 'api_key' ),
			array( 'user', 'apiKey' ),
			array( 'account', 'api_key' ),
		);
		foreach ( $paths as $path ) {
			$val = $json;
			foreach ( $path as $segment ) {
				if ( ! is_array( $val ) || ! isset( $val[ $segment ] ) ) {
					$val = null;
					break;
				}
				$val = $val[ $segment ];
			}
			if ( ( is_string( $val ) || is_numeric( $val ) ) && '' !== trim( (string) $val ) ) {
				return trim( (string) $val );
			}
		}
		return '';
	}

	/**
	 * Headers for /api/wordpress/user/me (aligned with the legacy Mersal connector).
	 *
	 * @param string $token Bearer token.
	 * @return array
	 */
	private function user_me_headers( $token ) {
		$theme = wp_get_theme();
		return array(
			'Authorization'    => 'Bearer ' . $token,
			'Accept'           => 'application/json',
			'X-Site-Url'       => get_site_url(),
			'X-Site-Email'     => (string) get_option( 'admin_email', '' ),
			'X-Plugin-Version' => MNOTIF_VERSION,
			'X-Wp-Version'     => (string) get_bloginfo( 'version' ),
			'X-Sso-Key'        => (string) get_option( 'mersal_sso_shared_key', '' ),
			'X-Remote-Support' => get_option( 'mersal_remote_support_enabled', '0' ) === '1' ? '1' : '0',
			'X-Active-Theme'   => $theme->get( 'Name' ),
			'X-Locale'         => get_locale(),
			'X-Mersal-App'     => self::MERSAL_APP,
		);
	}

	/**
	 * Headers for /api/whatsapp/send (legacy Mersal — Api-key only, no Bearer).
	 *
	 * @param string $api_key Message Api-key from user.api_key on mersal.it.
	 * @return array
	 */
	private function whatsapp_send_headers( $api_key, $with_bearer = true ) {
		$headers = array(
			'Content-Type' => 'application/json',
			'Accept'       => 'application/json',
			'User-Agent'   => 'MERSAL-Client/1.3 (+WordPress)',
			'Api-key'      => $api_key,
			'X-Api-Key'    => $api_key,
			// Identify the origin site + product so mersal.it can apply the
			// add-on's unlimited-sending allowance to this call.
			'X-Site-Url'   => get_site_url(),
			'X-Mersal-App' => self::MERSAL_APP,
		);
		if ( $with_bearer ) {
			$token = (string) get_option( self::OPT_TOKEN, '' );
			if ( $token ) {
				$headers['Authorization'] = 'Bearer ' . $token;
			}
		}
		return $headers;
	}

	/**
	 * POST /api/email/send (Api-key header).
	 *
	 * @param string $api_key  Message Api-key.
	 * @param string $email    Recipient email.
	 * @param string $subject  Subject.
	 * @param string $message  Plain text body.
	 * @param string $gateway  Optional gateway uid.
	 * @return array{ok:bool,message:string}
	 */
	/**
	 * POST /api/sms/send (same shape as WhatsApp send).
	 *
	 * @param string $api_key Message Api-key.
	 * @param string $phone   Phone digits.
	 * @param string $message Text.
	 * @param string $gateway Gateway uid.
	 * @return array{ok:bool,message:string}
	 */
	private function sms_send_request( $api_key, $phone, $message, $gateway ) {
		$api_key = $this->normalize_stored_api_key( $api_key );
		if ( '' === $api_key ) {
			return array(
				'ok'      => false,
				'message' => __( 'Message API key is empty.', 'mersal-notifications' ),
			);
		}

		$phones = array( (string) $phone );
		if ( $phone && '+' !== $phone[0] ) {
			$phones[] = '+' . ltrim( (string) $phone, '+' );
		}

		$last_err = '';
		foreach ( $phones as $number ) {
			$contact = array(
				'number'  => $number,
				'message' => $message,
			);
			if ( '' !== (string) $gateway ) {
				$contact['gateway_identifier'] = (string) $gateway;
			}

			$payload = array( 'contact' => array( $contact ) );

			$attempts = array(
				array(
					'url'     => $this->site_url() . '/api/sms/send',
					'headers' => $this->whatsapp_send_headers( $api_key ),
				),
				array(
					'url'     => add_query_arg( 'api_key', rawurlencode( $api_key ), $this->site_url() . '/api/sms/send' ),
					'headers' => $this->whatsapp_send_headers( $api_key, false ),
				),
			);

			foreach ( $attempts as $attempt ) {
				$resp = wp_remote_post(
					$attempt['url'],
					array(
						'timeout' => 20,
						'headers' => $attempt['headers'],
						'body'    => wp_json_encode( $payload ),
					)
				);

				if ( is_wp_error( $resp ) ) {
					$last_err = $resp->get_error_message();
					continue;
				}

				$code = wp_remote_retrieve_response_code( $resp );
				if ( $code >= 200 && $code < 300 ) {
					delete_option( 'mnotif_connect_last_error' );
					delete_option( 'mnotif_connect_error_code' );
					return array(
						'ok'      => true,
						'message' => __( 'Verification code sent via SMS.', 'mersal-notifications' ),
					);
				}

				$last_err = $this->parse_api_error_message( $resp );
				if ( $last_err && ! $this->is_api_key_required_message( $last_err ) ) {
					break 2;
				}
			}
		}

		if ( $last_err ) {
			$this->record_connect_error( $last_err );
		}

		return array(
			'ok'      => false,
			'message' => $last_err ? $last_err : __( 'SMS gateway rejected the request.', 'mersal-notifications' ),
		);
	}

	/**
	 * Pull a gateway message id from assorted mersal.it response shapes.
	 *
	 * @param mixed $body Decoded JSON body.
	 * @return string
	 */
	private function extract_message_id_from_body( $body ) {
		if ( ! is_array( $body ) ) {
			return '';
		}
		$candidates = array(
			$body['data']['messageId'] ?? '',
			$body['messageId'] ?? '',
			$body['data']['message_id'] ?? '',
			$body['message_id'] ?? '',
		);
		if ( ! empty( $body['data']['contact'][0]['messageId'] ) ) {
			$candidates[] = $body['data']['contact'][0]['messageId'];
		}
		if ( ! empty( $body['contact'][0]['messageId'] ) ) {
			$candidates[] = $body['contact'][0]['messageId'];
		}
		foreach ( $candidates as $id ) {
			$id = (string) $id;
			if ( '' !== $id ) {
				return $id;
			}
		}
		return '';
	}

	private function email_send_request( $api_key, $email, $subject, $message, $gateway = '' ) {
		$api_key = $this->normalize_stored_api_key( $api_key );
		if ( '' === $api_key ) {
			return array( 'ok' => false, 'message' => __( 'Message API key is empty.', 'mersal-notifications' ) );
		}

		$contact = array(
			'email'   => $email,
			'subject' => (string) $subject,
			'message' => (string) $message,
		);
		if ( $gateway ) {
			$contact['gateway_identifier'] = (string) $gateway;
		}

		$resp = wp_remote_post(
			$this->site_url() . '/api/email/send',
			array(
				'timeout' => 20,
				'headers' => $this->whatsapp_send_headers( $api_key ),
				'body'    => wp_json_encode( array( 'contact' => array( $contact ) ) ),
			)
		);

		if ( is_wp_error( $resp ) ) {
			$this->record_connect_error( $resp->get_error_message() );
			return array( 'ok' => false, 'message' => $resp->get_error_message() );
		}

		$code = wp_remote_retrieve_response_code( $resp );
		$ok   = $code >= 200 && $code < 300;
		if ( $ok ) {
			delete_option( 'mnotif_connect_last_error' );
			delete_option( 'mnotif_connect_error_code' );
			return array( 'ok' => true, 'message' => __( 'Verification code sent via Email.', 'mersal-notifications' ) );
		}

		$err = $this->parse_api_error_message( $resp );
		if ( $err ) {
			$this->record_connect_error( $err );
			return array( 'ok' => false, 'message' => $err );
		}
		return array( 'ok' => false, 'message' => __( 'Email gateway rejected the request.', 'mersal-notifications' ) );
	}

	/**
	 * HTTP headers for authenticated wordpress JSON APIs (Bearer SSO).
	 *
	 * @param string $api_key Optional Api-key for gateway list fallback.
	 * @return array
	 */
	private function api_headers( $api_key = '' ) {
		$headers = array(
			'Content-Type' => 'application/json',
			'Accept'       => 'application/json',
			'X-Site-Url'   => get_site_url(),
			'X-Mersal-App' => self::MERSAL_APP,
		);
		$token = get_option( self::OPT_TOKEN );
		if ( $token ) {
			$headers['Authorization'] = 'Bearer ' . $token;
		}
		if ( $api_key ) {
			$headers['Api-key'] = $api_key;
		}
		return $headers;
	}

	/**
	 * Persist api_key from a /user/me payload.
	 *
	 * @param array $user User object from API.
	 */
	private function persist_api_key_from_user( array $user ) {
		$this->apply_user_api_key_from_me( $user );
	}

	/**
	 * After syncing gateways, pre-select the first WhatsApp/SMS gateway in settings when empty.
	 */
	private function apply_gateway_defaults() {
		$s = Mersal_Notif_Settings::all();
		$changed = false;
		if ( empty( $s['wa_gateway'] ) ) {
			$wa = $this->first_gateway_uid( 'whatsapp' );
			if ( $wa ) {
				$s['wa_gateway'] = $wa;
				$changed         = true;
			}
		}
		if ( empty( $s['sms_gateway'] ) ) {
			$sms = $this->first_gateway_uid( 'sms' );
			if ( $sms ) {
				$s['sms_gateway'] = $sms;
				$changed          = true;
			}
		}
		if ( empty( $s['email_gateway'] ) ) {
			$em = $this->first_gateway_uid( 'email' );
			if ( $em ) {
				$s['email_gateway'] = $em;
				$changed            = true;
			}
		}
		if ( $changed ) {
			Mersal_Notif_Settings::update( $s );
		}
	}

	/**
	 * Whether a gateway row matches a channel type (admin UI helper).
	 *
	 * @param string $gtype Gateway channel/type string.
	 * @param string $type  whatsapp|sms|email.
	 * @return bool
	 */
	public function gateway_matches_type_public( $gtype, $type ) {
		return $this->gateway_matches_type( $gtype, $type );
	}

	/**
	 * Whether a gateway row matches a channel type.
	 *
	 * @param string $gtype Gateway channel/type string.
	 * @param string $type  whatsapp|sms.
	 * @return bool
	 */
	private function gateway_matches_type( $gtype, $type ) {
		$gtype = strtolower( (string) $gtype );
		if ( 'whatsapp' === $type ) {
			return false !== strpos( $gtype, 'whatsapp' ) || in_array( $gtype, array( 'wa', 'wawp', 'whatsapp_web' ), true );
		}
		if ( 'sms' === $type ) {
			if ( 'sms' === $gtype || false !== strpos( $gtype, 'sms' ) ) {
				return true;
			}
			// Android SIM gateways on mersal.it are often typed as device/android, not "sms".
			if ( in_array( $gtype, array( 'android', 'device', 'sim', 'mobile', 'phone' ), true ) ) {
				return true;
			}
			if ( false !== strpos( $gtype, 'android' ) && false === strpos( $gtype, 'whatsapp' ) && false === strpos( $gtype, 'wa' ) ) {
				return true;
			}
			return false;
		}
		if ( 'email' === $type ) {
			return 'email' === $gtype || false !== strpos( $gtype, 'email' ) || false !== strpos( $gtype, 'smtp' );
		}
		return false !== strpos( $gtype, $type );
	}

	/**
	 * Find the first gateway uid of a given type from the connected account.
	 *
	 * @param string $type whatsapp|sms.
	 * @return string
	 */
	public function first_gateway_uid( $type ) {
		$candidates = $this->gateway_candidates( $type, '' );
		return ! empty( $candidates ) ? (string) $candidates[0] : '';
	}

	/**
	 * Ordered gateway identifiers to try for a channel (active first).
	 *
	 * @param string $type           whatsapp|sms|email.
	 * @param string $preferred_uid  Admin-selected gateway_identifier.
	 * @return string[]
	 */
	public function gateway_candidates( $type, $preferred_uid = '' ) {
		$preferred_uid = trim( (string) $preferred_uid );
		$active        = array();
		$inactive      = array();
		$preferred     = '';

		foreach ( $this->gateways() as $g ) {
			$g     = (array) $g;
			$gtype = strtolower( (string) ( $g['type'] ?? $g['channel'] ?? 'whatsapp' ) );
			if ( ! $this->gateway_matches_type( $gtype, $type ) ) {
				continue;
			}
			$send_id = $this->gateway_send_identifier( $g );
			if ( '' === $send_id ) {
				continue;
			}
			if ( $preferred_uid && $this->gateway_id_matches( $g, $preferred_uid ) ) {
				$preferred = $send_id;
				continue;
			}
			if ( $this->gateway_is_active( $g ) ) {
				$active[] = $send_id;
			} else {
				$inactive[] = $send_id;
			}
		}

		$active    = array_values( array_unique( $active ) );
		$inactive  = array_values( array_unique( $inactive ) );
		$ordered   = array();
		if ( $preferred ) {
			$ordered[] = $preferred;
		}
		foreach ( $active as $id ) {
			if ( ! in_array( $id, $ordered, true ) ) {
				$ordered[] = $id;
			}
		}
		foreach ( $inactive as $id ) {
			if ( ! in_array( $id, $ordered, true ) ) {
				$ordered[] = $id;
			}
		}
		return $ordered;
	}

	/**
	 * The value mersal.it expects in gateway_identifier when sending.
	 *
	 * @param array $g Normalized gateway row.
	 * @return string
	 */
	private function gateway_send_identifier( array $g ) {
		foreach ( array( 'gateway_identifier', 'uid', 'identifier', 'access_token' ) as $k ) {
			if ( ! empty( $g[ $k ] ) ) {
				return (string) $g[ $k ];
			}
		}
		foreach ( array( 'id', 'instance_id' ) as $k ) {
			if ( ! empty( $g[ $k ] ) ) {
				return (string) $g[ $k ];
			}
		}
		return '';
	}

	/**
	 * @param array  $g   Gateway row.
	 * @param string $needle Configured gateway id/uuid.
	 * @return bool
	 */
	private function gateway_id_matches( array $g, $needle ) {
		$needle = trim( (string) $needle );
		if ( '' === $needle ) {
			return false;
		}
		foreach ( array( 'gateway_identifier', 'uid', 'identifier', 'access_token', 'id', 'instance_id' ) as $k ) {
			if ( ! empty( $g[ $k ] ) && (string) $g[ $k ] === $needle ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * @param array $g Gateway row.
	 * @return bool
	 */
	private function gateway_is_active( array $g ) {
		foreach ( array( 'online', 'connected', 'active', 'is_online', 'is_connected', 'is_active' ) as $k ) {
			if ( ! array_key_exists( $k, $g ) ) {
				continue;
			}
			$val = $g[ $k ];
			if ( is_bool( $val ) ) {
				return $val;
			}
			$s = strtolower( trim( (string) $val ) );
			if ( in_array( $s, array( '1', 'true', 'yes', 'online', 'connected', 'active', 'open', 'ready', 'ok' ), true ) ) {
				return true;
			}
			if ( in_array( $s, array( '0', 'false', 'no', 'offline', 'disconnected', 'inactive', 'closed', 'error' ), true ) ) {
				return false;
			}
		}
		$status = strtolower( trim( (string) ( $g['status'] ?? '' ) ) );
		if ( in_array( $status, array( 'offline', 'disconnected', 'inactive', 'closed', 'error' ), true ) ) {
			return false;
		}
		if ( in_array( $status, array( 'online', 'connected', 'active', 'open', 'ready' ), true ) ) {
			return true;
		}
		return true;
	}

	/**
	 * @param string $message API error text.
	 * @return bool
	 */
	private function is_gateway_retryable_error( $message ) {
		$m = strtolower( (string) $message );
		if ( '' === $m ) {
			return false;
		}
		$needles = array(
			'sim is not active',
			'connected session',
			'not active',
			'offline',
			'disconnected',
			'no connected',
			'session',
			'gateway not found',
			'invalid gateway',
		);
		foreach ( $needles as $needle ) {
			if ( false !== strpos( $m, $needle ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Base mersal.it URL.
	 *
	 * @return string
	 */
	public function site_url() {
		$base = get_option( self::OPT_SITE, '' );
		if ( ! is_string( $base ) || '' === $base ) {
			$base = get_option( 'mersal_site_url', 'https://mersal.it' );
		}
		return rtrim( (string) $base, '/' );
	}

	/**
	 * The admin connect page URL (where SSO returns).
	 *
	 * @return string
	 */
	public function connect_page() {
		if ( class_exists( 'Mersal_Suite_Account' ) ) {
			return Mersal_Suite_Account::connect_url( 'nt' );
		}
		return admin_url( 'admin.php?page=' . MNOTIF_MENU_SLUG . '&mnotif_page=mersal' );
	}

	/**
	 * "Login by Mersal" launch URL.
	 *
	 * Points at an internal, nonce-protected launcher (handle_sso_launch) rather
	 * than straight at mersal.it. The launcher records a one-time "expect SSO
	 * return" flag before bouncing the admin to the provider, so the token that
	 * comes back in handle_token() can only be accepted after a deliberate,
	 * nonce-verified click — defeating account-linking CSRF.
	 *
	 * @return string
	 */
	public function launch_url() {
		return wp_nonce_url(
			add_query_arg( 'mnotif_sso_launch', '1', $this->connect_page() ),
			'mnotif_sso_launch'
		);
	}

	/**
	 * The real SSO provider URL on mersal.it.
	 *
	 * @return string
	 */
	public function sso_provider_url() {
		// `app` + `site_url` let mersal.it check this specific product's
		// entitlement and its connected-site allowance before issuing a token.
		// add_query_arg() does NOT encode values — they must be encoded here, or
		// the connect page's own &route= would leak out into this query string.
		return $this->site_url() . '/wordpress/sso'
			. '?redirect_to=' . rawurlencode( $this->connect_page() )
			. '&app=' . rawurlencode( self::MERSAL_APP )
			. '&site_url=' . rawurlencode( get_site_url() );
	}

	/**
	 * Internal launcher: verify the admin intent (capability + nonce), arm a
	 * short-lived one-time flag, then redirect to the mersal.it SSO provider.
	 */
	public function handle_sso_launch() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( empty( $_GET['mnotif_sso_launch'] ) || ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		check_admin_referer( 'mnotif_sso_launch' );
		set_transient( 'mnotif_sso_expect_' . get_current_user_id(), 1, 10 * MINUTE_IN_SECONDS );
		$sso_url = $this->sso_provider_url();
		if ( wp_http_validate_url( $sso_url ) && preg_match( '#^https://#i', $sso_url ) ) {
			wp_redirect( $sso_url );
			exit;
		}
	}

	/**
	 * Disconnect (nonced) URL.
	 *
	 * @return string
	 */
	public function disconnect_url() {
		return wp_nonce_url( add_query_arg( 'mnotif_disconnect', '1', $this->connect_page() ), 'mnotif_disconnect' );
	}

	/**
	 * Is an account connected?
	 *
	 * @return bool
	 */
	public function is_connected() {
		$token = get_option( self::OPT_TOKEN );
		if ( ! $token ) {
			return false;
		}
		if ( $this->user_has_identity( $this->get_user() ) ) {
			return true;
		}
		static $retried = false;
		if ( ! $retried ) {
			$retried = true;
			$this->validate( (string) $token );
		}
		return $this->user_has_identity( $this->get_user() );
	}

	/**
	 * Whether stored user payload looks like a linked mersal.it account.
	 *
	 * @param array $user User option array.
	 * @return bool
	 */
	private function user_has_identity( array $user ) {
		if ( empty( $user ) ) {
			return false;
		}
		foreach ( array( 'email', 'user_email', 'id', 'user_id', 'name', 'user_name' ) as $key ) {
			if ( ! empty( $user[ $key ] ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Stored Mersal user payload.
	 *
	 * @return array
	 */
	public function get_user() {
		$u = get_option( self::OPT_USER, array() );
		return is_array( $u ) ? $u : array();
	}

	/**
	 * Connected account email.
	 *
	 * @return string
	 */
	public function account_email() {
		$u = $this->get_user();
		return isset( $u['email'] ) ? $u['email'] : ( isset( $u['user_email'] ) ? $u['user_email'] : '' );
	}

	/**
	 * Connected Mersal account display name.
	 *
	 * @return string
	 */
	public function account_name() {
		$u = $this->get_user();
		foreach ( array( 'name', 'organization', 'company', 'display_name' ) as $key ) {
			if ( ! empty( $u[ $key ] ) ) {
				return (string) $u[ $key ];
			}
		}
		$email = $this->account_email();
		return $email ? $email : '';
	}

	/**
	 * Two-letter initials for the Mersal account avatar.
	 *
	 * @return string
	 */
	public function account_initials() {
		$name = $this->account_name();
		if ( $name ) {
			return strtoupper( substr( preg_replace( '/\s+/', '', $name ), 0, 2 ) );
		}
		return 'م';
	}

	/**
	 * Capture the SSO token returned by mersal.it.
	 */
	/**
	 * mersal.it refused the link and sent us back with ?error= instead of a
	 * token — most often because the account's add-on has no connected-site
	 * slot left, or the plan/add-on does not cover this plugin at all.
	 *
	 * Scoped to our own connect screen for the same reason handle_token() is:
	 * every Mersal plugin watches admin_init, so an unscoped match would
	 * hijack a sibling's return.
	 */
	public function handle_sso_error() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( empty( $_GET['error'] ) || ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$route = isset( $_GET['route'] ) ? sanitize_text_field( wp_unslash( $_GET['route'] ) ) : '';
		if ( 'mersal-suite' === $page ? 'nt.connect' !== $route : MNOTIF_MENU_SLUG !== $page ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$code = sanitize_key( wp_unslash( $_GET['error'] ) );
		if ( ! in_array( $code, array( 'site_limit_reached', 'plan_upgrade_required' ), true ) ) {
			return;
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$limit = isset( $_GET['limit'] ) ? absint( wp_unslash( $_GET['limit'] ) ) : 0;

		// The flow ended here, so disarm the one-time launch flag.
		delete_transient( 'mnotif_sso_expect_' . get_current_user_id() );

		$this->record_connect_error( $this->sso_error_message( $code, $limit ), $code );

		wp_safe_redirect(
			add_query_arg(
				'mnotif_connect_error',
				'1',
				remove_query_arg( array( 'error', 'limit', 'token' ), $this->connect_page() )
			)
		);
		exit;
	}

	/**
	 * Human-readable text for an SSO refusal code from mersal.it.
	 *
	 * @param string $code  Error slug.
	 * @param int    $limit Site allowance reported alongside it (0 = unknown).
	 * @return string
	 */
	private function sso_error_message( $code, $limit = 0 ) {
		if ( 'site_limit_reached' === $code ) {
			if ( $limit > 0 ) {
				return sprintf(
					/* translators: %d: number of WordPress sites included in the add-on. */
					__( 'Your Mersal add-on covers %d WordPress sites and they are all in use. Open mersal.it → Add-ons → Connected Sites and disconnect one, then link this site again.', 'mersal-notifications' ),
					$limit
				);
			}
			return __( 'All the WordPress sites included in your Mersal add-on are already in use. Open mersal.it → Add-ons → Connected Sites and disconnect one, then link this site again.', 'mersal-notifications' );
		}

		return __( 'Your Mersal plan does not include this plugin. Subscribe to it from mersal.it → Add-ons, then link this site again.', 'mersal-notifications' );
	}

	public function handle_token() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( empty( $_GET['token'] ) || ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}

		// Scope to our own connect screens (suite route nt.connect, or the
		// standalone page): every Mersal plugin watches admin_init for the SSO
		// ?token= return, so an unscoped match hijacks the siblings' flows.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$page = isset( $_GET['page'] ) ? sanitize_text_field( wp_unslash( $_GET['page'] ) ) : '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$route = isset( $_GET['route'] ) ? sanitize_text_field( wp_unslash( $_GET['route'] ) ) : '';
		if ( 'mersal-suite' === $page ? 'nt.connect' !== $route : MNOTIF_MENU_SLUG !== $page ) {
			return;
		}

		// CSRF guard: only honour a returning SSO token when this admin actually
		// started the flow via the nonce-protected launcher (handle_sso_launch).
		// Without it a crafted ...?token=ATTACKER link could silently link the
		// site to an attacker's Mersal account.
		$expect_key = 'mnotif_sso_expect_' . get_current_user_id();
		if ( ! get_transient( $expect_key ) ) {
			wp_safe_redirect( add_query_arg( 'mnotif_connect_error', '1', remove_query_arg( array( 'token', 'redirect_to' ), $this->connect_page() ) ) );
			exit;
		}
		delete_transient( $expect_key );

		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		$token = trim( wp_unslash( $_GET['token'] ) );
		if ( '' === $token ) {
			return;
		}

		delete_option( self::OPT_UNLINKED );
		update_option( self::OPT_TOKEN, $token, false );

		$ok = $this->validate( $token );
		if ( $ok ) {
			$this->request_message_api_key( $token );
			delete_option( 'mnotif_connect_last_error' );
			delete_option( 'mnotif_connect_error_code' );
		} elseif ( ! $this->last_connect_error() ) {
			$this->record_connect_error(
				__( 'Could not verify your Mersal account. Open Connect and click Sync now, or try signing in again.', 'mersal-notifications' )
			);
		}

		$redirect = remove_query_arg( array( 'token', 'redirect_to' ), $this->connect_page() );
		if ( ! $ok ) {
			$redirect = add_query_arg( 'mnotif_connect_error', '1', $redirect );
		} else {
			$redirect = add_query_arg( 'mnotif_connected', '1', $redirect );
		}

		wp_safe_redirect( $redirect );
		exit;
	}

	/**
	 * Handle a disconnect request.
	 */
	public function handle_disconnect() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( empty( $_GET['mnotif_disconnect'] ) || ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		check_admin_referer( 'mnotif_disconnect' );
		delete_option( self::OPT_TOKEN );
		delete_option( self::OPT_APIKEY );
		delete_option( self::OPT_USER );
		delete_option( self::OPT_GATEWAYS );
		delete_option( 'mnotif_connect_last_error' );
		delete_option( 'mnotif_connect_error_code' );
		update_option( self::OPT_UNLINKED, '1', false );
		wp_safe_redirect(
			add_query_arg(
				'mnotif_disconnected',
				'1',
				remove_query_arg( array( 'mnotif_disconnect', '_wpnonce' ), $this->connect_page() )
			)
		);
		exit;
	}

	/**
	 * Sync gateways from mersal.it (admin link, no full SSO).
	 */
	public function handle_sync_gateways() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended
		if ( empty( $_GET['mnotif_sync_gateways'] ) || ! is_admin() || ! current_user_can( 'manage_options' ) ) {
			return;
		}
		if ( class_exists( 'Mersal_Suite_Account' ) && ! Mersal_Suite_Account::is_connect_screen( 'nt' ) ) {
			return;
		}
		check_admin_referer( 'mnotif_sync_gateways' );

		if ( $this->is_connected() ) {
			$token = get_option( self::OPT_TOKEN );
			$this->validate( $token );
			$this->request_message_api_key( $token );
			$this->sync_gateways();
		}

		wp_safe_redirect(
			add_query_arg(
				'synced',
				'1',
				remove_query_arg( array( 'mnotif_sync_gateways', '_wpnonce' ), $this->connect_page() )
			)
		);
		exit;
	}

	/**
	 * Nonced URL to refresh account + gateways from mersal.it.
	 *
	 * @return string
	 */
	public function sync_gateways_url() {
		return wp_nonce_url(
			add_query_arg( 'mnotif_sync_gateways', '1', $this->connect_page() ),
			'mnotif_sync_gateways'
		);
	}

	/**
	 * Validate a token against mersal.it and store the account.
	 *
	 * @param string $token Token.
	 * @return bool
	 */
	public function validate( $token ) {
		if ( empty( $token ) ) {
			return false;
		}

		$resp = wp_remote_get(
			$this->site_url() . '/api/wordpress/user/me',
			array(
				'timeout' => 15,
				'headers' => $this->user_me_headers( $token ),
			)
		);

		if ( is_wp_error( $resp ) ) {
			$this->record_connect_error( $resp->get_error_message() );
			return false;
		}

		$code = wp_remote_retrieve_response_code( $resp );
		$raw  = wp_remote_retrieve_body( $resp );
		if ( 401 === $code || 403 === $code ) {
			delete_option( self::OPT_TOKEN );
			delete_option( self::OPT_USER );
			delete_option( self::OPT_APIKEY );
			$this->record_connect_error(
				__( 'Mersal session expired or was rejected. Please sign in again.', 'mersal-notifications' )
			);
			return false;
		}

		if ( $code < 200 || $code >= 300 ) {
			$this->record_connect_error(
				sprintf(
					/* translators: %d: HTTP status code from mersal.it */
					__( 'mersal.it returned HTTP %d while loading your account.', 'mersal-notifications' ),
					(int) $code
				)
			);
			return false;
		}

		$json = json_decode( $raw, true );
		if ( ! is_array( $json ) ) {
			$this->record_connect_error( __( 'Invalid response from mersal.it (not JSON).', 'mersal-notifications' ) );
			return false;
		}

		$user = $this->parse_user_from_me_json( $json );

		// Legacy Mersal: ok + user — accept user node whenever the API returns one.
		if ( ! is_array( $user ) ) {
			$this->ingest_user_me_json( $json );
			if ( $this->get_api_key() ) {
				$user = array(
					'email' => (string) get_option( 'admin_email', '' ),
					'name'  => '',
				);
			} else {
				$msg = $this->parse_api_error_message( $resp );
				$this->record_connect_error( $msg ? $msg : __( 'mersal.it did not return account details.', 'mersal-notifications' ) );
				return false;
			}
		}

		if ( empty( $json['ok'] ) && empty( $json['success'] ) && ! $this->user_has_identity( $user ) ) {
			$msg = $this->parse_api_error_message( $resp );
			$this->record_connect_error( $msg ? $msg : __( 'mersal.it rejected the site link request.', 'mersal-notifications' ) );
			return false;
		}

		if ( ! empty( $json['plan'] ) && empty( $user['plan'] ) ) {
			$user['plan'] = $json['plan'];
		}

		// Legacy parity: api_key arrives in this same /user/me payload (user.api_key).
		// ingest_user_me_json() already extracts and stores it — no extra requests.
		$this->ingest_user_me_json( $json );
		// API returns plan in `plan`; keep plan_name in sync for display helpers.
		if ( ! empty( $user['plan'] ) && empty( $user['plan_name'] ) ) {
			$user['plan_name'] = is_string( $user['plan'] ) ? $user['plan'] : ( is_array( $user['plan'] ) ? ( $user['plan']['name'] ?? '' ) : '' );
		}
		if ( $key = $this->resolve_api_key_from_user( $user ) ) {
			$user['api_key'] = $key;
		}
		update_option( self::OPT_USER, $user, false );

		if ( ! empty( $user['gateways'] ) ) {
			$embedded = $this->normalize_gateway_list( $user['gateways'] );
			if ( $embedded ) {
				update_option( self::OPT_GATEWAYS, $embedded, false );
				$this->apply_gateway_defaults();
			}
		}

		$this->sync_gateways();
		return true;
	}

	/**
	 * AJAX: re-validate / refresh the stored account.
	 */
	public function ajax_refresh() {
		check_ajax_referer( 'mnotif_nonce', 'nonce' );
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error();
		}
		$token = get_option( self::OPT_TOKEN );
		$ok    = $this->validate( $token );
		if ( $ok && ! $this->get_api_key() ) {
			$this->request_message_api_key( $token );
		}
		wp_send_json_success(
			array(
				'connected' => $ok,
				'gateways'  => count( $this->gateways() ),
				'has_api_key' => $this->has_message_api_key(),
			)
		);
	}

	/**
	 * Fetch gateways from mersal.it (/api/wordpress/gateways) and store locally.
	 *
	 * @return int Number of gateways synced.
	 */
	public function sync_gateways() {
		$token = get_option( self::OPT_TOKEN );
		if ( empty( $token ) ) {
			return 0;
		}

		$url     = $this->site_url() . '/api/wordpress/gateways';
		$headers = array(
			'Authorization' => 'Bearer ' . $token,
			'Accept'        => 'application/json',
		);

		$resp  = wp_remote_get(
			$url,
			array(
				'timeout' => 15,
				'headers' => $headers,
			)
		);
		$list  = $this->parse_gateways_response( $resp );
		$this->maybe_store_api_key_from_http( $resp );

		// Fallback: some installs authenticate gateways with Api-key only.
		if ( empty( $list ) ) {
			$api_key = $this->get_api_key();
			if ( $api_key ) {
				$resp2 = wp_remote_get(
					$url,
					array(
						'timeout' => 15,
						'headers' => array(
							'Api-key' => $api_key,
							'Accept'  => 'application/json',
						),
					)
				);
				$list = $this->parse_gateways_response( $resp2 );
				$this->maybe_store_api_key_from_http( $resp2 );
			}
		}

		if ( ! empty( $list ) ) {
			update_option( self::OPT_GATEWAYS, $list, false );
			$this->apply_gateway_defaults();
		}
		return count( $list );
	}

	/**
	 * @param array|WP_Error $resp HTTP response.
	 */
	private function maybe_store_api_key_from_http( $resp ) {
		if ( is_wp_error( $resp ) ) {
			return;
		}
		$json = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( ! is_array( $json ) ) {
			return;
		}
		$key = $this->extract_message_api_key( $json );
		if ( $key ) {
			$this->store_api_key( $key );
		}
	}

	/**
	 * Parse /api/wordpress/gateways response into normalized gateway rows.
	 *
	 * @param array|WP_Error $resp HTTP response.
	 * @return array
	 */
	private function parse_gateways_response( $resp ) {
		if ( is_wp_error( $resp ) ) {
			return array();
		}
		$code = wp_remote_retrieve_response_code( $resp );
		$json = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( $code < 200 || $code >= 300 || ! is_array( $json ) ) {
			return array();
		}

		$raw = null;
		if ( isset( $json['gateways'] ) ) {
			$raw = $json['gateways'];
		} elseif ( isset( $json['data'] ) && is_array( $json['data'] ) && isset( $json['data']['gateways'] ) ) {
			$raw = $json['data']['gateways'];
		} elseif ( isset( $json['items'] ) ) {
			$raw = $json['items'];
		}

		if ( null === $raw ) {
			return array();
		}
		return $this->normalize_gateway_list( $raw );
	}

	/**
	 * Normalize gateway payloads from mersal.it into a flat list.
	 *
	 * @param mixed $raw Gateways from API (list or associative).
	 * @return array
	 */
	private function normalize_gateway_list( $raw ) {
		if ( ! is_array( $raw ) ) {
			return array();
		}

		// Associative map id => gateway.
		if ( $this->is_associative_array( $raw ) ) {
			$items = array();
			foreach ( $raw as $key => $gw ) {
				if ( is_array( $gw ) ) {
					if ( empty( $gw['id'] ) && is_string( $key ) ) {
						$gw['id'] = $key;
					}
					$items[] = $gw;
				}
			}
			$raw = $items;
		}

		$out = array();
		foreach ( $raw as $gw ) {
			if ( ! is_array( $gw ) ) {
				continue;
			}
			$channel = strtolower( (string) ( $gw['channel'] ?? $gw['type'] ?? '' ) );
			if ( '' === $channel ) {
				if ( ! empty( $gw['supports_sms'] ) || ! empty( $gw['sms'] ) ) {
					$channel = 'sms';
				} elseif ( ! empty( $gw['supports_whatsapp'] ) || ! empty( $gw['whatsapp'] ) ) {
					$channel = 'whatsapp';
				} else {
					$channel = 'whatsapp';
				}
			}
			$send_id = (string) ( $gw['gateway_identifier'] ?? $gw['uid'] ?? $gw['identifier'] ?? $gw['access_token'] ?? $gw['token'] ?? '' );
			$id      = (string) ( $gw['id'] ?? $gw['instance_id'] ?? $send_id );
			$name    = (string) ( $gw['name'] ?? $gw['label'] ?? $id );
			if ( '' === $send_id && '' === $id ) {
				continue;
			}
			$row = array(
				'id'                   => $id,
				'uid'                  => $send_id ? $send_id : $id,
				'gateway_identifier'   => $send_id ? $send_id : $id,
				'identifier'           => $send_id ? $send_id : $id,
				'name'                 => $name,
				'channel'              => $channel,
				'type'                 => $channel,
				'status'               => (string) ( $gw['status'] ?? '' ),
				'address'              => (string) ( $gw['address'] ?? $gw['number'] ?? $gw['phone'] ?? $gw['msisdn'] ?? '' ),
			);
			foreach ( array( 'online', 'connected', 'active', 'is_online', 'is_connected', 'is_active' ) as $flag ) {
				if ( array_key_exists( $flag, $gw ) ) {
					$row[ $flag ] = $gw[ $flag ];
				}
			}
			$out[] = $row;
		}
		return $out;
	}

	/**
	 * @param array $arr Array to test.
	 * @return bool
	 */
	private function is_associative_array( array $arr ) {
		if ( array() === $arr ) {
			return false;
		}
		return array_keys( $arr ) !== range( 0, count( $arr ) - 1 );
	}

	/**
	 * Convenience: plan label for display.
	 *
	 * @return string
	 */
	public function plan_label() {
		$u = $this->get_user();
		if ( ! empty( $u['is_lifetime'] ) ) {
			return 'Lifetime';
		}

		foreach ( array( 'plan_name', 'plan', 'subscription', 'package', 'tier' ) as $key ) {
			if ( empty( $u[ $key ] ) ) {
				continue;
			}
			$val = $u[ $key ];
			if ( is_array( $val ) ) {
				$label = $val['name'] ?? ( $val['title'] ?? ( $val['label'] ?? '' ) );
				if ( $label ) {
					return (string) $label;
				}
			} elseif ( is_string( $val ) && '' !== trim( $val ) ) {
				return trim( $val );
			}
		}

		if ( isset( $u['is_free'] ) && ! $u['is_free'] ) {
			return __( 'Active', 'mersal-notifications' );
		}

		return 'Free';
	}

	/**
	 * Convenience: remaining message credit if present.
	 *
	 * @return string|null
	 */
	public function credit() {
		$u = $this->get_user();
		foreach ( array( 'credit', 'balance', 'messages_credit', 'wallet' ) as $k ) {
			if ( isset( $u[ $k ] ) && is_numeric( $u[ $k ] ) ) {
				return number_format_i18n( (float) $u[ $k ] );
			}
		}
		return null;
	}

	/**
	 * Remaining message credit per channel (whatsapp / sms / email) as reported by
	 * mersal.it. Returns null for a channel when the account doesn't expose it.
	 *
	 * @return array<string,int|null>
	 */
	public function channel_credits() {
		$u   = $this->get_user();
		$map = array(
			'whatsapp' => array( 'whatsapp_credit', 'wa_credit', 'whatsapp_balance' ),
			'sms'      => array( 'sms_credit', 'sms_balance' ),
			'email'    => array( 'email_credit', 'email_balance' ),
		);
		$out = array();
		foreach ( $map as $channel => $keys ) {
			$out[ $channel ] = null;
			foreach ( $keys as $k ) {
				if ( isset( $u[ $k ] ) && is_numeric( $u[ $k ] ) ) {
					$out[ $channel ] = (int) $u[ $k ];
					break;
				}
			}
		}
		return $out;
	}

	/**
	 * Re-fetch the account (/user/me) so live balances stay fresh — throttled to
	 * one call per minute to avoid hammering mersal.it's rate limiter.
	 *
	 * @param bool $force Skip the throttle.
	 */
	public function refresh_account( $force = false ) {
		$token = (string) get_option( self::OPT_TOKEN, '' );
		if ( '' === $token ) {
			return; // Api-key-only installs can't call the sanctum /user/me endpoint.
		}
		if ( ! $force && get_transient( 'mnotif_user_refreshed' ) ) {
			return;
		}
		set_transient( 'mnotif_user_refreshed', 1, MINUTE_IN_SECONDS );
		$this->validate( $token );
	}

	const OPT_NOTIFS = 'mnotif_notifications';

	/**
	 * Notifications for this install, pulled from mersal.it (admin-created,
	 * channel = wordpress). The plugin no longer creates notifications locally —
	 * they are managed at mersal.it/admndash/notifications/create.
	 *
	 * Throttled to one network call per 5 minutes (transient), with the last
	 * good payload cached in an option so the bell still renders offline.
	 *
	 * @param bool $force Skip the throttle and re-fetch now.
	 * @return array<int,array<string,mixed>> Newest first: id,title,body,type,link,time.
	 */
	public function notifications( $force = false ) {
		if ( ! $force && get_transient( 'mnotif_notifs_synced' ) ) {
			$cached = get_option( self::OPT_NOTIFS, array() );
			return is_array( $cached ) ? $cached : array();
		}

		$token   = (string) get_option( self::OPT_TOKEN, '' );
		$api_key = $this->get_api_key();
		if ( '' === $token && '' === $api_key ) {
			$cached = get_option( self::OPT_NOTIFS, array() );
			return is_array( $cached ) ? $cached : array();
		}

		set_transient( 'mnotif_notifs_synced', 1, 5 * MINUTE_IN_SECONDS );

		$url     = $this->site_url() . '/api/wordpress/notifications?app=suite';
		$headers = array( 'Accept' => 'application/json' );
		if ( '' !== $token ) {
			$headers['Authorization'] = 'Bearer ' . $token;
		} else {
			$headers['Api-key'] = $api_key;
		}

		$resp = wp_remote_get( $url, array( 'timeout' => 15, 'headers' => $headers ) );

		// Fallback to Api-key if the bearer attempt yielded nothing.
		$list = $this->parse_notifications_response( $resp );
		if ( empty( $list ) && '' !== $token && '' !== $api_key ) {
			$resp2 = wp_remote_get(
				$url,
				array(
					'timeout' => 15,
					'headers' => array( 'Api-key' => $api_key, 'Accept' => 'application/json' ),
				)
			);
			$alt = $this->parse_notifications_response( $resp2 );
			if ( ! empty( $alt ) ) {
				$list = $alt;
			}
		}

		// Only overwrite the cache on a successful (2xx) response so a transient
		// network error never blanks the bell.
		if ( $this->notifications_response_ok( $resp ) || ( isset( $resp2 ) && $this->notifications_response_ok( $resp2 ) ) ) {
			update_option( self::OPT_NOTIFS, $list, false );
			return $list;
		}

		$cached = get_option( self::OPT_NOTIFS, array() );
		return is_array( $cached ) ? $cached : array();
	}

	/**
	 * @param array|WP_Error $resp HTTP response.
	 * @return bool True on a 2xx JSON response.
	 */
	private function notifications_response_ok( $resp ) {
		if ( is_wp_error( $resp ) ) {
			return false;
		}
		$code = (int) wp_remote_retrieve_response_code( $resp );
		return $code >= 200 && $code < 300;
	}

	/**
	 * Parse /api/wordpress/notifications into the plugin's bell shape.
	 *
	 * @param array|WP_Error $resp HTTP response.
	 * @return array<int,array<string,mixed>>
	 */
	private function parse_notifications_response( $resp ) {
		if ( ! $this->notifications_response_ok( $resp ) ) {
			return array();
		}
		$json = json_decode( wp_remote_retrieve_body( $resp ), true );
		if ( ! is_array( $json ) || empty( $json['notifications'] ) || ! is_array( $json['notifications'] ) ) {
			return array();
		}

		$out = array();
		foreach ( $json['notifications'] as $n ) {
			if ( ! is_array( $n ) || empty( $n['title'] ) ) {
				continue;
			}
			$type = isset( $n['type'] ) ? (string) $n['type'] : 'info';
			if ( ! in_array( $type, array( 'info', 'success', 'warning', 'update' ), true ) ) {
				$type = 'info';
			}
			$out[] = array(
				'id'    => (string) ( $n['id'] ?? 'm_' . md5( wp_json_encode( $n ) ) ),
				'title' => sanitize_text_field( (string) $n['title'] ),
				'body'  => sanitize_textarea_field( (string) ( $n['body'] ?? '' ) ),
				'type'  => $type,
				'link'  => esc_url_raw( (string) ( $n['link'] ?? '' ) ),
				'time'  => (int) ( $n['time'] ?? time() ),
			);
		}
		return $out;
	}

	/**
	 * Account notifications shaped for the shared Suite bell (fed into the
	 * mersal_suite_notifications filter). Managed at
	 * mersal.it/admndash/notifications/create.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	public function suite_bell_items() {
		$map = array(
			'info'    => array( 'info', 'info' ),
			'success' => array( 'accent', 'check_circle' ),
			'warning' => array( 'warn', 'warning' ),
			'update'  => array( 'hi', 'system_update_alt' ),
		);
		$out = array();
		foreach ( $this->notifications() as $n ) {
			if ( empty( $n['title'] ) ) {
				continue;
			}
			$type                = isset( $n['type'] ) ? (string) $n['type'] : 'info';
			list( $tone, $icon ) = isset( $map[ $type ] ) ? $map[ $type ] : $map['info'];
			$out[]               = array(
				'id'     => isset( $n['id'] ) ? (string) $n['id'] : '',
				'icon'   => $icon,
				'tone'   => $tone,
				'title'  => (string) $n['title'],
				'sub'    => isset( $n['body'] ) ? (string) $n['body'] : '',
				'time'   => ! empty( $n['time'] ) ? sprintf( /* translators: %s: human-readable time difference. */ __( '%s ago', 'default' ), human_time_diff( (int) $n['time'] ) ) : '',
				'href'   => isset( $n['link'] ) ? (string) $n['link'] : '',
				'unread' => true,
			);
		}
		return $out;
	}

	/**
	 * Gateways pulled from the account, if any.
	 *
	 * @return array
	 */
	public function gateways() {
		$stored = get_option( self::OPT_GATEWAYS, array() );
		if ( is_array( $stored ) && ! empty( $stored ) ) {
			return $stored;
		}

		$u = $this->get_user();
		if ( ! empty( $u['gateways'] ) && is_array( $u['gateways'] ) ) {
			return $this->normalize_gateway_list( $u['gateways'] );
		}
		if ( ! empty( $u['instances'] ) && is_array( $u['instances'] ) ) {
			return $this->normalize_gateway_list( $u['instances'] );
		}
		return array();
	}
}
