<?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\Api;

// 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.

// phpcs:disable WordPress.Security.NonceVerification.Missing -- Every $_POST/$_FILES read below happens inside a callback registered with register_rest_route(); WP core dispatches these and enforces the route's permission_callback (cookie-authenticated callers are nonce-checked by the REST infrastructure itself). The submission endpoints are deliberately public (__return_true) because anyone may post a review or submit a recipe -- a nonce would add no protection to an action an anonymous visitor is allowed to perform -- so abuse is instead controlled by reCAPTCHA, a honeypot field and per-IP hourly rate limiting (see the get_transient() rate gates).

// phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.InputNotValidated -- Applies to the $_FILES upload members only. name/type/tmp_name/error/size are PHP-generated upload metadata, not free-form user text, and must be passed through byte-for-byte: sanitizing tmp_name would break the upload path. They are validated instead of sanitized -- error === UPLOAD_ERR_OK, size against max_upload_size, count against a cap, and the real MIME via wp_check_filetype_and_ext() against an allow-list -- before being handed to media_handle_upload(), which re-validates. The isset()/!empty() guards on the parent keys precede every loop.

defined( 'ABSPATH' ) || exit;

use PlugixaRecipe\Cache;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;

/**
 * Frontend (Public) REST API
 */
class FrontendApi extends RestController {

    public function __construct() {
        parent::__construct();
        $this->rest_base = 'frontend';
    }

