<?php
/**
 * Copyright (C) 2026 Plugixa (https://plugixa.com)
 * License: GNU General Public License v2 or later
 * License URI: http://www.gnu.org/licenses/gpl-2.0.txt
 */

namespace PlugixaRecipe\Frontend;

// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQLPlaceholders.ReplacementsWrongNumber, WordPress.DB.SlowDBQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Custom-table data layer: table identifiers are built with $wpdb->prefix and are never user input; all request values are bound via $wpdb->prepare(); ORDER BY / LIMIT fragments come out of internal allow-list maps; content reads go through PlugixaRecipe\Cache (the static sniff cannot trace caching through the wrapper); meta_key/meta_value are dedicated plugin columns, not WP_Query.

defined( 'ABSPATH' ) || exit;

use PlugixaRecipe\Cache;

/**
 * Shortcodes Class - Registers and renders frontend recipe shortcodes.
 *
 * [plugixa_recipes] - Display recipes in grid/slider/masonry/list layouts
 * [plugixa_recipe_form] - Frontend recipe submission form
 */
class Shortcodes {

    /** @var bool Track whether assets have been enqueued */
    private bool $assets_enqueued = false;

    /** @var bool Track whether shortcode is present on the page */
    private static bool $has_shortcode = false;

    /**
     * Register shortcodes.
     */
    public function register(): void {
        add_shortcode('plugixarecipe', [$this, 'render_recipes']);
        add_shortcode('plugixa_recipes', [$this, 'render_recipes']); // backward compat
        add_shortcode('plugixa_recipe_form', [$this, 'render_submission_form']);

        // AJAX pagination handler
        add_action('wp_ajax_pxr_load_recipes', [$this, 'ajax_load_recipes']);
        add_action('wp_ajax_nopriv_pxr_load_recipes', [$this, 'ajax_load_recipes']);
    }

    /**
     * Render the [plugixa_recipes] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string Rendered HTML output.
     */
    public function render_recipes($atts): string {
        $atts = shortcode_atts([
            'id'         => '',        // Shortcode ID (loads config from DB)
            'view'       => '',        // View ID or slug
            'categories' => '',        // Comma-separated category slugs or IDs
            'cuisines'   => '',        // Comma-separated cuisine slugs or IDs
            'courses'    => '',        // Comma-separated course slugs or IDs
            'limit'      => 12,
            'orderby'    => 'created_at',
            'order'      => 'DESC',
            'featured'   => '',        // 'true' to show only featured
            'min_rating' => '',
            'difficulty' => '',        // easy, medium, hard
            'columns'    => '',        // Override columns (desktop)
            'dark_mode'        => 'false',
            'pagination'       => 'false',
            'pagination_style' => '',
            'per_page'         => 12,
        ], $atts, 'plugixa_recipes');

        self::$has_shortcode = true;

        // If shortcode ID is provided, merge DB config
        $shortcode_config = [];
        if (!empty($atts['id'])) {
            $shortcode_config = $this->get_shortcode_config((int) $atts['id']);
        }

        // Merge: DB config takes precedence; only inline attributes explicitly
        // different from defaults should override (avoids 'pagination'=>'false' default clobbering DB values)
        $defaults = ['dark_mode' => 'false', 'pagination' => 'false', 'per_page' => 12, 'limit' => 12, 'orderby' => 'created_at', 'order' => 'DESC'];
        $explicit_atts = array_filter($atts, function ($v, $k) use ($defaults) {
            if ($v === '' || $v === null) return false;
            // Skip if value matches the default (user didn't explicitly set it)
            if (isset($defaults[$k]) && (string) $v === (string) $defaults[$k]) return false;
            return true;
        }, ARRAY_FILTER_USE_BOTH);
        $config = array_merge($shortcode_config, $explicit_atts);

        // Load view config — check both inline 'view' attribute and DB 'view_id'
        $view_identifier = $config['view'] ?? '';
        if (empty($view_identifier) && !empty($config['view_id'])) {
            $view_identifier = $config['view_id'];
        }
        $view_config = $this->get_view_config($view_identifier);

        // Override columns if specified
        if (!empty($config['columns'])) {
            $view_config['layout']['columns']['desktop'] = (int) $config['columns'];
        }

        // Determine layout style
        $layout_style = $view_config['layout']['style'] ?? 'grid';

        // Store image size for enrich_recipe()
        $this->image_size = $view_config['image']['size'] ?? 'medium_large';

        // When pagination is enabled, limit initial fetch to perPage
        $pagination_on = !empty($config['paginationEnabled'])
            || filter_var($config['pagination'] ?? false, FILTER_VALIDATE_BOOLEAN);
        if ($pagination_on) {
            $pp = (int) ($config['perPage'] ?? $config['per_page'] ?? 12);
            if ($pp > 0 && ((int) ($config['limit'] ?? 0) <= 0 || (int) ($config['limit'] ?? 0) > $pp)) {
                $config['limit'] = $pp;
            }
        }

        // Fetch recipes
        $recipes = $this->query_recipes($config);

        if (empty($recipes)) {
            return '<div class="pxr-no-recipes">' . esc_html__('No recipes found.', 'plugixarecipe') . '</div>';
        }

        // Resolve display settings: shortcode config > view config > defaults
        $display_settings = $this->resolve_display_settings($config, $view_config);
        $view_config = array_merge($view_config, $display_settings);

        // Bulk-load ingredient names for ingredient filter (single query, not N+1)
        $show_ingredient_filter = !empty($display_settings['show_ingredient_filter']);
        if ($show_ingredient_filter) {
            global $wpdb;
            $prefix = $wpdb->prefix . 'plugixarecipe_';
            $recipe_ids = array_map(function ($r) { return (int) $r->id; }, $recipes);
            if (!empty($recipe_ids)) {
                $placeholders = implode(',', array_fill(0, count($recipe_ids), '%d'));
                // Read-through object cache; key = the recipe ids in the result set.
                $ingredient_rows = Cache::remember('recipes', 'ingredient_filter|' . wp_json_encode($recipe_ids), function () use ($wpdb, $prefix, $placeholders, $recipe_ids) {
                    return $wpdb->get_results($wpdb->prepare(
                        "SELECT recipe_id, GROUP_CONCAT(DISTINCT LOWER(name) SEPARATOR ',') as names
                         FROM {$prefix}ingredients WHERE recipe_id IN ({$placeholders}) GROUP BY recipe_id",
                        $recipe_ids
                    )) ?: [];
                });
                $ingredient_map = [];
                foreach ($ingredient_rows as $row) {
                    $ingredient_map[(int) $row->recipe_id] = $row->names;
                }
                foreach ($recipes as $recipe) {
                    $recipe->ingredient_names = $ingredient_map[(int) $recipe->id] ?? '';
                }
            }
        }

        // Enqueue assets
        $this->enqueue_frontend_assets($layout_style);

        // Enqueue self-hosted fonts for custom typography (no external CDN — see
        // assets/frontend/fonts/. Only the woff2 actually used by the rendered text
        // is downloaded; the rest are just @font-face declarations.)
        $uses_custom_font = false;
        foreach (['titleFontFamily', 'textFontFamily', 'metaFontFamily'] as $fk) {
            $font = $view_config['typography'][$fk] ?? '';
            if ($font && $font !== 'inherit') {
                $uses_custom_font = true;
                break;
            }
        }
        if ($uses_custom_font) {
            wp_enqueue_style(
                'pxr-fonts',
                PLUGIXARECIPE_URL . 'assets/frontend/fonts/fonts.css',
                [],
                PLUGIXARECIPE_VERSION
            );
        }

        // Determine dark mode BEFORE building CSS vars (so colors can be swapped)
        $dark_mode_state = 'disabled';
        $sc_dark = $config['darkMode'] ?? $config['dark_mode'] ?? null;
        if ($sc_dark !== null && $sc_dark !== 'false' && $sc_dark !== false) {
            $dark_mode_state = $this->resolve_dark_mode_state($sc_dark);
        }
        if ($dark_mode_state === 'disabled' && !empty($view_config['dark_mode'])) {
            $dark_mode_state = $this->resolve_dark_mode_state($view_config['dark_mode']);
        }
        if ($dark_mode_state === 'disabled' && !empty($view_config['darkMode']['enabled'])) {
            $dark_mode_state = 'enabled';
        }
        $dark_mode = ($dark_mode_state === 'enabled');
        $dark_mode_auto = ($dark_mode_state === 'auto');

        // Build CSS variables (dark mode state determines which colors to use)
        $css_vars = $this->build_css_vars($view_config, $config, $dark_mode_state);
        $unique_id = 'pxr-' . ($config['id'] ?? uniqid());

        // Pagination data — support both new nested keys and legacy flat keys
        $pagination_enabled = !empty($config['paginationEnabled'])
            || filter_var($config['pagination'] ?? false, FILTER_VALIDATE_BOOLEAN)
            || !empty($display_settings['show_pagination']);
        $per_page = (int) ($config['perPage'] ?? $config['per_page'] ?? $display_settings['items_per_page'] ?? 12);
        $pagination_style = $config['paginationStyle'] ?? $config['pagination_style'] ?? $display_settings['pagination_type'] ?? 'numbers';

        // For "numbers" pagination, compute total pages for the template
        $total_recipes = count($recipes);
        if ($pagination_enabled && $pagination_style === 'numbers') {
            // Count total recipes matching the query (without limit)
            $count_config = $config;
            $count_config['limit'] = -1;
            $count_config['offset'] = 0;
            $all_recipes = $this->query_recipes($count_config);
            $total_recipes = count($all_recipes);
        }
        $total_pages = $per_page > 0 ? (int) ceil($total_recipes / $per_page) : 1;

        // Build template data
        $shortcode_config = array_merge($config, [
            'page'        => 1,
            'total_pages' => $total_pages,
            'per_page'    => $per_page,
        ]);

        // Store config server-side and pass only a key to the frontend
        $config_key = 'pxr_pgn_' . md5(wp_json_encode($shortcode_config));
        set_transient($config_key, $shortcode_config, DAY_IN_SECONDS);

        $data = [
            'recipes'          => $recipes,
            'view_config'      => $view_config,
            'config'           => $config,
            'shortcode_config' => $shortcode_config,
            'config_key'       => $config_key,
            'css_vars'         => $css_vars,
            'css_vars_style'   => $css_vars,   // alias for templates
            'unique_id'        => $unique_id,
            'dark_mode'        => $dark_mode,
            'pagination'       => $pagination_enabled,
            'pagination_style' => $pagination_style,
            'per_page'         => $per_page,
            'total_pages'      => $total_pages,
            'layout_style'     => $layout_style,
            'settings'         => $this->get_settings(),
        ];

        // Render template
        ob_start();

        $wrapper_classes = 'pxr-recipes-wrapper';
        if ($dark_mode) {
            $wrapper_classes .= ' pxr-dark';
        }
        // Emitted in parts so every dynamic value is escaped inline at its own output site.
        echo '<div id="' . esc_attr($unique_id) . '" class="' . esc_attr($wrapper_classes) . '"';
        if ($dark_mode_auto) {
            echo ' data-pxr-dark-mode="auto"';
        }
        echo ' style="' . esc_attr($css_vars) . '">';

        $template = $this->locate_template($layout_style);
        if ($template && file_exists($template)) {
            extract($data, EXTR_SKIP);
            // Ensure pagination vars are available to template even if EXTR_SKIP skipped them
            $shortcode_config = $data['shortcode_config'];
            $total_pages = $data['total_pages'];
            include $template;
        }

        // Pagination — "numbers" is handled by grid.php template, others rendered here
        if ($pagination_enabled && $pagination_style !== 'numbers') {
            $this->render_pagination($data);
        }

        echo '</div>';

        return ob_get_clean();
    }

