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

/**
 * Query builder for the Products Grid widget.
 *
 * Extracted from the element so the renderer and the AJAX pagination handler
 * build the SAME args from the same content array — otherwise page 2 could
 * silently return a different result set than page 1.
 *
 * Every source resolves to a WC_Query/WP_Query args array. When WooCommerce is
 * inactive, run() returns Jalpi_Woo_Placeholder objects so the editor and the
 * frontend still show a sensible grid instead of an error.
 */
class Jalpi_Woo_Query {

	/** Sources that depend on a "current product" context. */
	private const CONTEXTUAL = [ 'related', 'upsells', 'cross-sells' ];

	/**
	 * Build WP_Query args for a Products Grid node's content.
	 *
	 * @param array $content Sanitized node content.
	 * @param int   $limit   Products per page.
	 * @param int   $page    1-based page number.
	 */
	public static function build_args( array $content, int $limit, int $page = 1 ): array {
		$source = self::source( $content );
		$limit  = max( 1, min( 100, $limit ) );
		$page   = max( 1, $page );

		$args = [
			'post_type'           => 'product',
			'post_status'         => 'publish',
			'posts_per_page'      => $limit,
			'paged'               => $page,
			'ignore_sticky_posts' => ! empty( $content['ignoreSticky'] ),
			// Products are rendered from WC_Product objects; skipping term/meta
			// priming here would only force per-product queries later, so both
			// caches stay on deliberately.
			'no_found_rows'       => false,
		];

		$offset = (int) ( $content['offset'] ?? 0 );
		if ( $offset > 0 ) {
			// WP ignores `offset` when `paged` is set, so fold the page into it.
			$args['offset'] = $offset + ( ( $page - 1 ) * $limit );
			unset( $args['paged'] );
		}

		$tax_query  = [];
		$meta_query = [];

		// ── Source ───────────────────────────────────────────────────────────
		switch ( $source ) {
			case 'featured':
				$tax_query[] = [
					'taxonomy' => 'product_visibility',
					'field'    => 'name',
					'terms'    => 'featured',
					'operator' => 'IN',
				];
				break;

			case 'sale':
				$on_sale = function_exists( 'wc_get_product_ids_on_sale' ) ? wc_get_product_ids_on_sale() : [];
				// An empty post__in matches everything, so force a no-match sentinel.
				$args['post__in'] = $on_sale ?: [ 0 ];
				break;

			case 'best-selling':
				$args['meta_key'] = 'total_sales'; // phpcs:ignore WordPress.DB.SlowDBQuery
				$args['orderby']  = 'meta_value_num';
				$args['order']    = 'DESC';
				break;

			case 'top-rated':
				$args['meta_key'] = '_wc_average_rating'; // phpcs:ignore WordPress.DB.SlowDBQuery
				$args['orderby']  = 'meta_value_num';
				$args['order']    = 'DESC';
				break;

			case 'manual':
				$ids = self::ids( $content['includeProducts'] ?? '' );
				$args['post__in'] = $ids ?: [ 0 ];
				$args['orderby']  = 'post__in';
				break;

			case 'related':
			case 'upsells':
			case 'cross-sells':
				$ids = self::contextual_ids( $source );
				$args['post__in'] = $ids ?: [ 0 ];
				$args['orderby']  = 'post__in';
				break;

			case 'latest':
			default:
				// Plain recency — orderby applied below.
				break;
		}

		// ── Taxonomy filters ─────────────────────────────────────────────────
		foreach ( [
			'categories' => 'product_cat',
			'tags'       => 'product_tag',
			'brands'     => 'product_brand',
		] as $key => $taxonomy ) {
			$terms = self::slugs( $content[ $key ] ?? '' );
			if ( ! $terms || ! taxonomy_exists( $taxonomy ) ) {
				continue;
			}
			$tax_query[] = [
				'taxonomy' => $taxonomy,
				'field'    => is_numeric( $terms[0] ) ? 'term_id' : 'slug',
				'terms'    => $terms,
				'operator' => 'IN',
			];
		}

		// ── Include / exclude by ID ──────────────────────────────────────────
		// A manual/contextual source already owns post__in; intersect instead of
		// overwriting so both filters apply.
		$include = self::ids( $content['includeIds'] ?? '' );
		if ( $include ) {
			$args['post__in'] = isset( $args['post__in'] )
				? ( array_values( array_intersect( $args['post__in'], $include ) ) ?: [ 0 ] )
				: $include;
		}

		$exclude = array_merge(
			self::ids( $content['excludeIds'] ?? '' ),
			self::ids( $content['excludeProducts'] ?? '' )
		);
		if ( $exclude ) {
			// Only set when the user has explicitly listed products to exclude
			// in the widget, so the common query path never carries it. There
			// is no alternative expression for "omit these specific IDs".
			// phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_post__not_in -- opt-in widget setting, empty by default
			$args['post__not_in'] = array_values( array_unique( $exclude ) );
		}

		// ── Stock status ─────────────────────────────────────────────────────
		$stock = (string) ( $content['stockStatus'] ?? 'any' );
		if ( in_array( $stock, [ 'instock', 'outofstock', 'onbackorder' ], true ) ) {
			$meta_query[] = [
				'key'     => '_stock_status',
				'value'   => $stock,
				'compare' => '=',
			];
		}

		// ── Hidden-product visibility ────────────────────────────────────────
		// Respect the catalog visibility setting the same way WC core shop does.
		if ( taxonomy_exists( 'product_visibility' ) ) {
			$tax_query[] = [
				'taxonomy' => 'product_visibility',
				'field'    => 'name',
				'terms'    => [ 'exclude-from-catalog' ],
				'operator' => 'NOT IN',
			];
		}

		if ( $tax_query ) {
			$tax_query['relation'] = 'AND';
			$args['tax_query']     = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery
		}
		if ( $meta_query ) {
			$args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery
		}

		// ── Order ────────────────────────────────────────────────────────────
		// best-selling/top-rated/manual set their own ordering above; only apply
		// the user's choice when it would not clobber a source-defined order.
		$fixed_order = in_array( $source, [ 'best-selling', 'top-rated', 'manual' ], true )
			|| in_array( $source, self::CONTEXTUAL, true );

		if ( ! $fixed_order ) {
			$orderby = (string) ( $content['orderBy'] ?? 'date' );
			$order   = strtoupper( (string) ( $content['order'] ?? 'DESC' ) );
			$args['order'] = 'ASC' === $order ? 'ASC' : 'DESC';

			switch ( $orderby ) {
				case 'price':
					$args['meta_key'] = '_price'; // phpcs:ignore WordPress.DB.SlowDBQuery
					$args['orderby']  = 'meta_value_num';
					break;
				case 'popularity':
					$args['meta_key'] = 'total_sales'; // phpcs:ignore WordPress.DB.SlowDBQuery
					$args['orderby']  = 'meta_value_num';
					break;
				case 'rating':
					$args['meta_key'] = '_wc_average_rating'; // phpcs:ignore WordPress.DB.SlowDBQuery
					$args['orderby']  = 'meta_value_num';
					break;
				case 'menu_order':
					$args['orderby'] = 'menu_order title';
					break;
				case 'rand':
					$args['orderby'] = 'rand';
					break;
				case 'title':
					$args['orderby'] = 'title';
					break;
				case 'modified':
					$args['orderby'] = 'modified';
					break;
				case 'date':
				default:
					$args['orderby'] = 'date';
					break;
			}
		}

		/**
		 * Filter the Products Grid query args.
		 *
		 * @param array $args    WP_Query args.
		 * @param array $content The widget's content array.
		 */
		return apply_filters( 'jalpi_products_grid_query_args', $args, $content );
	}

