<?php
/**
 * DataLayer Queue
 *
 * Collects TrackingEvent instances and flushes them as window.dataLayer pushes
 * on the frontend. No external API credentials required — events are consumed
 * by whatever GTM container or gtag.js is already installed on the site.
 *
 * Two storage paths:
 *  - Browser request  → session transient keyed to the ambh_tk cookie.
 *                        Flushed on wp_footer of the current or next page load.
 *  - Webhook / cron   → per-donation transient (ambh_dl_donation_{id}).
 *                        Flushed by DonationConfirmation::render_block() when
 *                        the user lands on the confirmation page.
 *
 * @package AmbassadorHub\Tracking
 */

namespace AmbassadorHub\Tracking;

use AmbassadorHub\Admin\InlineAssets;

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

/**
 * Manages queueing and flushing of dataLayer events.
 */
final class DataLayerQueue {

	/** Cookie name used to identify the browser session. */
	private const COOKIE_NAME = 'ambh_tk';

	/** Transient TTL for session-keyed events (1 hour). */
	private const SESSION_TTL = HOUR_IN_SECONDS;

	/** Transient TTL for donation-keyed events (4 hours, surviving async webhooks). */
	private const DONATION_TTL = 4 * HOUR_IN_SECONDS;

	/** In-memory cache of the session key for the current request. */
	private static ?string $session_key = null;

	/**
	 * Initialize the queue: ensure the session cookie exists and register the
	 * wp_footer flush hook.
	 *
	 * @return void
	 */
	public static function init(): void {
		// Set up the session tracking cookie before headers are sent.
		add_action( 'wp_loaded', array( self::class, 'ensure_cookie' ), 1 );

		// Flush any session-keyed events into inline JS on every frontend page.
		// Priority 5: before core's print_footer_scripts (priority 20), so the
		// inline script attached via wp_add_inline_script() in output_script()
		// is still enqueued in time to be printed.
		add_action( 'wp_footer', array( self::class, 'flush_session' ), 5 );
	}

	// =========================================================================
	// Public API
	// =========================================================================

	/**
	 * Queue an event for dataLayer output.
	 *
	 * @param TrackingEvent $event Event to queue.
	 *
	 * @return void
	 */
	public static function push( TrackingEvent $event ): void {
		if ( ! self::is_enabled() ) {
			return;
		}

		$payload = self::map_event( $event );

		if ( ! $payload ) {
			return;
		}

		if ( self::is_browser_request() ) {
			self::append_to_session( $payload );
		} elseif ( $event->entity_id ) {
			self::append_to_donation( $event->entity_id, $payload );
		}
	}

	/**
	 * Flush session-keyed events as inline JavaScript.
	 *
	 * Hooked to wp_footer. Also callable directly when needed.
	 *
	 * @return void
	 */
	public static function flush_session(): void {
		if ( is_admin() || ! self::is_enabled() ) {
			return;
		}

		$key    = self::get_session_key();
		$events = self::read_session_events( $key );

		if ( empty( $events ) ) {
			return;
		}

		self::output_script( $events );
		delete_transient( 'ambh_dl_session_' . $key );
	}

	/**
	 * Flush donation-keyed events for a given donation as inline JavaScript.
	 *
	 * Called from DonationConfirmation::render_block() after the donation is
	 * resolved. Handles events queued during async PSP webhooks.
	 *
	 * @param int $donation_id Donation ID.
	 *
	 * @return void
	 */
	public static function flush_for_donation( int $donation_id ): void {
		if ( ! self::is_enabled() ) {
			return;
		}

		$transient = 'ambh_dl_donation_' . $donation_id;
		$events    = (array) get_transient( $transient );

		if ( empty( $events ) ) {
			return;
		}

		self::output_script( $events );
		delete_transient( $transient );
	}

