<?php
/**
 * User Events REST API.
 *
 * @package PRECO
 * @since 1.0.0
 */

namespace PRECO\Includes\Frontend\UserEvents;

use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
use PRECO\Includes\Analytics\AnalyticsTable;

defined( 'ABSPATH' ) || exit;

/**
 * User Events REST class.
 *
 * Handles REST API endpoints for tracking user events.
 */
class UserEventsRest {

	/**
	 * Namespace for API.
	 *
	 * @var string
	 */
	const NAMESPACE = 'preco/v1';

	/**
	 * Rate limits for the public tracking endpoints (events/view,
	 * events/add_to_cart). Both are gated only by the shared `wp_rest`
	 * nonce, which is identical for every anonymous visitor and easy to
	 * obtain, so it's not real abuse protection on its own — this is a
	 * lightweight, transient-based backstop against a scripted client
	 * flooding either endpoint, not a substitute for a WAF/edge limiter.
	 *
	 * @var int
	 */
	const RATE_LIMIT_VIEW_MAX    = 60;
	const RATE_LIMIT_VIEW_WINDOW = 60;
	const RATE_LIMIT_CART_MAX    = 30;
	const RATE_LIMIT_CART_WINDOW = 60;

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
		add_action( 'wp_login', array( $this, 'sync_guest_session_to_db' ), 10, 2 );
		add_action( 'woocommerce_add_to_cart', array( $this, 'handle_add_to_cart' ), 10, 3 );
		add_action( 'woocommerce_cart_item_removed', array( $this, 'handle_remove_from_cart' ), 10, 2 );
		// phpcs:ignore Squiz.PHP.CommentedOutCode.Found -- Intentionally disabled hook; display_custom_meta_in_cart() is kept below for future re-enable.
		// add_filter( 'woocommerce_get_item_data', array( $this, 'display_custom_meta_in_cart' ), 10, 2 );.
	}

	/**
	 * Add the recommendation source to a cart item's displayed meta.
	 *
	 * @param array $item_data Existing cart item data rows.
	 * @param array $cart_item Cart item.
	 * @return array
	 */
	public function display_custom_meta_in_cart( $item_data, $cart_item ) {
		if ( isset( $cart_item['preco_page_source'] ) ) {
			$item_data[] = array(
				'name'  => __( 'Page Source:', 'wow-ai-product-recommendations' ),
				'value' => wc_clean( $cart_item['preco_page_source'] ),
			);
			$item_data[] = array(
				'name'  => __( 'Location:', 'wow-ai-product-recommendations' ),
				'value' => wc_clean( $cart_item['preco_location_source'] ),
			);
		}
		return $item_data;
	}
	/**
	 * Register REST routes.
	 *
	 * @return void
	 */
	public function register_routes() {
		// Track view event.
		register_rest_route(
			self::NAMESPACE,
			'/events/view',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'track_view' ),
				'permission_callback' => function ( WP_REST_Request $request ) {
					return (bool) wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' );
				},
				'args'                => array(
					'product_id' => array(
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
						'validate_callback' => function ( $param ) {
							return $param > 0;
						},
					),
				),
			)
		);

		// Add to cart event endpoint.
		register_rest_route(
			self::NAMESPACE,
			'/events/add_to_cart',
			array(
				'methods'             => 'POST',
				'callback'            => array( $this, 'handle_custom_add_to_cart' ),
				'permission_callback' => function ( WP_REST_Request $request ) {
					return (bool) wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' );
				},
				'args'                => array(
					'product_id'     => array(
						'required'          => true,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					),
					'quantity'       => array(
						'required'          => false,
						'type'              => 'integer',
						'sanitize_callback' => 'absint',
					),
					'custom_payload' => array(
						'required'          => false,
						'type'              => 'object',
						'sanitize_callback' => array( $this, 'sanitize_custom_payload' ),
					),
				),
			)
		);
	}

	/**
	 * Sanitize `custom_payload` down to the two known string fields it's
	 * actually used for (pageSource/locationSource), rather than letting an
	 * arbitrary client-supplied object reach WC cart-item meta unsanitized.
	 * Unknown keys are dropped; keys that are present are kept present (an
	 * absent key is left absent, not defaulted here) so the existing
	 * `?? 'none'` fallback in handle_custom_add_to_cart() keeps working
	 * exactly as before.
	 *
	 * @param mixed $value Raw request value for custom_payload.
	 * @return array
	 */
	public function sanitize_custom_payload( $value ) {
		if ( ! is_array( $value ) ) {
			return array();
		}

		$out = array();
		if ( isset( $value['pageSource'] ) ) {
			$out['pageSource'] = sanitize_text_field( (string) $value['pageSource'] );
		}
		if ( isset( $value['locationSource'] ) ) {
			$out['locationSource'] = sanitize_text_field( (string) $value['locationSource'] );
		}

		return $out;
	}

	// Remove stray closing brace here. All methods below should be inside the class.
	/**
	 * Track add to cart event from REST API.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error Response object.
	 */
	public function handle_custom_add_to_cart( $request ) {

		if ( $this->is_rate_limited( 'cart', self::RATE_LIMIT_CART_MAX, self::RATE_LIMIT_CART_WINDOW ) ) {
			return new WP_Error(
				'rate_limited',
				__( 'Too many requests. Please slow down and try again shortly.', 'wow-ai-product-recommendations' ),
				array( 'status' => 429 )
			);
		}

		$product_id     = absint( $request->get_param( 'product_id' ) );
		$quantity       = max( 1, absint( $request->get_param( 'quantity' ) ) ); // default 1.
		$custom_payload = $request->get_param( 'custom_payload' );

		if ( ! $product_id ) {
			return new WP_Error(
				'missing_product_id',
				__( 'Product ID is required.', 'wow-ai-product-recommendations' ),
				array( 'status' => 400 )
			);
		}

		// Initialize WooCommerce session & cart (REST-safe).
		if ( null === WC()->cart ) {
			wc_load_cart();
		}

		// Get product & check stock.
		$product = wc_get_product( $product_id );
		if ( ! $product || ! $product->is_in_stock() ) {
			return new WP_Error(
				'out_of_stock',
				__( 'Product is out of stock.', 'wow-ai-product-recommendations' ),
				array( 'status' => 400 )
			);
		}

		// Prepare unique cart item data.
		$cart_item_data = array(
			'preco_page_source'     => $custom_payload['pageSource'] ?? 'none',
			'preco_location_source' => $custom_payload['locationSource'] ?? 'none',
		);

		// Add to cart.

		$found = false;

		// Loop through existing cart items.
		foreach ( WC()->cart->get_cart() as $cart_item_key => $cart_item ) {

			// Check if this product already exists in the cart.
			if ( (int) $cart_item['product_id'] === $product_id ) {
				$new_cart_item_key = WC()->cart->generate_cart_id( $product_id, 0, array(), $cart_item_data );
				if ( $new_cart_item_key !== $cart_item_key ) {
					continue;
				}
				// Update the quantity.
				$new_qty = $cart_item['quantity'] + $quantity;
				WC()->cart->set_quantity( $cart_item_key, $new_qty );

				$found = true;

				break;
			}
		}

		if ( $found ) {
			$this->track_add_to_cart( $product_id );
		}

		// If not found, add as a new cart item.
		if ( ! $found ) {
			$new_cart_item_key = WC()->cart->add_to_cart( $product_id, $quantity, 0, array(), $cart_item_data );
		}

		if ( ! $new_cart_item_key ) {
			return new WP_Error(
				'add_to_cart_failed',
				__( 'Failed to add product to cart.', 'wow-ai-product-recommendations' ),
				array( 'status' => 500 )
			);
		}

		// Record into analytics table only when the request carries a Preco page source.
		$page_source = $cart_item_data['preco_page_source'] ?? '';
		if ( ! empty( $page_source ) && 'none' !== $page_source ) {
			$page = AnalyticsTable::resolve_page( sanitize_text_field( $page_source ) );
			AnalyticsTable::record( $page, AnalyticsTable::TYPE_ADD_TO_CART, $quantity );
		}

		// Build the same fragments/cart_hash payload WC_AJAX::get_refreshed_fragments()
		// returns for a native ajax add-to-cart, so the client can fire a real
		// `added_to_cart` event and let wc-add-to-cart.js (mini-cart, theme/plugin
		// listeners) update itself exactly as it would for a normal add-to-cart button.
		ob_start();
		woocommerce_mini_cart();
		$mini_cart = ob_get_clean();

		$fragments = apply_filters(
			'woocommerce_add_to_cart_fragments', // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
			array(
				'div.widget_shopping_cart_content' => '<div class="widget_shopping_cart_content">' . $mini_cart . '</div>',
			)
		);

		// Return success.
		return new WP_REST_Response(
			array(
				'success'        => true,
				'message'        => __( 'Add to cart tracked successfully.', 'wow-ai-product-recommendations' ),
				'cart_item_key'  => $new_cart_item_key,
				'custom_payload' => $custom_payload,
				'fragments'      => $fragments,
				'cart_hash'      => WC()->cart->get_cart_hash(),
			),
			200
		);
	}

	/**
	 * Track add to cart event.
	 *
	 * @param int $product_id Product ID.
	 */
	public function track_add_to_cart( $product_id ) {
		if ( ! is_user_logged_in() ) {
			$this->store_in_session( $product_id, UserEventsTable::EVENT_ADD_TO_CART );
			return;
		}

		UserEventsTable::track_event( get_current_user_id(), $product_id, UserEventsTable::EVENT_ADD_TO_CART );
	}

	/**
	 * Track view event.
	 *
	 * @param WP_REST_Request $request Request object.
	 * @return WP_REST_Response|WP_Error Response object.
	 */
	public function track_view( $request ) {
		if ( $this->is_rate_limited( 'view', self::RATE_LIMIT_VIEW_MAX, self::RATE_LIMIT_VIEW_WINDOW ) ) {
			return new WP_Error(
				'rate_limited',
				__( 'Too many requests. Please slow down and try again shortly.', 'wow-ai-product-recommendations' ),
				array( 'status' => 429 )
			);
		}

		$product_id = $request->get_param( 'product_id' );

		// Validate the product exists and is published to prevent data inflation
		// from arbitrary IDs sent by unauthenticated callers.
		$product = wc_get_product( $product_id );
		if ( ! $product || 'publish' !== $product->get_status() ) {
			return new WP_Error(
				'invalid_product',
				__( 'Product not found.', 'wow-ai-product-recommendations' ),
				array( 'status' => 404 )
			);
		}

		if ( ! is_user_logged_in() ) {
			$this->store_in_session( $product_id, UserEventsTable::EVENT_VIEW );
			return new WP_REST_Response( array( 'success' => true ), 200 );
		}

		$result = UserEventsTable::track_event( get_current_user_id(), $product_id, UserEventsTable::EVENT_VIEW );

		if ( $result ) {
			return new WP_REST_Response(
				array(
					'success' => true,
					'message' => __( 'View tracked successfully.', 'wow-ai-product-recommendations' ),
				),
				200
			);
		}

		return new WP_Error(
			'tracking_failed',
			__( 'Failed to track view event.', 'wow-ai-product-recommendations' ),
			array( 'status' => 500 )
		);
	}

	/**
	 * Handle add to cart via WooCommerce hook.
	 *
	 * @param string $cart_item_key Cart item key.
	 * @param int    $product_id    Product ID.
	 * @param int    $quantity      Quantity added.
	 * @return void
	 */
	public function handle_add_to_cart( $cart_item_key, $product_id, $quantity ) {
		$product_id = absint( $product_id );

		if ( ! $product_id ) {
			return;
		}
		$this->track_add_to_cart( $product_id );

		// Attribution: read from the JS cookie (primary) or from the POST field
		// injected by the localStorage fallback (for visitors with cookies disabled).
		$attribution = $this->resolve_reco_attribution( $product_id );
		if ( ! empty( $attribution['pageSource'] ) ) {
			$page = AnalyticsTable::resolve_page( $attribution['pageSource'] );
			AnalyticsTable::record( $page, AnalyticsTable::TYPE_ADD_TO_CART, max( 1, absint( $quantity ) ) );

			// Persist attribution onto the cart item (skip if already set — the
			// custom REST add-to-cart endpoint injects these via cart_item_data
			// before this hook fires) so Analytics::copy_page_source_to_order_item()
			// can carry it through to the order for purchase-attribution tracking.
			if ( empty( WC()->cart->cart_contents[ $cart_item_key ]['preco_page_source'] ) ) {
				WC()->cart->cart_contents[ $cart_item_key ]['preco_page_source']     = $attribution['pageSource'];
				WC()->cart->cart_contents[ $cart_item_key ]['preco_location_source'] = $attribution['locationSource'] ?? 'none';
			}
		}
	}

	/**
	 * Resolve recommendation attribution data for a given product.
	 *
	 * Checks in order:
	 *  1. The preco_reco_click cookie (set by JS, works for all request types).
	 *  2. The preco_reco_click_data POST field (injected into form.cart by JS when
	 *     cookies are disabled — covers standard form-submit add-to-cart).
	 *
	 * Returns an array with keys productId, pageSource, locationSource,
	 * or an empty array if no attribution data is present / product does not match.
	 *
	 * @param int $product_id Expected product ID.
	 * @return array
	 */
	private function resolve_reco_attribution( int $product_id ): array {
		// 1. Cookie (primary).
		if ( ! empty( $_COOKIE['preco_reco_click'] ) ) {
			// wp_unslash removes magic-quote escaping; urldecode reverses encodeURIComponent.
			// Do NOT sanitize_text_field before json_decode — it strips tags and collapses
			// whitespace which would corrupt the JSON string.
			$raw    = urldecode( wp_unslash( $_COOKIE['preco_reco_click'] ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
			$parsed = json_decode( $raw, true );
			// Handle both array (new) and plain object (legacy) formats.
			$list = is_array( $parsed ) && isset( $parsed[0] ) ? $parsed : ( is_array( $parsed ) ? array( $parsed ) : array() );
			foreach ( $list as $data ) {
				if (
					is_array( $data ) &&
					! empty( $data['productId'] ) &&
					(int) $data['productId'] === $product_id
				) {
					return array(
						'productId'      => $product_id,
						'pageSource'     => sanitize_text_field( $data['pageSource'] ?? '' ),
						'locationSource' => sanitize_text_field( $data['locationSource'] ?? '' ),
					);
				}
			}
		}

		// 2. POST field fallback (for cookies-disabled visitors using form submit).
		// Nonce verification is intentionally skipped — this is analytics-only attribution
		// data with no privileged side-effects; the product_id is validated separately.
		$preco_post_raw = filter_input( INPUT_POST, 'preco_reco_click_data' );
		if ( ! empty( $preco_post_raw ) ) {
			$raw  = wp_unslash( $preco_post_raw ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitize individual fields after decode
			$data = json_decode( $raw, true );
			// POST carries a single matched entry (injected by initProductPageAttributionFallback).
			if (
				is_array( $data ) &&
				! empty( $data['productId'] ) &&
				(int) $data['productId'] === $product_id
			) {
				return array(
					'productId'      => $product_id,
					'pageSource'     => sanitize_text_field( $data['pageSource'] ?? '' ),
					'locationSource' => sanitize_text_field( $data['locationSource'] ?? '' ),
				);
			}
		}

		return array();
	}

	/**
	 * Handle remove from cart via WooCommerce hook.
	 *
	 * @param string   $cart_item_key Cart item key.
	 * @param \WC_Cart $cart          Cart object.
	 * @return void
	 */
	public function handle_remove_from_cart( $cart_item_key, $cart ) {
		$removed = $cart->removed_cart_contents;

		if ( empty( $removed[ $cart_item_key ]['product_id'] ) ) {
			return;
		}

		$product_id = absint( $removed[ $cart_item_key ]['product_id'] );

		if ( ! is_user_logged_in() ) {
			$this->store_in_session( $product_id, UserEventsTable::EVENT_REMOVE_FROM_CART );
			return;
		}

		UserEventsTable::track_event( get_current_user_id(), $product_id, UserEventsTable::EVENT_REMOVE_FROM_CART );
	}

	/**
	 * Store event in a transient for guest users, keyed by a cookie-based guest ID.
	 *
	 * @param int $product_id Product ID.
	 * @param int $event_type Event type constant.
	 * @return void
	 */
	private function store_in_session( $product_id, $event_type ) {
		$guest_id = $this->get_or_create_guest_cookie();

		if ( ! $guest_id ) {
			return;
		}

		// $guest_id is already a validated UUIDv4 (see get_or_create_guest_cookie()),
		// so it's safe to use directly — this must match the raw key format read by
		// sync_guest_session_to_db() and the rule-helper readers.
		$transient_key = 'preco_guest_' . $guest_id;

		$events = get_transient( $transient_key );

		if ( ! is_array( $events ) ) {
			$events = array();
		}

		$key = $product_id . '_' . $event_type;

		if ( isset( $events[ $key ] ) ) {
			++$events[ $key ]['count'];
		} else {
			$events[ $key ] = array(
				'product_id' => $product_id,
				'event_type' => $event_type,
				'count'      => 1,
			);
		}

		set_transient( $transient_key, $events, 30 * DAY_IN_SECONDS );
	}

	/**
	 * Get or create a unique guest cookie ID.
	 *
	 * @return string|false Guest ID or false if cookie not available.
	 */
	private function get_or_create_guest_cookie() {
		$cookie_name = 'preco_guest_id';

		if ( empty( $_COOKIE[ $cookie_name ] ) ) {
			return false;
		}

		$guest_id = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );

		// Strict UUIDv4 validation.
		if ( ! preg_match( '/^[0-9a-fA-F-]{36}$/', $guest_id ) ) {
			return false;
		}

		return $guest_id;
	}

	/**
	 * Whether the current caller has exceeded a request budget for the given
	 * bucket. Backed by a single transient counter with a fixed-ish window
	 * (each accepted hit refreshes the TTL to $window seconds out, so a
	 * caller that stays at or under the limit never gets stuck — the window
	 * only fully resets $window seconds after their last accepted request).
	 *
	 * @param string $bucket   Distinguishes independent limits, e.g. 'view'/'cart'.
	 * @param int    $max_hits Max accepted hits within the window.
	 * @param int    $window   Window length in seconds.
	 * @return bool True if the caller is OVER the limit and should be rejected.
	 */
	private function is_rate_limited( string $bucket, int $max_hits, int $window ): bool {
		$key  = 'preco_rl_' . $bucket . '_' . md5( $this->rate_limit_identity() );
		$hits = get_transient( $key );
		$hits = is_int( $hits ) ? $hits : 0;

		if ( $hits >= $max_hits ) {
			return true;
		}

		set_transient( $key, $hits + 1, $window );
		return false;
	}

	/**
	 * Best-effort per-visitor identity for rate limiting: the logged-in
	 * user id, else the validated guest cookie (set for essentially every
	 * guest by Frontend::maybe_set_guest_cookie()), else a hash of the
	 * remote address as a last resort for the rare caller with neither —
	 * coarser (may bucket multiple visitors behind the same proxy/NAT
	 * together) but still better than no limit at all.
	 *
	 * @return string
	 */
	private function rate_limit_identity(): string {
		if ( is_user_logged_in() ) {
			return 'user_id_' . get_current_user_id();
		}

		$guest_id = $this->get_or_create_guest_cookie();
		if ( $guest_id ) {
			return 'guest_id_' . $guest_id;
		}

		return 'ip_' . ( isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '' );
	}

	/**
	 * Sync guest transient events to database on user login.
	 *
	 * @param string   $user_login Username.
	 * @param \WP_User $user       User object.
	 * @return void
	 */
	public function sync_guest_session_to_db( $user_login, $user ) {
		$cookie_name = 'preco_guest_id';

		if ( empty( $_COOKIE[ $cookie_name ] ) ) {
			return;
		}

		$guest_id      = sanitize_text_field( wp_unslash( $_COOKIE[ $cookie_name ] ) );
		$transient_key = 'preco_guest_' . $guest_id;
		$events        = get_transient( $transient_key );

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

		foreach ( $events as $event ) {
			UserEventsTable::track_event( $user->ID, $event['product_id'], $event['event_type'], $event['count'] );
		}

		// Clear transient after syncing.
		delete_transient( $transient_key );
	}
}
