<?php
/**
 * Linked Accounts Detection.
 *
 * @package TrustLens
 * @since   1.1.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * Linked Accounts class.
 *
 * Detects customers who may be using multiple accounts.
 *
 * @since 1.1.0
 */
class TrustLens_Linked_Accounts {

	/**
	 * Identity-grade fingerprint types. A shared value of one of these is real
	 * evidence that two accounts belong to the same person. `device` (a hash of the
	 * User-Agent) and `ip_address` are deliberately EXCLUDED: common browsers and
	 * shared egress IPs (CGNAT, mobile carriers, household/corporate NAT) collide
	 * across unrelated shoppers, so a weak-only overlap must never link accounts or
	 * drive a score penalty / payment-method restriction. Weak types are still
	 * stored and can appear as corroboration alongside a strong match.
	 *
	 * @since 1.3.8
	 * @var string[]
	 */
	public const STRONG_MATCH_TYPES = array( 'shipping_address', 'billing_address', 'phone', 'payment_token' );

	/**
	 * Single instance.
	 *
	 * @var TrustLens_Linked_Accounts|null
	 */
	private static ?TrustLens_Linked_Accounts $instance = null;

	/**
	 * Get instance.
	 *
	 * @since 1.1.0
	 * @return TrustLens_Linked_Accounts
	 */
	public static function instance(): TrustLens_Linked_Accounts {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	/**
	 * Constructor.
	 *
	 * @since 1.1.0
	 */
	private function __construct() {
		$this->init_hooks();
	}

	/**
	 * Initialize hooks.
	 *
	 * @since 1.1.0
	 */
	private function init_hooks(): void {
		// Only if linked accounts detection is enabled.
		if ( ! get_option( 'trustlens_module_linked_accounts_enabled', true ) ) {
			return;
		}

		// Track fingerprints on order creation.
		add_action( 'woocommerce_checkout_order_created', array( $this, 'track_order_fingerprints' ), 15 );

		// Add linked accounts signal to score calculation.
		add_filter( 'trustlens/score_signals', array( $this, 'add_linked_signal' ), 10, 2 );

		// Clean up old fingerprints on daily cron.
		add_action( 'trustlens/daily_cleanup', array( __CLASS__, 'cleanup_old_fingerprints' ) );
	}

	/**
	 * Track customer fingerprints from order.
	 *
	 * @since 1.1.0
	 * @param WC_Order $order Order object.
	 */
	public function track_order_fingerprints( WC_Order $order ): void {
		$customer_key = wstl_get_customer_key( $order );
		$email_hash = $customer_key['email_hash'];

		// Get fingerprints from order.
		$fingerprints = $this->extract_fingerprints( $order );

		// Map address fingerprint types to their country codes (empty string → null).
		$shipping_country = $order->get_shipping_country();
		$billing_country  = $order->get_billing_country();
		$country_map      = array(
			'shipping_address' => ! empty( $shipping_country ) ? $shipping_country : null,
			'billing_address'  => ! empty( $billing_country ) ? $billing_country : null,
		);

		// Store each fingerprint.
		foreach ( $fingerprints as $type => $value ) {
			if ( ! empty( $value ) ) {
				$country_code = isset( $country_map[ $type ] ) ? $country_map[ $type ] : null;
				$this->store_fingerprint( $email_hash, $type, $value, $country_code );
			}
		}

		// Check for linked accounts and update customer.
		$this->update_linked_accounts( $email_hash );
	}

	/**
	 * Extract fingerprints from order.
	 *
	 * @since 1.1.0
	 * @param WC_Order $order Order object.
	 * @return array Fingerprints.
	 */
	public function extract_fingerprints( WC_Order $order ): array {
		$fingerprints = array();

		// Shipping address hash.
		$shipping_address = $this->normalize_address(
			$order->get_shipping_address_1(),
			$order->get_shipping_address_2(),
			$order->get_shipping_city(),
			$order->get_shipping_postcode(),
			$order->get_shipping_country()
		);
		if ( ! empty( $shipping_address ) ) {
			$fingerprints['shipping_address'] = wstl_hash_personal_value( $shipping_address, 'shipping_address' );
		}

		// Billing address hash.
		$billing_address = $this->normalize_address(
			$order->get_billing_address_1(),
			$order->get_billing_address_2(),
			$order->get_billing_city(),
			$order->get_billing_postcode(),
			$order->get_billing_country()
		);
		if ( ! empty( $billing_address ) ) {
			$fingerprints['billing_address'] = wstl_hash_personal_value( $billing_address, 'billing_address' );
		}

		// Phone number (normalized).
		$phone = $this->normalize_phone( $order->get_billing_phone() );
		if ( ! empty( $phone ) ) {
			$fingerprints['phone'] = wstl_hash_personal_value( $phone, 'phone' );
		}

		// IP address.
		$ip = $order->get_customer_ip_address();
		if ( ! empty( $ip ) && '127.0.0.1' !== $ip ) {
			$fingerprints['ip_address'] = wstl_hash_personal_value( $ip, 'ip_address' );
		}

		// Payment method token (if available).
		$payment_tokens = $this->get_payment_tokens( $order );
		if ( ! empty( $payment_tokens ) ) {
			$fingerprints['payment_token'] = wstl_hash_personal_value( implode( '|', $payment_tokens ), 'payment_token' );
		}

		// Device fingerprint (from user agent).
		$user_agent = $order->get_customer_user_agent();
		if ( ! empty( $user_agent ) ) {
			$fingerprints['device'] = wstl_hash_personal_value( $user_agent, 'device' );
		}

		return $fingerprints;
	}

	/**
	 * Normalize address for comparison.
	 *
	 * @since 1.1.0
	 * @param string $line1    Address line 1.
	 * @param string $line2    Address line 2.
	 * @param string $city     City.
	 * @param string $postcode Postcode.
	 * @param string $country  Country.
	 * @return string Normalized address.
	 */
	private function normalize_address( string $line1, string $line2, string $city, string $postcode, string $country ): string {
		if ( empty( $line1 ) && empty( $city ) ) {
			return '';
		}

		// Combine and normalize.
		$address = strtolower( trim( $line1 . ' ' . $line2 . ' ' . $city . ' ' . $postcode . ' ' . $country ) );

		// Remove common abbreviations and punctuation.
		$address = preg_replace( '/[^\w\s]/', '', $address );
		$address = preg_replace( '/\s+/', ' ', $address );

		// Common abbreviation replacements.
		$replacements = array(
			' st '    => ' street ',
			' rd '    => ' road ',
			' ave '   => ' avenue ',
			' blvd '  => ' boulevard ',
			' dr '    => ' drive ',
			' ln '    => ' lane ',
			' ct '    => ' court ',
			' apt '   => ' apartment ',
			' ste '   => ' suite ',
		);
		$address = str_replace( array_keys( $replacements ), array_values( $replacements ), ' ' . $address . ' ' );

		return trim( $address );
	}

	/**
	 * Normalize phone number.
	 *
	 * @since 1.1.0
	 * @param string $phone Phone number.
	 * @return string Normalized phone.
	 */
	private function normalize_phone( string $phone ): string {
		// Remove all non-digits.
		$phone = preg_replace( '/\D/', '', $phone );

		// Remove leading country codes (1 for US, etc).
		if ( strlen( $phone ) > 10 && '1' === $phone[0] ) {
			$phone = substr( $phone, 1 );
		}

		return $phone;
	}

	/**
	 * Get payment tokens from order.
	 *
	 * @since 1.1.0
	 * @param WC_Order $order Order object.
	 * @return array Payment tokens.
	 */
	private function get_payment_tokens( WC_Order $order ): array {
		$tokens = array();

		// Get saved payment token ID.
		$token_id = $order->get_meta( '_payment_token_id' );
		if ( ! empty( $token_id ) ) {
			$tokens[] = $token_id;
		}

		// Get Stripe/payment gateway fingerprint if available.
		$stripe_fingerprint = $order->get_meta( '_stripe_card_fingerprint' );
		if ( ! empty( $stripe_fingerprint ) ) {
			$tokens[] = 'stripe:' . $stripe_fingerprint;
		}

		// Get last 4 digits of card if available.
		$last4 = $order->get_meta( '_last4' );
		if ( ! empty( $last4 ) ) {
			$tokens[] = 'card:' . $last4;
		}

		return $tokens;
	}

	/**
	 * Store a fingerprint.
	 *
	 * @since 1.1.0
	 * @param string      $email_hash       Customer email hash.
	 * @param string      $fingerprint_type Type of fingerprint.
	 * @param string      $fingerprint_hash Hashed fingerprint value.
	 * @param string|null $country_code     ISO 3166-1 alpha-2 country code (address types only).
	 */
	private function store_fingerprint( string $email_hash, string $fingerprint_type, string $fingerprint_hash, ?string $country_code = null ): void {
		global $wpdb;

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

		// Check if table exists.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHOW TABLES check, no user input.
		$table_exists = $wpdb->get_var( $wpdb->prepare(
			"SHOW TABLES LIKE %s",
			$table
		) );

		if ( ! $table_exists ) {
			return;
		}

		// Write first_seen/last_seen in UTC (current_time mysql true) to match the
		// fingerprint service and the UTC cutoffs the shipping-anomaly windows and the
		// cleanup cron compare against. SQL NOW() / the first_seen DEFAULT
		// CURRENT_TIMESTAMP record the DB-session timezone, which disagrees with those
		// UTC values on a non-UTC MySQL server. first_seen is set only on insert.
		$now = current_time( 'mysql', true );

		// Insert or update fingerprint.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		if ( null !== $country_code ) {
			$wpdb->query( $wpdb->prepare(
				"INSERT INTO {$table} (email_hash, fingerprint_type, fingerprint_hash, country_code, first_seen, last_seen, times_seen)
				 VALUES (%s, %s, %s, %s, %s, %s, 1)
				 ON DUPLICATE KEY UPDATE last_seen = VALUES(last_seen), times_seen = times_seen + 1, country_code = VALUES(country_code)",
				$email_hash,
				$fingerprint_type,
				$fingerprint_hash,
				$country_code,
				$now,
				$now
			) );
		} else {
			$wpdb->query( $wpdb->prepare(
				"INSERT INTO {$table} (email_hash, fingerprint_type, fingerprint_hash, first_seen, last_seen, times_seen)
				 VALUES (%s, %s, %s, %s, %s, 1)
				 ON DUPLICATE KEY UPDATE last_seen = VALUES(last_seen), times_seen = times_seen + 1",
				$email_hash,
				$fingerprint_type,
				$fingerprint_hash,
				$now,
				$now
			) );
		}
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * Update linked accounts for a customer.
	 *
	 * @since 1.1.0
	 * @param string $email_hash Customer email hash.
	 */
	private function update_linked_accounts( string $email_hash ): void {
		$linked = $this->find_linked_accounts( $email_hash );

		if ( empty( $linked ) ) {
			return;
		}

		// Store linked account IDs.
		global $wpdb;
		$customers_table = $wpdb->prefix . 'trustlens_customers';

		$linked_hashes = wp_list_pluck( $linked, 'email_hash' );

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Table name from $wpdb->prefix, safe.
		$wpdb->update(
			$customers_table,
			array(
				'linked_accounts' => wp_json_encode( $linked_hashes ),
				'linked_count'    => count( $linked_hashes ),
			),
			array( 'email_hash' => $email_hash ),
			array( '%s', '%d' ),
			array( '%s' )
		);

		// Fire action for automation/webhooks.
		if ( count( $linked_hashes ) > 0 ) {
			/**
			 * Fires when linked accounts are detected.
			 *
			 * @since 1.1.0
			 * @param string $email_hash    Customer email hash.
			 * @param array  $linked_hashes Array of linked account hashes.
			 * @param array  $linked        Full linked account data.
			 */
			do_action( 'trustlens/linked_accounts_detected', $email_hash, $linked_hashes, $linked );
		}
	}

	/**
	 * Find linked accounts for a customer.
	 *
	 * @since 1.1.0
	 * @param string $email_hash Customer email hash.
	 * @return array Linked accounts with match types.
	 */
	public function find_linked_accounts( string $email_hash ): array {
		global $wpdb;

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

		// Check if table exists.
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHOW TABLES check, no user input.
		$table_exists = $wpdb->get_var( $wpdb->prepare(
			"SHOW TABLES LIKE %s",
			$table
		) );

		if ( ! $table_exists ) {
			return array();
		}

		// Get this customer's fingerprints.
		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$my_fingerprints = $wpdb->get_results( $wpdb->prepare(
			"SELECT fingerprint_type, fingerprint_hash FROM {$table} WHERE email_hash = %s",
			$email_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

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

		// Find other customers with matching fingerprints.
		$linked = array();

		foreach ( $my_fingerprints as $fp ) {
			// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
			$matches = $wpdb->get_results( $wpdb->prepare(
				"SELECT DISTINCT email_hash FROM {$table}
				 WHERE fingerprint_type = %s
				   AND fingerprint_hash = %s
				   AND email_hash != %s",
				$fp->fingerprint_type,
				$fp->fingerprint_hash,
				$email_hash
			) );
			// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

			foreach ( $matches as $match ) {
				if ( ! isset( $linked[ $match->email_hash ] ) ) {
					$linked[ $match->email_hash ] = array(
						'email_hash'  => $match->email_hash,
						'match_types' => array(),
					);
				}
				$linked[ $match->email_hash ]['match_types'][] = $fp->fingerprint_type;
			}
		}

		// Calculate match strength. Deduplicate the matched fingerprint types
		// first so the strength count reflects distinct shared identifiers, not
		// repeated rows for the same type.
		foreach ( $linked as &$account ) {
			$account['match_types'] = array_values( array_unique( $account['match_types'] ) );
			$account['match_count'] = count( $account['match_types'] );
		}
		unset( $account );

		// Require at least one identity-grade shared identifier before treating two
		// accounts as linked. A weak-only overlap (a shared browser or IP) is not
		// evidence of the same person and must not link accounts or feed a penalty.
		$linked = array_filter(
			$linked,
			static function ( $account ) {
				return (bool) array_intersect( $account['match_types'], self::STRONG_MATCH_TYPES );
			}
		);

		// Sort by match count.
		usort( $linked, function( $a, $b ) {
			return $b['match_count'] - $a['match_count'];
		} );

		return array_values( $linked );
	}

	/**
	 * Add linked accounts signal to score calculation.
	 *
	 * @since 1.1.0
	 * @param array  $signals    Current signals.
	 * @param string $email_hash Customer email hash.
	 * @return array Modified signals.
	 */
	public function add_linked_signal( array $signals, string $email_hash ): array {
		$linked = $this->find_linked_accounts( $email_hash );

		if ( empty( $linked ) ) {
			return $signals;
		}

		// Check if any linked accounts are high-risk.
		$high_risk_links = 0;
		$blocked_links = 0;

		foreach ( $linked as $link ) {
			$customer = wstl_get_customer( $link['email_hash'] );
			if ( $customer ) {
				if ( in_array( $customer->segment, array( 'risk', 'critical' ), true ) ) {
					$high_risk_links++;
				}
				if ( $customer->is_blocked ) {
					$blocked_links++;
				}
			}
		}

		// Add signal based on linked accounts.
		$linked_count   = count( $linked );
		$penalty        = 0;
		$penalty_per_link = (int) get_option( 'trustlens_linked_accounts_penalty', 5 );

		// Penalty for number of linked accounts.
		if ( $linked_count >= 5 ) {
			$penalty += 15;
		} elseif ( $linked_count >= 3 ) {
			$penalty += 10;
		} elseif ( $linked_count >= 1 ) {
			$penalty += 5;
		}

		// Additional penalty for high-risk links (configurable).
		$penalty += $high_risk_links * $penalty_per_link;

		// Severe penalty for blocked account links (double the configured rate).
		$penalty += $blocked_links * ( $penalty_per_link * 2 );

		if ( $penalty > 0 ) {
			$signals[] = array(
				'module' => 'linked_accounts',
				'score'  => -$penalty,
				'reason' => sprintf(
					/* translators: 1: linked count, 2: high-risk count, 3: blocked count */
					__( 'Linked to %1$d other account(s) (%2$d high-risk, %3$d blocked)', 'trustlens' ),
					$linked_count,
					$high_risk_links,
					$blocked_links
				),
			);
		}

		return $signals;
	}

	/**
	 * Get linked accounts details for display.
	 *
	 * @since 1.1.0
	 * @param string $email_hash Customer email hash.
	 * @return array Linked accounts with customer details.
	 */
	public static function get_linked_accounts_details( string $email_hash ): array {
		$instance = self::instance();
		$linked = $instance->find_linked_accounts( $email_hash );

		$details = array();
		foreach ( $linked as $link ) {
			$customer = wstl_get_customer( $link['email_hash'] );
			if ( $customer ) {
				$details[] = array(
					'email_hash'   => $link['email_hash'],
					'email'        => $customer->customer_email ?? '',
					'trust_score'  => (int) $customer->trust_score,
					'segment'      => $customer->segment,
					'is_blocked'   => (bool) $customer->is_blocked,
					'match_types'  => $link['match_types'],
					'match_count'  => $link['match_count'],
					'total_orders' => (int) $customer->total_orders,
					'total_refunds' => (int) $customer->total_refunds,
				);
			}
		}

		return $details;
	}

	/**
	 * Get fingerprint types for a customer.
	 *
	 * @since 1.1.0
	 * @param string $email_hash Customer email hash.
	 * @return array Fingerprint data.
	 */
	public static function get_customer_fingerprints( string $email_hash ): array {
		global $wpdb;

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

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHOW TABLES check, no user input.
		$table_exists = $wpdb->get_var( $wpdb->prepare(
			"SHOW TABLES LIKE %s",
			$table
		) );

		if ( ! $table_exists ) {
			return array();
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		return $wpdb->get_results( $wpdb->prepare(
			"SELECT fingerprint_type, fingerprint_hash, first_seen, last_seen, times_seen
			 FROM {$table}
			 WHERE email_hash = %s
			 ORDER BY times_seen DESC",
			$email_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * Get accounts sharing a specific fingerprint.
	 *
	 * @since 1.1.0
	 * @param string $fingerprint_type Type of fingerprint.
	 * @param string $fingerprint_hash Fingerprint hash.
	 * @return array Customer email hashes.
	 */
	public static function get_accounts_by_fingerprint( string $fingerprint_type, string $fingerprint_hash ): array {
		global $wpdb;

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

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		return $wpdb->get_col( $wpdb->prepare(
			"SELECT DISTINCT email_hash FROM {$table}
			 WHERE fingerprint_type = %s AND fingerprint_hash = %s",
			$fingerprint_type,
			$fingerprint_hash
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter
	}

	/**
	 * Clean up old fingerprints.
	 *
	 * @since 1.1.0
	 * @param int $days Days to keep.
	 * @return int Number of deleted records.
	 */
	public static function cleanup_old_fingerprints( int $days = 365 ): int {
		global $wpdb;

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

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- SHOW TABLES check, no user input.
		$table_exists = $wpdb->get_var( $wpdb->prepare(
			"SHOW TABLES LIKE %s",
			$table
		) );

		if ( ! $table_exists ) {
			return 0;
		}

		// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix, safe.
		$wpdb->query( $wpdb->prepare(
			// UTC to match the UTC last_seen written by store_fingerprint()/the
			// fingerprint service; DATE_SUB(NOW()) would prune on the DB-session clock.
			"DELETE FROM {$table} WHERE last_seen < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)",
			$days
		) );
		// phpcs:enable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

		return $wpdb->rows_affected;
	}
}
