<?php

namespace SugarCalendarBookings\Support\Helpers;

/**
 * Address Parser.
 *
 * Parses single-line address strings into structured components
 * for US and Canadian address formats.
 *
 * @since 1.3.0
 */
class AddressParser {

	/**
	 * US postal code pattern (5 digits, optionally -4 digits).
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	private const US_POSTAL_PATTERN = '/\b(\d{5})(?:-(\d{4}))?\s*$/';

	/**
	 * Canadian postal code pattern (A1A 1A1 format).
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	private const CA_POSTAL_PATTERN = '/\b([A-Z]\d[A-Z])\s*(\d[A-Z]\d)\s*$/i';

	/**
	 * Secondary unit pattern (Apt, Suite, Unit, #).
	 *
	 * @since 1.3.0
	 *
	 * @var string
	 */
	private const SECONDARY_UNIT_PATTERN = '/\b(Apt\.?|Apartment|Suite|Ste\.?|Unit|#)\s*([A-Z0-9-]+)\b/i';

	/**
	 * US states and territories.
	 *
	 * @since 1.3.0
	 *
	 * @var array<string, string>
	 */
	private const US_STATES = [
		'AL' => 'Alabama',
		'AK' => 'Alaska',
		'AZ' => 'Arizona',
		'AR' => 'Arkansas',
		'CA' => 'California',
		'CO' => 'Colorado',
		'CT' => 'Connecticut',
		'DE' => 'Delaware',
		'DC' => 'District of Columbia',
		'FL' => 'Florida',
		'GA' => 'Georgia',
		'HI' => 'Hawaii',
		'ID' => 'Idaho',
		'IL' => 'Illinois',
		'IN' => 'Indiana',
		'IA' => 'Iowa',
		'KS' => 'Kansas',
		'KY' => 'Kentucky',
		'LA' => 'Louisiana',
		'ME' => 'Maine',
		'MD' => 'Maryland',
		'MA' => 'Massachusetts',
		'MI' => 'Michigan',
		'MN' => 'Minnesota',
		'MS' => 'Mississippi',
		'MO' => 'Missouri',
		'MT' => 'Montana',
		'NE' => 'Nebraska',
		'NV' => 'Nevada',
		'NH' => 'New Hampshire',
		'NJ' => 'New Jersey',
		'NM' => 'New Mexico',
		'NY' => 'New York',
		'NC' => 'North Carolina',
		'ND' => 'North Dakota',
		'OH' => 'Ohio',
		'OK' => 'Oklahoma',
		'OR' => 'Oregon',
		'PA' => 'Pennsylvania',
		'RI' => 'Rhode Island',
		'SC' => 'South Carolina',
		'SD' => 'South Dakota',
		'TN' => 'Tennessee',
		'TX' => 'Texas',
		'UT' => 'Utah',
		'VT' => 'Vermont',
		'VA' => 'Virginia',
		'WA' => 'Washington',
		'WV' => 'West Virginia',
		'WI' => 'Wisconsin',
		'WY' => 'Wyoming',
		'AS' => 'American Samoa',
		'GU' => 'Guam',
		'MP' => 'Northern Mariana Islands',
		'PR' => 'Puerto Rico',
		'VI' => 'Virgin Islands',
	];

	/**
	 * Canadian provinces and territories.
	 *
	 * @since 1.3.0
	 *
	 * @var array<string, string>
	 */
	private const CA_PROVINCES = [
		'AB' => 'Alberta',
		'BC' => 'British Columbia',
		'MB' => 'Manitoba',
		'NB' => 'New Brunswick',
		'NL' => 'Newfoundland and Labrador',
		'NS' => 'Nova Scotia',
		'NT' => 'Northwest Territories',
		'NU' => 'Nunavut',
		'ON' => 'Ontario',
		'PE' => 'Prince Edward Island',
		'QC' => 'Quebec',
		'SK' => 'Saskatchewan',
		'YT' => 'Yukon',
	];

	/**
	 * Parse a single-line address string into structured components.
	 *
	 * @since 1.3.0
	 *
	 * @param string $address_string The raw address string to parse.
	 * @param string $country_hint   ISO 2-letter country code hint (default 'US').
	 *
	 * @return array{
	 *     address: string,
	 *     address2: string,
	 *     city: string,
	 *     region: string,
	 *     postal_code: string,
	 *     country_code: string,
	 *     parsed: bool,
	 *     confidence: string
	 * }
	 */
	public static function parse( string $address_string, string $country_hint = 'US' ): array {

		$default = [
			'address'      => '',
			'address2'     => '',
			'city'         => '',
			'region'       => '',
			'postal_code'  => '',
			'country_code' => strtoupper( $country_hint ) ?: 'US',
			'parsed'       => false,
			'confidence'   => 'low',
		];

		$normalized = self::normalize( $address_string );

		if ( empty( $normalized ) ) {
			return $default;
		}

		// Extract secondary unit (Apt, Suite, etc.).
		$address2   = '';
		$normalized = self::extract_secondary_unit( $normalized, $address2 );

		// Try to parse with postal code anchoring.
		$result = self::parse_with_postal_anchor( $normalized, $country_hint );

		if ( $result !== null ) {
			$result['address2'] = $address2;

			return $result;
		}

		// Fallback to comma-based parsing.
		$result = self::parse_with_comma_fallback( $normalized, $country_hint );

		if ( $result !== null ) {
			$result['address2'] = $address2;

			return $result;
		}

		// Unable to parse - return original string in address field.
		$default['address']  = $address_string;
		$default['address2'] = $address2;

		return $default;
	}

