<?php
/**
 * Captures identity, attribution, and consent snapshots at order creation.
 *
 * @package Carticy\Conversions\Capture
 */

declare(strict_types=1);

namespace Carticy\Conversions\Capture;

use Carticy\Conversions\Consent\ConsentSignalCollector;
use Carticy\Conversions\Domain\AttributionData;
use Carticy\Conversions\Domain\ConsentResolver;
use Carticy\Conversions\Domain\IdentityData;
use Carticy\Conversions\Settings\ConsentPostureSettings;
use Carticy\Conversions\Storage\OrderMetaStore;
use Carticy\Conversions\Support\DiagnosticLogger;
use Carticy\Conversions\Support\ScriptAttributes;

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

/**
 * Stores order-bound snapshots while checkout context is still available.
 */
final class OrderCaptureService {

	/**
	 * Storage service.
	 *
	 * @var OrderMetaStore
	 */
	private OrderMetaStore $store;

	/**
	 * Consent collector.
	 *
	 * @var ConsentSignalCollector
	 */
	private ConsentSignalCollector $consent_collector;

	/**
	 * Settings service.
	 *
	 * @var ConsentPostureSettings
	 */
	private ConsentPostureSettings $settings;

	/**
	 * Attribution cookie parser.
	 *
	 * @var AttributionCookieParser
	 */
	private AttributionCookieParser $parser;

	/**
	 * Constructor.
	 *
	 * @param OrderMetaStore               $store Storage service.
	 * @param ConsentSignalCollector       $consent_collector Consent collector.
	 * @param ConsentPostureSettings       $settings Settings service.
	 * @param AttributionCookieParser|null $parser Parser.
	 */
	public function __construct(
		OrderMetaStore $store,
		ConsentSignalCollector $consent_collector,
		ConsentPostureSettings $settings,
		?AttributionCookieParser $parser = null
	) {
		$this->store             = $store;
		$this->consent_collector = $consent_collector;
		$this->settings          = $settings;
		$this->parser            = $parser ?? new AttributionCookieParser();
	}

	/**
	 * Register runtime hooks.
	 */
	public function register_hooks(): void {
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_storefront_script' ) );
		add_filter( 'script_loader_tag', array( $this, 'script_loader_tag' ), 10, 2 );
		add_action( 'woocommerce_checkout_create_order', array( $this, 'capture_classic_checkout_order' ), 20, 2 );
		add_action( 'woocommerce_checkout_order_created', array( $this, 'capture_classic_checkout_order_created' ), 20, 1 );
		add_action( 'woocommerce_store_api_checkout_order_processed', array( $this, 'capture_block_checkout_order' ), 20, 1 );
	}

	/**
	 * Add defer and optimizer-exclusion attributes to the capture script tag.
	 *
	 * @param string $tag Rendered tag.
	 * @param string $handle Script handle.
	 * @return string
	 */
	public function script_loader_tag( string $tag, string $handle ): string {
		if ( 'carticy-conversions-storefront-capture' !== $handle ) {
			return $tag;
		}

		return ScriptAttributes::with_attributes(
			$tag,
			array(
				'defer'            => '',
				'data-cfasync'     => 'false',
				'data-no-optimize' => '1',
				'data-no-minify'   => '1',
			)
		);
	}

