<?php

/**
 * Restriction REST Controller
 *
 * REST API endpoints for Members Access admin functionality.
 * Handles saving/loading restriction rules and select options.
 *
 * @package ArraySubs\Features\MembersAccess\REST
 * @since 1.0.0
 */

namespace ArraySubs\Features\MembersAccess\REST;

defined('ABSPATH') || exit;

use ArraySubs\Supports\BaseRestController;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;

/**
 * RestrictionController class
 */
class RestrictionController extends BaseRestController
{
    /**
     * Enable auto-loading
     *
     * @var bool
     */
    public static $loadable = true;

    /**
     * Constructor
     */
    public function __construct()
    {
        parent::__construct();
    }

    /**
     * Register REST routes
     *
     * @return void
     */
    public function registerRoutes(): void
    {
        // Get all restriction settings
        register_rest_route(
            $this->namespace,
            '/members-access/settings',
            [
                [
                    'methods'             => 'GET',
                    'callback'            => [$this, 'getSettings'],
                    'permission_callback' => [$this, 'checkAdminPermission'],
                ],
                [
                    'methods'             => 'POST',
                    'callback'            => [$this, 'saveSettings'],
                    'permission_callback' => [$this, 'checkAdminPermission'],
                ],
            ]
        );

        // Detect and resolve access-rule conflicts.
        register_rest_route(
            $this->namespace,
            '/members-access/conflicts',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getConflicts'],
                'permission_callback' => [$this, 'checkAdminPermission'],
            ]
        );