	/**
	 * Normalize the address string.
	 *
	 * @since 1.3.0
	 *
	 * @param string $address The address string to normalize.
	 *
	 * @return string
	 */
	private static function normalize( string $address ): string {

		// Trim and normalize whitespace.
		$address = trim( $address );
		$address = preg_replace( '/\s+/', ' ', $address );

		return $address;
	}

	/**
	 * Extract secondary unit from address string.
	 *
	 * @since 1.3.0
	 *
	 * @param string $address  The address string.
	 * @param string $address2 Reference to store extracted unit.
	 *
	 * @return string Address with secondary unit removed.
	 */
	private static function extract_secondary_unit( string $address, string &$address2 ): string {

		if ( preg_match( self::SECONDARY_UNIT_PATTERN, $address, $matches ) ) {
			$address2 = trim( $matches[0] );
			$address  = trim( str_replace( $matches[0], '', $address ) );

			// Clean up any resulting double commas or spaces.
			$address = preg_replace( '/,\s*,/', ',', $address );
			$address = preg_replace( '/\s+/', ' ', $address );
			$address = trim( $address, ', ' );
		}

		return $address;
	}

	/**
	 * Parse address using postal code as anchor point.
	 *
	 * @since 1.3.0
	 *
	 * @param string $address      The normalized address string.
	 * @param string $country_hint Country code hint.
	 *
	 * @return array|null Parsed result or null if parsing failed.
	 */
	private static function parse_with_postal_anchor( string $address, string $country_hint ): ?array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$postal_code  = '';
		$country_code = strtoupper( $country_hint ) ?: 'US';
		$remaining    = $address;

		// Try US postal code first.
		if ( preg_match( self::US_POSTAL_PATTERN, $address, $matches ) ) {
			$postal_code  = $matches[1];
			$country_code = 'US';

			if ( ! empty( $matches[2] ) ) {
				$postal_code .= '-' . $matches[2];
			}

			$remaining = trim( preg_replace( self::US_POSTAL_PATTERN, '', $address ) );
		} elseif ( preg_match( self::CA_POSTAL_PATTERN, $address, $matches ) ) {
			// Try Canadian postal code.
			$postal_code  = strtoupper( $matches[1] . ' ' . $matches[2] );
			$country_code = 'CA';
			$remaining    = trim( preg_replace( self::CA_POSTAL_PATTERN, '', $address ) );
		}

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

		// Remove trailing comma if present.
		$remaining = rtrim( $remaining, ', ' );

		// Try to extract region.
		$region    = '';
		$remaining = self::extract_region( $remaining, $region, $country_code );

		if ( empty( $region ) ) {
			// Could not find valid region - medium confidence.
			return self::parse_remaining_parts( $remaining, '', $postal_code, $country_code, 'medium' );
		}

