<?php
/**
 * Analytics class
 *
 * @package   PopupMaker
 * @copyright Copyright (c) 2024, Code Atlantic LLC
 */

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

/**
 * Controls the basic analytics methods for Popup Maker
 */
class PUM_Analytics {

	/**
	 * Initializes analytics endpoints and data
	 */
	public static function init() {
		if ( ! self::analytics_enabled() ) {
			return;
		}

		add_action( 'rest_api_init', [ __CLASS__, 'register_endpoints' ] );
		add_action( 'wp_ajax_pum_analytics', [ __CLASS__, 'ajax_request' ] );
		add_action( 'wp_ajax_nopriv_pum_analytics', [ __CLASS__, 'ajax_request' ] );
		add_filter( 'pum_vars', [ __CLASS__, 'pum_vars' ] );
	}

	/**
	 * Checks whether analytics is enabled.
	 *
	 * @return bool
	 */
	public static function analytics_enabled() {
		$disabled = pum_get_option( 'disable_analytics' ) || popmake_get_option( 'disable_popup_open_tracking' );

		return (bool) apply_filters( 'pum_analytics_enabled', ! $disabled );
	}

	/**
	 * Get a list of key pairs for each event type.
	 * Internally used only for meta keys.
	 *
	 * Example returns [[open,opened],[conversion,conversion]].
	 *
	 * Usage examples:
	 * - popup_open_count, popup_last_opened
	 * - popup_conversion_count, popup_last_conversion
	 *
	 * @param string $event Event key.
	 *
	 * @return mixed
	 */
	public static function event_keys( $event ) {
		$keys = [ $event, rtrim( $event, 'e' ) . 'ed' ];

		if ( 'conversion' === $event ) {
			$keys[1] = 'conversion';
		}

		return apply_filters( 'pum_analytics_event_keys', $keys, $event );
	}

	/**
	 * Returns an array of valid event types.
	 *
	 * @return string[]
	 */
	public static function valid_events() {
		return apply_filters( 'pum_analytics_valid_events', [ 'open', 'conversion' ] );
	}

	/**
	 * Track an event.
	 *
	 * This is called by various methods including the ajax & rest api requests.
	 *
	 * Can be used externally such as after purchase tracking.
	 *
	 * @param array $args
	 */
	public static function track( $args = [] ) {
		// TODO: Remove this to support beacon for CTA conversions.
		if ( empty( $args['pid'] ) || $args['pid'] <= 0 ) {
			return;
		}

		$event = sanitize_text_field( $args['event'] );

		$popup = pum_get_popup( $args['pid'] );

		if ( ! pum_is_popup( $popup ) || ! in_array( $event, self::valid_events(), true ) ) {
			return;
		}

		// The analytics endpoints are intentionally public (tracking pixels fire for
		// logged-out visitors), so they cannot use a nonce. Apply a lightweight
		// per-visitor rate limit to blunt automated counter inflation while leaving
		// legitimate tracking untouched.
		if ( self::is_rate_limited( (int) $args['pid'], $event ) ) {
			return;
		}

		$popup->increase_event_count( $event );

		if ( has_action( 'pum_analytics_' . $event ) ) {
			do_action( 'pum_analytics_' . $event, $popup->ID, $args );
		}

		do_action( 'pum_analytics_event', $args );
	}

	/**
	 * Determine whether the current visitor has exceeded the analytics rate limit
	 * for a given popup + event.
	 *
	 * The analytics endpoints are unauthenticated by design, so this provides a
	 * coarse per-visitor throttle to limit automated counter inflation. The cap is
	 * deliberately high so it never drops a real visitor's events; only abusive
	 * volumes are blocked.
	 *
	 * @param int    $pid   Popup ID.
	 * @param string $event Event key.
	 * @return bool True when the request should be dropped.
	 */
	protected static function is_rate_limited( $pid, $event ) {
		/**
		 * Filters the analytics rate limit window (seconds) and max events per window.
		 *
		 * @param array $limits [ 'window' => int seconds, 'max' => int events ].
		 */
		$limits = apply_filters(
			'popup_maker/analytics/rate_limit',
			[
				'window' => 5 * MINUTE_IN_SECONDS,
				'max'    => 30,
			]
		);

		// A non-positive max disables rate limiting entirely.
		if ( empty( $limits['max'] ) || $limits['max'] < 1 ) {
			return false;
		}

		$visitor = self::get_visitor_hash();
		$key     = 'pum_alrl_' . md5( $visitor . '|' . $pid . '|' . $event );

		$count = (int) get_transient( $key );

		if ( $count >= (int) $limits['max'] ) {
			return true;
		}

		set_transient( $key, $count + 1, (int) $limits['window'] );

		return false;
	}

