<?php

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

trait WPMR_Account_Mgt {

	/**
	 * Save a notification settings block for a given prefix.
	 *
	 * Prefix is 'wpmr_scan_' or 'wpmr_pulse_'. Handles booleans and CC list.
	 *
	 * @param string $prefix Settings key prefix.
	 * @param array  $args   Raw form data.
	 * @return true
	 */
	private function _save_notify_block( $prefix, $args ) {
		$this->update_setting( $prefix . 'notify_licensee', ! empty( $args[ $prefix . 'notify_licensee' ] ) );
		$this->update_setting( $prefix . 'notify_registrant', ! empty( $args[ $prefix . 'notify_registrant' ] ) );
		$this->update_setting( $prefix . 'cc_enabled', ! empty( $args[ $prefix . 'cc_enabled' ] ) );

		$cc_raw   = isset( $args[ $prefix . 'cc_list' ] ) ? sanitize_textarea_field( wp_unslash( $args[ $prefix . 'cc_list' ] ) ) : '';
		$cc_lines = explode( "\n", $cc_raw );
		$cc_valid = array();
		$cc_dropped = 0;
		foreach ( $cc_lines as $line ) {
			$email = trim( $line );
			if ( '' === $email ) {
				continue;
			}
			if ( is_email( $email ) ) {
				$cc_valid[] = $email;
			} else {
				++$cc_dropped;
			}
		}
		$this->update_setting( $prefix . 'cc_list', implode( "\n", $cc_valid ) );

		if ( $cc_dropped > 0 ) {
			$this->flog( 'INFO: Dropped ' . $cc_dropped . ' invalid CC email(s) from ' . $prefix . ' settings.' );
		}

		return true;
	}

	/**
	 * Save Scheduled Scan notification settings.
	 *
	 * @param array $args Form data.
	 * @return true
	 */
	function save_scan_notify_settings( $args = array() ) {
		if ( ! is_array( $args ) ) {
			wp_die( 'Invalid settings data.' );
		}
		return $this->_save_notify_block( 'wpmr_scan_', $args );
	}

	/**
	 * Save Weekly Security Pulse notification settings.
	 *
	 * @param array $args Form data.
	 * @return true
	 */
	function save_pulse_notify_settings( $args = array() ) {
		if ( ! is_array( $args ) ) {
			wp_die( 'Invalid settings data.' );
		}
		return $this->_save_notify_block( 'wpmr_pulse_', $args );
	}

	/**
	 * Save a CC email list for a notification type.
	 *
	 * Validates each line as an email address. Invalid entries are dropped and logged.
	 * Called via AJAX from the notification metabox textarea blur handler.
	 *
	 * @param string $setting_key Full setting key (e.g. 'wpmr_scan_cc_list').
	 * @param string $raw_list    Raw textarea value.
	 * @return true
	 */
	function save_notify_cc_list( $setting_key, $raw_list ) {
		$raw_list = sanitize_textarea_field( wp_unslash( $raw_list ) );
		$lines    = explode( "\n", $raw_list );
		$valid    = array();
		$dropped  = 0;
		foreach ( $lines as $line ) {
			$email = trim( $line );
			if ( '' === $email ) {
				continue;
			}
			if ( is_email( $email ) ) {
				$valid[] = $email;
			} else {
				++$dropped;
			}
		}
		$this->update_setting( $setting_key, implode( "\n", $valid ) );

		if ( $dropped > 0 ) {
			$this->flog( 'INFO: Dropped ' . $dropped . ' invalid CC email(s) from ' . $setting_key . '.' );
		}

		return true;
	}

	/**
	 * Save a single notification toggle setting — always returns true.
	 *
	 * Wraps update_setting() so the AJAX dispatcher receives true even when
	 * the underlying update_option() returns false (value unchanged in DB).
	 *
	 * @param string $key   Setting key (e.g. 'wpmr_scan_notify_licensee').
	 * @param bool   $value Checkbox state.
	 * @return true
	 */
	function save_notify_toggle( $key, $value ) {
		$this->update_setting( $key, (bool) $value );
		return true;
	}

	/**
	 * Dismiss the weekly notification nudge for 7 days.
	 *
	 * Stores a timestamp so the notice reappears after the cooldown period.
	 *
	 * @return true
	 */
	function dismiss_notify_nudge() {
		$this->update_setting( 'wpmr_notify_nudge_dismissed', time() );
		return true;
	}

	/**
	 * Format a single issue row as a human-readable line for notification emails.
	 *
	 * Reads the `details` JSON column to extract type-specific fields.
	 * Falls back to a generic message when details are missing or unparseable.
	 *
	 * @param array $row Issue row from the issues table (ARRAY_A).
	 *                   Expected keys: issue_type, severity, details.
	 * @return string Single-line description, e.g. '"/wp-content/evil.php" — malicious'.
	 */
	private function wpmr_derive_issue_line( $row ) {
		$type     = isset( $row['issue_type'] ) ? $row['issue_type'] : 'unknown';
		$details  = isset( $row['details'] ) ? json_decode( $row['details'], true ) : null;
		if ( ! is_array( $details ) ) {
			$details = array();
		}

		// Severity: prefer details JSON, fall back to row column.
		$severity = isset( $details['severity'] ) ? $details['severity'] : ( isset( $row['severity'] ) ? $row['severity'] : 'unknown' );

		switch ( $type ) {
			case 'file':
				// pointer is the absolute file path string.
				$path = isset( $details['pointer'] ) ? $details['pointer'] : 'unknown file';
				return '"' . $path . '" — ' . $severity;

			case 'database':
				// pointer.table and pointer.id.
				$table = isset( $details['pointer']['table'] ) ? $details['pointer']['table'] : 'unknown';
				$id    = isset( $details['pointer']['id'] ) ? ' #' . $details['pointer']['id'] : '';
				return $table . $id . ' — ' . $severity;

			case 'vulnerability':
				// pointer is the plugin slug; comment is the human-readable name.
				$slug = isset( $details['pointer'] ) ? $details['pointer'] : 'unknown';
				$name = isset( $details['comment'] ) ? ' (' . $details['comment'] . ')' : '';
				return 'plugin: ' . $slug . $name . ' — ' . $severity;

			case 'user':
				// pointer is the user ID; comment.has_elevated_caps for role flag.
				// Extract the reason from comment.message (everything after "flagged: ").
				$uid       = isset( $details['pointer'] ) ? $details['pointer'] : '?';
				$elevated  = isset( $details['comment']['has_elevated_caps'] ) ? $details['comment']['has_elevated_caps'] : false;
				$role_hint = $elevated ? ' (elevated caps)' : '';
				$reason    = '';
				if ( ! empty( $details['comment']['message'] ) ) {
					$msg    = wp_strip_all_tags( $details['comment']['message'] );
					$prefix = 'flagged: ';
					$pos    = strpos( $msg, $prefix );
					$reason = false !== $pos ? ' — ' . substr( $msg, $pos + strlen( $prefix ) ) : '';
				}
				return 'User ID ' . $uid . $role_hint . $reason . ' — ' . $severity;

			default:
				return $type . ': see report for details — ' . $severity;
		}
	}