		return self::parse_remaining_parts( $remaining, $region, $postal_code, $country_code, 'high' );
	}

	/**
	 * Extract region (state/province) from address string.
	 *
	 * @since 1.3.0
	 *
	 * @param string $address      The address string.
	 * @param string $region       Reference to store extracted region.
	 * @param string $country_code The country code (US or CA).
	 *
	 * @return string Address with region removed.
	 */
	private static function extract_region( string $address, string &$region, string $country_code ): string { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.TooHigh

		$all_states = array_merge( self::US_STATES, self::CA_PROVINCES );

		// Try full state/province name FIRST to avoid matching abbreviations within words.
		// E.g., "California" should not match "IA" (Iowa).
		foreach ( $all_states as $abbr => $name ) {
			$pattern = '/,?\s*' . preg_quote( $name, '/' ) . '\s*$/i';

			if ( preg_match( $pattern, $address ) ) {
				$region = $abbr;

				return trim( preg_replace( $pattern, '', $address ), ', ' );
			}
		}

		// Try abbreviation (2 letters at end, must be preceded by comma or space for word boundary).
		if ( preg_match( '/(?:,\s*|\s+)([A-Z]{2})\s*$/i', $address, $matches ) ) {
			$abbr   = strtoupper( $matches[1] );
			$states = $country_code === 'CA' ? self::CA_PROVINCES : self::US_STATES;

			if ( isset( $states[ $abbr ] ) ) {
				$region = $abbr;

				return trim( preg_replace( '/(?:,\s*|\s+)[A-Z]{2}\s*$/i', '', $address ), ', ' );
			}

			// Check if it's from the other country.
			$other_states = $country_code === 'CA' ? self::US_STATES : self::CA_PROVINCES;

			if ( isset( $other_states[ $abbr ] ) ) {
				$region = $abbr;

				return trim( preg_replace( '/(?:,\s*|\s+)[A-Z]{2}\s*$/i', '', $address ), ', ' );
			}
		}

		return $address;
	}

	/**
	 * Parse remaining address parts (street and city).
	 *
	 * @since 1.3.0
	 *
	 * @param string $remaining    Remaining address after postal and region extraction.
	 * @param string $region       Extracted region.
	 * @param string $postal_code  Extracted postal code.
	 * @param string $country_code Country code.
	 * @param string $confidence   Confidence level.
	 *
	 * @return array Parsed result.
	 */
	private static function parse_remaining_parts(
		string $remaining,
		string $region,
		string $postal_code,
		string $country_code,
		string $confidence
	): array {

		$parts = array_map( 'trim', explode( ',', $remaining ) );
		$parts = array_filter( $parts );

		$city    = '';
		$address = $remaining;

		if ( count( $parts ) >= 2 ) {
			// Last part is city, rest is address.
			$city    = array_pop( $parts );
			$address = implode( ', ', $parts );
		} elseif ( count( $parts ) === 1 ) {
			// Only one part - could be just city or just address.
			// If we have region and postal, assume this is city.
			if ( ! empty( $region ) && ! empty( $postal_code ) ) {
				$city    = $parts[0];
				$address = '';
			} else {
				$address = $parts[0];
			}
		}

		return [
			'address'      => $address,
			'address2'     => '',
			'city'         => $city,
			'region'       => $region,
			'postal_code'  => $postal_code,
			'country_code' => $country_code,
			'parsed'       => true,
			'confidence'   => $confidence,
		];
	}

	/**
	 * Fallback parsing using comma separation.
	 *
	 * @since 1.3.0
	 *
	 * @param string $address      The address string.
	 * @param string $country_hint Country code hint.
	 *
	 * @return array|null Parsed result or null if parsing failed.
	 */
	private static function parse_with_comma_fallback( string $address, string $country_hint ): ?array { // phpcs:ignore Generic.Metrics.CyclomaticComplexity.MaxExceeded

		$parts = array_map( 'trim', explode( ',', $address ) );
		$parts = array_filter( $parts );

		if ( count( $parts ) < 2 ) {
			return null;
		}

		$country_code = strtoupper( $country_hint ) ?: 'US';

		// Analyze last part for region and/or postal code.
		$last_part   = array_pop( $parts );
		$region      = '';
		$postal_code = '';

		// Check for "ST 12345" or "State 12345" pattern.
		if ( preg_match( '/^([A-Z]{2})\s+(\d{5}(?:-\d{4})?)$/i', $last_part, $matches ) ) {
			$region       = strtoupper( $matches[1] );
			$postal_code  = $matches[2];
			$country_code = 'US';
		} elseif ( preg_match( '/^([A-Z]{2})\s+([A-Z]\d[A-Z]\s*\d[A-Z]\d)$/i', $last_part, $matches ) ) {
			// Check for "ON A1B 2C3" pattern.
			$region       = strtoupper( $matches[1] );
			$postal_code  = strtoupper( $matches[2] );
			$country_code = 'CA';
		} elseif ( preg_match( '/^(\d{5}(?:-\d{4})?)$/', $last_part, $matches ) ) {
			// Just postal code.
			$postal_code  = $matches[1];
			$country_code = 'US';
		} elseif ( preg_match( '/^([A-Z]\d[A-Z]\s*\d[A-Z]\d)$/i', $last_part, $matches ) ) {
			// Just Canadian postal code.
			$postal_code  = strtoupper( $matches[1] );
			$country_code = 'CA';
		} elseif ( preg_match( '/^([A-Z]{2})$/i', $last_part, $matches ) ) {
			// Just state abbreviation.
			$region = strtoupper( $matches[1] );
		} else {
			// Could be full state name.
			$all_states = array_merge( self::US_STATES, self::CA_PROVINCES );

			foreach ( $all_states as $abbr => $name ) {
				if ( strcasecmp( $last_part, $name ) === 0 ) {
					$region = $abbr;

					break;
				}
			}

			// If not a state, put it back.
			if ( empty( $region ) ) {
				$parts[] = $last_part;
			}
		}

		// If we still have parts, try to get city from the last one.
		$city = '';

		if ( count( $parts ) >= 2 ) {
			$city = array_pop( $parts );
		} elseif ( count( $parts ) === 1 && ! empty( $region ) ) {
			$city  = array_pop( $parts );
			$parts = [];
		}

		$street = implode( ', ', $parts );

		// Determine confidence.
		$confidence = 'low';

		if ( ! empty( $postal_code ) && ! empty( $region ) ) {
			$confidence = 'medium';
		}

		return [
			'address'      => $street,
			'address2'     => '',
			'city'         => $city,
			'region'       => $region,
			'postal_code'  => $postal_code,
			'country_code' => $country_code,
			'parsed'       => true,
			'confidence'   => $confidence,
		];
	}
}
