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

// phpcs:disable WordPressVIPMinimum.Performance.WPQueryParams -- False positive: this file contains no get_posts()/WP_Query call. "exclude" here is a SHORTCODE attribute naming plugin-owned custom-table category/cuisine IDs to omit; it is absint()-mapped and bound as %d placeholders, and never reaches WP_Query.

defined( 'ABSPATH' ) || exit;

use PlugixaRecipe\Cache;

/**
 * Widget Shortcodes — compact, sidebar-friendly recipe elements.
 *
 * [plugixa_categories]       — Category list with links
 * [plugixa_cuisines]         — Cuisine list with links
 * [plugixa_latest_recipes]   — Latest recipes compact list
 * [plugixa_popular_recipes]  — Popular recipes compact list
 * [plugixa_related_recipes]  — Related recipe cards
 * [plugixa_recipe_search]    — Recipe search box
 * [plugixa_recipe_of_day]    — Recipe of the day
 * [plugixa_recipe_count]     — Total recipe count badge
 */
class WidgetShortcodes {

    /** @var bool Prevent double-enqueue of CSS */
    private bool $assets_enqueued = false;

    /**
     * Register all widget shortcodes.
     */
    public function register(): void {
        add_shortcode( 'plugixa_categories', [ $this, 'render_categories' ] );
        add_shortcode( 'plugixa_cuisines', [ $this, 'render_cuisines' ] );
        add_shortcode( 'plugixa_latest_recipes', [ $this, 'render_latest_recipes' ] );
        add_shortcode( 'plugixa_popular_recipes', [ $this, 'render_popular_recipes' ] );
        add_shortcode( 'plugixa_related_recipes', [ $this, 'render_related_recipes' ] );
        add_shortcode( 'plugixa_recipe_search', [ $this, 'render_recipe_search' ] );
        add_shortcode( 'plugixa_recipe_of_day', [ $this, 'render_recipe_of_day' ] );
        add_shortcode( 'plugixa_recipe_count', [ $this, 'render_recipe_count' ] );
    }

    /* ═══════════════════════════════════════════════
       Taxonomy Widgets: Categories & Cuisines
       ═══════════════════════════════════════════════ */