	/**
	 * Enqueue the tiny first-party capture bridge.
	 */
	public function enqueue_storefront_script(): void {
		if ( ! defined( 'CARTICY_CONVERSIONS_URL' ) || ! defined( 'CARTICY_CONVERSIONS_PATH' ) ) {
			return;
		}

		$path = CARTICY_CONVERSIONS_PATH . 'assets/js/dist/storefront-capture.js';
		if ( ! file_exists( $path ) ) {
			return;
		}

		wp_enqueue_script(
			'carticy-conversions-storefront-capture',
			CARTICY_CONVERSIONS_URL . 'assets/js/dist/storefront-capture.js',
			array(),
			defined( 'CARTICY_CONVERSIONS_VERSION' ) ? CARTICY_CONVERSIONS_VERSION : (string) filemtime( $path ),
			true
		);
		ScriptAttributes::exclude_from_optimizers( 'carticy-conversions-storefront-capture' );

		$adapters = $this->consent_collector->browser_adapters();
		if ( array() !== $adapters ) {
			wp_add_inline_script(
				'carticy-conversions-storefront-capture',
				'window.carticyConversionsConsentAdapters = ' . wp_json_encode( $adapters ) . ';',
				'before'
			);
		}

		// The JS listener mirrors the PHP intake's explicitness default; the
		// same filter controls both so they can never disagree.
		$config = array(
			'wpConsentApiExplicit' => (bool) apply_filters( 'carticy_conversions_wp_consent_api_is_explicit', false, array() ),
		);
		wp_add_inline_script(
			'carticy-conversions-storefront-capture',
			'window.carticyConversionsConsentConfig = ' . wp_json_encode( $config ) . ';',
			'before'
		);
	}

	/**
	 * Capture classic checkout snapshots before Woo saves the order.
	 *
	 * @param object              $order WooCommerce order.
	 * @param array<string,mixed> $data Checkout data.
	 */
	public function capture_classic_checkout_order( object $order, array $data = array() ): void {
		$this->capture_order( $order, false );
	}

	/**
	 * Capture classic checkout snapshots once WooCommerce has assigned an order ID.
	 *
	 * @param object $order WooCommerce order.
	 */
	public function capture_classic_checkout_order_created( object $order ): void {
		$this->capture_order( $order, true );
	}

	/**
	 * Capture block checkout snapshots after Store API creates the order.
	 *
	 * @param object $order WooCommerce order.
	 */
	public function capture_block_checkout_order( object $order ): void {
		$this->capture_order( $order, true );
	}

	/**
	 * Capture snapshots for an order.
	 *
	 * @param object $order WooCommerce order-like object.
	 * @param bool   $save Save immediately.
	 */
	public function capture_order( object $order, bool $save = true ): void {
		$order_id = method_exists( $order, 'get_id' ) ? (int) $order->get_id() : 0;
		if ( $order_id <= 0 ) {
			return;
		}

		// Capture runs inside WooCommerce's order-creation hooks: a tracking
		// failure (malformed external consent signal, storage hiccup) must
		// never abort the customer's checkout. Fail soft, log, and leave
		// consent unset (fail closed for sends).
		try {
			$attribution = $this->build_attribution();
			$this->store->save_identity( $order, $this->build_identity( $order, $attribution ), false );
			$this->store->save_attribution( $order, $attribution, false );
			$this->store->save_consent(
				$order,
				ConsentResolver::resolve(
					$order_id,
					$this->consent_collector->collect_from_request(),
					$this->settings->posture(),
					defined( 'CARTICY_CONVERSIONS_VERSION' ) ? CARTICY_CONVERSIONS_VERSION : '0.0.0',
					$this->settings->attestation()
				),
				$save
			);
		} catch ( \Throwable $exception ) {
			DiagnosticLogger::error(
				'Order capture failed; checkout continues with unset consent.',
				array(
					'order_id' => $order_id,
					'error'    => get_class( $exception ),
				)
			);
		}
	}

