<?php
/**
 * Locations & Service Areas module for BW AI Schema Pro.
 *
 * Covers three client shapes that the single site-wide address could not:
 *
 *   1. Storefronts   — several branches, each its own LocalBusiness node.
 *   2. Service area  — one business that travels to customers (areaServed).
 *   3. Hybrid        — a shop plus a service radius.
 *
 * Locations live in a Settings repeater (option bw_schema_locations), not a CPT —
 * client sites generally have no per-branch pages. Each row therefore carries an
 * IMMUTABLE synthetic id so its @id survives renames, reordering, and URL changes.
 *
 * Everything here is data + builders; the admin UI lives in admin/views/settings.php
 * and its save handler in class-bw-schema-admin.php. See docs/SPEC-locations.md.
 *
 * @package BW_AI_Schema_Pro
 * @since 2.5.0
 */

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

class BW_Schema_Location {

	/**
	 * LocalBusiness subtypes offered for a location. Deliberately a short,
	 * practical list rather than Schema.org's full hierarchy — the Organization
	 * tab already exposes the long list for the parent company.
	 *
	 * @return array
	 */
	public static function get_location_types() {
		return array(
			'LocalBusiness'             => __( 'Local Business (general)', 'bw-ai-schema-pro' ),
			'Store'                     => __( 'Store', 'bw-ai-schema-pro' ),
			'HardwareStore'             => __( 'Hardware Store', 'bw-ai-schema-pro' ),
			'AutoPartsStore'            => __( 'Auto Parts Store', 'bw-ai-schema-pro' ),
			'AutoRepair'                => __( 'Auto Repair', 'bw-ai-schema-pro' ),
			'HomeAndConstructionBusiness' => __( 'Home & Construction Business', 'bw-ai-schema-pro' ),
			'ProfessionalService'       => __( 'Professional Service', 'bw-ai-schema-pro' ),
			'Plumber'                   => __( 'Plumber', 'bw-ai-schema-pro' ),
			'HVACBusiness'              => __( 'HVAC Business', 'bw-ai-schema-pro' ),
			'ElectricalContractor'      => __( 'Electrical Contractor', 'bw-ai-schema-pro' ),
			'GeneralContractor'         => __( 'General Contractor', 'bw-ai-schema-pro' ),
			'MovingCompany'             => __( 'Moving Company', 'bw-ai-schema-pro' ),
			'Restaurant'                => __( 'Restaurant', 'bw-ai-schema-pro' ),
			'MedicalClinic'             => __( 'Medical Clinic', 'bw-ai-schema-pro' ),
			'Dentist'                   => __( 'Dentist', 'bw-ai-schema-pro' ),
			'RealEstateAgent'           => __( 'Real Estate Agent', 'bw-ai-schema-pro' ),
			'FinancialService'          => __( 'Financial Service', 'bw-ai-schema-pro' ),
			'LegalService'              => __( 'Legal Service', 'bw-ai-schema-pro' ),
			'Warehouse'                 => __( 'Warehouse', 'bw-ai-schema-pro' ),
		);
	}

	/**
	 * Service-area entry kinds → the Schema.org type each emits.
	 *
	 * 'radius' is special-cased into a GeoCircle; 'text' emits a bare string.
	 *
	 * @return array
	 */
	public static function get_area_kinds() {
		return array(
			'city'    => array( 'label' => __( 'City', 'bw-ai-schema-pro' ),                'type' => 'City' ),
			'region'  => array( 'label' => __( 'Province / State', 'bw-ai-schema-pro' ),    'type' => 'State' ),
			'country' => array( 'label' => __( 'Country', 'bw-ai-schema-pro' ),             'type' => 'Country' ),
			'admin'   => array( 'label' => __( 'Administrative area', 'bw-ai-schema-pro' ), 'type' => 'AdministrativeArea' ),
			'radius'  => array( 'label' => __( 'Radius from a point', 'bw-ai-schema-pro' ), 'type' => 'GeoCircle' ),
			'text'    => array( 'label' => __( 'Free text', 'bw-ai-schema-pro' ),           'type' => '' ),
		);
	}