    /**
     * Render [plugixa_categories] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_categories( $atts ): string {
        $atts = shortcode_atts( [
            'limit'      => 10,
            'orderby'    => 'display_order', // display_order, name, count
            'order'      => 'ASC',
            'show_count' => 'true',
            'style'      => 'list',          // list, pills, grid
            'exclude'    => '',
            'title'      => '',
        ], $atts, 'plugixa_categories' );

        return $this->render_taxonomy( 'category', $atts );
    }

    /**
     * Render [plugixa_cuisines] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_cuisines( $atts ): string {
        $atts = shortcode_atts( [
            'limit'      => 10,
            'orderby'    => 'display_order',
            'order'      => 'ASC',
            'show_count' => 'true',
            'style'      => 'list',
            'exclude'    => '',
            'title'      => '',
        ], $atts, 'plugixa_cuisines' );

        return $this->render_taxonomy( 'cuisine', $atts );
    }

    /**
     * Shared renderer for category/cuisine taxonomy widgets.
     */
    private function render_taxonomy( string $type, array $atts ): string {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        $table        = $type === 'category' ? 'categories' : 'cuisines';
        $pivot_table  = $type === 'category' ? 'recipe_categories' : 'recipe_cuisines';
        $pivot_fk     = $type === 'category' ? 'category_id' : 'cuisine_id';
        $archive_base = $type === 'category' ? 'recipe-category' : 'recipe-cuisine';

        // Build query
        $limit  = absint( $atts['limit'] ) ?: 10;
        $order  = strtoupper( $atts['order'] ) === 'DESC' ? 'DESC' : 'ASC';

        $orderby_map = [
            'name'          => 't.name',
            'count'         => 'recipe_count',
            'display_order' => 't.display_order',
        ];
        $orderby = $orderby_map[ $atts['orderby'] ] ?? 't.display_order';

        $where  = [];
        $params = [];

        // Exclude
        if ( ! empty( $atts['exclude'] ) ) {
            $exclude_ids = array_map( 'absint', explode( ',', $atts['exclude'] ) );
            $exclude_ids = array_filter( $exclude_ids );
            if ( ! empty( $exclude_ids ) ) {
                $placeholders = implode( ',', array_fill( 0, count( $exclude_ids ), '%d' ) );
                $where[]      = "t.id NOT IN ({$placeholders})";
                $params       = array_merge( $params, $exclude_ids );
            }
        }

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

        $where_clause = ! empty( $where ) ? 'WHERE ' . implode( ' AND ', $where ) : '';

        $params[] = $limit;

        $sql = "SELECT t.id, t.name, t.slug, t.description, t.image_id,
                       COUNT(DISTINCT p.recipe_id) as recipe_count
                FROM {$prefix}{$table} t
                LEFT JOIN {$prefix}{$pivot_table} p ON t.id = p.{$pivot_fk}
                LEFT JOIN {$prefix}recipes r ON p.recipe_id = r.id AND r.status = 'published'
                {$where_clause}
                GROUP BY t.id
                ORDER BY {$orderby} {$order}
                LIMIT %d";

        // Read-through object cache; key = SQL + bound params (captures exclude/limit/orderby/
        // order/language). Cached under the taxonomy's own entity — the recipe_count column is
        // therefore eventually consistent, like the other counters.
        $entity = $type === 'category' ? 'categories' : 'cuisines';
        $items  = Cache::remember( $entity, 'widget_taxonomy|' . $sql . '|' . wp_json_encode( $params ), function () use ( $wpdb, $sql, $params ) {
            return $wpdb->get_results( $wpdb->prepare( $sql, $params ) ) ?: [];
        } );

        if ( empty( $items ) ) {
            return '';
        }

        // Build permalink for each item
        foreach ( $items as $item ) {
            $item->permalink = home_url( "/{$archive_base}/" . urlencode( $item->slug ) . '/' );
            if ( ! empty( $item->image_id ) ) {
                $item->image_url = wp_get_attachment_image_url( (int) $item->image_id, 'thumbnail' ) ?: '';
            } else {
                $item->image_url = '';
            }
        }

        $this->enqueue_assets();

        return $this->render_template( 'categories', [
            'items'      => $items,
            'type'       => $type,
            'style'      => sanitize_key( $atts['style'] ),
            'show_count' => filter_var( $atts['show_count'], FILTER_VALIDATE_BOOLEAN ),
            'title'      => sanitize_text_field( $atts['title'] ),
            'unique_id'  => 'pxr-widget-' . uniqid(),
        ] );
    }

    /* ═══════════════════════════════════════════════
       Compact Recipe Lists: Latest & Popular
       ═══════════════════════════════════════════════ */

