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

/**
 * WooCommerce Products Grid.
 *
 * A bespoke element (not schema-driven) because it needs a real WC_Query,
 * per-breakpoint column settings, AJAX pagination and a placeholder mode for
 * when WooCommerce is inactive — none of which the generic schema path models.
 *
 * Follows the standard element contract: ONE root wrapper, scoped
 * `.jalpi-el.jalpi-{id}` CSS pushed through the renderer, every dynamic value
 * escaped, and free-form CSS run through Jalpi_Sanitizer::sanitize_css_value().
 *
 * Query building lives in Jalpi_Woo_Query so the REST/AJAX pagination handler
 * and this renderer produce byte-identical result sets.
 */
class Jalpi_Element_Products_Grid extends Jalpi_Element {

	public static function type(): string { return 'products-grid'; }

	/**
	 * Strict validator for free-text CSS values (padding, margin, box-shadow).
	 *
	 * Jalpi_Sanitizer::sanitize_css_value() strips script vectors (javascript:,
	 * expression(), @import) but permits braces and semicolons, so a value like
	 *   "0 2px 4px #000; } body{display:none} /*"
	 * would terminate its own declaration block and inject page-wide rules.
	 * Every value this element interpolates into a stylesheet goes through here
	 * instead: anything containing a brace, semicolon, at-rule, comment marker
	 * or backslash escape is rejected outright in favour of the default.
	 *
	 * @param string $val Raw author value.
	 * @param string $default Returned when $val is empty or rejected.
	 */
	private static function css_value( string $val, string $default = '' ): string {
		$val = Jalpi_Sanitizer::sanitize_css_value( $val );
		if ( '' === $val ) {
			return $default;
		}
		// Structural characters that could escape the declaration.
		if ( preg_match( '/[{};@\\\\<>]/', $val ) ) {
			return $default;
		}
		// CSS comment markers (a /* can swallow the rest of the rule).
		if ( false !== strpos( $val, '/*' ) || false !== strpos( $val, '*/' ) ) {
			return $default;
		}
		// Allow-list: lengths, keywords, hex/rgb(a)/hsl(a) colors, var(), calc()
		// and the spaces/commas/parens that join them. Anything else is dropped.
		if ( ! preg_match( '/^[A-Za-z0-9_\-\s.,%#()\/]+$/', $val ) ) {
			return $default;
		}
		return $val;
	}

