<?php
/**
 * Product search class.
 *
 * Extends the WooCommerce REST products endpoint so the wishlist admin and
 * frontend "Add product" flows can match SKU and GTIN/Barcode identifiers in
 * addition to product titles.
 *
 * @package SaveToWishlist\Classes\Integrations
 * @since 1.0.6
 */
namespace SaveToWishlist\Classes\Integrations;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

use SaveToWishlist\Abstracts\Singleton;

/**
 * Class Product_Search
 *
 * Adds SKU and GTIN/Barcode matching to the WooCommerce REST product search
 * when the request opts in via the `stwlite_extended_search` query parameter.
 */
class Product_Search extends Singleton {

    /**
     * Default GTIN/Barcode meta keys to match against.
     *
     * Covers WooCommerce core (9.0+) plus the meta keys used by the most
     * common third-party GTIN/Barcode plugins. Extendable via the
     * `stwlite_product_search_gtin_meta_keys` filter.
     *
     * Exposed via {@see get_default_gtin_meta_keys()} so other classes
     * (e.g. Collections::get_collection_items) can share the same defaults
     * without duplicating the list.
     *
     * @since 1.0.6
     * @var string[]
     */
    private static $default_gtin_meta_keys = array(
        '_global_unique_id',
        '_wc_gtin',
        '_woocommerce_gtin',
        '_alg_ean',
        '_alg_wc_ean',
        '_barcode',
        'barcode',
    );

    /**
     * Maximum number of IDs returned per meta lookup.
     *
     * Caps both {@see get_ids_by_meta_like()} (GTIN/Barcode partial search) and
     * {@see get_ids_by_exact_meta()} (exact SKU). Results are silently truncated
     * with no `has_more` signal — refine the search term if the expected SKU
     * is missing from the results.
     *
     * @since 1.0.6
     * @var int
     */
    private $meta_lookup_limit = 100;

    /**
     * Run the integration.
     *
     * @since 1.0.6
     * @access public
     * @return void
     */
    public function run() {
        // `woocommerce_rest_product_object_query` fires on every WC REST product query, and the
        // `wc/v3/products` GET endpoint is publicly readable on most stores — the `stwlite_extended_search`
        // opt-in flag is therefore reachable by unauthenticated callers. The 100-row cap, controlled
        // meta-keys allowlist, and `post_type IN ( 'product', 'product_variation' )` filter keep the
        // worst case to a bounded postmeta scan; re-evaluate before adding more expensive lookups here.
        add_filter( 'woocommerce_rest_product_object_query', array( $this, 'extend_product_search' ), 10, 2 );
    }

    /**
     * Extend the WooCommerce REST product search to include SKU and GTIN/Barcode meta fields.
     *
     * Only runs when the request opts in via `stwlite_extended_search=1`, so other
     * consumers of the WooCommerce products endpoint remain unaffected.
     *
     * @since 1.0.6
     * @access public
     *
     * @param array            $args    WP_Query args prepared by the controller.
     * @param \WP_REST_Request $request Incoming REST request.
     *
     * @return array
     */
    public function extend_product_search( $args, $request ) {
        if ( ! $request instanceof \WP_REST_Request ) {
            return $args;
        }

        if ( empty( $request->get_param( 'stwlite_extended_search' ) ) ) {
            return $args;
        }

        $search = $request->get_param( 'search' );
        if ( ! is_string( $search ) ) {
            return $args;
        }

        $term = function_exists( 'wc_clean' ) ? wc_clean( wp_unslash( $search ) ) : sanitize_text_field( wp_unslash( $search ) );
        if ( '' === $term ) {
            return $args;
        }

        $title_sku_ids = $this->get_title_and_sku_ids( $term );
        $gtin_ids      = $this->get_ids_by_meta_like( $term, self::get_gtin_meta_keys() );
        $exact_sku_ids = $this->get_ids_by_exact_meta( $term, '_sku' );

        // Variation matches resolve to their parent product ID so the products
        // endpoint (post_type=product) doesn't silently drop them from results.
        $exact_sku_ids = $this->resolve_to_product_ids( $exact_sku_ids );
        $title_sku_ids = $this->resolve_to_product_ids( $title_sku_ids );
        $gtin_ids      = $this->resolve_to_product_ids( $gtin_ids );

        $combined = array_merge( $exact_sku_ids, $title_sku_ids, $gtin_ids );
        $combined = array_values( array_unique( array_filter( array_map( 'absint', $combined ) ) ) );

        // No matches: force an empty result instead of falling back to an unfiltered query.
        $args['post__in'] = empty( $combined ) ? array( 0 ) : $combined;

        // Drop the title-only search so the post__in list drives the result set.
        unset( $args['s'] );

        // Preserve exact SKU and SKU-prefixed matches at the top of the results.
        $args['orderby'] = 'post__in';

        return $args;
    }

    /**
     * Get product IDs matching the term by title or SKU.
     *
     * Uses the WooCommerce product data store, which natively searches
     * `post_title`, `post_excerpt`, `post_content`, and `wc_product_meta_lookup.sku`
     * (derived from `_sku` postmeta) — preserving the performance of the
     * existing title-based search.
     *
     * @since 1.0.6
     * @access private
     *
     * @param string $term Search term.
     *
     * @return int[]
     */
    private function get_title_and_sku_ids( $term ) {
        if ( ! class_exists( '\WC_Data_Store' ) ) {
            return array();
        }

        try {
            $data_store = \WC_Data_Store::load( 'product' );
        } catch ( \Exception $e ) {
            return array();
        }

        // `WC_Data_Store::load()` returns the wrapper class, which exposes
        // `search_products()` via `__call` — invisible to `method_exists()`.
        // Use `is_callable()` so the data-store path actually runs.
        if ( ! is_object( $data_store ) || ! is_callable( array( $data_store, 'search_products' ) ) ) {
            return array();
        }

        // Include variations so SKU/GTIN stored on variations (the common case
        // for variable products) is matched. WC returns each match's ID plus
        // its parent ID; we map variations to parents downstream.
        $ids = $data_store->search_products( $term, '', true, false );

        return array_map( 'absint', (array) $ids );
    }

