<?php
/**
 * Products REST API Controller.
 *
 * Standalone class — does NOT extend WP_REST_Controller to avoid
 * any inherited schema-based arg validation interfering with POST/PUT bodies.
 *
 * Endpoints:
 *   GET    /titancart/v1/products               List products
 *   POST   /titancart/v1/products               Create product
 *   GET    /titancart/v1/products/{id}           Get product
 *   PUT    /titancart/v1/products/{id}           Update product
 *   DELETE /titancart/v1/products/{id}           Delete product
 *   GET    /titancart/v1/products/{id}/variations Variation list
 *   PATCH  /titancart/v1/products/{id}/stock     Update stock
 *   POST   /titancart/v1/products/{id}/images    Upload image
 *   DELETE /titancart/v1/products/{id}/images/{image_id}  Delete image
 *   PUT    /titancart/v1/products/{id}/images/{image_id}/set-main  Set main image
 *
 * @package TitanCart\REST\Products
 */

namespace TitanCart\REST\Products;

use TitanCart\Modules\Products\ProductRepository;
use TitanCart\Core\LogService;
use TitanCart\Modules\Products\TagManager;
use WP_REST_Server;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

defined( 'ABSPATH' ) || exit;

class ProductsController {

    const NS   = 'titancart/v1';
    const BASE = 'products';

    private ProductRepository $repo;

    public function __construct() {
        $this->repo = new ProductRepository();
    }

    /**
     * Attach the storefront permalink to a product payload (S308).
     *
     * The editor's "Preview" button opens this URL. Computed in the REST layer
     * (not the portable repository) because it depends on home_url() + the SEO
     * product prefix. Returns the array unchanged when there is no slug.
     *
     * @param  array<string,mixed>|null $product
     * @return array<string,mixed>|null
     */
    private function with_url( ?array $product ): ?array {
        if ( is_array( $product ) && ! empty( $product['slug'] ) ) {
            $product['url'] = \TitanCart\Services\SeoService::product_url( (string) $product['slug'] );
        }
        return $product;
    }

    // ── Route registration ────────────────────────────────────────────────────