    /**
     * Render [plugixa_latest_recipes] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_latest_recipes( $atts ): string {
        $atts = shortcode_atts( [
            'limit'          => 5,
            'show_thumbnail' => 'true',
            'show_category'  => 'true',
            'show_date'      => 'true',
            'thumbnail_size' => 'thumbnail',
            'category'       => '',
            'title'          => __( 'Latest Recipes', 'plugixarecipe' ),
        ], $atts, 'plugixa_latest_recipes' );

        $recipes = $this->query_compact_recipes( [
            'limit'          => absint( $atts['limit'] ) ?: 5,
            'orderby'        => 'published_at',
            'order'          => 'DESC',
            'category'       => sanitize_text_field( $atts['category'] ),
            'thumbnail_size' => sanitize_key( $atts['thumbnail_size'] ),
        ] );

        if ( empty( $recipes ) ) {
            return '';
        }

        $this->enqueue_assets();

        return $this->render_template( 'latest-recipes', [
            'recipes'        => $recipes,
            'show_thumbnail' => filter_var( $atts['show_thumbnail'], FILTER_VALIDATE_BOOLEAN ),
            'show_category'  => filter_var( $atts['show_category'], FILTER_VALIDATE_BOOLEAN ),
            'show_date'      => filter_var( $atts['show_date'], FILTER_VALIDATE_BOOLEAN ),
            'title'          => sanitize_text_field( $atts['title'] ),
            'unique_id'      => 'pxr-widget-' . uniqid(),
        ] );
    }

    /**
     * Render [plugixa_popular_recipes] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_popular_recipes( $atts ): string {
        $atts = shortcode_atts( [
            'limit'          => 5,
            'orderby'        => 'views', // views, rating
            'show_thumbnail' => 'true',
            'show_category'  => 'true',
            'show_rating'    => 'false',
            'show_views'     => 'false',
            'title'          => __( 'Popular Recipes', 'plugixarecipe' ),
        ], $atts, 'plugixa_popular_recipes' );

        $orderby = $atts['orderby'] === 'rating' ? 'rating_average' : 'view_count';

        $recipes = $this->query_compact_recipes( [
            'limit'          => absint( $atts['limit'] ) ?: 5,
            'orderby'        => $orderby,
            'order'          => 'DESC',
            'thumbnail_size' => 'thumbnail',
        ] );

        if ( empty( $recipes ) ) {
            return '';
        }

        $this->enqueue_assets();

        return $this->render_template( 'popular-recipes', [
            'recipes'        => $recipes,
            'show_thumbnail' => filter_var( $atts['show_thumbnail'], FILTER_VALIDATE_BOOLEAN ),
            'show_category'  => filter_var( $atts['show_category'], FILTER_VALIDATE_BOOLEAN ),
            'show_rating'    => filter_var( $atts['show_rating'], FILTER_VALIDATE_BOOLEAN ),
            'show_views'     => filter_var( $atts['show_views'], FILTER_VALIDATE_BOOLEAN ),
            'title'          => sanitize_text_field( $atts['title'] ),
            'unique_id'      => 'pxr-widget-' . uniqid(),
        ] );
    }

    /**
     * Query recipes for compact list widgets (latest/popular).
     * Lightweight enrichment: image + first category + permalink only.
     */
    private function query_compact_recipes( array $config ): array {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

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

        // Category filter
        if ( ! empty( $config['category'] ) ) {
            $cat_slugs = array_map( 'trim', explode( ',', $config['category'] ) );
            $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 );
        }

