<?php
/**
 * Syncs a WooCommerce customer or order's billing contact to Attio CRM
 *
 * @package WooAttioSync
 */

declare( strict_types=1 );

defined( 'ABSPATH' ) || exit;

/**
 * Handles syncing a single customer (or order billing contact) to Attio
 */
class WooAttioSync_Sync_Customer {

	private WooAttioSync_Attio_Client $client;

	public function __construct( WooAttioSync_Attio_Client $client ) {
		$this->client = $client;
	}

	/**
	 * Syncs a customer or order's billing contact to Attio
	 *
	 * @param int    $customer_id_or_order_id WC customer ID or order ID depending on $source.
	 * @param string $source                  'customer' or 'order'.
	 * @return array|WP_Error Attio person record on success, WP_Error on failure.
	 */
	public function sync( int $customer_id_or_order_id, string $source = 'customer' ): array|WP_Error {
		[ $email, $first_name, $last_name, $phone, $company ] = $this->extract_contact_data(
			$customer_id_or_order_id,
			$source
		);

		// Order-sourced person rows are keyed by order ID, which shares the integer
		// space with WP user IDs. Namespace them so an order and a customer with the
		// same numeric ID cannot collide on the sync-log UNIQUE KEY (object_type, object_id).
		$log_object_id = 'order' === $source
			? 'order-' . $customer_id_or_order_id
			: (string) $customer_id_or_order_id;

		if ( is_wp_error( $email ) ) {
			WooAttioSync_Sync_Log::log( 'person', $log_object_id, 'error', $email->get_error_message() );
			return $email;
		}

		if ( empty( $email ) ) {
			return new WP_Error( 'missing_email', 'Contact has no email address; skipping Attio sync', array( 'status' => 422 ) );
		}

		$attributes = array();

		// Only include name when at least one part is non-empty to avoid overwriting
		// existing Attio records with blank names
		$first_name = trim( $first_name );
		$last_name  = trim( $last_name );
		if ( '' !== $first_name || '' !== $last_name ) {
			// Attio's personal-name attribute rejects a value missing full_name
			$attributes['name'] = array(
				array(
					'first_name' => $first_name,
					'last_name'  => $last_name,
					'full_name'  => trim( "{$first_name} {$last_name}" ),
				),
			);
		}

		if ( ! empty( $phone ) ) {
			$attributes['phone_numbers'] = array(
				array(
					'original_phone_number' => $phone,
				),
			);
		}

		// Field_Mapper is a Pro-only file stripped from the free build
		if ( class_exists( 'WooAttioSync_Field_Mapper' ) ) {
			$mapper     = new WooAttioSync_Field_Mapper();
			$attributes = $mapper->apply( $attributes, $customer_id_or_order_id, $source );
		}

		// Attio rejects the ENTIRE record with a 400 when any single attribute value
		// is invalid - a malformed billing phone, or a Pro field-mapping whose value
		// type does not match the target Attio attribute. Drop the offending
		// attribute(s) and retry so one bad value never blocks the whole contact.
		$result  = $this->client->upsert_person( $email, $attributes );
		$dropped = array();
		$guard   = 0;
		while ( is_wp_error( $result ) && array() !== $attributes && $guard++ < 6 ) {
			$data = $result->get_error_data();
			if ( 400 !== (int) ( is_array( $data ) ? ( $data['status'] ?? 0 ) : 0 ) ) {
				break;
			}
			$offending = $this->offending_attribute( $result->get_error_message(), $attributes );
			if ( null === $offending ) {
				break;
			}
			unset( $attributes[ $offending ] );
			$dropped[] = $offending;
			$result    = $this->client->upsert_person( $email, $attributes );
		}

		if ( is_wp_error( $result ) ) {
			WooAttioSync_Sync_Log::log( 'person', $log_object_id, 'error', $result->get_error_message() );
			return $result;
		}

		$attio_record_id = $result['data']['id']['record_id'] ?? '';
		if ( '' === $attio_record_id ) {
			$error = new WP_Error( 'missing_record_id', 'Attio person upsert succeeded but returned no record ID', array( 'status' => 422 ) );
			WooAttioSync_Sync_Log::log( 'person', $log_object_id, 'error', $error->get_error_message() );
			return $error;
		}

		$success_note = $dropped
			? sprintf( 'Synced; dropped invalid attribute(s): %s', implode( ', ', $dropped ) )
			: '';
		WooAttioSync_Sync_Log::log( 'person', $log_object_id, 'success', $success_note, $attio_record_id );

		/**
		 * Fires after a WooCommerce customer is synced to an Attio person. Pro
		 * features (customer-intelligence enrichment, lifecycle tasks) hook this;
		 * nothing hooks it in the free build.
		 *
		 * @param string $attio_record_id The Attio People record ID.
		 * @param string $email           The customer's billing email.
		 * @param string $source          'customer' or 'order'.
		 */
		do_action( 'wcattio_after_person_sync', $attio_record_id, $email, $source );

		// Optionally sync the billing company when we can derive a domain from the email
		$company = trim( $company );
		if ( '' !== $company ) {
			$domain = $this->extract_domain_from_email( $email );
			if ( ! empty( $domain ) && ! $this->is_personal_email_domain( $domain ) ) {
				$company_result = $this->client->upsert_company( $company, $domain, array() );
				// Key the log row by the domain (the stable Attio matching attribute),
				// not the company name: names sharing a 100-char prefix would otherwise
				// collide on the sync-log UNIQUE KEY, and the domain matches how Attio dedupes.
				if ( is_wp_error( $company_result ) ) {
					WooAttioSync_Sync_Log::log( 'company', $domain, 'error', $company_result->get_error_message() );
				} else {
					$company_record_id = $company_result['data']['id']['record_id'] ?? '';
					WooAttioSync_Sync_Log::log( 'company', $domain, 'success', '', $company_record_id );

					// Associate the person with the company in Attio's relationship graph.
					// Best-effort: both records already exist, so a link failure must not
					// fail the sync or overwrite the person/company success rows.
					if ( '' !== $company_record_id ) {
						$this->client->link_person_to_company( $attio_record_id, $company_record_id );
					}
				}
			}
		}

		return $result;
	}