	/**
	 * Whether the site owner has opted in to analytics event tracking.
	 *
	 * Off by default. See the "Analytics" field on the Advanced settings tab
	 * (option: ambh_analytics_tracking_enabled). While disabled, no cookie is
	 * set and no event is queued or pushed to window.dataLayer.
	 *
	 * @return bool
	 */
	private static function is_enabled(): bool {
		return (bool) get_option( 'ambh_analytics_tracking_enabled', false );
	}

	// =========================================================================
	// Cookie / session helpers
	// =========================================================================

	/**
	 * Create the ambh_tk session cookie if it does not already exist.
	 *
	 * Hooked to wp_loaded at priority 1, before wp_head / any output.
	 *
	 * @return void
	 */
	public static function ensure_cookie(): void {
		if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) {
			return;
		}

		// Analytics event tracking is opt-in and off by default. No option, no
		// cookie, no dataLayer events — see the "Analytics" field on the
		// Advanced settings tab.
		if ( ! self::is_enabled() ) {
			return;
		}

		// Only set the tracking cookie when a payment provider is active — i.e.
		// the site is actually running a fundraising campaign. Sites that have
		// not yet configured a PSP receive no cookie at all.
		if ( ! get_option( 'ambh_active_connector_psp' ) ) {
			return;
		}