	/**
	 * Build identity data from order, request, and first-party bridge cookies.
	 *
	 * @param object               $order WooCommerce order-like object.
	 * @param AttributionData|null $attribution Captured attribution (for fbclid fallback).
	 * @return IdentityData
	 */
	public function build_identity( object $order, ?AttributionData $attribution = null ): IdentityData {
		$fields = array(
			'customer_id' => $this->call_order_getter( $order, 'get_customer_id' ),
			'email'       => $this->call_order_getter( $order, 'get_billing_email' ),
			'phone'       => $this->call_order_getter( $order, 'get_billing_phone' ),
			'first_name'  => $this->call_order_getter( $order, 'get_billing_first_name' ),
			'last_name'   => $this->call_order_getter( $order, 'get_billing_last_name' ),
			'country'     => $this->call_order_getter( $order, 'get_billing_country' ),
			'postcode'    => $this->call_order_getter( $order, 'get_billing_postcode' ),
			'user_agent'  => $this->server_value( 'HTTP_USER_AGENT' ),
		);

		$ip_data = $this->ip_address_data();
		$fields  = array_merge( $fields, $ip_data );

		$ga_client_id = $this->request_value( AttributionCookieParser::GA_CLIENT_COOKIE );
		if ( '' === $ga_client_id ) {
			$ga_client_id = $this->cookie_value( AttributionCookieParser::GA_CLIENT_COOKIE );
		}
		if ( '' === $ga_client_id ) {
			$ga_client_id = $this->parser->parse_ga_client_id( $this->cookie_value( '_ga' ) );
		}
		$fields['ga_client_id'] = $this->parser->parse_ga_client_id( $ga_client_id );

		$ga_session_id = $this->request_value( AttributionCookieParser::GA_SESSION_COOKIE );
		if ( '' === $ga_session_id ) {
			$ga_session_id = $this->cookie_value( AttributionCookieParser::GA_SESSION_COOKIE );
		}
		if ( '' === $ga_session_id ) {
			$ga_session_id = $this->native_ga_session_cookie();
		}
		$fields['ga_session_id'] = $this->parser->parse_ga_session_id( $ga_session_id );
		if ( '' !== $fields['ga_session_id'] ) {
			$fields['ga_session_captured_at'] = gmdate( 'Y-m-d H:i:s' );
		}

		$fields['fbp'] = $this->request_value( 'carticy_conversions_fbp' );
		if ( '' === $fields['fbp'] ) {
			$fields['fbp'] = $this->cookie_value( AttributionCookieParser::FBP_COOKIE );
		}

		$fields['fbc'] = $this->request_value( 'carticy_conversions_fbc' );
		if ( '' === $fields['fbc'] ) {
			$fields['fbc'] = $this->cookie_value( AttributionCookieParser::FBC_COOKIE );
		}
		if ( '' === $fields['fbc'] ) {
			$fields['fbc'] = $this->synthesize_fbc( $attribution );
		}

		return new IdentityData( $fields );
	}

	/**
	 * Synthesize _fbc from a captured fbclid when the Pixel never set the cookie.
	 *
	 * A pre-consent or blocked-pixel ad landing keeps its fbclid in the
	 * attribution touch (URL passthrough), but Meta only receives click
	 * attribution through fbc. Meta documents the synthetic format
	 * fb.1.<creation_time_ms>.<fbclid> for exactly this case.
	 *
	 * @param AttributionData|null $attribution Captured attribution.
	 * @return string
	 */
	private function synthesize_fbc( ?AttributionData $attribution ): string {
		$fbclid = '';
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public ad click identifiers have no nonce; value is sanitized immediately.
		if ( isset( $_GET['fbclid'] ) ) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public ad click identifiers have no nonce; value is sanitized immediately.
			$fbclid = sanitize_text_field( (string) wp_unslash( $_GET['fbclid'] ) );
		}

		if ( '' === $fbclid && null !== $attribution ) {
			$touches = $attribution->to_array();
			$fbclid  = (string) ( $touches['last_touch']['fbclid'] ?? $touches['first_touch']['fbclid'] ?? '' );
		}

		if ( '' === $fbclid ) {
			return '';
		}