	/**
	 * Identify which sent attribute an Attio 400 validation error refers to.
	 *
	 * Attio's message names the rejected attribute by slug (e.g. ...slug
	 * "funding_raised_usd"...). Matching on the slug's presence in the message is
	 * resilient to wording changes. Never returns a core identity attribute
	 * (name/email) so retries only ever shed optional data.
	 *
	 * @param string              $message    The Attio error message.
	 * @param array<string,mixed> $attributes Attributes still in the payload.
	 * @return string|null The attribute key to drop, or null when none matches.
	 */
	private function offending_attribute( string $message, array $attributes ): ?string {
		foreach ( array_keys( $attributes ) as $slug ) {
			if ( 'name' === $slug ) {
				continue;
			}
			if ( false !== strpos( $message, (string) $slug ) ) {
				return $slug;
			}
		}
		return null;
	}

	/**
	 * Extracts contact fields from either a customer record or an order's billing info
	 *
	 * @param int    $id     Customer ID or order ID.
	 * @param string $source 'customer' or 'order'.
	 * @return array{0: string|WP_Error, 1: string, 2: string, 3: string, 4: string}
	 *         Tuple of [email, first_name, last_name, phone, company].
	 *         Email may be a WP_Error if the source object cannot be loaded.
	 */
	private function extract_contact_data( int $id, string $source ): array {
		if ( 'order' === $source ) {
			$order = wc_get_order( $id );
			if ( ! $order ) {
				return array(
					new WP_Error( 'order_not_found', sprintf( 'Order %d not found', $id ), array( 'status' => 404 ) ),
					'',
					'',
					'',
					'',
				);
			}

			return array(
				$order->get_billing_email(),
				$order->get_billing_first_name(),
				$order->get_billing_last_name(),
				$order->get_billing_phone(),
				$order->get_billing_company(),
			);
		}

		try {
			$customer = new WC_Customer( $id );
		} catch ( \Exception $e ) {
			return array(
				new WP_Error( 'customer_not_found', $e->getMessage(), array( 'status' => 404 ) ),
				'',
				'',
				'',
				'',
			);
		}

		$first = $customer->get_billing_first_name();
		$last  = $customer->get_billing_last_name();
		return array(
			$customer->get_email(),
			'' !== $first ? $first : $customer->get_first_name(),
			'' !== $last ? $last : $customer->get_last_name(),
			$customer->get_billing_phone(),
			$customer->get_billing_company(),
		);
	}

	/**
	 * Extracts the domain portion of an email address
	 *
	 * @param string $email Email address.
	 * @return string Domain string, or empty string if not parseable.
	 */
	private function extract_domain_from_email( string $email ): string {
		$parts = explode( '@', $email, 2 );
		return isset( $parts[1] ) ? strtolower( trim( $parts[1] ) ) : '';
	}

	/**
	 * Returns true for common personal / free-tier email domains
	 *
	 * Avoids creating Attio company records for "gmail.com" and similar domains
	 * where the company name would be meaningless.
	 *
	 * @param string $domain Email domain to test.
	 * @return bool
	 */
	private function is_personal_email_domain( string $domain ): bool {
		$personal_domains = array(
			'gmail.com',
			'googlemail.com',
			'yahoo.com',
			'yahoo.co.uk',
			'yahoo.fr',
			'hotmail.com',
			'hotmail.co.uk',
			'outlook.com',
			'live.com',
			'msn.com',
			'icloud.com',
			'me.com',
			'mac.com',
			'aol.com',
			'protonmail.com',
			'proton.me',
			'pm.me',
			'tutanota.com',
			'gmx.com',
			'zoho.com',
			'yandex.com',
			'mail.com',
		);

		/**
		 * Filters the list of personal email domains that should not trigger
		 * an Attio company upsert
		 *
		 * @param string[] $personal_domains List of domain strings.
		 */
		$personal_domains = apply_filters( 'wcattio_personal_email_domains', $personal_domains );

		return in_array( $domain, $personal_domains, true );
	}
}
