<?php
/**
 * TitanCart — Storefront REST API.
 *
 * Public (no auth) endpoints for customer-facing pages:
 *   GET /store/products    — filtered, paginated product listing
 *   GET /store/categories  — category tree for navigation
 *   GET /store/filters     — active filter definitions with value counts
 *
 * @package TitanCart\REST
 */

namespace TitanCart\REST;

defined( 'ABSPATH' ) || exit;

use WP_REST_Response;
use WP_REST_Server;
use TitanCart\Modules\Products\ProductRepository;
use TitanCart\Services\SeoService;
use TitanCart\Services\SettingsService;

class StorefrontApiController {

    private const NS = 'titancart/v1';

    private function db() { return \titancart_db(); }
    private function t( string $n ): string { return $this->db()->table( $n ); }

    /**
     * Roadmap #4 (KI-08) — decorate a serialized product row's display price
     * with the catalog-effective price (qty=1, viewer usergroups) so that
     * JS-rendered cards/suggestions/quick-views match the server-rendered
     * card.php shelf. Mutates $out['price'] / $out['list_price'] in place.
     *
     * Behaviour-preserving: when no catalog promo applies, compare_at == the
     * original list_price (the engine's "regular" folds in any list_price
     * markdown), so the emitted values are byte-identical to the pre-#4 output.
     * Only diverges when a catalog promo fires (price drops; list_price becomes
     * the regular "was"). Consumers compute hasDiscount = list_price > price, so
     * this keeps their existing strike-through/badge logic correct unchanged.
     *
     * Display-only: does NOT alter which products are returned or their sort
     * (the On-Sale facet / Biggest-Deals sort stay list_price-keyed for v1 per
     * the locked Option-1 decision — catalog-aware filtering is post-launch).
     *
     * @param array $out  The outgoing serialized row (has 'price','list_price').
     * @param int   $pid  Product ID.
     * @param array $base Raw base row {price, list_price} for the ?row fast-path
     *                    (skips a redundant product SELECT on listing queries).
     */
    private function apply_effective_price( array &$out, int $pid, array $base ): void {
        // class_exists: Lite strips the Promotions module (R1 §🅰). $out already
        // carries the raw base price/list_price, so absence is a clean no-op.
        if ( ! class_exists( \TitanCart\Modules\Promotions\CatalogPromotionService::class ) ) {
            return;
        }
        $ep = \TitanCart\Modules\Promotions\CatalogPromotionService::instance()
                ->effective_price_for_viewer( $pid, 1, [
                    'price'      => (float) ( $base['price'] ?? 0 ),
                    'list_price' => (float) ( $base['list_price'] ?? 0 ),
                ] );
        $out['price']      = (float) $ep['price'];
        $out['list_price'] = $ep['on_sale']
            ? (float) $ep['compare_at']
            : (float) ( $base['list_price'] ?? 0 );
    }

