<?php
/**
 * Shipping address anomalies detection module.
 *
 * @package TrustLens\Modules
 * @since   1.2.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Shipping anomalies module class.
 *
 * Detects suspicious shipping address patterns:
 * - Address diversity (many unique addresses relative to order count)
 * - Billing/shipping country mismatch
 * - Address change velocity (many new addresses in short window)
 * - Address diversity trend (Pro: behavioral shift detection)
 *
 * @since 1.2.0
 */
class TrustLens_Module_Shipping_Anomalies extends TrustLens_Module {

	/**
	 * Module ID.
	 *
	 * @var string
	 */
	protected string $id = 'shipping_anomalies';

	/**
	 * Module name.
	 *
	 * @var string
	 */
	protected string $name = 'Shipping Address Anomalies';

	/**
	 * Module description.
	 *
	 * @var string
	 */
	protected string $description = 'Detect suspicious shipping address patterns including address hopping, billing/shipping mismatches, and address change velocity.';

	/**
	 * Is Pro feature.
	 *
	 * @var bool
	 */
	protected bool $is_pro = false;

	/**
	 * Register hooks.
	 *
	 * @since 1.2.0
	 */
	public function register_hooks(): void {
		// 2 args — take the WC_Order passed by WooCommerce instead of re-fetching it.
		add_action( 'woocommerce_order_status_completed', array( $this, 'handle_order_completed' ), 10, 2 );

		// Historical backfill handler — always registered so Action Scheduler
		// can fire the callback even if the module is later disabled.
		self::register_backfill_hooks();
	}

	/**
	 * Register backfill hooks unconditionally.
	 *
	 * Called from register_hooks() and also directly from init_modules()
	 * so the backfill callback exists even when the module is disabled.
	 *
	 * @since 1.2.0
	 */
	public static function register_backfill_hooks(): void {
		static $registered = false;
		if ( $registered ) {
			return;
		}
		$registered = true;

		add_action( 'trustlens/backfill_address_country_codes', array( new self(), 'process_backfill_batch' ) );
	}

	/**
	 * Handle order completed — queue score recalculation.
	 *
	 * @since 1.2.0
	 * @param int           $order_id Order ID.
	 * @param WC_Order|null $order    Order object passed by WooCommerce. Optional fallback
	 *                                handles third-party do_action() calls firing the hook with 1 arg.
	 */
	public function handle_order_completed( int $order_id, $order = null ): void {
		if ( ! $order instanceof WC_Order ) {
			$order = wc_get_order( $order_id );
		}
		if ( ! $order ) {
			return;
		}

		$customer_key = wstl_get_customer_key( $order );
		wstl_ensure_customer_record( $customer_key, $order );
		$email_hash = $customer_key['email_hash'];
		$this->queue_score_update( $email_hash );

		// Fire the anomaly event ONCE per order completion. Previously this lived
		// inside get_signal(), which meant every score recalc re-emitted the event
		// and could spawn re-entrant AS jobs through automation rules.
		$signal = $this->get_signal( $email_hash );
		$score  = (int) ( $signal['score'] ?? 0 );
		if ( $score < 0 ) {
			$reasons = '' !== ( $signal['reason'] ?? '' )
				? array_values( array_filter( array_map( 'trim', explode( ';', (string) $signal['reason'] ) ) ) )
				: array();
			$this->log_event(
				$email_hash,
				'address_anomaly_detected',
				array(
					'total_penalty' => $score,
					'signals'       => $reasons,
				)
			);
			do_action( 'trustlens/shipping_anomaly', $email_hash, $score, $reasons );
		}
	}