    public function register_routes(): void {
        // Public recipes
        register_rest_route($this->namespace, '/' . $this->rest_base . '/recipes', [
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'get_recipes'],
                'permission_callback' => '__return_true',
                'args'                => $this->get_collection_params(),
            ],
        ]);

        // Paginated HTML for AJAX
        register_rest_route($this->namespace, '/' . $this->rest_base . '/recipes/page', [
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'get_recipes_page'],
                'permission_callback' => '__return_true',
                'args'                => $this->get_collection_params(),
            ],
        ]);

        // Single recipe
        register_rest_route($this->namespace, '/' . $this->rest_base . '/recipes/(?P<id>\d+)', [
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'get_recipe'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Recipe reviews
        register_rest_route($this->namespace, '/' . $this->rest_base . '/recipes/(?P<id>\d+)/reviews', [
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'get_recipe_reviews'],
                'permission_callback' => '__return_true',
                'args'                => $this->get_collection_params(),
            ],
        ]);

        // Submit review (public)
        register_rest_route($this->namespace, '/' . $this->rest_base . '/reviews', [
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'submit_review'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Toggle helpful vote on a review (public)
        register_rest_route($this->namespace, '/' . $this->rest_base . '/reviews/(?P<review_id>\d+)/helpful', [
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'toggle_review_helpful'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Collections (public)
        register_rest_route($this->namespace, '/' . $this->rest_base . '/collections', [
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'get_collections'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Print view
        register_rest_route($this->namespace, '/' . $this->rest_base . '/recipes/(?P<id>\d+)/print', [
            [
                'methods'             => 'GET',
                'callback'            => [$this, 'get_print_view'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Servings calculator
        register_rest_route($this->namespace, '/' . $this->rest_base . '/servings-calculator', [
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'calculate_servings'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Toggle favorite (requires login)
        register_rest_route($this->namespace, '/' . $this->rest_base . '/favorites/(?P<recipe_id>\d+)', [
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'toggle_favorite'],
                'permission_callback' => function () {
                    return is_user_logged_in();
                },
            ],
        ]);

        // Toggle like (requires login)
        register_rest_route($this->namespace, '/' . $this->rest_base . '/likes/(?P<recipe_id>\d+)', [
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'toggle_like'],
                'permission_callback' => function () {
                    return is_user_logged_in();
                },
            ],
        ]);

        // Submit recipe (public frontend submission)
        register_rest_route($this->namespace, '/' . $this->rest_base . '/submit-recipe', [
            [
                'methods'             => 'POST',
                'callback'            => [$this, 'submit_recipe'],
                'permission_callback' => '__return_true',
            ],
        ]);
    }

    /**
     * Get published recipes for frontend display.
     */
    public function get_recipes($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $pagination = $this->get_pagination($request);
        $sort = $this->get_sort($request);

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

        // Category filter
        $category_id = $request->get_param('category_id');
        if ($category_id) {
            $join .= " INNER JOIN {$prefix}recipe_categories rc_f ON r.id = rc_f.recipe_id AND rc_f.category_id = %d";
            $values[] = (int) $category_id;
        }

        // Cuisine filter
        $cuisine_id = $request->get_param('cuisine_id');
        if ($cuisine_id) {
            $join .= " INNER JOIN {$prefix}recipe_cuisines rcu_f ON r.id = rcu_f.recipe_id AND rcu_f.cuisine_id = %d";
            $values[] = (int) $cuisine_id;
        }

        // Search
        $search = $request->get_param('search');
        if ($search) {
            $s = '%' . $wpdb->esc_like($search) . '%';
            $where[] = '(r.title LIKE %s OR r.description LIKE %s)';
            $values[] = $s;
            $values[] = $s;
        }

        // Featured only
        if ($request->get_param('is_featured')) {
            $where[] = 'r.is_featured = 1';
        }

        // Min rating
        $min_rating = $request->get_param('min_rating');
        if ($min_rating) {
            $where[] = 'r.rating_average >= %f';
            $values[] = (float) $min_rating;
        }

        // Difficulty
        $difficulty = $request->get_param('difficulty');
        if ($difficulty) {
            $where[] = 'r.difficulty = %s';
            $values[] = $difficulty;
        }

        // Diet flags
        $diet_flags = ['is_vegetarian', 'is_vegan', 'is_gluten_free', 'is_dairy_free', 'is_keto', 'is_low_carb'];
        foreach ($diet_flags as $flag) {
            if ($request->get_param($flag)) {
                $where[] = "r.{$flag} = 1";
            }
        }

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

        $allowed_orderby = ['title', 'created_at', 'rating_average', 'total_time', 'display_order', 'published_at'];
        $orderby = in_array($sort['orderby'], $allowed_orderby) ? $sort['orderby'] : 'published_at';
        $order   = $sort['order'] === 'ASC' ? 'ASC' : 'DESC';

        // Count — read-through object cache; key = SQL + bound values (captures every filter).
        $count_sql = "SELECT COUNT(DISTINCT r.id) FROM {$prefix}recipes r {$join} WHERE {$where_clause}";
        $total = (int) Cache::remember('recipes', 'frontend_count|' . $count_sql . '|' . wp_json_encode($values), function () use ($wpdb, $count_sql, $values) {
            return !empty($values)
                ? (int) $wpdb->get_var($wpdb->prepare($count_sql, ...$values))
                : (int) $wpdb->get_var($count_sql);
        });

        // Data
        $sql = "SELECT DISTINCT r.id, r.title, r.slug, r.description, r.image_id,
                    r.servings, r.prep_time, r.cook_time, r.total_time, r.difficulty,
                    r.rating_average, r.rating_count, r.is_featured,
                    r.is_vegetarian, r.is_vegan, r.is_gluten_free, r.is_dairy_free,
                    r.is_keto, r.is_low_carb, r.published_at
                FROM {$prefix}recipes r {$join}
                WHERE {$where_clause}
                ORDER BY r.{$orderby} {$order}
                LIMIT %d OFFSET %d";
        $values[] = $pagination['limit'];
        $values[] = $pagination['offset'];

        // Read-through object cache; key = SQL + bound values (captures filters/sort/pagination).
        $recipes = Cache::remember('recipes', 'frontend_list|' . $sql . '|' . wp_json_encode($values), function () use ($wpdb, $sql, $values) {
            return $wpdb->get_results($wpdb->prepare($sql, ...$values), ARRAY_A) ?: [];
        });

        foreach ($recipes as &$recipe) {
            $rid = (int) $recipe['id'];

            if (!empty($recipe['image_id'])) {
                $recipe['image_url'] = wp_get_attachment_image_url((int) $recipe['image_id'], 'medium_large');
                $recipe['image_thumbnail'] = wp_get_attachment_image_url((int) $recipe['image_id'], 'thumbnail');
            }

            $recipe['rating_average'] = (float) $recipe['rating_average'];
            $recipe['rating_count'] = (int) $recipe['rating_count'];

            // Categories — child rows cached under the owning recipe.
            $recipe['categories'] = Cache::remember('recipes', 'recipe_categories|' . $rid, function () use ($wpdb, $prefix, $rid) {
                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",
                    $rid
                ), ARRAY_A) ?: [];
            });

            // Cuisines — child rows cached under the owning recipe.
            $recipe['cuisines'] = Cache::remember('recipes', 'recipe_cuisines|' . $rid, function () use ($wpdb, $prefix, $rid) {
                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",
                    $rid
                ), ARRAY_A) ?: [];
            });

            // Check favorite for logged-in user (per-user state: not cached).
            if (is_user_logged_in()) {
                $recipe['is_favorited'] = (bool) $wpdb->get_var($wpdb->prepare(
                    "SELECT id FROM {$prefix}favorites WHERE user_id = %d AND recipe_id = %d",
                    get_current_user_id(), $rid
                ));
            }
        }

        return $this->success([
            'items'  => $recipes,
            'total'  => $total,
            'limit'  => $pagination['limit'],
            'offset' => $pagination['offset'],
        ]);
    }

    /**
     * Get paginated HTML for AJAX pagination.
     */
    public function get_recipes_page($request): WP_REST_Response|WP_Error {
        // Delegate to get_recipes for data, then template rendering is handled client-side
        return $this->get_recipes($request);
    }

    /**
     * Get single published recipe with full data.
     */
    public function get_recipe($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $id = (int) $request->get_param('id');

        // Read-through object cache; key = recipe id.
        $recipe = Cache::remember('recipes', 'frontend_single|' . $id, function () use ($wpdb, $prefix, $id) {
            return $wpdb->get_row(
                $wpdb->prepare("SELECT * FROM {$prefix}recipes WHERE id = %d AND status = 'published'", $id),
                ARRAY_A
            );
        });

        if (!$recipe) {
            return $this->error(__('Recipe not found.', 'plugixarecipe'), 404);
        }

        // Increment view count.
        // Counter increment only: deliberately does NOT flush the cache — this runs on every
        // render, so flushing here would invalidate the whole cache on every page view. Cached
        // lists therefore carry eventually-consistent view counts.
        $wpdb->query($wpdb->prepare(
            "UPDATE {$prefix}recipes SET view_count = view_count + 1 WHERE id = %d",
            $id
        ));

        // Image
        if (!empty($recipe['image_id'])) {
            $recipe['image_url'] = wp_get_attachment_image_url((int) $recipe['image_id'], 'large');
            $recipe['image_thumbnail'] = wp_get_attachment_image_url((int) $recipe['image_id'], 'thumbnail');
        }

        // Gallery
        if (!empty($recipe['gallery'])) {
            $gallery_ids = json_decode($recipe['gallery'], true);
            $recipe['gallery_urls'] = [];
            if (is_array($gallery_ids)) {
                foreach ($gallery_ids as $gid) {
                    $recipe['gallery_urls'][] = [
                        'id'  => (int) $gid,
                        'url' => wp_get_attachment_image_url((int) $gid, 'large'),
                    ];
                }
            }
        }

        // Categories — child rows cached under the owning recipe.
        $recipe['categories'] = Cache::remember('recipes', 'recipe_categories|' . $id, function () use ($wpdb, $prefix, $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",
                $id
            ), ARRAY_A) ?: [];
        });

        // Cuisines — child rows cached under the owning recipe.
        $recipe['cuisines'] = Cache::remember('recipes', 'recipe_cuisines|' . $id, function () use ($wpdb, $prefix, $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",
                $id
            ), ARRAY_A) ?: [];
        });

        // Courses — child rows cached under the owning recipe.
        $recipe['courses'] = Cache::remember('recipes', 'recipe_courses|' . $id, function () use ($wpdb, $prefix, $id) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT co.id, co.name, co.slug FROM {$prefix}courses co
                INNER JOIN {$prefix}recipe_courses rco ON co.id = rco.course_id
                WHERE rco.recipe_id = %d",
                $id
            ), ARRAY_A) ?: [];
        });

        // Ingredients — child rows cached under the owning recipe.
        $recipe['ingredients'] = Cache::remember('recipes', 'recipe_ingredients|' . $id, function () use ($wpdb, $prefix, $id) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$prefix}ingredients WHERE recipe_id = %d ORDER BY group_order ASC, sort_order ASC",
                $id
            ), ARRAY_A) ?: [];
        });

        // Instructions — child rows cached under the owning recipe.
        $recipe['instructions'] = Cache::remember('recipes', 'recipe_instructions|' . $id, function () use ($wpdb, $prefix, $id) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$prefix}instructions WHERE recipe_id = %d ORDER BY group_order ASC, sort_order ASC",
                $id
            ), ARRAY_A) ?: [];
        });
        foreach ($recipe['instructions'] as &$inst) {
            if (!empty($inst['image_id'])) {
                $inst['image_url'] = wp_get_attachment_image_url((int) $inst['image_id'], 'medium');
            }
        }
        unset($inst);

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

        /**
         * Filter the recipe returned to the public frontend.
         *
         * The premium overlay attaches the data belonging to its own features. The free
         * build ships none of that machinery, so nothing is attached.
         *
         * @param array<string,mixed> $recipe
         * @param int                 $id
         */
        $recipe = apply_filters('plugixarecipe_prepare_recipe_frontend', $recipe, $id);

        // Favorite status (per-user state: not cached).
        if (is_user_logged_in()) {
            $recipe['is_favorited'] = (bool) $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$prefix}favorites WHERE user_id = %d AND recipe_id = %d",
                get_current_user_id(), $id
            ));
        }

        $recipe['rating_average'] = (float) $recipe['rating_average'];
        $recipe['rating_count'] = (int) $recipe['rating_count'];

        return $this->success($recipe);
    }

    /**
     * Get approved reviews for a recipe.
     */
    public function get_recipe_reviews($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $recipe_id = (int) $request->get_param('id');
        $pagination = $this->get_pagination($request);

        // Apply settings for default limit and sort order.
        $settings = get_option('plugixarecipe_settings', []);
        $default_limit = (int) ($settings['reviews_per_page'] ?? 10);
        $limit = (int) ($request->get_param('limit') ?? $default_limit);
        $offset = (int) ($request->get_param('offset') ?? 0);

        $sort = $settings['reviews_default_sort'] ?? 'newest';
        $order_map = [
            'newest'  => 'created_at DESC',
            'oldest'  => 'created_at ASC',
            'highest' => 'rating DESC, created_at DESC',
            'lowest'  => 'rating ASC, created_at DESC',
            'helpful' => 'helpful_count DESC, created_at DESC',
        ];
        $order_by = $order_map[$sort] ?? 'created_at DESC';

        // Read-through object cache; key = recipe id.
        $total = (int) Cache::remember('reviews', 'frontend_count|' . $recipe_id, function () use ($wpdb, $prefix, $recipe_id) {
            return (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$prefix}reviews WHERE recipe_id = %d AND status = 'approved'",
                $recipe_id
            ));
        });

        // Read-through object cache; key = recipe id + sort + pagination.
        $reviews_key = 'frontend_list|' . $recipe_id . '|' . $order_by . '|' . $limit . '|' . $offset;
        $reviews = Cache::remember('reviews', $reviews_key, function () use ($wpdb, $prefix, $recipe_id, $order_by, $limit, $offset) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT id, author_name, rating, review_text, images, is_verified, helpful_count, admin_reply, admin_reply_at, created_at
                FROM {$prefix}reviews
                WHERE recipe_id = %d AND status = 'approved'
                ORDER BY {$order_by}
                LIMIT %d OFFSET %d",
                $recipe_id, $limit, $offset
            ), ARRAY_A) ?: [];
        });

        // Check which reviews the current user has voted as helpful (per-user/IP state: not cached).
        $user_id = is_user_logged_in() ? get_current_user_id() : null;
        $ip_address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));

        foreach ($reviews as &$review) {
            $review['rating'] = (int) $review['rating'];
            $review['is_verified'] = (bool) $review['is_verified'];
            $review['helpful_count'] = (int) $review['helpful_count'];

            // Check if current user/IP voted helpful on this review.
            if ($user_id) {
                $review['is_helpful'] = (bool) $wpdb->get_var($wpdb->prepare(
                    "SELECT id FROM {$prefix}review_helpful_votes WHERE review_id = %d AND user_id = %d",
                    $review['id'], $user_id
                ));
            } else {
                $review['is_helpful'] = (bool) $wpdb->get_var($wpdb->prepare(
                    "SELECT id FROM {$prefix}review_helpful_votes WHERE review_id = %d AND ip_address = %s AND user_id IS NULL",
                    $review['id'], $ip_address
                ));
            }

            if (!empty($review['images'])) {
                $ids = json_decode($review['images'], true);
                $review['images'] = [];
                if (is_array($ids)) {
                    foreach ($ids as $att_id) {
                        $url = wp_get_attachment_image_url((int) $att_id, 'medium');
                        $full = wp_get_attachment_image_url((int) $att_id, 'full');
                        if ($url) {
                            $review['images'][] = [
                                'id'   => (int) $att_id,
                                'url'  => $url,
                                'full' => $full ?: $url,
                            ];
                        }
                    }
                }
            }
        }

        return $this->success([
            'items'  => $reviews,
            'total'  => $total,
            'limit'  => $pagination['limit'],
            'offset' => $pagination['offset'],
        ]);
    }

    /**
     * Submit a review (public).
     */
    public function submit_review($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        // Support both JSON and multipart (FormData) for image uploads.
        $content_type = $request->get_content_type();
        $is_multipart = $content_type && strpos( $content_type['value'] ?? '', 'multipart' ) !== false;

        if ( $is_multipart ) {
            // $_POST is slashed by WP core; unslash so this branch matches the (unslashed) JSON branch.
            // Individual fields are sanitized where they are read below (sanitize_text_field / sanitize_email /
            // wp_kses_post / (int)).
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per-field at each read below.
            $params = wp_unslash( $_POST );
        } else {
            $params = $request->get_json_params();
        }

        $recipe_id = (int) ($params['recipe_id'] ?? 0);
        if (!$recipe_id) {
            return $this->error(__('Recipe ID is required.', 'plugixarecipe'));
        }

        $exists = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$prefix}recipes WHERE id = %d AND status = 'published'",
            $recipe_id
        ));
        if (!$exists) {
            return $this->error(__('Recipe not found.', 'plugixarecipe'), 404);
        }

        // Guest review gate
        $settings = get_option('plugixarecipe_settings', []);
        if (empty($settings['allow_guest_reviews']) && !is_user_logged_in()) {
            return $this->error(__('You must be logged in to submit a review.', 'plugixarecipe'), 403);
        }

        // Rate limiting (per IP, per hour) — mirrors submit_recipe.
        $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
        $rate_key = 'pxr_review_' . md5($ip);
        $review_submissions = (int) get_transient($rate_key);
        $max_per_hour = min((int) ($settings['api_rate_limit'] ?? 100), 10);
        if ($review_submissions >= $max_per_hour) {
            return $this->error(__('Too many reviews submitted. Please try again later.', 'plugixarecipe'), 429);
        }
        set_transient($rate_key, $review_submissions + 1, HOUR_IN_SECONDS);

        $author_name = sanitize_text_field($params['author_name'] ?? '');
        if (empty($author_name)) {
            return $this->error(__('Name is required.', 'plugixarecipe'));
        }

        $author_email = sanitize_email($params['author_email'] ?? '');
        if (empty($author_email) || !is_email($author_email)) {
            return $this->error(__('A valid email address is required.', 'plugixarecipe'));
        }

        $review_text = wp_kses_post($params['review_text'] ?? '');
        if (empty(trim($review_text)) || mb_strlen(trim($review_text)) < 10) {
            return $this->error(__('Review must be at least 10 characters.', 'plugixarecipe'));
        }

        // Rating: required by default, optional if require_rating is false
        $require_rating = $settings['require_rating'] ?? true;
        $raw_rating = $params['rating'] ?? null;
        if ($require_rating && (empty($raw_rating) || (int) $raw_rating < 1)) {
            return $this->error(__('Rating is required.', 'plugixarecipe'));
        }
        $rating = $raw_rating ? max(1, min(5, (int) $raw_rating)) : 0;
        if (!empty($settings['recaptcha_reviews_enabled']) && !empty($settings['recaptcha_secret_key'])) {
            $token = $params['recaptcha_token'] ?? '';
            if (empty($token)) {
                return $this->error(__('reCAPTCHA verification failed.', 'plugixarecipe'));
            }

            $verify = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
                'body' => [
                    'secret'   => $settings['recaptcha_secret_key'],
                    'response' => $token,
                    'remoteip' => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
                ],
            ]);

            if (!is_wp_error($verify)) {
                $result = json_decode(wp_remote_retrieve_body($verify), true);
                $threshold = (float) ($settings['recaptcha_score_threshold'] ?? 0.5);
                if (empty($result['success']) || ($result['score'] ?? 0) < $threshold) {
                    return $this->error(__('reCAPTCHA verification failed.', 'plugixarecipe'));
                }
            }
        }

        $default_status = !empty($settings['review_moderation']) ? 'pending' : 'approved';

        $wpdb->insert($prefix . 'reviews', [
            'recipe_id'    => $recipe_id,
            'user_id'      => is_user_logged_in() ? get_current_user_id() : null,
            'author_name'  => $author_name,
            'author_email' => $author_email,
            'author_ip'    => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
            'rating'       => $rating,
            'review_text'  => $review_text,
            'status'       => $default_status,
            'is_verified'  => is_user_logged_in() ? 1 : 0,
            'created_at'   => current_time('mysql'),
        ]);

        $review_id = $wpdb->insert_id;

        // Handle review image uploads when enabled.
        if ( $review_id && ! empty( $settings['review_enable_images'] ) && ! empty( $_FILES['review_images'] ) && ! empty( $_FILES['review_images']['name'][0] ) ) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            require_once ABSPATH . 'wp-admin/includes/file.php';
            require_once ABSPATH . 'wp-admin/includes/media.php';

            $allowed_types = [ 'image/jpeg', 'image/png', 'image/webp' ];
            $max_size      = ( (int) ( $settings['max_upload_size'] ?? 5 ) ) * 1024 * 1024;
            $max_images    = max( 1, min( 10, (int) ( $settings['review_max_images'] ?? 3 ) ) );
            $image_ids     = [];

            $file_count = min( count( $_FILES['review_images']['name'] ), $max_images );
            for ( $i = 0; $i < $file_count; $i++ ) {
                if ( $_FILES['review_images']['error'][ $i ] !== UPLOAD_ERR_OK ) continue;
                if ( $_FILES['review_images']['size'][ $i ] > $max_size ) continue;

                $file_mime = wp_check_filetype_and_ext(
                    $_FILES['review_images']['tmp_name'][ $i ],
                    $_FILES['review_images']['name'][ $i ]
                );
                if ( empty( $file_mime['type'] ) || ! in_array( $file_mime['type'], $allowed_types, true ) ) continue;

                $_FILES['pxr_review_img_temp'] = [
                    'name'     => $_FILES['review_images']['name'][ $i ],
                    'type'     => $_FILES['review_images']['type'][ $i ],
                    'tmp_name' => $_FILES['review_images']['tmp_name'][ $i ],
                    'error'    => $_FILES['review_images']['error'][ $i ],
                    'size'     => $_FILES['review_images']['size'][ $i ],
                ];
                $att_id = media_handle_upload( 'pxr_review_img_temp', 0 );
                if ( ! is_wp_error( $att_id ) ) {
                    $image_ids[] = $att_id;
                }
            }
            unset( $_FILES['pxr_review_img_temp'] );

            if ( ! empty( $image_ids ) ) {
                $wpdb->update( $prefix . 'reviews', [ 'images' => wp_json_encode( $image_ids ) ], [ 'id' => $review_id ] );
            }
        }

        // Recalculate recipe rating if auto-approved
        if ($default_status === 'approved') {
            $this->recalculate_recipe_rating($recipe_id);
        }

        // Send notification email
        if (!empty($settings['notification_enabled']) && !empty($settings['notification_recipients'])) {
            $recipe_title = $wpdb->get_var($wpdb->prepare("SELECT title FROM {$prefix}recipes WHERE id = %d", $recipe_id));
            $subject = str_replace('{recipe_title}', $recipe_title, $settings['notification_subject'] ?? 'New review submitted');
            $message = str_replace(
                ['{recipe_title}', '{author_name}', '{rating}'],
                [$recipe_title, $author_name, $rating],
                $settings['notification_message'] ?? ''
            );
            wp_mail($settings['notification_recipients'], $subject, $message);
        }

        // Content write: a review also changes the recipe's rating aggregate shown in recipe lists.
        Cache::flush('reviews', 'recipes');

        return $this->success([
            'id'     => $review_id,
            'status' => $default_status,
        ], $default_status === 'approved'
            ? __('Thank you for your review!', 'plugixarecipe')
            : __('Thank you! Your review will be published after moderation.', 'plugixarecipe')
        );
    }

    /**
     * Toggle helpful vote on a review (public).
     */
    public function toggle_review_helpful($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix    = $wpdb->prefix . 'plugixarecipe_';
        $review_id = (int) $request->get_param('review_id');

        $review = $wpdb->get_row($wpdb->prepare(
            "SELECT id, helpful_count FROM {$prefix}reviews WHERE id = %d AND status = 'approved'",
            $review_id
        ));
        if (!$review) {
            return $this->error(__('Review not found.', 'plugixarecipe'), 404);
        }

        $settings = get_option('plugixarecipe_settings', []);
        if (empty($settings['review_enable_helpful_votes'])) {
            return $this->error(__('This feature is disabled.', 'plugixarecipe'), 403);
        }

        $user_id    = is_user_logged_in() ? get_current_user_id() : null;
        $ip_address = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));

        // Rate limiting (per IP, per hour) — votes are lightweight so the cap is generous.
        $rate_key   = 'pxr_helpful_' . md5($ip_address);
        $vote_count = (int) get_transient($rate_key);
        $max_per_hour = min((int) ($settings['api_rate_limit'] ?? 100), 60);
        if ($vote_count >= $max_per_hour) {
            return $this->error(__('Too many requests. Please try again later.', 'plugixarecipe'), 429);
        }
        set_transient($rate_key, $vote_count + 1, HOUR_IN_SECONDS);

        // Check for existing vote (by user_id if logged in, by IP if guest).
        if ($user_id) {
            $existing = $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$prefix}review_helpful_votes WHERE review_id = %d AND user_id = %d",
                $review_id, $user_id
            ));
        } else {
            $existing = $wpdb->get_var($wpdb->prepare(
                "SELECT id FROM {$prefix}review_helpful_votes WHERE review_id = %d AND ip_address = %s AND user_id IS NULL",
                $review_id, $ip_address
            ));
        }

        if ($existing) {
            // Remove vote.
            $wpdb->delete($prefix . 'review_helpful_votes', ['id' => $existing]);
            $new_count = max(0, (int) $review->helpful_count - 1);
            // Counter increment only: deliberately does NOT flush the cache — helpful votes fire
            // straight off a rendered review list, so flushing here would invalidate the whole
            // cache on every vote. Cached lists therefore carry eventually-consistent helpful counts.
            $wpdb->update($prefix . 'reviews', ['helpful_count' => $new_count], ['id' => $review_id]);
            return $this->success(['is_helpful' => false, 'helpful_count' => $new_count]);
        } else {
            // Add vote.
            $wpdb->insert($prefix . 'review_helpful_votes', [
                'review_id'  => $review_id,
                'user_id'    => $user_id,
                'ip_address' => $ip_address,
                'created_at' => current_time('mysql'),
            ]);
            $new_count = (int) $review->helpful_count + 1;
            // Counter increment only: deliberately does NOT flush the cache — helpful votes fire
            // straight off a rendered review list, so flushing here would invalidate the whole
            // cache on every vote. Cached lists therefore carry eventually-consistent helpful counts.
            $wpdb->update($prefix . 'reviews', ['helpful_count' => $new_count], ['id' => $review_id]);
            return $this->success(['is_helpful' => true, 'helpful_count' => $new_count]);
        }
    }

    /**
     * Get public collections.
     */
    public function get_collections($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        // Read-through object cache; the query takes no inputs.
        $collections = Cache::remember('collections', 'frontend_public', function () use ($wpdb, $prefix) {
            return $wpdb->get_results(
                "SELECT col.*, COUNT(cr.recipe_id) AS recipe_count
                FROM {$prefix}collections col
                LEFT JOIN {$prefix}collection_recipes cr ON col.id = cr.collection_id
                WHERE col.status = 'published' AND col.visibility = 'public'
                GROUP BY col.id
                ORDER BY col.display_order ASC",
                ARRAY_A
            ) ?: [];
        });

        foreach ($collections as &$col) {
            $col['recipe_count'] = (int) $col['recipe_count'];
            if (!empty($col['image_id'])) {
                $col['image_url'] = wp_get_attachment_image_url((int) $col['image_id'], 'medium');
            }
        }

        return $this->success($collections);
    }

    /**
     * Get print-friendly recipe data.
     */
    public function get_print_view($request): WP_REST_Response|WP_Error {
        // Reuse get_recipe but strip unnecessary data
        return $this->get_recipe($request);
    }

    /**
     * Calculate adjusted ingredient quantities for different servings.
     */
    public function calculate_servings($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $params = $request->get_json_params();

        $recipe_id    = (int) ($params['recipe_id'] ?? 0);
        $new_servings = (int) ($params['servings'] ?? 0);

        if (!$recipe_id || !$new_servings || $new_servings < 1) {
            return $this->error(__('Recipe ID and valid servings count are required.', 'plugixarecipe'));
        }

        // Read-through object cache; key = recipe id.
        $recipe = Cache::remember('recipes', 'recipe_servings|' . $recipe_id, function () use ($wpdb, $prefix, $recipe_id) {
            return $wpdb->get_row($wpdb->prepare(
                "SELECT servings FROM {$prefix}recipes WHERE id = %d",
                $recipe_id
            ), ARRAY_A);
        });

        if (!$recipe) {
            return $this->error(__('Recipe not found.', 'plugixarecipe'), 404);
        }

        $original_servings = (int) $recipe['servings'];
        if ($original_servings < 1) {
            $original_servings = 1;
        }

        $multiplier = $new_servings / $original_servings;

        // Read-through object cache; key = recipe id (child rows cached under the owning recipe).
        $ingredients = Cache::remember('recipes', 'recipe_ingredients|' . $recipe_id, function () use ($wpdb, $prefix, $recipe_id) {
            return $wpdb->get_results($wpdb->prepare(
                "SELECT * FROM {$prefix}ingredients WHERE recipe_id = %d ORDER BY group_order ASC, sort_order ASC",
                $recipe_id
            ), ARRAY_A) ?: [];
        });

        foreach ($ingredients as &$ing) {
            if (is_numeric($ing['quantity']) && $ing['quantity'] > 0) {
                $ing['original_quantity'] = $ing['quantity'];
                $ing['quantity'] = round((float) $ing['quantity'] * $multiplier, 2);
                // Clean up: remove trailing zeros
                $ing['quantity'] = rtrim(rtrim((string) $ing['quantity'], '0'), '.');
            }
        }

        return $this->success([
            'original_servings' => $original_servings,
            'new_servings'      => $new_servings,
            'multiplier'        => round($multiplier, 2),
            'ingredients'       => $ingredients,
        ]);
    }

    /**
     * Toggle favorite (requires login, public route).
     */
    public function toggle_favorite($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $user_id = get_current_user_id();
        $recipe_id = (int) $request->get_param('recipe_id');

        $exists = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$prefix}recipes WHERE id = %d AND status = 'published'",
            $recipe_id
        ));
        if (!$exists) {
            return $this->error(__('Recipe not found.', 'plugixarecipe'), 404);
        }

        $existing = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$prefix}favorites WHERE user_id = %d AND recipe_id = %d",
            $user_id, $recipe_id
        ));

        if ($existing) {
            $wpdb->delete($prefix . 'favorites', ['user_id' => $user_id, 'recipe_id' => $recipe_id]);

            // Content write: favourite status is surfaced on cached recipe reads too.
            Cache::flush('favorites', 'recipes');

            return $this->success(['is_favorited' => false], __('Removed from favorites.', 'plugixarecipe'));
        } else {
            $wpdb->insert($prefix . 'favorites', [
                'user_id' => $user_id, 'recipe_id' => $recipe_id, 'created_at' => current_time('mysql'),
            ]);

            // Content write: favourite status is surfaced on cached recipe reads too.
            Cache::flush('favorites', 'recipes');

            return $this->success(['is_favorited' => true], __('Added to favorites.', 'plugixarecipe'));
        }
    }

    /**
     * Toggle like (requires login, public route).
     */
    public function toggle_like($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';
        $user_id = get_current_user_id();
        $recipe_id = (int) $request->get_param('recipe_id');

        $exists = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$prefix}recipes WHERE id = %d AND status = 'published'",
            $recipe_id
        ));
        if (!$exists) {
            return $this->error(__('Recipe not found.', 'plugixarecipe'), 404);
        }

        $existing = $wpdb->get_var($wpdb->prepare(
            "SELECT id FROM {$prefix}likes WHERE user_id = %d AND recipe_id = %d",
            $user_id, $recipe_id
        ));

        if ($existing) {
            $wpdb->delete($prefix . 'likes', ['user_id' => $user_id, 'recipe_id' => $recipe_id]);
            // Counter increment only: deliberately does NOT flush the cache — likes are fired
            // straight off a rendered recipe card, so flushing here would invalidate the whole
            // cache on a plain page view. Cached lists therefore carry eventually-consistent
            // like counts (the live count is returned in this response for the UI to apply).
            $wpdb->query($wpdb->prepare(
                "UPDATE {$prefix}recipes SET like_count = GREATEST(like_count - 1, 0) WHERE id = %d",
                $recipe_id
            ));
        } else {
            $wpdb->insert($prefix . 'likes', [
                'user_id' => $user_id, 'recipe_id' => $recipe_id, 'created_at' => current_time('mysql'),
            ]);
            // Counter increment only: deliberately does NOT flush the cache — likes are fired
            // straight off a rendered recipe card, so flushing here would invalidate the whole
            // cache on a plain page view. Cached lists therefore carry eventually-consistent
            // like counts (the live count is returned in this response for the UI to apply).
            $wpdb->query($wpdb->prepare(
                "UPDATE {$prefix}recipes SET like_count = like_count + 1 WHERE id = %d",
                $recipe_id
            ));
        }

        $like_count = (int) $wpdb->get_var($wpdb->prepare(
            "SELECT like_count FROM {$prefix}recipes WHERE id = %d",
            $recipe_id
        ));

        $is_liked = !$existing;

        return $this->success([
            'is_liked'   => $is_liked,
            'like_count' => $like_count,
        ], $is_liked
            ? __('Liked!', 'plugixarecipe')
            : __('Like removed.', 'plugixarecipe')
        );
    }

    /**
     * Submit a recipe from the frontend form.
     */
    public function submit_recipe($request): WP_REST_Response|WP_Error {
        global $wpdb;
        $prefix   = $wpdb->prefix . 'plugixarecipe_';
        $settings = get_option('plugixarecipe_settings', []);

        if (empty($settings['enable_submissions'])) {
            return $this->error(__('Recipe submissions are not enabled.', 'plugixarecipe'));
        }

        // Configurable login gate.
        if (!empty($settings['submission_require_login']) && !is_user_logged_in()) {
            return $this->error(__('You must be logged in to submit a recipe.', 'plugixarecipe'), 401);
        }

        // Detect content type — form sends FormData (multipart) for image upload.
        $content_type = $request->get_content_type();
        $is_multipart = $content_type && strpos($content_type['value'] ?? '', 'multipart') !== false;

        $get_param = function (string $key, $default = '') use ($is_multipart, $request) {
            if ($is_multipart) {
                // $_POST is slashed by WP core; unslash ONCE here so the multipart branch yields the
                // same raw value the JSON branch does. The value is returned raw-but-unslashed on
                // purpose: each caller below applies the sanitizer that fits that field's type
                // (sanitize_text_field / sanitize_textarea_field / wp_kses_post / esc_url_raw / (int) /
                // json_decode). A blanket sanitizer here would corrupt the JSON payloads
                // (ingredients / instructions / nutrition) and the array-valued fields.
                // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized per-field by every caller; see above.
                return isset($_POST[$key]) ? wp_unslash($_POST[$key]) : $default;
            }
            return $request->get_param($key) ?? $default;
        };

        // reCAPTCHA validation.
        if (!empty($settings['recaptcha_form_enabled']) && !empty($settings['recaptcha_secret_key'])) {
            $token = $get_param('recaptcha_token');
            if (empty($token)) {
                return $this->error(__('reCAPTCHA verification failed.', 'plugixarecipe'));
            }

            $verify = wp_remote_post('https://www.google.com/recaptcha/api/siteverify', [
                'body' => [
                    'secret'   => $settings['recaptcha_secret_key'],
                    'response' => $token,
                    'remoteip' => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
                ],
            ]);

            if (!is_wp_error($verify)) {
                $result = json_decode(wp_remote_retrieve_body($verify), true);
                $threshold = (float) ($settings['recaptcha_score_threshold'] ?? 0.5);
                if (empty($result['success']) || ($result['score'] ?? 0) < $threshold) {
                    return $this->error(__('reCAPTCHA verification failed.', 'plugixarecipe'));
                }
            }
        }

        // Honeypot bot detection.
        $honeypot = $get_param('website_url', '');
        if (!empty($honeypot)) {
            return $this->success(['id' => 0, 'status' => 'pending'], __('Thank you! Your recipe has been submitted for review.', 'plugixarecipe'));
        }

        // Rate limiting (per IP, per hour).
        $ip = sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1'));
        $rate_key = 'pxr_submit_' . md5($ip);
        $submissions = (int) get_transient($rate_key);
        $max_per_hour = min((int) ($settings['api_rate_limit'] ?? 100), 10);
        if ($submissions >= $max_per_hour) {
            return $this->error(__('Too many submissions. Please try again later.', 'plugixarecipe'), 429);
        }
        set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);

        // Validate required fields.
        $title = sanitize_text_field($get_param('title'));
        if (empty($title)) {
            return $this->error(__('Recipe title is required.', 'plugixarecipe'));
        }
        if (mb_strlen($title) > 200) {
            return $this->error(__('Recipe title must be 200 characters or fewer.', 'plugixarecipe'));
        }

        $description = wp_kses_post($get_param('description'));
        if (empty($description)) {
            return $this->error(__('Recipe description is required.', 'plugixarecipe'));
        }
        if (mb_strlen(wp_strip_all_tags($description)) > 5000) {
            return $this->error(__('Recipe description is too long.', 'plugixarecipe'));
        }

        // Validate recipe image is provided.
        if (empty($_FILES['recipe_image']) || empty($_FILES['recipe_image']['size']) || $_FILES['recipe_image']['error'] !== UPLOAD_ERR_OK) {
            return $this->error(__('Recipe image is required.', 'plugixarecipe'));
        }

        // Validate at least one category.
        $categories_input = $is_multipart
            ? array_map('intval', (array) wp_unslash($_POST['categories'] ?? []))
            : ($request->get_param('categories') ?? []);
        if (empty($categories_input) || !is_array($categories_input) || count(array_filter(array_map('intval', $categories_input))) < 1) {
            return $this->error(__('Please select at least one category.', 'plugixarecipe'));
        }

        // Pre-validate ingredients: at least one with a name.
        $ingredients_json_raw = $get_param('ingredients', '');
        $ingredients_pre = is_string($ingredients_json_raw) ? json_decode($ingredients_json_raw, true) : $ingredients_json_raw;
        $has_valid_ingredient = false;
        if (!empty($ingredients_pre) && is_array($ingredients_pre)) {
            foreach ($ingredients_pre as $ing) {
                $name = is_string($ing) ? $ing : ($ing['name'] ?? '');
                if (trim($name) !== '') { $has_valid_ingredient = true; break; }
            }
        }
        if (!$has_valid_ingredient) {
            return $this->error(__('At least one ingredient with a name is required.', 'plugixarecipe'));
        }

        // Pre-validate instructions: at least one with text.
        $instructions_json_raw = $get_param('instructions', '');
        $instructions_pre = is_string($instructions_json_raw) ? json_decode($instructions_json_raw, true) : $instructions_json_raw;
        $has_valid_instruction = false;
        if (!empty($instructions_pre) && is_array($instructions_pre)) {
            foreach ($instructions_pre as $inst) {
                $text = is_string($inst) ? $inst : ($inst['instruction_text'] ?? '');
                if (trim($text) !== '') { $has_valid_instruction = true; break; }
            }
        }
        if (!$has_valid_instruction) {
            return $this->error(__('At least one instruction step is required.', 'plugixarecipe'));
        }

        // Generate unique slug.
        $slug = sanitize_title($title);
        $original_slug = $slug;
        $counter = 1;
        while ($wpdb->get_var($wpdb->prepare("SELECT id FROM {$prefix}recipes WHERE slug = %s", $slug))) {
            $slug = $original_slug . '-' . (++$counter);
        }

        $status = sanitize_text_field($settings['submission_status'] ?? 'pending');

        // Validate enums.
        $difficulty = sanitize_text_field($get_param('difficulty', 'medium'));
        if (!in_array($difficulty, ['easy', 'medium', 'hard'], true)) {
            $difficulty = 'medium';
        }
        $cost = sanitize_text_field($get_param('cost'));
        if (!in_array($cost, ['budget', 'medium', 'expensive', ''], true)) {
            $cost = '';
        }

        // Validate season enum.
        $season = sanitize_text_field($get_param('season', 'all'));
        if (!in_array($season, ['spring', 'summer', 'autumn', 'winter', 'all'], true)) {
            $season = 'all';
        }

        // Validate required numeric fields.
        $servings_raw = $get_param('servings', '');
        if ($servings_raw === '' || (int) $servings_raw < 1) {
            return $this->error(__('Servings is required and must be at least 1.', 'plugixarecipe'));
        }
        $servings = min(999, max(1, (int) $servings_raw));

        $prep_time_raw = $get_param('prep_time', '');
        if ($prep_time_raw === '') {
            return $this->error(__('Prep time is required.', 'plugixarecipe'));
        }
        $prep_time = min(9999, max(0, (int) $prep_time_raw));

        $cook_time_raw = $get_param('cook_time', '');
        if ($cook_time_raw === '') {
            return $this->error(__('Cook time is required.', 'plugixarecipe'));
        }
        $cook_time = min(9999, max(0, (int) $cook_time_raw));

        $rest_time = $get_param('rest_time') !== '' ? min(9999, max(0, (int) $get_param('rest_time'))) : null;

        // Validate text field max-lengths.
        $excerpt = sanitize_textarea_field($get_param('excerpt'));
        if (mb_strlen($excerpt) > 500) {
            return $this->error(__('Excerpt must be 500 characters or fewer.', 'plugixarecipe'));
        }
        $notes = wp_kses_post($get_param('notes'));
        if (mb_strlen(wp_strip_all_tags($notes)) > 5000) {
            return $this->error(__('Notes field is too long.', 'plugixarecipe'));
        }
        $tips = wp_kses_post($get_param('tips'));
        if (mb_strlen(wp_strip_all_tags($tips)) > 5000) {
            return $this->error(__('Tips field is too long.', 'plugixarecipe'));
        }

        // Parse tags & equipment from comma-separated to JSON.
        $tags_raw  = sanitize_text_field($get_param('tags', ''));
        $tags_json = wp_json_encode(array_values(array_filter(array_map('trim', explode(',', $tags_raw)))));

        $equip_raw  = sanitize_text_field($get_param('equipment', ''));
        $equip_json = wp_json_encode(array_values(array_filter(array_map('trim', explode(',', $equip_raw)))));

        // Insert recipe.
        $recipe_data = [
            'title'          => $title,
            'slug'           => $slug,
            'description'    => $description,
            'excerpt'        => $excerpt,
            'notes'          => $notes,
            'tips'           => $tips,
            'servings'       => $servings,
            'servings_unit'  => sanitize_text_field($get_param('servings_unit')),
            'prep_time'      => $prep_time,
            'cook_time'      => $cook_time,
            'rest_time'      => $rest_time,
            'difficulty'     => $difficulty,
            'cost'           => $cost ?: null,
            'season'         => $season,
            'video_url'      => esc_url_raw($get_param('video_url')),
            'author_name'    => sanitize_text_field($get_param('author_name')),
            'source'         => sanitize_text_field($get_param('source_name')) ?: 'form',
            'source_url'     => esc_url_raw($get_param('source_url')),
            'tags'           => $tags_json,
            'equipment'      => $equip_json,
            'is_vegetarian'  => (int) (bool) $get_param('is_vegetarian', false),
            'is_vegan'       => (int) (bool) $get_param('is_vegan', false),
            'is_gluten_free' => (int) (bool) $get_param('is_gluten_free', false),
            'is_dairy_free'  => (int) (bool) $get_param('is_dairy_free', false),
            'is_keto'        => (int) (bool) $get_param('is_keto', false),
            'is_low_carb'    => (int) (bool) $get_param('is_low_carb', false),
            'is_nut_free'    => (int) (bool) $get_param('is_nut_free', false),
            'is_paleo'       => (int) (bool) $get_param('is_paleo', false),
            'is_sugar_free'  => (int) (bool) $get_param('is_sugar_free', false),
            'is_halal'       => (int) (bool) $get_param('is_halal', false),
            'is_kosher'      => (int) (bool) $get_param('is_kosher', false),
            'status'         => $status,
            'created_at'     => current_time('mysql'),
            'created_by'     => is_user_logged_in() ? get_current_user_id() : 0,
            'submission_ip'  => sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'] ?? '')),
        ];

        $wpdb->insert($prefix . 'recipes', $recipe_data);
        $recipe_id = $wpdb->insert_id;

        if (!$recipe_id) {
            return $this->error(__('Failed to submit recipe.', 'plugixarecipe'), 500);
        }

        // Image upload (with server-side MIME verification).
        if (!empty($_FILES['recipe_image']) && !empty($_FILES['recipe_image']['size'])) {
            $file          = $_FILES['recipe_image'];
            $allowed_types = ['image/jpeg', 'image/png', 'image/webp'];
            $max_size      = ((int) ($settings['max_upload_size'] ?? 5)) * 1024 * 1024;
            $real_mime     = wp_check_filetype_and_ext($file['tmp_name'], $file['name']);

            if (empty($real_mime['type']) || !in_array($real_mime['type'], $allowed_types, true)) {
                // Non-blocking: skip file with invalid or mismatched MIME type.
            } elseif ($file['size'] > $max_size) {
                // Non-blocking: skip oversized file.
            } else {
                require_once ABSPATH . 'wp-admin/includes/image.php';
                require_once ABSPATH . 'wp-admin/includes/file.php';
                require_once ABSPATH . 'wp-admin/includes/media.php';

                $attachment_id = media_handle_upload('recipe_image', 0);
                if (!is_wp_error($attachment_id)) {
                    $wpdb->update($prefix . 'recipes', ['image_id' => $attachment_id], ['id' => $recipe_id]);
                }
            }
        }

        // Gallery images upload (multiple files, max 10).
        if (!empty($_FILES['gallery_images']) && !empty($_FILES['gallery_images']['name'][0])) {
            require_once ABSPATH . 'wp-admin/includes/image.php';
            require_once ABSPATH . 'wp-admin/includes/file.php';
            require_once ABSPATH . 'wp-admin/includes/media.php';

            $allowed_types = ['image/jpeg', 'image/png', 'image/webp'];
            $max_size      = ((int) ($settings['max_upload_size'] ?? 5)) * 1024 * 1024;
            $gallery_ids   = [];
            $max_gallery   = 10;

            $file_count = min(count($_FILES['gallery_images']['name']), $max_gallery);
            for ($g = 0; $g < $file_count; $g++) {
                if ($_FILES['gallery_images']['error'][$g] !== UPLOAD_ERR_OK) continue;
                if ($_FILES['gallery_images']['size'][$g] > $max_size) continue;

                // Server-side MIME verification.
                $gallery_mime = wp_check_filetype_and_ext(
                    $_FILES['gallery_images']['tmp_name'][$g],
                    $_FILES['gallery_images']['name'][$g]
                );
                if (empty($gallery_mime['type']) || !in_array($gallery_mime['type'], $allowed_types, true)) continue;

                // Restructure into single-file format for media_handle_upload.
                $_FILES['pxr_gallery_temp'] = [
                    'name'     => $_FILES['gallery_images']['name'][$g],
                    'type'     => $_FILES['gallery_images']['type'][$g],
                    'tmp_name' => $_FILES['gallery_images']['tmp_name'][$g],
                    'error'    => $_FILES['gallery_images']['error'][$g],
                    'size'     => $_FILES['gallery_images']['size'][$g],
                ];
                $att_id = media_handle_upload('pxr_gallery_temp', 0);
                if (!is_wp_error($att_id)) {
                    $gallery_ids[] = $att_id;
                }
            }
            unset($_FILES['pxr_gallery_temp']);

            if (!empty($gallery_ids)) {
                $wpdb->update($prefix . 'recipes', ['gallery' => wp_json_encode($gallery_ids)], ['id' => $recipe_id]);
            }
        }

        // Save categories (array of IDs → junction table, with FK validation).
        $categories = $is_multipart
            ? array_map('intval', (array) wp_unslash($_POST['categories'] ?? []))
            : ($request->get_param('categories') ?? []);
        if (!empty($categories) && is_array($categories)) {
            $cat_ids = array_filter(array_map('intval', $categories));
            if (!empty($cat_ids)) {
                $placeholders = implode(',', array_fill(0, count($cat_ids), '%d'));
                $valid_cats = $wpdb->get_col($wpdb->prepare(
                    "SELECT id FROM {$prefix}categories WHERE id IN ($placeholders)",
                    ...$cat_ids
                ));
                foreach ($valid_cats as $i => $cat_id) {
                    $wpdb->insert($prefix . 'recipe_categories', [
                        'recipe_id'     => $recipe_id,
                        'category_id'   => (int) $cat_id,
                        'display_order' => $i,
                    ]);
                }
            }
        }

        // Save cuisine (single ID → junction table, with FK validation).
        $cuisine_id = (int) $get_param('cuisine_id', 0);
        if ($cuisine_id > 0) {
            $exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$prefix}cuisines WHERE id = %d", $cuisine_id));
            if ($exists) {
                $wpdb->insert($prefix . 'recipe_cuisines', [
                    'recipe_id'  => $recipe_id,
                    'cuisine_id' => $cuisine_id,
                ]);
            }
        }

        // Save course (single ID → junction table, with FK validation).
        $course_id = (int) $get_param('course_id', 0);
        if ($course_id > 0) {
            $exists = $wpdb->get_var($wpdb->prepare("SELECT id FROM {$prefix}courses WHERE id = %d", $course_id));
            if ($exists) {
                $wpdb->insert($prefix . 'recipe_courses', [
                    'recipe_id' => $recipe_id,
                    'course_id' => $course_id,
                ]);
            }
        }

        // Insert ingredients (structured: quantity + unit + name + notes, max 100).
        $ingredients_json = $get_param('ingredients', '');
        $ingredients      = is_string($ingredients_json) ? json_decode($ingredients_json, true) : $ingredients_json;
        if (!empty($ingredients) && is_array($ingredients)) {
            $ingredients = array_slice($ingredients, 0, 100);
            foreach ($ingredients as $i => $ing) {
                $name = is_string($ing) ? $ing : sanitize_text_field($ing['name'] ?? '');
                if (empty($name)) continue;
                $wpdb->insert($prefix . 'ingredients', [
                    'recipe_id'   => $recipe_id,
                    'name'        => $name,
                    'quantity'    => sanitize_text_field($ing['quantity'] ?? ''),
                    'unit'        => sanitize_text_field($ing['unit'] ?? ''),
                    'notes'       => sanitize_text_field($ing['notes'] ?? ''),
                    'sort_order'  => $i,
                    'group_order' => 0,
                ]);
            }
        }

        // Insert instructions (with optional title, max 50).
        $instructions_json = $get_param('instructions', '');
        $instructions      = is_string($instructions_json) ? json_decode($instructions_json, true) : $instructions_json;
        if (!empty($instructions) && is_array($instructions)) {
            $instructions = array_slice($instructions, 0, 50);
            foreach ($instructions as $i => $inst) {
                $text = is_string($inst) ? $inst : ($inst['instruction_text'] ?? '');
                if (empty($text)) continue;
                $wpdb->insert($prefix . 'instructions', [
                    'recipe_id'        => $recipe_id,
                    'title'            => sanitize_text_field($inst['title'] ?? ''),
                    'instruction_text' => wp_kses_post($text),
                    'sort_order'       => $i,
                    'group_order'      => 0,
                ]);
            }
        }

        // Insert nutrition data (if any fields provided, with range clamping).
        $nutrition_json = $get_param('nutrition', '');
        $nutrition      = is_string($nutrition_json) ? json_decode($nutrition_json, true) : $nutrition_json;
        if (!empty($nutrition) && is_array($nutrition)) {
            $nf_names       = ['serving_size', 'calories', 'total_fat', 'saturated_fat', 'trans_fat',
                'cholesterol', 'sodium', 'total_carbohydrate', 'dietary_fiber', 'total_sugars',
                'protein', 'vitamin_a', 'vitamin_c', 'calcium', 'iron', 'potassium'];
            $nutrition_data = ['recipe_id' => $recipe_id];
            $has_values     = false;
            foreach ($nf_names as $nf) {
                $val = $nutrition[$nf] ?? null;
                if ($nf === 'serving_size') {
                    $nutrition_data[$nf] = sanitize_text_field($val ?? '');
                    if (!empty($val)) $has_values = true;
                } else {
                    $numeric_val = ($val !== '' && $val !== null) ? (float) $val : null;
                    if ($numeric_val !== null) {
                        $numeric_val = max(0, min(99999.99, $numeric_val));
                        $has_values = true;
                    }
                    $nutrition_data[$nf] = $numeric_val;
                }
            }
            if ($has_values) {
                $wpdb->insert($prefix . 'nutrition', $nutrition_data);
            }
        }

        /**
         * Fires after a recipe submitted through the public form has been stored.
         *
         * The premium overlay hooks this to persist the extra data its own form fields
         * collect. $get_param reads one sanitized request value: fn(string $key, $default).
         *
         * @param int      $recipe_id
         * @param callable $get_param
         */
        do_action( 'plugixarecipe_frontend_submission_saved', $recipe_id, $get_param );

        // Email notification.
        if (class_exists('\\PlugixaRecipe\\Email\\EmailNotifier')) {
            \PlugixaRecipe\Email\EmailNotifier::send_new_submission_notification([
                'title'       => $title,
                'author_name' => $recipe_data['author_name'],
                'description' => $description,
                'difficulty'  => $difficulty,
                'servings'    => $recipe_data['servings'],
            ]);
        }

        // Content write: the new recipe (plus its taxonomy pivots, which feed the recipe counts
        // shown in the category/cuisine/course widgets).
        Cache::flush('recipes', 'categories', 'cuisines', 'courses');

        return $this->success([
            'id'     => $recipe_id,
            'status' => $status,
        ], __('Thank you! Your recipe has been submitted for review.', 'plugixarecipe'));
    }

    /**
     * Recalculate average rating for a recipe.
     */
    private function recalculate_recipe_rating(int $recipe_id): void {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        $stats = $wpdb->get_row($wpdb->prepare(
            "SELECT AVG(rating) AS avg_rating, COUNT(*) AS count
            FROM {$prefix}reviews
            WHERE recipe_id = %d AND status = 'approved'",
            $recipe_id
        ));

        $wpdb->update($prefix . 'recipes', [
            'rating_average' => $stats->avg_rating ? round((float) $stats->avg_rating, 2) : 0,
            'rating_count'   => (int) $stats->count,
        ], ['id' => $recipe_id]);
    }
}