        // 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;
        }

        $allowed_orderby = [ 'published_at', 'created_at', 'view_count', 'rating_average' ];
        $orderby = in_array( $config['orderby'], $allowed_orderby, true ) ? $config['orderby'] : 'published_at';
        $order   = strtoupper( $config['order'] ?? 'DESC' ) === 'ASC' ? 'ASC' : 'DESC';
        $limit   = absint( $config['limit'] ) ?: 5;

        $where_clause = implode( ' AND ', $where );
        $params[]     = $limit;

        $sql = "SELECT DISTINCT r.id, r.title, r.image_id, r.published_at, r.created_at,
                       r.rating_average, r.rating_count, r.view_count, r.difficulty,
                       r.prep_time, r.cook_time
                FROM {$prefix}recipes r
                {$join}
                WHERE {$where_clause}
                ORDER BY r.{$orderby} {$order}
                LIMIT %d";

        // Read-through object cache; key = SQL + bound params (captures category filter,
        // language, orderby/order and limit).
        $results = Cache::remember( 'recipes', 'widget_compact|' . $sql . '|' . wp_json_encode( $params ), function () use ( $wpdb, $sql, $params ) {
            return $wpdb->get_results( $wpdb->prepare( $sql, $params ) ) ?: [];
        } );

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

        $thumbnail_size = $config['thumbnail_size'] ?? 'thumbnail';

        // Batch-load first category for all recipes — read-through object cache; key = recipe ids.
        $recipe_ids   = wp_list_pluck( $results, 'id' );
        $placeholders = implode( ',', array_fill( 0, count( $recipe_ids ), '%d' ) );
        $cat_rows     = Cache::remember( 'recipes', 'widget_compact_categories|' . wp_json_encode( $recipe_ids ), function () use ( $wpdb, $prefix, $placeholders, $recipe_ids ) {
            return $wpdb->get_results( $wpdb->prepare(
                "SELECT rc.recipe_id, cat.name, cat.slug
                 FROM {$prefix}recipe_categories rc
                 INNER JOIN {$prefix}categories cat ON rc.category_id = cat.id
                 WHERE rc.recipe_id IN ({$placeholders})
                 ORDER BY rc.display_order ASC",
                $recipe_ids
            ) ) ?: [];
        } );

        // Index: first category per recipe
        $cat_map = [];
        foreach ( $cat_rows as $row ) {
            $rid = (int) $row->recipe_id;
            if ( ! isset( $cat_map[ $rid ] ) ) {
                $cat_map[ $rid ] = $row;
            }
        }

        // Enrich each recipe
        $settings = $this->get_settings();
        foreach ( $results as $recipe ) {
            $rid = (int) $recipe->id;

            // Image
            if ( ! empty( $recipe->image_id ) ) {
                $recipe->image_url = wp_get_attachment_image_url( (int) $recipe->image_id, $thumbnail_size ) ?: '';
            } else {
                $recipe->image_url = '';
            }

            // First category
            $recipe->category_name = isset( $cat_map[ $rid ] ) ? $cat_map[ $rid ]->name : '';
            $recipe->category_slug = isset( $cat_map[ $rid ] ) ? $cat_map[ $rid ]->slug : '';

            // Permalink
            $slug = sanitize_title( $recipe->title ?? '' );
            $recipe->permalink = home_url( "/recipe/{$rid}/{$slug}/" );

            // Formatted date
            $date_format    = $settings['date_format'] ?? 'M j, Y';
            $date_str       = $recipe->published_at ?? $recipe->created_at;
            $recipe->date   = $date_str ? date_i18n( $date_format, strtotime( $date_str ) ) : '';

            // Aliases
            $recipe->rating = floatval( $recipe->rating_average ?? 0 );
        }

        return $results;
    }

    /* ═══════════════════════════════════════════════
       Related Recipes
       ═══════════════════════════════════════════════ */

    /**
     * Render [plugixa_related_recipes] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_related_recipes( $atts ): string {
        $atts = shortcode_atts( [
            'recipe_id' => 0,
            'limit'     => 4,
            'columns'   => 4,
            'relation'  => 'both', // category, cuisine, both
            'title'     => __( 'You May Also Like', 'plugixarecipe' ),
        ], $atts, 'plugixa_related_recipes' );

        // Auto-detect recipe ID from single recipe page
        $recipe_id = absint( $atts['recipe_id'] );
        if ( ! $recipe_id ) {
            $recipe_id = absint( get_query_var( 'pxr_recipe_id', 0 ) );
        }

        if ( ! $recipe_id ) {
            return '';
        }

        $recipes = $this->query_related_recipes( $recipe_id, [
            'limit'    => absint( $atts['limit'] ) ?: 4,
            'relation' => sanitize_key( $atts['relation'] ),
        ] );

        if ( empty( $recipes ) ) {
            return '';
        }

        $this->enqueue_assets();

        $columns = absint( $atts['columns'] ) ?: 4;

        return $this->render_template( 'related-recipes', [
            'recipes'   => $recipes,
            'columns'   => $columns,
            'title'     => sanitize_text_field( $atts['title'] ),
            'unique_id' => 'pxr-widget-' . uniqid(),
            // Provide a minimal view_config for recipe-card.php
            'view_config' => [
                'show_image'      => true,
                'show_rating'     => true,
                'show_meta'       => true,
                'show_difficulty' => true,
                'show_diet'       => false,
                'show_cuisine'    => false,
                'show_excerpt'    => false,
            ],
        ] );
    }

    /**
     * Query related recipes based on shared categories/cuisines.
     */
    private function query_related_recipes( int $recipe_id, array $config ): array {
        global $wpdb;
        $prefix = $wpdb->prefix . 'plugixarecipe_';

        $limit    = absint( $config['limit'] ) ?: 4;
        $relation = $config['relation'] ?? 'both';

        // Get current recipe's category and cuisine IDs
        $cat_ids     = [];
        $cuisine_ids = [];

        // Read-through object cache; child rows cached under the owning recipe.
        if ( in_array( $relation, [ 'category', 'both' ], true ) ) {
            $cat_ids = Cache::remember( 'recipes', 'related_cat_ids|' . $recipe_id, function () use ( $wpdb, $prefix, $recipe_id ) {
                return $wpdb->get_col( $wpdb->prepare(
                    "SELECT category_id FROM {$prefix}recipe_categories WHERE recipe_id = %d",
                    $recipe_id
                ) ) ?: [];
            } );
        }

        if ( in_array( $relation, [ 'cuisine', 'both' ], true ) ) {
            $cuisine_ids = Cache::remember( 'recipes', 'related_cuisine_ids|' . $recipe_id, function () use ( $wpdb, $prefix, $recipe_id ) {
                return $wpdb->get_col( $wpdb->prepare(
                    "SELECT cuisine_id FROM {$prefix}recipe_cuisines WHERE recipe_id = %d",
                    $recipe_id
                ) ) ?: [];
            } );
        }

        if ( empty( $cat_ids ) && empty( $cuisine_ids ) ) {
            return [];
        }

        // Build union query to find related recipes
        $params = [];
        $unions = [];

        if ( ! empty( $cat_ids ) ) {
            $placeholders = implode( ',', array_fill( 0, count( $cat_ids ), '%d' ) );
            $unions[] = "SELECT DISTINCT rc.recipe_id
                         FROM {$prefix}recipe_categories rc
                         WHERE rc.category_id IN ({$placeholders})";
            $params   = array_merge( $params, array_map( 'absint', $cat_ids ) );
        }

        if ( ! empty( $cuisine_ids ) ) {
            $placeholders = implode( ',', array_fill( 0, count( $cuisine_ids ), '%d' ) );
            $unions[] = "SELECT DISTINCT rcu.recipe_id
                         FROM {$prefix}recipe_cuisines rcu
                         WHERE rcu.cuisine_id IN ({$placeholders})";
            $params   = array_merge( $params, array_map( 'absint', $cuisine_ids ) );
        }

        $union_sql = implode( ' UNION ', $unions );

        // Fetch related recipes excluding the current one
        $params[] = $recipe_id;
        $params[] = $limit;

        $sql = "SELECT r.*
                FROM {$prefix}recipes r
                INNER JOIN ({$union_sql}) related ON r.id = related.recipe_id
                WHERE r.status = 'published' AND r.id != %d
                ORDER BY r.rating_average DESC, r.created_at DESC
                LIMIT %d";

        // Read-through object cache; key = SQL + bound params (captures the related category /
        // cuisine ids, the excluded recipe and the limit).
        $results = Cache::remember( 'recipes', 'widget_related|' . $sql . '|' . wp_json_encode( $params ), function () use ( $wpdb, $sql, $params ) {
            return $wpdb->get_results( $wpdb->prepare( $sql, $params ), ARRAY_A ) ?: [];
        } );

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

        // Full enrichment for recipe-card.php
        return array_map( [ $this, 'enrich_recipe_for_card' ], $results );
    }

    /**
     * Enrich a recipe with all data recipe-card.php needs.
     */
    private function enrich_recipe_for_card( array $recipe ): object {
        global $wpdb;
        $prefix    = $wpdb->prefix . 'plugixarecipe_';
        $recipe_id = (int) $recipe['id'];

        // Image
        if ( ! empty( $recipe['image_id'] ) ) {
            $recipe['image_url']    = wp_get_attachment_image_url( (int) $recipe['image_id'], 'medium_large' ) ?: '';
            $recipe['image_srcset'] = wp_get_attachment_image_srcset( (int) $recipe['image_id'], 'medium_large' ) ?: '';
        } 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 ) ?: [];
        } );

        // 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
        if ( ! empty( $recipe['description'] ) ) {
            $plain = wp_strip_all_tags( $recipe['description'] );
            $recipe['excerpt'] = mb_strlen( $plain ) > 150
                ? mb_substr( $plain, 0, 150 ) . "\u{2026}"
                : $plain;
        } else {
            $recipe['excerpt'] = '';
        }

        // Like/favorite status (per-user state: not cached).
        $recipe['like_count'] = isset( $recipe['like_count'] ) ? (int) $recipe['like_count'] : 0;
        if ( is_user_logged_in() ) {
            $uid = 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", $uid, $recipe_id
            ) );
            $recipe['is_favorited'] = (bool) $wpdb->get_var( $wpdb->prepare(
                "SELECT id FROM {$prefix}favorites WHERE user_id = %d AND recipe_id = %d", $uid, $recipe_id
            ) );
        } else {
            $recipe['is_liked']     = false;
            $recipe['is_favorited'] = false;
        }

        // Template aliases
        $recipe['rating']    = $recipe['rating_average'] ?? 0;
        $recipe['cuisine']   = ! empty( $recipe['cuisines'][0]['name'] ) ? $recipe['cuisines'][0]['name'] : '';
        $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;
    }

    /* ═══════════════════════════════════════════════
       Recipe Search Widget
       ═══════════════════════════════════════════════ */

    /**
     * Render [plugixa_recipe_search] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_recipe_search( $atts ): string {
        $atts = shortcode_atts( [
            'placeholder' => __( 'Search recipes...', 'plugixarecipe' ),
            'style'       => 'simple', // simple, expanded
            'action'      => '',
            'title'       => '',
        ], $atts, 'plugixa_recipe_search' );

        $this->enqueue_assets();

        // For expanded style, load categories and cuisines
        $categories = [];
        $cuisines   = [];
        if ( $atts['style'] === 'expanded' ) {
            global $wpdb;
            $prefix     = $wpdb->prefix . 'plugixarecipe_';
            // Read-through object cache; these queries take no inputs.
            $categories = Cache::remember( 'categories', 'widget_search_options', function () use ( $wpdb, $prefix ) {
                return $wpdb->get_results( "SELECT id, name, slug FROM {$prefix}categories ORDER BY display_order ASC" ) ?: [];
            } );
            $cuisines   = Cache::remember( 'cuisines', 'widget_search_options', function () use ( $wpdb, $prefix ) {
                return $wpdb->get_results( "SELECT id, name, slug FROM {$prefix}cuisines ORDER BY display_order ASC" ) ?: [];
            } );
        }

        return $this->render_template( 'recipe-search', [
            'placeholder' => sanitize_text_field( $atts['placeholder'] ),
            'style'       => sanitize_key( $atts['style'] ),
            'action'      => esc_url( $atts['action'] ) ?: '',
            'title'       => sanitize_text_field( $atts['title'] ),
            'categories'  => $categories,
            'cuisines'    => $cuisines,
            'unique_id'   => 'pxr-widget-' . uniqid(),
        ] );
    }

    /* ═══════════════════════════════════════════════
       Recipe of the Day
       ═══════════════════════════════════════════════ */

    /**
     * Render [plugixa_recipe_of_day] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_recipe_of_day( $atts ): string {
        $atts = shortcode_atts( [
            'style'       => 'card',  // card, compact
            'show_rating' => 'true',
            'show_time'   => 'true',
            'title'       => __( 'Recipe of the Day', 'plugixarecipe' ),
        ], $atts, 'plugixa_recipe_of_day' );

        $recipe = $this->get_recipe_of_day();

        if ( ! $recipe ) {
            return '';
        }

        $this->enqueue_assets();

        return $this->render_template( 'recipe-of-day', [
            'recipe'      => $recipe,
            'style'       => sanitize_key( $atts['style'] ),
            'show_rating' => filter_var( $atts['show_rating'], FILTER_VALIDATE_BOOLEAN ),
            'show_time'   => filter_var( $atts['show_time'], FILTER_VALIDATE_BOOLEAN ),
            'title'       => sanitize_text_field( $atts['title'] ),
            'unique_id'   => 'pxr-widget-' . uniqid(),
        ] );
    }

    /**
     * Get the "recipe of the day" — deterministic per day via transient + seeded random.
     */
    private function get_recipe_of_day(): ?object {
        $transient_key = 'pxr_recipe_of_day';
        $recipe_id     = get_transient( $transient_key );

        if ( $recipe_id ) {
            // Verify the recipe still exists and is published — read-through object cache.
            global $wpdb;
            $prefix = $wpdb->prefix . 'plugixarecipe_';
            $recipe = Cache::remember( 'recipes', 'widget_of_day|' . (int) $recipe_id, function () use ( $wpdb, $prefix, $recipe_id ) {
                return $wpdb->get_row( $wpdb->prepare(
                    "SELECT * FROM {$prefix}recipes WHERE id = %d AND status = 'published'",
                    $recipe_id
                ), ARRAY_A );
            } );

            if ( $recipe ) {
                return $this->enrich_recipe_for_card( $recipe );
            }
        }

        // Pick a new recipe — prefer featured, fallback to any published
        global $wpdb;
        $prefix    = $wpdb->prefix . 'plugixarecipe_';
        $daily_seed = crc32( gmdate( 'Y-m-d' ) );

        // Read-through object cache; key = the daily seed (the only input).
        $recipe = Cache::remember( 'recipes', 'widget_of_day_featured|' . $daily_seed, function () use ( $wpdb, $prefix, $daily_seed ) {
            return $wpdb->get_row( $wpdb->prepare(
                "SELECT * FROM {$prefix}recipes WHERE status = 'published' AND is_featured = 1 ORDER BY RAND(%d) LIMIT 1",
                $daily_seed
            ), ARRAY_A );
        } );

        if ( ! $recipe ) {
            $recipe = Cache::remember( 'recipes', 'widget_of_day_any|' . $daily_seed, function () use ( $wpdb, $prefix, $daily_seed ) {
                return $wpdb->get_row( $wpdb->prepare(
                    "SELECT * FROM {$prefix}recipes WHERE status = 'published' ORDER BY RAND(%d) LIMIT 1",
                    $daily_seed
                ), ARRAY_A );
            } );
        }

        if ( ! $recipe ) {
            return null;
        }

        set_transient( $transient_key, $recipe['id'], DAY_IN_SECONDS );

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

    /* ═══════════════════════════════════════════════
       Recipe Count Badge
       ═══════════════════════════════════════════════ */

    /**
     * Render [plugixa_recipe_count] shortcode.
     *
     * @param array|string $atts Shortcode attributes.
     * @return string
     */
    public function render_recipe_count( $atts ): string {
        $atts = shortcode_atts( [
            'prefix'   => '',
            'suffix'   => ' ' . __( 'Recipes', 'plugixarecipe' ),
            'style'    => 'badge', // badge, text
            'category' => '',
        ], $atts, 'plugixa_recipe_count' );

        $category = sanitize_text_field( $atts['category'] );
        $cache_key = 'pxr_recipe_count' . ( $category ? '_' . $category : '' );

        $count = get_transient( $cache_key );
        if ( false === $count ) {
            global $wpdb;
            $prefix = $wpdb->prefix . 'plugixarecipe_';

            if ( $category ) {
                $count = (int) $wpdb->get_var( $wpdb->prepare(
                    "SELECT COUNT(DISTINCT r.id) FROM {$prefix}recipes r
                     INNER JOIN {$prefix}recipe_categories rc ON r.id = rc.recipe_id
                     INNER JOIN {$prefix}categories c ON rc.category_id = c.id AND c.slug = %s
                     WHERE r.status = 'published'",
                    $category
                ) );
            } else {
                $count = (int) $wpdb->get_var(
                    "SELECT COUNT(*) FROM {$prefix}recipes WHERE status = 'published'"
                );
            }

            set_transient( $cache_key, $count, HOUR_IN_SECONDS );
        }

        $this->enqueue_assets();

        return $this->render_template( 'recipe-count', [
            'count'  => (int) $count,
            'prefix' => sanitize_text_field( $atts['prefix'] ),
            'suffix' => sanitize_text_field( $atts['suffix'] ),
            'style'  => sanitize_key( $atts['style'] ),
        ] );
    }

    /* ═══════════════════════════════════════════════
       Shared Helpers
       ═══════════════════════════════════════════════ */

    /**
     * Render a widget template and return the HTML.
     */
    private function render_template( string $template_name, array $data ): string {
        $template = PLUGIXARECIPE_PATH . "templates/widgets/{$template_name}.php";

        // Allow theme override
        $theme_template = get_stylesheet_directory() . "/plugixarecipe/widgets/{$template_name}.php";
        if ( file_exists( $theme_template ) ) {
            $template = $theme_template;
        }

        if ( ! file_exists( $template ) ) {
            return '';
        }

        ob_start();
        extract( $data, EXTR_SKIP );
        include $template;
        return ob_get_clean();
    }

    /**
     * Enqueue frontend CSS (shared with main shortcode CSS).
     */
    private function enqueue_assets(): void {
        if ( $this->assets_enqueued ) {
            return;
        }

        $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 for interactive features (likes, favorites)
        $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',
                [],
                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'       => [
                    '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' ),
                    ],
                ],
            ] );
        }

        $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',
        ];

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