    /**
     * Render the [plugixa_recipe_form] shortcode for frontend submissions.
     */
    /**
     * Enqueue Google reCAPTCHA v3 for a public form.
     *
     * The templates used to print a raw <script src> tag, which WordPress.org rejects
     * (WordPress.WP.EnqueuedResources.NonEnqueuedScript). It only loads when the site
     * owner has switched reCAPTCHA on and supplied their own site key, so the free build
     * contacts Google only on an explicit opt-in.
     *
     * @param bool   $enabled  Whether reCAPTCHA is switched on for this form.
     * @param string $site_key The site owner's reCAPTCHA site key.
     */
    public static function enqueue_recaptcha(bool $enabled, string $site_key): void {
        if (!$enabled || $site_key === '') {
            return;
        }

        wp_enqueue_script(
            'plugixarecipe-recaptcha',
            'https://www.google.com/recaptcha/api.js?render=' . rawurlencode($site_key),
            [],
            null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Third-party Google reCAPTCHA endpoint: always served current, and must not carry a plugin ?ver= cache-buster we do not control.
            true
        );
    }

    public function render_submission_form($atts): string {
        $atts = shortcode_atts([
            'style'      => 'inline',
            'categories' => '',
            'cuisines'   => '',
            'redirect'   => '',
        ], $atts, 'plugixa_recipe_form');

        $settings = $this->get_settings();
        if (empty($settings['enable_submissions'])) {
            return '';
        }

        // Login gate (configurable via settings).
        $require_login = ! empty($settings['submission_require_login']);
        if ($require_login && ! is_user_logged_in()) {
            return '<div class="pxr-custom-submission-login pxr:max-w-2xl pxr:mx-auto pxr:text-center pxr:py-12">'
                 . '<p class="pxr:text-gray-600 pxr:text-sm pxr:mb-4">'
                 . esc_html__('You must be logged in to submit a recipe.', 'plugixarecipe')
                 . '</p>'
                 . '<a href="' . esc_url(wp_login_url(get_permalink())) . '" class="pxr:inline-block pxr:rounded-md pxr:bg-indigo-600 pxr:px-4 pxr:py-2 pxr:text-sm pxr:font-semibold pxr:text-white hover:pxr:bg-indigo-500">'
                 . esc_html__('Log In', 'plugixarecipe')
                 . '</a></div>';
        }

        self::$has_shortcode = true;
        $this->enqueue_frontend_assets('form');

        // Template variables.
        $unique_id     = 'pxr-form-' . wp_unique_id();
        $display_mode  = sanitize_text_field($atts['style']);
        $nonce         = wp_create_nonce('wp_rest');
        $rest_url      = rest_url('plugixarecipe/v1/frontend/submit-recipe');
        $max_file_size = (int) ($settings['max_upload_size'] ?? 5);

        $recaptcha_enabled  = ! empty($settings['recaptcha_form_enabled']);
        $recaptcha_site_key = $settings['recaptcha_site_key'] ?? '';

        self::enqueue_recaptcha($recaptcha_enabled, $recaptcha_site_key);

        // Fetch categories and cuisines from DB.
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        // Read-through object cache; these queries take no inputs.
        $category_options = Cache::remember('categories', 'form_options', function () use ($wpdb, $prefix) {
            return $wpdb->get_results(
                "SELECT id, name FROM {$prefix}categories ORDER BY name ASC",
                ARRAY_A
            ) ?: [];
        });
        $cuisine_options = Cache::remember('cuisines', 'form_options', function () use ($wpdb, $prefix) {
            return $wpdb->get_results(
                "SELECT id, name FROM {$prefix}cuisines ORDER BY name ASC",
                ARRAY_A
            ) ?: [];
        });
        $course_options = Cache::remember('courses', 'form_options', function () use ($wpdb, $prefix) {
            return $wpdb->get_results(
                "SELECT id, name FROM {$prefix}courses ORDER BY name ASC",
                ARRAY_A
            ) ?: [];
        });

        $difficulty_options = [
            'easy'   => __('Easy', 'plugixarecipe'),
            'medium' => __('Medium', 'plugixarecipe'),
            'hard'   => __('Hard', 'plugixarecipe'),
        ];
        $cost_options = [
            'budget'    => __('Budget', 'plugixarecipe'),
            'medium'    => __('Medium', 'plugixarecipe'),
            'expensive' => __('Expensive', 'plugixarecipe'),
        ];
        $season_options = [
            'all'    => __('All Seasons', 'plugixarecipe'),
            'spring' => __('Spring', 'plugixarecipe'),
            'summer' => __('Summer', 'plugixarecipe'),
            'autumn' => __('Autumn', 'plugixarecipe'),
            'winter' => __('Winter', 'plugixarecipe'),
        ];
        $unit_options = [
            ''      => __('Unit', 'plugixarecipe'),
            'tsp'   => 'tsp',
            'tbsp'  => 'tbsp',
            'cup'   => 'cup',
            'oz'    => 'oz',
            'lb'    => 'lb',
            'g'     => 'g',
            'kg'    => 'kg',
            'ml'    => 'ml',
            'l'     => 'l',
            'piece' => 'piece',
            'slice' => 'slice',
            'pinch' => 'pinch',
            'dash'  => 'dash',
            'clove' => 'clove',
            'can'   => 'can',
            'bunch' => 'bunch',
            'sprig' => 'sprig',
        ];

        ob_start();

        $template = PLUGIXARECIPE_PATH . 'templates/submission-form.php';
        if (file_exists($template)) {
            include $template;
        }

        return ob_get_clean();
    }

