<?php
defined( 'ABSPATH' ) || exit;

/**
 * AJAX endpoints for the Products Grid widget (pagination + add-to-cart).
 *
 * SECURITY MODEL
 * The client sends only a post ID, a node ID and a page number. The handler
 * loads the SAVED node from the database and rebuilds the query from its own
 * sanitized content — no query arguments (taxonomy, meta, post__in, orderby…)
 * ever come from the request. A tampered payload can therefore only ask for a
 * different page of a grid that already exists on that page.
 *
 * Both endpoints are exposed to logged-out visitors (nopriv) because product
 * browsing is public; the nonce guards against cross-origin abuse rather than
 * privilege escalation.
 */
class Jalpi_Woo_Ajax {

	const NONCE = 'jalpi_pg';

	private static ?self $instance = null;

	public static function instance(): self {
		return self::$instance ??= new self();
	}

	private function __construct() {
		add_action( 'wp_ajax_jalpi_pg_page',        [ $this, 'handle_page' ] );
		add_action( 'wp_ajax_nopriv_jalpi_pg_page', [ $this, 'handle_page' ] );
		add_action( 'wp_ajax_jalpi_pg_cart',        [ $this, 'handle_cart' ] );
		add_action( 'wp_ajax_nopriv_jalpi_pg_cart', [ $this, 'handle_cart' ] );
	}

	/** Return one page of product cards as HTML. */
	public function handle_page(): void {
		check_ajax_referer( self::NONCE, 'nonce' );

		$post_id = isset( $_POST['post'] ) ? absint( wp_unslash( $_POST['post'] ) ) : 0;
		$node_id = isset( $_POST['node'] ) ? sanitize_key( wp_unslash( $_POST['node'] ) ) : '';
		$page    = isset( $_POST['page'] ) ? max( 1, absint( wp_unslash( $_POST['page'] ) ) ) : 1;

		if ( ! $post_id || ! $node_id ) {
			wp_send_json_error( [ 'message' => __( 'Invalid request.', 'jalpi-page-builder' ) ], 400 );
		}

		// Only serve grids from content the visitor can actually read.
		$post = get_post( $post_id );
		if ( ! $post || 'publish' !== $post->post_status ) {
			if ( ! current_user_can( 'read_post', $post_id ) ) {
				wp_send_json_error( [ 'message' => __( 'Not found.', 'jalpi-page-builder' ) ], 404 );
			}
		}

		$node = $this->find_node( $post_id, $node_id );
		if ( null === $node ) {
			wp_send_json_error( [ 'message' => __( 'Widget not found.', 'jalpi-page-builder' ) ], 404 );
		}

		$content  = (array) ( $node['content'] ?? [] );
		$settings = (array) ( $node['settings'] ?? [] );

		// Recompute the page size exactly as the element does, so page N here
		// lines up with page 1 rendered server-side.
		$cols  = max( 1, min( 6, (int) ( $settings['pg_cols_desktop'] ?? 4 ) ) );
		$rows  = max( 1, min( 20, (int) ( $content['rows'] ?? 1 ) ) );
		$limit = ! empty( $content['useRows'] )
			? ( $cols * $rows )
			: max( 1, min( 100, (int) ( $content['perPage'] ?? 8 ) ) );

		$args     = Jalpi_Woo_Query::build_args( $content, $limit, $page );
		$products = Jalpi_Woo_Query::run( $args );

		// Reuse the element's own card renderer so AJAX pages are identical to
		// the server-rendered first page. setup_postdata is handled per-product
		// by WooCommerce template tags where needed.
		$element  = new Jalpi_Element_Products_Grid();
		$renderer = Jalpi_Renderer::instance();

		$html = '';
		foreach ( $products as $product ) {
			$html .= $this->render_card_via_element( $element, $product, $content, $settings, $renderer );
		}

		wp_send_json_success( [
			'html'  => $html,
			'page'  => $page,
			'pages' => Jalpi_Woo_Query::max_pages( $args ),
			'count' => count( $products ),
		] );
	}

	/**
	 * Bridge to the element's private card renderer.
	 *
	 * The card markup lives in the element class (single source of truth for
	 * the HTML). Rather than duplicate it here, call through a closure bound to
	 * the element instance — this keeps AJAX output and initial render in sync
	 * automatically when the card markup changes.
	 */
	private function render_card_via_element(
		Jalpi_Element_Products_Grid $element, $product,
		array $content, array $settings, Jalpi_Renderer $renderer
	): string {
		$opts = $this->card_opts( $content, $settings );

		$call = Closure::bind(
			function ( $product, $opts, $renderer ) {
				/** @var Jalpi_Element_Products_Grid $this */
				return $this->render_card( $product, $opts, $renderer );
			},
			$element,
			Jalpi_Element_Products_Grid::class
		);

		return (string) $call( $product, $opts, $renderer );
	}