	/**
	 * Build a grouped threat summary for the infected scan notification email.
	 *
	 * Fetches all issues, groups by severity, and displays sample lines per group.
	 * Severities are ordered by priority: severe, vulnerable, suspicious, then remainder.
	 * Groups with more issues than $per_group append overflow counts.
	 *
	 * @param int $per_group Sample lines per severity group. Default 5, filterable via
	 *                       'wpmr_threat_summary_per_group'. Pass 0 for unlimited.
	 * @return string Formatted multi-line grouped threat summary, or empty string when no issues.
	 */
	private function wpmr_derive_threat_summary( $per_group = 5 ) {
		$per_group = (int) apply_filters( 'wpmr_threat_summary_per_group', $per_group );

		$total = $this->get_issue_count();
		if ( 0 === $total ) {
			return '';
		}

		$rows = $this->get_issues( 0 ); // fetch all
		if ( empty( $rows ) ) {
			return '';
		}

		// Group by severity.
		$groups = array();
		foreach ( $rows as $row ) {
			$details  = isset( $row['details'] ) ? json_decode( $row['details'], true ) : null;
			$severity = isset( $details['severity'] ) ? $details['severity'] : ( isset( $row['severity'] ) ? $row['severity'] : 'unknown' );
			$groups[ $severity ][] = $row;
		}

		// Severity display order.
		$order = array(
			'severe'      => 0,
			'vulnerable'  => 1,
			'suspicious'  => 2,
			'skipped'     => 3,
			'unreadable'  => 4,
		);

		// Sort groups by priority. Unknown severities go last.
		uksort(
			$groups,
			function ( $a, $b ) use ( $order ) {
				$pa = isset( $order[ $a ] ) ? $order[ $a ] : 999;
				$pb = isset( $order[ $b ] ) ? $order[ $b ] : 999;
				return $pa - $pb;
			}
		);

		$sections = array();

		foreach ( $groups as $severity => $issues ) {
			$count      = count( $issues );
			$header     = strtoupper( $severity ) . ' (' . $count . ')';
			$show_count = $per_group > 0 ? min( $count, $per_group ) : $count;
			$lines      = array( $header );

			for ( $i = 0; $i < $show_count; $i++ ) {
				$lines[] = '    ' . $this->wpmr_derive_issue_line( $issues[ $i ] );
			}

			if ( $per_group > 0 && $count > $per_group ) {
				$overflow = $count - $per_group;
				$lines[]  = '    ...and ' . $overflow . ' more';
			}

			$sections[] = implode( "\n", $lines );
		}

		return implode( "\n\n", $sections );
	}

	/**
	 * Send a scan completion or termination notification.
	 *
	 * Called from term_scan_routines() after a stateful scan finishes.
	 * Only sends for scheduled scans (WP-Cron). Browser and CLI scans are
	 * skipped — the operator is present.
	 *
	 * @param string $outcome     'clean', 'infected', or 'terminated'.
	 * @param int    $issue_count Number of issues detected.
	 * @param string $duration    Human-readable scan duration.
	 * @param int    $per_group  Sample lines per severity group in the threat summary. Default 2.
	 * @return bool True if email was sent, false if skipped or failed.
	 */
	function send_scan_notification( $outcome, $issue_count, $duration, $per_group = 5 ) {
		// Gate: Advanced Edition only.
		if ( ! $this->is_advanced_edition() ) {
			return false;
		}

		// Gate: only send for scheduled scans. Browser and CLI are skipped.
		$source = isset( $this->state['wpmr_scan_meta']['source'] )
			? $this->state['wpmr_scan_meta']['source']
			: 'browser';
		if ( 'scheduled' !== $source ) {
			return false;
		}

		// Guard: state must be populated (defends hard-kill path).
		if ( empty( $this->state ) || ! is_array( $this->state ) ) {
			$this->flog( 'ERROR: send_scan_notification() called without populated state.' );
			return false;
		}

		// Resolve recipients for scan notifications.
		$recipients = $this->resolve_notification_recipients( 'wpmr_scan_' );
		if ( empty( $recipients['to'] ) ) {
			return false;
		}

		$site_name  = get_bloginfo( 'name' );
		$report_url = admin_url( 'admin.php?page=wpmr_stateful_scanner' );

		// Resolve a human name for the greeting.
		// Checks toggles so the greeting matches who is actually receiving the email.
		// When both licensee and registrant are receiving, greets both by first name.
		$greeting    = '';
		$settings    = $this->get_wpmr_option();
		$licensee_on = array_key_exists( 'wpmr_scan_notify_licensee', $settings )
			? $settings['wpmr_scan_notify_licensee']
			: false;
		$registrant_on = array_key_exists( 'wpmr_scan_notify_registrant', $settings )
			? $settings['wpmr_scan_notify_registrant']
			: false;

		$reg_user = $this->get_setting( 'user' );
		$status   = get_transient( 'WPMR_license_status' );

		$licensee_name  = '';
		$registrant_name = '';
		if ( $licensee_on && ! empty( $status['customer_name'] ) ) {
			$parts          = explode( ' ', trim( $status['customer_name'] ), 2 );
			$licensee_name  = $parts[0];
		}
		if ( $registrant_on && ! empty( $reg_user['first_name'] ) ) {
			$registrant_name = $reg_user['first_name'];
		}

		if ( $licensee_name && $registrant_name ) {
			$greeting = $licensee_name . ', ' . $registrant_name;
		} elseif ( $licensee_name ) {
			$greeting = $licensee_name;
		} elseif ( $registrant_name ) {
			$greeting = $registrant_name;
		}
		$greeting_line = $greeting ? "Hello {$greeting},\n\n" : "Hi,\n\n";

		$ending = "\n\n—\n" . $this->get_plugin_simple_name() . "\n\nTo stop receiving these emails, visit your notification settings:\n" . admin_url( 'admin.php?page=wpmr_notifications' ) . "\n(Disabling security-related emails is not recommended.)";

		switch ( $outcome ) {
			case 'clean':
				$subject = $site_name . ' — scan clear';
				$message = $greeting_line . "Your scheduled scan on {$site_name} completed in {$duration}.\n\nEverything looks good. Nothing to worry about." . $ending;
				break;

			case 'infected':
				$subject     = $site_name . ' — ' . $issue_count . ' issue' . ( 1 === $issue_count ? '' : 's' ) . ' found';
				$threat_list = $this->wpmr_derive_threat_summary( $per_group );
				$message     = $greeting_line . "Your scheduled scan on {$site_name} completed in {$duration} and found {$issue_count} issue" . ( 1 === $issue_count ? '' : 's' ) . ".\n\n{$threat_list}\n\nTake a look:\n{$report_url}" . $ending;
				break;

			case 'terminated':
				$subject = $site_name . ' — scan did not finish';

				// Include who cancelled the scan when the source is known.
				switch ( $source ) {
					case 'browser':
						$term_note = ' was cancelled from the Malcure dashboard after ' . $duration . '.';
						break;
					case 'cli':
						$term_note = ' was cancelled by someone using WP-CLI after ' . $duration . '.';
						break;
					default:
						$term_note = ' was stopped after ' . $duration . '.';
						break;
				}

				$message = $greeting_line . "Your scheduled scan on {$site_name}" . $term_note . "\n\nRun a new scan:\n{$report_url}" . $ending;
				break;

			default:
				return false;
		}

		$headers = array(
			'Content-Type: text/plain; charset=UTF-8',
			'Auto-Submitted: auto-generated',
		);

		// Merge CC recipients into headers.
		if ( ! empty( $recipients['cc'] ) ) {
			foreach ( $recipients['cc'] as $cc_email ) {
				$headers[] = 'Cc: ' . $cc_email;
			}
		}

		$sent = @wp_mail( $recipients['to'], $subject, $message, $headers );

		if ( ! $sent ) {
			$this->flog( 'ERROR: wp_mail() returned false for ' . $outcome . ' scan notification.' );
		}

		return $sent;
	}

	/**
	 * Resolve notification recipients for a given notification type.
	 *
	 * Reads per-type settings identified by prefix ('wpmr_scan_' or 'wpmr_pulse_').
	 * Licensee email comes from the WPMR_license_status transient (may be stale).
	 * Registrant email comes from the persistent 'user' setting.
	 * CC recipients are validated via is_email() and deduplicated against To.
	 *
	 * @param string $prefix Settings key prefix ('wpmr_scan_' or 'wpmr_pulse_').
	 * @return array{to:string[], cc:string[]} Always returns both keys, even when empty.
	 */
	function resolve_notification_recipients( $prefix ) {
		$to = array();
		$cc = array();

		// Toggles default to true for new installs, but respect explicit false.
		// get_setting() returns false for both "key absent" and "value is false",
		// so we check the raw option array to distinguish.
		$settings = $this->get_wpmr_option();

		$key_licensee   = $prefix . 'notify_licensee';
		$key_registrant = $prefix . 'notify_registrant';

		$notify_licensee   = array_key_exists( $key_licensee, $settings ) ? $settings[ $key_licensee ] : true;
		$notify_registrant = array_key_exists( $key_registrant, $settings ) ? $settings[ $key_registrant ] : false;

		// License holder — from transient, respects per-type toggle
		$license_status = get_transient( 'WPMR_license_status' );
		if ( $notify_licensee && ! empty( $license_status['customer_email'] ) ) {
			$to[] = $license_status['customer_email'];
		}

		// Site registrant — from persistent setting, respects per-type toggle
		$user = $this->get_setting( 'user' );
		if ( $notify_registrant && ! empty( $user['user_email'] ) ) {
			$to[] = $user['user_email'];
		}

		// Deduplicate — licensee and registrant may be the same person
		$to = array_values( array_unique( $to ) );

		// Additional CCs — gated on cc_enabled toggle
		if ( $this->get_setting( $prefix . 'cc_enabled' ) ) {
			$cc_raw  = $this->get_setting( $prefix . 'cc_list' );
			$to_map  = array_flip( $to ); // fast dedup lookup
			if ( ! empty( $cc_raw ) ) {
				foreach ( explode( "\n", $cc_raw ) as $line ) {
					$email = trim( $line );
					if ( '' === $email ) {
						continue;
					}
					if ( ! is_email( $email ) ) {
						continue; // invalid entries silently skipped (validated on save)
					}
					if ( isset( $to_map[ $email ] ) ) {
						continue; // duplicate of a To address
					}
					$cc[]       = $email;
					$to_map[ $email ] = 1; // prevent duplicate CC entries
				}
			}
		}

		return array(
			'to' => $to,
			'cc' => $cc,
		);
	}