    /**
     * Build inline CSS custom property declarations from view config.
     *
     * @param array $view_config View configuration array.
     * @param array $config      Shortcode configuration overrides.
     * @return string CSS custom properties string (for style attribute).
     */
    public function build_css_vars(array $view_config, array $config = [], string $dark_mode_state = 'disabled'): string {
        $vars = [];

        // Colors — swap to dark mode palette when enabled
        $colors = $view_config['colors'] ?? [];
        $dark_colors = $view_config['darkMode'] ?? [];

        if ($dark_mode_state === 'enabled' && !empty($dark_colors)) {
            $dark_keys = ['cardBg', 'textColor', 'titleColor', 'metaColor', 'starColor', 'starEmptyColor', 'borderColor', 'badgeBg', 'badgeText'];
            foreach ($dark_keys as $dk) {
                if (!empty($dark_colors[$dk])) {
                    $colors[$dk] = $dark_colors[$dk];
                }
            }
            if (!empty($dark_colors['containerBg'])) {
                $vars['--pxr-container-bg'] = $dark_colors['containerBg'];
            }
        }

        $color_map = [
            'cardBg'         => '--pxr-card-bg',
            'textColor'      => '--pxr-text-color',
            'titleColor'      => '--pxr-title-color',
            'titleHoverColor' => '--pxr-title-hover-color',
            'metaColor'       => '--pxr-meta-color',
            'starColor'      => '--pxr-star-color',
            'starEmptyColor' => '--pxr-star-empty-color',
            'borderColor'    => '--pxr-border-color',
            'badgeBg'        => '--pxr-badge-bg',
            'badgeText'      => '--pxr-badge-text',
        ];

        foreach ($color_map as $key => $var) {
            if (!empty($colors[$key])) {
                $vars[$var] = $colors[$key];
            }
        }

        // Typography
        $typography = $view_config['typography'] ?? [];
        $type_map = [
            'titleFontSize'   => ['--pxr-title-font-size', 'px'],
            'titleFontWeight' => ['--pxr-title-font-weight', ''],
            'textFontSize'    => ['--pxr-text-font-size', 'px'],
            'textFontWeight'  => ['--pxr-text-font-weight', ''],
            'metaFontSize'    => ['--pxr-meta-font-size', 'px'],
        ];

        foreach ($type_map as $key => [$var, $unit]) {
            if (isset($typography[$key])) {
                $vars[$var] = $typography[$key] . $unit;
            }
        }

        // Typography - font families (individual per element)
        $font_family_map = [
            'titleFontFamily' => '--pxr-title-font-family',
            'textFontFamily'  => '--pxr-text-font-family',
            'metaFontFamily'  => '--pxr-meta-font-family',
        ];
        foreach ($font_family_map as $key => $var) {
            if (!empty($typography[$key]) && $typography[$key] !== 'inherit') {
                $vars[$var] = '"' . $typography[$key] . '", sans-serif';
            }
        }
        // Legacy single fontFamily (backward compat)
        if (!empty($typography['fontFamily']) && $typography['fontFamily'] !== 'inherit') {
            $vars['--pxr-font-family'] = '"' . $typography['fontFamily'] . '", sans-serif';
        }

        // Spacing
        $spacing = $view_config['spacing'] ?? [];
        if (isset($spacing['cardPadding'])) {
            $vars['--pxr-card-padding'] = $spacing['cardPadding'] . 'px';
        }
        if (isset($spacing['contentPadding'])) {
            $vars['--pxr-content-padding'] = $spacing['contentPadding'] . 'px';
        }
        if (isset($spacing['gap'])) {
            $vars['--pxr-gap'] = $spacing['gap'] . 'px';
        }

        // Borders
        $borders = $view_config['borders'] ?? [];
        if (isset($borders['radius'])) {
            $vars['--pxr-border-radius'] = $borders['radius'] . 'px';
        }
        if (isset($borders['width'])) {
            $vars['--pxr-border-width'] = $borders['width'] . 'px';
        }

        // Shadows
        $shadows = $view_config['shadows'] ?? [];
        if (!empty($shadows['enabled']) && !empty($shadows['value'])) {
            $vars['--pxr-box-shadow'] = $shadows['value'];
        } else {
            $vars['--pxr-box-shadow'] = 'none';
        }

        // Image
        $image = $view_config['image'] ?? [];
        if (!empty($image['aspectRatio'])) {
            $vars['--pxr-image-aspect-ratio'] = $image['aspectRatio'];
        }
        if (!empty($image['borderRadius'])) {
            $vars['--pxr-image-border-radius'] = $image['borderRadius'] . 'px';
        }
        if (!empty($image['objectFit'])) {
            $vars['--pxr-image-object-fit'] = $image['objectFit'];
        }

        // Video
        $vars['--pxr-video-play-icon-size'] = '48px';

        // Categories — swap to dark mode category colors when enabled
        $categories = $view_config['categories'] ?? [];
        $cat_colors = $categories['colors'] ?? [];
        if ($dark_mode_state === 'enabled' && !empty($dark_colors['categories'])) {
            $cat_colors = array_merge($cat_colors, $dark_colors['categories']);
        }
        $cat_color_map = [
            'backgroundColor'       => '--pxr-cat-bg',
            'textColor'             => '--pxr-cat-text',
            'activeBackgroundColor' => '--pxr-cat-active-bg',
            'activeTextColor'       => '--pxr-cat-active-text',
            'borderColor'           => '--pxr-cat-border-color',
        ];
        foreach ($cat_color_map as $key => $var) {
            if (!empty($cat_colors[$key])) {
                $vars[$var] = $cat_colors[$key];
            }
        }
        $cat_typo = $categories['typography'] ?? [];
        if (isset($cat_typo['fontSize'])) {
            $vars['--pxr-cat-font-size'] = $cat_typo['fontSize'] . 'px';
        }
        if (isset($cat_typo['fontWeight'])) {
            $vars['--pxr-cat-font-weight'] = $cat_typo['fontWeight'];
        }
        if (!empty($cat_typo['fontFamily']) && $cat_typo['fontFamily'] !== 'inherit') {
            $vars['--pxr-cat-font-family'] = '"' . $cat_typo['fontFamily'] . '", sans-serif';
        }
        $cat_spacing = $categories['spacing'] ?? [];
        if (isset($cat_spacing['padding'])) {
            $vars['--pxr-cat-padding'] = $cat_spacing['padding'] . 'px';
        }
        if (isset($cat_spacing['gap'])) {
            $vars['--pxr-cat-gap'] = $cat_spacing['gap'] . 'px';
        }
        $cat_border = $categories['border'] ?? [];
        if (isset($cat_border['width'])) {
            $vars['--pxr-cat-border-width'] = $cat_border['width'] . 'px';
        }
        if (isset($cat_border['radius'])) {
            $vars['--pxr-cat-border-radius'] = $cat_border['radius'] . 'px';
        }

        // Pagination colors — swap to dark mode when enabled
        $pagination = $view_config['pagination'] ?? [];
        $pag_alignment = $pagination['alignment'] ?? 'center';
        $align_map = ['left' => 'flex-start', 'center' => 'center', 'right' => 'flex-end'];
        $vars['--pxr-pag-alignment'] = $align_map[$pag_alignment] ?? 'center';

        $pag_numbers = $pagination['numbers'] ?? [];
        $pag_load_more = $pagination['loadMore'] ?? [];
        $pag_infinite = $pagination['infiniteScroll'] ?? [];

        if ($dark_mode_state === 'enabled' && !empty($dark_colors['pagination'])) {
            $dp = $dark_colors['pagination'];
            if (!empty($dp['numbers'])) $pag_numbers = array_merge($pag_numbers, $dp['numbers']);
            if (!empty($dp['loadMore'])) $pag_load_more = array_merge($pag_load_more, $dp['loadMore']);
            if (!empty($dp['infiniteScroll'])) $pag_infinite = array_merge($pag_infinite, $dp['infiniteScroll']);
        }

        $pag_num_map = [
            'bg' => '--pxr-pag-bg', 'text' => '--pxr-pag-text', 'border' => '--pxr-pag-border',
            'activeBg' => '--pxr-pag-active-bg', 'activeText' => '--pxr-pag-active-text', 'activeBorder' => '--pxr-pag-active-border',
        ];
        foreach ($pag_num_map as $key => $var) {
            if (!empty($pag_numbers[$key])) {
                $vars[$var] = $pag_numbers[$key];
            }
        }

        $pag_lm_map = [
            'bg' => '--pxr-pag-lm-bg', 'text' => '--pxr-pag-lm-text', 'border' => '--pxr-pag-lm-border',
            'hoverBg' => '--pxr-pag-lm-hover-bg', 'hoverText' => '--pxr-pag-lm-hover-text', 'hoverBorder' => '--pxr-pag-lm-hover-border',
        ];
        foreach ($pag_lm_map as $key => $var) {
            if (!empty($pag_load_more[$key])) {
                $vars[$var] = $pag_load_more[$key];
            }
        }

        if (!empty($pag_infinite['spinnerColor'])) {
            $vars['--pxr-pag-spinner'] = $pag_infinite['spinnerColor'];
        }
        if (!empty($pag_infinite['textColor'])) {
            $vars['--pxr-pag-loading-text'] = $pag_infinite['textColor'];
        }

        // Layout columns
        $layout = $view_config['layout'] ?? [];
        $columns = $layout['columns'] ?? $layout['slidesPerView'] ?? [];
        if (!empty($columns['desktop'])) {
            $vars['--pxr-columns-desktop'] = $columns['desktop'];
        }
        if (!empty($columns['tablet'])) {
            $vars['--pxr-columns-tablet'] = $columns['tablet'];
        }
        if (!empty($columns['mobile'])) {
            $vars['--pxr-columns-mobile'] = $columns['mobile'];
        }

        // Build CSS string
        $css = '';
        foreach ($vars as $prop => $value) {
            $css .= $prop . ':' . $value . ';';
        }

        return $css;
    }