    public function register_routes(): void {
        // WP.org R1 🅱4 — permission_callback audit: every route below is
        // public BY DESIGN. These are the anonymous storefront catalog reads
        // (product lists, single product, search-suggest) that power the shop
        // for guest visitors — the same data the public shop pages render.
        // Nothing customer-identifying is exposed. Per-route rationale:
        // docs/WPORG-REVIEWER-NOTES.md §REST.

        \register_rest_route( self::NS, '/store/products', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_products' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read (public catalog data only).
            ],
        ] );

        \register_rest_route( self::NS, '/store/products/by-ids', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_products_by_ids' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read (public catalog data only).
                'args'                => [
                    'ids' => [ 'type' => 'string', 'required' => true ],
                ],
            ],
        ] );

        \register_rest_route( self::NS, '/store/products/(?P<id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_single_product' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read (public catalog data only).
            ],
        ] );

        \register_rest_route( self::NS, '/store/categories', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_categories' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read (public catalog data only).
            ],
        ] );

        \register_rest_route( self::NS, '/store/filters', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_filters' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read (public catalog data only).
            ],
        ] );

        \register_rest_route( self::NS, '/store/search-suggest', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'search_suggest' ],
                'permission_callback' => '__return_true', // Public by design: anonymous storefront read (public catalog data only).
            ],
        ] );
    }

    /**
     * Lightweight product search for typeahead suggestions.
     * Returns up to 6 results with minimal data (name, price, image, URL).
     *
     * GET /store/search-suggest?q=wireless
     */
    public function search_suggest( \WP_REST_Request $request ): WP_REST_Response {
        $q = sanitize_text_field( wp_unslash( $request->get_param( 'q' ) ?? '' ) );

        if ( strlen( $q ) < max( 1, SettingsService::int( 'appearance', 'search_min_length', 1 ) ) ) {
            return new WP_REST_Response( [] );
        }

        global $wpdb;
        $lang = 'en';
        $like = '%' . $wpdb->esc_like( $q ) . '%';
        $starts = $wpdb->esc_like( $q ) . '%';

        $t_products = $this->t('products');
        $t_trans    = $this->t('product_translations');

        // roadmap #15: exclude non-representative members of single-display
        // groups from typeahead suggestions. The fragment carries NO bound args,
        // so it embeds directly in the prepare() string (it contains no % chars).
        $tc_single_excl = \TitanCart\Modules\Products\ProductRepository::single_group_exclusion_sql( 'p' );

        $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names via $this->t() (constant-prefix builder); $tc_single_excl is an internally generated arg-free SQL fragment; user input bound via placeholders (esc_like'd LIKE terms).
            "SELECT p.id, p.slug, p.price, p.list_price, p.stock_status,
                    COALESCE(pt.name, '') AS name
             FROM {$t_products} p
             LEFT JOIN {$t_trans} pt
                ON pt.product_id = p.id AND pt.language_code = %s
             WHERE p.deleted_at IS NULL
               AND p.status = 'active'
               AND p.parent_id = 0
               AND {$tc_single_excl}
                AND (pt.name LIKE %s OR p.sku LIKE %s OR p.search_words LIKE %s)
             ORDER BY
                CASE WHEN pt.name LIKE %s THEN 0 ELSE 1 END,
                p.sales_count DESC
             LIMIT 6",
            $lang, $like, $like, $like, $starts
        ), ARRAY_A );

        // KI-21 / S283: batch-flag products that need a PDP selection (variation
        // members, shared-variation products, or required-option products) so the
        // typeahead routes them to "Choose Options" -> PDP instead of a direct add.
        $member_set = function_exists( 'titancart_options_required_product_ids' )
            ? array_flip( titancart_options_required_product_ids( array_map( static fn( $r ) => (int) $r['id'], $rows ?: [] ) ) )
            : [];

        $t_images = $this->t('product_images');
        $results  = [];
        foreach ( $rows ?: [] as $row ) {
            $img_id = $wpdb->get_var( $wpdb->prepare(
                "SELECT wp_attachment_id FROM {$t_images}
                 WHERE product_id = %d AND is_main = 1 LIMIT 1",
                (int) $row['id']
            ) );
            $thumb = $img_id ? ( wp_get_attachment_image_url( (int) $img_id, \TitanCart\Modules\Products\ImageService::cart_thumb_size() ) ?: '' ) : '';

            $results[] = [
                'id'        => (int) $row['id'],
                'name'      => $row['name'],
                'price'     => (float) $row['price'],
                'list_price'=> (float) $row['list_price'],
                'image'     => $thumb,
                'url'       => SeoService::product_url( $row['slug'] ?: $row['id'] ),
                'in_stock'  => $row['stock_status'] === 'in_stock',
                'has_variations' => isset( $member_set[ (int) $row['id'] ] ),
            ];
            $this->apply_effective_price( $results[ count( $results ) - 1 ], (int) $row['id'], $row );
        }

        return new WP_REST_Response( $results );
    }

    /**
     * Fetch products by a comma-separated list of IDs.
     * Used by the "Just For You" recently-viewed auto-fill on the homepage.
     *
     * GET /store/products/by-ids?ids=12,45,78
     */
    public function get_products_by_ids( \WP_REST_Request $request ): WP_REST_Response {
        $raw = sanitize_text_field( $request->get_param( 'ids' ) ?? '' );
        $ids = array_filter( array_map( 'intval', explode( ',', $raw ) ) );

        if ( empty( $ids ) || count( $ids ) > 20 ) {
            return new WP_REST_Response( [] );
        }

        // KI-21 / S283: batch-flag which of these need a PDP selection.
        $member_set = function_exists( 'titancart_options_required_product_ids' )
            ? array_flip( titancart_options_required_product_ids( $ids ) )
            : [];

        global $wpdb;
        $lang     = 'en';
        $t_prod   = $this->t( 'products' );
        $t_trans  = $this->t( 'product_translations' );
        $t_images = $this->t( 'product_images' );

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

        $rows = $wpdb->get_results( $wpdb->prepare( // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- table names via $this->t() (constant-prefix builder); $placeholders is a %d list from count($ids); all values bound via placeholders.
            "SELECT p.id, p.slug, p.sku, p.price, p.list_price, p.stock_status,
                    COALESCE(pt.name, '') AS name
             FROM {$t_prod} p
             LEFT JOIN {$t_trans} pt
                ON pt.product_id = p.id AND pt.language_code = %s
             WHERE p.id IN ({$placeholders})
               AND p.deleted_at IS NULL
               AND p.status = 'active'",
            array_merge( [ $lang ], $ids )
        ), ARRAY_A );

        // Build ordered result set matching input ID order.
        $map = [];
        foreach ( $rows ?: [] as $row ) {
            $pid    = (int) $row['id'];
            $img_id = $wpdb->get_var( $wpdb->prepare(
                "SELECT wp_attachment_id FROM {$t_images}
                 WHERE product_id = %d AND is_main = 1 LIMIT 1",
                $pid
            ) );
            $thumb = $img_id ? ( wp_get_attachment_image_url( (int) $img_id, 'medium_large' ) ?: '' ) : '';

            $map[ $pid ] = [
                'id'           => $pid,
                'name'         => $row['name'],
                'slug'         => $row['slug'],
                'sku'          => $row['sku'] ?? '',
                'price'        => (float) $row['price'],
                'list_price'   => (float) $row['list_price'],
                'image_url'    => $thumb,
                'url'          => SeoService::product_url( $row['slug'] ?: $pid ),
                'in_stock'     => $row['stock_status'] === 'in_stock',
                'stock_status' => $row['stock_status'] ?? 'in_stock',
                'features'     => [],
                'has_variations' => isset( $member_set[ $pid ] ),
            ];
            $this->apply_effective_price( $map[ $pid ], $pid, $row );
        }

        // Batch-load features for compare table.
        $found_pids = array_keys( $map );
        if ( ! empty( $found_pids ) ) {
            $db   = $this->db();
            $tFV  = $this->t( 'product_feature_values' );
            $tF   = $this->t( 'product_features' );
            $tFT  = $this->t( 'product_feature_translations' );
            $tFVT = $this->t( 'product_feature_variant_translations' );

            $feat_rows = $db->getArray(
                "SELECT fv.product_id, ft.name AS feature_name, vt.name AS variant_name,
                        fv.value_str, fv.value_text, fv.value_int, fv.value_float
                 FROM {$tFV} fv
                 INNER JOIN {$tF} f ON f.id = fv.feature_id
                 LEFT JOIN {$tFT} ft ON ft.feature_id = fv.feature_id AND ft.language_code = 'en'
                 LEFT JOIN {$tFVT} vt ON vt.variant_id = fv.variant_id AND vt.language_code = 'en'
                 WHERE fv.product_id IN (?a)",
                $found_pids
            );

            foreach ( $feat_rows as $row ) {
                $pid = (int) $row['product_id'];
                $nm  = $row['feature_name'] ?: '';
                $val = $row['variant_name'] ?: $row['value_str'] ?: $row['value_text'] ?: '';
                if ( ! $val && $row['value_int'] !== null && $row['value_int'] !== '' ) $val = (string) $row['value_int'];
                if ( ! $val && $row['value_float'] !== null && $row['value_float'] !== '' ) $val = (string) $row['value_float'];
                if ( $nm && $val && isset( $map[ $pid ] ) ) {
                    $map[ $pid ]['features'][] = [ 'name' => $nm, 'variant_name' => $val ];
                }
            }
        }

        // Preserve original order.
        $results = [];
        foreach ( $ids as $id ) {
            if ( isset( $map[ $id ] ) ) {
                $results[] = $map[ $id ];
            }
        }

        return new WP_REST_Response( $results );
    }

    /* ────────────────────────────────────────────────────────────────────────
     *  GET /store/products
     *
     *  Params: page, per_page, category, sort, search, price_min, price_max,
     *          stock (in_stock|all), features[feature_id]=variant_id,...
     * ──────────────────────────────────────────────────────────────────────── */

    public function get_products( $request ): WP_REST_Response {
        $repo = new ProductRepository();
        $lang = 'en';

        $params = [
            'status'         => 'active',
            'parent_id'      => 0,
            'category_id'    => (int) ( $request->get_param( 'category' ) ?? 0 ),
            'search'         => sanitize_text_field( $request->get_param( 'search' ) ?? '' ),
            'price_min'      => $request->get_param( 'price_min' ) !== null ? (float) $request->get_param( 'price_min' ) : null,
            'price_max'      => $request->get_param( 'price_max' ) !== null ? (float) $request->get_param( 'price_max' ) : null,
            'stock_status'   => $request->get_param( 'stock' ) === 'in_stock' ? 'in_stock' : '',
            'sort_by'        => sanitize_key( $request->get_param( 'sort' ) ?? 'date' ),
            'sort_order'     => strtolower( $request->get_param( 'order' ) ?? 'desc' ) === 'asc' ? 'asc' : 'desc',
            'items_per_page' => min( 48, max( 1, (int) ( $request->get_param( 'per_page' ) ?? SettingsService::int( 'appearance', 'products_per_page', 24 ) ) ) ),
            'page'           => max( 1, (int) ( $request->get_param( 'page' ) ?? 1 ) ),
            'brand_id'       => (int) ( $request->get_param( 'brand' ) ?? 0 ),
        ];

        // Tag scoping (S123). When the storefront is rendering /product-tag/{slug}/
        // the page passes data-tag-slug down to shop.js, which forwards it as ?tag=
        // on every AJAX call. Resolve the slug to product IDs and constrain the
        // result set so sort / price / availability filters compose under the
        // tag scope. Mutually exclusive with $filter_mode below — if both are set
        // (shouldn't happen via the UI), filter_mode wins because it overrides ids.
        $tag_slug = sanitize_text_field( $request->get_param( 'tag' ) ?? '' );
        if ( $tag_slug !== '' ) {
            global $wpdb;
            $p = $wpdb->prefix . TITANCART_TABLE_PREFIX;
            $tag_id = (int) $wpdb->get_var( $wpdb->prepare(
                "SELECT id FROM {$p}tags WHERE slug = %s LIMIT 1",
                $tag_slug
            ) );
            if ( $tag_id > 0 ) {
                $tag_product_ids = array_map( 'intval', $wpdb->get_col( $wpdb->prepare(
                    "SELECT pt.product_id
                     FROM {$p}product_tags pt
                     INNER JOIN {$p}products p ON p.id = pt.product_id
                     WHERE pt.tag_id = %d
                       AND p.status = 'active'
                       AND p.deleted_at IS NULL",
                    $tag_id
                ) ) );
                $params['ids'] = $tag_product_ids ?: [ 0 ]; // [0] forces empty result
            } else {
                // Slug didn't resolve — return empty rather than full catalog.
                $params['ids'] = [ 0 ];
            }
        }

        // Availability filter values: in_stock, out_of_stock, on_sale, free_delivery
        $avail_raw = $request->get_param( 'availability' );
        if ( $avail_raw ) {
            $avail_vals = array_map( 'sanitize_key', explode( ',', $avail_raw ) );
            if ( in_array( 'in_stock', $avail_vals, true ) ) {
                $params['stock_status'] = 'in_stock';
            } elseif ( in_array( 'out_of_stock', $avail_vals, true ) ) {
                $params['stock_status'] = 'out_of_stock';
            }
            if ( in_array( 'on_sale', $avail_vals, true ) ) {
                $params['on_sale'] = true;
            }
            if ( in_array( 'free_delivery', $avail_vals, true ) ) {
                $params['free_shipping'] = true;
            }
        }

        // Global out-of-stock visibility — if no explicit stock filter was set by the user,
        // hide OOS products when the admin setting says so.
        if ( empty( $params['stock_status'] ) && ! SettingsService::bool( 'general', 'catalog_show_out_of_stock', true ) ) {
            $params['stock_status'] = 'in_stock';
        }

        // Subcategory products — when enabled, expand category_id to include all descendants.
        // Mirrors the same BFS logic in StorefrontModule::build_template_data().
        if ( $params['category_id'] > 0 && SettingsService::bool( 'general', 'catalog_show_subcategory_products', true ) ) {
            global $wpdb;
            $p = $wpdb->prefix . TITANCART_TABLE_PREFIX;
            $all_cat_ids = [ $params['category_id'] ];
            $queue       = [ $params['category_id'] ];
            $max_depth   = 50;
            while ( ! empty( $queue ) && $max_depth-- > 0 ) {
                $parent   = array_shift( $queue );
                $children = $wpdb->get_col( $wpdb->prepare(
                    "SELECT id FROM {$p}categories WHERE parent_id = %d AND status = 'active' AND deleted_at IS NULL",
                    $parent
                ) );
                foreach ( $children as $child_id ) {
                    $cid = (int) $child_id;
                    if ( ! in_array( $cid, $all_cat_ids, true ) ) {
                        $all_cat_ids[] = $cid;
                        $queue[]       = $cid;
                    }
                }
            }
            if ( count( $all_cat_ids ) > 1 ) {
                unset( $params['category_id'] );
                $params['category_ids'] = $all_cat_ids;
            }
        }

        // ── Special filter modes (Just For You landing pages) ──────────────
        $filter_mode = sanitize_key( $request->get_param( 'filter' ) ?? '' );

        if ( $filter_mode === 'hidden-treasures' ) {
            // Products referenced in active cart-type promotions.
            $db  = $this->db();
            $tPr = $this->t( 'promotions' );
            $tPC = $this->t( 'promotion_conditions' );

            $now  = titancart_now_utc(); // KI-24: windows stored UTC; compare UTC, not MySQL-tz NOW().
            $rows = $db->getArray(
                "SELECT pc.value
                 FROM {$tPC} pc
                 INNER JOIN {$tPr} pr ON pr.id = pc.promotion_id
                 WHERE pr.type = 'cart' AND pr.status = 'active'
                   AND pc.type = 'products'
                   AND (pr.valid_from IS NULL OR pr.valid_from <= ?s)
                   AND (pr.valid_to   IS NULL OR pr.valid_to   >= ?s)",
                $now,
                $now
            );

            $promo_product_ids = [];
            foreach ( $rows as $row ) {
                $val = is_string( $row['value'] ) ? json_decode( $row['value'], true ) : $row['value'];
                if ( ! empty( $val['ids'] ) && is_array( $val['ids'] ) ) {
                    $promo_product_ids = array_merge( $promo_product_ids, array_map( 'intval', $val['ids'] ) );
                }
            }
            $promo_product_ids = array_unique( array_filter( $promo_product_ids ) );

            if ( ! empty( $promo_product_ids ) ) {
                $params['ids'] = $promo_product_ids;
            } else {
                // No cart promos with product conditions — return empty.
                return new WP_REST_Response( [
                    'products'    => [],
                    'total'       => 0,
                    'total_pages' => 0,
                    'page'        => 1,
                    'filter'      => $filter_mode,
                ] );
            }

        } elseif ( $filter_mode === 'biggest-deals' ) {
            // Products on sale, sorted by discount percentage descending.
            $params['on_sale']   = true;
            $params['sort_by']   = 'discount';  // Custom sort handled below.
            $params['sort_order'] = 'desc';
        }

        // ── Attribute (feature) filtering ──────────────────────────────────
        // Resolve the selected attribute variants to matching product IDs
        // BEFORE the main query, then constrain get_list via its `ids` param.
        // The prior approach filtered AFTER pagination, so it only saw the
        // current page's slice — under any sort where the matching products
        // didn't land on page 1 (e.g. the store's name-asc default), valid
        // matches were silently dropped and the filter appeared to return
        // nothing. Resolving up front filters the whole catalog and lets
        // get_list compute correct totals/pagination.
        $feature_filters = [];
        foreach ( $request->get_params() as $key => $val ) {
            if ( strpos( $key, 'attr_' ) === 0 && $val !== '' && $val !== null ) {
                $fid = (int) substr( $key, 5 );
                if ( $fid > 0 ) {
                    $vids = array_values( array_filter( array_map( 'intval', explode( ',', (string) $val ) ) ) );
                    if ( ! empty( $vids ) ) {
                        $feature_filters[ $fid ] = $vids;
                    }
                }
            }
        }

        if ( ! empty( $feature_filters ) ) {
            $db   = $this->db();
            $tFV  = $this->t( 'product_feature_values' );

            // AND across attributes: a product must match every selected
            // attribute (variant_id IN that attribute's chosen set). Intersect
            // the per-attribute product-id sets.
            $matched_ids = null;
            foreach ( $feature_filters as $fid => $variant_ids ) {
                $ids_for_feature = array_map( 'intval', $db->getColumn(
                    "SELECT DISTINCT product_id FROM {$tFV}
                     WHERE feature_id = ?i AND variant_id IN (?a)",
                    $fid,
                    $variant_ids
                ) );
                $matched_ids = ( $matched_ids === null )
                    ? $ids_for_feature
                    : array_values( array_intersect( $matched_ids, $ids_for_feature ) );
                if ( empty( $matched_ids ) ) {
                    break; // an AND can only shrink the set
                }
            }

            if ( empty( $matched_ids ) ) {
                return new WP_REST_Response( [
                    'products'    => [],
                    'total'       => 0,
                    'total_pages' => 0,
                    'page'        => 1,
                    'filter'      => $filter_mode,
                ] );
            }

            // Intersect with any pre-existing id constraint (e.g. the
            // hidden-treasures promo set); otherwise use the matched set.
            if ( ! empty( $params['ids'] ) ) {
                $params['ids'] = array_values( array_intersect(
                    array_map( 'intval', $params['ids'] ),
                    $matched_ids
                ) );
                if ( empty( $params['ids'] ) ) {
                    return new WP_REST_Response( [
                        'products'    => [],
                        'total'       => 0,
                        'total_pages' => 0,
                        'page'        => 1,
                        'filter'      => $filter_mode,
                    ] );
                }
            } else {
                $params['ids'] = $matched_ids;
            }
        }

        // roadmap #15: collapse "display as a single product" groups to one
        // representative card in the JSON shop grid (mirrors the storefront
        // archive). Members still resolve by direct URL / quick-view.
        $params['hide_single_group_members'] = true;

        $result   = $repo->get_list( $params, $lang );
        $products = $result['products'];


        // Slim down response — storefront doesn't need admin fields
        $clean = array_map( function( $p ) {
            $out = [
                'id'             => (int) $p['id'],
                'name'           => $p['name'] ?? '',
                'brand'          => $p['brand'] ?? '',
                'slug'           => $p['slug'] ?? '',
                'price'          => (float) ( $p['price'] ?? 0 ),
                'list_price'     => (float) ( $p['list_price'] ?? 0 ),
                'sku'            => $p['sku'] ?? '',
                'stock_status'   => $p['stock_status'] ?? 'in_stock',
                'stock_quantity' => (int) ( $p['stock_quantity'] ?? 0 ),
                'featured'       => (bool) ( $p['featured'] ?? false ),
                'rating_avg'     => (float) ( $p['rating_avg'] ?? 0 ),
                'rating_count'   => (int) ( $p['rating_count'] ?? 0 ),
                'image'          => $p['image'] ?? null,
                'short_desc'     => wp_strip_all_tags( $p['short_desc'] ?? '' ),
                'url'            => SeoService::product_url( $p['slug'] ?? $p['id'] ),
                'features'       => [],
                'has_variations' => false,
            ];
            // Roadmap #4: catalog-effective shelf price (?row fast-path = no extra SELECT).
            $this->apply_effective_price( $out, (int) $p['id'], $p );
            return $out;
        }, $products );

        // Batch-load features for list view (single query, not N+1).
        $pids = array_column( $clean, 'id' );
        if ( ! empty( $pids ) ) {
            // KI-21 / S283: batch-flag products needing a PDP selection (one
            // query) so list/grid cards route them to "Choose Options" -> PDP
            // instead of a direct add.
            $member_set = function_exists( 'titancart_options_required_product_ids' )
                ? array_flip( titancart_options_required_product_ids( $pids ) )
                : [];

            // Subscription-priced products carry $0 on tc_products.price (the real
            // recurring price lives in tc_subscription_plans). Surface the lowest
            // active plan's price as additive fields so catalog consumers (e.g. the
            // in-admin Extensions Marketplace browser, which fetches this endpoint
            // from market.titancart.io) can show "$99.00 / year" instead of $0.
            // One batched query; skipped entirely when the Subscriptions extension
            // is absent (zero overhead on non-subscription stores). The raw `price`
            // field is left untouched so existing storefront card/cart logic is
            // unchanged — these are purely informational.
            $sub_price_map = class_exists( '\\TitanCartSubscriptions\\Services\\PlanService' )
                ? \TitanCartSubscriptions\Services\PlanService::lowest_for_products( $pids )
                : [];

            $db   = $this->db();
            $tFV  = $this->t( 'product_feature_values' );
            $tF   = $this->t( 'product_features' );
            $tFT  = $this->t( 'product_feature_translations' );
            $tFVT = $this->t( 'product_feature_variant_translations' );

            $tFVar = $this->t( 'product_feature_variants' );
            $feat_rows = $db->getArray(
                "SELECT fv.product_id, fv.feature_id, ft.name AS feature_name, vt.name AS variant_name,
                        fv.value_str, fv.value_text,
                        f.filter_type AS filter_type, fvar.color_hex AS color_hex
                 FROM {$tFV} fv
                 INNER JOIN {$tF} f ON f.id = fv.feature_id
                    AND f.filter_type <> 'none'
                    AND (
                        f.purpose IN ('filter','info')
                        OR ( f.purpose IN ('variation_shared','variation_split') AND f.filter_type = 'color' )
                    )
                 LEFT JOIN {$tFT} ft ON ft.feature_id = fv.feature_id AND ft.language_code = 'en'
                 LEFT JOIN {$tFVT} vt ON vt.variant_id = fv.variant_id AND vt.language_code = 'en'
                 LEFT JOIN {$tFVar} fvar ON fvar.id = fv.variant_id
                 WHERE fv.product_id IN (?a)",
                $pids
            );

            // Group by product_id, carrying the attribute's list-display type
            // (filter_type) and per-value swatch hex so cards can honor the
            // admin "List Display" setting: 'color' -> swatch dots, 'none' ->
            // hidden (excluded in the query above), anything else -> text chip.
            $feat_map = [];
            foreach ( $feat_rows as $row ) {
                $pid = (int) $row['product_id'];
                $val = $row['variant_name'] ?: $row['value_str'] ?: $row['value_text'] ?: '';
                $nm  = $row['feature_name'] ?: '';
                if ( $nm && $val !== '' ) {
                    $feat_map[ $pid ][] = [
                        'name'        => $nm,
                        'value'       => $val,
                        'filter_type' => $row['filter_type'] ?: 'checkbox',
                        'color_hex'   => $row['color_hex'] ?: '',
                    ];
                }
            }

            // Attach to products (max 6 entries per product — a colour attribute
            // can contribute several swatch values, so the cap is a little higher
            // than the old text-only 4).
            foreach ( $clean as &$cp ) {
                $cp['features']       = array_slice( $feat_map[ $cp['id'] ] ?? [], 0, 6 );
                $cp['has_variations'] = isset( $member_set[ (int) $cp['id'] ] );

                $cp_sub = $sub_price_map[ (int) $cp['id'] ] ?? null;
                if ( $cp_sub ) {
                    $cp['subscription_price'] = (float) $cp_sub['price'];
                    $label = \TitanCartSubscriptions\Services\PlanService::billing_description( $cp_sub );
                    if ( (int) ( $cp_sub['count'] ?? 1 ) > 1 ) {
                        $label = 'from ' . $label;
                    }
                    $cp['subscription_label'] = $label;
                }
            }
            unset( $cp );
        }

        return new WP_REST_Response( [
            'products'    => $clean,
            'total'       => (int) $result['params']['total_items'],
            'total_pages' => (int) $result['params']['total_pages'],
            'page'        => (int) $result['params']['page'],
            'filter'      => $filter_mode ?: null,
        ] );
    }

    /* ────────────────────────────────────────────────────────────────────────
     *  GET /store/products/{id} — single product for quick-view modal
     * ──────────────────────────────────────────────────────────────────────── */

    public function get_single_product( $request ): WP_REST_Response {
        $id   = (int) $request->get_param( 'id' );
        $repo = new ProductRepository();
        $lang = 'en';

        $product = $repo->get( $id, $lang );

        if ( ! $product || ( $product['status'] ?? '' ) !== 'active' ) {
            return new WP_REST_Response( [ 'error' => 'Product not found' ], 404 );
        }

        // Category breadcrumb
        $db   = $this->db();
        $tPC  = $this->t( 'product_categories' );
        $tC   = $this->t( 'categories' );
        $tCT  = $this->t( 'category_translations' );
        $cats = $db->getArray(
            "SELECT c.id, c.slug, COALESCE( ct.name, c.slug ) AS name
             FROM {$tPC} pc
             JOIN {$tC} c ON c.id = pc.category_id AND c.status = 'active' AND c.deleted_at IS NULL
             LEFT JOIN {$tCT} ct ON ct.category_id = c.id AND ct.language_code = ?s
             WHERE pc.product_id = ?i
             LIMIT 5",
            $lang, $id
        );

        $category_names = array_column( $cats, 'name' );

        // Get main image from images array
        $images    = $product['images'] ?? [];
        $main_img  = null;
        foreach ( $images as $img ) {
            if ( ! empty( $img['is_main'] ) || $main_img === null ) {
                $main_img = $img;
            }
        }
        $image_data = $main_img ? [ 'url' => $main_img['url'] ?? '', 'alt' => $main_img['alt_text'] ?? '' ] : null;

        $single = [
            'id'           => (int) $product['id'],
            'name'         => $product['name'] ?? '',
            'slug'         => $product['slug'] ?? '',
            'price'        => (float) ( $product['price'] ?? 0 ),
            'list_price'   => (float) ( $product['list_price'] ?? 0 ),
            'sku'          => $product['sku'] ?? '',
            'stock_status' => $product['stock_status'] ?? 'in_stock',
            'short_desc'   => $product['short_desc'] ?? '',
            'description'  => wp_strip_all_tags( $product['description'] ?? '' ),
            'image'        => $image_data,
            'url'          => SeoService::product_url( $product['slug'] ?? $product['id'] ),
            'categories'   => $category_names,
            'has_variations' => ( function_exists( 'titancart_product_requires_options' ) && titancart_product_requires_options( (int) $product['id'] ) ),
        ];
        // Roadmap #4: catalog-effective price for the quick-view (was/discount).
        $this->apply_effective_price( $single, (int) $product['id'], $product );

        return new WP_REST_Response( $single );
    }

    /* ────────────────────────────────────────────────────────────────────────
     *  GET /store/categories — nested tree
     * ──────────────────────────────────────────────────────────────────────── */

    public function get_categories( $request ): WP_REST_Response {
        $db    = $this->db();
        $tC    = $this->t( 'categories' );
        $tCT   = $this->t( 'category_translations' );
        $tPC   = $this->t( 'product_categories' );
        $tP    = $this->t( 'products' );

        $rows = $db->getArray(
            "SELECT c.id, c.parent_id, c.slug, c.depth, c.position,
                    ct.name,
                    (SELECT COUNT(*) FROM {$tPC} pc
                     INNER JOIN {$tP} p2 ON p2.id = pc.product_id AND p2.status = 'active' AND p2.deleted_at IS NULL
                     WHERE pc.category_id = c.id) AS product_count
             FROM {$tC} c
             LEFT JOIN {$tCT} ct ON ct.category_id = c.id AND ct.language_code = ?s
             WHERE c.status = 'active' AND c.deleted_at IS NULL
             ORDER BY c.depth ASC, c.position ASC, c.id ASC",
            'en'
        );

        // Build tree
        $map  = [];
        $tree = [];
        foreach ( $rows as $row ) {
            $row['children'] = [];
            $row['url']      = SeoService::category_url( $row['slug'] );
            $map[ (int) $row['id'] ] = $row;
        }
        foreach ( $map as &$node ) {
            $pid = (int) $node['parent_id'];
            if ( $pid > 0 && isset( $map[ $pid ] ) ) {
                $map[ $pid ]['children'][] = &$node;
            } else {
                $tree[] = &$node;
            }
        }
        unset( $node );

        return new WP_REST_Response( $tree );
    }

    /* ────────────────────────────────────────────────────────────────────────
     *  GET /store/filters — only admin-configured filters from Product Filters page
     *
     *  Reads from tc_product_filters table (configured in admin dashboard).
     *  Only returns active filters, respects category assignments.
     * ──────────────────────────────────────────────────────────────────────── */

    public function get_filters( $request ): WP_REST_Response {
        $db   = $this->db();
        $cat  = (int) ( $request->get_param( 'category' ) ?? 0 );
        $lang = 'en';

        $tPF   = $this->t( 'product_filters' );
        $tPFT  = $this->t( 'product_filter_translations' );
        $tPFC  = $this->t( 'product_filter_categories' );
        $tP    = $this->t( 'products' );
        $tC    = $this->t( 'categories' );
        $tCT   = $this->t( 'category_translations' );
        $tPC   = $this->t( 'product_categories' );

        // ──────────────────────────────────────────────────────────────────────
        // Build a category-scope JOIN clause so built-in filter counts (In Stock,
        // Free Shipping, Rating, attribute features) only count products that
        // are actually in the active category subtree. Without this, an empty
        // category like "Shoes" still shows "In Stock (8)" pulled from the
        // global product set — the bug Sean reported.
        //
        // The clause assumes the products table is aliased as `p` in the outer
        // query. Each count then uses COUNT(DISTINCT p.id) since a product
        // tagged to multiple subcategories would otherwise double-count.
        //
        // lft/rgt nested-set bounds work to any depth (vs the 3-level subquery
        // the categories rollup uses below). Subtree includes the root, so
        // products tagged directly to "Women" count alongside products tagged
        // to "Women > Dresses" / "Women > Tops".
        // ──────────────────────────────────────────────────────────────────────
        $scope_join = '';
        $scope_args = [];
        if ( $cat > 0 ) {
            $cat_row = $db->getRow(
                "SELECT lft, rgt FROM {$tC} WHERE id = ?i AND deleted_at IS NULL AND status = 'active'",
                $cat
            );
            if ( $cat_row && (int) $cat_row['lft'] > 0 ) {
                $scope_join = " INNER JOIN {$tPC} sc ON sc.product_id = p.id"
                            . " INNER JOIN {$tC} c2 ON c2.id = sc.category_id"
                            . "                     AND c2.lft >= ?i AND c2.rgt <= ?i"
                            . "                     AND c2.status = 'active'"
                            . "                     AND c2.deleted_at IS NULL";
                $scope_args = [ (int) $cat_row['lft'], (int) $cat_row['rgt'] ];
            }
        }

        $filters = [];

        // ──────────────────────────────────────────────────────────────────────
        // 0. CATEGORIES — always present as the first filter.
        //    Nested tree: parent → children → grandchildren.
        //    Each level's count INCLUDES products in all its subcategories.
        //    Categories section is always expanded in the filter panel.
        // ──────────────────────────────────────────────────────────────────────
        $all_cats = $db->getArray(
            "SELECT c.id, c.parent_id, ct.name,
                    (SELECT COUNT(DISTINCT pc.product_id)
                     FROM {$tPC} pc
                     INNER JOIN {$tP} p ON p.id = pc.product_id AND p.status = 'active' AND p.deleted_at IS NULL AND p.parent_id = 0
                     WHERE pc.category_id IN (
                         SELECT d.id FROM {$tC} d
                         WHERE d.deleted_at IS NULL AND (
                             d.id = c.id
                             OR d.parent_id = c.id
                             OR d.parent_id IN (
                                 SELECT d2.id FROM {$tC} d2
                                 WHERE d2.parent_id = c.id AND d2.deleted_at IS NULL
                             )
                         )
                     )
                    ) AS product_count
             FROM {$tC} c
             LEFT JOIN {$tCT} ct ON ct.category_id = c.id AND ct.language_code = ?s
             WHERE c.status = 'active' AND c.deleted_at IS NULL
             ORDER BY c.position ASC, c.id ASC",
            $lang
        );

        // Build tree: group by parent_id, then nest children under parents.
        $by_parent = [];
        foreach ( $all_cats as $cat ) {
            $pid = (int) $cat['parent_id'];
            $by_parent[ $pid ][] = $cat;
        }

        $top_level = $by_parent[0] ?? [];
        if ( ! empty( $top_level ) ) {
            $cat_options = [];
            foreach ( $top_level as $c ) {
                $cid = (int) $c['id'];
                $opt = [
                    'value' => $cid,
                    'label' => $c['name'] ?: 'Category #' . $cid,
                    'count' => (int) $c['product_count'],
                ];
                // Children (level 1)
                if ( ! empty( $by_parent[ $cid ] ) ) {
                    $children = [];
                    foreach ( $by_parent[ $cid ] as $child ) {
                        $child_id  = (int) $child['id'];
                        $child_opt = [
                            'value' => $child_id,
                            'label' => $child['name'] ?: 'Category #' . $child_id,
                            'count' => (int) $child['product_count'],
                        ];
                        // Grandchildren (level 2)
                        if ( ! empty( $by_parent[ $child_id ] ) ) {
                            $grandchildren = [];
                            foreach ( $by_parent[ $child_id ] as $gc ) {
                                $grandchildren[] = [
                                    'value' => (int) $gc['id'],
                                    'label' => $gc['name'] ?: 'Category #' . $gc['id'],
                                    'count' => (int) $gc['product_count'],
                                ];
                            }
                            $child_opt['children'] = $grandchildren;
                        }
                        $children[] = $child_opt;
                    }
                    $opt['children'] = $children;
                }
                $cat_options[] = $opt;
            }

            $filters[] = [
                'key'            => 'category',
                'name'           => 'Categories',
                'type'           => 'category_list',
                'start_expanded' => 1,
                'options'        => $cat_options,
            ];
        }

        // ──────────────────────────────────────────────────────────────────────
        // Global default: "Filters start expanded" from storefront settings.
        // Individual filters override via their own start_expanded column.
        // Categories are always expanded regardless.
        // ──────────────────────────────────────────────────────────────────────
        $filters_default_expanded = SettingsService::bool( 'appearance', 'filters_default_expanded', true );

        // ──────────────────────────────────────────────────────────────────────
        // 1. Admin-configured filters
        // ──────────────────────────────────────────────────────────────────────
        $configured = $db->getArray(
            "SELECT f.id, f.filter_by, f.display_type, f.round_to, f.position, f.start_expanded,
                    ft.name
             FROM {$tPF} f
             LEFT JOIN {$tPFT} ft ON ft.filter_id = f.id AND ft.language_code = ?s
             WHERE f.status = 'active'
             ORDER BY f.position ASC, f.id ASC",
            $lang
        );

        // 2. If browsing a category, filter out filters not assigned to it
        if ( $cat > 0 && ! empty( $configured ) ) {
            $filter_ids  = array_column( $configured, 'id' );
            $assignments = $db->getArray(
                "SELECT filter_id, category_id FROM {$tPFC} WHERE filter_id IN (?a)",
                $filter_ids
            );
            $cat_map = [];
            foreach ( $assignments as $a ) {
                $cat_map[ (int) $a['filter_id'] ][] = (int) $a['category_id'];
            }
            $configured = array_filter( $configured, function( $f ) use ( $cat_map, $cat ) {
                $fid = (int) $f['id'];
                if ( ! isset( $cat_map[ $fid ] ) ) return true;
                return in_array( $cat, $cat_map[ $fid ], true );
            } );
            $configured = array_values( $configured );
        }

        // 3. Build each admin-configured filter
        foreach ( $configured as $row ) {
            $filter_by = $row['filter_by'];
            $name      = $row['name'] ?: ucfirst( str_replace( '_', ' ', $filter_by ) );
            $display   = $row['display_type'] ?: 'checkbox';
            $expanded  = ( (int) ( $row['start_expanded'] ?? 0 ) || $filters_default_expanded ) ? 1 : 0;

            // ── Built-in: Price ──
            if ( $filter_by === 'price' ) {
                $price_row = $db->getRow(
                    "SELECT MIN(price) as min_price, MAX(price) as max_price
                     FROM {$tP} WHERE status = 'active' AND deleted_at IS NULL"
                );
                $price_display = in_array( $display, [ 'slider', 'range' ], true ) ? $display : 'range';
                $filters[] = [
                    'key'            => 'price',
                    'name'           => $name,
                    'type'           => $price_display,
                    'start_expanded' => $expanded,
                    'min'  => (float) ( $price_row['min_price'] ?? 0 ),
                    'max'  => (float) ( $price_row['max_price'] ?? 1000 ),
                ];
                continue;
            }

            // ── Built-in: In Stock → Availability (ShopNest-style) ──
            if ( $filter_by === 'in_stock' ) {
                // Counts use the shared $scope_join so they reflect the active
                // category subtree (or all products when on the shop index).
                // parent_id = 0 excludes variation children (S90 bug class).
                // ...$scope_args expands to 0 args when no category is active,
                // which matches the variadic getField signature for global counts.
                $base_select = "SELECT COUNT(DISTINCT p.id) FROM {$tP} p{$scope_join}";
                $base_where  = " WHERE p.status = 'active' AND p.deleted_at IS NULL AND p.parent_id = 0";

                $in_stock = (int) $db->getField(
                    $base_select . $base_where . " AND p.stock_status = 'in_stock'",
                    ...$scope_args
                );
                $out_stock = (int) $db->getField(
                    $base_select . $base_where . " AND p.stock_status = 'out_of_stock'",
                    ...$scope_args
                );
                $on_sale = (int) $db->getField(
                    $base_select . $base_where . " AND p.list_price > 0 AND p.list_price > p.price",
                    ...$scope_args
                );
                $free_ship = (int) $db->getField(
                    $base_select . $base_where . " AND p.free_shipping = 1",
                    ...$scope_args
                );
                $filters[] = [
                    'key'            => 'availability',
                    'name'           => $name ?: 'Availability',
                    'type'           => 'checkbox',
                    'start_expanded' => $expanded,
                    'options' => [
                        [ 'value' => 'in_stock',      'label' => 'In Stock',      'count' => $in_stock ],
                        [ 'value' => 'out_of_stock',  'label' => 'Out of Stock',  'count' => $out_stock ],
                        [ 'value' => 'on_sale',       'label' => 'On Sale',       'count' => $on_sale ],
                        [ 'value' => 'free_delivery',  'label' => 'Free Delivery', 'count' => $free_ship ],
                    ],
                ];
                continue;
            }

            // ── Built-in: Free Shipping ──
            if ( $filter_by === 'free_shipping' ) {
                $free_count = (int) $db->getField(
                    "SELECT COUNT(DISTINCT p.id) FROM {$tP} p{$scope_join}
                     WHERE p.status = 'active' AND p.deleted_at IS NULL AND p.parent_id = 0 AND p.free_shipping = 1",
                    ...$scope_args
                );
                $filters[] = [
                    'key'            => 'free_shipping',
                    'name'           => $name,
                    'type'           => 'checkbox',
                    'start_expanded' => $expanded,
                    'options' => [
                        [ 'value' => '1', 'label' => 'Free Shipping', 'count' => $free_count ],
                    ],
                ];
                continue;
            }

            // ── Built-in: Rating ──
            if ( $filter_by === 'rating' ) {
                $rated = (int) $db->getField(
                    "SELECT COUNT(DISTINCT p.id) FROM {$tP} p{$scope_join}
                     WHERE p.status = 'active' AND p.deleted_at IS NULL AND p.parent_id = 0 AND p.rating_avg >= 4",
                    ...$scope_args
                );
                $filters[] = [
                    'key'            => 'rating',
                    'name'           => $name,
                    'type'           => 'checkbox',
                    'start_expanded' => $expanded,
                    'options' => [
                        [ 'value' => '4', 'label' => '4★ & above', 'count' => $rated ],
                    ],
                ];
                continue;
            }

            // ── Attribute-based: feature:123 ──
            if ( strpos( $filter_by, 'feature:' ) === 0 ) {
                $fid  = (int) substr( $filter_by, 8 );
                $tF   = $this->t( 'product_features' );
                $tFV  = $this->t( 'product_feature_variants' );
                $tFVT = $this->t( 'product_feature_variant_translations' );
                $tPFV = $this->t( 'product_feature_values' );

                $variants = $db->getArray(
                    "SELECT v.id, v.color_hex, vt.name,
                            (SELECT COUNT(DISTINCT pfv.product_id)
                             FROM {$tPFV} pfv
                             INNER JOIN {$tP} p ON p.id = pfv.product_id AND p.status = 'active' AND p.deleted_at IS NULL AND p.parent_id = 0
                             {$scope_join}
                             WHERE pfv.feature_id = ?i AND pfv.variant_id = v.id
                            ) AS product_count
                     FROM {$tFV} v
                     INNER JOIN {$tF} fsm ON fsm.id = v.feature_id
                     LEFT JOIN {$tFVT} vt ON vt.variant_id = v.id AND vt.language_code = ?s
                     WHERE v.feature_id = ?i AND v.status = 'active'
                     ORDER BY " . \TitanCart\Modules\Products\FeatureValueOrder::clause( 'fsm', 'v', 'vt' ),
                    ...array_merge( $scope_args, [ $fid, $lang, $fid ] )
                );

                if ( empty( $variants ) ) continue;

                $filters[] = [
                    'key'            => 'attr_' . $fid,
                    'name'           => $name,
                    'type'           => $display,
                    'start_expanded' => $expanded,
                    'options' => array_map( function( $v ) {
                        $opt = [
                            'value' => (int) $v['id'],
                            'label' => $v['name'] ?: 'Option #' . $v['id'],
                            'count' => (int) $v['product_count'],
                        ];
                        if ( ! empty( $v['color_hex'] ) ) {
                            $opt['color_hex'] = $v['color_hex'];
                        }
                        return $opt;
                    }, $variants ),
                ];
                continue;
            }
        }

        /**
         * Filter the assembled storefront filter facets before they are returned.
         *
         * Lets a theme/extension scope or reshape the facet set (e.g. the Prism
         * demo-store gate prunes the `category` facet to the active preset's
         * scoped category set). Storefront-only: this endpoint is public
         * (public by design — '__return_true') and has no admin counterpart.
         *
         * @param array            $filters Ordered list of facet definitions.
         * @param \WP_REST_Request $request The current request (category/tag params).
         */
        $filters = \apply_filters( 'titancart_store_filters', $filters, $request );

        return new WP_REST_Response( $filters );
    }
}