    /**
     * Get product IDs matching the term against a list of meta keys (LIKE).
     *
     * @since 1.0.6
     * @access private
     *
     * @param string   $term      Search term.
     * @param string[] $meta_keys Meta keys to match against.
     *
     * @return int[]
     */
    private function get_ids_by_meta_like( $term, array $meta_keys ) {
        global $wpdb;

        if ( empty( $meta_keys ) ) {
            return array();
        }

        $meta_keys = array_values( array_unique( array_filter( array_map( 'strval', $meta_keys ) ) ) );
        $like      = '%' . $wpdb->esc_like( $term ) . '%';

        // Meta keys come from a controlled default list (extendable via filter); escape each
        // value with esc_sql() so the IN() clause can be safely inlined while keeping the
        // dynamic LIKE / post_type / LIMIT values bound through $wpdb->prepare().
        $escaped_keys = array_map(
            static function ( $key ) {
                return "'" . esc_sql( $key ) . "'";
            },
            $meta_keys
        );
        $keys_sql     = implode( ',', $escaped_keys );

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $sql = $wpdb->prepare(
            "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm
             INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN ({$keys_sql})
               AND pm.meta_value LIKE %s
               AND p.post_type IN ( 'product', 'product_variation' )
             LIMIT %d",
            $like,
            $this->meta_lookup_limit
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $ids = $wpdb->get_col( $sql ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared

        return array_map( 'absint', (array) $ids );
    }

    /**
     * Get product IDs whose meta value exactly matches the term.
     *
     * Used to surface exact SKU matches at the top of the results.
     *
     * @since 1.0.6
     * @access private
     *
     * @param string $term     Search term.
     * @param string $meta_key Meta key to match against.
     *
     * @return int[]
     */
    private function get_ids_by_exact_meta( $term, $meta_key ) {
        global $wpdb;

        $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare(
                "SELECT DISTINCT pm.post_id FROM {$wpdb->postmeta} pm
                 INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
                 WHERE pm.meta_key = %s
                   AND pm.meta_value = %s
                   AND p.post_type IN ( 'product', 'product_variation' )
                 LIMIT %d",
                $meta_key,
                $term,
                $this->meta_lookup_limit
            )
        );

        return array_map( 'absint', (array) $ids );
    }

    /**
     * Resolve a mixed list of product/variation IDs down to product IDs.
     *
     * Variation IDs are swapped for their parent product ID; anything that
     * isn't a product or variation is dropped. Keeps `post__in` aligned with
     * the products endpoint's `post_type=product` filter.
     *
     * @since 1.0.6
     * @access private
     *
     * @param int[] $ids Mixed list of product and/or variation IDs.
     *
     * @return int[]
     */
    private function resolve_to_product_ids( array $ids ) {
        $ids = array_values( array_unique( array_filter( array_map( 'absint', $ids ) ) ) );
        if ( empty( $ids ) ) {
            return array();
        }

        global $wpdb;

        $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->prepare(
                "SELECT ID, post_type, post_parent FROM {$wpdb->posts}
                 WHERE ID IN ({$placeholders})
                   AND post_type IN ( 'product', 'product_variation' )",
                $ids
            )
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $product_ids = array();
        foreach ( (array) $rows as $row ) {
            if ( 'product' === $row->post_type ) {
                $product_ids[] = absint( $row->ID );
            } elseif ( 'product_variation' === $row->post_type && ! empty( $row->post_parent ) ) {
                $product_ids[] = absint( $row->post_parent );
            }
        }

        return array_values( array_unique( array_filter( $product_ids ) ) );
    }

    /**
     * Get the default GTIN/Barcode meta keys (unfiltered).
     *
     * @since 1.0.6
     * @access public
     *
     * @return string[]
     */
    public static function get_default_gtin_meta_keys() {
        return self::$default_gtin_meta_keys;
    }

    /**
     * Get the GTIN/Barcode meta keys used for extended search.
     *
     * @since 1.0.6
     * @access public
     *
     * @return string[]
     */
    public static function get_gtin_meta_keys() {
        /**
         * Filter the GTIN/Barcode meta keys used for the wishlist product search.
         *
         * @since 1.0.6
         *
         * @param string[] $meta_keys Default list of GTIN/Barcode meta keys.
         */
        $meta_keys = apply_filters( 'stwlite_product_search_gtin_meta_keys', self::$default_gtin_meta_keys );

        if ( ! is_array( $meta_keys ) ) {
            return array();
        }

        return array_values( array_unique( array_filter( array_map( 'strval', $meta_keys ) ) ) );
    }

    /**
     * Find parent product IDs whose own meta or any variation's meta matches the term.
     *
     * Wraps the bounded LIKE lookup and variation→parent resolution so other
     * classes can reuse the same search semantics without duplicating SQL.
     *
     * @since 1.0.6
     * @access public
     *
     * @param string   $term      Search term.
     * @param string[] $meta_keys Meta keys to match against.
     *
     * @return int[]  Parent product IDs.
     */
    public function find_product_ids_by_meta( $term, array $meta_keys ) {
        $term = is_string( $term ) ? trim( $term ) : '';
        if ( '' === $term || empty( $meta_keys ) ) {
            return array();
        }

        $ids = $this->get_ids_by_meta_like( $term, $meta_keys );

        return $this->resolve_to_product_ids( $ids );
    }
}