	/** Rebuild the card options array the element passes to render_card(). */
	private function card_opts( array $content, array $settings ): array {
		$show = static fn( string $k, bool $d ): bool =>
			array_key_exists( $k, $content ) ? ! empty( $content[ $k ] ) : $d;

		$enum = static function ( string $k, array $allowed, string $d ) use ( $settings ): string {
			$v = (string) ( $settings[ $k ] ?? '' );
			return in_array( $v, $allowed, true ) ? $v : $d;
		};

		return [
			'el' => [
				'image'      => $show( 'showImage',      true ),
				'badge'      => $show( 'showSaleBadge',  true ),
				'title'      => $show( 'showTitle',      true ),
				'rating'     => $show( 'showRating',     true ),
				'price'      => $show( 'showPrice',      true ),
				'excerpt'    => $show( 'showExcerpt',    false ),
				'cart'       => $show( 'showAddToCart',  true ),
				'wishlist'   => $show( 'showWishlist',   false ),
				'compare'    => $show( 'showCompare',    false ),
				'quickview'  => $show( 'showQuickView',  false ),
				'categories' => $show( 'showCategories', false ),
				'sku'        => $show( 'showSku',        false ),
			],
			'skin'      => in_array( (string) ( $content['skin'] ?? '' ), [ 'classic', 'cards', 'minimal' ], true )
				? (string) $content['skin'] : 'classic',
			'img_size'  => sanitize_key( (string) ( $settings['pg_img_size'] ?? '' ) ) ?: 'woocommerce_thumbnail',
			'img_hover' => $enum( 'pg_img_hover', [ 'none', 'zoom', 'zoom-out', 'grayscale', 'blur', 'lift' ], 'zoom' ),
			'secondary' => ! empty( $content['secondaryImage'] ),
			'lazy'      => $show( 'lazyLoad', true ),
			'btn_text'  => sanitize_text_field( (string) ( $content['buttonText'] ?? '' ) ),
			'btn_icon'  => $enum( 'pg_btn_icon', [ 'none', 'cart', 'bag', 'plus', 'arrow' ], 'none' ),
			'btn_ipos'  => $enum( 'pg_btn_icon_pos', [ 'before', 'after' ], 'before' ),
			'show_qty'  => ! empty( $content['showQuantity'] ),
			'ajax_cart' => $show( 'ajaxAddToCart', true ),
		];
	}

	/** Add a product to the cart and return the updated fragment counts. */
	public function handle_cart(): void {
		check_ajax_referer( self::NONCE, 'nonce' );

		if ( ! Jalpi_Woo_Query::woo_active() ) {
			wp_send_json_error( [ 'message' => __( 'WooCommerce is not active.', 'jalpi-page-builder' ) ], 400 );
		}

		$product_id = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
		$quantity   = isset( $_POST['quantity'] )   ? absint( wp_unslash( $_POST['quantity'] ) )   : 1;
		$quantity   = max( 1, min( 999, $quantity ) );

		$product = $product_id ? wc_get_product( $product_id ) : null;
		if ( ! $product || ! $product->is_purchasable() || ! $product->is_in_stock() ) {
			wp_send_json_error( [ 'message' => __( 'This product cannot be purchased.', 'jalpi-page-builder' ) ], 400 );
		}

		// Let WooCommerce own validation (stock, limits, extensions).
		// This is WooCommerce's OWN filter, deliberately applied unprefixed so
		// WooCommerce core and its extensions can veto the add-to-cart exactly
		// as they do on a native product page. A jalpi_-prefixed name would be
		// a different hook that nothing listens to.
		// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- third-party (WooCommerce) hook, intentionally not prefixed
		$passed = apply_filters( 'woocommerce_add_to_cart_validation', true, $product_id, $quantity );
		if ( ! $passed ) {
			wp_send_json_error( [ 'message' => __( 'Could not add to cart.', 'jalpi-page-builder' ) ], 400 );
		}

		$key = WC()->cart ? WC()->cart->add_to_cart( $product_id, $quantity ) : false;
		if ( ! $key ) {
			wp_send_json_error( [ 'message' => __( 'Could not add to cart.', 'jalpi-page-builder' ) ], 400 );
		}

		WC_AJAX::get_refreshed_fragments();
	}

	// ── Node lookup ──────────────────────────────────────────────────────────

	/**
	 * Locate a products-grid node by id within a page's saved tree.
	 *
	 * @return array|null The node, or null when absent / of the wrong type.
	 */
	private function find_node( int $post_id, string $node_id ): ?array {
		$data = Jalpi_API::instance()->load_page_data( $post_id );
		$node = $this->search( (array) ( $data['sections'] ?? [] ), $node_id );

		// Type check matters: without it a caller could point at any node id.
		if ( is_array( $node ) && 'products-grid' === ( $node['type'] ?? '' ) ) {
			return $node;
		}
		return null;
	}

	/** Depth-first search over the node tree. */
	private function search( array $nodes, string $node_id ): ?array {
		foreach ( $nodes as $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			if ( sanitize_key( (string) ( $node['id'] ?? '' ) ) === $node_id ) {
				return $node;
			}
			$children = $node['children'] ?? [];
			if ( is_array( $children ) && $children ) {
				$found = $this->search( $children, $node_id );
				if ( null !== $found ) {
					return $found;
				}
			}
		}
		return null;
	}
}