	/**
	 * Radius units → metres. Schema.org's geoRadius is metres unless a Distance
	 * object is supplied, so we convert on output and emit a plain number.
	 *
	 * @return array
	 */
	public static function get_radius_units() {
		return array(
			'km' => array( 'label' => __( 'kilometres', 'bw-ai-schema-pro' ), 'metres' => 1000 ),
			'mi' => array( 'label' => __( 'miles', 'bw-ai-schema-pro' ),      'metres' => 1609.344 ),
			'm'  => array( 'label' => __( 'metres', 'bw-ai-schema-pro' ),     'metres' => 1 ),
		);
	}

	/**
	 * Countries, ISO 3166-1 alpha-2 → display name.
	 *
	 * Not the full 249-entry list: the ones this fleet actually serves, plus the
	 * major English-speaking and European markets. The admin also accepts a raw
	 * two-letter code for anything not listed, so nothing is unreachable.
	 *
	 * @return array
	 */
	public static function get_countries() {
		return array(
			'CA' => __( 'Canada', 'bw-ai-schema-pro' ),
			'US' => __( 'United States', 'bw-ai-schema-pro' ),
			'GB' => __( 'United Kingdom', 'bw-ai-schema-pro' ),
			'IE' => __( 'Ireland', 'bw-ai-schema-pro' ),
			'AU' => __( 'Australia', 'bw-ai-schema-pro' ),
			'NZ' => __( 'New Zealand', 'bw-ai-schema-pro' ),
			'MX' => __( 'Mexico', 'bw-ai-schema-pro' ),
			'DE' => __( 'Germany', 'bw-ai-schema-pro' ),
			'FR' => __( 'France', 'bw-ai-schema-pro' ),
			'ES' => __( 'Spain', 'bw-ai-schema-pro' ),
			'IT' => __( 'Italy', 'bw-ai-schema-pro' ),
			'NL' => __( 'Netherlands', 'bw-ai-schema-pro' ),
			'BE' => __( 'Belgium', 'bw-ai-schema-pro' ),
			'CH' => __( 'Switzerland', 'bw-ai-schema-pro' ),
			'AT' => __( 'Austria', 'bw-ai-schema-pro' ),
			'SE' => __( 'Sweden', 'bw-ai-schema-pro' ),
			'NO' => __( 'Norway', 'bw-ai-schema-pro' ),
			'DK' => __( 'Denmark', 'bw-ai-schema-pro' ),
			'FI' => __( 'Finland', 'bw-ai-schema-pro' ),
			'PL' => __( 'Poland', 'bw-ai-schema-pro' ),
			'PT' => __( 'Portugal', 'bw-ai-schema-pro' ),
			'AE' => __( 'United Arab Emirates', 'bw-ai-schema-pro' ),
			'ZA' => __( 'South Africa', 'bw-ai-schema-pro' ),
			'IN' => __( 'India', 'bw-ai-schema-pro' ),
			'SG' => __( 'Singapore', 'bw-ai-schema-pro' ),
			'JP' => __( 'Japan', 'bw-ai-schema-pro' ),
			'BR' => __( 'Brazil', 'bw-ai-schema-pro' ),
		);
	}

	/**
	 * Canadian provinces and territories, ISO 3166-2 subdivision codes.
	 *
	 * @return array
	 */
	public static function get_ca_regions() {
		return array(
			'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',
		);
	}

	/**
	 * US states, DC and inhabited territories.
	 *
	 * @return array
	 */
	public static function get_us_regions() {
		return array(
			'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', 'PR' => 'Puerto Rico',
			'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',
		);
	}

	/**
	 * Region options for a country, or an empty array when we don't ship a list
	 * (the admin then shows a free-text field — the international case).
	 *
	 * @param string $country ISO 3166-1 alpha-2 code.
	 * @return array
	 */
	public static function get_regions_for_country( $country ) {
		switch ( strtoupper( (string) $country ) ) {
			case 'CA':
				return self::get_ca_regions();
			case 'US':
				return self::get_us_regions();
			default:
				return array();
		}
	}

	/**
	 * The admin label for the region field, per country. "State" is wrong in
	 * Canada and "Province" is wrong in the US; everywhere else, stay neutral.
	 *
	 * @param string $country ISO 3166-1 alpha-2 code.
	 * @return string
	 */
	public static function get_region_label( $country ) {
		switch ( strtoupper( (string) $country ) ) {
			case 'CA':
				return __( 'Province', 'bw-ai-schema-pro' );
			case 'US':
				return __( 'State', 'bw-ai-schema-pro' );
			default:
				return __( 'Region / Province / State', 'bw-ai-schema-pro' );
		}
	}

