<?php
/**
 * TitanCart — Product Attributes (Features) REST Controller.
 *
 * Manages global product attributes: CRUD for attributes, their variants
 * (predefined choices), and category scoping.
 *
 * @package TitanCart\REST\Products
 */

namespace TitanCart\REST\Products;

defined( 'ABSPATH' ) || exit;

use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use WP_Error;
use TitanCart\Modules\Products\FeatureValueOrder;

class FeaturesController {

    private const NS   = 'titancart/v1';
    private const BASE = 'product-features';

    public function register_routes(): void {

        // Collection
        \register_rest_route( self::NS, '/' . self::BASE, [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_items' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'create_item' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Single
        \register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_item' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
            [
                'methods'             => WP_REST_Server::EDITABLE,
                'callback'            => [ $this, 'update_item' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_item' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Variants
        \register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/variants', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_variants' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'save_variants' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Single variant delete
        \register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/variants/(?P<variant_id>[\d]+)', [
            [
                'methods'             => WP_REST_Server::DELETABLE,
                'callback'            => [ $this, 'delete_variant' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Sort choices A–Z now (rewrites `position`; roadmap #11)
        \register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/variants/sort-alpha', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'sort_variants_alpha' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Reorder
        \register_rest_route( self::NS, '/' . self::BASE . '/reorder', [
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'reorder' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Categories
        \register_rest_route( self::NS, '/' . self::BASE . '/(?P<id>[\d]+)/categories', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_categories' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
            [
                'methods'             => WP_REST_Server::CREATABLE,
                'callback'            => [ $this, 'save_categories' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );

        // Groups (features with type=G)
        \register_rest_route( self::NS, '/' . self::BASE . '/groups', [
            [
                'methods'             => WP_REST_Server::READABLE,
                'callback'            => [ $this, 'get_groups' ],
                'permission_callback' => [ $this, 'permission' ],
            ],
        ] );
    }

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

    private function db() {
        return \titancart_db();
    }

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

    /**
     * Normalise a swatch colour to canonical "#RRGGBB" (or "#RGB").
     * Accepts the code with or without the leading "#", any case, surrounding
     * whitespace. Returns null for empty / invalid input (stored as NULL).
     *   "ff0000" → "#FF0000"   "#FF0000" → "#FF0000"   "f00" → "#F00"   "red" → null
     */
    private function normalize_hex( $raw ): ?string {
        if ( $raw === null ) return null;
        $hex = strtoupper( ltrim( trim( (string) $raw ), '#' ) );
        if ( $hex === '' ) return null;
        if ( ! preg_match( '/^[0-9A-F]{3}$|^[0-9A-F]{6}$/', $hex ) ) return null;
        return '#' . $hex;
    }

    private function feature_row( int $id ): ?array {
        $db  = $this->db();
        $row = $db->getRow(
            "SELECT f.*, f.type AS feature_type, ft.name, ft.description AS feature_description
             FROM {$this->t('product_features')} f
             LEFT JOIN {$this->t('product_feature_translations')} ft
                ON ft.feature_id = f.id AND ft.language_code = 'en'
             WHERE f.id = ?i",
            $id
        );
        if ( ! $row ) return null;
        $row['feature_id'] = (int) $row['id'];
        return $row;
    }

    // ── Handlers ──────────────────────────────────────────────────────────────

    /** GET /product-features */
    public function get_items( WP_REST_Request $request ): WP_REST_Response {
        $db = $this->db();
        $search = \sanitize_text_field( $request->get_param( 'search' ) ?? '' );
        $group  = $request->get_param( 'group' );

        $sql = "SELECT f.id, f.parent_id, f.type AS feature_type, f.filter_type, f.purpose, f.feature_code,
                       f.position, f.filterable, f.compareable, f.show_on_pdp, f.sort_mode, f.global_prices, f.status,
                       ft.name, ft.description AS feature_description
                FROM {$this->t('product_features')} f
                LEFT JOIN {$this->t('product_feature_translations')} ft
                   ON ft.feature_id = f.id AND ft.language_code = 'en'
                WHERE 1=1";

        $args = [];
        if ( $search !== '' ) {
            $sql .= $db->parse( " AND ft.name LIKE ?s", '%' . $search . '%' );
        }
        if ( $group !== null && $group !== '' ) {
            $sql .= $db->parse( " AND f.parent_id = ?i", (int) $group );
        }

        $sql .= " ORDER BY f.position ASC, f.id ASC";

        $rows = $db->getArray( $sql );

        // Attach variant count for each feature.
        $tv = $this->t( 'product_feature_variants' );
        foreach ( $rows as &$r ) {
            $r['feature_id']    = (int) $r['id'];
            $r['variant_count'] = (int) $db->getField(
                "SELECT COUNT(*) FROM {$tv} WHERE feature_id = ?i",
                $r['id']
            );
        }
        unset( $r );

        return new WP_REST_Response( $rows );
    }

    /** GET /product-features/{id} */
    public function get_item( WP_REST_Request $request ): WP_REST_Response {
        $row = $this->feature_row( (int) $request->get_param( 'id' ) );
        if ( ! $row ) {
            return new WP_REST_Response( [ 'error' => 'Not found.' ], 404 );
        }
        return new WP_REST_Response( $row );
    }

    /** POST /product-features */
    public function create_item( WP_REST_Request $request ): WP_REST_Response {
        $body = $request->get_json_params();
        $db   = $this->db();

        $name = \sanitize_text_field( $body['name'] ?? '' );
        if ( ! $name ) {
            return new WP_REST_Response( [ 'error' => 'Name required.' ], 400 );
        }

        $feature_id = $db->insert( 'product_features', [
            'parent_id'   => (int) ( $body['parent_id'] ?? 0 ),
            'type'        => \sanitize_text_field( $body['feature_type'] ?? $body['type'] ?? 'T' ),
            'filter_type' => \sanitize_text_field( $body['filter_type'] ?? 'checkbox' ),
            'purpose'     => \sanitize_text_field( $body['purpose'] ?? 'filter' ),
            'feature_code'=> \sanitize_text_field( $body['feature_code'] ?? '' ),
            'position'    => (int) ( $body['position'] ?? 0 ),
            'filterable'  => ! empty( $body['filterable'] ) ? 1 : 0,
            'compareable' => ! empty( $body['compareable'] ) ? 1 : 0,
            'show_on_pdp' => array_key_exists( 'show_on_pdp', $body ) ? ( ! empty( $body['show_on_pdp'] ) ? 1 : 0 ) : 1,
            'sort_mode'   => FeatureValueOrder::normalize( $body['sort_mode'] ?? FeatureValueOrder::MANUAL ),
            'global_prices' => ! empty( $body['global_prices'] ) ? 1 : 0,
            'status'      => ( $body['status'] ?? 'active' ) === 'disabled' ? 'disabled' : 'active',
        ] );

        if ( $feature_id ) {
            $db->insert( 'product_feature_translations', [
                'feature_id'    => $feature_id,
                'language_code' => 'en',
                'name'          => $name,
                'description'   => \sanitize_textarea_field( $body['feature_description'] ?? '' ),
            ] );
        }

        return new WP_REST_Response( $this->feature_row( $feature_id ) );
    }

    /** PUT /product-features/{id} */
    public function update_item( WP_REST_Request $request ): WP_REST_Response {
        $id   = (int) $request->get_param( 'id' );
        $body = $request->get_json_params();
        $db   = $this->db();

        $update = [];
        if ( isset( $body['parent_id'] ) )     $update['parent_id']   = (int) $body['parent_id'];
        if ( isset( $body['feature_type'] ) || isset( $body['type'] ) )
            $update['type'] = \sanitize_text_field( $body['feature_type'] ?? $body['type'] );
        if ( isset( $body['filter_type'] ) )   $update['filter_type'] = \sanitize_text_field( $body['filter_type'] );
        if ( isset( $body['purpose'] ) )       $update['purpose']     = \sanitize_text_field( $body['purpose'] );
        if ( isset( $body['feature_code'] ) )  $update['feature_code']= \sanitize_text_field( $body['feature_code'] );
        if ( isset( $body['position'] ) )      $update['position']    = (int) $body['position'];
        if ( isset( $body['filterable'] ) )    $update['filterable']  = ! empty( $body['filterable'] ) ? 1 : 0;
        if ( isset( $body['compareable'] ) )   $update['compareable'] = ! empty( $body['compareable'] ) ? 1 : 0;
        if ( array_key_exists( 'show_on_pdp', $body ) ) $update['show_on_pdp'] = ! empty( $body['show_on_pdp'] ) ? 1 : 0;
        if ( isset( $body['sort_mode'] ) )     $update['sort_mode']   = FeatureValueOrder::normalize( $body['sort_mode'] );
        if ( array_key_exists( 'global_prices', $body ) ) $update['global_prices'] = ! empty( $body['global_prices'] ) ? 1 : 0;
        if ( isset( $body['status'] ) )        $update['status']      = $body['status'] === 'disabled' ? 'disabled' : 'active';

        if ( $update ) {
            $db->update( 'product_features', $update, [ 'id' => $id ] );
        }

        // Translation upsert.
        $name = \sanitize_text_field( $body['name'] ?? '' );
        if ( $name ) {
            $existing = $db->getField(
                "SELECT id FROM {$this->t('product_feature_translations')}
                 WHERE feature_id = ?i AND language_code = 'en'",
                $id
            );
            $trow = [
                'name'        => $name,
                'description' => \sanitize_textarea_field( $body['feature_description'] ?? '' ),
            ];
            if ( $existing ) {
                $db->update( 'product_feature_translations', $trow, [
                    'feature_id' => $id, 'language_code' => 'en',
                ] );
            } else {
                $trow['feature_id']    = $id;
                $trow['language_code'] = 'en';
                $db->insert( 'product_feature_translations', $trow );
            }
        }

        return new WP_REST_Response( $this->feature_row( $id ) );
    }

    /** DELETE /product-features/{id} */
    public function delete_item( WP_REST_Request $request ): WP_REST_Response {
        $id = (int) $request->get_param( 'id' );
        $db = $this->db();

        // Cascade: variant translations, variants, feature translations, feature values, feature categories, feature
        $variant_ids = $db->getColumn(
            "SELECT id FROM {$this->t('product_feature_variants')} WHERE feature_id = ?i",
            $id
        );
        foreach ( $variant_ids as $vid ) {
            $db->delete( 'product_feature_variant_translations', [ 'variant_id' => (int) $vid ] );
        }
        $db->delete( 'product_feature_variants',    [ 'feature_id' => $id ] );
        $db->delete( 'product_feature_translations', [ 'feature_id' => $id ] );
        $db->delete( 'product_feature_values',       [ 'feature_id' => $id ] );
        $db->delete( 'product_feature_categories',   [ 'feature_id' => $id ] );
        $db->delete( 'product_features',             [ 'id'         => $id ] );

        // KI-124 (S478): variation groups built on this attribute lose the axis;
        // a group with no axis left is disbanded (members stay as products).
        $healed = \TitanCart\Modules\Products\ProductRepository::heal_groups_for_deleted_feature( $id );

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

    // ── Variants ──────────────────────────────────────────────────────────────

    /** GET /product-features/{id}/variants */
    public function get_variants( WP_REST_Request $request ): WP_REST_Response {
        $feature_id = (int) $request->get_param( 'id' );
        $db = $this->db();

        // Roadmap #11: order honours the attribute's sort_mode (manual | alpha).
        $mode  = FeatureValueOrder::mode_for( $feature_id );
        $order = FeatureValueOrder::clause_for_mode( $mode, 'v', 'vt' );

        $rows = $db->getArray(
            "SELECT v.id, v.color_hex, v.price_modifier, v.position, v.status, vt.name
             FROM {$this->t('product_feature_variants')} v
             LEFT JOIN {$this->t('product_feature_variant_translations')} vt
                ON vt.variant_id = v.id AND vt.language_code = 'en'
             WHERE v.feature_id = ?i
             ORDER BY {$order}",
            $feature_id
        );

        // Flag duplicate names (case/whitespace-insensitive) so the editor can
        // warn — e.g. "Soft Sage" entered twice. Rows keep their plain shape;
        // only `is_duplicate` is added.
        $seen = [];
        foreach ( $rows as $r ) {
            $k = mb_strtolower( trim( (string) ( $r['name'] ?? '' ) ) );
            if ( $k === '' ) continue;
            $seen[ $k ] = ( $seen[ $k ] ?? 0 ) + 1;
        }
        foreach ( $rows as &$r ) {
            $k = mb_strtolower( trim( (string) ( $r['name'] ?? '' ) ) );
            $r['position']     = (int) $r['position'];
            $r['is_duplicate'] = ( $k !== '' && ( $seen[ $k ] ?? 0 ) > 1 );
            // S478: site-wide price change (NULL = none) as a plain number or null.
            $r['price_modifier'] = ( $r['price_modifier'] === null || $r['price_modifier'] === '' ) ? null : (float) $r['price_modifier'];
        }
        unset( $r );

        return new WP_REST_Response( $rows );
    }

    /**
     * POST /product-features/{id}/variants/sort-alpha
     *
     * One-click "Sort A–Z now": rewrites `position` on the existing choices to
     * their alphabetical (collation-aware, case-insensitive) order. This is the
     * ONLY operation that rewrites positions for alphabetical purposes — the
     * 'alpha' sort_mode itself is read-side and leaves positions untouched.
     * Returns the refreshed list (same shape as get_variants).
     */
    public function sort_variants_alpha( WP_REST_Request $request ): WP_REST_Response {
        $feature_id = (int) $request->get_param( 'id' );
        $db         = $this->db();
        $collate    = FeatureValueOrder::collate();

        $ids = $db->getColumn(
            "SELECT v.id
             FROM {$this->t('product_feature_variants')} v
             LEFT JOIN {$this->t('product_feature_variant_translations')} vt
                ON vt.variant_id = v.id AND vt.language_code = 'en'
             WHERE v.feature_id = ?i
             ORDER BY vt.name{$collate} ASC, v.id ASC",
            $feature_id
        );

        foreach ( $ids as $pos => $vid ) {
            $db->update( 'product_feature_variants', [ 'position' => (int) $pos ], [ 'id' => (int) $vid ] );
        }

        return $this->get_variants( $request );
    }

    /** POST /product-features/{id}/variants — accepts { variants: [...] } bulk save. */
    public function save_variants( WP_REST_Request $request ): WP_REST_Response {
        $feature_id = (int) $request->get_param( 'id' );
        $body       = $request->get_json_params();
        $db         = $this->db();

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

        foreach ( $variants as $i => $v ) {
            $vid  = (int) ( $v['id'] ?? 0 );
            $name = \sanitize_text_field( $v['name'] ?? '' );
            if ( ! $name ) continue;

            $row = [
                'feature_id' => $feature_id,
                'color_hex'  => $this->normalize_hex( $v['color_hex'] ?? null ),
                'position'   => (int) ( $v['position'] ?? $i ),
                'status'     => 'active',
            ];
            // S478: site-wide price change — blank / null clears it, a number sets it.
            if ( array_key_exists( 'price_modifier', $v ) ) {
                $pm = $v['price_modifier'];
                $row['price_modifier'] = ( $pm === null || $pm === '' || ! is_numeric( $pm ) ) ? null : round( (float) $pm, 4 );
            }

            if ( $vid ) {
                $db->update( 'product_feature_variants', $row, [ 'id' => $vid ] );
            } else {
                $vid = $db->insert( 'product_feature_variants', $row );
            }

            if ( $vid ) {
                $existing = $db->getField(
                    "SELECT id FROM {$this->t('product_feature_variant_translations')}
                     WHERE variant_id = ?i AND language_code = 'en'",
                    $vid
                );
                if ( $existing ) {
                    $db->update( 'product_feature_variant_translations',
                        [ 'name' => $name ],
                        [ 'variant_id' => $vid, 'language_code' => 'en' ]
                    );
                } else {
                    $db->insert( 'product_feature_variant_translations', [
                        'variant_id'    => $vid,
                        'language_code' => 'en',
                        'name'          => $name,
                    ] );
                }
            }
        }

        // Return refreshed list.
        return $this->get_variants( $request );
    }

    /**
     * DELETE /product-features/{id}/variants/{variant_id}
     *
     * S466 (KI-110): deleting a DUPLICATE choice must not orphan products.
     * If another choice on the same attribute has the same name (case-insensitive,
     * trimmed), every reference to the deleted id is re-pointed at that survivor:
     *   - tc_product_feature_values.variant_id          (product ↔ choice assignments)
     *   - tc_product_variation_links.combination JSON   (split-variation children)
     *   - tc_product_group_products.combination JSON    (shared-variation groups)
     * Survivor = same-name sibling with the lowest position, then lowest id.
     * If the deleted choice carried a hex colour and the survivor has none, the
     * hex is carried over. With no same-name sibling, dangling value rows are
     * removed (previous behaviour left them orphaned).
     */
    public function delete_variant( WP_REST_Request $request ): WP_REST_Response {
        $feature_id = (int) $request->get_param( 'id' );
        $vid        = (int) $request->get_param( 'variant_id' );
        $db         = $this->db();

        $tV   = $this->t( 'product_feature_variants' );
        $tVT  = $this->t( 'product_feature_variant_translations' );
        $tPFV = $this->t( 'product_feature_values' );
        $tVL  = $this->t( 'product_variation_links' );
        $tGP  = $this->t( 'product_group_products' );

        $row = $db->getRow(
            "SELECT v.id, v.feature_id, v.color_hex, vt.name
             FROM {$tV} v
             LEFT JOIN {$tVT} vt ON vt.variant_id = v.id AND vt.language_code = 'en'
             WHERE v.id = ?i",
            $vid
        );
        if ( ! $row ) {
            return new WP_REST_Response( [ 'error' => 'Choice not found.' ], 404 );
        }
        if ( $feature_id && (int) $row['feature_id'] !== $feature_id ) {
            return new WP_REST_Response( [ 'error' => 'Choice does not belong to this attribute.' ], 400 );
        }
        $feature_id = (int) $row['feature_id'];
        $name_key   = mb_strtolower( trim( (string) ( $row['name'] ?? '' ) ) );

        // ── Find the surviving same-name sibling (if any) ──
        $survivor = null;
        if ( $name_key !== '' ) {
            $siblings = $db->getArray(
                "SELECT v.id, v.position, v.color_hex, vt.name
                 FROM {$tV} v
                 LEFT JOIN {$tVT} vt ON vt.variant_id = v.id AND vt.language_code = 'en'
                 WHERE v.feature_id = ?i AND v.id <> ?i
                 ORDER BY v.position ASC, v.id ASC",
                $feature_id, $vid
            );
            foreach ( $siblings as $s ) {
                if ( mb_strtolower( trim( (string) ( $s['name'] ?? '' ) ) ) === $name_key ) {
                    $survivor = $s;
                    break;
                }
            }
        }

        $moved = [ 'values' => 0, 'variation_links' => 0, 'group_products' => 0, 'dropped_values' => 0 ];

        $db->begin();
        if ( $survivor ) {
            $sid = (int) $survivor['id'];

            // 1. Product ↔ choice assignments. If a product already points at the
            //    survivor for this attribute, drop the duplicate row instead of
            //    creating a second identical assignment.
            $dup_products = $db->getColumn(
                "SELECT a.product_id
                 FROM {$tPFV} a
                 INNER JOIN {$tPFV} b
                    ON b.product_id = a.product_id AND b.feature_id = a.feature_id AND b.variant_id = ?i
                 WHERE a.variant_id = ?i",
                $sid, $vid
            );
            if ( $dup_products ) {
                $in = implode( ',', array_map( 'intval', $dup_products ) );
                $moved['dropped_values'] = (int) $db->query(
                    "DELETE FROM {$tPFV} WHERE variant_id = ?i AND product_id IN ({$in})",
                    $vid
                );
            }
            $moved['values'] = (int) $db->update( 'product_feature_values', [ 'variant_id' => $sid ], [ 'variant_id' => $vid ] );

            // 2 + 3. JSON combinations { "<feature_id>": "<variant_id>" }.
            //    Read-modify-write in PHP so we don't depend on JSON_* function
            //    availability or the int-vs-string form the writer used.
            foreach ( [ 'variation_links' => $tVL, 'group_products' => $tGP ] as $key => $table ) {
                $rows = $db->getArray(
                    "SELECT id, combination FROM {$table} WHERE combination LIKE ?s",
                    '%' . $vid . '%'
                );
                foreach ( $rows as $r ) {
                    $combo = json_decode( (string) $r['combination'], true );
                    if ( ! is_array( $combo ) ) continue;
                    $changed = false;
                    foreach ( $combo as $fk => $vv ) {
                        if ( (int) $fk === $feature_id && (int) $vv === $vid ) {
                            $combo[ $fk ] = is_int( $vv ) ? $sid : (string) $sid;
                            $changed = true;
                        }
                    }
                    if ( $changed ) {
                        $db->query(
                            "UPDATE {$table} SET combination = ?s WHERE id = ?i",
                            \wp_json_encode( $combo ), (int) $r['id']
                        );
                        $moved[ $key ]++;
                    }
                }
            }

            // 4. Carry the swatch colour over if the survivor has none.
            if ( empty( $survivor['color_hex'] ) && ! empty( $row['color_hex'] ) ) {
                $db->update( 'product_feature_variants', [ 'color_hex' => $row['color_hex'] ], [ 'id' => $sid ] );
            }
        } else {
            // No same-name sibling: the assignments have nowhere to go. Remove the
            // dangling rows rather than leaving orphans that point at a dead id.
            $moved['dropped_values'] = (int) $db->delete( 'product_feature_values', [ 'variant_id' => $vid ] );
            // KI-124 (S478): group members whose combination named this choice
            // can no longer be told apart — they leave the group.
            $moved['group_products'] = (int) \TitanCart\Modules\Products\ProductRepository::heal_groups_for_deleted_variant( $feature_id, $vid )['members_touched'];
        }

        $db->delete( 'product_feature_variant_translations', [ 'variant_id' => $vid ] );
        $deleted = $db->delete( 'product_feature_variants', [ 'id' => $vid ] );
        if ( $deleted === false ) {
            $db->rollback();
            return new WP_REST_Response( [ 'error' => 'Delete failed; no changes were made.' ], 500 );
        }
        $db->commit();

        return new WP_REST_Response( [
            'success'     => true,
            'deleted_id'  => $vid,
            'merged_into' => $survivor ? (int) $survivor['id'] : null,
            'merged_name' => $survivor ? (string) $survivor['name'] : null,
            'moved'       => $moved,
        ] );
    }

    /** POST /product-features/reorder — accepts { ids: [3, 1, 5, 2] } */
    public function reorder( WP_REST_Request $request ): WP_REST_Response {
        $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    = $this->db();
        $table = $this->t( 'product_features' );
        foreach ( $ids as $pos => $id ) {
            $db->update( 'product_features', [ 'position' => (int) $pos ], [ 'id' => (int) $id ] );
        }

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

    // ── Categories ────────────────────────────────────────────────────────────

    /** GET /product-features/{id}/categories */
    public function get_categories( WP_REST_Request $request ): WP_REST_Response {
        $feature_id = (int) $request->get_param( 'id' );
        $db = $this->db();

        $rows = $db->getArray(
            "SELECT fc.category_id, ct.name
             FROM {$this->t('product_feature_categories')} fc
             LEFT JOIN {$this->t('category_translations')} ct
                ON ct.category_id = fc.category_id AND ct.language_code = 'en'
             WHERE fc.feature_id = ?i
             ORDER BY ct.name ASC",
            $feature_id
        );

        return new WP_REST_Response( $rows );
    }

    /** POST /product-features/{id}/categories — replace all: { category_ids: [1,2,3] } */
    public function save_categories( WP_REST_Request $request ): WP_REST_Response {
        $feature_id   = (int) $request->get_param( 'id' );
        $body         = $request->get_json_params();
        $category_ids = array_map( 'intval', $body['category_ids'] ?? [] );
        $db           = $this->db();

        // Replace.
        $db->delete( 'product_feature_categories', [ 'feature_id' => $feature_id ] );
        foreach ( $category_ids as $cid ) {
            $db->insert( 'product_feature_categories', [
                'feature_id'  => $feature_id,
                'category_id' => $cid,
            ] );
        }

        return $this->get_categories( $request );
    }

    // ── Groups ────────────────────────────────────────────────────────────────

    /** GET /product-features/groups */
    public function get_groups( WP_REST_Request $request ): WP_REST_Response {
        $db = $this->db();
        $rows = $db->getArray(
            "SELECT f.id, ft.name
             FROM {$this->t('product_features')} f
             LEFT JOIN {$this->t('product_feature_translations')} ft
                ON ft.feature_id = f.id AND ft.language_code = 'en'
             WHERE f.type = 'G'
             ORDER BY f.position ASC, f.id ASC"
        );
        return new WP_REST_Response( $rows );
    }

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

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