    /**
     * AJAX handler for paginated recipe loading.
     */
    public function ajax_load_recipes(): void {
        check_ajax_referer('pxr_frontend', 'nonce');

        $page = isset($_POST['page']) ? absint(wp_unslash($_POST['page'])) : 1;

        // Load config from server-side transient (no client-side config accepted)
        $config_key = isset($_POST['config_key']) ? sanitize_text_field(wp_unslash($_POST['config_key'])) : '';
        if (!$config_key || !preg_match('/^pxr_pgn_[a-f0-9]{32}$/', $config_key)) {
            wp_send_json_error(['message' => 'Invalid configuration key.']);
        }

        $config = get_transient($config_key);
        if (!is_array($config)) {
            wp_send_json_error(['message' => 'Configuration expired. Please refresh the page.']);
        }

        $per_page = (int) ($config['perPage'] ?? $config['per_page'] ?? 12);
        $config['limit'] = $per_page;
        $config['offset'] = ($page - 1) * $per_page;

        $recipes = $this->query_recipes($config);
        $view_config = $this->get_view_config($config['view'] ?? '');
        $layout_style = $view_config['layout']['style'] ?? 'grid';

        // Resolve display settings: shortcode config > view config > defaults
        $display_settings = $this->resolve_display_settings($config, $view_config);
        $view_config = array_merge($view_config, $display_settings);

        ob_start();

        $template = $this->locate_template($layout_style);
        if ($template && file_exists($template)) {
            $data = [
                'recipes'     => $recipes,
                'view_config' => $view_config,
                'config'      => $config,
                'settings'    => $this->get_settings(),
            ];
            extract($data, EXTR_SKIP);
            include $template;
        }

        $html = ob_get_clean();

        wp_send_json_success([
            'html'     => $html,
            'has_more' => count($recipes) >= $per_page,
        ]);
    }

    /**
     * Query recipes from the database based on config.
     */
    private function query_recipes(array $config): array {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        $where = ["r.status = 'published'"];
        $join = '';
        $params = [];

        // Featured filter
        if (filter_var($config['featured'] ?? false, FILTER_VALIDATE_BOOLEAN)) {
            $where[] = 'r.is_featured = 1';
        }

        // Min rating
        if (!empty($config['min_rating'])) {
            $where[] = 'r.rating_average >= %f';
            $params[] = (float) $config['min_rating'];
        }

        // Difficulty
        if (!empty($config['difficulty'])) {
            $where[] = 'r.difficulty = %s';
            $params[] = sanitize_text_field($config['difficulty']);
        }

        // Category filter
        if (!empty($config['categories'])) {
            $cat_slugs = array_map('trim', explode(',', $config['categories']));
            $cat_placeholders = implode(',', array_fill(0, count($cat_slugs), '%s'));
            $join .= " INNER JOIN {$prefix}recipe_categories rc ON r.id = rc.recipe_id";
            $join .= " INNER JOIN {$prefix}categories c ON rc.category_id = c.id AND c.slug IN ({$cat_placeholders})";
            $params = array_merge($params, $cat_slugs);
        }

        // Cuisine filter
        if (!empty($config['cuisines'])) {
            $cuisine_slugs = array_map('trim', explode(',', $config['cuisines']));
            $cuisine_placeholders = implode(',', array_fill(0, count($cuisine_slugs), '%s'));
            $join .= " INNER JOIN {$prefix}recipe_cuisines rcu ON r.id = rcu.recipe_id";
            $join .= " INNER JOIN {$prefix}cuisines cu ON rcu.cuisine_id = cu.id AND cu.slug IN ({$cuisine_placeholders})";
            $params = array_merge($params, $cuisine_slugs);
        }

        // Course filter
        if (!empty($config['courses'])) {
            $course_slugs = array_map('trim', explode(',', $config['courses']));
            $course_placeholders = implode(',', array_fill(0, count($course_slugs), '%s'));
            $join .= " INNER JOIN {$prefix}recipe_courses rco ON r.id = rco.recipe_id";
            $join .= " INNER JOIN {$prefix}courses co ON rco.course_id = co.id AND co.slug IN ({$course_placeholders})";
            $params = array_merge($params, $course_slugs);
        }

        // Language filter
        $multilingual = \PlugixaRecipe\Multilingual::instance();
        if ($multilingual->is_active()) {
            $lang = $multilingual->get_current_language();
            $where[] = "(r.language_code = %s OR r.language_code IS NULL OR r.language_code = '')";
            $params[] = $lang;
        }

        // Order
        $allowed_orderby = ['created_at', 'title', 'rating_average', 'prep_time', 'cook_time', 'total_time', 'display_order', 'view_count'];
        $orderby = in_array($config['orderby'] ?? '', $allowed_orderby) ? $config['orderby'] : 'created_at';
        $order = strtoupper($config['order'] ?? 'DESC') === 'ASC' ? 'ASC' : 'DESC';

        // Limit & offset — limit=-1 means unlimited
        $limit = (int) ($config['limit'] ?? 12);
        $offset = (int) ($config['offset'] ?? 0);

        $where_clause = implode(' AND ', $where);

        if ($limit > 0) {
            $sql = "SELECT DISTINCT r.* FROM {$prefix}recipes r {$join} WHERE {$where_clause} ORDER BY r.{$orderby} {$order} LIMIT %d OFFSET %d";
            $params[] = $limit;
            $params[] = $offset;
        } else {
            // Unlimited (-1 or 0)
            $sql = "SELECT DISTINCT r.* FROM {$prefix}recipes r {$join} WHERE {$where_clause} ORDER BY r.{$orderby} {$order}";
        }

        // Read-through object cache; key = SQL + bound params, which together capture every
        // input that changes the result (categories/cuisines/courses, featured, min_rating,
        // difficulty, language, orderby/order, limit/offset).
        $results = Cache::remember('recipes', 'shortcode|' . $sql . '|' . wp_json_encode($params), function () use ($wpdb, $sql, $params) {
            return $wpdb->get_results($wpdb->prepare($sql, $params), ARRAY_A) ?: [];
        });

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

        // Enrich recipes with related data
        return array_map([$this, 'enrich_recipe'], $results);
    }

