<?php
/**
 * Data Normalizer — E.164 phone, PII SHA-256 hashing, field normalization.
 *
 * All PII fields hashed per Meta CAPI requirements.
 * client_ip_address, client_user_agent, fbc, fbp are NEVER hashed.
 *
 * Architecture: "Normalize Once, Use Everywhere"
 * - normalize_*() : produces raw normalized value (for Browser Advanced Matching)
 * - hash_*()      : calls normalize_*() + sha256 (for Server CAPI)
 *
 * @package CapiFlow
 */

namespace CapiFlow\Core;

defined( 'ABSPATH' ) || exit;

class Data_Normalizer {

	/**
	 * Country dialing codes map for E.164 normalization.
	 *
	 * @var array
	 */
	const COUNTRY_CODES = array(
		'BD' => '880',   // Bangladesh
		'IN' => '91',    // India
		'US' => '1',     // USA
		'GB' => '44',    // UK
		'AE' => '971',   // UAE
		'SA' => '966',   // Saudi Arabia
		'MY' => '60',    // Malaysia
		'SG' => '65',    // Singapore
		'PK' => '92',    // Pakistan
		'NP' => '977',   // Nepal
		'LK' => '94',    // Sri Lanka
	);

	/**
	 * Default country code.
	 *
	 * @var string
	 */
	const DEFAULT_COUNTRY = 'BD';

	// ════════════════════════════════════════════════════════════
	// Private Helpers
	// ════════════════════════════════════════════════════════════

	/**
	 * Safely lowercase a UTF-8 string.
	 *
	 * Uses mb_strtolower() when mbstring extension is available,
	 * falls back to strtolower() which handles ASCII lowercase correctly
	 * and preserves UTF-8 multibyte characters unchanged.
	 *
	 * @param string $value String to lowercase.
	 * @return string Lowercased string.
	 */
	private static function safe_mb_lower( $value ) {
		if ( function_exists( 'mb_strtolower' ) ) {
			return mb_strtolower( $value, 'UTF-8' );
		}
		return strtolower( $value );
	}

	/**
	 * Check if a value is already SHA-256 hashed.
	 *
	 * Detects 64-character hex strings (case-insensitive).
	 * The /i flag catches uppercase hex from third-party systems.
	 *
	 * @param string $value Value to check.
	 * @return bool True if already hashed.
	 */
	private static function is_already_hashed( $value ) {
		return is_string( $value ) && preg_match( '/^[a-f0-9]{64}$/i', $value );
	}

	// ════════════════════════════════════════════════════════════
	// Deprecated
	// ════════════════════════════════════════════════════════════

	/**
	 * Hash a value with SHA-256 after basic normalization (trim + lowercase).
	 *
	 * @deprecated Use field-specific hash functions (hash_name, hash_city, etc.)
	 *             which apply Meta-compliant normalization. This function does NOT
	 *             strip punctuation or handle Unicode correctly.
	 *
	 * @param string $value     Value to hash.
	 * @param bool   $lowercase Whether to lowercase before hashing.
	 * @return string|null SHA-256 hash, or null if empty/invalid.
	 */
	public static function hash_pii( $value, $lowercase = true ) {
		if ( empty( $value ) || ! is_string( $value ) ) {
			return null;
		}

		$value = trim( $value );

		if ( $lowercase ) {
			$value = strtolower( $value );
		}

		return hash( 'sha256', $value );
	}

	// ════════════════════════════════════════════════════════════
	// Email — trim + lowercase (NO punctuation strip)
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize email address per Meta CAPI rules.
	 *
	 * Rules: trim whitespace + lowercase. DO NOT strip underscores or dots!
	 * Meta CSV: "     John_Smith@gmail.com    " → "john_smith@gmail.com"
	 *
	 * @param string $email Raw email.
	 * @return string Normalized email, or empty string.
	 */
	public static function normalize_email( $email ) {
		if ( empty( $email ) || ! is_string( $email ) ) {
			return '';
		}
		return strtolower( trim( $email ) );
	}