	/**
	 * Build a privacy-preserving identifier for the current visitor.
	 *
	 * Hashes the client IP together with the auth salt so no raw IP is ever stored
	 * in a transient key. Logged-in users are keyed by user ID.
	 *
	 * @return string
	 */
	protected static function get_visitor_hash() {
		if ( is_user_logged_in() ) {
			return 'u' . get_current_user_id();
		}

		$ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';

		return wp_hash( $ip );
	}

	/**
	 * Process ajax requests.
	 *
	 * Only used when WP-JSON Restful API is not available.
	 */
	public static function ajax_request() {

		$args = wp_parse_args(
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended
			$_REQUEST,
			[
				'event'  => null,
				'pid'    => null,
				'method' => null,
			]
		);

		self::track( $args );

		switch ( $args['method'] ) {
			case 'image':
				self::serve_pixel();
				break;

			case 'json':
				self::serve_json();
				break;

			default:
				self::serve_no_content();
				break;
		}
	}

	/**
	 * @param WP_REST_Request $request
	 *
	 * @return WP_Error|mixed
	 */
	public static function analytics_endpoint( WP_REST_Request $request ) {
		$args = $request->get_params();

		// Batch beacon: the client may send multiple events in one request as
		// an `events` array (debounced/flush-on-exit batching). Each entry is a
		// full event payload; process them through the same single-event path.
		// Back-compatible — a single-event POST (top-level pid/event) still works.
		$batch = self::parse_batch_events( $request, $args );

		if ( null !== $batch ) {
			foreach ( $batch as $event_args ) {
				if ( is_array( $event_args ) && ! empty( $event_args['pid'] ) ) {
					self::track( $event_args );
				}
			}

			self::serve_no_content();

			return true;
		}

		if ( ! $args || empty( $args['pid'] ) ) {
			return new WP_Error( 'missing_params', __( 'Missing Parameters.', 'default' ), [ 'status' => 404 ] );
		}

		self::track( $args );

		self::serve_no_content();

		return true;
	}

	/**
	 * Extract a batch of event payloads from the request, if present.
	 *
	 * Accepts an `events` parameter that is either a JSON-encoded array
	 * (sendBeacon FormData can only carry strings) or an already-decoded array.
	 *
	 * @param WP_REST_Request $request Request.
	 * @param array           $args    Parsed params.
	 * @return array<int,array>|null Array of event payloads, or null when not a batch.
	 */
	protected static function parse_batch_events( WP_REST_Request $request, $args ) {
		$events = isset( $args['events'] ) ? $args['events'] : $request->get_param( 'events' );

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

		if ( is_string( $events ) ) {
			$decoded = json_decode( $events, true );
			$events  = is_array( $decoded ) ? $decoded : null;
		}

		if ( ! is_array( $events ) || empty( $events ) ) {
			return null;
		}

		// Must be a list of event objects, not an associative single event.
		$first = reset( $events );
		if ( ! is_array( $first ) ) {
			return null;
		}

		return array_values( $events );
	}

	/**
	 * @param $param
	 *
	 * @return bool
	 */
	public static function endpoint_absint( $param ) {
		return is_numeric( $param );
	}

	/**
	 * Sanitize eventData parameter (matches Pro's approach).
	 *
	 * Decodes JSON string to array if needed.
	 *
	 * @param mixed $value EventData value (JSON string or array).
	 *
	 * @return array Decoded eventData as array.
	 */
	public static function sanitize_event_data( $value ) {
		// If already an array, return as-is.
		if ( is_array( $value ) ) {
			return $value;
		}

		// Decode JSON string to array (matches Pro's Request::parse_tracking_data).
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			return is_array( $decoded ) ? $decoded : [];
		}