    /**
     * Enrich a recipe with its ingredients, nutrition, categories, and image URL.
     */
    private function enrich_recipe(array $recipe): object {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $recipe_id = (int) $recipe['id'];

        // Image URL — use configured image size or fall back to medium_large
        $image_size = $this->image_size ?? 'medium_large';
        if (!empty($recipe['image_id'])) {
            $image_url = wp_get_attachment_image_url((int) $recipe['image_id'], $image_size);
            $recipe['image_url'] = $image_url ?: '';
            $recipe['image_srcset'] = wp_get_attachment_image_srcset((int) $recipe['image_id'], $image_size) ?: '';
        } else {
            $recipe['image_url'] = '';
            $recipe['image_srcset'] = '';
        }

        // Categories — child rows cached under the owning recipe.
        $recipe['categories'] = Cache::remember('recipes', 'card_categories|' . $recipe_id, function () use ($wpdb, $prefix, $recipe_id) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT c.id, c.name, c.slug FROM {$prefix}categories c
                 INNER JOIN {$prefix}recipe_categories rc ON c.id = rc.category_id
                 WHERE rc.recipe_id = %d ORDER BY rc.display_order ASC",
                $recipe_id
            ), ARRAY_A) ?: [];
        });

        // Cuisines — child rows cached under the owning recipe.
        $recipe['cuisines'] = Cache::remember('recipes', 'card_cuisines|' . $recipe_id, function () use ($wpdb, $prefix, $recipe_id) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT cu.id, cu.name, cu.slug FROM {$prefix}cuisines cu
                 INNER JOIN {$prefix}recipe_cuisines rcu ON cu.id = rcu.cuisine_id
                 WHERE rcu.recipe_id = %d",
                $recipe_id
            ), ARRAY_A) ?: [];
        });

        // Nutrition (single row) — child row cached under the owning recipe.
        $recipe['nutrition'] = Cache::remember('recipes', 'card_nutrition|' . $recipe_id, function () use ($wpdb, $prefix, $recipe_id) {
            return $wpdb->get_row($wpdb->prepare(
                "SELECT * FROM {$prefix}nutrition WHERE recipe_id = %d",
                $recipe_id
            ), ARRAY_A) ?: [];
        });

        // Diet badges
        $recipe['diet_badges'] = [];
        if (!empty($recipe['is_vegetarian'])) $recipe['diet_badges'][] = __('Vegetarian', 'plugixarecipe');
        if (!empty($recipe['is_vegan'])) $recipe['diet_badges'][] = __('Vegan', 'plugixarecipe');
        if (!empty($recipe['is_gluten_free'])) $recipe['diet_badges'][] = __('Gluten-Free', 'plugixarecipe');
        if (!empty($recipe['is_dairy_free'])) $recipe['diet_badges'][] = __('Dairy-Free', 'plugixarecipe');
        if (!empty($recipe['is_keto'])) $recipe['diet_badges'][] = __('Keto', 'plugixarecipe');
        if (!empty($recipe['is_low_carb'])) $recipe['diet_badges'][] = __('Low-Carb', 'plugixarecipe');

        // Excerpt from description (strip tags, truncate)
        $excerpt_length = (int) ($this->get_settings()['excerpt_length'] ?? 150);
        if (!empty($recipe['description'])) {
            $plain = wp_strip_all_tags($recipe['description']);
            $recipe['excerpt'] = mb_strlen($plain) > $excerpt_length
                ? mb_substr($plain, 0, $excerpt_length) . '…'
                : $plain;
        } else {
            $recipe['excerpt'] = '';
        }

        // Like count
        $recipe['like_count'] = isset($recipe['like_count']) ? (int) $recipe['like_count'] : 0;

        // Logged-in user: check like & favorite status (per-user state: not cached).
        if (is_user_logged_in()) {
            $current_user_id = get_current_user_id();
            $recipe['is_liked'] = (bool) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$prefix}likes WHERE user_id = %d AND recipe_id = %d",
                $current_user_id, $recipe_id
            ));
            $recipe['is_favorited'] = (bool) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$prefix}favorites WHERE user_id = %d AND recipe_id = %d",
                $current_user_id, $recipe_id
            ));
        } else {
            $recipe['is_liked'] = false;
            $recipe['is_favorited'] = false;
        }

        // Template-friendly aliases
        $recipe['rating'] = $recipe['rating_average'] ?? 0;
        $recipe['cuisine'] = !empty($recipe['cuisines'][0]['name']) ? $recipe['cuisines'][0]['name'] : '';

        // Video type detection for card templates
        $recipe['video_type'] = '';
        $recipe['video_id']   = '';
        if ( ! empty( $recipe['video_url'] ) ) {
            $vurl = $recipe['video_url'];
            if ( preg_match( '/youtube\.com|youtu\.be/', $vurl ) ) {
                $recipe['video_type'] = 'youtube';
                preg_match( '/(?:v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/', $vurl, $m );
                $recipe['video_id'] = $m[1] ?? '';
            } elseif ( preg_match( '/vimeo\.com/', $vurl ) ) {
                $recipe['video_type'] = 'vimeo';
                preg_match( '/vimeo\.com\/(\d+)/', $vurl, $m );
                $recipe['video_id'] = $m[1] ?? '';
            } else {
                $ext = strtolower( pathinfo( wp_parse_url( $vurl, PHP_URL_PATH ) ?: '', PATHINFO_EXTENSION ) );
                if ( in_array( $ext, array( 'mp4', 'webm', 'ogg' ), true ) ) {
                    $recipe['video_type'] = 'self-hosted';
                }
            }
        }

        // Permalink & print URL
        $slug = sanitize_title($recipe['title'] ?? '');
        $recipe['permalink'] = home_url("/recipe/{$recipe_id}/{$slug}/");
        $recipe['print_url'] = home_url("/recipe-print/{$recipe_id}/?auto=1");

        return (object) $recipe;
    }

    /**
     * Load shortcode config from the database by ID.
     */
    private function get_shortcode_config(int $id): array {
        global $wpdb;
        $table = $wpdb->prefix . 'plugixarecipe_shortcodes';

        $row = $wpdb->get_row($wpdb->prepare(
            "SELECT config FROM {$table} WHERE id = %d",
            $id
        ));

        if (!$row || empty($row->config)) {
            return [];
        }

        $config = json_decode($row->config, true);
        return is_array($config) ? $config : [];
    }

    /**
     * Load view configuration from the database.
     */
    private function get_view_config($view_identifier): array {
        global $wpdb;
        $table = $wpdb->prefix . 'plugixarecipe_views';
        $row = null;

        if (!empty($view_identifier)) {
            if (is_numeric($view_identifier)) {
                $row = $wpdb->get_row($wpdb->prepare(
                    "SELECT config FROM {$table} WHERE id = %d", (int) $view_identifier
                ));
            } else {
                $row = $wpdb->get_row($wpdb->prepare(
                    "SELECT config FROM {$table} WHERE slug = %s", sanitize_text_field($view_identifier)
                ));
            }
        }

        // Fall back to default view
        if (!$row) {
            $row = $wpdb->get_row("SELECT config FROM {$table} WHERE is_default = 1 LIMIT 1");
        }

        // Fall back to first view
        if (!$row) {
            $row = $wpdb->get_row("SELECT config FROM {$table} ORDER BY id ASC LIMIT 1");
        }

        if ($row && !empty($row->config)) {
            $config = json_decode($row->config, true);
            return is_array($config) ? $this->normalize_view_config($config) : $this->normalize_view_config([]);
        }

        return $this->normalize_view_config([]);
    }

    /**
     * Normalize view config to handle both nested (seed) and flat (admin) schemas.
     *
     * Seed data uses nested keys: layout.style, colors.cardBg, typography.titleFontSize, etc.
     * Admin form saves flat keys: layout (string), card_bg_color, title_font_size, etc.
     *
     * This method produces a unified config with:
     * - Nested keys for build_css_vars() (colors, typography, spacing, borders, shadows, image, layout)
     * - Flat keys for templates (show_image, autoplay, slides_per_view_desktop, etc.)
     *
     * @param array $config Raw config from DB.
     * @return array Normalized config with both nested and flat keys.
     */
    private function normalize_view_config(array $config): array {
        $layout_raw = $config['layout'] ?? 'grid';

        // --- 1. Resolve layout style and columns ---
        if (is_array($layout_raw)) {
            // Schema A (nested): { style: "slider", columns: { desktop: 3, ... } }
            $style = $layout_raw['style'] ?? 'grid';
            $columns_desktop = $layout_raw['columns']['desktop'] ?? $layout_raw['slidesPerView']['desktop'] ?? 3;
            $columns_tablet = $layout_raw['columns']['tablet'] ?? $layout_raw['slidesPerView']['tablet'] ?? min((int) $columns_desktop, 2);
            $columns_mobile = $layout_raw['columns']['mobile'] ?? $layout_raw['slidesPerView']['mobile'] ?? 1;
        } else {
            // Schema B (flat): layout is a string
            $style = is_string($layout_raw) ? $layout_raw : 'grid';
            $columns_desktop = (int) ($config['columns'] ?? 3);
            $columns_tablet = min($columns_desktop, 2);
            $columns_mobile = 1;
        }

        // Ensure layout is always nested
        $config['layout'] = [
            'style' => $style,
            'columns' => [
                'desktop' => (int) $columns_desktop,
                'tablet'  => (int) $columns_tablet,
                'mobile'  => (int) $columns_mobile,
            ],
        ];

        // --- 2. Build nested sections from flat admin keys (if not already nested) ---

        // Colors
        if (!isset($config['colors']) || !is_array($config['colors'])) {
            $config['colors'] = [];
        }
        $color_flat_map = [
            'card_bg_color'     => 'cardBg',
            'title_color'       => 'titleColor',
            'meta_color'        => 'metaColor',
            'card_border_color' => 'borderColor',
            'text_color'        => 'textColor',
            'star_color'        => 'starColor',
            'star_empty_color'  => 'starEmptyColor',
            'badge_bg'          => 'badgeBg',
            'badge_text'        => 'badgeText',
        ];
        foreach ($color_flat_map as $flat => $nested) {
            if (!empty($config[$flat]) && empty($config['colors'][$nested])) {
                $config['colors'][$nested] = $config[$flat];
            }
        }

        // Typography
        if (!isset($config['typography']) || !is_array($config['typography'])) {
            $config['typography'] = [];
        }
        if (!empty($config['title_font_size']) && !isset($config['typography']['titleFontSize'])) {
            $config['typography']['titleFontSize'] = (int) $config['title_font_size'];
        }
        if (!empty($config['meta_font_size']) && !isset($config['typography']['metaFontSize'])) {
            $config['typography']['metaFontSize'] = (int) $config['meta_font_size'];
        }
        if (!empty($config['title_font_family']) && empty($config['typography']['fontFamily'])) {
            $config['typography']['fontFamily'] = $config['title_font_family'];
        }

        // Spacing
        if (!isset($config['spacing']) || !is_array($config['spacing'])) {
            $config['spacing'] = [];
        }
        if (isset($config['gap']) && !isset($config['spacing']['gap'])) {
            $config['spacing']['gap'] = (int) $config['gap'];
        }

        // Borders
        if (!isset($config['borders']) || !is_array($config['borders'])) {
            $config['borders'] = [];
        }
        if (!empty($config['card_border_radius']) && !isset($config['borders']['radius'])) {
            $config['borders']['radius'] = (int) $config['card_border_radius'];
        }

        // Shadows
        if (!isset($config['shadows']) || !is_array($config['shadows'])) {
            $config['shadows'] = [];
        }
        if (!empty($config['card_shadow']) && empty($config['shadows']['value'])) {
            $config['shadows']['value'] = $config['card_shadow'];
            $config['shadows']['enabled'] = true;
        }

        // Image
        if (!isset($config['image']) || !is_array($config['image'])) {
            $config['image'] = [];
        }
        if (!empty($config['image_aspect_ratio']) && empty($config['image']['aspectRatio'])) {
            // Convert "4:3" to "4/3"
            $config['image']['aspectRatio'] = str_replace(':', '/', $config['image_aspect_ratio']);
        }

        // Video
        if (!isset($config['video']) || !is_array($config['video'])) {
            $config['video'] = [];
        }

        // --- 3. Build flat template keys from nested config (for templates that read flat) ---

        // Slider template reads flat keys from $view_config
        if ($style === 'slider') {
            $slider_nested = is_array($layout_raw) ? $layout_raw : [];

            // Admin form keys → template flat keys
            $config['autoplay']               = $config['autoplay'] ?? (bool) ($config['slider_autoplay'] ?? $slider_nested['autoplay'] ?? false);
            $config['autoplay_delay']          = $config['autoplay_delay'] ?? (int) ($config['slider_autoplay_speed'] ?? $slider_nested['autoplaySpeed'] ?? $slider_nested['autoplayDelay'] ?? 5000);
            $config['loop']                    = $config['loop'] ?? (bool) ($config['slider_loop'] ?? $slider_nested['loop'] ?? true);
            $config['show_arrows']             = $config['show_arrows'] ?? (bool) ($config['slider_show_arrows'] ?? $slider_nested['showArrows'] ?? true);
            $config['show_dots']               = $config['show_dots'] ?? (bool) ($config['slider_show_dots'] ?? $slider_nested['showDots'] ?? true);
            $config['speed']                   = $config['speed'] ?? (int) ($config['slider_speed'] ?? $slider_nested['speed'] ?? 400);
            $config['pause_on_hover']          = $config['pause_on_hover'] ?? (bool) ($config['slider_pause_on_hover'] ?? $slider_nested['pauseOnHover'] ?? true);
            $config['space_between']           = $config['space_between'] ?? (int) ($config['slider_space_between'] ?? $slider_nested['spaceBetween'] ?? $config['gap'] ?? 24);

            // Swiper 12 module settings
            $config['effect']           = $config['effect'] ?? ($config['slider_effect'] ?? $slider_nested['effect'] ?? 'slide');
            $config['pagination_style'] = $config['pagination_style'] ?? ($config['slider_pagination_type'] ?? $slider_nested['paginationType'] ?? 'bullets');
            $config['keyboard']         = (bool) ($config['keyboard'] ?? $config['slider_keyboard'] ?? $slider_nested['keyboard'] ?? true);
            $config['free_mode']        = (bool) ($config['free_mode'] ?? $config['slider_free_mode'] ?? $slider_nested['freeMode'] ?? false);
            $config['lazy']             = (bool) ($config['lazy'] ?? $config['slider_lazy'] ?? $slider_nested['lazy'] ?? true);

            $slides_to_show = (int) ($config['slider_slides_to_show'] ?? $slider_nested['slidesPerView']['desktop'] ?? $columns_desktop);
            $config['slides_per_view_desktop'] = $config['slides_per_view_desktop'] ?? $slides_to_show;
            $config['slides_per_view_tablet']  = $config['slides_per_view_tablet'] ?? (int) ($config['slider_slides_tablet'] ?? $slider_nested['slidesPerView']['tablet'] ?? min($slides_to_show, 2));
            $config['slides_per_view_mobile']  = $config['slides_per_view_mobile'] ?? (int) ($config['slider_slides_mobile'] ?? $slider_nested['slidesPerView']['mobile'] ?? 1);

            // Also set as slidesPerView in layout for CSS vars
            $config['layout']['slidesPerView'] = [
                'desktop' => $config['slides_per_view_desktop'],
                'tablet'  => $config['slides_per_view_tablet'],
                'mobile'  => $config['slides_per_view_mobile'],
            ];
        }

        // Masonry/grid/list column_gap
        if (!isset($config['column_gap'])) {
            $config['column_gap'] = $config['spacing']['gap'] ?? (int) ($config['gap'] ?? 24);
        }

        // Display toggle keys for recipe-card.php (map admin keys if not already set)
        if (isset($config['show_diet_badges']) && !isset($config['show_diet'])) {
            $config['show_diet'] = (bool) $config['show_diet_badges'];
        }
        if (isset($config['show_category']) && !isset($config['show_category_nav'])) {
            $config['show_category_nav'] = (bool) $config['show_category'];
        }
        if (isset($config['show_cuisine_nav'])) {
            $config['show_cuisine_nav'] = (bool) $config['show_cuisine_nav'];
        }
        if (isset($config['show_alpha_filter'])) {
            $config['show_alpha_filter'] = (bool) $config['show_alpha_filter'];
        }
        if (isset($config['show_ingredient_filter'])) {
            $config['show_ingredient_filter'] = (bool) $config['show_ingredient_filter'];
        }
        // Dark mode — handle nested darkMode section and legacy boolean/string
        if (isset($config['darkMode']) && is_array($config['darkMode'])) {
            $config['dark_mode'] = !empty($config['darkMode']['enabled']);
        } elseif (isset($config['dark_mode'])) {
            $config['dark_mode'] = ($this->resolve_dark_mode_state($config['dark_mode']) === 'enabled');
        }

        // Ensure darkMode section exists for CSS vars
        if (!isset($config['darkMode']) || !is_array($config['darkMode'])) {
            $config['darkMode'] = ['enabled' => !empty($config['dark_mode'])];
        }

        // Ensure darkMode.pagination defaults exist for dark color swapping
        if (!isset($config['darkMode']['pagination']) || !is_array($config['darkMode']['pagination'])) {
            $config['darkMode']['pagination'] = [
                'numbers' => [
                    'bg' => '#1e1e2e', 'text' => '#e5e7eb', 'border' => '#374151',
                    'activeBg' => '#3b82f6', 'activeText' => '#ffffff', 'activeBorder' => '#3b82f6',
                ],
                'loadMore' => [
                    'bg' => '#3b82f6', 'text' => '#ffffff', 'border' => '#3b82f6',
                    'hoverBg' => '#2563eb', 'hoverText' => '#ffffff', 'hoverBorder' => '#2563eb',
                ],
                'infiniteScroll' => ['spinnerColor' => '#3b82f6', 'textColor' => '#9ca3af'],
            ];
        }

        // Ensure categories section exists
        if (!isset($config['categories']) || !is_array($config['categories'])) {
            $config['categories'] = ['displayStyle' => 'none'];
        }

        // Ensure pagination section exists
        if (!isset($config['pagination']) || !is_array($config['pagination'])) {
            $config['pagination'] = ['enabled' => true, 'style' => 'numbers', 'perPage' => 12];
        }

        // Map nested display section to flat keys (supports both old and new schemas)
        $display = $config['display'] ?? [];
        if (!empty($display)) {
            $display_map = [
                'showImage'          => 'show_image',
                'showTitle'          => 'show_title',
                'showExcerpt'        => 'show_excerpt',
                'showMeta'           => 'show_meta',
                'showRating'         => 'show_rating',
                'showDietBadges'     => 'show_diet_badges',
                'showDifficulty'     => 'show_difficulty',
                'showTime'           => 'show_time',
                'showServings'       => 'show_servings',
                'showAuthor'         => 'show_author',
                'showCategory'       => 'show_category',
                'showCuisine'        => 'show_cuisine',
                'showFavoriteButton' => 'show_favorite_button',
                'showPrintButton'    => 'show_print_button',
            ];
            foreach ($display_map as $nested_key => $flat_key) {
                if (isset($display[$nested_key]) && !isset($config[$flat_key])) {
                    $config[$flat_key] = (bool) $display[$nested_key];
                }
            }
        }

        // Map nested filterNav section to flat keys
        $filter_nav = $config['filterNav'] ?? [];
        if (!empty($filter_nav)) {
            $filter_nav_map = [
                'showCategoryNav'   => 'show_category_nav',
                'showCuisineNav'    => 'show_cuisine_nav',
                'showAlphaFilter'   => 'show_alpha_filter',
                'showIngredientFilter' => 'show_ingredient_filter',
            ];
            foreach ($filter_nav_map as $nested_key => $flat_key) {
                if (isset($filter_nav[$nested_key]) && !isset($config[$flat_key])) {
                    $config[$flat_key] = (bool) $filter_nav[$nested_key];
                }
            }
        }

        return $config;
    }

    /**
     * Resolve show/hide and filter navigation settings.
     * Priority: shortcode config > view config > hardcoded defaults.
     */
    private function resolve_display_settings(array $shortcode_config, array $view_config): array {
        // Map nested camelCase keys to flat snake_case keys
        $nested_display_map = [
            'showImage' => 'show_image', 'showTitle' => 'show_title', 'showExcerpt' => 'show_excerpt',
            'showMeta' => 'show_meta', 'showRating' => 'show_rating', 'showDietBadges' => 'show_diet_badges',
            'showDifficulty' => 'show_difficulty', 'showTime' => 'show_time', 'showServings' => 'show_servings',
            'showAuthor' => 'show_author', 'showCategory' => 'show_category', 'showCuisine' => 'show_cuisine',
            'showFavoriteButton' => 'show_favorite_button', 'showPrintButton' => 'show_print_button',
        ];
        $nested_filter_nav_map = [
            'showCategoryNav' => 'show_category_nav', 'showCuisineNav' => 'show_cuisine_nav',
            'showAlphaFilter' => 'show_alpha_filter', 'showIngredientFilter' => 'show_ingredient_filter',
        ];

        // Flatten nested display/filterNav into flat keys for resolution
        $display_nested  = $shortcode_config['display'] ?? [];
        $filter_nested   = $shortcode_config['filterNav'] ?? [];
        foreach ($nested_display_map as $camel => $flat) {
            if (isset($display_nested[$camel]) && !isset($shortcode_config[$flat])) {
                $shortcode_config[$flat] = $display_nested[$camel];
            }
        }
        foreach ($nested_filter_nav_map as $camel => $flat) {
            if (isset($filter_nested[$camel]) && !isset($shortcode_config[$flat])) {
                $shortcode_config[$flat] = $filter_nested[$camel];
            }
        }

        // Map new paginationEnabled to flat show_pagination
        if (isset($shortcode_config['paginationEnabled']) && !isset($shortcode_config['show_pagination'])) {
            $shortcode_config['show_pagination'] = (bool) $shortcode_config['paginationEnabled'];
        }

        $defaults = [
            'show_image'             => true,
            'show_title'             => true,
            'show_excerpt'           => true,
            'show_meta'              => true,
            'show_rating'            => true,
            'show_diet_badges'       => false,
            'show_diet'              => false,
            'show_difficulty'        => true,
            'show_time'              => true,
            'show_servings'          => false,
            'show_author'            => false,
            'show_category'          => true,
            'show_cuisine'           => false,
            'show_favorite_button'   => true,
            'show_print_button'      => false,
            'show_category_nav'      => false,
            'show_cuisine_nav'       => false,
            'show_alpha_filter'      => false,
            'show_ingredient_filter' => false,
            'show_pagination'        => false,
        ];

        $resolved = [];
        foreach ($defaults as $key => $default) {
            if (isset($shortcode_config[$key])) {
                $resolved[$key] = (bool) $shortcode_config[$key];
            } elseif (isset($view_config[$key])) {
                $resolved[$key] = (bool) $view_config[$key];
            } else {
                $resolved[$key] = $default;
            }
        }

        // Alias: show_diet mirrors show_diet_badges
        if (!isset($shortcode_config['show_diet']) && !isset($view_config['show_diet'])) {
            $resolved['show_diet'] = $resolved['show_diet_badges'];
        }

        // Pagination settings — support both old and new config shapes
        $pagination_type = $shortcode_config['pagination_type'] ?? null;
        if (!$pagination_type && !empty($shortcode_config['paginationEnabled'])) {
            $pagination_type = $shortcode_config['paginationStyle'] ?? $view_config['pagination_type'] ?? 'numbers';
        }
        $resolved['pagination_type'] = $pagination_type ?? $view_config['pagination_type'] ?? 'none';

        $items_per_page = $shortcode_config['items_per_page'] ?? $shortcode_config['perPage'] ?? null;
        $resolved['items_per_page'] = (int) ($items_per_page ?? $view_config['items_per_page'] ?? 12);

        // Dark mode — support tri-state string ('disabled'|'enabled'|'auto') or legacy boolean
        $dark_mode = $shortcode_config['darkMode'] ?? $shortcode_config['dark_mode'] ?? 'disabled';
        if ($dark_mode === true || $dark_mode === 'enabled') {
            $resolved['dark_mode'] = 'enabled';
        } elseif ($dark_mode === 'auto') {
            $resolved['dark_mode'] = 'auto';
        } else {
            $resolved['dark_mode'] = 'disabled';
        }

        // Schema
        $resolved['schema'] = $shortcode_config['schema'] ?? true;

        return $resolved;
    }

    /**
     * Resolve dark mode state from various input formats.
     *
     * Handles tri-state strings ('enabled'/'disabled'/'auto'),
     * booleans, and PHP filter_var-compatible strings ('true'/'1'/'on').
     *
     * @param mixed $value The dark mode setting value.
     * @return string 'enabled', 'auto', or 'disabled'
     */
    private function resolve_dark_mode_state($value): string {
        if ($value === true || $value === 'enabled' || $value === 1 || $value === '1') {
            return 'enabled';
        }
        if ($value === 'auto') {
            return 'auto';
        }
        if (is_string($value) && in_array(strtolower($value), ['true', 'on', 'yes'], true)) {
            return 'enabled';
        }
        return 'disabled';
    }

    /**
     * Locate the template file for a given layout style.
     */
    private function locate_template(string $layout_style): ?string {
        $template_name = sanitize_file_name($layout_style) . '.php';

        // Check theme override first
        $theme_template = get_stylesheet_directory() . '/plugixarecipe/' . $template_name;
        if (file_exists($theme_template)) {
            return $theme_template;
        }

        // Plugin template
        $plugin_template = PLUGIXARECIPE_PATH . 'templates/' . $template_name;
        if (file_exists($plugin_template)) {
            return $plugin_template;
        }

        // Fallback to grid
        $fallback = PLUGIXARECIPE_PATH . 'templates/grid.php';
        return file_exists($fallback) ? $fallback : null;
    }

    /**
     * Render pagination controls (load_more or infinite_scroll).
     * "numbers" style is handled by the grid/list/masonry template directly.
     */
    private function render_pagination(array $data): void {
        $style = $data['pagination_style'] ?? 'load_more';
        $config_key = $data['config_key'] ?? '';
        $label = $data['config']['loadMoreLabel'] ?? __('Load More Recipes', 'plugixarecipe');

        if ($style === 'infinite_scroll') {
            ?>
            <div class="pxr-pagination pxr-pagination--infinite" data-config-key="<?php echo esc_attr($config_key); ?>" data-style="infinite_scroll">
                <div class="pxr-infinite-sentinel" data-page="2" data-nonce="<?php echo esc_attr(wp_create_nonce('pxr_frontend')); ?>"></div>
                <span class="pxr-loading-spinner" style="display:none;">
                    <?php esc_html_e('Loading...', 'plugixarecipe'); ?>
                </span>
            </div>
            <?php
        } else {
            // load_more (default for non-numbers)
            ?>
            <div class="pxr-pagination pxr-pagination--load-more" data-config-key="<?php echo esc_attr($config_key); ?>" data-style="load_more">
                <button type="button" class="pxr-load-more" data-page="2" data-nonce="<?php echo esc_attr(wp_create_nonce('pxr_frontend')); ?>">
                    <?php echo esc_html($label); ?>
                </button>
                <span class="pxr-loading-spinner" style="display:none;">
                    <?php esc_html_e('Loading...', 'plugixarecipe'); ?>
                </span>
            </div>
            <?php
        }
    }

    /**
     * Enqueue frontend CSS and JS conditionally.
     */
    private function enqueue_frontend_assets(string $layout_style = 'grid'): void {
        // Layout-specific extras are evaluated on EVERY call: a slider or
        // submission form can appear further down the page after another
        // shortcode already enqueued the common assets, and the early-return
        // below would otherwise skip them (wp_enqueue_* dedupes by handle,
        // so repeated calls are harmless).

        // Recipe submission form JS
        if ($layout_style === 'form') {
            $form_js = PLUGIXARECIPE_PATH . 'assets/frontend/js/recipe-form.js';
            if (file_exists($form_js)) {
                wp_enqueue_script(
                    'plugixarecipe-form',
                    PLUGIXARECIPE_URL . 'assets/frontend/js/recipe-form.js',
                    [],
                    PLUGIXARECIPE_VERSION,
                    true
                );
            }
        }

        // Swiper for slider layout
        if ($layout_style === 'slider') {
            $swiper_css = PLUGIXARECIPE_PATH . 'assets/frontend/vendor/swiper/swiper-bundle.min.css';
            $swiper_js = PLUGIXARECIPE_PATH . 'assets/frontend/vendor/swiper/swiper-bundle.min.js';

            if (file_exists($swiper_css)) {
                wp_enqueue_style(
                    'swiper',
                    PLUGIXARECIPE_URL . 'assets/frontend/vendor/swiper/swiper-bundle.min.css',
                    [],
                    '12.1.2'
                );
            }

            if (file_exists($swiper_js)) {
                wp_enqueue_script(
                    'swiper',
                    PLUGIXARECIPE_URL . 'assets/frontend/vendor/swiper/swiper-bundle.min.js',
                    [],
                    '12.1.2',
                    true
                );
            }
        }

        if ($this->assets_enqueued) {
            return;
        }

        // Frontend CSS
        $css_path = PLUGIXARECIPE_PATH . 'assets/frontend/build/css/frontend.css';
        if (file_exists($css_path)) {
            wp_enqueue_style(
                'plugixarecipe-frontend',
                PLUGIXARECIPE_URL . 'assets/frontend/build/css/frontend.css',
                [],
                PLUGIXARECIPE_VERSION
            );
        }

        // Frontend JS
        $js_path = PLUGIXARECIPE_PATH . 'assets/frontend/js/recipes.js';
        if (file_exists($js_path)) {
            wp_enqueue_script(
                'plugixarecipe-frontend',
                PLUGIXARECIPE_URL . 'assets/frontend/js/recipes.js',
                $layout_style === 'slider' ? ['swiper'] : [],
                PLUGIXARECIPE_VERSION,
                true
            );

            wp_localize_script('plugixarecipe-frontend', 'pxrConfig', [
                'ajaxUrl'    => admin_url('admin-ajax.php'),
                'restUrl'    => esc_url_raw(rest_url('plugixarecipe/v1/')),
                'nonce'      => wp_create_nonce('pxr_frontend'),
                'restNonce'  => wp_create_nonce('wp_rest'),
                'isLoggedIn' => is_user_logged_in(),
                'loginUrl'   => wp_login_url(get_permalink()),
                'i18n'       => [
                    'loadMore'  => __('Load More Recipes', 'plugixarecipe'),
                    'loading'   => __('Loading...', 'plugixarecipe'),
                    'noMore'    => __('No more recipes to load.', 'plugixarecipe'),
                    'error'     => __('An error occurred while loading recipes.', 'plugixarecipe'),
                    'tooltip'   => [
                        'like'       => __('Like this recipe', 'plugixarecipe'),
                        'likeActive' => __('Unlike this recipe', 'plugixarecipe'),
                        'save'       => __('Save to favorites', 'plugixarecipe'),
                        'saveActive' => __('Remove from saved', 'plugixarecipe'),
                        'loginLike'  => __('Log in to like this recipe', 'plugixarecipe'),
                        'loginSave'  => __('Log in to save this recipe', 'plugixarecipe'),
                        'loginLink'  => __('Log in', 'plugixarecipe'),
                    ],
                ],
            ]);
        }

        // Auto dark mode: toggle pxr-dark class based on OS preference
        wp_add_inline_script('plugixarecipe-frontend', '(function(){var e=document.querySelectorAll("[data-pxr-dark-mode=auto]");if(!e.length)return;var m=window.matchMedia("(prefers-color-scheme:dark)");function t(q){e.forEach(function(el){el.classList.toggle("pxr-dark",q.matches)})}t(m);m.addEventListener("change",t)})();');

        // Custom CSS/JS injection from settings
        $plugin_settings = $this->get_settings();
        if (!empty($plugin_settings['custom_css'])) {
            wp_add_inline_style('plugixarecipe-frontend', $plugin_settings['custom_css']);
        }
        if (!empty($plugin_settings['custom_js'])) {
            wp_add_inline_script('plugixarecipe-frontend', $plugin_settings['custom_js']);
        }

        $this->assets_enqueued = true;
    }

    /**
     * Get plugin settings.
     */
    private function get_settings(): array {
        $defaults = [
            'show_nutrition'       => true,
            'show_reviews'         => true,
            'enable_print'         => true,
            'enable_favorites'     => true,
            'enable_submissions'   => false,
            'excerpt_length'       => 150,
            'date_format'          => 'M j, Y',
            'custom_css'           => '',
            'custom_js'            => '',
            'enable_collections'   => true,
            'enable_nutrition'     => true,
            'enable_unit_conversion' => true,
            'measurement_system'   => 'metric',
            'enable_lazy_load'     => true,
            'enable_cache'         => false,
            'cache_duration'       => 24,
            'show_breadcrumbs'     => true,
        ];

        return array_merge($defaults, (array) get_option('plugixarecipe_settings', []));
    }

    /**
     * Check if the shortcode has been used on the current page.
     */
    public static function has_shortcode(): bool {
        return self::$has_shortcode;
    }
}