	/**
	 * Shared identity data for notification metaboxes.
	 *
	 * @return array{has_license:bool,licensee_name:string,licensee_email:string,is_registered:bool,reg_name:string,reg_email:string}
	 */
	private function _notify_identity_data() {
		$license_status = get_transient( 'WPMR_license_status' );
		$has_license    = ! empty( $license_status['customer_email'] );
		$licensee_name  = $has_license && isset( $license_status['customer_name'] ) ? $license_status['customer_name'] : '';
		$licensee_email = $has_license ? $license_status['customer_email'] : '';

		$user          = $this->get_setting( 'user' );
		$is_registered = ! empty( $user );
		$reg_name      = '';
		$reg_email     = '';
		if ( $is_registered ) {
			$first     = isset( $user['first_name'] ) ? $user['first_name'] : '';
			$last      = isset( $user['last_name'] ) ? $user['last_name'] : '';
			$reg_name  = trim( $first . ' ' . $last );
			$reg_email = isset( $user['user_email'] ) ? $user['user_email'] : '';
		}

		return compact( 'has_license', 'licensee_name', 'licensee_email', 'is_registered', 'reg_name', 'reg_email' );
	}

	/**
	 * Render recipient rows shared by scan and pulse metaboxes.
	 *
	 * @param string $prefix   Settings key prefix ('wpmr_scan_' or 'wpmr_pulse_').
	 * @param array  $identity From _notify_identity_data().
	 * @return void
	 */
	private function _render_recipient_rows( $prefix, $identity ) {
		extract( $identity );

		$notify_licensee   = $this->get_setting( $prefix . 'notify_licensee' );
		$notify_registrant = $this->get_setting( $prefix . 'notify_registrant' );
		$cc_enabled        = $this->get_setting( $prefix . 'cc_enabled' );
		$cc_list           = $this->get_setting( $prefix . 'cc_list' );

		if ( $has_license ) : ?>
		<div>
			<h4>Advanced Edition License holder (Recommended)</h4>
			<label>
				<input type="checkbox" name="<?php echo esc_attr( $prefix ); ?>notify_licensee" value="1" <?php checked( $notify_licensee ); ?> />
				<?php if ( $licensee_name ) : ?>
					<span title="<?php echo esc_attr( $licensee_email ); ?>"><?php echo esc_html( $licensee_name ); ?></span>
				<?php endif; ?>
			</label>
		</div>
		<?php endif;

		if ( $is_registered ) : ?>
		<div>
			<h4><?php echo esc_html( $this->get_plugin_simple_name() ); ?> &mdash; Plugin Registrant (Recommended)</h4>
			<label>
				<input type="checkbox" name="<?php echo esc_attr( $prefix ); ?>notify_registrant" value="1" <?php checked( $notify_registrant ); ?> />
				<?php if ( $reg_name ) : ?>
					<span title="<?php echo esc_attr( $reg_email ); ?>"><?php echo esc_html( $reg_name ); ?></span>
				<?php endif; ?>
			</label>
		</div>
		<?php endif; ?>

		<div>
			<h4>Additional recipients</h4>
			<label>
				<input type="checkbox" name="<?php echo esc_attr( $prefix ); ?>cc_enabled" value="1" <?php checked( $cc_enabled ); ?> />
				Cc'd on every notification
			</label>
			<p><strong>One email per line.</strong></p>
			<textarea name="<?php echo esc_attr( $prefix ); ?>cc_list" rows="6" placeholder="client@agency.com&#10;dev@example.com"><?php echo esc_textarea( $cc_list ); ?></textarea>
		</div>
		<?php
	}

	/**
	 * Render the Scheduled Scan Notifications metabox.
	 *
	 * @return void
	 */
	function render_scan_notify_metabox() {
		$identity = $this->_notify_identity_data();
		?>
		<form id="wpmr_scan_notify_form" method="post">
			<p><strong>Know if your site is clean or infected after every scheduled scan — without keeping a browser open or running scans manually.</strong></p>
			<?php $this->_render_recipient_rows( 'wpmr_scan_', $identity ); ?>
		</form>
		<?php
	}

	/**
	 * Render the Weekly Security Pulse metabox.
	 *
	 * @return void
	 */
	function render_pulse_notify_metabox() {
		$identity = $this->_notify_identity_data();
		$current_user = wp_get_current_user();
		$email        = $current_user->user_email;
		?>
		<form id="wpmr_pulse_notify_form" method="post">
			<p><strong>Get alerted the moment something is wrong — before malware spreads, before your checkout breaks, before Google blacklists your site, and before your emails stop delivering.</strong></p>
			<?php $this->_render_recipient_rows( 'wpmr_pulse_', $identity ); ?>
		</form>
		<p>
			<button type="button" id="wpmr_send_test_pulse" class="button">Send Test Pulse</button>
		</p>
		<p>A test email of "Malcure Security Pulse" will be sent to <strong><?php echo esc_html( $email ); ?></strong> using actual events from the past 7 days.</p>
		<script type="text/javascript">
			jQuery(document).ready(function($) {
				$('#wpmr_send_test_pulse').on('click', function(e) {
					e.preventDefault();
					showButtonSpinner('#wpmr_send_test_pulse', 'Sending\u00A0…');
					wpmr_ajax_request(
						'send_security_pulse_test',
						[],
						'<p>Test Security Pulse sent to <?php echo esc_js( $email ); ?>.</p>',
						'<p>Failed to send test email. Check your mail configuration.</p>'
					).always(function() {
						hideButtonSpinner('#wpmr_send_test_pulse');
					});
				});
			});
		</script>
		<?php
	}

	/**
	 * Render the Test Email metabox.
	 *
	 * Sends a test notification to the currently logged-in admin to verify
	 * the email delivery pipeline is working.
	 *
	 * @return void
	 */
	function render_notify_test_metabox() {
		$current_user = wp_get_current_user();
		$email        = $current_user->user_email;
		?>
		<p>Verify that notification emails can be delivered from this site.</p>
		<p>
			<button type="button" id="wpmr_send_test_email" class="button">Send Test Email</button>
		</p>
		<p>A test email will be sent to <strong><?php echo esc_html( $email ); ?></strong>.</p>
		<script type="text/javascript">
			jQuery(document).ready(function($) {
				$('#wpmr_send_test_email').on('click', function(e) {
					e.preventDefault();
					showButtonSpinner('#wpmr_send_test_email', 'Sending\u00A0…');
					wpmr_ajax_request(
						'send_test_notification',
						[],
						'<p>Test email sent to <?php echo esc_js( $email ); ?>.</p>',
						'<p>Failed to send test email. Check your mail configuration.</p>'
					).always(function() {
						hideButtonSpinner('#wpmr_send_test_email');
					});
				});
			});
		</script>
		<?php
	}