	public function render( array $node, Jalpi_Renderer $renderer ): string {
		$id       = sanitize_html_class( $node['id'] ?? '' );
		$content  = (array) ( $node['content']  ?? [] );
		$styles   = (array) ( $node['styles']   ?? [] );
		$settings = (array) ( $node['settings'] ?? [] );
		$a        = $renderer->get_css_attrs( $node );
		$sel      = '.jalpi-el.jalpi-' . $id;

		// ── Local helpers ────────────────────────────────────────────────────
		$raw = static fn( string $k, string $d = '' ): string =>
			isset( $settings[ $k ] ) && '' !== $settings[ $k ] ? (string) $settings[ $k ] : $d;

		// Bare number → px; already-united values pass through untouched.
		$px = static function ( $v, string $d = '' ): string {
			$v = (string) $v;
			if ( '' === $v ) return $d;
			if ( preg_match( '/^-?[\d.]+$/', $v ) ) return '0' === $v ? '0' : $v . 'px';
			return self::css_value( $v, $d );
		};
		$enum = static fn( string $k, array $allowed, string $d ): string =>
			in_array( $raw( $k, $d ), $allowed, true ) ? $raw( $k, $d ) : $d;

		// ── Layout ───────────────────────────────────────────────────────────
		$skin    = (string) ( $content['skin'] ?? 'classic' );
		$skin    = in_array( $skin, [ 'classic', 'cards', 'minimal' ], true ) ? $skin : 'classic';
		$cols_d  = max( 1, min( 6, (int) $raw( 'pg_cols_desktop', '4' ) ) );
		$cols_t  = max( 1, min( 6, (int) $raw( 'pg_cols_tablet',  '2' ) ) );
		$cols_m  = max( 1, min( 6, (int) $raw( 'pg_cols_mobile',  '1' ) ) );
		$col_gap = $px( $raw( 'pg_col_gap', '24' ), '24px' );
		$row_gap = $px( $raw( 'pg_row_gap', '32' ), '32px' );
		$masonry = ! empty( $content['masonry'] );
		$equal_h = ! empty( $content['equalHeight'] );

		// ── Which product elements are visible ───────────────────────────────
		// Defaults mirror Elementor: image/title/price/cart on, extras off.
		$show = static fn( string $k, bool $d ): bool =>
			array_key_exists( $k, $content ) ? ! empty( $content[ $k ] ) : $d;

		$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 ),
		];

		// ── Image / button / pagination settings ─────────────────────────────
		$img_size  = sanitize_key( $raw( 'pg_img_size', 'woocommerce_thumbnail' ) ) ?: 'woocommerce_thumbnail';
		$img_ratio = $raw( 'pg_img_ratio', '1/1' );
		$img_ratio = ( preg_match( '#^\d+(\.\d+)?/\d+(\.\d+)?$#', $img_ratio ) || 'auto' === $img_ratio ) ? $img_ratio : '1/1';
		$img_fit   = $enum( 'pg_img_fit', [ 'cover', 'contain', 'fill', 'none', 'scale-down' ], 'cover' );
		$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 );

		$pagination = (string) ( $content['pagination'] ?? 'none' );
		$pagination = in_array( $pagination,
			[ 'none', 'numbers', 'prev-next', 'load-more', 'infinite' ], true ) ? $pagination : 'none';

		// ── Build + run the query ────────────────────────────────────────────
		$per_page = max( 1, min( 100, (int) ( $content['perPage'] ?? 8 ) ) );
		$rows     = max( 1, min( 20,  (int) ( $content['rows']    ?? 1 ) ) );
		// Elementor semantics: when Rows is set, the grid shows cols × rows.
		$limit    = ! empty( $content['useRows'] ) ? ( $cols_d * $rows ) : $per_page;

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

		// ── CSS ──────────────────────────────────────────────────────────────
		$this->emit_css( $renderer, $sel, $node, [
			'cols_d' => $cols_d, 'cols_t' => $cols_t, 'cols_m' => $cols_m,
			'col_gap' => $col_gap, 'row_gap' => $row_gap,
			'masonry' => $masonry, 'equal_h' => $equal_h, 'skin' => $skin,
			'img_ratio' => $img_ratio, 'img_fit' => $img_fit, 'img_hover' => $img_hover,
		], $raw, $px, $enum );

		$renderer->add_css( $sel . '{box-sizing:border-box;'
			. $renderer->bg_data_to_css( (array) ( $settings['bgNormal'] ?? [] ) )
			. $renderer->border_data_to_css( (array) ( $settings['borderNormal'] ?? [] ) )
			. $renderer->styles_to_css( $styles ) . '}' );

		$hover_css = $renderer->bg_data_to_css( (array) ( $settings['bgHover'] ?? [] ) )
			. $renderer->border_data_to_css( (array) ( $settings['borderHover'] ?? [] ) );
		if ( $hover_css ) {
			$renderer->add_css( $sel . ':hover{' . $hover_css . '}' );
		}

		// The frontend script drives AJAX pagination, quick view and add-to-cart.
		if ( 'none' !== $pagination || $ajax_cart || $el['quickview'] ) {
			$renderer->set_needs_js( 'generic' );
		}

		// ── Markup ───────────────────────────────────────────────────────────
		$card_opts = compact( 'el', 'skin', 'img_size', 'img_hover', 'secondary', 'lazy',
			'btn_text', 'btn_icon', 'btn_ipos', 'show_qty', 'ajax_cart' );

		$items = '';
		foreach ( $products as $product ) {
			$items .= $this->render_card( $product, $card_opts, $renderer );
		}

		if ( '' === $items ) {
			$items = $this->render_empty_state( $content );
		}

		// Config the frontend JS reads for AJAX paging — the node id lets the
		// handler rebuild the exact same query server-side.
		$config = [
			'node'  => $id,
			'post'  => (int) get_the_ID(),
			'mode'  => $pagination,
			'page'  => 1,
			'pages' => Jalpi_Woo_Query::max_pages( $args ),
			'ajax'  => (bool) $ajax_cart,
		];

		$classes = 'jalpi-el jalpi-' . esc_attr( $id ) . ' jalpi-pg jalpi-pg--' . esc_attr( $skin )
			. ( $masonry ? ' jalpi-pg--masonry' : '' )
			. ( $equal_h ? ' jalpi-pg--equal' : '' )
			. $a['extra_cls'];

		$out  = '<div class="' . $classes . '"' . $a['id_attr']
			. ' data-jalpi-pg="' . esc_attr( wp_json_encode( $config ) ) . '">';
		$out .= '<div class="jalpi-pg__grid" role="list">' . $items . '</div>';
		$out .= $this->render_pagination( $pagination, $config, $content );
		$out .= '</div>';

		return $out;
	}

	// ─────────────────────────────────────────────────────────────────────────
	// Card
	// ─────────────────────────────────────────────────────────────────────────

	/**
	 * Render one product card. $product is a WC_Product when WooCommerce is
	 * active, or a Jalpi_Woo_Placeholder stand-in when it is not — both expose
	 * the same read methods used here.
	 */
	private function render_card( $product, array $o, Jalpi_Renderer $renderer ): string {
		$el   = $o['el'];
		$pid  = (int) $product->get_id();
		$link = $product->get_permalink();
		$name = $product->get_name();

		$out = '<div class="jalpi-pg__item" role="listitem" data-product-id="' . esc_attr( (string) $pid ) . '">';
		$out .= '<div class="jalpi-pg__card">';

		// ── Media ────────────────────────────────────────────────────────────
		if ( $el['image'] ) {
			$out .= '<div class="jalpi-pg__media jalpi-pg__media--' . esc_attr( $o['img_hover'] ) . '">';
			$out .= '<a class="jalpi-pg__imglink" href="' . esc_url( $link ) . '" tabindex="-1" aria-hidden="true">';
			$out .= $this->product_image( $product, $o );

			if ( $o['secondary'] ) {
				$second = $this->secondary_image( $product, $o );
				if ( $second ) {
					$out .= $second;
				}
			}
			$out .= '</a>';

			if ( $el['badge'] && $product->is_on_sale() ) {
				$out .= '<span class="jalpi-pg__badge">' . esc_html__( 'Sale', 'jalpi-page-builder' ) . '</span>';
			}

			// Floating actions — wishlist / compare / quick view.
			$actions = '';
			if ( $el['wishlist'] ) {
				$actions .= $this->action_button( 'wishlist', __( 'Add to wishlist', 'jalpi-page-builder' ), $pid, '♥' );
			}
			if ( $el['compare'] ) {
				$actions .= $this->action_button( 'compare', __( 'Compare', 'jalpi-page-builder' ), $pid, '⇄' );
			}
			if ( $el['quickview'] ) {
				$actions .= $this->action_button( 'quickview', __( 'Quick view', 'jalpi-page-builder' ), $pid, '⊕' );
			}
			if ( $actions ) {
				$out .= '<div class="jalpi-pg__actions">' . $actions . '</div>';
			}

			$out .= '</div>';
		}

		// ── Body ─────────────────────────────────────────────────────────────
		$out .= '<div class="jalpi-pg__body">';

		if ( $el['categories'] ) {
			$cats = $product->get_category_names();
			if ( $cats ) {
				$out .= '<div class="jalpi-pg__cats">' . esc_html( implode( ', ', $cats ) ) . '</div>';
			}
		}

		if ( $el['title'] ) {
			$out .= '<h3 class="jalpi-pg__title"><a href="' . esc_url( $link ) . '">'
				. esc_html( $name ) . '</a></h3>';
		}

		if ( $el['sku'] ) {
			$sku = $product->get_sku();
			if ( $sku ) {
				$out .= '<div class="jalpi-pg__sku">' . esc_html__( 'SKU:', 'jalpi-page-builder' )
					. ' ' . esc_html( $sku ) . '</div>';
			}
		}

		if ( $el['rating'] ) {
			$out .= $this->rating_html( $product );
		}

		if ( $el['price'] ) {
			$price = $product->get_price_html();
			if ( $price ) {
				// get_price_html() is WooCommerce-generated markup (<del>/<ins>/<bdi>
				// + currency spans), so it is filtered rather than escaped flat.
				$out .= '<div class="jalpi-pg__price">' . wp_kses_post( $price ) . '</div>';
			}
		}

		if ( $el['excerpt'] ) {
			$short = $product->get_short_description();
			if ( $short ) {
				$out .= '<div class="jalpi-pg__excerpt">' . wp_kses_post( wp_trim_words( $short, 18 ) ) . '</div>';
			}
		}

		if ( $el['cart'] ) {
			$out .= $this->cart_html( $product, $o );
		}

		$out .= '</div></div></div>';
		return $out;
	}

	private function action_button( string $kind, string $label, int $pid, string $glyph ): string {
		return '<button type="button" class="jalpi-pg__action jalpi-pg__action--' . esc_attr( $kind ) . '"'
			. ' data-action="' . esc_attr( $kind ) . '" data-product-id="' . esc_attr( (string) $pid ) . '"'
			. ' aria-label="' . esc_attr( $label ) . '"><span aria-hidden="true">' . esc_html( $glyph ) . '</span></button>';
	}

	private function product_image( $product, array $o ): string {
		$loading = $o['lazy'] ? 'lazy' : 'eager';

		if ( $product instanceof Jalpi_Woo_Placeholder ) {
			return '<img class="jalpi-pg__img" src="' . esc_url( $product->get_image_url() ) . '"'
				. ' alt="' . esc_attr( $product->get_name() ) . '" loading="' . esc_attr( $loading ) . '" />';
		}

		$html = $product->get_image( $o['img_size'], [
			'class'   => 'jalpi-pg__img',
			'loading' => $loading,
		] );
		return $html ?: '';
	}

	/** Second gallery image, revealed on hover. Empty string when none exists. */
	private function secondary_image( $product, array $o ): string {
		if ( $product instanceof Jalpi_Woo_Placeholder ) {
			return '';
		}
		$gallery = $product->get_gallery_image_ids();
		if ( empty( $gallery ) ) {
			return '';
		}
		$html = wp_get_attachment_image( (int) $gallery[0], $o['img_size'], false, [
			'class'   => 'jalpi-pg__img jalpi-pg__img--second',
			'loading' => 'lazy',
			'alt'     => '',
		] );
		return $html ?: '';
	}

	private function rating_html( $product ): string {
		$avg   = (float) $product->get_average_rating();
		$count = (int) $product->get_review_count();
		$pct   = max( 0, min( 100, ( $avg / 5 ) * 100 ) );

		$label = sprintf(
			/* translators: 1: average rating, 2: review count */
			__( 'Rated %1$s out of 5 based on %2$s reviews', 'jalpi-page-builder' ),
			number_format_i18n( $avg, 2 ),
			number_format_i18n( $count )
		);

		return '<div class="jalpi-pg__rating" role="img" aria-label="' . esc_attr( $label ) . '">'
			. '<span class="jalpi-pg__stars"><span class="jalpi-pg__stars-fill" style="width:'
			. esc_attr( (string) round( $pct, 2 ) ) . '%"></span></span>'
			. ( $count ? '<span class="jalpi-pg__rating-count">(' . esc_html( number_format_i18n( $count ) ) . ')</span>' : '' )
			. '</div>';
	}

	private function cart_html( $product, array $o ): string {
		$text = $o['btn_text'] ?: $product->add_to_cart_text();
		$icon = $this->button_icon( $o['btn_icon'] );

		$label = '<span class="jalpi-pg__btn-text">' . esc_html( $text ) . '</span>';
		$inner = 'after' === $o['btn_ipos'] ? $label . $icon : $icon . $label;

		$out = '<div class="jalpi-pg__cart">';

		// Quantity only makes sense for simple, purchasable, in-stock products.
		$simple_qty = $o['show_qty']
			&& ! ( $product instanceof Jalpi_Woo_Placeholder )
			&& $product->is_purchasable()
			&& $product->is_in_stock()
			&& ! $product->is_sold_individually()
			&& 'simple' === $product->get_type();

		if ( $simple_qty ) {
			$out .= '<input type="number" class="jalpi-pg__qty" value="1" min="1" step="1"'
				. ' aria-label="' . esc_attr__( 'Quantity', 'jalpi-page-builder' ) . '" />';
		}

		// Variable / external products must go to the product page, never AJAX.
		$ajax = $o['ajax_cart']
			&& ! ( $product instanceof Jalpi_Woo_Placeholder )
			&& $product->is_purchasable()
			&& $product->is_in_stock()
			&& 'simple' === $product->get_type();

		$href = $ajax ? '?add-to-cart=' . (int) $product->get_id() : $product->add_to_cart_url();

		$out .= '<a href="' . esc_url( $href ) . '" class="jalpi-pg__btn'
			. ( $ajax ? ' jalpi-pg__btn--ajax' : '' ) . '"'
			. ' data-product-id="' . esc_attr( (string) $product->get_id() ) . '"'
			. ' rel="nofollow">' . $inner . '</a>';

		$out .= '</div>';
		return $out;
	}

	private function button_icon( string $key ): string {
		if ( 'none' === $key ) {
			return '';
		}
		$glyphs = [ 'cart' => '🛒', 'bag' => '🛍', 'plus' => '＋', 'arrow' => '→' ];
		$glyph  = $glyphs[ $key ] ?? '';
		return $glyph ? '<span class="jalpi-pg__btn-icon" aria-hidden="true">' . esc_html( $glyph ) . '</span>' : '';
	}

	private function render_empty_state( array $content ): string {
		$msg  = sanitize_text_field( (string) ( $content['emptyMessage'] ?? '' ) )
			?: __( 'No products found.', 'jalpi-page-builder' );
		$icon = sanitize_text_field( (string) ( $content['emptyIcon'] ?? '' ) ) ?: '🛒';

		return '<div class="jalpi-pg__empty">'
			. '<div class="jalpi-pg__empty-icon" aria-hidden="true">' . esc_html( $icon ) . '</div>'
			. '<p class="jalpi-pg__empty-text">' . esc_html( $msg ) . '</p>'
			. '</div>';
	}

	private function render_pagination( string $mode, array $config, array $content ): string {
		if ( 'none' === $mode || $config['pages'] < 2 ) {
			return '';
		}

		$nav = '<nav class="jalpi-pg__pagination jalpi-pg__pagination--' . esc_attr( $mode ) . '"'
			. ' aria-label="' . esc_attr__( 'Products pagination', 'jalpi-page-builder' ) . '">';

		if ( 'numbers' === $mode || 'prev-next' === $mode ) {
			$nav .= '<button type="button" class="jalpi-pg__page jalpi-pg__page--prev" data-page="0" disabled>'
				. esc_html__( 'Previous', 'jalpi-page-builder' ) . '</button>';

			if ( 'numbers' === $mode ) {
				for ( $i = 1; $i <= min( $config['pages'], 12 ); $i++ ) {
					$nav .= '<button type="button" class="jalpi-pg__page jalpi-pg__page--num'
						. ( 1 === $i ? ' is-active' : '' ) . '" data-page="' . esc_attr( (string) $i ) . '"'
						. ( 1 === $i ? ' aria-current="page"' : '' ) . '>'
						. esc_html( number_format_i18n( $i ) ) . '</button>';
				}
			}

			$nav .= '<button type="button" class="jalpi-pg__page jalpi-pg__page--next" data-page="2">'
				. esc_html__( 'Next', 'jalpi-page-builder' ) . '</button>';
		} elseif ( 'load-more' === $mode ) {
			$label = sanitize_text_field( (string) ( $content['loadMoreText'] ?? '' ) )
				?: __( 'Load More', 'jalpi-page-builder' );
			$nav .= '<button type="button" class="jalpi-pg__loadmore" data-page="2">'
				. esc_html( $label ) . '</button>';
		} else { // infinite
			$nav .= '<div class="jalpi-pg__sentinel" aria-hidden="true"></div>';
		}

		$nav .= '</nav>';
		return $nav;
	}

	// ─────────────────────────────────────────────────────────────────────────
	// CSS
	// ─────────────────────────────────────────────────────────────────────────

	/**
	 * Emit every scoped rule for this instance. Kept in one place so the style
	 * controls map 1:1 onto selectors and stay auditable.
	 */
	private function emit_css(
		Jalpi_Renderer $renderer, string $sel, array $node,
		array $l, callable $raw, callable $px, callable $enum
	): void {
		$settings = (array) ( $node['settings'] ?? [] );
		$col      = static fn( string $k, string $d = '' ): string =>
			Jalpi_Sanitizer::sanitize_color( (string) ( $settings[ $k ] ?? '' ) ) ?: $d;

		// ── Grid ─────────────────────────────────────────────────────────────
		$gap = $l['row_gap'] . ' ' . $l['col_gap'];
		if ( $l['masonry'] ) {
			$renderer->add_css( $sel . ' .jalpi-pg__grid{columns:' . $l['cols_d']
				. ';column-gap:' . $l['col_gap'] . ';}' );
			$renderer->add_css( $sel . ' .jalpi-pg__item{break-inside:avoid;margin-bottom:' . $l['row_gap'] . ';}' );
			$renderer->add_tablet_css( $sel . ' .jalpi-pg__grid{columns:' . $l['cols_t'] . ';}' );
			$renderer->add_mobile_css( $sel . ' .jalpi-pg__grid{columns:' . $l['cols_m'] . ';}' );
		} else {
			$renderer->add_css( $sel . ' .jalpi-pg__grid{display:grid;grid-template-columns:repeat('
				. $l['cols_d'] . ',minmax(0,1fr));gap:' . $gap . ';}' );
			$renderer->add_tablet_css( $sel . ' .jalpi-pg__grid{grid-template-columns:repeat('
				. $l['cols_t'] . ',minmax(0,1fr));}' );
			$renderer->add_mobile_css( $sel . ' .jalpi-pg__grid{grid-template-columns:repeat('
				. $l['cols_m'] . ',minmax(0,1fr));}' );
		}

		// Equal height: card fills the track and the button pins to the bottom.
		if ( $l['equal_h'] && ! $l['masonry'] ) {
			$renderer->add_css( $sel . ' .jalpi-pg__item{display:flex;}' );
			$renderer->add_css( $sel . ' .jalpi-pg__card{display:flex;flex-direction:column;width:100%;}' );
			$renderer->add_css( $sel . ' .jalpi-pg__body{display:flex;flex-direction:column;flex:1 1 auto;}' );
			$renderer->add_css( $sel . ' .jalpi-pg__cart{margin-top:auto;}' );
		}

		// ── Card ─────────────────────────────────────────────────────────────
		$card_align  = $enum( 'pg_card_align', [ 'left', 'center', 'right' ], 'left' );
		$card_bg     = $col( 'pg_card_bg' );
		$card_pad    = $px( $raw( 'pg_card_pad', '' ) );
		$card_radius = $px( $raw( 'pg_card_radius', '12' ), '12px' );
		$card_bd_w   = $px( $raw( 'pg_card_bd_width', '' ) );
		$card_bd_c   = $col( 'pg_card_bd_color' );
		$card_bd_t   = $enum( 'pg_card_bd_type', [ 'none', 'solid', 'dashed', 'dotted', 'double' ], 'none' );
		$card_shadow = self::css_value( $raw( 'pg_card_shadow', '' ) );
		$transition  = max( 0, min( 3000, (int) $raw( 'pg_card_transition', '250' ) ) );

		$card  = 'position:relative;overflow:hidden;text-align:' . $card_align . ';';
		$card .= 'border-radius:' . $card_radius . ';';
		$card .= 'transition:box-shadow ' . $transition . 'ms ease,border-color ' . $transition
			. 'ms ease,transform ' . $transition . 'ms ease;';
		if ( $card_bg )              $card .= 'background:' . $card_bg . ';';
		if ( '' !== $card_pad )      $card .= 'padding:' . $card_pad . ';';
		if ( $card_shadow )          $card .= 'box-shadow:' . $card_shadow . ';';
		if ( 'none' !== $card_bd_t ) {
			$card .= 'border-style:' . $card_bd_t . ';';
			$card .= 'border-width:' . ( '' !== $card_bd_w ? $card_bd_w : '1px' ) . ';';
			$card .= 'border-color:' . ( $card_bd_c ?: '#e5e7eb' ) . ';';
		}
		$renderer->add_css( $sel . ' .jalpi-pg__card{' . $card . '}' );

		$hover_bd     = $col( 'pg_card_hover_bd_color' );
		$hover_shadow = self::css_value( $raw( 'pg_card_hover_shadow', '' ) );
		$hover_lift   = $px( $raw( 'pg_card_hover_lift', '' ) );
		$card_hover   = '';
		if ( $hover_bd )         $card_hover .= 'border-color:' . $hover_bd . ';';
		if ( $hover_shadow )     $card_hover .= 'box-shadow:' . $hover_shadow . ';';
		if ( '' !== $hover_lift ) $card_hover .= 'transform:translateY(-' . $hover_lift . ');';
		if ( $card_hover ) {
			$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__card{' . $card_hover . '}' );
		}

		// ── Image ────────────────────────────────────────────────────────────
		$img_radius = $px( $raw( 'pg_img_radius', '' ) );
		$img_w      = $px( $raw( 'pg_img_width', '' ) );
		$img_h      = $px( $raw( 'pg_img_height', '' ) );

		$media = 'position:relative;overflow:hidden;line-height:0;';
		if ( 'auto' !== $l['img_ratio'] ) $media .= 'aspect-ratio:' . $l['img_ratio'] . ';';
		if ( $img_radius !== '' )         $media .= 'border-radius:' . $img_radius . ';';
		if ( $img_w !== '' )              $media .= 'width:' . $img_w . ';';
		if ( $img_h !== '' )              $media .= 'height:' . $img_h . ';';
		$renderer->add_css( $sel . ' .jalpi-pg__media{' . $media . '}' );

		$renderer->add_css( $sel . ' .jalpi-pg__media img{width:100%;height:100%;display:block;object-fit:'
			. $l['img_fit'] . ';transition:transform 400ms ease,opacity 300ms ease,filter 300ms ease;}' );

		// Hover treatments.
		switch ( $l['img_hover'] ) {
			case 'zoom':
				$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__img{transform:scale(1.07);}' );
				break;
			case 'zoom-out':
				$renderer->add_css( $sel . ' .jalpi-pg__img{transform:scale(1.07);}' );
				$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__img{transform:scale(1);}' );
				break;
			case 'grayscale':
				$renderer->add_css( $sel . ' .jalpi-pg__img{filter:grayscale(1);}' );
				$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__img{filter:grayscale(0);}' );
				break;
			case 'blur':
				$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__img{filter:blur(3px);}' );
				break;
			case 'lift':
				$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__media{transform:translateY(-4px);}' );
				break;
		}

		// Secondary image cross-fade — stacked above the primary.
		$renderer->add_css( $sel . ' .jalpi-pg__img--second{position:absolute;inset:0;opacity:0;}' );
		$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__img--second{opacity:1;}' );

		// Overlay.
		$ovl = $col( 'pg_img_overlay_color' );
		if ( $ovl ) {
			$renderer->add_css( $sel . ' .jalpi-pg__media::after{content:"";position:absolute;inset:0;'
				. 'background:' . $ovl . ';opacity:0;transition:opacity 300ms ease;pointer-events:none;}' );
			$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__media::after{opacity:1;}' );
		}

		// ── Sale badge ───────────────────────────────────────────────────────
		$badge_pos = $enum( 'pg_badge_pos', [ 'top-left', 'top-right', 'bottom-left', 'bottom-right' ], 'top-left' );
		$vert      = str_starts_with( $badge_pos, 'top' ) ? 'top' : 'bottom';
		$horz      = str_ends_with( $badge_pos, 'left' ) ? 'left' : 'right';

		$badge  = 'position:absolute;' . $vert . ':10px;' . $horz . ':10px;z-index:2;pointer-events:none;';
		$badge .= 'background:' . ( $col( 'pg_badge_bg' ) ?: '#111827' ) . ';';
		$badge .= 'color:' . ( $col( 'pg_badge_color' ) ?: '#ffffff' ) . ';';
		$badge .= 'border-radius:' . $px( $raw( 'pg_badge_radius', '4' ), '4px' ) . ';';
		$badge .= 'padding:' . ( $px( $raw( 'pg_badge_pad', '' ) ) ?: '4px 10px' ) . ';';
		$badge .= 'font-size:' . $px( $raw( 'pg_badge_size', '12' ), '12px' ) . ';';
		$badge .= 'font-weight:' . ( (int) $raw( 'pg_badge_weight', '600' ) ?: 600 ) . ';';
		$badge .= 'line-height:1.4;';
		$renderer->add_css( $sel . ' .jalpi-pg__badge{' . $badge . '}' );

		// ── Title ────────────────────────────────────────────────────────────
		$title  = 'margin:' . ( $px( $raw( 'pg_title_margin', '' ) ) ?: '0 0 6px' ) . ';';
		$title .= 'font-size:' . $px( $raw( 'pg_title_size', '16' ), '16px' ) . ';';
		$title .= 'font-weight:' . ( (int) $raw( 'pg_title_weight', '600' ) ?: 600 ) . ';';
		$title .= 'line-height:1.4;';
		$renderer->add_css( $sel . ' .jalpi-pg__title{' . $title . '}' );
		$renderer->add_css( $sel . ' .jalpi-pg__title a{color:' . ( $col( 'pg_title_color' ) ?: 'inherit' )
			. ';text-decoration:none;transition:color 200ms ease;}' );
		if ( $col( 'pg_title_hover_color' ) ) {
			$renderer->add_css( $sel . ' .jalpi-pg__title a:hover{color:' . $col( 'pg_title_hover_color' ) . ';}' );
		}

		// ── Price ────────────────────────────────────────────────────────────
		$price  = 'margin-top:' . $px( $raw( 'pg_price_spacing', '6' ), '6px' ) . ';';
		$price .= 'font-size:' . $px( $raw( 'pg_price_size', '15' ), '15px' ) . ';';
		$price .= 'font-weight:' . ( (int) $raw( 'pg_price_weight', '600' ) ?: 600 ) . ';';
		$price .= 'color:' . ( $col( 'pg_price_color' ) ?: 'inherit' ) . ';';
		$renderer->add_css( $sel . ' .jalpi-pg__price{' . $price . '}' );
		// WooCommerce marks the struck-through original with <del> and the active
		// (sale) figure with <ins>.
		$renderer->add_css( $sel . ' .jalpi-pg__price del{opacity:.55;font-weight:400;margin-right:6px;}' );
		if ( $col( 'pg_sale_price_color' ) ) {
			$renderer->add_css( $sel . ' .jalpi-pg__price ins{color:' . $col( 'pg_sale_price_color' )
				. ';text-decoration:none;}' );
		}

		// ── Rating ───────────────────────────────────────────────────────────
		$star_size  = $px( $raw( 'pg_star_size', '15' ), '15px' );
		$star_fill  = $col( 'pg_star_color',  '#f59e0b' );
		$star_empty = $col( 'pg_star_empty_color', '#d1d5db' );

		$renderer->add_css( $sel . ' .jalpi-pg__rating{display:flex;align-items:center;gap:6px;margin-top:'
			. $px( $raw( 'pg_rating_spacing', '4' ), '4px' ) . ';font-size:' . $star_size . ';}' );
		// Two stacked star rows clipped by width — no icon font needed.
		$renderer->add_css( $sel . ' .jalpi-pg__stars{position:relative;display:inline-block;'
			. 'width:5.2em;height:1em;line-height:1;font-size:' . $star_size . ';}' );
		$renderer->add_css( $sel . ' .jalpi-pg__stars::before{content:"★★★★★";letter-spacing:.05em;color:'
			. $star_empty . ';}' );
		$renderer->add_css( $sel . ' .jalpi-pg__stars-fill{position:absolute;inset:0;overflow:hidden;'
			. 'white-space:nowrap;}' );
		$renderer->add_css( $sel . ' .jalpi-pg__stars-fill::before{content:"★★★★★";letter-spacing:.05em;color:'
			. $star_fill . ';}' );
		$renderer->add_css( $sel . ' .jalpi-pg__rating-count{font-size:.78em;opacity:.7;}' );

		// ── Button ───────────────────────────────────────────────────────────
		$btn  = 'display:inline-flex;align-items:center;justify-content:center;gap:8px;';
		$btn .= 'text-decoration:none;cursor:pointer;box-sizing:border-box;';
		$btn .= 'padding:' . ( $px( $raw( 'pg_btn_pad', '' ) ) ?: '10px 18px' ) . ';';
		$btn .= 'font-size:' . $px( $raw( 'pg_btn_size', '14' ), '14px' ) . ';';
		$btn .= 'font-weight:' . ( (int) $raw( 'pg_btn_weight', '600' ) ?: 600 ) . ';';
		$btn .= 'color:' . ( $col( 'pg_btn_color' ) ?: '#ffffff' ) . ';';
		$btn .= 'background:' . ( $col( 'pg_btn_bg' ) ?: '#111827' ) . ';';
		$btn .= 'border-radius:' . $px( $raw( 'pg_btn_radius', '8' ), '8px' ) . ';';
		$btn .= 'transition:background 200ms ease,color 200ms ease,border-color 200ms ease,transform 200ms ease;';

		$btn_bd_t = $enum( 'pg_btn_bd_type', [ 'none', 'solid', 'dashed', 'dotted' ], 'none' );
		if ( 'none' !== $btn_bd_t ) {
			$btn .= 'border-style:' . $btn_bd_t . ';';
			$btn .= 'border-width:' . ( $px( $raw( 'pg_btn_bd_width', '' ) ) ?: '1px' ) . ';';
			$btn .= 'border-color:' . ( $col( 'pg_btn_bd_color' ) ?: 'currentColor' ) . ';';
		} else {
			$btn .= 'border:none;';
		}
		$renderer->add_css( $sel . ' .jalpi-pg__btn{' . $btn . '}' );

		$renderer->add_css( $sel . ' .jalpi-pg__cart{display:flex;align-items:center;gap:8px;margin-top:'
			. $px( $raw( 'pg_btn_spacing', '12' ), '12px' ) . ';}' );
		$renderer->add_css( $sel . ' .jalpi-pg__qty{width:64px;padding:8px;border:1px solid #e5e7eb;'
			. 'border-radius:6px;box-sizing:border-box;}' );

		$btn_hover = '';
		if ( $col( 'pg_btn_hover_color' ) ) $btn_hover .= 'color:' . $col( 'pg_btn_hover_color' ) . ';';
		if ( $col( 'pg_btn_hover_bg' ) )    $btn_hover .= 'background:' . $col( 'pg_btn_hover_bg' ) . ';';
		if ( $col( 'pg_btn_hover_bd' ) )    $btn_hover .= 'border-color:' . $col( 'pg_btn_hover_bd' ) . ';';

		$anim = $enum( 'pg_btn_hover_anim', [ 'none', 'grow', 'shrink', 'float', 'sink' ], 'none' );
		$transforms = [ 'grow' => 'scale(1.05)', 'shrink' => 'scale(.95)',
			'float' => 'translateY(-3px)', 'sink' => 'translateY(3px)' ];
		if ( isset( $transforms[ $anim ] ) ) {
			$btn_hover .= 'transform:' . $transforms[ $anim ] . ';';
		}
		if ( $btn_hover ) {
			$renderer->add_css( $sel . ' .jalpi-pg__btn:hover,' . $sel . ' .jalpi-pg__btn:focus-visible{'
				. $btn_hover . '}' );
		}

		// ── Floating actions (wishlist / compare / quick view) ───────────────
		$act_size = $px( $raw( 'pg_action_size', '16' ), '16px' );
		$renderer->add_css( $sel . ' .jalpi-pg__actions{position:absolute;top:10px;right:10px;z-index:2;'
			. 'display:flex;flex-direction:column;gap:6px;opacity:0;transform:translateX(6px);'
			. 'transition:opacity 250ms ease,transform 250ms ease;}' );
		$renderer->add_css( $sel . ' .jalpi-pg__item:hover .jalpi-pg__actions,'
			. $sel . ' .jalpi-pg__actions:focus-within{opacity:1;transform:translateX(0);}' );
		$renderer->add_css( $sel . ' .jalpi-pg__action{display:inline-flex;align-items:center;'
			. 'justify-content:center;width:2em;height:2em;font-size:' . $act_size . ';line-height:1;'
			. 'cursor:pointer;border:none;border-radius:50%;background:'
			. ( $col( 'pg_action_bg' ) ?: '#ffffff' ) . ';color:' . ( $col( 'pg_action_color' ) ?: '#111827' )
			. ';box-shadow:0 1px 4px rgba(0,0,0,.15);transition:color 200ms ease,background 200ms ease;}' );
		if ( $col( 'pg_action_hover_color' ) ) {
			$renderer->add_css( $sel . ' .jalpi-pg__action:hover{color:'
				. $col( 'pg_action_hover_color' ) . ';}' );
		}

		// ── Secondary text ───────────────────────────────────────────────────
		$renderer->add_css( $sel . ' .jalpi-pg__cats,' . $sel . ' .jalpi-pg__sku{font-size:12px;'
			. 'opacity:.7;margin-bottom:4px;}' );
		$renderer->add_css( $sel . ' .jalpi-pg__excerpt{font-size:13px;opacity:.8;margin-top:6px;'
			. 'line-height:1.55;}' );
		$renderer->add_css( $sel . ' .jalpi-pg__body{padding:'
			. ( $px( $raw( 'pg_body_pad', '' ) ) ?: '14px 0 0' ) . ';}' );

		// ── Pagination ───────────────────────────────────────────────────────
		$renderer->add_css( $sel . ' .jalpi-pg__pagination{display:flex;flex-wrap:wrap;align-items:center;'
			. 'justify-content:center;gap:' . $px( $raw( 'pg_pag_spacing', '8' ), '8px' )
			. ';margin-top:' . $px( $raw( 'pg_pag_margin', '28' ), '28px' ) . ';}' );

		$pag  = 'cursor:pointer;background:transparent;font-size:'
			. $px( $raw( 'pg_pag_size', '14' ), '14px' ) . ';';
		$pag .= 'color:' . ( $col( 'pg_pag_color' ) ?: 'inherit' ) . ';';
		$pag .= 'padding:8px 14px;border-radius:' . $px( $raw( 'pg_pag_radius', '6' ), '6px' ) . ';';
		$pag .= 'border:1px solid ' . ( $col( 'pg_pag_bd_color' ) ?: '#e5e7eb' ) . ';';
		$pag .= 'transition:color 200ms ease,background 200ms ease,border-color 200ms ease;';
		$renderer->add_css( $sel . ' .jalpi-pg__page,' . $sel . ' .jalpi-pg__loadmore{' . $pag . '}' );
		$renderer->add_css( $sel . ' .jalpi-pg__page[disabled]{opacity:.45;cursor:default;}' );

		$active = $col( 'pg_pag_active_color', '#111827' );
		$renderer->add_css( $sel . ' .jalpi-pg__page.is-active{background:' . $active
			. ';border-color:' . $active . ';color:#fff;}' );
		if ( $col( 'pg_pag_hover_color' ) ) {
			$renderer->add_css( $sel . ' .jalpi-pg__page:not(.is-active):not([disabled]):hover,'
				. $sel . ' .jalpi-pg__loadmore:hover{color:' . $col( 'pg_pag_hover_color' ) . ';}' );
		}

		// ── Empty state ──────────────────────────────────────────────────────
		$renderer->add_css( $sel . ' .jalpi-pg__empty{grid-column:1/-1;text-align:center;padding:48px 16px;'
			. 'color:' . ( $col( 'pg_empty_color' ) ?: '#6b7280' ) . ';}' );
		$renderer->add_css( $sel . ' .jalpi-pg__empty-icon{font-size:34px;margin-bottom:10px;opacity:.7;}' );
		$renderer->add_css( $sel . ' .jalpi-pg__empty-text{margin:0;font-size:15px;}' );

		// Loading state toggled by the frontend script during AJAX paging.
		$renderer->add_css( $sel . '.is-loading .jalpi-pg__grid{opacity:.45;pointer-events:none;'
			. 'transition:opacity 200ms ease;}' );
	}
}