	/**
	 * Hash email address per Meta CAPI rules.
	 *
	 * @param string $email Raw email.
	 * @return string|null SHA-256 hash, or null if already hashed or empty.
	 */
	public static function hash_email( $email ) {
		if ( self::is_already_hashed( $email ) ) {
			return $email;
		}

		$normalized = self::normalize_email( $email );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// Name (fn/ln) — lowercase + strip ALL non-letter chars
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize a name (first or last) per Meta CAPI rules.
	 *
	 * Rules: trim → lowercase → remove ALL non-letter characters.
	 * Preserves Unicode letters (é, ñ, ঢ, 정) but strips digits,
	 * punctuation, spaces, and symbols.
	 *
	 * Meta CSV: "Na'than-Boile" → "nathanboile", "정" → "정", "Valéry" → "valéry"
	 *
	 * @param string $name Raw name.
	 * @return string Normalized name, or empty string.
	 */
	public static function normalize_name( $name ) {
		if ( empty( $name ) || ! is_string( $name ) ) {
			return '';
		}

		$name = trim( $name );
		$name = self::safe_mb_lower( $name );

		// Keep ONLY Unicode letters (\p{L}) and combining marks (\p{M}).
		// \p{M} preserves Bengali vowel signs (ি, া, ু), Hindi matras, Arabic diacritics.
		// Remove digits, punctuation, spaces, symbols.
		$name = preg_replace( '/[^\p{L}\p{M}]/u', '', $name );

		return $name;
	}

	/**
	 * Hash a name (first or last) per Meta CAPI rules.
	 *
	 * Normalizes the name (strip punctuation, lowercase, Unicode-safe)
	 * then hashes with SHA-256.
	 *
	 * @param string $name Raw name.
	 * @return string|null SHA-256 hash, or null if empty/invalid.
	 */
	public static function hash_name( $name ) {
		if ( self::is_already_hashed( $name ) ) {
			return $name;
		}

		$normalized = self::normalize_name( $name );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// City — lowercase + strip non-letter/digit chars (Unicode-safe)
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize city name per Meta CAPI rules.
	 *
	 * Rules: trim → lowercase → remove spaces, punctuation, symbols.
	 * Preserves Unicode letters AND digits. "District 5" → "district5".
	 *
	 * Meta CSV: "Menlo Park" → "menlopark", "ঢাকা" → "ঢাকা" (preserved!)
	 *
	 * @param string $city City name.
	 * @return string Normalized city, or empty string.
	 */
	public static function normalize_city( $city ) {
		if ( empty( $city ) || ! is_string( $city ) ) {
			return '';
		}

		$city = trim( $city );
		$city = self::safe_mb_lower( $city );

		// Keep Unicode letters (\p{L}), combining marks (\p{M}), and digits (\p{N}).
		// \p{M} preserves Bengali vowel signs, Hindi matras, Arabic diacritics.
		// Remove spaces, punctuation, symbols.
		$city = preg_replace( '/[^\p{L}\p{M}\p{N}]/u', '', $city );

		return $city;
	}

	/**
	 * Hash city name per Meta CAPI rules.
	 *
	 * @param string $city City name.
	 * @return string|null SHA-256 hash, or null if empty/invalid.
	 */
	public static function hash_city( $city ) {
		if ( self::is_already_hashed( $city ) ) {
			return $city;
		}

		$normalized = self::normalize_city( $city );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// State — lowercase + strip non-letter/digit chars
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize state per Meta CAPI rules.
	 *
	 * Rules: trim → lowercase → remove spaces, punctuation, symbols.
	 * Preserves Unicode letters AND digits. "BD-13" → "bd13".
	 *
	 * Meta CSV: "C/a/lifo,rnia." → "california", "CA" → "ca"
	 *
	 * @param string $state State code or name.
	 * @return string Normalized state, or empty string.
	 */
	public static function normalize_state( $state ) {
		if ( empty( $state ) || ! is_string( $state ) ) {
			return '';
		}

		$state = trim( $state );
		$state = self::safe_mb_lower( $state );

		// Keep Unicode letters (\p{L}), combining marks (\p{M}), and digits (\p{N}).
		$state = preg_replace( '/[^\p{L}\p{M}\p{N}]/u', '', $state );

		return $state;
	}

	/**
	 * Hash state per Meta CAPI rules.
	 *
	 * @param string $state State code or name.
	 * @return string|null SHA-256 hash, or null if empty/invalid.
	 */
	public static function hash_state( $state ) {
		if ( self::is_already_hashed( $state ) ) {
			return $state;
		}

		$normalized = self::normalize_state( $state );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// Country — 2-letter ISO fast-path + fallback regex
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize country per Meta CAPI rules.
	 *
	 * WooCommerce always provides 2-letter ISO 3166-1 alpha-2 code.
	 * Fast-path for 2-letter codes, fallback regex for edge cases.
	 *
	 * Meta CSV: "US" → "us", "United States" → "unitedstates"
	 *
	 * @param string $country Country code or name.
	 * @return string Normalized country, or empty string.
	 */
	public static function normalize_country( $country ) {
		if ( empty( $country ) || ! is_string( $country ) ) {
			return '';
		}

		$country = trim( $country );

		// Fast-path: WooCommerce always gives 2-letter ISO code (e.g., "BD", "US").
		if ( strlen( $country ) === 2 ) {
			return strtolower( $country );
		}

		// Fallback: full country name → strip spaces/punctuation, lowercase.
		// "United States" → "unitedstates", "U.S." → "us"
		$country = self::safe_mb_lower( $country );
		$country = preg_replace( '/[^\p{L}\p{M}\p{N}]/u', '', $country );

		return $country;
	}

	/**
	 * Hash country per Meta CAPI rules.
	 *
	 * @param string $country Country code or name.
	 * @return string|null SHA-256 hash, or null if empty/invalid.
	 */
	public static function hash_country( $country ) {
		if ( self::is_already_hashed( $country ) ) {
			return $country;
		}

		$normalized = self::normalize_country( $country );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// Zip Code — US 5+4 handling + strip spaces/dashes
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize zip/postal code per Meta CAPI rules.
	 *
	 * US zip 5+4 format (37221-3312) → keep only first 5 digits.
	 * All zips: remove spaces, dashes, lowercase.
	 *
	 * Meta CSV: "37221-3312" → "37221", "37221" → "37221"
	 *
	 * @param string $zip Raw zip/postal code.
	 * @return string Normalized zip, or empty string.
	 */
	public static function normalize_zip( $zip ) {
		if ( empty( $zip ) || ! is_string( $zip ) ) {
			return '';
		}

		$zip = strtolower( trim( $zip ) );

		// US zip 5+4 format: "37221-3312" → "37221" (first 5 digits only).
		// This regex ONLY matches the exact US 5+4 pattern, so non-US zips are unaffected.
		if ( preg_match( '/^(\d{5})-\d{4}$/', $zip, $m ) ) {
			$zip = $m[1];
		}

		// Remove remaining spaces and dashes.
		$zip = preg_replace( '/[\s\-]/', '', $zip );

		return $zip;
	}

	/**
	 * Hash zip/postal code per Meta CAPI rules.
	 *
	 * @param string $zip Raw zip/postal code.
	 * @return string|null SHA-256 hash, or null if empty/invalid.
	 */
	public static function hash_zip( $zip ) {
		if ( self::is_already_hashed( $zip ) ) {
			return $zip;
		}

		$normalized = self::normalize_zip( $zip );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// Phone — E.164 normalization + country code
	// ════════════════════════════════════════════════════════════

	/**
	 * Normalize phone to E.164 format (digits only, with country code).
	 *
	 * Meta docs: "Remove symbols, letters, and any leading zeros.
	 * Phone numbers must include a country code."
	 *
	 * Meta CSV: "+001 (616) 954-78 88" → "16169547888"
	 *           "01712345678" (BD) → "8801712345678"
	 *
	 * @param string $phone        Raw phone number.
	 * @param string $country_code ISO 3166-1 alpha-2 country code.
	 * @return string E.164 formatted phone (digits only), or empty string.
	 */
	public static function normalize_phone_e164( $phone, $country_code = '' ) {
		if ( empty( $phone ) ) {
			return '';
		}

		// Remove everything except digits.
		$digits = preg_replace( '/[^0-9]/', '', $phone );

		if ( empty( $digits ) ) {
			return '';
		}

		// Determine country dialing code.
		if ( empty( $country_code ) ) {
			$country_code = get_option( 'woocommerce_default_country', self::DEFAULT_COUNTRY );
			// WooCommerce stores "BD:DHA" format — extract country.
			if ( strpos( $country_code, ':' ) !== false ) {
				$country_code = explode( ':', $country_code )[0];
			}
		}

		$country_code  = strtoupper( $country_code );
		$dialing_code  = isset( self::COUNTRY_CODES[ $country_code ] )
			? self::COUNTRY_CODES[ $country_code ]
			: self::COUNTRY_CODES[ self::DEFAULT_COUNTRY ];

		// Strip ALL leading zeros (handles +00, 00, 0 prefixes).
		// "0016169547888" → "16169547888"
		// "01712345678"   → "1712345678"
		// "8801712345678" → "8801712345678" (no leading zeros)
		$digits = ltrim( $digits, '0' );

		// Safety: if phone was all zeros (e.g., "000"), return empty.
		if ( empty( $digits ) ) {
			return '';
		}

		// Already starts with country dialing code?
		if ( strpos( $digits, $dialing_code ) === 0 ) {
			return $digits;
		}

		// Prepend country dialing code.
		return $dialing_code . $digits;
	}

	/**
	 * Normalize phone number to E.164 format and hash.
	 *
	 * @param string $phone        Raw phone number.
	 * @param string $country_code ISO 3166-1 alpha-2 country code.
	 * @return string|null SHA-256 hash of E.164 phone, or null if empty.
	 */
	public static function hash_phone( $phone, $country_code = '' ) {
		if ( self::is_already_hashed( $phone ) ) {
			return $phone;
		}

		$normalized = self::normalize_phone_e164( $phone, $country_code );

		return empty( $normalized ) ? null : hash( 'sha256', $normalized );
	}

	// ════════════════════════════════════════════════════════════
	// build_user_data — Server CAPI payload builder
	// ════════════════════════════════════════════════════════════

	/**
	 * Build the complete user_data object for CAPI payload.
	 *
	 * @param \WC_Order|null $order    WooCommerce order (for server events).
	 * @param array          $context  Additional context (IP, UA, cookies).
	 * @return array Normalized and hashed user_data array.
	 */
	public static function build_user_data( $order = null, $context = array() ) {
		$user_data = array();

		// Client IP — NEVER hashed.
		if ( ! empty( $context['client_ip'] ) ) {
			$user_data['client_ip_address'] = $context['client_ip'];
		}

		// User Agent — NEVER hashed.
		if ( ! empty( $context['user_agent'] ) ) {
			$user_data['client_user_agent'] = $context['user_agent'];
		}

		// Cookies — NEVER hashed.
		if ( ! empty( $context['fbc'] ) ) {
			$user_data['fbc'] = $context['fbc'];
		}
		if ( ! empty( $context['fbp'] ) ) {
			$user_data['fbp'] = $context['fbp'];
		}

		// External ID — hashed, as array.
		if ( ! empty( $context['external_id'] ) ) {
			$user_data['external_id'] = array( $context['external_id'] );
		}

		// Order-based PII data.
		if ( $order instanceof \WC_Order ) {
			$country_code = $order->get_billing_country();

			$email = self::hash_email( $order->get_billing_email() );
			if ( $email !== null ) {
				$user_data['em'] = array( $email );
			}

			$phone = self::hash_phone( $order->get_billing_phone(), $country_code );
			if ( $phone !== null ) {
				$user_data['ph'] = array( $phone );
			}

			$fn = self::hash_name( $order->get_billing_first_name() );
			if ( $fn !== null ) {
				$user_data['fn'] = array( $fn );
			}

			$ln = self::hash_name( $order->get_billing_last_name() );
			if ( $ln !== null ) {
				$user_data['ln'] = array( $ln );
			}

			$ct = self::hash_city( $order->get_billing_city() );
			if ( $ct !== null ) {
				$user_data['ct'] = array( $ct );
			}

			$st = self::hash_state( $order->get_billing_state() );
			if ( $st !== null ) {
				$user_data['st'] = array( $st );
			}

			$zp = self::hash_zip( $order->get_billing_postcode() );
			if ( $zp !== null ) {
				$user_data['zp'] = array( $zp );
			}

			$co = self::hash_country( $country_code );
			if ( $co !== null ) {
				$user_data['country'] = array( $co );
			}
		}

		// ── Logged-in user external_id fallback (when no context provided) ──
		if ( empty( $user_data['external_id'] ) && function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
			$user_data['external_id'] = array( hash( 'sha256', 'wp_' . get_current_user_id() ) );
		}

		return $user_data;
	}

	// ════════════════════════════════════════════════════════════
	// Utility — IP & User Agent
	// ════════════════════════════════════════════════════════════

	/**
	 * Get the real client IP address (CDN/Proxy safe).
	 *
	 * Checks: CF-Connecting-IP → X-Real-IP → X-Forwarded-For → REMOTE_ADDR
	 *
	 * @return string Client IP address.
	 */
	public static function get_real_client_ip() {
		$headers = array(
			'HTTP_CF_CONNECTING_IP',     // Cloudflare.
			'HTTP_X_REAL_IP',            // Nginx reverse proxy.
			'HTTP_X_FORWARDED_FOR',      // Standard proxy header.
		);

		foreach ( $headers as $header ) {
			if ( ! empty( $_SERVER[ $header ] ) ) {
				$ips = explode( ',', sanitize_text_field( wp_unslash( $_SERVER[ $header ] ) ) );
				$ip  = trim( $ips[0] );

				// Accept any valid IP (including private ranges — needed for shared hosting).
				if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) {
					return $ip;
				}
			}
		}

		// Always fall back to REMOTE_ADDR.
		return isset( $_SERVER['REMOTE_ADDR'] )
			? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) )
			: '';
	}

	/**
	 * Get the client user agent.
	 *
	 * @return string User agent string.
	 */
	public static function get_user_agent() {
		return isset( $_SERVER['HTTP_USER_AGENT'] )
			? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
			: '';
	}
}