	/**
	 * A postal-code placeholder per country. A hint only — postal formats vary
	 * enough internationally that hard validation would reject valid addresses.
	 *
	 * @param string $country ISO 3166-1 alpha-2 code.
	 * @return string
	 */
	public static function get_postal_placeholder( $country ) {
		$map = array(
			'CA' => 'A1A 1A1',
			'US' => '12345',
			'GB' => 'SW1A 1AA',
			'AU' => '2000',
			'NL' => '1012 AB',
			'DE' => '10115',
			'FR' => '75001',
		);
		$country = strtoupper( (string) $country );

		return isset( $map[ $country ] ) ? $map[ $country ] : '';
	}

	/* ---------------------------------------------------------------------
	 * Stored data
	 * ------------------------------------------------------------------ */

	/**
	 * All configured locations.
	 *
	 * @return array
	 */
	public static function get_locations() {
		$locations = get_option( 'bw_schema_locations', array() );

		return is_array( $locations ) ? $locations : array();
	}

	/**
	 * Site-wide structured service areas, falling back to the legacy
	 * comma-separated `bw_schema_organization['areaServed']` strings so existing
	 * customer sites keep working untouched.
	 *
	 * @return array Normalised service-area rows.
	 */
	public static function get_service_areas() {
		$areas = get_option( 'bw_schema_service_areas', array() );

		if ( is_array( $areas ) && ! empty( $areas ) ) {
			return $areas;
		}

		// Legacy fallback — plain strings become Text entries, which is valid.
		// They were NEVER valid as GeoCircle (no midpoint, no radius).
		$org_data = get_option( 'bw_schema_organization', array() );
		$legacy   = is_array( $org_data ) && isset( $org_data['areaServed'] ) ? $org_data['areaServed'] : array();

		if ( is_string( $legacy ) ) {
			$legacy = array_map( 'trim', explode( ',', $legacy ) );
		}

		if ( ! is_array( $legacy ) ) {
			return array();
		}

		$out = array();
		foreach ( $legacy as $name ) {
			$name = trim( (string) $name );
			if ( '' !== $name ) {
				$out[] = array( 'kind' => 'text', 'name' => $name );
			}
		}

		return $out;
	}

	/**
	 * Generate an immutable location id.
	 *
	 * Never derived from the name — renaming a branch must not change its @id.
	 *
	 * @return string
	 */
	public static function generate_id() {
		return 'loc_' . substr( md5( uniqid( 'bw_loc', true ) ), 0, 8 );
	}

	/**
	 * Stable @id for a location.
	 *
	 * Anchored to the home URL rather than any page URL, because these locations
	 * have no page of their own; a location may still carry a `url` property.
	 *
	 * @param array $location Location row.
	 * @return string
	 */
	public static function id_for( $location ) {
		$id = isset( $location['id'] ) ? $location['id'] : '';

		return home_url( '/' ) . '#location-' . $id;
	}

	/**
	 * Whether a location row has enough to be worth emitting. Google requires
	 * name + address on a LocalBusiness; we accept a name plus *something*
	 * locating it, since a service-area branch legitimately has no street.
	 *
	 * @param array $location Location row.
	 * @return bool
	 */
	public static function is_publishable( $location ) {
		if ( empty( $location['name'] ) ) {
			return false;
		}

		return ! empty( $location['street'] )
			|| ! empty( $location['city'] )
			|| ! empty( $location['region'] )
			|| ! empty( $location['postal'] );
	}

	/**
	 * Whether a location pairs a street address with a radius service area.
	 *
	 * Community guidance (not Google policy) is that this sends mixed
	 * storefront/service-area signals. We emit it anyway and surface a note in
	 * the admin — see docs/SPEC-locations.md.
	 *
	 * @param array $location Location row.
	 * @return bool
	 */
	public static function has_mixed_signals( $location ) {
		if ( empty( $location['street'] ) || empty( $location['areas'] ) || ! is_array( $location['areas'] ) ) {
			return false;
		}

		foreach ( $location['areas'] as $area ) {
			if ( isset( $area['kind'] ) && 'radius' === $area['kind'] ) {
				return true;
			}
		}

		return false;
	}

	/* ---------------------------------------------------------------------
	 * Builders
	 * ------------------------------------------------------------------ */