	/**
	 * Send a test notification email to the current user.
	 *
	 * Called via AJAX from the Test Email metabox. Uses the same HTML mail
	 * content type filter as the scanner notification emails.
	 *
	 * @return string Success message.
	 */
	function send_test_notification() {
		$current_user = wp_get_current_user();
		$to           = $current_user->user_email;
		$site_name    = get_bloginfo( 'name' );
		$site_url     = site_url();
		$settings_url = admin_url( 'admin.php?page=wpmr_notifications' );
		$date         = wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) );

		$subject = 'Malcure Test Notification — ' . $site_name;

		$message = '<html><body style="font-family: sans-serif; line-height: 1.6; color: #333;">';
		$message .= '<h2 style="color: #23282d;">Test Notification</h2>';
		$message .= '<p>This is a test email from <strong>' . esc_html( $this->get_plugin_simple_name() ) . '</strong> on ' . esc_html( $site_name ) . '.</p>';
		$message .= '<p>If you received this, your notification email delivery is working correctly.</p>';
		$message .= '<div style="padding: 15px; background-color: #f0f0f1; border-left: 4px solid #00ffff; margin: 15px 0;">';
		$message .= '<p style="margin: 0;"><strong>Site:</strong> <a href="' . esc_url( $site_url ) . '">' . esc_html( $site_name ) . '</a></p>';
		$message .= '<p style="margin: 5px 0 0 0;"><strong>Sent:</strong> ' . esc_html( $date ) . '</p>';
		$message .= '</div>';
		$message .= '<p><a href="' . esc_url( $settings_url ) . '" style="color: #0073aa;">Manage notification settings</a></p>';
		$message .= '</body></html>';

		$headers = array( 'Content-Type: text/html; charset=UTF-8' );

		add_filter( 'wp_mail_content_type', array( $this, 'set_mail_content_type' ) );
		$sent = wp_mail( $to, $subject, $message, $headers );
		remove_filter( 'wp_mail_content_type', array( $this, 'set_mail_content_type' ) );

		if ( ! $sent ) {
			$this->flog( 'ERROR: Test notification email failed to send to ' . $to );
			wp_die( 'Failed to send test email. Check your mail configuration.' );
		}

		$this->update_setting( 'wpmr_notify_last_test_sent', time() );
		$this->flog( 'INFO: Test notification email sent to ' . $to );

		return true;
	}

	/**
	 * Check whether the site has completed registration.
	 *
	 * Registration state is stored in the `user` setting.
	 *
	 * @return mixed Truthy registration payload when registered, otherwise falsy.
	 *
	 * // has_registration or get_registration_payload
	 */
	function is_registered() {
		return $this->get_setting( 'user' );
	}

	/**
	 * Send the weekly Security Pulse email — a plain-text activity summary.
	 *
	 * Hooked to `wpmr_daily` cron. Six sequential gates before send:
	 * 1. Advanced Edition only.
	 * 2. At least one pulse toggle (licensee or registrant) must be enabled.
	 * 3. If `wpmr_last_pulse_sent` is absent, anchor it now — first pulse fires
	 *    in 7 days, not immediately (toggle-time anchor).
	 * 4. 7-day rate limit via `wpmr_last_pulse_sent`.
	 * 5. Recipient resolution via `resolve_notification_recipients('wpmr_pulse_')`.
	 * 6. Body non-empty (build_security_pulse_body()).
	 *
	 * On send: plain-text headers, CC merged, `wpmr_last_pulse_sent` updated.
	 * On failure: error logged, anchor NOT updated — retries next cron tick.
	 *
	 * @return void
	 */
	function send_security_pulse() {
		// Gate: Advanced Edition only.
		if ( ! $this->is_advanced_edition() ) {
			return;
		}

		// Gate: at least one pulse toggle must be enabled.
		$settings = $this->get_wpmr_option();
		$licensee_on = array_key_exists( 'wpmr_pulse_notify_licensee', $settings )
			? $settings['wpmr_pulse_notify_licensee']
			: true;
		$registrant_on = array_key_exists( 'wpmr_pulse_notify_registrant', $settings )
			? $settings['wpmr_pulse_notify_registrant']
			: false;
		if ( ! $licensee_on && ! $registrant_on ) {
			$this->flog( 'INFO: Security Pulse skipped — both pulse toggles disabled.' );
			return;
		}

		// Toggle-time anchor: if never set, initialize to now so the first pulse
		// fires 7 days from toggle-enable time, not immediately.
		$last_sent = $this->get_setting( 'wpmr_last_pulse_sent' );
		if ( ! $last_sent ) {
			$this->update_setting( 'wpmr_last_pulse_sent', time() );
			$this->flog( 'INFO: Security Pulse rate-limit anchor initialised. First pulse in 7 days.' );
			return;
		}

		// Rate limit: 7 days between pulses.
		if ( ( time() - $last_sent ) < 7 * DAY_IN_SECONDS ) {
			return;
		}

		// Resolve recipients.
		$recipients = $this->resolve_notification_recipients( 'wpmr_pulse_' );
		if ( empty( $recipients['to'] ) ) {
			$this->flog( 'INFO: Security Pulse skipped — no recipients resolved.' );
			return;
		}

		// Build body.
		$body = $this->build_security_pulse_body();
		if ( '' === $body ) {
			$this->flog( 'INFO: Security Pulse skipped — empty body.' );
			return;
		}

		$site_name = get_bloginfo( 'name' );
		$subject   = 'Malcure Security Pulse — ' . $site_name;

		$headers = array(
			'Content-Type: text/plain; charset=UTF-8',
			'Auto-Submitted: auto-generated',
		);

		// Merge CC recipients into headers.
		if ( ! empty( $recipients['cc'] ) ) {
			foreach ( $recipients['cc'] as $cc_email ) {
				$headers[] = 'Cc: ' . $cc_email;
			}
		}

		$sent = @wp_mail( $recipients['to'], $subject, $body, $headers );

		if ( ! $sent ) {
			$this->flog( 'ERROR: Security Pulse wp_mail() returned false.' );
			return;
		}

		$this->update_setting( 'wpmr_last_pulse_sent', time() );
		$this->flog( 'INFO: Security Pulse sent to ' . implode( ', ', $recipients['to'] ) . '.' );
	}

	/**
	 * Build the Security Pulse plain-text email body from wp_wpmr_events.
	 *
	 * Pure function — returns a string. No side effects. Testable via WP-CLI:
	 *   wp eval 'echo WPMR_Init::get_instance()->build_security_pulse_body();'
	 *
	 * @return string Formatted pulse body, or empty string if no events and no scan schedule.
	 */
	function build_security_pulse_body() {
		global $wpdb;

		$since = gmdate( 'Y-m-d H:i:s', time() - 7 * DAY_IN_SECONDS );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Security Pulse reads current event truth for the weekly digest.
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				'SELECT event_name, event_data, user_id, ip_address, created_at FROM %i WHERE created_at > %s ORDER BY created_at DESC',
				$this->table_events,
				$since
			)
		);
		// phpcs:enable

		// Group events by name, decode JSON payloads.
		$grouped = array();
		if ( ! empty( $rows ) ) {
			foreach ( $rows as $row ) {
				$data               = json_decode( $row->event_data, true );
				if ( ! is_array( $data ) ) {
					$data = array();
				}
				$data['_user_id']   = (int) $row->user_id;
				$data['_ip']        = $row->ip_address;
				$data['_created_at'] = $row->created_at;
				$grouped[ $row->event_name ][] = $data;
			}
		}

		// Resolve elevated-cap user IDs for login_success gating.
		$elevated_ids = $this->_pulse_elevated_user_ids( $grouped );

		// Build five sections.
		$sections = array();

		$threats = $this->_pulse_section_threats( $grouped );
		if ( '' !== $threats ) {
			$sections[] = "THREATS\n" . $threats;
		}

		$changes = $this->_pulse_section_changes( $grouped, $elevated_ids );
		if ( '' !== $changes ) {
			$sections[] = "CHANGES\n" . $changes;
		}

		$scans = $this->_pulse_section_scans( $grouped );
		if ( '' !== $scans ) {
			$sections[] = "SCANS\n" . $scans;
		}

		$remediation = $this->_pulse_section_remediation( $grouped );
		if ( '' !== $remediation ) {
			$sections[] = "REMEDIATION\n" . $remediation;
		}

		$misc = $this->_pulse_section_others( $grouped );
		if ( '' !== $misc ) {
			$sections[] = "OTHERS\n" . $misc;
		}

		// All-quiet week.
		if ( empty( $sections ) ) {
			return $this->_pulse_all_quiet_body();
		}

		$greeting  = $this->_pulse_greeting();
		$preamble  = "Please feel free to skim over this email. It is a weekly summary of what happened on your site — threats, changes, scans, and remediation actions. It is important for you to know what happened so nothing catches you by surprise.";
		$body      = "Hello {$greeting},\n\n{$preamble}\n\n";
		$body     .= implode( "\n\n", $sections );
		$settings  = admin_url( 'admin.php?page=wpmr_notifications' );
		$log_url   = admin_url( 'admin.php?page=wpmr_logs' );
		$body     .= "\n\nReview your event log:\n{$log_url}\n\n—\n" . $this->get_plugin_simple_name() . "\n\nTo stop receiving these emails, visit your notification settings:\n{$settings}\n(Disabling security-related emails is not recommended.)";

		return $body;
	}

	/**
	 * Build the all-quiet variant of the Security Pulse body.
	 *
	 * @return string
	 */
	private function _pulse_all_quiet_body() {
		$preamble  = "Please feel free to skim over this email. It is a weekly summary of what happened on your site — threats, changes, scans, and remediation actions. It is important for you to know what happened so nothing catches you by surprise.";
		$greeting  = $this->_pulse_greeting();
		$settings  = admin_url( 'admin.php?page=wpmr_notifications' );

		$log_url   = admin_url( 'admin.php?page=wpmr_logs' );
		return "Hello {$greeting},\n\n{$preamble}\n\nNothing happened this week that needs your attention. All scans ran clean. No threats detected. No unexpected changes.\n\nWe'll let you know if anything changes.\n\nReview your event log:\n{$log_url}\n\n—\n" . $this->get_plugin_simple_name() . "\n\nTo stop receiving these emails, visit your notification settings:\n{$settings}\n(Disabling security-related emails is not recommended.)";
	}

	/**
	 * Resolve the greeting name for the Security Pulse.
	 *
	 * Uses the same logic as send_scan_notification(): greets licensee + registrant
	 * by first name when both are receiving.
	 *
	 * @return string Greeting name, or empty string.
	 */
	private function _pulse_greeting() {
		$greeting    = '';
		$settings    = $this->get_wpmr_option();
		$licensee_on = array_key_exists( 'wpmr_pulse_notify_licensee', $settings )
			? $settings['wpmr_pulse_notify_licensee']
			: true;
		$registrant_on = array_key_exists( 'wpmr_pulse_notify_registrant', $settings )
			? $settings['wpmr_pulse_notify_registrant']
			: false;

		$status  = get_transient( 'WPMR_license_status' );
		$reg     = $this->get_setting( 'user' );

		$licensee_name   = '';
		$registrant_name = '';
		if ( $licensee_on && ! empty( $status['customer_name'] ) ) {
			$parts         = explode( ' ', trim( $status['customer_name'] ), 2 );
			$licensee_name = $parts[0];
		}
		if ( $registrant_on && ! empty( $reg['first_name'] ) ) {
			$registrant_name = $reg['first_name'];
		}

		if ( $licensee_name && $registrant_name ) {
			$greeting = $licensee_name . ', ' . $registrant_name;
		} elseif ( $licensee_name ) {
			$greeting = $licensee_name;
		} elseif ( $registrant_name ) {
			$greeting = $registrant_name;
		}
		return $greeting;
	}

	/**
	 * Collect user IDs that have elevated capabilities (beyond subscriber/contributor).
	 *
	 * Used to gate login_success reporting — only elevated-cap logins are surfaced.
	 * Uses the same exclusion logic as scan_user() SAFE_CAPS.
	 *
	 * @param array $grouped Events grouped by event_name.
	 * @return array<int,bool> User ID => true for elevated-cap users.
	 */
	private function _pulse_elevated_user_ids( $grouped ) {
		global $wpdb;

		if ( empty( $grouped['login_success'] ) ) {
			return array();
		}

		// Collect unique user IDs from login_success events.
		$user_ids = array();
		foreach ( $grouped['login_success'] as $ev ) {
			$uid = isset( $ev['user_id'] ) ? (int) $ev['user_id'] : 0;
			if ( $uid > 0 ) {
				$user_ids[ $uid ] = true;
			}
		}
		if ( empty( $user_ids ) ) {
			return array();
		}

		$ids     = array_keys( $user_ids );
		$elevated = array();

		// Batch-fetch capabilities from usermeta.
		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Pulse reads current user capability truth; {$placeholders} generates %d tokens for $wpdb->prepare() IN-clause.
		$caps_rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT user_id, meta_value FROM {$wpdb->usermeta} WHERE user_id IN ({$placeholders}) AND meta_key = %s",
				array_merge( $ids, array( $wpdb->prefix . 'capabilities' ) )
			)
		);
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared

		if ( empty( $caps_rows ) ) {
			return array();
		}

		// Define safe caps inline (mirrors WPMR_Scanner::SAFE_CAPS).
		$safe_caps = array(
			'subscriber'   => true,
			'contributor'  => true,
			'read'         => true,
			'edit_posts'   => true,
			'delete_posts' => true,
			'level_0'      => true,
			'level_1'      => true,
			'level_2'      => true,
			'level_3'      => true,
			'level_4'      => true,
			'level_5'      => true,
			'level_6'      => true,
			'level_7'      => true,
			'level_8'      => true,
			'level_9'      => true,
			'level_10'     => true,
		);

		foreach ( $caps_rows as $row ) {
			$caps = maybe_unserialize( $row->meta_value );
			if ( ! is_array( $caps ) ) {
				continue;
			}
			$caps_true = array_filter( $caps );
			$excess    = array_diff_key( $caps_true, $safe_caps );
			if ( ! empty( $excess ) ) {
				$elevated[ (int) $row->user_id ] = true;
			}
		}

		return $elevated;
	}

	/**
	 * Build the THREATS section of the Security Pulse.
	 *
	 * Covers: failed_login, user_created, user_role_changed, file_edited, xmlrpc_post_published.
	 *
	 * @param array $grouped Events grouped by event_name.
	 * @return string Section body, or empty string.
	 */
	private function _pulse_section_threats( $grouped ) {
		$lines = array();

		// Define event types and their display labels for this section.
		$threat_events = array(
			'failed_login'          => 'Failed logins',
			'user_created'          => 'Users created',
			'user_role_changed'     => 'User role changes',
			'file_edited'           => 'Files edited',
			'xmlrpc_post_published' => 'XML-RPC posts published',
		);

		foreach ( $threat_events as $ev_name => $label ) {
			if ( empty( $grouped[ $ev_name ] ) ) {
				continue;
			}
			$count = count( $grouped[ $ev_name ] );

			// failed_login: also show unique IP count for context.
			if ( 'failed_login' === $ev_name ) {
				$ips = array();
				foreach ( $grouped['failed_login'] as $ev ) {
					if ( ! empty( $ev['_ip'] ) ) {
						$ips[ $ev['_ip'] ] = true;
					}
				}
				$ip_count = count( $ips );
				$lines[]  = "{$label}: {$count} (from {$ip_count} " . ( 1 === $ip_count ? 'IP' : 'IPs' ) . ')';
			} else {
				$lines[] = "{$label}: {$count}";
			}
		}

		return empty( $lines ) ? '' : implode( "\n", $lines );
	}

	/**
	 * Build the CHANGES section of the Security Pulse.
	 *
	 * Covers: plugin_updated, theme_updated, core_updated, plugin_activated,
	 * plugin_deactivated, plugin_deleted, theme_activated, user_deleted,
	 * user_updated (email/password only), password_reset_requested,
	 * login_success (elevated caps only).
	 *
	 * @param array          $grouped      Events grouped by event_name.
	 * @param array<int,bool> $elevated_ids Elevated-cap user IDs.
	 * @return string Section body, or empty string.
	 */
	private function _pulse_section_changes( $grouped, $elevated_ids ) {
		$lines = array();

		$change_events = array(
			'core_updated'             => 'WordPress updated',
			'plugin_updated'           => 'Plugins updated',
			'theme_updated'            => 'Themes updated',
			'plugin_activated'         => 'Plugins activated',
			'plugin_deactivated'       => 'Plugins deactivated',
			'plugin_deleted'           => 'Plugins deleted',
			'theme_activated'          => 'Themes activated',
			'user_deleted'             => 'Users deleted',
			'user_updated'             => 'Users updated',
			'password_reset_requested' => 'Password resets requested',
		);

		foreach ( $change_events as $ev_name => $label ) {
			if ( empty( $grouped[ $ev_name ] ) ) {
				continue;
			}
			$count = count( $grouped[ $ev_name ] );
			$lines[] = "{$label}: {$count}";
		}

		// login_success — gated: elevated caps only.
		if ( ! empty( $grouped['login_success'] ) && ! empty( $elevated_ids ) ) {
			$elevated_count = 0;
			foreach ( $grouped['login_success'] as $ev ) {
				$uid = isset( $ev['user_id'] ) ? (int) $ev['user_id'] : 0;
				if ( isset( $elevated_ids[ $uid ] ) ) {
					++$elevated_count;
				}
			}
			if ( $elevated_count > 0 ) {
				$lines[] = "Elevated logins: {$elevated_count}";
			}
		}

		return empty( $lines ) ? '' : implode( "\n", $lines );
	}

	/**
	 * Build the SCANS section of the Security Pulse.
	 *
	 * Reports: last scan time, browser-driven scan count, missing scheduled scan warning.
	 * Does NOT duplicate per-scan email content.
	 *
	 * @param array $grouped Events grouped by event_name.
	 * @return string Section body, or empty string.
	 */
	private function _pulse_section_scans( $grouped ) {
		$lines        = array();
		$date_format  = get_option( 'date_format' );
		$time_format  = get_option( 'time_format' );

		$completed_scans = empty( $grouped['malcure_scan_completed'] ) ? 0 : count( $grouped['malcure_scan_completed'] );
		$initiated       = empty( $grouped['malcure_scan_initiated'] ) ? 0 : count( $grouped['malcure_scan_initiated'] );

		if ( $completed_scans > 0 ) {
			$lines[] = "Scans completed: {$completed_scans}";
		}
		if ( $initiated > 0 ) {
			$lines[] = "Scans initiated: {$initiated}";
		}

		// Last scan timestamp from events.
		$last_scan_time = '';
		if ( ! empty( $grouped['malcure_scan_completed'] ) ) {
			$last_scan_time = $grouped['malcure_scan_completed'][0]['_created_at'];
		} elseif ( ! empty( $grouped['malcure_scan_initiated'] ) ) {
			$last_scan_time = $grouped['malcure_scan_initiated'][0]['_created_at'];
		}

		if ( '' !== $last_scan_time ) {
			$last_date = mysql2date( $date_format . ' ' . $time_format, $last_scan_time );
			$lines[]   = "Last scan: {$last_date}";
		} else {
			$lines[] = 'No scans completed this week.';
		}

		// Schedule information.
		$schedule_enabled = $this->get_setting( 'wpmr_scan_schedule_enabled' );
		if ( $schedule_enabled ) {
			$next_scheduled = wp_next_scheduled( 'wpmr_scan_monitor_event' );
			if ( $next_scheduled ) {
				$next_date = gmdate( $date_format . ' ' . $time_format, $next_scheduled );
				$lines[]   = "Your scans are scheduled for {$next_date}.";
			}
		} else {
			// Determine if the site was recently infected — recommend weekly if so.
			$infected      = $this->get_setting( 'infected' );
			$had_threats   = ! empty( $grouped['file_repaired'] ) || ! empty( $grouped['file_deleted'] );
			$is_risky      = $infected || $had_threats;
			$frequency     = $is_risky ? 'weekly' : 'monthly';
			$schedule_url  = admin_url( 'admin.php?page=wpmr' );
			$lines[] = "You have no scheduled malware scans. We recommend {$frequency} scans. Schedule one at {$schedule_url}";
		}

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

	/**
	 * Build the OTHERS section — catch-all for event types not mapped to THREATS/CHANGES/SCANS/REMEDIATION.
	 *
	 * @param array $grouped Events grouped by event_name.
	 * @return string Section body, or empty string.
	 */
	private function _pulse_section_others( $grouped ) {
		// All known event types mapped to the four main sections.
		$mapped = array(
			// Threats.
			'failed_login'          => true,
			'user_created'          => true,
			'user_role_changed'     => true,
			'file_edited'           => true,
			'xmlrpc_post_published' => true,
			// Changes.
			'core_updated'             => true,
			'plugin_updated'           => true,
			'theme_updated'            => true,
			'plugin_activated'         => true,
			'plugin_deactivated'       => true,
			'plugin_deleted'           => true,
			'theme_activated'          => true,
			'user_deleted'             => true,
			'user_updated'             => true,
			'password_reset_requested' => true,
			'password_reset_attempt'   => true,
			'login_success'            => true,
			'automatic_update'         => true,
			// Scans.
			'malcure_scan_completed' => true,
			'malcure_scan_initiated' => true,
			// Remediation.
			'file_repaired'    => true,
			'file_deleted'     => true,
			'file_whitelisted' => true,
		);

		$lines = array();
		foreach ( $grouped as $ev_name => $events ) {
			if ( isset( $mapped[ $ev_name ] ) ) {
				continue;
			}
			$lines[] = ucfirst( str_replace( '_', ' ', $ev_name ) ) . ': ' . count( $events );
		}

		return empty( $lines ) ? '' : implode( "\n", $lines );
	}

	/**
	 * Build the REMEDIATION section of the Security Pulse.
	 *
	 * Covers: file_repaired, file_deleted, file_whitelisted.
	 *
	 * @param array $grouped Events grouped by event_name.
	 * @return string Section body, or empty string.
	 */
	private function _pulse_section_remediation( $grouped ) {
		$lines = array();

		$remediation_events = array(
			'file_repaired'    => 'Files repaired',
			'file_deleted'     => 'Files deleted',
			'file_whitelisted' => 'Files whitelisted',
		);

		foreach ( $remediation_events as $ev_name => $label ) {
			if ( empty( $grouped[ $ev_name ] ) ) {
				continue;
			}
			$lines[] = "{$label}: " . count( $grouped[ $ev_name ] );
		}

		return empty( $lines ) ? '' : implode( "\n", $lines );
	}

	/**
	 * Send a test Security Pulse email to the current user.
	 *
	 * AJAX handler for the "Send Test Pulse" button. Bypasses rate limit and
	 * recipient resolution — sends to current user only. Rate-limited to one
	 * send per 60 seconds.
	 *
	 * @return string Success message.
	 */
	function send_security_pulse_test() {
		$current_user = wp_get_current_user();
		$to           = $current_user->user_email;
		$site_name    = get_bloginfo( 'name' );

		// Rate limit: 60 seconds between test sends.
		$last_sent = $this->get_setting( 'wpmr_pulse_test_last_sent' );
		if ( $last_sent && ( time() - $last_sent ) < 60 ) {
			wp_die( 'Please wait at least a minute between test emails.' );
		}

		$body = $this->build_security_pulse_body();
		if ( '' === $body ) {
			wp_die( 'No events to report. Try again after some activity occurs on the site.' );
		}

		$subject = 'Malcure Security Pulse — ' . $site_name;

		$headers = array(
			'Content-Type: text/plain; charset=UTF-8',
			'Auto-Submitted: auto-generated',
		);

		$sent = @wp_mail( $to, $subject, $body, $headers );

		if ( ! $sent ) {
			$this->flog( 'ERROR: Security Pulse test email failed to send to ' . $to );
			wp_die( 'Failed to send test email. Check your mail configuration.' );
		}

		$this->update_setting( 'wpmr_pulse_test_last_sent', time() );
		$this->flog( 'INFO: Security Pulse test email sent to ' . $to );

		return true;
	}

	/**
	 * Register a site/user via WP-CLI.
	 *
	 * Called from CLI commands and internal helpers. When `$echo` is true, outputs
	 * feedback using WP-CLI (when available) or via plain output.
	 *
	 * @param string $email Email address.
	 * @param string $fn    First name.
	 * @param string $ln    Last name.
	 * @param bool   $echo  Whether to output feedback.
	 * @return bool True on successful registration, false otherwise.
	 */
	function wpmr_cli_register( $email, $fn, $ln, $echo = false ) {
		$sanitized_user = array(
			'email' => sanitize_email( $email ),
			'fn'    => sanitize_text_field( $fn ),
			'ln'    => sanitize_text_field( $ln ),
		);

		if ( empty( $sanitized_user['email'] ) || ! is_email( $sanitized_user['email'] ) ) {
			return $this->wpmr_cli_registration_feedback( 'Invalid email address.', $echo, false );
		}

		if ( empty( $sanitized_user['fn'] ) || empty( $sanitized_user['ln'] ) ) {
			return $this->wpmr_cli_registration_feedback( 'Please provide first and last name for registration.', $echo, false );
		}

		$result = $this->wpmr_send_registration_state( $sanitized_user, 'cli' );

		if ( is_wp_error( $result ) ) {
			return $this->wpmr_cli_registration_feedback( $result->get_error_message(), $echo, false );
		}

		return $this->wpmr_cli_registration_feedback( sprintf( 'Registration complete. Please use %s as your USER ID.', $result['user_email'] ), $echo, true );
	}

	/**
	 * AJAX handler to register a user/site from the admin UI.
	 *
	 * Validates nonce + capability, sanitizes fields, sends state to the SaaS backend,
	 * then triggers a definitions refresh.
	 *
	 * @return void Sends JSON response and exits.
	 *
	 * // ajax_register_user or ajax_web_register
	 */
	function wpmr_web_register() {
		check_ajax_referer( 'wpmr_web_register', 'wpmr_web_register_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			return;
		}
		$this->flog( 'Starting web registration' );
		$start_time = microtime( true );

		if ( ! isset( $_REQUEST['user'] ) || ! is_array( $_REQUEST['user'] ) || empty( $_REQUEST['user']['email'] ) || empty( $_REQUEST['user']['fn'] ) || empty( $_REQUEST['user']['ln'] ) ) {
			wp_send_json( array( 'error' => 'Please fill all details' ) );
		}

		$email      = sanitize_email( wp_unslash( $_REQUEST['user']['email'] ) );
		$first_name = sanitize_text_field( wp_unslash( $_REQUEST['user']['fn'] ) );
		$last_name  = sanitize_text_field( wp_unslash( $_REQUEST['user']['ln'] ) );

		if ( ! filter_var( $email, FILTER_VALIDATE_EMAIL ) ) {
			wp_send_json( array( 'error' => 'Invalid email' ) );
		}

		// Sanitize user data
		$sanitized_user = array(
			'email' => $email,
			'fn'    => $first_name,
			'ln'    => $last_name,
		);

		$result = $this->wpmr_send_registration_state( $sanitized_user, 'web' );
		$this->flog( 'Web registration completed in ' . ( microtime( true ) - $start_time ) . ' seconds' );
		$start_time = microtime( true );

		if ( is_wp_error( $result ) ) {
			wp_send_json( array( 'error' => $result->get_error_message() ) );
		}

		// $this->refresh_checksums_async(); // trigger in background so user doesn't have to wait
		// $this->flog( 'Checksums fetched in ' . ( microtime( true ) - $start_time ) . ' seconds' );
		// $start_time = microtime( true );
		// $this->update_definitions_cli( false );
		// $this->flog( 'Definitions updated in ' . ( microtime( true ) - $start_time ) . ' seconds' );

		// Save notification preferences chosen during registration (opt-in).
		$notify_scan  = ! empty( $_REQUEST['notify_scan'] );
		$notify_pulse = ! empty( $_REQUEST['notify_pulse'] );
		$this->update_setting( 'wpmr_scan_notify_registrant', $notify_scan );
		$this->update_setting( 'wpmr_pulse_notify_registrant', $notify_pulse );

		wp_send_json( $result );
	}

	/**
	 * Send registration state to the SaaS backend and persist the returned payload.
	 *
	 * @param array{email:string,fn:string,ln:string} $user   Sanitized user registration data.
	 * @param string                                  $source Source identifier (e.g. web|cli).
	 * @return array|WP_Error Registration payload on success, or WP_Error on failure.
	 *
	 * // send_registration_to_saas or register_with_saas
	 */
	private function wpmr_send_registration_state( $user, $source = 'web' ) {
		$user_payload = array_merge(
			$user,
			array(
				'key'    => site_url(),
				'source' => $source,
			)
		);

		$request = $this->saas_request(
			'saas_registration',
			array(
				'method'      => 'GET',
				'send_state'  => 'query',
				'state_extra' => array( 'user' => $user_payload ),
				'timeout'     => $this->timeout,
			)
		);

		if ( is_wp_error( $request ) ) {
			return $request;
		}

		$data = isset( $request['response'] ) ? $request['response'] : null;
		if ( empty( $data ) || ! is_array( $data ) ) {
			return new WP_Error( 'registration_invalid_response', 'Invalid response from server.' );
		}

		if ( ! empty( $data['error'] ) ) {
			return new WP_Error( 'registration_error', is_string( $data['error'] ) ? $data['error'] : wp_json_encode( $data['error'] ) );
		}

		$payload = isset( $request['payload'] ) ? $request['payload'] : null;

		if ( empty( $payload ) || ! is_array( $payload ) ) {
			return new WP_Error( 'registration_invalid_payload', 'Registration payload missing in response.' );
		}

		$this->update_setting( 'user', $payload );

		return $payload;
	}

	/**
	 * Output/return registration feedback for CLI flows.
	 *
	 * @param string $message Message to display.
	 * @param bool   $echo    Whether to output feedback.
	 * @param bool   $success Whether the operation succeeded.
	 * @return bool True when successful, false otherwise.
	 *
	 * // output_cli_registration_feedback
	 */
	private function wpmr_cli_registration_feedback( $message, $echo, $success ) {
		if ( ! $echo ) {
			return $success;
		}

		if ( $this->wpmr_iscli() ) {
			if ( $success ) {
				WP_CLI::success( $message );
			} else {
				WP_CLI::error( $message, false );
			}
		} else {
			echo wp_kses_post( $message );
		}

		return $success;
	}

	/**
	 * Clear the cached license status.
	 *
	 * // clear_license_status_cache
	 *
	 * @return true Always returns true.
	 */
	function clear_license_status() {
		delete_transient( 'WPMR_license_status' );
		return true;
	}

	/**
	 * Deactivate the stored license key and clear local state.
	 *
	 * This is used during plugin deactivation and other reset flows. It intentionally
	 * fails silently (no user-facing messaging).
	 *
	 * // deactivate_license_key
	 *
	 * @param bool $silent Whether to run silently (accepted for compatibility).
	 * @return void
	 */
	function deactivate_license() {
		$key      = $this->get_setting( 'license_key' );
		$response = $key ? $this->get_license_api_response( 'deactivate_license', $key ) : new WP_Error( 'error', 'No license key found for ' . __FUNCTION__ );
		// fail silently as we are only called during plugin deactivation;
		$this->delete_setting( 'license_key' );
		$this->clear_license_status();
	}

	/**
	 * Build a formatted JSON blob describing current license status.
	 *
	 * Used by the AJAX endpoint that populates license status UI. This method reads the
	 * `WPMR_license_status` transient when available, optionally refreshing via the license API.
	 *
	 * // get_license_status_json() or format_license_status_json()
	 *
	 * @param int|bool $from_cache When truthy, prefer cached transient.
	 * @return string JSON-encoded license status payload.
	 */
	function format_license_status_data( $from_cache = 0 ) {
		if ( ! $this->get_setting( 'license_key' ) ) {
			return json_encode( array( 'error' => 'No license key found to format.' ) );
		}

		$status = get_transient( 'WPMR_license_status' );

		if ( ! $from_cache || ! $status ) {
			$status = $this->get_license_api_response( 'check_license', $this->get_setting( 'license_key' ) );
		}

		if ( ! $status ) {
			return json_encode( array( 'error' => 'Unable to retrieve license status' ) );
		}

		// Build the response data
		$response_data = array(
			'success'  => true,
			'license'  => isset( $status['license'] ) ? $status['license'] : 'unknown',
			'raw_data' => $status,
		);

		// Add formatted status information
		if ( isset( $status['license'] ) ) {
			$response_data['license_status'] = ucwords( preg_replace( '/[^A-Za-z0-9 ]/', ' ', $status['license'] ) );
		}

		// Add error/reason if present
		if ( isset( $status['error'] ) ) {
			$response_data['error_reason'] = ucwords( preg_replace( '/[^A-Za-z0-9 ]/', ' ', $status['error'] ) );
		}

		// Add customer information
		if ( ! empty( $status['customer_email'] ) ) {
			$response_data['customer_email'] = $status['customer_email'];
		}

		if ( ! empty( $status['customer_name'] ) ) {
			$response_data['customer_name'] = $status['customer_name'];
		}

		// Add activation information
		if ( ! empty( $status['site_count'] ) && isset( $status['activations_left'] ) ) {
			$limit = 'Unlimited';
			if ( isset( $status['license_limit'] ) && $status['license_limit'] !== null ) {
				$limit = $status['license_limit'] == 0 ? 'Unlimited' : $status['license_limit'];
			}
			$response_data['activations'] = array(
				'used'      => $status['site_count'],
				'limit'     => $limit,
				'remaining' => $status['activations_left'],
			);
		}

		// Add expiration information
		if ( ! empty( $status['expires'] ) ) {
			if ( $status['expires'] == 'lifetime' ) {
				$response_data['expires']           = 'Never';
				$response_data['expires_formatted'] = 'Lifetime';
			} else {
				$response_data['expires'] = $status['expires'];
				try {
					$timezone_string = function_exists( 'wp_timezone_string' ) ? wp_timezone_string() : $this->timezone_string_compat();
					$timezone        = new DateTimeZone( $timezone_string );
					$date            = new DateTime( $status['expires'], new DateTimeZone( 'UTC' ) );
					$date->setTimezone( $timezone );
					$response_data['expires_formatted'] = $date->format( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ) . ' ' . $timezone_string;
				} catch ( Exception $e ) {
					$response_data['expires_formatted'] = $status['expires'];
				}
			}
		}

		// Add renewal link if expired
		if ( isset( $status['license'] ) && $status['license'] == 'expired' ) {
			$response_data['renewal_link'] = $this->wpmr_url( 'p=168&edd_license_key=' . $this->get_setting( 'license_key' ) . '&edd_action=apply_license_renewal', 'license-renewal', 'expired-ajax' );
		}

		return json_encode( $response_data );
	}

	/**
	 * Call the Malcure licensing API (EDD) and return the decoded response.
	 *
	 * @param string $action      EDD action (e.g. check_license|activate_license|deactivate_license).
	 * @param string $license_key License key (defaults to stored key).
	 * @param bool   $silent      Whether to suppress logging/user messaging (currently unused).
	 *
	 * // request_license_api() or fetch_license_api_response()
	 *
	 * @return array|null Decoded JSON response array, or null on error.
	 */
	function get_license_api_response( $action, $license_key = '', $silent = false ) {
		if ( empty( $license_key ) ) {
			$license_key = $this->get_setting( 'license_key' );
		}
		if ( empty( $license_key ) ) {
			$this->flog( 'License key not found.' );
			return null;
		}
		$url      = MALCURE_API . '?edd_action=' . $action . '&item_id=1725&license=' . urlencode( $license_key ) . '&url=' . site_url();
		$response = wp_safe_remote_request( $url, array( 'timeout' => $this->timeout ) );

		$this->flog( 'License API Request: ' . $url );

		if ( is_wp_error( $response ) ) {
			$this->flog( 'API Error: ' . $response->get_error_message() );
			return null;
		}

		$status_code = wp_remote_retrieve_response_code( $response );
		if ( 200 !== $status_code ) {
			$this->flog( 'HTTP Error: ' . $status_code );
			return null;
		}

		$body = wp_remote_retrieve_body( $response );
		$this->flog( 'License API Response Body: ' );
		$this->flog( $body );
		$data = json_decode( $body, true );

		if ( is_null( $data ) ) {
			$this->flog( 'Unparsable response: ' . $body );
			return null;
		}
		return $data;
	}

	/**
	 * Determine whether Advanced Edition features should be enabled.
	 *
	 * This validates the stored license status (cached in a transient) and refreshes
	 * it via the license API when needed.
	 *
	 * // has_valid_license() or is_license_valid()
	 *
	 * @return bool True if the license is present and valid.
	 */
	function is_advanced_edition() {
		$status = get_transient( 'WPMR_license_status' );
		if ( ! $status ) {
			$key = $this->get_setting( 'license_key' );
			if ( empty( $key ) ) {
				return false;
			}

			$status = $this->get_license_api_response( 'check_license', $key );
			if ( is_wp_error( $status ) || empty( $status['success'] ) ) {
				return false;
			}

			$this->save_license_status( $status );
		}

		return $status['license'] === 'valid';
	}

	/**
	 * Check if the Advanced Edition license is expired.
	 *
	 * Ensures the cached status is refreshed when missing.
	 *
	 * // is_license_expired()
	 *
	 * @return bool True if the cached license status indicates expiration.
	 */
	function is_advanced_edition_expired() {
		$this->is_advanced_edition(); // Ensure the transient is refreshed if necessary
		$status = get_transient( 'WPMR_license_status' );
		return ( ! empty( $status['license'] ) && $status['license'] === 'expired' );
	}

	/**
	 * Persist license status into a transient.
	 *
	 * @param array $status License status payload.
	 *
	 * // cache_license_status
	 *
	 * @return array The same status payload.
	 */
	function save_license_status( $status ) {
		// $this->flog( $status );
		set_transient( 'WPMR_license_status', $status, 24 * HOUR_IN_SECONDS );
		return $status;
	}

	/**
	 * Persist Google Search Console (GSC) OAuth status into registration settings.
	 *
	 * Hooked to `admin_init`. Reads request parameters for OAuth callback and persists
	 * a `gsc` flag inside the stored `user` setting.
	 *
	 * // handle_gsc_oauth_callback
	 *
	 * @return void
	 */
	function save_gsc_profile() {
		if ( isset( $_REQUEST['wpmr-action'] ) && $_REQUEST['wpmr-action'] == 'oauth' ) {
			$origin_nonce = isset( $_REQUEST['origin_nonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['origin_nonce'] ) ) : '';
			if ( ! wp_verify_nonce( sanitize_text_field( wp_unslash( $_REQUEST['origin_nonce'] ) ), 'wpmr_gscapi' ) ) {
				return;
			}
			$success = isset( $_REQUEST['success'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['success'] ) ) : '';
			if ( current_user_can( $this->cap ) && $success ) {
				$wpmr_reg = $this->get_setting( 'user' );
				if ( $wpmr_reg ) {
					$wpmr_reg['gsc'] = true;
					$this->update_setting( 'user', $wpmr_reg );
				}
			} else {
			}
			wp_safe_redirect( get_admin_url( null, 'options-general.php?page=wpmr' ), 302 );
			exit;
		}
		if ( isset( $_REQUEST['wpmr-action'] ) && $_REQUEST['wpmr-action'] == 'revoke' && isset( $_REQUEST['success'] ) && $_REQUEST['success'] == '1' ) {
			$wpmr_reg = $this->get_setting( 'user' );
			if ( $wpmr_reg && ! empty( $wpmr_reg['gsc'] ) ) {
				unset( $wpmr_reg['gsc'] );
				$this->update_setting( 'user', $wpmr_reg );
			}
		}
	}

	/**
	 * AJAX endpoint to fetch the current license status.
	 *
	 * Returns a structured JSON response (success/error) used by the admin UI.
	 *
	 * // ajax_fetch_license_status
	 *
	 * @return void Sends JSON response and exits.
	 */
	function ajax_get_license_status() {
		check_ajax_referer( 'wpmr_fetch_license', 'wpmr_fetch_license_nonce' );
		if ( ! current_user_can( $this->cap ) ) {
			wp_send_json_error( 'Insufficient permissions.' );
		}

		$license_status_json = $this->format_license_status_data();
		$license_data        = json_decode( $license_status_json, true );

		if ( $license_data && isset( $license_data['success'] ) && $license_data['success'] ) {
			wp_send_json_success( $license_data );
		} else {
			$error_message = isset( $license_data['error'] ) ? $license_data['error'] : 'Failed to retrieve license status';
			wp_send_json_error( $error_message );
		}
	}
}