	/**
	 * Run the query and return product objects.
	 *
	 * @return array<int,WC_Product|Jalpi_Woo_Placeholder>
	 */
	public static function run( array $args ): array {
		if ( ! self::woo_active() ) {
			return Jalpi_Woo_Placeholder::make( (int) ( $args['posts_per_page'] ?? 4 ) );
		}

		$query    = new WP_Query( $args );
		$products = [];

		foreach ( $query->posts as $post ) {
			$product = wc_get_product( $post );
			// wc_get_product() returns false for corrupt/unsupported rows.
			if ( $product && $product->is_visible() ) {
				$products[] = $product;
			}
		}

		wp_reset_postdata();
		return $products;
	}

	/** Total pages for the given args (1 when unpaginated or Woo is inactive). */
	public static function max_pages( array $args ): int {
		if ( ! self::woo_active() ) {
			return 1;
		}
		$probe = $args;
		$probe['fields']         = 'ids';
		$probe['no_found_rows']  = false;
		$probe['posts_per_page'] = (int) ( $args['posts_per_page'] ?? 8 );

		$query = new WP_Query( $probe );
		$max   = (int) $query->max_num_pages;
		wp_reset_postdata();

		return max( 1, $max );
	}

	public static function woo_active(): bool {
		return class_exists( 'WooCommerce' ) && function_exists( 'wc_get_product' );
	}

	// ── Helpers ──────────────────────────────────────────────────────────────

	private static function source( array $content ): string {
		$allowed = [
			'latest', 'featured', 'sale', 'best-selling', 'top-rated',
			'related', 'upsells', 'cross-sells', 'manual',
		];
		$source = (string) ( $content['source'] ?? 'latest' );
		return in_array( $source, $allowed, true ) ? $source : 'latest';
	}