	/**
	 * Calculate signal score.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @return array Signal data.
	 */
	public function get_signal( string $email_hash, ?object $customer = null ): array {
		$customer = $customer ?? wstl_get_customer( $email_hash );

		if ( ! $customer ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		// Require minimum orders before scoring.
		$min_orders = (int) get_option( 'trustlens_min_orders', 3 );
		if ( $customer->total_orders < $min_orders ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		$score   = 0;
		$reasons = array();

		// Signal 1: Address Diversity Ratio.
		$diversity_result = $this->calculate_address_diversity( $email_hash, $customer );
		$score           += $diversity_result['score'];
		if ( ! empty( $diversity_result['reason'] ) ) {
			$reasons[] = $diversity_result['reason'];
		}

		// Signal 2: Country Mismatch.
		$mismatch_result = $this->calculate_country_mismatch( $email_hash );
		$score          += $mismatch_result['score'];
		if ( ! empty( $mismatch_result['reason'] ) ) {
			$reasons[] = $mismatch_result['reason'];
		}

		// Signal 3: Address Change Velocity.
		$velocity_result = $this->calculate_address_velocity( $email_hash );
		$score          += $velocity_result['score'];
		if ( ! empty( $velocity_result['reason'] ) ) {
			$reasons[] = $velocity_result['reason'];
		}

		// Signal 4 (Pro): Address Diversity Trend.
		if ( $this->is_pro_active() && get_option( 'trustlens_shipping_anomalies_trend_enabled', true ) ) {
			$trend_result = $this->calculate_diversity_trend( $email_hash, $customer );
			$score       += $trend_result['score'];
			if ( ! empty( $trend_result['reason'] ) ) {
				$reasons[] = $trend_result['reason'];
			}
		}

		// Clamp to module limit.
		$score = max( -50, $score );

		// Note: log_event and do_action('trustlens/shipping_anomaly') moved to
		// handle_order_completed() so they fire once per order event, not on
		// every score recalculation (which previously could re-trigger automation
		// rules and spawn re-entrant Action Scheduler jobs).
		return array(
			'score'  => $score,
			'reason' => implode( '; ', $reasons ),
		);
	}

	/**
	 * Signal 1: Address Diversity Ratio.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @param object $customer   Customer object.
	 * @return array Signal result with score and reason.
	 */
	private function calculate_address_diversity( string $email_hash, object $customer ): array {
		global $wpdb;

		$table = $wpdb->prefix . 'trustlens_fingerprints';

		// Count distinct shipping addresses.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$unique_addresses = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(DISTINCT fingerprint_hash) FROM {$table}
			 WHERE email_hash = %s AND fingerprint_type = 'shipping_address'",
			$email_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( $customer->total_orders <= 0 ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		$ratio = $unique_addresses / $customer->total_orders;

		if ( $ratio > 0.8 ) {
			return array(
				'score'  => -15,
				'reason' => sprintf(
					/* translators: %d: number of unique addresses */
					__( 'Very high address diversity: %d unique shipping addresses', 'trustlens' ),
					$unique_addresses
				),
			);
		}

		if ( $ratio > 0.5 ) {
			return array(
				'score'  => -10,
				'reason' => sprintf(
					/* translators: %d: number of unique addresses */
					__( 'High address diversity: %d unique shipping addresses', 'trustlens' ),
					$unique_addresses
				),
			);
		}

		if ( $ratio > 0.3 ) {
			return array(
				'score'  => -5,
				'reason' => sprintf(
					/* translators: %d: number of unique addresses */
					__( 'Elevated address diversity: %d unique shipping addresses', 'trustlens' ),
					$unique_addresses
				),
			);
		}

		return array( 'score' => 0, 'reason' => '' );
	}

	/**
	 * Signal 2: Billing/Shipping Country Mismatch.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @return array Signal result with score and reason.
	 */
	private function calculate_country_mismatch( string $email_hash ): array {
		global $wpdb;

		$table = $wpdb->prefix . 'trustlens_fingerprints';

		// Get all shipping and billing country codes (with counts for order-level analysis).
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$shipping_countries = $wpdb->get_col( $wpdb->prepare(
			"SELECT DISTINCT country_code FROM {$table}
			 WHERE email_hash = %s AND fingerprint_type = 'shipping_address' AND country_code IS NOT NULL AND country_code != ''",
			$email_hash
		) );

		$billing_countries = $wpdb->get_col( $wpdb->prepare(
			"SELECT DISTINCT country_code FROM {$table}
			 WHERE email_hash = %s AND fingerprint_type = 'billing_address' AND country_code IS NOT NULL AND country_code != ''",
			$email_hash
		) );

		// Count how many shipping fingerprints have a country NOT in the billing set (order-level mismatch count).
		$mismatch_order_count = 0;
		if ( ! empty( $shipping_countries ) && ! empty( $billing_countries ) ) {
			$billing_placeholders = implode( ',', array_fill( 0, count( $billing_countries ), '%s' ) );
			$mismatch_order_count = (int) $wpdb->get_var( $wpdb->prepare(
				"SELECT COUNT(*) FROM {$table}
				 WHERE email_hash = %s AND fingerprint_type = 'shipping_address'
				   AND country_code IS NOT NULL AND country_code != ''
				   AND country_code NOT IN ($billing_placeholders)",
				array_merge( array( $email_hash ), $billing_countries )
			) );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( empty( $shipping_countries ) || empty( $billing_countries ) ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		// Find shipping countries not in billing countries.
		$mismatched = array_diff( $shipping_countries, $billing_countries );

		if ( empty( $mismatched ) ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		// Pro: Enhanced country mismatch severity.
		if ( $this->is_pro_active() ) {
			$shipping_count = count( $shipping_countries );
			$billing_count  = count( $billing_countries );

			// Multiple different shipping countries but single billing — reshipping pattern.
			if ( $shipping_count >= 3 && 1 === $billing_count ) {
				return array(
					'score'  => -15,
					'reason' => sprintf(
						/* translators: %d: number of different shipping countries */
						__( 'Shipping to %d different countries with single billing country (reshipping pattern)', 'trustlens' ),
						$shipping_count
					),
				);
			}

			// Two shipping countries with single billing.
			if ( $shipping_count >= 2 && 1 === $billing_count ) {
				return array(
					'score'  => -10,
					'reason' => __( 'Multiple shipping countries with single billing country', 'trustlens' ),
				);
			}
		}

		// Free: Consistent mismatch across multiple orders (order-level count, not just distinct countries).
		if ( $mismatch_order_count >= 2 ) {
			return array(
				'score'  => -10,
				'reason' => sprintf(
					/* translators: %d: number of orders with mismatched countries */
					__( 'Billing/shipping country mismatch across %d orders', 'trustlens' ),
					$mismatch_order_count
				),
			);
		}

		return array(
			'score'  => -3,
			'reason' => __( 'Billing/shipping country mismatch detected', 'trustlens' ),
		);
	}

	/**
	 * Signal 3: Address Change Velocity.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @return array Signal result with score and reason.
	 */
	private function calculate_address_velocity( string $email_hash ): array {
		global $wpdb;

		$table         = $wpdb->prefix . 'trustlens_fingerprints';
		$velocity_days = (int) get_option( 'trustlens_shipping_anomalies_velocity_days', 30 );
		$cutoff_date   = gmdate( 'Y-m-d H:i:s', strtotime( "-{$velocity_days} days" ) );

		// Count distinct new shipping addresses in the velocity window.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$recent_addresses = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(DISTINCT fingerprint_hash) FROM {$table}
			 WHERE email_hash = %s AND fingerprint_type = 'shipping_address' AND first_seen >= %s",
			$email_hash,
			$cutoff_date
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		if ( $recent_addresses >= 5 ) {
			return array(
				'score'  => -10,
				'reason' => sprintf(
					/* translators: %1$d: address count, %2$d: number of days */
					__( '%1$d new shipping addresses in %2$d days', 'trustlens' ),
					$recent_addresses,
					$velocity_days
				),
			);
		}

		if ( $recent_addresses >= 3 ) {
			return array(
				'score'  => -5,
				'reason' => sprintf(
					/* translators: %1$d: address count, %2$d: number of days */
					__( '%1$d new shipping addresses in %2$d days', 'trustlens' ),
					$recent_addresses,
					$velocity_days
				),
			);
		}

		return array( 'score' => 0, 'reason' => '' );
	}

	/**
	 * Signal 4 (Pro): Address Diversity Trend.
	 *
	 * Compares recent address diversity to lifetime average.
	 *
	 * @since 1.2.0
	 * @param string $email_hash Customer email hash.
	 * @param object $customer   Customer object.
	 * @return array Signal result with score and reason.
	 */
	private function calculate_diversity_trend( string $email_hash, object $customer ): array {
		global $wpdb;

		$table       = $wpdb->prefix . 'trustlens_fingerprints';
		$cutoff_date = gmdate( 'Y-m-d H:i:s', strtotime( '-30 days' ) );

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.

		// Total distinct shipping addresses (lifetime).
		$total_addresses = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(DISTINCT fingerprint_hash) FROM {$table}
			 WHERE email_hash = %s AND fingerprint_type = 'shipping_address'",
			$email_hash
		) );

		// Recent distinct shipping addresses (last 30 days).
		$recent_addresses = (int) $wpdb->get_var( $wpdb->prepare(
			"SELECT COUNT(DISTINCT fingerprint_hash) FROM {$table}
			 WHERE email_hash = %s AND fingerprint_type = 'shipping_address' AND first_seen >= %s",
			$email_hash,
			$cutoff_date
		) );

		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		// Need meaningful history to compare against.
		if ( $total_addresses < 2 || $customer->total_orders < 5 || empty( $customer->first_order_date ) ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		// Calculate lifetime average: addresses per 30-day period. first_order_date is
		// site-local (current_time), so measure it against current_time( 'timestamp' ) —
		// the offsets cancel. Mixing it with UTC time() drifted days_active by the offset.
		$days_active = max( 1, wstl_days_between( $customer->first_order_date, current_time( 'timestamp' ) ) );
		$periods     = max( 1, $days_active / 30 );
		$lifetime_avg = $total_addresses / $periods;

		if ( $lifetime_avg <= 0 ) {
			return array( 'score' => 0, 'reason' => '' );
		}

		$ratio = $recent_addresses / $lifetime_avg;

		if ( $ratio >= 3.0 ) {
			return array(
				'score'  => -10,
				'reason' => sprintf(
					/* translators: %d: number of recent addresses */
					__( 'Sudden spike: %d new addresses in last 30 days (3x above average)', 'trustlens' ),
					$recent_addresses
				),
			);
		}

		if ( $ratio >= 2.0 ) {
			return array(
				'score'  => -5,
				'reason' => sprintf(
					/* translators: %d: number of recent addresses */
					__( 'Rising trend: %d new addresses in last 30 days (2x above average)', 'trustlens' ),
					$recent_addresses
				),
			);
		}

		return array( 'score' => 0, 'reason' => '' );
	}

	/**
	 * Process a batch of fingerprints to backfill country codes.
	 *
	 * Terminates safely even when rows can't be resolved to a country code:
	 * unresolvable rows are tagged with an empty-string sentinel so they drop
	 * out of the `country_code IS NULL` query — otherwise they'd stay in the
	 * result set forever and the self-continuation path would loop endlessly.
	 *
	 * Scheduling uses `wstl_ensure_single_action()` replace-semantics so a
	 * concurrent activation + self-continuation can never produce duplicate
	 * pending rows. See BUG-ACTIONSCHEDULER-RUNAWAY.md (1.1.9).
	 *
	 * @since 1.2.0
	 */
	public function process_backfill_batch(): void {
		global $wpdb;

		$table      = $wpdb->prefix . 'trustlens_fingerprints';
		$batch_size = 100;

		try {
			// Get fingerprints missing country codes.
			// Always OFFSET 0: updated rows leave the NULL result set naturally,
			// either with a resolved code or the empty-string sentinel below.
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
			$fingerprints = $wpdb->get_results( $wpdb->prepare(
				"SELECT id, email_hash, fingerprint_type FROM {$table}
				 WHERE fingerprint_type IN ('shipping_address', 'billing_address')
				   AND country_code IS NULL
				 ORDER BY id ASC
				 LIMIT %d",
				$batch_size
			) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

			if ( empty( $fingerprints ) ) {
				// Done — no more NULL rows. Mark complete so activation/upgrade
				// doesn't keep re-enqueuing a no-op batch on every run.
				update_option( 'trustlens_country_code_backfill_done', true );
				return;
			}

			$processed = 0;

			foreach ( $fingerprints as $fp ) {
				try {
					$country_code = $this->resolve_country_from_orders( $fp->email_hash, $fp->fingerprint_type );

					// Always write a non-NULL value so the row drops out of the
					// `IS NULL` query on the next iteration. Empty string means
					// "we looked but couldn't resolve this" — it's treated as
					// absent by the detection logic but isn't NULL.
					$value = ! empty( $country_code ) ? $country_code : '';

					// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Batch update, safe.
					$wpdb->update(
						$table,
						array( 'country_code' => $value ),
						array( 'id' => $fp->id ),
						array( '%s' ),
						array( '%d' )
					);
					++$processed;
				} catch ( \Throwable $row_error ) {
					// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Operational log.
					error_log( sprintf(
						'[TrustLens] Country code backfill failed on fingerprint %d: %s',
						(int) $fp->id,
						$row_error->getMessage()
					) );
				}
			}

			// If the batch was full AND we actually made progress, continue.
			// Otherwise mark complete: either there are no more rows, or every
			// row in this batch failed (continuing would just loop on the same
			// poisoned rows forever).
			if ( count( $fingerprints ) >= $batch_size && $processed > 0 ) {
				wstl_ensure_single_action( 'trustlens/backfill_address_country_codes', array(), 'trustlens' );
			} else {
				update_option( 'trustlens_country_code_backfill_done', true );
			}
		} catch ( \Throwable $batch_error ) {
			// Catastrophic failure — mark done so activation/upgrade can't keep
			// reissuing forever. Merchants can reset the option and re-run.
			update_option( 'trustlens_country_code_backfill_done', true );
			// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Operational log.
			error_log( '[TrustLens] Country code backfill fatal error: ' . $batch_error->getMessage() );
		}
	}

	/**
	 * Resolve country code from WooCommerce orders for a customer.
	 *
	 * @since 1.2.0
	 * @param string $email_hash      Customer email hash.
	 * @param string $fingerprint_type Fingerprint type.
	 * @return string|null Country code or null.
	 */
	private function resolve_country_from_orders( string $email_hash, string $fingerprint_type ): ?string {
		$customer = wstl_get_customer( $email_hash );
		if ( ! $customer || empty( $customer->customer_email ) ) {
			return null;
		}

		// Get the most recent order for this customer.
		$orders = wc_get_orders( array(
			'billing_email' => $customer->customer_email,
			'limit'         => 1,
			'orderby'       => 'date',
			'order'         => 'DESC',
			'status'        => array( 'wc-completed', 'wc-processing', 'wc-on-hold' ),
		) );

		if ( empty( $orders ) ) {
			return null;
		}

		$order = $orders[0];

		if ( 'shipping_address' === $fingerprint_type ) {
			$country = $order->get_shipping_country();
			// Fall back to billing country if shipping country empty.
			return ! empty( $country ) ? $country : $order->get_billing_country();
		}

		return $order->get_billing_country();
	}
}