	/**
	 * Build an areaServed[] array from normalised service-area rows.
	 *
	 * This is the single service-area builder — the Organization node, each
	 * location node, and the per-post LocalBusiness path all route through it,
	 * so a service area is expressed identically wherever it appears.
	 *
	 * @param array $areas Service-area rows.
	 * @return array
	 */
	public static function build_area_served( $areas ) {
		if ( ! is_array( $areas ) || empty( $areas ) ) {
			return array();
		}

		$kinds  = self::get_area_kinds();
		$units  = self::get_radius_units();
		$out    = array();

		foreach ( $areas as $area ) {
			if ( ! is_array( $area ) ) {
				// Tolerate a bare string (legacy data).
				$area = array( 'kind' => 'text', 'name' => (string) $area );
			}

			$kind = isset( $area['kind'] ) ? $area['kind'] : 'text';
			$name = isset( $area['name'] ) ? trim( (string) $area['name'] ) : '';

			if ( 'radius' === $kind ) {
				$lat    = isset( $area['lat'] ) ? (float) $area['lat'] : 0;
				$lng    = isset( $area['lng'] ) ? (float) $area['lng'] : 0;
				$radius = isset( $area['radius'] ) ? (float) $area['radius'] : 0;
				$unit   = isset( $area['unit'] ) && isset( $units[ $area['unit'] ] ) ? $area['unit'] : 'km';

				// A GeoCircle without a midpoint and radius says nothing — skip
				// rather than emit a shell. (This is exactly the pre-2.5.0 bug.)
				if ( ! $radius || ( 0.0 === $lat && 0.0 === $lng ) ) {
					continue;
				}

				$circle = array(
					'@type'       => 'GeoCircle',
					'geoMidpoint' => array(
						'@type'     => 'GeoCoordinates',
						'latitude'  => $lat,
						'longitude' => $lng,
					),
					// geoRadius is metres unless a Distance is given. Convert so
					// the emitted number is unambiguous.
					'geoRadius'   => round( $radius * $units[ $unit ]['metres'] ),
				);

				if ( '' !== $name ) {
					$circle['name'] = $name;
				}

				$out[] = $circle;
				continue;
			}

			if ( '' === $name ) {
				continue;
			}

			if ( 'text' === $kind || empty( $kinds[ $kind ]['type'] ) ) {
				$out[] = $name;
				continue;
			}

			$out[] = array(
				'@type' => $kinds[ $kind ]['type'],
				'name'  => $name,
			);
		}

		return $out;
	}

	/**
	 * Build a PostalAddress from a location row.
	 *
	 * addressCountry is emitted as the ISO 3166-1 alpha-2 code, and is OMITTED
	 * when unset — never defaulted. (Pre-2.5.0 the per-post LocalBusiness path
	 * defaulted it to 'US', silently publishing Canadian businesses as American.)
	 *
	 * @param array $location Location row.
	 * @return array PostalAddress node, or empty array when there's nothing to say.
	 */
	public static function build_address( $location ) {
		$map = array(
			'street' => 'streetAddress',
			'city'   => 'addressLocality',
			'region' => 'addressRegion',
			'postal' => 'postalCode',
		);

		$address = array( '@type' => 'PostalAddress' );
		$has_any = false;

		foreach ( $map as $src => $dst ) {
			$value = isset( $location[ $src ] ) ? trim( (string) $location[ $src ] ) : '';
			if ( '' !== $value ) {
				$address[ $dst ] = $value;
				$has_any         = true;
			}
		}

		$country = isset( $location['country'] ) ? strtoupper( trim( (string) $location['country'] ) ) : '';
		if ( '' !== $country ) {
			$address['addressCountry'] = $country;
			$has_any                   = true;
		}

		return $has_any ? $address : array();
	}