    public function register_routes(): void {

        // Collection: GET (list) + POST (create)
        register_rest_route( self::NS, '/' . self::BASE, [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_items' ],
                'permission_callback' => [ $this, 'admin_permission' ],
                // No type constraints — WP REST rejects '' for integer fields (400).
                // All coercion happens inside get_items().
                'args'                => [
                    'page'         => [ 'default' => 1 ],
                    'per_page'     => [ 'default' => 24 ],
                    'search'       => [ 'default' => '' ],
                    'status'       => [ 'default' => '' ],
                    'category_id'  => [ 'default' => 0 ],
                    'sort_by'      => [ 'default' => 'date' ],
                    'sort_order'   => [ 'default' => 'desc' ],
                    'lang'         => [ 'default' => 'en' ],
                ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_item' ],
                'permission_callback' => [ $this, 'admin_permission' ],
                // No declared args — all validation done inside the callback.
            ],
        ] );

        // Slug availability check — powers the SEO tab's live "already taken" warning.
        register_rest_route( self::NS, '/' . self::BASE . '/check-slug', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'check_slug' ],
                'permission_callback' => [ $this, 'admin_permission' ],
                'args'                => [
                    'slug'    => [ 'default' => '' ],
                    'exclude' => [ 'default' => 0 ], // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- false positive: this is our own REST route arg (product id excluded from the slug-uniqueness check), not a WP_Query 'exclude' param; TitanCart queries tc_ tables via its own ORM.
                ],
            ],
        ] );

        // Single: GET + PUT + DELETE
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_item' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_item' ],
                'permission_callback' => [ $this, 'admin_permission' ],
                // No declared args — all validation done inside the callback.
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_item' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Variations
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/variations', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_variations' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Stock update
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/stock', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_stock' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Image upload
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/images', [
            [
                'methods'             => 'POST',
                'callback'            => [ $this, 'upload_image' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Image: update metadata (PUT) + delete
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/images/(?P<image_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_image' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => 'DELETE',
                'callback'            => [ $this, 'delete_image' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Set main image
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/images/(?P<image_id>[\d]+)/set-main', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'set_main_image' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // Reorder images
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/images/reorder', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'reorder_images' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Product-less image STAGING (S308) ────────────────────────────────
        // Lets the editor attach images to a NEW product before its first save:
        // upload to the WP media library (returning the attachment id + URLs)
        // WITHOUT inserting a product_images row, then the create body carries
        // the staged wp_attachment_ids into repo->save() / save_images(). The
        // companion DELETE cleans up a staged attachment the user removes before
        // ever saving (it is unreferenced by any product, so it would otherwise
        // orphan in the media library).
        register_rest_route( self::NS, '/' . self::BASE . '/stage-image', [
            [
                'methods'             => 'POST',
                'callback'            => [ $this, 'stage_image' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
        register_rest_route( self::NS, '/' . self::BASE . '/stage-image/(?P<attachment_id>[\d]+)', [
            [
                'methods'             => 'DELETE',
                'callback'            => [ $this, 'delete_staged_image' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );


        // ── Product Attributes (feature values) ─────────────────────────────
        // GET applicable attributes + current values, PUT to save values
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/attributes', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_product_attributes' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'save_product_attributes' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Product Group ────────────────────────────────────────────────────
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/group', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_product_group' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_product_group' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/group/create-variations', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_variations' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/group/add-existing', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'add_existing_to_group' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/group/remove/(?P<member_product_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'remove_from_group' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
        // S478: inline edit of a group member's shelf fields from the Product
        // Group tab (price / list price / qty / code / status) — no need to open
        // each variation's own editor to price the sizes differently.
        register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/group/member/(?P<member_product_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_group_member' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );

        // ── Bulk update ─────────────────────────────────────────────────────
        register_rest_route( self::NS, '/' . self::BASE . '/bulk', [
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'bulk_update' ],
                'permission_callback' => [ $this, 'admin_permission' ],
            ],
        ] );
    }
    public function get_items( WP_REST_Request $request ): WP_REST_Response {
        $params = [
            'status'         => sanitize_text_field( $request->get_param( 'status' ) ?? '' ),
            'search'         => sanitize_text_field( $request->get_param( 'search' ) ?? '' ),
            // S274: forward a comma-separated `ids` filter (repo already supports `ids IN`).
            // Lets the promotion-condition name-picker resolve existing ids → names in one call.
            'ids'            => array_values( array_filter( array_map( 'intval', explode( ',', (string) ( $request->get_param( 'ids' ) ?? '' ) ) ) ) ),
            'category_id'    => (int) $request->get_param( 'category_id' ),
            'sort_by'        => sanitize_key( $request->get_param( 'sort_by' ) ?? 'date' ),
            'sort_order'     => in_array( $request->get_param( 'sort_order' ), [ 'asc', 'desc' ], true )
                ? $request->get_param( 'sort_order' )
                : 'desc',
            'items_per_page' => (int) ( $request->get_param( 'per_page' ) ?? 24 ),
            'page'           => (int) ( $request->get_param( 'page' ) ?? 1 ),
        ];

        $lang   = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $result = $this->repo->get_list( $params, $lang );

        // Note: `titancart_products_list_response` filter migrated INTO
        // ProductRepository::get_list at S197 (Track B Module B8). Single
        // fire site now covers REST + storefront + all internal callers;
        // the duplicate apply_filters call that used to live here was
        // removed to eliminate the only known double-fire risk.

        $response = new WP_REST_Response( [
            'products'    => $result['products'],
            'total'       => $result['params']['total_items'],
            'total_pages' => $result['params']['total_pages'],
            'page'        => $result['params']['page'],
        ] );
        $response->header( 'X-TC-Total',       $result['params']['total_items'] );
        $response->header( 'X-TC-Total-Pages', $result['params']['total_pages'] );
        $response->header( 'X-TC-Page',        $result['params']['page'] );

        return $response;
    }

    /** POST /products */
    public function create_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $data = $this->sanitize_product_input( $request );
        $lang = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );

        // A blank Product Code is allowed when auto-generation is enabled
        // (Settings → General → Product Code Generation) — the repository fills
        // one in on save. Otherwise it stays required.
        if ( empty( $data['sku'] )
            && ! \TitanCart\Services\SettingsService::bool( 'general', 'product_code_autogenerate', false ) ) {
            return new WP_Error( 'tc_missing_product_code', 'Product Code is required.', [ 'status' => 400 ] );
        }

        // KI-50 (S362). A MANUALLY-ENTERED Product Code was never checked for
        // uniqueness anywhere. The auto-generator has always been safe —
        // ProductRepository::unique_product_code() loops until the code is free —
        // but it only runs when the code is blank AND autogenerate is ON, so on a
        // store with autogenerate OFF nothing validated the code at all. That is
        // how market ended up with three rows carrying `product2`.
        //
        // We REJECT rather than silently rename: a Product Code is a user-facing
        // identifier the operator typed, and quietly turning it into `product2-2`
        // is the kind of "success" that teaches nobody anything.
        if ( ! empty( $data['sku'] ) && $this->product_code_taken( (string) $data['sku'], 0 ) ) {
            return new WP_Error(
                'tc_duplicate_product_code',
                sprintf( 'Product Code "%s" is already in use by another product.', $data['sku'] ),
                [ 'status' => 409 ]
            );
        }

        $product_id = $this->repo->save( $data, 0, $lang );

        if ( ! $product_id ) {
            global $wpdb;
            $db_err = $wpdb->last_error ?? 'unknown';
            LogService::error( 'products', 'create', 'Product creation failed: ' . $db_err, [
                'product_name' => sanitize_text_field( $request->get_param( 'name' ) ?? '' ),
                'db_error'     => $db_err,
                'backtrace'    => true,
            ] );
            return new WP_Error(
                'tc_product_create_failed',
                'Could not create product. DB: ' . $db_err,
                [ 'status' => 500 ]
            );
        }

        // Sync tags if provided.
        $tags = $request->get_param( 'tags' );
        if ( is_array( $tags ) ) {
            ( new TagManager() )->sync_product_tags( $product_id, $tags );
        }

        LogService::info( 'products', 'create', 'Product created: #' . $product_id, [
            'object_type'  => 'product',
            'object_id'    => $product_id,
            'product_name' => sanitize_text_field( $request->get_param( 'name' ) ?? '' ),
            'product_code' => sanitize_text_field( $request->get_param( 'product_code' ) ?? '' ),
            'price'        => $request->get_param( 'price' ),
            'status'       => sanitize_text_field( $request->get_param( 'status' ) ?? 'active' ),
        ] );

        $product  = $this->repo->get( $product_id, $lang );
        $response = new WP_REST_Response( $this->with_url( $product ), 201 );
        $response->header( 'Location', rest_url( self::NS . '/' . self::BASE . '/' . $product_id ) );
        return $response;
    }

    /** GET /products/{id} */
    public function get_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $lang    = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $product = $this->repo->get( (int) $request->get_param( 'id' ), $lang );

        if ( ! $product ) {
            return new WP_Error( 'tc_product_not_found', 'Product not found.', [ 'status' => 404 ] );
        }

        // Append tags.
        $tag_rows = ( new TagManager() )->get_product_tags( (int) $request->get_param( 'id' ) );
        $product['tags'] = array_map( function( $t ) { return $t['name']; }, $tag_rows );

        return new WP_REST_Response( $this->with_url( $product ) );
    }

    /**
     * Is this Product Code already used by another (non-deleted) product?
     *
     * KI-50 (S362). Scoped to `deleted_at IS NULL` deliberately: that is the exact
     * predicate `ProductRepository::get()` L87 filters on, so the API and the admin
     * UI agree about which products exist. A soft-deleted product's code is free
     * for reuse — which is precisely why `tc_products.sku` must NOT carry a MySQL
     * UNIQUE index. See KNOWN_ISSUES.md KI-50.
     *
     * @param string $sku        The Product Code as typed by the operator.
     * @param int    $exclude_id Row being saved (0 on create).
     */
    private function product_code_taken( string $sku, int $exclude_id ): bool {
        // ⚠️ `\titancart_db()` — NOT `Database::instance()`, which does not exist. The class
        // is constructed and resolved through the service container (`titancart.php`
        // L151 → `Plugin::service('db')`). Calling a nonexistent static fatals mid-
        // response: WP REST has already sent headers, so the client receives a
        // 200 with an EMPTY body and every save silently no-ops. (S362, live.)
        $db = \titancart_db();

        return (bool) $db->getField(
            "SELECT id FROM {$db->table('products')}"
            . " WHERE sku = ?s AND id <> ?i AND deleted_at IS NULL LIMIT 1",
            $sku,
            $exclude_id
        );
    }

    /** PUT/PATCH /products/{id} */
    public function update_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $lang       = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );

        if ( ! $this->repo->get( $product_id, $lang ) ) {
            return new WP_Error( 'tc_product_not_found', 'Product not found.', [ 'status' => 404 ] );
        }

        $data    = $this->sanitize_product_input( $request );

        // KI-50 (S362) — same guard as create_item(), excluding the row being saved.
        if ( ! empty( $data['sku'] ) && $this->product_code_taken( (string) $data['sku'], $product_id ) ) {
            return new WP_Error(
                'tc_duplicate_product_code',
                sprintf( 'Product Code "%s" is already in use by another product.', $data['sku'] ),
                [ 'status' => 409 ]
            );
        }

        $updated = $this->repo->save( $data, $product_id, $lang );

        if ( ! $updated ) {
            global $wpdb;
            $db_err = $wpdb->last_error ?? 'unknown';
            return new WP_Error( 'tc_product_update_failed', 'Could not update product. DB: ' . $db_err, [ 'status' => 500 ] );
        }

        // Sync tags if provided.
        $tags = $request->get_param( 'tags' );
        if ( is_array( $tags ) ) {
            ( new TagManager() )->sync_product_tags( $product_id, $tags );
        }

        LogService::info( 'products', 'update', 'Product updated: #' . $product_id, [
            'object_type'    => 'product',
            'object_id'      => $product_id,
            'product_name'   => sanitize_text_field( $request->get_param( 'name' ) ?? '' ),
            'fields_changed' => array_keys( array_filter( $request->get_params(), function( $v, $k ) {
                return $v !== null && ! in_array( $k, [ 'id', 'lang', '_wpnonce', '_locale' ], true );
            }, ARRAY_FILTER_USE_BOTH ) ),
        ] );

        return new WP_REST_Response( $this->with_url( $this->repo->get( $product_id, $lang ) ) );
    }

    /** DELETE /products/{id} */
    public function delete_item( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $product    = $this->repo->get( $product_id );

        if ( ! $product ) {
            return new WP_Error( 'tc_product_not_found', 'Product not found.', [ 'status' => 404 ] );
        }

        $this->repo->delete( $product_id );

        LogService::info( 'products', 'delete', 'Product deleted: #' . $product_id . ' — ' . ( $product['name'] ?? '' ), [
            'object_type'  => 'product',
            'object_id'    => $product_id,
            'product_name' => $product['name'] ?? '',
            'product_code' => $product['product_code'] ?? '',
        ] );

        return new WP_REST_Response( [ 'deleted' => true, 'id' => $product_id ] );
    }

    /** GET /products/{id}/variations */
    public function get_variations( WP_REST_Request $request ): WP_REST_Response {
        $lang = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        return new WP_REST_Response( $this->repo->get_variations( (int) $request->get_param( 'id' ), $lang ) );
    }

    /** PATCH /products/{id}/stock */
    public function update_stock( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $delta      = (int) $request->get_param( 'delta' );
        $new_qty    = $this->repo->update_stock( $product_id, $delta );

        LogService::info( 'products', 'update', 'Stock adjusted: product #' . $product_id . ' delta=' . $delta . ' new_qty=' . $new_qty, [
            'object_type' => 'product',
            'object_id'   => $product_id,
            'delta'        => $delta,
            'new_quantity' => $new_qty,
        ] );

        return new WP_REST_Response( [ 'product_id' => $product_id, 'stock_quantity' => $new_qty ] );
    }

    // ── Image endpoints ───────────────────────────────────────────────────────

    /** POST /products/{id}/images — upload image file via FormData */
    public function upload_image( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $files      = $request->get_file_params();

        if ( empty( $files['image'] ) ) {
            return new WP_Error( 'tc_no_file', 'No image file provided.', [ 'status' => 400 ] );
        }

        // ── S128 #9: enforce app-level image_max_file_size before WP handles upload ──
        $size_check = \TitanCart\Modules\Products\ImageService::validate_upload_size( $files['image'] );
        if ( \is_wp_error( $size_check ) ) {
            return $size_check;
        }

        // Require media functions.
        if ( ! \function_exists( 'media_handle_upload' ) ) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            require_once ABSPATH . 'wp-admin/includes/file.php';
            require_once ABSPATH . 'wp-admin/includes/media.php';
        }

        // Upload to WordPress media library.
        $attachment_id = \media_handle_upload( 'image', 0 );
        if ( \is_wp_error( $attachment_id ) ) {
            return new WP_Error( 'tc_upload_failed', $attachment_id->get_error_message(), [ 'status' => 500 ] );
        }

        // Determine position and is_main.
        $db = \titancart_db();
        $table = $db->table( 'product_images' );
        $existing_count = (int) $db->getField(
            "SELECT COUNT(*) FROM {$table} WHERE product_id = ?i",
            $product_id
        );
        $is_main  = $existing_count === 0 ? 1 : 0;
        $position = $existing_count;

        // Insert record.
        $image_id = $db->insert( 'product_images', [
            'product_id'       => $product_id,
            'wp_attachment_id' => $attachment_id,
            'position'         => $position,
            'is_main'          => $is_main,
            'alt_text'         => '',
        ] );

        // Build response matching get_images() format.
        $url   = \wp_get_attachment_image_url( $attachment_id, 'full' ) ?: '';
        $thumb = \wp_get_attachment_image_url( $attachment_id, 'medium' ) ?: '';

        return new WP_REST_Response( [
            'id'                => $image_id,
            'image_id'          => $image_id,
            'wp_attachment_id'  => $attachment_id,
            'url'               => $url,
            'image_url'         => $url,
            'thumb'             => $thumb,
            'position'          => $position,
            'is_main'           => $is_main,
            'type'              => $is_main ? 'main' : 'additional',
            'alt_text'          => '',
        ], 201 );
    }

    /**
     * POST /products/stage-image — upload an image to the WP media library
     * WITHOUT associating it to a product (S308).
     *
     * Used by the editor when creating a NEW product so images can be added
     * before the first save. Returns the attachment id + URLs; the create
     * body then carries `images: [{ wp_attachment_id, ... }]` which
     * sanitize_product_input() → repo->save() → save_images() persists.
     */
    public function stage_image( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $files = $request->get_file_params();

        if ( empty( $files['image'] ) ) {
            return new WP_Error( 'tc_no_file', 'No image file provided.', [ 'status' => 400 ] );
        }

        // Enforce app-level image_max_file_size before WP handles the upload
        // (mirrors upload_image()).
        $size_check = \TitanCart\Modules\Products\ImageService::validate_upload_size( $files['image'] );
        if ( \is_wp_error( $size_check ) ) {
            return $size_check;
        }

        if ( ! \function_exists( 'media_handle_upload' ) ) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            require_once ABSPATH . 'wp-admin/includes/file.php';
            require_once ABSPATH . 'wp-admin/includes/media.php';
        }

        // Upload to the media library with no parent post (0 = unattached).
        $attachment_id = \media_handle_upload( 'image', 0 );
        if ( \is_wp_error( $attachment_id ) ) {
            return new WP_Error( 'tc_upload_failed', $attachment_id->get_error_message(), [ 'status' => 500 ] );
        }

        $url   = \wp_get_attachment_image_url( $attachment_id, 'full' ) ?: '';
        $thumb = \wp_get_attachment_image_url( $attachment_id, 'medium' ) ?: '';

        return new WP_REST_Response( [
            'wp_attachment_id' => $attachment_id,
            'url'              => $url,
            'image_url'        => $url,
            'thumb'            => $thumb,
            'alt_text'         => '',
            'staged'           => true,
        ], 201 );
    }

    /**
     * DELETE /products/stage-image/{attachment_id} — remove a STAGED image's
     * media-library attachment that was never persisted to a product (S308).
     *
     * Safety: only deletes the WP attachment when no product_images row
     * references it, so a media file shared/reused by a real product is never
     * removed. Mirrors the unreferenced-cleanup guard in delete_image().
     */
    public function delete_staged_image( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $attachment_id = (int) $request->get_param( 'attachment_id' );
        if ( ! $attachment_id ) {
            return new WP_Error( 'tc_bad_request', 'attachment_id required.', [ 'status' => 400 ] );
        }

        $db    = \titancart_db();
        $table = $db->table( 'product_images' );

        $in_use = (int) $db->getField(
            "SELECT COUNT(*) FROM {$table} WHERE wp_attachment_id = ?i",
            $attachment_id
        );

        // Only delete the orphaned media file; never touch one a product uses.
        if ( $in_use === 0 ) {
            \wp_delete_attachment( $attachment_id, true );
        }

        return new WP_REST_Response( [ 'deleted' => true, 'wp_attachment_id' => $attachment_id ] );
    }

    /** DELETE /products/{id}/images/{image_id} */
    public function delete_image( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $image_id   = (int) $request->get_param( 'image_id' );

        $db    = \titancart_db();
        $table = $db->table( 'product_images' );

        $row = $db->getRow(
            "SELECT * FROM {$table} WHERE id = ?i AND product_id = ?i",
            $image_id,
            $product_id
        );

        if ( ! $row ) {
            return new WP_Error( 'tc_not_found', 'Image not found.', [ 'status' => 404 ] );
        }

        // Remove the record.
        $db->query( "DELETE FROM {$table} WHERE id = ?i", $image_id );

        // Optionally delete the WP attachment if no other products use it.
        $attachment_id = (int) ( $row['wp_attachment_id'] ?? 0 );
        if ( $attachment_id ) {
            $other_uses = (int) $db->getField(
                "SELECT COUNT(*) FROM {$table} WHERE wp_attachment_id = ?i",
                $attachment_id
            );
            if ( $other_uses === 0 ) {
                \wp_delete_attachment( $attachment_id, true );
            }
        }

        return new WP_REST_Response( [ 'deleted' => true, 'image_id' => $image_id ] );
    }

    /**
     * PUT /products/{id}/images/{image_id}
     *
     * Update a product image's metadata. Currently supports: alt_text.
     * Extensible for future fields (title, caption) without changing the endpoint.
     */
    public function update_image( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $image_id   = (int) $request->get_param( 'image_id' );
        $body       = $request->get_json_params();

        $db    = \titancart_db();
        $table = $db->table( 'product_images' );

        // Verify the image belongs to this product.
        $exists = $db->getField(
            "SELECT id FROM {$table} WHERE id = ?i AND product_id = ?i",
            $image_id,
            $product_id
        );
        if ( ! $exists ) {
            return new WP_Error( 'tc_not_found', 'Image not found.', [ 'status' => 404 ] );
        }

        // Whitelist of updatable fields.
        $updates = [];
        if ( is_array( $body ) && array_key_exists( 'alt_text', $body ) ) {
            $updates['alt_text'] = sanitize_text_field( (string) $body['alt_text'] );
        }

        if ( empty( $updates ) ) {
            return new WP_REST_Response( [
                'success'  => true,
                'image_id' => $image_id,
                'updated'  => 0,
            ] );
        }

        $db->update( 'product_images', $updates, [ 'id' => $image_id ] );

        return new WP_REST_Response( array_merge(
            [ 'success' => true, 'image_id' => $image_id ],
            $updates
        ) );
    }

    /** PUT /products/{id}/images/{image_id}/set-main */
    public function set_main_image( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $image_id   = (int) $request->get_param( 'image_id' );

        $db    = \titancart_db();
        $table = $db->table( 'product_images' );

        // Unset all is_main for this product.
        $db->query( "UPDATE {$table} SET is_main = 0 WHERE product_id = ?i", $product_id );

        // Set the target image as main.
        $db->query( "UPDATE {$table} SET is_main = 1 WHERE id = ?i AND product_id = ?i", $image_id, $product_id );

        return new WP_REST_Response( [ 'success' => true, 'main_image_id' => $image_id ] );
    }

    /** PUT /products/{id}/images/reorder — accepts { ids: [3, 1, 5, 2] } */
    public function reorder_images( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $product_id = (int) $request->get_param( 'id' );
        $body       = $request->get_json_params();
        $ids        = $body['ids'] ?? [];

        if ( ! is_array( $ids ) || empty( $ids ) ) {
            return new WP_REST_Response( [ 'error' => 'ids array required.' ], 400 );
        }

        $db    = \titancart_db();
        $table = $db->table( 'product_images' );

        foreach ( $ids as $pos => $id ) {
            $db->query(
                "UPDATE {$table} SET position = ?i WHERE id = ?i AND product_id = ?i",
                (int) $pos,
                (int) $id,
                $product_id
            );
        }

        return new WP_REST_Response( [ 'success' => true ] );
    }


    // ── Product Attributes ────────────────────────────────────────────────────

    /**
     * GET /products/{id}/attributes
     *
     * Returns all applicable attributes for this product based on its categories,
     * plus any attributes already assigned (even if category scope changed).
     * Each attribute includes its current value for this product and available choices.
     */
    public function get_product_attributes( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $lang       = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $db         = \titancart_db();

        // Get the product's category IDs.
        $cat_ids = $this->repo->get_category_ids( $product_id );

        // Get all active non-group features that either:
        // (a) have no category restriction (no rows in product_feature_categories), or
        // (b) are scoped to at least one of this product's categories
        $t_f   = $db->table( 'product_features' );
        $t_ft  = $db->table( 'product_feature_translations' );
        $t_fc  = $db->table( 'product_feature_categories' );
        $t_fv  = $db->table( 'product_feature_variants' );
        $t_fvt = $db->table( 'product_feature_variant_translations' );

        $cat_clause = '';
        if ( ! empty( $cat_ids ) ) {
            $placeholders = implode( ',', array_map( 'intval', $cat_ids ) );
            $cat_clause = " AND (
                NOT EXISTS (SELECT 1 FROM {$t_fc} fc2 WHERE fc2.feature_id = f.id)
                OR EXISTS (SELECT 1 FROM {$t_fc} fc3 WHERE fc3.feature_id = f.id AND fc3.category_id IN ({$placeholders}))
            )";
        } else {
            // No categories — only show features with no category restriction
            $cat_clause = " AND NOT EXISTS (SELECT 1 FROM {$t_fc} fc2 WHERE fc2.feature_id = f.id)";
        }

        $features = $db->getArray(
            "SELECT f.id, f.type AS feature_type, f.filter_type, f.purpose, f.parent_id, f.global_prices,
                    ft.name, ft.description AS feature_description
             FROM {$t_f} f
             LEFT JOIN {$t_ft} ft ON ft.feature_id = f.id AND ft.language_code = ?s
             WHERE f.status = 'active' AND f.type != 'G'
             {$cat_clause}
             ORDER BY f.position ASC, f.id ASC",
            $lang
        );

        // Get current values for this product.
        $current_values = $this->repo->get_feature_values( $product_id, $lang );
        $value_map    = [];  // single-value features: keyed by feature_id
        $multi_map    = [];  // variation_shared features: keyed by feature_id → array of variant_ids
        $image_map    = [];  // variation_shared features: keyed by feature_id → { variant_id => image_id }
        $price_map    = [];  // S478: variation_shared features: feature_id → { variant_id => price modifier }
        foreach ( $current_values as $cv ) {
            $fid = (int) $cv['feature_id'];
            // Does this feature allow MULTIPLE values per product? True for
            // variation_shared (selectable variants) AND for a filter-purpose
            // Color-Swatch attribute (type 'E') — e.g. a "Color Family" filter a
            // single product can belong to several of. Both store one
            // product_feature_values row per selected variant_id, so the editor
            // needs the variant_ids[] shape back (not a single variant_id).
            $is_shared = false;
            foreach ( $features as $_chk ) {
                if ( (int) $_chk['id'] !== $fid ) {
                    continue;
                }
                $_purpose = $_chk['purpose'] ?? '';
                $_ftype   = $_chk['feature_type'] ?? '';
                if ( $_purpose === 'variation_shared'
                    || ( $_purpose === 'filter' && $_ftype === 'E' ) ) {
                    $is_shared = true;
                }
                break;
            }
            if ( $is_shared && ! empty( $cv['variant_id'] ) ) {
                $vid = (int) $cv['variant_id'];
                $multi_map[ $fid ][] = $vid;
                if ( ! empty( $cv['image_id'] ) ) {
                    $image_map[ $fid ][ $vid ] = (int) $cv['image_id'];
                }
                if ( isset( $cv['value_float'] ) && $cv['value_float'] !== null && $cv['value_float'] !== '' ) {
                    $price_map[ $fid ][ $vid ] = round( (float) $cv['value_float'], 4 );
                }
            } else {
                $value_map[ $fid ] = $cv;
            }
        }

        // Attach choices (variants) and current value to each feature.
        foreach ( $features as &$feat ) {
            $fid = (int) $feat['id'];

            // Choices for select/multi/color/variation types — always check for variants
            // (T/N types can have variants when used as variation attributes).
            // Roadmap #11: choice order honours the attribute's sort_mode.
            $feat['choices'] = $db->getArray(
                "SELECT v.id, vt.name, v.price_modifier
                 FROM {$t_fv} v
                 INNER JOIN {$t_f} f2 ON f2.id = v.feature_id
                 LEFT JOIN {$t_fvt} 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( 'f2', 'v', 'vt' ),
                $lang,
                $fid
            );
            // S478: site-wide amount per choice, only meaningful when the attribute has it on.
            foreach ( $feat['choices'] as &$_c ) {
                $_c['price_modifier'] = ( ! empty( $feat['global_prices'] ) && $_c['price_modifier'] !== null && $_c['price_modifier'] !== '' ) ? (float) $_c['price_modifier'] : null;
            }
            unset( $_c );
            $feat['global_prices'] = (int) ( $feat['global_prices'] ?? 0 );

            // Current value — for variation_shared, return variant_ids + image mapping.
            if ( isset( $multi_map[ $fid ] ) ) {
                $val = [ 'variant_ids' => $multi_map[ $fid ] ];
                if ( ! empty( $image_map[ $fid ] ) ) {
                    $val['variant_images'] = $image_map[ $fid ]; // { variant_id: image_id }
                }
                if ( ! empty( $price_map[ $fid ] ) ) {
                    $val['variant_prices'] = $price_map[ $fid ]; // S478: { variant_id: modifier }
                }
                $feat['value'] = $val;
            } elseif ( isset( $value_map[ $fid ] ) ) {
                $v = $value_map[ $fid ];
                $feat['value'] = [
                    'variant_id'  => $v['variant_id'] ? (int) $v['variant_id'] : null,
                    'value_str'   => $v['value_str'],
                    'value_int'   => $v['value_int'] !== null ? (int) $v['value_int'] : null,
                    'value_float' => $v['value_float'] !== null ? (float) $v['value_float'] : null,
                    'value_text'  => $v['value_text'],
                    'value_date'  => $v['value_date'],
                ];
            } else {
                $feat['value'] = null;
            }

            // Group name if nested under a parent.
            if ( (int) $feat['parent_id'] > 0 ) {
                $feat['group_name'] = $db->getField(
                    "SELECT ft2.name FROM {$t_ft} ft2 WHERE ft2.feature_id = ?i AND ft2.language_code = ?s",
                    (int) $feat['parent_id'],
                    $lang
                );
            }
        }
        unset( $feat );

        return new WP_REST_Response( $features );
    }

    /**
     * PUT /products/{id}/attributes
     *
     * Expects { values: [ { feature_id, variant_id?, value_str?, value_int?, value_float?, value_text?, value_date? }, ... ] }
     */
    public function save_product_attributes( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $body       = $request->get_json_params();
        $values     = $body['values'] ?? [];

        if ( ! is_array( $values ) ) {
            return new WP_REST_Response( [ 'error' => 'values array required.' ], 400 );
        }

        // Filter out empty values and expand variant_ids arrays (variation_shared).
        $clean = [];
        foreach ( $values as $v ) {
            if ( empty( $v['feature_id'] ) ) continue;

            // Expand variant_ids array into separate rows (variation_shared attributes).
            if ( ! empty( $v['variant_ids'] ) && is_array( $v['variant_ids'] ) ) {
                $img_map = $v['variant_images'] ?? [];
                $prc_map = is_array( $v['variant_prices'] ?? null ) ? $v['variant_prices'] : []; // S478
                foreach ( $v['variant_ids'] as $vid ) {
                    $row = [
                        'feature_id' => (int) $v['feature_id'],
                        'variant_id' => (int) $vid,
                    ];
                    if ( ! empty( $img_map[ (string) $vid ] ) ) {
                        $row['image_id'] = (int) $img_map[ (string) $vid ];
                    }
                    // S478: per-variant price modifier → value_float. A number
                    // (including 0 — "no change for this product even though the
                    // attribute has a site-wide amount") is stored; blank = inherit.
                    $prc = $prc_map[ (string) $vid ] ?? ( $prc_map[ (int) $vid ] ?? null );
                    if ( $prc !== null && $prc !== '' && is_numeric( $prc ) ) {
                        $row['value_float'] = round( (float) $prc, 4 );
                    }
                    $clean[] = $row;
                }
                continue;
            }

            $has_value = ! empty( $v['variant_id'] )
                      || ( isset( $v['value_str'] ) && $v['value_str'] !== '' )
                      || ( isset( $v['value_int'] ) && $v['value_int'] !== '' && $v['value_int'] !== null )
                      || ( isset( $v['value_float'] ) && $v['value_float'] !== '' && $v['value_float'] !== null )
                      || ( isset( $v['value_text'] ) && $v['value_text'] !== '' )
                      || ( isset( $v['value_date'] ) && $v['value_date'] !== '' );
            if ( $has_value ) {
                $clean[] = $v;
            }
        }

        $this->repo->save_feature_values( $product_id, $clean );

        return new WP_REST_Response( [ 'success' => true ] );
    }

    // ── Product Group ─────────────────────────────────────────────────────────

    /**
     * GET /products/check-slug?slug=&exclude= — is this product slug free?
     *
     * Returns { available, slug (sanitized), suggestion (a free alternative if taken) }.
     * `exclude` is the current product id (so editing a product doesn't flag its own slug).
     */
    public function check_slug( WP_REST_Request $request ): WP_REST_Response {
        $exclude = (int) $request->get_param( 'exclude' );
        $slug    = sanitize_title( (string) $request->get_param( 'slug' ) );
        $db      = \titancart_db();
        $t       = $db->table( 'products' );

        if ( $slug === '' ) {
            return new WP_REST_Response( [ 'available' => true, 'slug' => '', 'suggestion' => '' ] );
        }

        // S362 / KI-52. `deleted_at IS NULL` is mandatory here: products are
        // SOFT-deleted, and `ProductRepository::get()` L87 + `list()` L171 both
        // filter on it. Counting soft-deleted rows made a dead product's slug
        // permanently unusable — the admin reported "already in use" for a URL
        // no storefront route can ever resolve. Same reasoning as
        // product_code_taken(): the API and the UI must agree about what exists.
        $taken = function ( string $s ) use ( $db, $t, $exclude ) {
            return (int) $db->getField(
                "SELECT COUNT(*) FROM {$t} WHERE slug = ?s AND deleted_at IS NULL"
                . ( $exclude > 0 ? " AND id != {$exclude}" : '' ),
                $s
            ) > 0;
        };

        $available  = ! $taken( $slug );
        $suggestion = $slug;
        if ( ! $available ) {
            // Mirror ProductRepository::unique_slug() so the hint matches the auto-slug.
            $n = 1;
            do { $suggestion = $slug . '-' . $n; $n++; } while ( $taken( $suggestion ) );
        }

        return new WP_REST_Response( [
            'available'  => $available,
            'slug'       => $slug,
            'suggestion' => $suggestion,
        ] );
    }

    /** GET /products/{id}/group — get group info + member products. */
    public function get_product_group( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $lang       = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $db         = \titancart_db();

        $t_gp = $db->table( 'product_group_products' );
        $t_g  = $db->table( 'product_groups' );

        // Find which group this product belongs to.
        $membership = $db->getRow(
            "SELECT gp.group_id FROM {$t_gp} gp WHERE gp.product_id = ?i",
            $product_id
        );

        if ( ! $membership ) {
            return new WP_REST_Response( [ 'group' => null, 'members' => [] ] );
        }

        $group_id = (int) $membership['group_id'];
        $group    = $db->getRow( "SELECT * FROM {$t_g} WHERE id = ?i", $group_id );

        // Get all products in the group.
        $members_raw = $db->getArray(
            "SELECT gp.product_id, gp.combination, gp.position
             FROM {$t_gp} gp
             WHERE gp.group_id = ?i
             ORDER BY gp.position ASC",
            $group_id
        );

        $members = [];
        foreach ( $members_raw as $m ) {
            $product = $this->repo->get( (int) $m['product_id'], $lang );
            if ( $product ) {
                $product['combination'] = json_decode( $m['combination'], true ) ?: [];
                $product['position']    = (int) $m['position'];
                $members[] = $product;
            }
        }

        // Resolve combination labels.
        $feature_ids = json_decode( $group['feature_ids'] ?? '[]', true ) ?: [];
        $features = [];
        $t_ft  = $db->table( 'product_feature_translations' );
        $t_fvt = $db->table( 'product_feature_variant_translations' );
        foreach ( $feature_ids as $fid ) {
            $fname = $db->getField(
                "SELECT name FROM {$t_ft} WHERE feature_id = ?i AND language_code = 'en'",
                $fid
            );
            $features[] = [ 'id' => (int) $fid, 'name' => $fname ?: __( '(deleted attribute)', 'titancart-lite' ) ];
        }

        // For each member, resolve variant names in the combination.
        foreach ( $members as &$member ) {
            $combo_labels = [];
            foreach ( $member['combination'] as $fid => $vid ) {
                $vname = $db->getField(
                    "SELECT name FROM {$t_fvt} WHERE variant_id = ?i AND language_code = 'en'",
                    (int) $vid
                );
                $combo_labels[] = $vname ?: __( '(deleted choice)', 'titancart-lite' );
            }
            $member['combination_label'] = implode( ' / ', $combo_labels );
        }
        unset( $member );

        return new WP_REST_Response( [
            'group'    => [
                'id'                => $group_id,
                'group_code'        => $group['group_code'],
                'feature_ids'       => $feature_ids,
                'features'          => $features,
                'display_as_single' => (int) ( $group['display_as_single'] ?? 0 ),
                'display_name'      => (string) ( $group['display_name'] ?? '' ),
            ],
            'members'  => $members,
        ] );
    }

    /** PUT /products/{id}/group — update group code. */
    public function update_product_group( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $body       = $request->get_json_params();
        $db         = \titancart_db();

        $t_gp = $db->table( 'product_group_products' );
        $t_g  = $db->table( 'product_groups' );

        $membership = $db->getRow( "SELECT group_id FROM {$t_gp} WHERE product_id = ?i", $product_id );
        if ( ! $membership ) {
            return new WP_REST_Response( [ 'error' => 'Product not in a group.' ], 400 );
        }

        $group_id = (int) $membership['group_id'];
        $update   = [];

        if ( isset( $body['group_code'] ) ) {
            $code = sanitize_text_field( $body['group_code'] );
            if ( ! $code ) {
                return new WP_REST_Response( [ 'error' => 'Group code cannot be empty.' ], 400 );
            }
            // Check uniqueness.
            $existing = $db->getField(
                "SELECT id FROM {$t_g} WHERE group_code = ?s AND id != ?i",
                $code, $group_id
            );
            if ( $existing ) {
                return new WP_REST_Response( [ 'error' => 'Group code "' . $code . '" is already in use.' ], 400 );
            }
            $update['group_code'] = $code;
        }

        // roadmap #15: group-level "display as a single product" toggle + the
        // optional clean storefront display name for the representative card.
        if ( array_key_exists( 'display_as_single', $body ) ) {
            $update['display_as_single'] = ! empty( $body['display_as_single'] ) ? 1 : 0;
        }
        if ( array_key_exists( 'display_name', $body ) ) {
            $update['display_name'] = mb_substr(
                sanitize_text_field( (string) $body['display_name'] ), 0, 255
            );
        }

        if ( ! empty( $update ) ) {
            $db->update( 'product_groups', $update, [ 'id' => $group_id ] );
        }

        return new WP_REST_Response( [
            'success'           => true,
            'group_code'        => $update['group_code'] ?? null,
            'display_as_single' => $update['display_as_single'] ?? null,
            'display_name'      => $update['display_name'] ?? null,
        ] );
    }

    /**
     * POST /products/{id}/group/create-variations
     *
     * Creates new child products from attribute choice combinations.
     * Supports multi-attribute variations (cartesian product).
     *
     * Body: { features: [ { feature_id: 42, variant_ids: [15,16,17] }, { feature_id: 55, variant_ids: [10,11] } ] }
     * Also supports legacy single: { feature_id: 42, variant_ids: [15,16,17] }
     *
     * Generates cartesian product: Color(Red,Blue) × Size(S,M) = Red/S, Red/M, Blue/S, Blue/M
     * Parent's own combination automatically excluded.
     *
     * S478 — attribute pricing. Each feature entry may carry
     *   prices: { "<variant_id>": <number> }      (blank / missing = no change)
     * and the body may carry
     *   price_mode: 'add' | 'set'                   (default 'add')
     * 'add'  → child price = parent price + Σ of the selected variants' amounts
     *          (negative amounts allowed; a parent list price > 0 shifts by the
     *          same delta so the compare-at stays consistent).
     * 'set'  → child price = the amount entered for the FIRST feature (in the
     *          order sent) whose selected variant has one; parent price when none.
     * Child prices never go below 0.
     *
     * S478 (Sean) — copy_description / copy_images (bool): clone the parent's
     * description + short description and/or its image gallery onto every child.
     */
    public function create_variations( WP_REST_Request $request ): WP_REST_Response {
      try {
        $product_id = (int) $request->get_param( 'id' );
        $lang       = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $body       = $request->get_json_params();
        $db         = \titancart_db();

        // Accept both formats.
        $features_input = $body['features'] ?? [];
        if ( empty( $features_input ) && ! empty( $body['feature_id'] ) ) {
            $features_input = [ [ 'feature_id' => $body['feature_id'], 'variant_ids' => $body['variant_ids'] ?? [] ] ];
        }

        if ( empty( $features_input ) ) {
            return new WP_REST_Response( [ 'error' => 'features array required.' ], 400 );
        }

        // S478: attribute price amounts, keyed feature_id => variant_id => float.
        $price_mode = ( isset( $body['price_mode'] ) && 'set' === $body['price_mode'] ) ? 'set' : 'add';
        $price_map  = [];

        // S478 (Sean): optionally clone the parent's description / short
        // description and its image gallery onto every new variation, so a
        // family of sizes that shares copy and photos needs no per-child edits.
        $copy_description = ! empty( $body['copy_description'] );
        $copy_images      = ! empty( $body['copy_images'] );

        $t_g   = $db->table( 'product_groups' );
        $t_gp  = $db->table( 'product_group_products' );
        $t_fvt = $db->table( 'product_feature_variant_translations' );

        // ── Validate parent has saved values for ALL selected features ──
        $parent_values   = $this->repo->get_feature_values( $product_id, $lang );
        $parent_combo    = [];   // fid => vid
        $all_feature_ids = [];
        $variant_sets    = [];   // fid => [vid, vid, ...]

        foreach ( $features_input as $fi ) {
            $fid  = (int) ( $fi['feature_id'] ?? 0 );
            $vids = array_map( 'intval', $fi['variant_ids'] ?? [] );
            if ( ! $fid || empty( $vids ) ) continue;

            // S478: per-variant amounts for this feature (blank strings = none).
            $amounts = $fi['prices'] ?? ( $fi['price_adjust'] ?? [] );
            if ( is_array( $amounts ) ) {
                foreach ( $amounts as $vid_key => $amt ) {
                    if ( $amt === '' || $amt === null || ! is_numeric( $amt ) ) {
                        continue;
                    }
                    $price_map[ $fid ][ (int) $vid_key ] = (float) $amt;
                }
            }

            $parent_vid = null;
            foreach ( $parent_values as $pv ) {
                if ( (int) $pv['feature_id'] === $fid && ! empty( $pv['variant_id'] ) ) {
                    $parent_vid = (int) $pv['variant_id'];
                }
            }
            if ( ! $parent_vid ) {
                $fname = $db->getField(
                    "SELECT name FROM {$db->table('product_feature_translations')} WHERE feature_id = ?i AND language_code = 'en'", $fid
                );
                return new WP_REST_Response( [
                    'error' => 'This product has no saved value for "' . ( $fname ?: 'Attribute #' . $fid ) . '". Go to the Attributes tab and set it first.',
                ], 400 );
            }

            $parent_combo[ $fid ] = $parent_vid;
            $all_feature_ids[]    = $fid;
            $variant_sets[ $fid ] = $vids;
        }

        if ( empty( $all_feature_ids ) ) {
            return new WP_REST_Response( [ 'error' => 'No valid features selected.' ], 400 );
        }

        // ── Generate cartesian product of all variant combinations ──
        $combinations = [ [] ];
        foreach ( $variant_sets as $fid => $vids ) {
            $new = [];
            foreach ( $combinations as $c ) {
                foreach ( $vids as $vid ) {
                    $new[] = $c + [ $fid => $vid ];
                }
            }
            $combinations = $new;
        }

        // Remove the parent's own combination.
        $combinations = array_values( array_filter( $combinations, function ( $c ) use ( $parent_combo ) {
            return $c !== $parent_combo;
        } ) );

        if ( empty( $combinations ) ) {
            return new WP_REST_Response( [ 'error' => 'All selected combinations already exist (the parent covers its own values).' ], 400 );
        }

        // ── Find or create the group ──
        $existing = $db->getRow( "SELECT gp.group_id FROM {$t_gp} gp WHERE gp.product_id = ?i", $product_id );

        if ( $existing ) {
            $group_id       = (int) $existing['group_id'];
            $group          = $db->getRow( "SELECT * FROM {$t_g} WHERE id = ?i", $group_id );
            $g_fids         = json_decode( $group['feature_ids'] ?? '[]', true ) ?: [];
            $changed = false;
            foreach ( $all_feature_ids as $fid ) {
                if ( ! in_array( $fid, $g_fids, true ) ) { $g_fids[] = $fid; $changed = true; }
            }
            if ( $changed ) {
                $db->update( 'product_groups', [ 'feature_ids' => wp_json_encode( $g_fids ) ], [ 'id' => $group_id ] );
            }
            // Update parent's combination to include all features.
            $pc_str = [];
            foreach ( $parent_combo as $fid => $vid ) { $pc_str[ (string) $fid ] = $vid; }
            $db->update( 'product_group_products', [ 'combination' => wp_json_encode( $pc_str ) ], [
                'group_id' => $group_id, 'product_id' => $product_id,
            ] );
        } else {
            $group_code = 'PG-' . strtoupper( substr( md5( uniqid( (string) $product_id, true ) ), 0, 8 ) );
            $group_id   = $db->insert( 'product_groups', [
                'group_code'  => $group_code,
                'feature_ids' => wp_json_encode( $all_feature_ids ),
            ] );
            if ( ! $group_id ) {
                return new WP_REST_Response( [
                    'error' => 'Failed to create product group. Ensure the plugin has been deactivated and reactivated to create new tables.',
                ], 500 );
            }
            $pc_str = [];
            foreach ( $parent_combo as $fid => $vid ) { $pc_str[ (string) $fid ] = $vid; }
            $db->insert( 'product_group_products', [
                'group_id' => $group_id, 'product_id' => $product_id,
                'combination' => wp_json_encode( $pc_str ), 'position' => 0,
            ] );
        }

        // ── Existing combos for dedup ──
        $existing_members = $db->getArray( "SELECT product_id, combination FROM {$t_gp} WHERE group_id = ?i", $group_id );
        $existing_combos  = [];
        foreach ( $existing_members as $em ) {
            $c = json_decode( $em['combination'], true ) ?: [];
            ksort( $c );
            $existing_combos[] = array_map( 'intval', $c );
        }

        // ── Get parent for cloning ──
        $parent = $this->repo->get( $product_id, $lang );
        if ( ! $parent ) {
            return new WP_REST_Response( [ 'error' => 'Parent product not found.' ], 404 );
        }

        $pos     = count( $existing_members );
        $created = [];
        $vname_cache = [];

        foreach ( $combinations as $combo ) {
            ksort( $combo );
            $combo_int = array_map( 'intval', $combo );

            // Check duplicate.
            $dup = false;
            foreach ( $existing_combos as $ec ) { if ( $combo_int == $ec ) { $dup = true; break; } }
            if ( $dup ) continue;

            // Build name parts and feature values.
            $name_parts   = [];
            $feature_vals = [];
            foreach ( $combo as $fid => $vid ) {
                if ( ! isset( $vname_cache[ $vid ] ) ) {
                    $vname_cache[ $vid ] = $db->getField(
                        "SELECT name FROM {$t_fvt} WHERE variant_id = ?i AND language_code = 'en'", $vid
                    ) ?: 'V' . $vid;
                }
                $name_parts[]   = $vname_cache[ $vid ];
                $feature_vals[] = [ 'feature_id' => (int) $fid, 'variant_id' => (int) $vid ];
            }

            // Generate unique random product code (alphanumeric, like Group ID).
            do {
                $child_sku = strtoupper( substr( md5( uniqid( (string) $product_id . '-' . $pos . '-' . microtime( true ), true ) ), 0, 12 ) );
            } while ( $db->getField( "SELECT id FROM {$db->table('products')} WHERE sku = ?s", $child_sku ) );

            // Create child product.
            // Compute child name — strip any existing variant suffix from parent name.
            $raw_pname = $parent['name'] ?? 'Product';
            $dp = strpos( $raw_pname, ' - ' );
            $child_base = ( $dp !== false && $dp > 0 ) ? substr( $raw_pname, 0, $dp ) : $raw_pname;
            $child_name = trim( $child_base ) . ' - ' . implode( ' / ', $name_parts );

            // S478: attribute pricing — derive this combination's price from the
            // amounts entered per attribute value (see the docblock).
            $parent_price = (float) ( $parent['price'] ?? 0 );
            $parent_list  = (float) ( $parent['list_price'] ?? 0 );
            $child_price  = $parent_price;
            $child_list   = $parent_list;
            if ( ! empty( $price_map ) ) {
                if ( 'set' === $price_mode ) {
                    foreach ( $all_feature_ids as $pf ) {
                        $pv = $combo[ $pf ] ?? null;
                        if ( $pv !== null && isset( $price_map[ $pf ][ (int) $pv ] ) ) {
                            $child_price = (float) $price_map[ $pf ][ (int) $pv ];
                            break;
                        }
                    }
                } else {
                    $delta = 0.0;
                    foreach ( $combo as $pf => $pv ) {
                        $delta += (float) ( $price_map[ (int) $pf ][ (int) $pv ] ?? 0 );
                    }
                    $child_price = $parent_price + $delta;
                    if ( $parent_list > 0 ) {
                        $child_list = $parent_list + $delta;
                    }
                }
            }
            $child_price = round( max( 0.0, $child_price ), 4 );
            $child_list  = round( max( 0.0, $child_list ), 4 );

            // S478: optional copies of the parent's copy + gallery.
            $child_translation = [ 'name' => $child_name ];
            if ( $copy_description ) {
                foreach ( [ 'description', 'short_desc' ] as $tk ) {
                    if ( isset( $parent[ $tk ] ) && '' !== (string) $parent[ $tk ] ) {
                        $child_translation[ $tk ] = (string) $parent[ $tk ];
                    }
                }
            }
            $child_images = null;
            if ( $copy_images && ! empty( $parent['images'] ) && is_array( $parent['images'] ) ) {
                $child_images = [];
                foreach ( $parent['images'] as $pi => $img ) {
                    $att = (int) ( $img['wp_attachment_id'] ?? 0 );
                    if ( $att <= 0 ) {
                        continue;
                    }
                    $child_images[] = [
                        'wp_attachment_id' => $att,
                        'position'         => (int) ( $img['position'] ?? $pi ),
                        'is_main'          => (int) ( $img['is_main'] ?? 0 ),
                        'alt_text'         => (string) ( $img['alt_text'] ?? '' ),
                    ];
                }
                if ( empty( $child_images ) ) {
                    $child_images = null;
                }
            }

            $child_data = [
                'status'            => $parent['status'] ?? 'active',
                'price'             => $child_price,
                'list_price'        => $child_list,
                'stock_quantity'    => (int) ( $parent['stock_quantity'] ?? 1 ),
                'sku'               => $child_sku,
                'weight'            => (float) ( $parent['weight'] ?? 0 ),
                'track_inventory'   => (int) ( $parent['track_inventory'] ?? 1 ),
                'requires_shipping' => (int) ( $parent['requires_shipping'] ?? 1 ),
                'translation'       => $child_translation,
                'categories'        => $parent['categories'] ?? [],
            ];
            if ( null !== $child_images ) {
                $child_data['images'] = $child_images;
            }
            $child_id = $this->repo->save( $child_data, 0, $lang );

            if ( $child_id ) {
                $this->repo->save_feature_values( $child_id, $feature_vals );
                $cs = [];
                foreach ( $combo as $fid => $vid ) { $cs[ (string) $fid ] = $vid; }
                $db->insert( 'product_group_products', [
                    'group_id' => $group_id, 'product_id' => $child_id,
                    'combination' => wp_json_encode( $cs ), 'position' => $pos++,
                ] );
                $created[] = $child_id;
            }
        }

        // ── Rename the parent product to include its own combination label ──
        if ( ! empty( $created ) ) {
            $parent_name_parts = [];
            foreach ( $parent_combo as $fid => $vid ) {
                if ( ! isset( $vname_cache[ $vid ] ) ) {
                    $vname_cache[ $vid ] = $db->getField(
                        "SELECT name FROM {$t_fvt} WHERE variant_id = ?i AND language_code = 'en'", $vid
                    ) ?: 'V' . $vid;
                }
                $parent_name_parts[] = $vname_cache[ $vid ];
            }
            if ( ! empty( $parent_name_parts ) ) {
                $raw_name  = $parent['name'] ?? 'Product';
                // Strip any existing variant suffix: look for " - "
                $dash_pos = strpos( $raw_name, ' - ' );
                $base_name = ( $dash_pos !== false && $dash_pos > 0 ) ? substr( $raw_name, 0, $dash_pos ) : $raw_name;
                $new_parent_name = trim( $base_name ) . ' - ' . implode( ' / ', $parent_name_parts );

                $db->update( 'product_translations', [ 'name' => $new_parent_name ], [
                    'product_id'    => $product_id,
                    'language_code' => $lang,
                ] );
            }
        }

        return new WP_REST_Response( [ 'success' => true, 'created' => $created, 'count' => count( $created ), 'group_id' => $group_id ] );
      } catch ( \Throwable $e ) {
        titancart_log( '[TitanCart] create_variations EXCEPTION: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine() );
        return new WP_REST_Response( [ 'error' => 'Server error: ' . $e->getMessage() ], 500 );
      }
    }

    /**
     * POST /products/{id}/group/add-existing — add an existing product to this product's group.
     *
     * Body: { product_id: int, combination?: { <feature_id>: <variant_id>, ... } }
     *
     * The group's defining feature(s) come from the anchor product's variation attributes
     * (or the existing group's feature_ids). The added product's combination is resolved
     * AUTOMATICALLY from its own saved attribute values — the caller normally need not send
     * a combination at all. `combination` is only consulted to fill a feature the product
     * has no saved value for.
     *
     * If the product is missing a value for a group feature and none was supplied, the
     * response is { needs_assignment: true, product_id, feature_ids: [...] } so the UI can
     * prompt for just the missing axis. On success the group's feature_ids and the anchor's
     * own combination are healed if a legacy add had left them empty.
     */
    public function add_existing_to_group( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $lang       = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $body       = $request->get_json_params();
        $db         = \titancart_db();

        $add_product_id = (int) ( $body['product_id'] ?? 0 );
        $raw_combo      = is_array( $body['combination'] ?? null ) ? $body['combination'] : [];

        if ( ! $add_product_id ) {
            return new WP_REST_Response( [ 'error' => 'product_id required.' ], 400 );
        }

        // Explicit picks from the UI — only used to fill in a value the added product
        // doesn't already have saved on its Attributes tab.
        $explicit = [];
        foreach ( $raw_combo as $fid => $vid ) {
            $fid = (int) $fid;
            $vid = (int) $vid;
            if ( $fid && $vid ) { $explicit[ $fid ] = $vid; }
        }

        $t_gp = $db->table( 'product_group_products' );
        $t_g  = $db->table( 'product_groups' );

        // ── Resolve the group's defining feature(s) ──
        $membership = $db->getRow( "SELECT group_id FROM {$t_gp} WHERE product_id = ?i", $product_id );

        if ( $membership ) {
            $group_id          = (int) $membership['group_id'];
            $group_feature_ids = array_map( 'intval', json_decode( $db->getField( "SELECT feature_ids FROM {$t_g} WHERE id = ?i", $group_id ) ?? '[]', true ) ?: [] );
            // Heal a legacy/empty group by inferring from the anchor's variation attributes.
            if ( empty( $group_feature_ids ) ) {
                $group_feature_ids = $this->variation_feature_ids_for( $product_id, $lang );
            }
        } else {
            $group_id          = 0; // created below
            $group_feature_ids = $this->variation_feature_ids_for( $product_id, $lang );
        }
        // Last resort: features implied by any explicit picks.
        if ( empty( $group_feature_ids ) ) {
            $group_feature_ids = array_keys( $explicit );
        }

        if ( empty( $group_feature_ids ) ) {
            return new WP_REST_Response( [
                'error' => 'This product has no variation attribute set. Set one (e.g. Color) on its Attributes tab first.',
            ], 400 );
        }

        // ── Build the added product's combination ──
        // Prefer the product's OWN saved attribute values (resolved by product id);
        // only fall back to an explicit pick where it has none.
        $auto  = $this->combo_from_saved_values( $add_product_id, $group_feature_ids, $lang );
        $combo = [];
        foreach ( $group_feature_ids as $fid ) {
            if ( isset( $auto[ $fid ] ) )         { $combo[ $fid ] = $auto[ $fid ]; }
            elseif ( isset( $explicit[ $fid ] ) ) { $combo[ $fid ] = $explicit[ $fid ]; }
        }

        // Missing a value for some group feature and no pick supplied → ask the UI to prompt.
        $missing = array_values( array_diff( $group_feature_ids, array_keys( $combo ) ) );
        if ( ! empty( $missing ) ) {
            return new WP_REST_Response( [
                'needs_assignment' => true,
                'product_id'       => $add_product_id,
                'feature_ids'      => $missing,
            ] );
        }

        // ── Create or heal the group ──
        if ( ! $membership ) {
            $group_code = 'PG-' . strtoupper( substr( md5( uniqid( (string) $product_id, true ) ), 0, 8 ) );
            $group_id   = (int) $db->insert( 'product_groups', [
                'group_code'  => $group_code,
                'feature_ids' => wp_json_encode( array_values( $group_feature_ids ) ),
            ] );
            $anchor_combo = $this->combo_from_saved_values( $product_id, $group_feature_ids, $lang );
            $db->insert( 'product_group_products', [
                'group_id'    => $group_id,
                'product_id'  => $product_id,
                'combination' => wp_json_encode( $this->combo_to_string_keys( $anchor_combo ) ),
                'position'    => 0,
            ] );
        } else {
            // Persist the (possibly healed) feature set on the existing group.
            $existing_fids = array_map( 'intval', json_decode( $db->getField( "SELECT feature_ids FROM {$t_g} WHERE id = ?i", $group_id ) ?? '[]', true ) ?: [] );
            $merged        = array_values( array_unique( array_merge( $existing_fids, $group_feature_ids ) ) );
            if ( $merged !== $existing_fids ) {
                $db->update( 'product_groups', [ 'feature_ids' => wp_json_encode( $merged ) ], [ 'id' => $group_id ] );
            }
            // Heal the anchor's own combination if a legacy add left it empty ('{}').
            $anchor_combo = json_decode( $db->getField( "SELECT combination FROM {$t_gp} WHERE group_id = ?i AND product_id = ?i", $group_id, $product_id ) ?? '{}', true ) ?: [];
            if ( empty( $anchor_combo ) ) {
                $derived = $this->combo_from_saved_values( $product_id, $group_feature_ids, $lang );
                if ( ! empty( $derived ) ) {
                    $db->update( 'product_group_products', [ 'combination' => wp_json_encode( $this->combo_to_string_keys( $derived ) ) ], [
                        'group_id' => $group_id, 'product_id' => $product_id,
                    ] );
                }
            }
        }

        // Already a member?
        $already = $db->getField(
            "SELECT id FROM {$t_gp} WHERE group_id = ?i AND product_id = ?i",
            $group_id, $add_product_id
        );
        if ( $already ) {
            return new WP_REST_Response( [ 'error' => 'Product already in this group.' ], 400 );
        }

        $pos = (int) $db->getField( "SELECT COALESCE(MAX(position),0)+1 FROM {$t_gp} WHERE group_id = ?i", $group_id );

        $db->insert( 'product_group_products', [
            'group_id'    => $group_id,
            'product_id'  => $add_product_id,
            'combination' => wp_json_encode( $this->combo_to_string_keys( $combo ) ),
            'position'    => $pos,
        ] );

        // Only persist values that came from an explicit pick (the product lacked them).
        // Auto-resolved values are already saved on the product, so don't rewrite them.
        $to_persist = [];
        foreach ( $combo as $fid => $vid ) {
            if ( ! isset( $auto[ $fid ] ) ) { $to_persist[ $fid ] = $vid; }
        }
        if ( ! empty( $to_persist ) ) {
            $this->merge_variant_values( $add_product_id, $to_persist, $lang );
        }

        return new WP_REST_Response( [ 'success' => true ] );
    }

    /** Variation feature ids a product has a saved variant for (purpose variation_split/shared). */
    private function variation_feature_ids_for( int $product_id, string $lang ): array {
        $ids = [];
        foreach ( $this->repo->get_feature_values( $product_id, $lang ) as $row ) {
            $purpose = $row['feature_purpose'] ?? '';
            if ( ! empty( $row['variant_id'] ) && in_array( $purpose, [ 'variation_split', 'variation_shared' ], true ) ) {
                $ids[] = (int) $row['feature_id'];
            }
        }
        return array_values( array_unique( $ids ) );
    }

    /** Build fid=>vid from a product's saved feature values, limited to the given feature ids. */
    private function combo_from_saved_values( int $product_id, array $feature_ids, string $lang ): array {
        if ( empty( $feature_ids ) ) {
            return [];
        }
        $combo = [];
        foreach ( $this->repo->get_feature_values( $product_id, $lang ) as $row ) {
            $fid = (int) $row['feature_id'];
            if ( in_array( $fid, $feature_ids, true ) && ! empty( $row['variant_id'] ) ) {
                $combo[ $fid ] = (int) $row['variant_id'];
            }
        }
        return $combo;
    }

    /** Cast a fid=>vid combination to string-keyed ints for JSON-object storage (matches create_variations). */
    private function combo_to_string_keys( array $combo ): array {
        $out = [];
        foreach ( $combo as $fid => $vid ) {
            $out[ (string) (int) $fid ] = (int) $vid;
        }
        return $out;
    }

    /** Merge variation variant(s) into a product's feature values without dropping its other attributes. */
    private function merge_variant_values( int $product_id, array $combo, string $lang ): void {
        $by_fid = [];
        foreach ( $this->repo->get_feature_values( $product_id, $lang ) as $row ) {
            $fid = (int) $row['feature_id'];
            $by_fid[ $fid ] = [
                'feature_id'  => $fid,
                'value_int'   => $row['value_int'],
                'value_float' => $row['value_float'],
                'value_str'   => $row['value_str'],
                'value_text'  => $row['value_text'],
                'value_date'  => $row['value_date'],
                'variant_id'  => $row['variant_id'],
                'image_id'    => $row['image_id'],
            ];
        }
        foreach ( $combo as $fid => $vid ) {
            $fid = (int) $fid;
            $by_fid[ $fid ] = [
                'feature_id'  => $fid,
                'value_int'   => null,
                'value_float' => null,
                'value_str'   => null,
                'value_text'  => null,
                'value_date'  => null,
                'variant_id'  => (int) $vid,
                'image_id'    => null,
            ];
        }
        $this->repo->save_feature_values( $product_id, array_values( $by_fid ) );
    }

    /**
     * PUT /products/{id}/group/member/{member_product_id}  (S478)
     *
     * Inline edit of a group member's shelf fields from the Product Group tab.
     * Body (any subset): { price, list_price, stock_quantity, sku, status }
     *
     * The member must be in the same group as {id}. Writes go through
     * ProductRepository::save() (partial update — only the sent columns change,
     * stock_status auto-syncs, percent Bulk-Buys rows recompute). Base price is
     * tc_products.price alone as of KI-122 (no shadow row to keep in step).
     */
    public function update_group_member( WP_REST_Request $request ): WP_REST_Response {
        $product_id = (int) $request->get_param( 'id' );
        $member_id  = (int) $request->get_param( 'member_product_id' );
        $lang       = sanitize_text_field( $request->get_param( 'lang' ) ?? 'en' );
        $body       = (array) $request->get_json_params();
        $db         = \titancart_db();

        $t_gp = $db->table( 'product_group_products' );
        $t_p  = $db->table( 'products' );

        $anchor = $db->getRow( "SELECT group_id FROM {$t_gp} WHERE product_id = ?i", $product_id );
        $member = $db->getRow( "SELECT group_id FROM {$t_gp} WHERE product_id = ?i", $member_id );
        if ( ! $anchor || ! $member || (int) $anchor['group_id'] !== (int) $member['group_id'] ) {
            return new WP_REST_Response( [ 'error' => 'That product is not in this group.' ], 400 );
        }

        $row = [];
        if ( array_key_exists( 'price', $body ) && is_numeric( $body['price'] ) ) {
            $row['price'] = round( max( 0.0, (float) $body['price'] ), 4 );
        }
        if ( array_key_exists( 'list_price', $body ) ) {
            $row['list_price'] = is_numeric( $body['list_price'] ) ? round( max( 0.0, (float) $body['list_price'] ), 4 ) : 0.0;
        }
        if ( array_key_exists( 'stock_quantity', $body ) && is_numeric( $body['stock_quantity'] ) ) {
            $row['stock_quantity'] = max( 0, (int) $body['stock_quantity'] );
        }
        if ( array_key_exists( 'status', $body ) && in_array( $body['status'], [ 'active', 'disabled', 'hidden' ], true ) ) {
            $row['status'] = (string) $body['status'];
        }
        if ( array_key_exists( 'sku', $body ) ) {
            $sku = sanitize_text_field( (string) $body['sku'] );
            if ( '' === $sku ) {
                return new WP_REST_Response( [ 'error' => 'Product code cannot be empty.' ], 400 );
            }
            $taken = $db->getField( "SELECT id FROM {$t_p} WHERE sku = ?s AND id != ?i AND deleted_at IS NULL", $sku, $member_id );
            if ( $taken ) {
                return new WP_REST_Response( [ 'error' => 'Product code "' . $sku . '" is already in use.' ], 400 );
            }
            $row['sku'] = $sku;
        }

        if ( empty( $row ) ) {
            return new WP_REST_Response( [ 'error' => 'Nothing to update.' ], 400 );
        }

        $saved = $this->repo->save( $row, $member_id, $lang );
        if ( ! $saved ) {
            return new WP_REST_Response( [ 'error' => 'Update failed.' ], 500 );
        }

        $fresh = $db->getRow( "SELECT price, list_price, stock_quantity, stock_status, sku, status FROM {$t_p} WHERE id = ?i", $member_id );
        return new WP_REST_Response( [
            'success'        => true,
            'product_id'     => $member_id,
            'price'          => (float) ( $fresh['price'] ?? 0 ),
            'list_price'     => (float) ( $fresh['list_price'] ?? 0 ),
            'stock_quantity' => (int) ( $fresh['stock_quantity'] ?? 0 ),
            'stock_status'   => (string) ( $fresh['stock_status'] ?? '' ),
            'sku'            => (string) ( $fresh['sku'] ?? '' ),
            'status'         => (string) ( $fresh['status'] ?? '' ),
        ] );
    }

    /** DELETE /products/{id}/group/remove/{member_product_id} */
    public function remove_from_group( WP_REST_Request $request ): WP_REST_Response {
        $product_id        = (int) $request->get_param( 'id' );
        $member_product_id = (int) $request->get_param( 'member_product_id' );
        $db                = \titancart_db();

        $t_gp = $db->table( 'product_group_products' );
        $t_g  = $db->table( 'product_groups' );

        $membership = $db->getRow(
            "SELECT group_id FROM {$t_gp} WHERE product_id = ?i",
            $product_id
        );

        if ( ! $membership ) {
            return new WP_REST_Response( [ 'error' => 'Product not in a group.' ], 400 );
        }

        $group_id = (int) $membership['group_id'];

        // Remove the member.
        $db->delete( 'product_group_products', [ 'group_id' => $group_id, 'product_id' => $member_product_id ] );

        // If group now has ≤1 member, dissolve it.
        $remaining = (int) $db->getField( "SELECT COUNT(*) FROM {$t_gp} WHERE group_id = ?i", $group_id );
        if ( $remaining <= 1 ) {
            $db->delete( 'product_group_products', [ 'group_id' => $group_id ] );
            $db->delete( 'product_groups', [ 'id' => $group_id ] );
        }

        return new WP_REST_Response( [ 'success' => true ] );
    }

    // ── Permission ────────────────────────────────────────────────────────────

    // ── Bulk update ─────────────────────────────────────────────────────────

    /** PUT /products/bulk — update multiple products at once */
    public function bulk_update( WP_REST_Request $request ): WP_REST_Response|WP_Error {
        $body = $request->get_json_params();
        $products = $body['products'] ?? [];

        if ( empty( $products ) || ! is_array( $products ) ) {
            return new WP_Error( 'tc_invalid_input', 'products object required.', [ 'status' => 400 ] );
        }

        $lang    = sanitize_text_field( $body['lang'] ?? 'en' );
        $updated = 0;
        $errors  = [];

        foreach ( $products as $id => $fields ) {
            $product_id = (int) $id;
            if ( $product_id < 1 ) {
                $errors[] = [ 'id' => $id, 'error' => 'Invalid product ID.' ];
                continue;
            }

            if ( ! $this->repo->get( $product_id, $lang ) ) {
                $errors[] = [ 'id' => $product_id, 'error' => 'Product not found.' ];
                continue;
            }

            // Build a synthetic WP_REST_Request so we can reuse sanitize_product_input().
            $sub = new WP_REST_Request( 'PUT' );
            if ( is_array( $fields ) ) {
                foreach ( $fields as $k => $v ) {
                    $sub->set_param( $k, $v );
                }
            }

            $data = $this->sanitize_product_input( $sub );

            $did_something = false;

            if ( ! empty( $data ) ) {
                $result = $this->repo->save( $data, $product_id, $lang );
                if ( $result ) {
                    $did_something = true;
                } else {
                    global $wpdb;
                    $errors[] = [ 'id' => $product_id, 'error' => $wpdb->last_error ?? 'Save failed.' ];
                }
            }

            // Attributes (feature values) — separate save path.
            $attr_values = $fields['attributes'] ?? null;
            if ( is_array( $attr_values ) && ! empty( $attr_values ) ) {
                $clean = [];
                foreach ( $attr_values as $av ) {
                    if ( empty( $av['feature_id'] ) ) continue;
                    $has_value = ! empty( $av['variant_id'] )
                              || ( isset( $av['value_str'] ) && $av['value_str'] !== '' )
                              || ( isset( $av['value_int'] ) && $av['value_int'] !== '' && $av['value_int'] !== null )
                              || ( isset( $av['value_float'] ) && $av['value_float'] !== '' && $av['value_float'] !== null )
                              || ( isset( $av['value_text'] ) && $av['value_text'] !== '' )
                              || ( isset( $av['value_date'] ) && $av['value_date'] !== '' );
                    if ( $has_value ) {
                        $clean[] = $av;
                    }
                }
                if ( ! empty( $clean ) ) {
                    $this->repo->save_feature_values( $product_id, $clean );
                    $did_something = true;
                }
            }

            if ( $did_something ) {
                $updated++;
            }
        }

        LogService::info( 'products', 'update', 'Bulk updated ' . $updated . ' product(s)', [
            'object_type' => 'product',
            'updated'     => $updated,
            'error_count' => count( $errors ),
        ] );

        return new WP_REST_Response( [
            'updated' => $updated,
            'errors'  => $errors,
        ] );
    }

    public function admin_permission(): bool|WP_Error {
        if ( current_user_can( 'manage_options' ) ) {
            return true;
        }
        return new WP_Error( 'tc_rest_forbidden', 'Forbidden.', [ 'status' => 403 ] );
    }


    // ── Input sanitization ────────────────────────────────────────────────────

    private function sanitize_product_input( WP_REST_Request $request ): array {
        $data = [];

        // Translation sub-object (name, description, meta fields).
        $translation = $request->get_param( 'translation' );
        if ( is_array( $translation ) ) {
            $allowed = [ 'name', 'description', 'short_desc', 'meta_title', 'meta_desc' ];
            $data['translation'] = array_intersect_key( $translation, array_flip( $allowed ) );
        }

        // Core string fields.
        foreach ( [ 'sku', 'slug', 'type', 'status', 'visibility', 'stock_status', 'weight_unit', 'dimension_unit', 'details_view' ] as $f ) {
            if ( $request->get_param( $f ) !== null ) {
                $data[ $f ] = sanitize_text_field( (string) $request->get_param( $f ) );
            }
        }

        // ── S120: ENUM fields with strict allow-lists ────────────────────────
        // Each ENUM value must match Schema.php exactly or MySQL will reject the row.
        // S124: rp_earn_*_type entries removed — per-product reward overrides are
        // deprecated in favor of group-keyed earning rates (Loyalty Rewards admin →
        // Earning Rates tab). Schema columns stay on tc_products as legacy fossils.
        $enum_fields = [
            'zero_price_action'    => [ 'do_not_allow', 'allow', 'ask' ],
            'out_of_stock_actions' => [ 'none', 'buy', 'subscribe' ],
            'options_type'         => [ 'P', 'S', 'N' ],     // simultaneous / sequential / individual
            'exceptions_type'      => [ 'F', 'A' ],          // forbidden / allowed
        ];
        foreach ( $enum_fields as $field => $allowed_values ) {
            $val = $request->get_param( $field );
            if ( $val !== null ) {
                $val = sanitize_text_field( (string) $val );
                if ( in_array( $val, $allowed_values, true ) ) {
                    $data[ $field ] = $val;
                }
                // Silently drop invalid values — frontend should never send them.
            }
        }

        // ── S120: Price-per-unit string + numeric trio ───────────────────────
        if ( $request->get_param( 'unit_name' ) !== null ) {
            $data['unit_name'] = sanitize_text_field( (string) $request->get_param( 'unit_name' ) );
        }
        foreach ( [ 'units_in_product', 'show_price_per' ] as $f ) {
            if ( $request->get_param( $f ) !== null ) {
                $raw = $request->get_param( $f );
                $data[ $f ] = ( $raw === '' || $raw === null ) ? 0.0 : (float) $raw;
            }
        }

        // ── S120: Availability date (DATETIME NULL) ──────────────────────────
        // Frontend sends YYYY-MM-DD from <input type="date"> or empty string for "no date".
        $avail = $request->get_param( 'avail_since' );
        if ( $avail !== null ) {
            $avail = trim( (string) $avail );
            if ( $avail === '' ) {
                $data['avail_since'] = null;
            } else {
                // Accept date-only or full datetime; normalize to YYYY-MM-DD HH:MM:SS.
                $ts = strtotime( $avail );
                $data['avail_since'] = $ts ? gmdate( 'Y-m-d H:i:s', $ts ) : null;
            }
        }

        // Text fields (longer content, still sanitized).
        foreach ( [ 'search_words', 'promo_text' ] as $f ) {
            if ( $request->get_param( $f ) !== null ) {
                $data[ $f ] = sanitize_textarea_field( (string) $request->get_param( $f ) );
            }
        }

        // Videos JSON array.
        $videos = $request->get_param( 'videos' );
        if ( is_array( $videos ) ) {
            $clean = [];
            foreach ( $videos as $v ) {
                if ( ! empty( $v['url'] ) ) {
                    $clean[] = [
                        'url'     => esc_url_raw( $v['url'] ),
                        'hosting' => sanitize_text_field( $v['hosting'] ?? 'YouTube' ),
                    ];
                }
            }
            $data['videos'] = wp_json_encode( $clean );
        }

        // Numeric fields.
        foreach ( [ 'price', 'list_price', 'cost_price', 'weight', 'length', 'width', 'height', 'shipping_freight' ] as $f ) {
            if ( $request->get_param( $f ) !== null ) {
                $data[ $f ] = (float) $request->get_param( $f );
            }
        }

        // Integer fields.
        foreach ( [ 'parent_id', 'brand_id', 'stock_quantity', 'min_quantity', 'max_quantity', 'quantity_step', 'tax_class_id', 'position' ] as $f ) {
            if ( $request->get_param( $f ) !== null ) {
                $data[ $f ] = (int) $request->get_param( $f );
            }
        }

        // S124: Reward Points integer fields removed from the sanitizer
        // allow-list. Per-product overrides are deprecated; group-keyed rates
        // live in tc_settings under section='rewards' (managed by the new
        // Earning Rates tab). Old payloads that still send rp_price /
        // rp_earn_all / rp_earn_guest / rp_earn_reg are silently ignored.

        // Boolean fields.
        foreach ( [ 'track_inventory', 'allow_backorders', 'free_shipping', 'is_downloadable', 'subscriptions_only', 'requires_shipping', 'featured', 'age_restricted' ] as $f ) {
            if ( $request->get_param( $f ) !== null ) {
                $data[ $f ] = $request->get_param( $f ) ? 1 : 0;
            }
        }

        // S124: Reward Points boolean fields (rp_allow_payment / rp_override_per /
        // rp_override_earn) likewise removed — see comment above.

        // Age limit (integer, 0 = use global default).
        if ( $request->get_param( 'age_limit' ) !== null ) {
            $data['age_limit'] = max( 0, (int) $request->get_param( 'age_limit' ) );
        }

        // Categories array.
        $categories = $request->get_param( 'categories' );
        if ( is_array( $categories ) ) {
            $data['categories'] = array_map( 'intval', $categories );
        }

        // ── S120: Bulk Buys prices array — new shape ─────────────────────────
        // Old shape (pre-S120): qty_discounts: [{ qty, value, type, usergroup }]  ← silently dropped
        // New shape:            prices:        [{ usergroup_id, min_quantity, discount_type, discount_value }]
        // Backward-compat: if a caller still sends `price`, fall back to it as discount_value.
        $prices = $request->get_param( 'prices' );
        if ( is_array( $prices ) ) {
            $data['prices'] = array_map( function( $p ) {
                $type = $p['discount_type'] ?? 'absolute';
                if ( ! in_array( $type, [ 'absolute', 'percent' ], true ) ) {
                    $type = 'absolute';
                }
                $value = isset( $p['discount_value'] )
                    ? (float) $p['discount_value']
                    : (float) ( $p['price'] ?? 0 );
                return [
                    'usergroup_id'   => (int) ( $p['usergroup_id'] ?? 0 ),
                    'min_quantity'   => max( 1, (int) ( $p['min_quantity'] ?? 1 ) ),
                    'discount_type'  => $type,
                    'discount_value' => max( 0, $value ),
                ];
            }, $prices );
        }

        // Images array.
        $images = $request->get_param( 'images' );
        if ( is_array( $images ) ) {
            $data['images'] = array_map( fn( $img ) => [
                'wp_attachment_id' => (int) ( $img['wp_attachment_id'] ?? 0 ),
                'position'         => (int) ( $img['position'] ?? 0 ),
                'is_main'          => (int) ( $img['is_main'] ?? 0 ),
                'alt_text'         => sanitize_text_field( $img['alt_text'] ?? '' ),
            ], $images );
        }

        return $data;
    }
}