        register_rest_route(
            $this->namespace,
            '/members-access/conflicts/(?P<conflict_id>[a-f0-9]+)/disable-url-rule',
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'disableConflictUrlRule'],
                'permission_callback' => [$this, 'checkAdminPermission'],
                'args'                => [
                    'conflict_id' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                ],
            ]
        );

        // Get select options for products
        register_rest_route(
            $this->namespace,
            '/members-access/select/products',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getProductOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
                'args'                => [
                    'search' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'type' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                ],
            ]
        );

        // Get select options for variations
        register_rest_route(
            $this->namespace,
            '/members-access/select/variations',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getVariationOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
                'args'                => [
                    'search' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'product_id' => [
                        'type'              => 'integer',
                        'sanitize_callback' => 'absint',
                    ],
                ],
            ]
        );

        // Get select options for taxonomies
        register_rest_route(
            $this->namespace,
            '/members-access/select/taxonomies',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getTaxonomyOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
            ]
        );

        // Get content taxonomies for CPT rules.
        register_rest_route(
            $this->namespace,
            '/members-access/select/content-taxonomies',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getContentTaxonomyOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
            ]
        );

        // Get select options for terms
        register_rest_route(
            $this->namespace,
            '/members-access/select/terms',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getTermOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
                'args'                => [
                    'taxonomy' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'search' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                ],
            ]
        );

        // Get select options for WordPress roles
        register_rest_route(
            $this->namespace,
            '/members-access/select/roles',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getRoleOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
            ]
        );

        // Get select options for post types
        register_rest_route(
            $this->namespace,
            '/members-access/select/post-types',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getPostTypeOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
            ]
        );

        // Get select options for posts/pages/CPT entries.
        register_rest_route(
            $this->namespace,
            '/members-access/select/posts',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getPostOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
                'args'                => [
                    'search' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'post_type' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_key',
                    ],
                ],
            ]
        );

        // Get select options for features (from Feature Manager)
        register_rest_route(
            $this->namespace,
            '/members-access/select/features',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getFeatureOptions'],
                'permission_callback' => [$this, 'checkAdminPermission'],
            ]
        );

        // Unified select options endpoint (routes based on 'type' param)
        register_rest_route(
            $this->namespace,
            '/members-access/select-options',
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'getSelectOptions'],
                'permission_callback' => [$this, 'checkSelectOptionsPermission'],
                'args'                => [
                    'type' => [
                        'required'          => true,
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'search' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'taxonomy' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'product_id' => [
                        'type'              => 'integer',
                        'sanitize_callback' => 'absint',
                    ],
                    'product_type' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                    'post_type' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_key',
                    ],
                    'include' => [
                        'type'              => 'string',
                        'sanitize_callback' => 'sanitize_text_field',
                    ],
                ],
            ]
        );
    }

    /**
     * Check admin permission
     *
     * @return bool
     */
    public function checkAdminPermission(): bool
    {
        return current_user_can('manage_woocommerce') || current_user_can('manage_options');
    }

    /**
     * Permission for the read-only select-options endpoint.
     *
     * These are non-sensitive option lists (product/variation/feature/role names)
     * consumed by the editor pickers, so any user who can edit content may read
     * them. Write/settings routes keep the stricter checkAdminPermission().
     *
     * @return bool
     */
    public function checkSelectOptionsPermission(): bool
    {
        return current_user_can('edit_posts')
            || current_user_can('manage_woocommerce')
            || current_user_can('manage_options');
    }

    /**
     * Unified select options endpoint
     *
     * Routes to the appropriate handler based on 'type' parameter.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getSelectOptions(WP_REST_Request $request): WP_REST_Response
    {
        $type = $request->get_param('type');

        switch ($type) {
            case 'products':
                return $this->getProductOptions($request);

            case 'variations':
                return $this->getVariationOptions($request);

            case 'taxonomies':
                return $this->getTaxonomyOptions($request);

            case 'content-taxonomies':
                return $this->getContentTaxonomyOptions($request);

            case 'terms':
                return $this->getTermOptions($request);

            case 'roles':
                return $this->getRoleOptions($request);

            case 'post-types':
            case 'postTypes':
                return $this->getPostTypeOptions($request);

            case 'posts':
                return $this->getPostOptions($request);

            case 'features':
                return $this->getFeatureOptions($request);

            default:
                return new WP_REST_Response([
                    'success' => false,
                    'message' => sprintf(
                        /* translators: %s: option type */
                        __('Unknown select option type: %s', 'arraysubs'),
                        $type
                    ),
                ], 400);
        }
    }

    /**
     * Get restriction settings
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getSettings(WP_REST_Request $request): WP_REST_Response
    {
        $settings = arraysubs_get_setting('members_access', []);

        // Apply defaults
        $defaults = $this->getDefaultSettings();
        $settings = wp_parse_args($settings, $defaults);

        return new WP_REST_Response([
            'success' => true,
            'data'    => $settings,
        ], 200);
    }

    /**
     * Save restriction settings
     *
     * Handles partial updates - only saves the keys that are provided.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function saveSettings(WP_REST_Request $request): WP_REST_Response
    {
        // Get current settings
        $all_settings = arraysubs_get_settings();
        $current = $all_settings['members_access'] ?? [];

        // Get all JSON body params
        $body = $request->get_json_params();

        // Merge provided settings with current
        // This allows partial updates (e.g., just role_mapping_rules)
        $known_keys = [
            'enabled',
            'default_redirect_url',
            'default_message',
            'role_mapping_rules',
            'url_rules',
            'cpt_rules',
            'download_rules',
            'download_settings',
            'discount_rules',
            'ecommerce_rules',
        ];

        $known_keys = apply_filters('arraysubs_members_access_known_keys', $known_keys);

        foreach ($known_keys as $key) {
            if (isset($body[$key])) {
                $current[$key] = $body[$key];
            }
        }

        // Sanitize the merged settings
        $sanitized = $this->sanitizeSettings($current);

        // Save to options
        $all_settings['members_access'] = $sanitized;
        arraysubs_save_settings($all_settings);

        return new WP_REST_Response([
            'success' => true,
            'message' => __('Settings saved successfully.', 'arraysubs'),
        ], 200);
    }

    /**
     * Get detected access-rule conflicts.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getConflicts(WP_REST_Request $request): WP_REST_Response
    {
        unset($request);

        return arraysubs_rest_response([
            'conflicts'      => $this->detectAccessRuleConflicts(),
            'priority_order' => $this->getAccessRulePriorityOrder(),
        ]);
    }

    /**
     * Disable the URL rule from a detected conflict.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response|WP_Error
     */
    public function disableConflictUrlRule(WP_REST_Request $request)
    {
        $conflict_id = sanitize_text_field((string) $request->get_param('conflict_id'));
        $conflict    = null;

        foreach ($this->detectAccessRuleConflicts() as $candidate) {
            if ($candidate['id'] === $conflict_id) {
                $conflict = $candidate;
                break;
            }
        }

        if (null === $conflict) {
            return new WP_Error(
                'conflict_not_found',
                __('Access-rule conflict not found.', 'arraysubs'),
                ['status' => 404]
            );
        }

        $settings  = arraysubs_get_settings();
        $rules     = $settings['members_access']['url_rules'] ?? [];
        $rule_id   = $conflict['url_rule']['id'];
        $rule_name = $conflict['url_rule']['name'];
        $updated   = false;

        foreach ($rules as $index => $rule) {
            if (($rule['id'] ?? '') !== $rule_id) {
                continue;
            }

            $rules[$index]['enabled'] = false;
            $updated = true;
            break;
        }

        if (! $updated) {
            return new WP_Error(
                'url_rule_not_found',
                __('The URL rule for this conflict could not be found.', 'arraysubs'),
                ['status' => 404]
            );
        }

        $settings['members_access']['url_rules'] = $rules;
        arraysubs_save_settings($settings);

        if (function_exists('arraysubs_add_global_note')) {
            arraysubs_add_global_note(
                sprintf(
                    /* translators: 1: URL rule name, 2: URL path. */
                    __('Access-rule conflict resolved by disabling URL rule "%1$s" for %2$s.', 'arraysubs'),
                    esc_html($rule_name),
                    esc_html($conflict['target']['url'])
                ),
                'private',
                get_current_user_id(),
                [
                    [
                        'field'          => 'url_rule_enabled',
                        'label'          => __('URL rule enabled', 'arraysubs'),
                        'previous_value' => __('Enabled', 'arraysubs'),
                        'changed_value'  => __('Disabled', 'arraysubs'),
                    ],
                ],
                'settings'
            );
        }

        return arraysubs_rest_response(
            [
                'conflict_id' => $conflict_id,
                'rule_id'     => $rule_id,
            ],
            200,
            __('URL rule disabled and conflict resolved.', 'arraysubs')
        );
    }

    /**
     * Get product options for select
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getProductOptions(WP_REST_Request $request): WP_REST_Response
    {
        $search = $request->get_param('search') ?? '';
        $type = $request->get_param('type') ?? '';
        $product_type = $request->get_param('product_type') ?? '';
        $include = $request->get_param('include') ?? '';

        // Subscription-only filtering. Via the unified select-options dispatcher
        // `type` carries the route ("products"), so the subscription flag is read
        // from `product_type`; the dedicated /select/products route still passes
        // it as `type` for back-compat.
        $subscription_only = ($product_type === 'subscription') || ($type === 'subscription');

        $args = [
            'status'         => 'publish',
            'limit'          => 20,
            'orderby'        => 'title',
            'order'          => 'ASC',
        ];

        // Resolve specific IDs (used to render labels for saved selections).
        if (!empty($include)) {
            $ids = array_filter(array_map('absint', explode(',', $include)));
            if (!empty($ids)) {
                $args['include'] = $ids;
                $args['limit']   = count($ids);
            }
        } elseif (!empty($search)) {
            $args['s'] = $search;
        } else {
            // No search yet: seed the picker with the 10 most-recently-created
            // products so the dropdown isn't empty before the user types.
            $args['orderby'] = 'date';
            $args['order']   = 'DESC';
            $args['limit']   = 10;
        }

        // Filter by subscription type if requested (skip when resolving specific
        // IDs, so saved selections always resolve to a label).
        if ($subscription_only && empty($args['include'])) {
            // Subscription products are flagged with the `_is_subscription` meta
            // (see arraysubs_is_subscription_product() / the product edit screen).
            $args['meta_key'] = '_is_subscription';
            $args['meta_value'] = 'yes';
        }

        $products = wc_get_products($args);
        $options = [];

        foreach ($products as $product) {
            $options[] = [
                'value' => $product->get_id(),
                'label' => $product->get_name(),
            ];
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get variation options for select
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getVariationOptions(WP_REST_Request $request): WP_REST_Response
    {
        $search = $request->get_param('search') ?? '';
        $product_id = $request->get_param('product_id') ?? 0;
        $include = $request->get_param('include') ?? '';
        $product_type = $request->get_param('product_type') ?? '';
        $type = $request->get_param('type') ?? '';

        // Subscription-only filtering. Via the unified select-options dispatcher
        // `type` carries the route ("variations"), so the subscription flag is
        // read from `product_type`; `type` is accepted too for parity with the
        // products endpoint.
        $subscription_only = ($product_type === 'subscription') || ($type === 'subscription');

        $options = [];

        // Resolve specific variation IDs (used to render labels for saved selections).
        if (!empty($include)) {
            $ids = array_filter(array_map('absint', explode(',', $include)));
            foreach ($ids as $variation_id) {
                $variation = wc_get_product($variation_id);
                if ($variation) {
                    $parent = wc_get_product($variation->get_parent_id());
                    $label = $parent ? $parent->get_name() . ' - ' : '';
                    $label .= $variation->get_attribute_summary();

                    $options[] = [
                        'value' => $variation->get_id(),
                        'label' => $label,
                    ];
                }
            }

            return new WP_REST_Response([
                'success' => true,
                'data'    => $options,
            ], 200);
        }

        if ($product_id > 0) {
            // Get variations for specific product
            $product = wc_get_product($product_id);
            if ($product && $product->is_type('variable')) {
                foreach ($product->get_children() as $variation_id) {
                    $variation = wc_get_product($variation_id);
                    if ($variation) {
                        $options[] = [
                            'value' => $variation_id,
                            'label' => $product->get_name() . ' - ' . $variation->get_attribute_summary(),
                        ];
                    }
                }
            }
        } else {
            // Search all variations
            $args = [
                'post_type'      => 'product_variation',
                'post_status'    => 'publish',
                'posts_per_page' => 20,
                'orderby'        => 'title',
                'order'          => 'ASC',
            ];

            if (!empty($search)) {
                $args['s'] = $search;
            } else {
                // No search yet: seed with the 10 most-recently-created
                // variations so the picker isn't empty before typing.
                $args['orderby']        = 'date';
                $args['order']          = 'DESC';
                $args['posts_per_page'] = 10;
            }

            // Subscription variations are flagged per-variation with the
            // `_is_subscription` meta (see arraysubs_is_subscription_product()).
            if ($subscription_only) {
                $args['meta_key']   = '_is_subscription';
                $args['meta_value'] = 'yes';
            }

            $variations = get_posts($args);

            foreach ($variations as $variation_post) {
                $variation = wc_get_product($variation_post->ID);
                if ($variation) {
                    $parent = wc_get_product($variation->get_parent_id());
                    $label = $parent ? $parent->get_name() . ' - ' : '';
                    $label .= $variation->get_attribute_summary();

                    $options[] = [
                        'value' => $variation->get_id(),
                        'label' => $label,
                    ];
                }
            }
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get taxonomy options for select
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getTaxonomyOptions(WP_REST_Request $request): WP_REST_Response
    {
        $taxonomies = get_taxonomies(['object_type' => ['product']], 'objects');
        $options = [];

        foreach ($taxonomies as $taxonomy) {
            $options[] = [
                'value' => $taxonomy->name,
                'label' => $taxonomy->label,
            ];
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get content taxonomy options for CPT rules.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getContentTaxonomyOptions(WP_REST_Request $request): WP_REST_Response
    {
        $taxonomies = get_taxonomies(['show_ui' => true], 'objects');
        $excluded_taxonomies = [
            'nav_menu',
            'link_category',
            'post_format',
        ];

        $content_taxonomies = [];

        foreach ($taxonomies as $taxonomy) {
            if (in_array($taxonomy->name, $excluded_taxonomies, true)) {
                continue;
            }

            $content_taxonomies[$taxonomy->name] = [
                'value' => $taxonomy->name,
                'label' => $taxonomy->label,
            ];
        }

        uasort(
            $content_taxonomies,
            static function (array $left, array $right): int {
                return strcasecmp($left['label'], $right['label']);
            }
        );

        return new WP_REST_Response([
            'success' => true,
            'data'    => array_values($content_taxonomies),
        ], 200);
    }

    /**
     * Get term options for select
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getTermOptions(WP_REST_Request $request): WP_REST_Response
    {
        $taxonomy = $request->get_param('taxonomy');
        $search = $request->get_param('search') ?? '';

        $args = [
            'taxonomy'   => $taxonomy,
            'hide_empty' => false,
            'number'     => 50,
        ];

        if (!empty($search)) {
            $args['search'] = $search;
        }

        $terms = get_terms($args);
        $options = [];

        if (!is_wp_error($terms)) {
            foreach ($terms as $term) {
                $options[] = [
                    'value' => $term->term_id,
                    'label' => $term->name,
                ];
            }
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get role options for select
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getRoleOptions(WP_REST_Request $request): WP_REST_Response
    {
        global $wp_roles;
        $options = [];

        foreach ($wp_roles->roles as $slug => $role) {
            $options[] = [
                'value' => $slug,
                'label' => $role['name'],
            ];
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get post type options for select
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getPostTypeOptions(WP_REST_Request $request): WP_REST_Response
    {
        $post_types = get_post_types(['show_ui' => true], 'objects');
        $options = [];

        foreach ($post_types as $post_type) {
            if (in_array($post_type->name, ['attachment', 'revision', 'nav_menu_item'], true)) {
                continue;
            }

            $options[] = [
                'value' => $post_type->name,
                'label' => $post_type->label,
            ];
        }

        usort(
            $options,
            static function (array $left, array $right): int {
                return strcasecmp($left['label'], $right['label']);
            }
        );

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get post options for select.
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getPostOptions(WP_REST_Request $request): WP_REST_Response
    {
        $search = $request->get_param('search') ?? '';
        $post_type = sanitize_key((string) ($request->get_param('post_type') ?? 'post'));

        if (empty($post_type) || !post_type_exists($post_type)) {
            $post_type = 'post';
        }

        $query = new \WP_Query([
            'post_type'              => $post_type,
            'post_status'            => ['publish', 'private'],
            'posts_per_page'         => 20,
            'orderby'                => 'title',
            'order'                  => 'ASC',
            's'                      => $search,
            'ignore_sticky_posts'    => true,
            'no_found_rows'          => true,
            'update_post_meta_cache' => false,
            'update_post_term_cache' => false,
        ]);

        $options = [];

        foreach ($query->posts as $post) {
            if (!$post instanceof \WP_Post) {
                continue;
            }

            $title = get_the_title($post);

            if ('' === $title) {
                /* translators: %d: post ID */
                $title = sprintf(__('(no title) #%d', 'arraysubs'), $post->ID);
            }

            $options[] = [
                'value' => $post->ID,
                'label' => $title,
            ];
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Get feature options for select (from Feature Manager)
     *
     * @param WP_REST_Request $request Request object.
     * @return WP_REST_Response
     */
    public function getFeatureOptions(WP_REST_Request $request): WP_REST_Response
    {
        // Get all defined features from products
        global $wpdb;

        $search = $request->get_param('search') ?? '';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Aggregate query for unique features
        $features_data = $wpdb->get_col(
            "SELECT DISTINCT meta_value FROM {$wpdb->postmeta}
            WHERE meta_key = '_arraysubs_features'
            AND meta_value != ''
            AND meta_value != '[]'"
        );

        $feature_keys = [];

        foreach ($features_data as $features_json) {
            $features = json_decode($features_json, true);
            if (is_array($features)) {
                foreach ($features as $feature) {
                    // Feature rows are stored as { id, name, type, value, enabled, order };
                    // `name` is both the identifier (matched by the gate) and the label.
                    $name = isset($feature['name']) ? trim((string) $feature['name']) : '';
                    if ($name === '') {
                        continue;
                    }
                    if ($search !== '' && stripos($name, (string) $search) === false) {
                        continue;
                    }
                    $feature_keys[$name] = $name;
                }
            }
        }

        $options = [];
        foreach ($feature_keys as $key => $label) {
            $options[] = [
                'value' => $key,
                'label' => $label,
            ];
        }

        return new WP_REST_Response([
            'success' => true,
            'data'    => $options,
        ], 200);
    }

    /**
     * Detect conflicts where a URL pattern rule overlaps a per-post override.
     *
     * @return array<int,array<string,mixed>>
     */
    private function detectAccessRuleConflicts(): array
    {
        $url_rules = arraysubs_get_setting('members_access.url_rules', []);
        if (empty($url_rules) || ! is_array($url_rules)) {
            return [];
        }

        $post_ids = get_posts([
            'post_type'      => 'any',
            'post_status'    => 'publish',
            'posts_per_page' => 200,
            'fields'         => 'ids',
            'meta_query'     => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Conflict detector must find posts with per-post overrides.
                [
                    'key'   => '_arraysubs_restrict_enabled',
                    'value' => '1',
                ],
            ],
        ]);

        if (empty($post_ids)) {
            return [];
        }

        $conflicts = [];

        foreach ($url_rules as $url_rule) {
            if (empty($url_rule['enabled']) || empty($url_rule['pattern'])) {
                continue;
            }

            foreach ($post_ids as $post_id) {
                $path = $this->getPostUrlPath((int) $post_id);

                if ('' === $path || ! $this->urlMatchesRule($path, $url_rule) || $this->urlExcludedByRule($path, $url_rule)) {
                    continue;
                }

                $post_conditions = $this->getPostRestrictionConditions((int) $post_id);
                if (null === $post_conditions) {
                    continue;
                }

                $conflicts[] = $this->formatAccessRuleConflict((int) $post_id, $path, $url_rule, $post_conditions);
            }
        }

        return $conflicts;
    }

    /**
     * Format a single conflict row.
     *
     * @param int    $post_id         Post ID.
     * @param string $path            URL path.
     * @param array  $url_rule        URL rule.
     * @param array  $post_conditions Per-post conditions.
     * @return array<string,mixed>
     */
    private function formatAccessRuleConflict(int $post_id, string $path, array $url_rule, array $post_conditions): array
    {
        $post = get_post($post_id);

        return [
            'id'       => md5(($url_rule['id'] ?? '') . '|' . $post_id),
            'target'   => [
                'post_id'   => $post_id,
                'title'     => $post ? get_the_title($post) : sprintf(__('Post #%d', 'arraysubs'), $post_id),
                'url'       => $path,
                'edit_url'  => get_edit_post_link($post_id, ''),
                'view_url'  => get_permalink($post_id),
            ],
            'url_rule' => [
                'id'                => (string) ($url_rule['id'] ?? ''),
                'name'              => (string) ($url_rule['name'] ?? __('URL rule', 'arraysubs')),
                'type'              => __('URL pattern rule', 'arraysubs'),
                'pattern'           => (string) ($url_rule['pattern'] ?? ''),
                'pattern_type'      => (string) ($url_rule['pattern_type'] ?? 'prefix'),
                'priority'          => (int) ($url_rule['priority'] ?? 10),
                'specificity_score' => $this->getUrlRuleSpecificityScore($url_rule),
                'effect'            => __('Deny when conditions fail', 'arraysubs'),
                'conditions'        => $url_rule['conditions'] ?? ['logic' => 'and', 'rules' => []],
                'conditions_label'  => $this->summarizeConditions($url_rule['conditions'] ?? []),
            ],
            'post_rule' => [
                'id'                => 'post_' . $post_id,
                'name'              => __('Per-post restriction', 'arraysubs'),
                'type'              => __('Per-post restriction', 'arraysubs'),
                'priority'          => 1,
                'specificity_score' => 100,
                'effect'            => __('Allow when conditions pass', 'arraysubs'),
                'conditions'        => $post_conditions,
                'conditions_label'  => $this->summarizeConditions($post_conditions),
            ],
            'winner'   => [
                'rule'              => 'post_rule',
                'label'             => __('Per-post restriction wins', 'arraysubs'),
                'effect'            => __('Allow when per-post conditions pass', 'arraysubs'),
                'specificity_score' => 100,
                'reason'            => __('Per-post restrictions have higher priority than URL pattern rules.', 'arraysubs'),
            ],
        ];
    }

    /**
     * Get priority order for conflict UI.
     *
     * @return string[]
     */
    private function getAccessRulePriorityOrder(): array
    {
        return [
            __('Per-post restriction', 'arraysubs'),
            __('Specific URL rule', 'arraysubs'),
            __('URL pattern rule', 'arraysubs'),
            __('Post type rule', 'arraysubs'),
            __('Taxonomy rule', 'arraysubs'),
        ];
    }

    /**
     * Get per-post condition data.
     *
     * @param int $post_id Post ID.
     * @return array|null
     */
    private function getPostRestrictionConditions(int $post_id): ?array
    {
        if ('1' !== get_post_meta($post_id, '_arraysubs_restrict_enabled', true)) {
            return null;
        }

        $raw = (string) get_post_meta($post_id, '_arraysubs_restrict_conditions', true);
        if ('' === $raw) {
            return null;
        }

        $conditions = json_decode($raw, true);

        return is_array($conditions) ? $conditions : null;
    }

    /**
     * Get URL path for a post.
     *
     * @param int $post_id Post ID.
     * @return string
     */
    private function getPostUrlPath(int $post_id): string
    {
        $permalink = get_permalink($post_id);
        if (! $permalink) {
            return '';
        }

        $path = wp_parse_url($permalink, PHP_URL_PATH);

        if ($path && '/' !== $path) {
            return '/' . ltrim((string) $path, '/');
        }

        $post = get_post($post_id);
        if ($post instanceof \WP_Post && 'page' === $post->post_type) {
            $page_uri = get_page_uri($post_id);

            return $page_uri ? '/' . ltrim($page_uri, '/') : '';
        }

        return $post instanceof \WP_Post && $post->post_name
            ? '/' . ltrim($post->post_name, '/')
            : '';
    }

    /**
     * Check whether a URL path matches a URL rule.
     *
     * @param string $path URL path.
     * @param array  $rule URL rule.
     * @return bool
     */
    private function urlMatchesRule(string $path, array $rule): bool
    {
        $pattern = (string) ($rule['pattern'] ?? '');
        $type    = (string) ($rule['pattern_type'] ?? 'prefix');

        if ('' === $pattern) {
            return false;
        }

        switch ($type) {
            case 'exact':
                return $path === $pattern;

            case 'contains':
                return false !== strpos($path, $pattern);

            case 'regex':
                return 1 === preg_match('~' . str_replace('~', '\~', $pattern) . '~i', $path);

            case 'prefix':
            default:
                return 0 === strpos($path, rtrim($pattern, '*'));
        }
    }

    /**
     * Check whether a URL path is excluded by a URL rule.
     *
     * @param string $path URL path.
     * @param array  $rule URL rule.
     * @return bool
     */
    private function urlExcludedByRule(string $path, array $rule): bool
    {
        $exclusions = $rule['exclusions'] ?? [];
        if (! is_array($exclusions)) {
            return false;
        }

        foreach ($exclusions as $exclusion) {
            $exclusion = trim((string) $exclusion);
            if ('' !== $exclusion && 0 === strpos($path, rtrim($exclusion, '*'))) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get rule specificity score.
     *
     * @param array $rule URL rule.
     * @return int
     */
    private function getUrlRuleSpecificityScore(array $rule): int
    {
        $type = (string) ($rule['pattern_type'] ?? 'prefix');

        if ('exact' === $type) {
            return 80;
        }

        if ('prefix' === $type) {
            return 60;
        }

        return 55;
    }

    /**
     * Summarize a condition tree.
     *
     * @param array $conditions Conditions.
     * @return string
     */
    private function summarizeConditions(array $conditions): string
    {
        $rules = $conditions['rules'] ?? [];
        if (empty($rules) || ! is_array($rules)) {
            return __('No conditions configured', 'arraysubs');
        }

        $logic = strtoupper((string) ($conditions['logic'] ?? 'and'));
        $parts = [];

        foreach ($rules as $rule) {
            if (! is_array($rule)) {
                continue;
            }

            if ('group' === ($rule['type'] ?? '')) {
                $parts[] = '(' . $this->summarizeConditions([
                    'logic' => $rule['logic'] ?? 'or',
                    'rules' => $rule['rules'] ?? [],
                ]) . ')';
                continue;
            }

            $parts[] = $this->summarizeConditionRule($rule);
        }

        return implode(' ' . $logic . ' ', array_filter($parts));
    }

    /**
     * Summarize a single condition.
     *
     * @param array $rule Condition rule.
     * @return string
     */
    private function summarizeConditionRule(array $rule): string
    {
        $type = (string) ($rule['type'] ?? '');

        switch ($type) {
            case 'user_role':
                $roles = $rule['roles'] ?? [];
                return sprintf(
                    /* translators: %s: role list. */
                    __('User role is %s', 'arraysubs'),
                    $this->formatList($roles)
                );

            case 'has_active_subscription':
                $product_ids = $rule['product_ids'] ?? [];
                return empty($product_ids)
                    ? __('Has any active subscription', 'arraysubs')
                    : sprintf(
                        /* translators: %s: product ID list. */
                        __('Has active subscription to product(s) %s', 'arraysubs'),
                        $this->formatList($product_ids)
                    );

            case 'has_subscription_variation':
                return sprintf(
                    /* translators: %s: variation ID list. */
                    __('Has subscription variation(s) %s', 'arraysubs'),
                    $this->formatList($rule['variation_ids'] ?? [])
                );

            case 'lifetime_spent':
                return sprintf(
                    /* translators: 1: operator, 2: amount. */
                    __('Lifetime spent %1$s %2$s', 'arraysubs'),
                    (string) ($rule['operator'] ?? '>='),
                    (string) ($rule['amount'] ?? 0)
                );

            default:
                return ucwords(str_replace('_', ' ', $type ?: __('Condition', 'arraysubs')));
        }
    }

    /**
     * Format a scalar list for display.
     *
     * @param mixed $value Raw value.
     * @return string
     */
    private function formatList($value): string
    {
        if (! is_array($value)) {
            return (string) $value;
        }

        $items = array_filter(array_map('strval', $value));

        return empty($items) ? __('any', 'arraysubs') : implode(', ', $items);
    }

    /**
     * Get default settings
     *
     * @return array
     */
    private function getDefaultSettings(): array
    {
        $defaults = [
            'enabled'              => true,
            'default_redirect_url' => '/pricing',
            'default_message'      => __('This content is restricted. Please subscribe to access.', 'arraysubs'),
            'require_login'        => true,
            'cache_compatibility'  => true,
            'role_mapping_rules'   => [],
            'url_rules'            => [],
            'cpt_rules'            => [],
            'download_rules'       => [],
            'discount_rules'       => [],
            'ecommerce_rules'      => [],
            'download_settings'    => [
                'protected_directories' => [],
                'serve_via_php'         => false,
                'signed_url_expiry'     => 3600,
            ],
        ];

        return apply_filters('arraysubs_members_access_default_settings', $defaults);
    }

    /**
     * Sanitize settings
     *
     * @param array $settings Settings to sanitize.
     * @return array
     */
    private function sanitizeSettings(array $settings): array
    {
        $sanitized = [];

        // Boolean settings
        $sanitized['enabled'] = !empty($settings['enabled']);
        $sanitized['require_login'] = !empty($settings['require_login']);
        $sanitized['cache_compatibility'] = !empty($settings['cache_compatibility']);

        // String settings
        $sanitized['default_redirect_url'] = sanitize_text_field($settings['default_redirect_url'] ?? '/pricing');
        $sanitized['default_message'] = wp_kses_post($settings['default_message'] ?? '');

        // Rule arrays (sanitize each rule)
        $sanitized['role_mapping_rules'] = $this->sanitizeRules($settings['role_mapping_rules'] ?? []);
        $sanitized['url_rules'] = $this->sanitizeRules($settings['url_rules'] ?? []);
        $sanitized['cpt_rules'] = $this->sanitizeRules($settings['cpt_rules'] ?? []);
        $sanitized['download_rules'] = $this->sanitizeRules($settings['download_rules'] ?? []);
        $sanitized['discount_rules'] = $this->sanitizeRules($settings['discount_rules'] ?? []);
        $sanitized['ecommerce_rules'] = $this->sanitizeRules($settings['ecommerce_rules'] ?? []);

        // Download settings
        $download_settings = $settings['download_settings'] ?? [];
        $sanitized['download_settings'] = [
            'protected_directories' => array_map('sanitize_text_field', $download_settings['protected_directories'] ?? []),
            'serve_via_php'         => !empty($download_settings['serve_via_php']),
            'signed_url_expiry'     => absint($download_settings['signed_url_expiry'] ?? 3600),
        ];

        return apply_filters('arraysubs_members_access_sanitize_settings', $sanitized, $settings);
    }

    /**
     * Sanitize rules array
     *
     * @param array $rules Rules to sanitize.
     * @return array
     */
    private function sanitizeRules(array $rules): array
    {
        $sanitized = [];

        foreach ($rules as $rule) {
            if (!is_array($rule)) {
                continue;
            }

            $sanitized_rule = [
                'id'      => sanitize_text_field($rule['id'] ?? uniqid('rule_')),
                'enabled' => !empty($rule['enabled']),
            ];

            // Copy other fields based on rule type
            foreach ($rule as $key => $value) {
                if (in_array($key, ['id', 'enabled'], true)) {
                    continue;
                }

                if ($key === 'conditions') {
                    $sanitized_rule[$key] = $this->sanitizeConditions($value);
                } elseif ($key === 'schedule_enabled') {
                    $sanitized_rule[$key] = !empty($value);
                } elseif ($key === 'schedule_value') {
                    $sanitized_rule[$key] = max(0, absint($value));
                } elseif ($key === 'schedule_unit') {
                    $allowed_units = ['days', 'weeks', 'months'];
                    $sanitized_rule[$key] = in_array($value, $allowed_units, true) ? $value : 'days';
                } elseif ($key === 'message') {
                    $sanitized_rule[$key] = wp_kses_post($value);
                } elseif ($key === 'redirect_url') {
                    $sanitized_rule[$key] = esc_url_raw($value);
                } elseif ($key === 'files') {
                    // Handle files array (for download rules)
                    $sanitized_rule[$key] = $this->sanitizeFilesList($value);
                } elseif (is_array($value)) {
                    $sanitized_rule[$key] = array_map(function ($v) {
                        return is_numeric($v) ? absint($v) : sanitize_text_field($v);
                    }, $value);
                } elseif (is_bool($value)) {
                    $sanitized_rule[$key] = $value;
                } elseif (is_numeric($value)) {
                    $sanitized_rule[$key] = is_float($value) ? floatval($value) : absint($value);
                } else {
                    $sanitized_rule[$key] = sanitize_text_field($value);
                }
            }

            $sanitized[] = $sanitized_rule;
        }

        return $sanitized;
    }

    /**
     * Sanitize files list for download rules
     *
     * @param array $files Files array.
     * @return array
     */
    private function sanitizeFilesList(array $files): array
    {
        $sanitized = [];

        foreach ($files as $file) {
            if (!is_array($file)) {
                continue;
            }

            // Skip files without URL (not selected from media library)
            $url = esc_url_raw($file['url'] ?? '');
            if (empty($url)) {
                continue;
            }

            $sanitized[] = [
                'id'       => is_numeric($file['id'] ?? '') ? absint($file['id']) : sanitize_text_field($file['id'] ?? uniqid()),
                'name'     => sanitize_text_field($file['name'] ?? ''),
                'url'      => $url,
                'filename' => sanitize_file_name($file['filename'] ?? basename($url)),
            ];
        }

        return $sanitized;
    }

    /**
     * Sanitize conditions
     *
     * @param array $conditions Conditions to sanitize.
     * @return array
     */
    private function sanitizeConditions(array $conditions): array
    {
        return [
            'logic' => in_array($conditions['logic'] ?? 'and', ['and', 'or'], true) ? $conditions['logic'] : 'and',
            'rules' => $this->sanitizeConditionRules($conditions['rules'] ?? []),
        ];
    }

    /**
     * Sanitize condition rules
     *
     * @param array $rules Condition rules.
     * @return array
     */
    private function sanitizeConditionRules(array $rules): array
    {
        $sanitized = [];

        foreach ($rules as $rule) {
            if (!is_array($rule)) {
                continue;
            }

            $type = sanitize_text_field($rule['type'] ?? '');
            if (empty($type)) {
                continue;
            }

            $sanitized_rule = ['type' => $type];

            // Handle nested groups
            if ($type === 'group') {
                $sanitized_rule['logic'] = in_array($rule['logic'] ?? 'and', ['and', 'or'], true) ? $rule['logic'] : 'and';
                $sanitized_rule['rules'] = $this->sanitizeConditionRules($rule['rules'] ?? []);
                $sanitized[] = $sanitized_rule;
                continue;
            }

            // Copy other fields
            foreach ($rule as $key => $value) {
                if ($key === 'type') {
                    continue;
                }

                if (is_array($value)) {
                    $sanitized_rule[$key] = array_map(function ($v) {
                        return is_numeric($v) ? absint($v) : sanitize_text_field($v);
                    }, $value);
                } elseif (is_numeric($value)) {
                    $sanitized_rule[$key] = is_float($value + 0) ? floatval($value) : absint($value);
                } else {
                    $sanitized_rule[$key] = sanitize_text_field($value);
                }
            }

            $sanitized[] = $sanitized_rule;
        }

        return $sanitized;
    }
}