	/**
	 * Build the full LocalBusiness node for one location.
	 *
	 * @param array $location Location row.
	 * @return array Node, or empty array when the row isn't publishable.
	 */
	public static function build_location_node( $location ) {
		if ( ! self::is_publishable( $location ) ) {
			return array();
		}

		$type = isset( $location['type'] ) && array_key_exists( $location['type'], self::get_location_types() )
			? $location['type']
			: 'LocalBusiness';

		$node = array(
			'@type' => $type,
			'@id'   => self::id_for( $location ),
			'name'  => $location['name'],
		);

		$url = isset( $location['url'] ) ? trim( (string) $location['url'] ) : '';
		$node['url'] = '' !== $url ? $url : home_url( '/' );

		$address = self::build_address( $location );
		if ( ! empty( $address ) ) {
			$node['address'] = $address;
		}

		// Geo — both required, and Google wants >= 5 decimal places, which we
		// preserve by casting to float rather than rounding.
		$lat = isset( $location['lat'] ) ? trim( (string) $location['lat'] ) : '';
		$lng = isset( $location['lng'] ) ? trim( (string) $location['lng'] ) : '';
		if ( '' !== $lat && '' !== $lng ) {
			$node['geo'] = array(
				'@type'     => 'GeoCoordinates',
				'latitude'  => (float) $lat,
				'longitude' => (float) $lng,
			);
		}

		foreach ( array( 'telephone' => 'telephone', 'email' => 'email' ) as $src => $dst ) {
			$value = isset( $location[ $src ] ) ? trim( (string) $location[ $src ] ) : '';
			if ( '' !== $value ) {
				$node[ $dst ] = $value;
			}
		}

		// Opening hours: reuse the Organization's when the row opts in. Full
		// per-branch hours are the top deferred item (see SPEC).
		if ( ! empty( $location['use_main_hours'] ) ) {
			$hours = self::build_main_opening_hours();
			if ( ! empty( $hours ) ) {
				$node['openingHoursSpecification'] = $hours;
			}
		}

		// This branch's own service areas.
		$areas = isset( $location['areas'] ) ? self::build_area_served( $location['areas'] ) : array();
		if ( ! empty( $areas ) ) {
			$node['areaServed'] = $areas;
		}

		// parentOrganization — NOT branchOf, which schema.org superseded.
		$node['parentOrganization'] = class_exists( 'BW_Schema_Org_Builder' )
			? BW_Schema_Org_Builder::reference()
			: array( '@id' => home_url( '/' ) . '#organization' );

		return apply_filters( 'bw_schema_location_node', $node, $location );
	}

	/**
	 * Full LocalBusiness nodes for every publishable location.
	 *
	 * @return array
	 */
	public static function collect_location_nodes() {
		$out = array();

		foreach ( self::get_locations() as $location ) {
			$node = self::build_location_node( $location );
			if ( ! empty( $node ) ) {
				$out[] = $node;
			}
		}

		return apply_filters( 'bw_schema_location_nodes', $out );
	}

	/**
	 * Compact @id references for Organization.subOrganization[].
	 *
	 * @return array
	 */
	public static function collect_location_references() {
		$out = array();

		foreach ( self::get_locations() as $location ) {
			if ( ! self::is_publishable( $location ) ) {
				continue;
			}
			$out[] = array( '@id' => self::id_for( $location ) );
		}

		return $out;
	}

	/**
	 * The Organization's openingHoursSpecification, rebuilt from the same option
	 * the org builder reads, so a location opting into "same as main hours" can
	 * carry them without duplicating the parsing logic.
	 *
	 * @return array
	 */
	private static function build_main_opening_hours() {
		$org_data = get_option( 'bw_schema_organization', array() );
		$hours    = is_array( $org_data ) && isset( $org_data['openingHours'] ) ? $org_data['openingHours'] : array();

		if ( ! is_array( $hours ) || empty( $hours ) ) {
			return array();
		}

		$specs = array();
		foreach ( $hours as $row ) {
			if ( empty( $row['days'] ) || empty( $row['opens'] ) || empty( $row['closes'] ) ) {
				continue;
			}
			$specs[] = array(
				'@type'     => 'OpeningHoursSpecification',
				'dayOfWeek' => $row['days'],
				'opens'     => $row['opens'],
				'closes'    => $row['closes'],
			);
		}

		return $specs;
	}

	/* ---------------------------------------------------------------------
	 * Sanitization (used by the settings save handler)
	 * ------------------------------------------------------------------ */