	/** Comma/space-separated list → positive int IDs. */
	private static function ids( $raw ): array {
		if ( is_array( $raw ) ) {
			$parts = $raw;
		} else {
			$parts = preg_split( '/[\s,]+/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY ) ?: [];
		}
		$ids = array_map( 'absint', $parts );
		return array_values( array_filter( $ids ) );
	}

	/** Comma-separated list → term slugs or numeric IDs. */
	private static function slugs( $raw ): array {
		if ( is_array( $raw ) ) {
			$parts = $raw;
		} else {
			$parts = preg_split( '/\s*,\s*/', (string) $raw, -1, PREG_SPLIT_NO_EMPTY ) ?: [];
		}
		$out = [];
		foreach ( $parts as $part ) {
			$part = trim( (string) $part );
			if ( '' === $part ) {
				continue;
			}
			$out[] = is_numeric( $part ) ? (string) absint( $part ) : sanitize_title( $part );
		}
		return array_values( array_filter( $out ) );
	}

	/**
	 * Related / upsell / cross-sell IDs for the product currently in context.
	 * Returns [] off a single-product view, which renders the empty state.
	 */
	private static function contextual_ids( string $source ): array {
		if ( ! self::woo_active() ) {
			return [];
		}
		$product = wc_get_product( get_the_ID() );
		if ( ! $product instanceof WC_Product ) {
			return [];
		}

		switch ( $source ) {
			case 'upsells':
				return array_map( 'absint', $product->get_upsell_ids() );
			case 'cross-sells':
				return array_map( 'absint', $product->get_cross_sell_ids() );
			case 'related':
				return function_exists( 'wc_get_related_products' )
					? array_map( 'absint', wc_get_related_products( $product->get_id(), 12 ) )
					: [];
		}
		return [];
	}
}

/**
 * Stand-in product used when WooCommerce is not active.
 *
 * Implements only the read methods Jalpi_Element_Products_Grid calls, so the
 * card renderer needs no WooCommerce-specific branching beyond the couple of
 * instanceof checks for genuinely Woo-only features (gallery, AJAX cart).
 */
class Jalpi_Woo_Placeholder {

	private int    $index;
	private string $name;
	private string $price;
	private bool   $on_sale;

	public function __construct( int $index, string $name, string $price, bool $on_sale ) {
		$this->index   = $index;
		$this->name    = $name;
		$this->price   = $price;
		$this->on_sale = $on_sale;
	}

	/** @return array<int,self> */
	public static function make( int $count ): array {
		$count = max( 1, min( 24, $count ) );
		$names = [
			'Classic Product', 'Premium Item', 'Everyday Essential', 'Limited Edition',
			'Signature Piece', 'Studio Collection', 'Traveler Set', 'Weekend Bundle',
		];
		$prices = [ '29.00', '49.00', '19.50', '89.00', '35.00', '64.00', '24.00', '120.00' ];

		$out = [];
		for ( $i = 0; $i < $count; $i++ ) {
			$out[] = new self(
				$i + 1,
				$names[ $i % count( $names ) ],
				$prices[ $i % count( $prices ) ],
				0 === $i % 3
			);
		}
		return $out;
	}

	public function get_id(): int             { return 0; }
	public function get_name(): string        { return $this->name; }
	public function get_permalink(): string   { return '#'; }
	public function get_sku(): string         { return 'DEMO-' . str_pad( (string) $this->index, 3, '0', STR_PAD_LEFT ); }
	public function is_on_sale(): bool        { return $this->on_sale; }
	public function get_average_rating(): float { return 4.5; }
	public function get_review_count(): int   { return 12; }
	public function get_category_names(): array { return [ __( 'Sample', 'jalpi-page-builder' ) ]; }
	public function get_short_description(): string {
		return __( 'A short sample description shown while WooCommerce is unavailable.', 'jalpi-page-builder' );
	}
	public function add_to_cart_text(): string { return __( 'Add to cart', 'jalpi-page-builder' ); }
	public function add_to_cart_url(): string  { return '#'; }
	public function is_purchasable(): bool     { return false; }
	public function is_in_stock(): bool        { return true; }
	public function is_sold_individually(): bool { return false; }
	public function get_type(): string         { return 'simple'; }
	public function is_visible(): bool         { return true; }
	public function get_gallery_image_ids(): array { return []; }

	public function get_price_html(): string {
		$symbol = function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol() : '$';
		if ( $this->on_sale ) {
			$was = number_format( (float) $this->price * 1.3, 2 );
			return '<del>' . esc_html( $symbol . $was ) . '</del> <ins>' . esc_html( $symbol . $this->price ) . '</ins>';
		}
		return esc_html( $symbol . $this->price );
	}

	/** Inline SVG placeholder — no HTTP request, no missing-asset flash. */
	public function get_image_url(): string {
		$hues = [ '#eef2f7', '#f3eef7', '#eef7f2', '#f7f3ee' ];
		$hue  = $hues[ $this->index % count( $hues ) ];
		$svg  = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400">'
			. '<rect width="400" height="400" fill="' . $hue . '"/>'
			. '<path d="M150 250h100l-20-70h-60z" fill="#c3ccd8"/>'
			. '<circle cx="200" cy="160" r="34" fill="#c3ccd8"/></svg>';
		return 'data:image/svg+xml;base64,' . base64_encode( $svg ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions
	}
}