		return [];
	}

	/**
	 * Registers the analytics endpoints
	 */
	public static function register_endpoints() {
		register_rest_route(
			self::get_analytics_namespace(),
			self::get_analytics_route(),
			apply_filters(
				'pum_analytics_rest_route_args',
				[
					'methods'             => [ 'GET', 'POST' ],
					'callback'            => [ __CLASS__, 'analytics_endpoint' ],
					'permission_callback' => '__return_true',
					'args'                => [
						'event'     => [
							'required'    => true,
							'description' => __( 'Event Type', 'popup-maker' ),
							'type'        => 'string',
						],
						'pid'       => [
							'required'            => true,
							'description'         => __( 'Popup ID', 'popup-maker' ),
							'type'                => 'integer',
							'validation_callback' => [ __CLASS__, 'endpoint_absint' ],
							'sanitize_callback'   => 'absint',
						],
						'eventData' => [
							'required'          => false,
							'description'       => __( 'Event metadata (JSON or array)', 'popup-maker' ),
							'sanitize_callback' => [ __CLASS__, 'sanitize_event_data' ],
						],
					],
				]
			)
		);
	}

	/**
	 * Adds our analytics endpoint to pum_vars
	 *
	 * @param array $vars The current pum_vars.
	 * @return array The updates pum_vars
	 */
	public static function pum_vars( $vars = [] ) {
		$vars['analytics_enabled'] = self::analytics_enabled();
		$vars['analytics_route']   = self::get_analytics_route();
		if ( function_exists( 'rest_url' ) ) {
			$vars['analytics_api'] = esc_url_raw( rest_url( self::get_analytics_namespace() ) );
		} else {
			$vars['analytics_api'] = false;
		}
		return $vars;
	}

	/**
	 * Gets the analytics namespace
	 *
	 * If bypass adblockers is enabled, will return random or custom string. If not, returns 'pum/v1'.
	 *
	 * @return string The analytics namespce
	 * @since 1.13.0
	 */
	public static function get_analytics_namespace() {
		$version   = 1;
		$namespace = self::customize_endpoint_value( 'pum' );
		return "$namespace/v$version";
	}

	/**
	 * Gets the analytics route
	 *
	 * If bypass adblockers is enabled, will return random or custom string. If not, returns 'analytics'.
	 *
	 * @return string The analytics route
	 * @since 1.13.0
	 */
	public static function get_analytics_route() {
		$route = 'analytics';
		return self::customize_endpoint_value( $route );
	}

	/**
	 * Customizes the endpoint value given to it
	 *
	 * If bypass adblockers is enabled, will return random or custom string. If not, returns the value given to it.
	 *
	 * @param string $value The value to, potentially, customize.
	 * @return string
	 * @since 1.13.0
	 */
	public static function customize_endpoint_value( $value = '' ) {
		$bypass_adblockers = pum_get_option( 'bypass_adblockers', false );
		if ( true === $bypass_adblockers || 1 === intval( $bypass_adblockers ) ) {
			switch ( pum_get_option( 'adblock_bypass_url_method', 'custom' ) ) {
				case 'custom':
					$value = preg_replace( '/[^a-z0-9]+/', '-', pum_get_option( 'adblock_bypass_custom_filename', $value ) );
					break;
				case 'random':
				default:
					$site_url = get_site_url();
					$value    = md5( $site_url . $value );
					break;
			}
		}
		return $value;
	}

	/**
	 * Creates and returns a 1x1 tracking gif to the browser.
	 */
	public static function serve_pixel() {
		$gif = self::get_file( Popup_Maker::$DIR . 'assets/images/beacon.gif' );
		header( 'Content-Type: image/gif' );
		header( 'Content-Length: ' . strlen( $gif ) );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo $gif;
		exit;
	}

	/**
	 * @param $path
	 *
	 * @return bool|string
	 */
	public static function get_file( $path ) {

		if ( function_exists( 'realpath' ) ) {
			$path = realpath( $path );
		}

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		if ( ! $path || ! @is_file( $path ) ) {
			return '';
		}

		// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
		return @file_get_contents( $path );
	}

	/**
	 * Returns a 204 no content header.
	 */
	public static function serve_no_content() {
		header( 'HTTP/1.0 204 No Content' );
		header( 'Content-Type: image/gif' );
		header( 'Content-Length: 0' );
		exit;
	}

	/**
	 * Serves a proper json response.
	 *
	 * @param mixed $data
	 */
	public static function serve_json( $data = 0 ) {
		header( 'Content-Type: application/json' );
		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
		echo PUM_Utils_Array::safe_json_encode( $data );
		exit;
	}
}