		return 'fb.1.' . ( (int) ( microtime( true ) * 1000 ) ) . '.' . $fbclid;
	}

	/**
	 * Build attribution from first-party bridge cookies.
	 *
	 * @return AttributionData
	 */
	public function build_attribution(): AttributionData {
		$first = $this->request_raw_value( AttributionCookieParser::FIRST_TOUCH_COOKIE );
		$last  = $this->request_raw_value( AttributionCookieParser::LAST_TOUCH_COOKIE );

		if ( '' === $first ) {
			$first = $this->cookie_raw_value( AttributionCookieParser::FIRST_TOUCH_COOKIE );
		}
		if ( '' === $last ) {
			$last = $this->cookie_raw_value( AttributionCookieParser::LAST_TOUCH_COOKIE );
		}

		return new AttributionData(
			$this->parser->parse_touch_cookie( $first ),
			$this->parser->parse_touch_cookie( $last )
		);
	}

	/**
	 * Read and sanitize a request value.
	 *
	 * @param string $key Request key.
	 * @return string
	 */
	private function request_value( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public checkout bridge input has no nonce; callers sanitize and allow-list keys.
		if ( ! isset( $_POST[ $key ] ) ) {
			return '';
		}

		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public checkout bridge input has no nonce; value is sanitized immediately.
		return sanitize_text_field( (string) wp_unslash( $_POST[ $key ] ) );
	}

	/**
	 * Read, URL-decode, and sanitize a JSON-bridge request value.
	 *
	 * @param string $key Request key.
	 * @return string
	 */
	private function request_raw_value( string $key ): string {
		// phpcs:ignore WordPress.Security.NonceVerification.Missing -- Public checkout bridge input has no nonce; the value is unslashed and sanitized here, then structure-validated by the typed JSON parsers.
		return isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( (string) $_POST[ $key ] ) ) : '';
	}

	/**
	 * Read and sanitize a cookie value.
	 *
	 * @param string $key Cookie key.
	 * @return string
	 */
	private function cookie_value( string $key ): string {
		if ( ! isset( $_COOKIE[ $key ] ) ) {
			return '';
		}

		return sanitize_text_field( (string) wp_unslash( $_COOKIE[ $key ] ) );
	}

	/**
	 * Read, URL-decode, and sanitize a JSON-bridge cookie value.
	 *
	 * @param string $key Cookie key.
	 * @return string
	 */
	private function cookie_raw_value( string $key ): string {
		if ( ! isset( $_COOKIE[ $key ] ) ) {
			return '';
		}

		return sanitize_text_field( wp_unslash( (string) $_COOKIE[ $key ] ) );
	}

	/**
	 * Read and sanitize a server value.
	 *
	 * @param string $key Server key.
	 * @return string
	 */
	private function server_value( string $key ): string {
		return isset( $_SERVER[ $key ] ) ? sanitize_text_field( (string) wp_unslash( $_SERVER[ $key ] ) ) : '';
	}

	/**
	 * Find the native GA4 session cookie value.
	 *
	 * @return string
	 */
	private function native_ga_session_cookie(): string {
		foreach ( $_COOKIE as $name => $value ) {
			if ( 1 === preg_match( '/^_ga_[A-Z0-9]+$/', (string) $name ) ) {
				return sanitize_text_field( wp_unslash( (string) $value ) );
			}
		}

		return '';
	}

	/**
	 * Get order data without binding to WC_Order concrete class.
	 *
	 * @param object $order Order-like object.
	 * @param string $method Getter method.
	 * @return mixed
	 */
	private function call_order_getter( object $order, string $method ) {
		return method_exists( $order, $method ) ? $order->{$method}() : '';
	}

	/**
	 * Capture IP according to merchant privacy mode.
	 *
	 * @return array<string, string>
	 */
	private function ip_address_data(): array {
		$mode = $this->settings->ip_mode();
		if ( 'omit' === $mode ) {
			return array();
		}

		$ip = $this->server_value( 'REMOTE_ADDR' );
		if ( '' === $ip ) {
			return array();
		}

		if ( 'anonymize' === $mode && function_exists( 'wp_privacy_anonymize_ip' ) ) {
			$ip = wp_privacy_anonymize_ip( $ip );
		}

		return array(
			'ip_address'      => $ip,
			'ip_address_mode' => $mode,
		);
	}
}