	/**
	 * Sanitize a posted locations repeater into storable rows.
	 *
	 * Preserves each row's existing immutable id, and mints one only for rows
	 * that don't have one yet.
	 *
	 * @param array $posted Raw $_POST rows (already unslashed).
	 * @return array
	 */
	public static function sanitize_locations( $posted ) {
		if ( ! is_array( $posted ) ) {
			return array();
		}

		$types = self::get_location_types();
		$out   = array();

		foreach ( $posted as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$name = isset( $row['name'] ) ? sanitize_text_field( $row['name'] ) : '';
			if ( '' === $name ) {
				// A row with no name is an empty template row — drop it.
				continue;
			}

			$id = isset( $row['id'] ) ? sanitize_text_field( $row['id'] ) : '';
			if ( ! preg_match( '/^loc_[a-f0-9]{8}$/', $id ) ) {
				$id = self::generate_id();
			}

			// Any well-formed two-letter code is accepted, including ones the
			// picker doesn't list — get_countries() is a convenience list, not
			// the set of legal ISO 3166-1 alpha-2 values.
			$country = isset( $row['country'] ) ? strtoupper( sanitize_text_field( $row['country'] ) ) : '';
			if ( '' !== $country && ! preg_match( '/^[A-Z]{2}$/', $country ) ) {
				$country = '';
			}

			$type = isset( $row['type'] ) ? sanitize_text_field( $row['type'] ) : '';
			if ( ! array_key_exists( $type, $types ) ) {
				$type = 'LocalBusiness';
			}

			$location = array(
				'id'             => $id,
				'name'           => $name,
				'type'           => $type,
				'street'         => isset( $row['street'] ) ? sanitize_text_field( $row['street'] ) : '',
				'city'           => isset( $row['city'] ) ? sanitize_text_field( $row['city'] ) : '',
				'region'         => isset( $row['region'] ) ? sanitize_text_field( $row['region'] ) : '',
				'postal'         => isset( $row['postal'] ) ? sanitize_text_field( $row['postal'] ) : '',
				'country'        => $country,
				'telephone'      => isset( $row['telephone'] ) ? sanitize_text_field( $row['telephone'] ) : '',
				'email'          => isset( $row['email'] ) ? sanitize_email( $row['email'] ) : '',
				'url'            => isset( $row['url'] ) ? esc_url_raw( $row['url'] ) : '',
				'use_main_hours' => ! empty( $row['use_main_hours'] ) ? 1 : 0,
				'lat'            => '',
				'lng'            => '',
				'areas'          => self::sanitize_areas( isset( $row['areas'] ) ? $row['areas'] : array() ),
			);

			foreach ( array( 'lat', 'lng' ) as $coord ) {
				$value = isset( $row[ $coord ] ) ? trim( (string) $row[ $coord ] ) : '';
				if ( '' !== $value && is_numeric( $value ) ) {
					$location[ $coord ] = $value;
				}
			}

			$out[] = $location;
		}

		return $out;
	}

	/**
	 * Sanitize a posted service-area repeater.
	 *
	 * @param array $posted Raw rows (already unslashed).
	 * @return array
	 */
	public static function sanitize_areas( $posted ) {
		if ( ! is_array( $posted ) ) {
			return array();
		}

		$kinds = self::get_area_kinds();
		$units = self::get_radius_units();
		$out   = array();

		foreach ( $posted as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}

			$kind = isset( $row['kind'] ) ? sanitize_text_field( $row['kind'] ) : 'text';
			if ( ! array_key_exists( $kind, $kinds ) ) {
				$kind = 'text';
			}

			$name = isset( $row['name'] ) ? sanitize_text_field( $row['name'] ) : '';

			if ( 'radius' === $kind ) {
				$lat    = isset( $row['lat'] ) ? trim( (string) $row['lat'] ) : '';
				$lng    = isset( $row['lng'] ) ? trim( (string) $row['lng'] ) : '';
				$radius = isset( $row['radius'] ) ? trim( (string) $row['radius'] ) : '';
				$unit   = isset( $row['unit'] ) ? sanitize_text_field( $row['unit'] ) : 'km';

				if ( ! is_numeric( $lat ) || ! is_numeric( $lng ) || ! is_numeric( $radius ) || (float) $radius <= 0 ) {
					// Incomplete radius rows are dropped, not stored half-built —
					// a GeoCircle needs both a midpoint and a radius to mean anything.
					continue;
				}

				$out[] = array(
					'kind'   => 'radius',
					'name'   => $name,
					'lat'    => $lat,
					'lng'    => $lng,
					'radius' => (float) $radius,
					'unit'   => array_key_exists( $unit, $units ) ? $unit : 'km',
				);
				continue;
			}

			if ( '' === $name ) {
				continue;
			}

			$out[] = array( 'kind' => $kind, 'name' => $name );
		}

		return $out;
	}
}