		if ( empty( $_COOKIE[ self::COOKIE_NAME ] ) ) {
			$value = wp_generate_uuid4();
			// Session-correlation cookie; not HttpOnly so the dataLayer can read
			// it client-side. SameSite=Lax prevents cross-site sending.
			setcookie(
				self::COOKIE_NAME,
				$value,
				array(
					'expires'  => time() + YEAR_IN_SECONDS,
					'path'     => '/',
					'secure'   => is_ssl(),
					'httponly' => false,
					'samesite' => 'Lax',
				)
			);
			// Make the value available for the rest of this request.
			$_COOKIE[ self::COOKIE_NAME ] = $value;
		}
	}

	/**
	 * Return the session key for the current browser request.
	 *
	 * @return string
	 */
	private static function get_session_key(): string {
		if ( null !== self::$session_key ) {
			return self::$session_key;
		}

		// The cookie is fully attacker-controlled (a visitor can set ambh_tk to
		// anything), so sanitize_key()'s character-class restriction alone
		// only constrains its *shape* — it's still an arbitrary value at that
		// point, not a real allowlist. wp_hash() it before it ever becomes
		// part of a transient name, same pattern as
		// DonationForm\Block::donation_form_transient_key().
		$raw               = sanitize_key( wp_unslash( (string) ( $_COOKIE[ self::COOKIE_NAME ] ?? '' ) ) );
		self::$session_key = $raw ? wp_hash( $raw ) : 'unknown';

		return self::$session_key;
	}

	// =========================================================================
	// Storage helpers
	// =========================================================================

	/**
	 * Append a payload to the session transient.
	 *
	 * @param array $payload Mapped dataLayer event payload.
	 *
	 * @return void
	 */
	private static function append_to_session( array $payload ): void {
		$key       = self::get_session_key();
		$transient = 'ambh_dl_session_' . $key;
		$events    = self::read_session_events( $key );
		$events[]  = $payload;
		set_transient( $transient, $events, self::SESSION_TTL );
	}

	/**
	 * Append a payload to the per-donation transient.
	 *
	 * @param int   $donation_id Donation ID.
	 * @param array $payload     Mapped dataLayer event payload.
	 *
	 * @return void
	 */
	private static function append_to_donation( int $donation_id, array $payload ): void {
		$transient = 'ambh_dl_donation_' . $donation_id;
		$events    = (array) get_transient( $transient );
		$events[]  = $payload;
		set_transient( $transient, $events, self::DONATION_TTL );
	}

	/**
	 * Read queued events for the given session key.
	 *
	 * @param string $key Session key.
	 *
	 * @return array
	 */
	private static function read_session_events( string $key ): array {
		return (array) get_transient( 'ambh_dl_session_' . $key );
	}

	// =========================================================================
	// Mapping
	// =========================================================================

	/**
	 * Map a canonical TrackingEvent to a dataLayer push payload.
	 *
	 * All keys are prefixed with `ambh_` to avoid collisions with
	 * other dataLayer pushes on the page.
	 *
	 * @param TrackingEvent $event Canonical event.
	 *
	 * @return array|null Payload ready for dataLayer.push(), or null to skip.
	 */
	public static function map_event( TrackingEvent $event ): ?array {
		$p = $event->properties;

		return match ( $event->name ) {

			'donation_created' => array(
				'event'              => 'ambh_begin_checkout',
				'ambh_value'         => (float) ( $p['amount'] ?? 0 ),
				'ambh_currency'      => sanitize_key( $p['currency'] ?? 'EUR' ),
				'ambh_fundraiser_id' => (int) ( $p['fundraiser_id'] ?? 0 ),
			),

			'donation_completed' => array(
				'event'               => 'ambh_purchase',
				'ambh_value'          => (float) ( $p['amount'] ?? 0 ),
				'ambh_currency'       => sanitize_key( $p['currency'] ?? 'EUR' ),
				'ambh_transaction_id' => (string) ( $p['transaction_id'] ?? $event->entity_id ),
				'ambh_fundraiser_id'  => (int) ( $p['fundraiser_id'] ?? 0 ),
			),

			'donation_failed' => array(
				'event'               => 'ambh_donation_failed',
				'ambh_value'          => (float) ( $p['amount'] ?? 0 ),
				'ambh_currency'       => sanitize_key( $p['currency'] ?? 'EUR' ),
				'ambh_transaction_id' => (string) ( $p['transaction_id'] ?? $event->entity_id ),
				'ambh_fundraiser_id'  => (int) ( $p['fundraiser_id'] ?? 0 ),
			),

			'fundraiser_published' => array(
				'event'                 => 'ambh_fundraiser_published',
				'ambh_fundraiser_title' => (string) ( $p['fundraiser_title'] ?? '' ),
			),

			'ambassador_registered' => array(
				'event' => 'ambh_sign_up',
			),

			'campaign_started' => array(
				'event'               => 'ambh_campaign_started',
				'ambh_campaign_title' => (string) ( $p['campaign_title'] ?? '' ),
			),

			'campaign_ended' => array(
				'event'               => 'ambh_campaign_ended',
				'ambh_campaign_title' => (string) ( $p['campaign_title'] ?? '' ),
			),

			default => null,
		};
	}

	// =========================================================================
	// Output
	// =========================================================================

	/**
	 * Queue an inline script block that pushes events to window.dataLayer via
	 * the standard wp_add_inline_script() enqueue path, instead of echoing a
	 * raw <script> tag.
	 *
	 * This only works because flush_session() now hooks wp_footer at
	 * priority 5 (see init()) — before core's print_footer_scripts (priority
	 * 20) — so the shared inline-script handle is still enqueued in time to
	 * be picked up when the footer script queue actually prints.
	 * flush_for_donation() is called synchronously from
	 * DonationConfirmation::render_block() during content rendering, well
	 * before wp_footer fires either way.
	 *
	 * @param array[] $events Array of dataLayer payloads.
	 *
	 * @return void
	 */
	private static function output_script( array $events ): void {
		$js = "window.dataLayer = window.dataLayer || [];\n";
		foreach ( $events as $payload ) {
			$js .= 'window.dataLayer.push(' . wp_json_encode( $payload ) . ");\n";
		}

		InlineAssets::script( $js );
	}

	// =========================================================================
	// Context detection
	// =========================================================================

	/**
	 * Determine whether the current request has a browser session context.
	 *
	 * Returns false for REST API requests (PSP webhooks) and WP-cron.
	 *
	 * @return bool
	 */
	private static function is_browser_request(): bool {
		if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) {
			return false;
		}

		if ( wp_doing_cron() ) {
			return false;
		}

		if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
			return false;
		}

		return true;
	}
}
