<?php
if ( ! defined( 'ABSPATH' ) ) {
    exit; // Exit if accessed directly.
}

class CGC_Blocks {
    public function __construct() {
        add_action('init', array($this, 'register_blocks'));
        add_action('enqueue_block_editor_assets', array($this, 'enqueue_design_editor_for_block'));
        add_action('enqueue_block_editor_assets', array($this, 'expose_pro_status_to_block_editor'));
    }

    // Lets the gift-card-field block's edit.tsx know whether Pro-only field
    // types/controls should show as disabled. Targets the always-present
    // core "wp-blocks" handle rather than this block's own auto-generated
    // editor-script handle, since that handle name isn't guaranteed and
    // wp-blocks is guaranteed to already be enqueued on this screen.
    public function expose_pro_status_to_block_editor() {
        // Same fallback chain as resolve_currency_symbol(), minus the
        // per-field override (the editor UI already shows that separately) -
        // this is just what the preview pill should show when the field's
        // own currencySymbol is empty.
        $currency_symbol = $this->resolve_currency_symbol( array() );

        wp_add_inline_script(
            'wp-blocks',
            'window.cgcProStatus = ' . wp_json_encode( cgc_is_pro_active() ) . ';'
            . 'window.cgcUpgradeUrl = ' . wp_json_encode( CGC_UPGRADE_URL ) . ';'
            . 'window.cgcStoreCurrencySymbol = ' . wp_json_encode( $currency_symbol ) . ';',
            'before'
        );
    }

    public function register_blocks() {
        // Child blocks placed inside the gift-card-form block's InnerBlocks area.
        // They have no render_callback - their save() is null, and they're only
        // ever rendered as part of render_gift_card_form() walking $block->inner_blocks,
        // never standalone.
        register_block_type( CGC_PLUGIN_PATH . 'build/gift-card-design-picker' );
        register_block_type( CGC_PLUGIN_PATH . 'build/gift-card-field' );
        register_block_type( CGC_PLUGIN_PATH . 'build/gift-card-submit-button' );

        register_block_type(
            CGC_PLUGIN_PATH . 'build/gift-card-block',
            [
                'render_callback' => array($this, 'render_gift_card_form'),
            ]
        );
    }

    // Loads the design editor app (the same app used on the cgc-design post-edit
    // screen) on the block editor screen, so the block's "Create New Design" /
    // "Edit Design" modal can mount it into a .design-edit div on demand.
    public function enqueue_design_editor_for_block() {
        $asset_file = include( CGC_PLUGIN_PATH . 'build/design-edit/index.asset.php' );

        if( ! is_array($asset_file) ) {
            return;
        }

        wp_enqueue_script('cgc-design-edit', CGC_PLUGIN_URL . 'build/design-edit/index.js', $asset_file['dependencies'], CGC_PLUGIN_VERSION, true);
        wp_set_script_translations( 'cgc-design-edit', 'gift-card-studio', CGC_PLUGIN_PATH . 'languages' );
        wp_enqueue_style('cgc-design-edit', CGC_PLUGIN_URL . 'build/design-edit.css', array(), CGC_PLUGIN_VERSION);
        wp_enqueue_style( 'cgc-fonts', CGC_PLUGIN_URL . 'assets/fonts.css', array(), CGC_PLUGIN_VERSION );
        // enqueue_block_editor_assets only ever fires in wp-admin's block
        // editor (requires edit_posts to reach at all) - lets the Page
        // tab's Background Image field open the real WP media library.
        wp_enqueue_media();

        $current_user = wp_get_current_user();

        $display_name = $current_user->display_name ?? '';
        $is_admin = current_user_can('administrator');

        global $cgc_fetch;
        if( ! $cgc_fetch ) return;

        $uploads = $cgc_fetch->get_user_uploads();

        $designs_path = CGC_PLUGIN_PATH . 'assets/designs/';
        $design_files = glob( $designs_path . '*.json' );

        $starters = array();

        foreach ( $design_files as $file ) {
            $starters[] = array(
                'file' => basename( $file ),
                'url'  => CGC_PLUGIN_URL . 'assets/designs/' . basename( $file ),
                'ver'  => '1.0.2'
            );
        }

        wp_localize_script( 'cgc-design-edit', 'appData', array(
            'user_name' => $display_name,
            'rest_url' => rest_url( 'cgc/v2' ),
            'site_url' => site_url(),
            'nonce' => wp_create_nonce( 'wp_rest' ),
            'plugin_url' => CGC_PLUGIN_URL,
            'is_admin' => $is_admin,
            'design' => null,
            'starters' => $starters,
            'uploads' => $uploads,
            'action' => 'edit',
            'embedded' => true,
            'parent' => 0,
            'formFields' => CGC_Form_Fields::get_default_fields(),
        ) );
    }

    // Converts a #rgb/#rrggbb hex color into the "H S% L%" triplet format that
    // app/src/styles.css's CSS custom properties expect (consumed as hsl(var(--x))
    // by app/tailwind.config.js) - the block editor authors colors as hex, but the
    // frontend theme variables are stored/read as bare HSL components.
    private function hex_to_hsl_triplet( $hex ) {
        $hex = ltrim( (string) $hex, '#' );

        if ( 3 === strlen( $hex ) ) {
            $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
        }

        if ( 6 !== strlen( $hex ) || ! ctype_xdigit( $hex ) ) {
            return '0 0% 0%';
        }

        $r = hexdec( substr( $hex, 0, 2 ) ) / 255;
        $g = hexdec( substr( $hex, 2, 2 ) ) / 255;
        $b = hexdec( substr( $hex, 4, 2 ) ) / 255;

        $max = max( $r, $g, $b );
        $min = min( $r, $g, $b );
        $l = ( $max + $min ) / 2;

        if ( $max === $min ) {
            $h = 0;
            $s = 0;
        } else {
            $d = $max - $min;
            $s = $l > 0.5 ? $d / ( 2 - $max - $min ) : $d / ( $max + $min );

            if ( $max === $r ) {
                $h = ( $g - $b ) / $d + ( $g < $b ? 6 : 0 );
            } elseif ( $max === $g ) {
                $h = ( $b - $r ) / $d + 2;
            } else {
                $h = ( $r - $g ) / $d + 4;
            }

            $h /= 6;
        }

        return sprintf( '%d %d%% %d%%', round( $h * 360 ), round( $s * 100 ), round( $l * 100 ) );
    }

    // Builds the inline CSS custom properties for the form wrapper from the
    // block's "styles" attribute. --border and --input share one color since
    // the block only exposes a single "Input/Border Color" control for both.
    private function build_style_vars( $styles ) {
        $vars = sprintf(
            '--primary:%s;--primary-foreground:%s;--accent:%s;--accent-foreground:%s;--background:%s;--border:%s;--input:%s;',
            $this->hex_to_hsl_triplet( $styles['primaryColor'] ),
            $this->hex_to_hsl_triplet( $styles['primaryTextColor'] ),
            $this->hex_to_hsl_triplet( $styles['accentColor'] ),
            $this->hex_to_hsl_triplet( $styles['accentTextColor'] ),
            $this->hex_to_hsl_triplet( $styles['backgroundColor'] ),
            $this->hex_to_hsl_triplet( $styles['borderColor'] ),
            $this->hex_to_hsl_triplet( $styles['borderColor'] )
        );

        $vars .= sprintf( '--radius:%dpx;', (int) $styles['radius'] );

        return $vars;
    }

    // Inline CSS custom properties for a field's per-instance accent override
    // (set on $field['styleVars'] earlier in render_gift_card_form()).
    private function style_vars_attr( $field ) {
        if ( empty( $field['styleVars'] ) || ! is_array( $field['styleVars'] ) ) {
            return '';
        }

        $css = '';
        foreach ( $field['styleVars'] as $property => $value ) {
            $css .= $property . ':' . $value . ';';
        }

        return $css;
    }

    // Public - also called directly by CGC_Elementor_Design_Picker::render()
    // (an Elementor nested-element child needs its own independently
    // callable render() for the Elementor editor to show/select it at all;
    // see includes/elementor/class-cgc-elementor-gift-card-form.php).
    public function render_design_picker_markup( $starter_templates, $choose_label ) {
        $count = count( $starter_templates );

        if ( 1 === $count ) {
            return sprintf(
                '<div class="cgc-design-single"><div class="cgc-canvas-mount" data-card-id="%d"></div></div>',
                (int) $starter_templates[0]['card_id']
            );
        }

        if ( $count < 2 ) {
            return '';
        }

        $items = '';

        foreach ( $starter_templates as $template ) {
            $card_id = (int) $template['card_id'];

            $items .= '<div class="cgc-design-item">'
                . '<div class="cgc-canvas-mount" data-card-id="' . $card_id . '"></div>'
                . '<button type="button" class="cgc-design-select" '
                . wp_interactivity_data_wp_context( array( 'cardId' => $card_id ) ) . ' '
                . 'data-wp-on--click="actions.selectDesign" '
                . 'data-wp-class--is-selected="state.isDesignSelected" '
                . 'data-wp-text="state.designButtonLabel"></button>'
                . '</div>';
        }

        return '<div class="cgc-design-picker">'
            . '<label>' . esc_html( $choose_label ) . '</label>'
            . '<div class="cgc-design-carousel">' . $items . '</div>'
            . '</div>';
    }

    // Elementor-only variant of render_design_picker_markup() above - shows
    // each design's featured image instead of mounting a live Fabric.js
    // canvas. Elementor renders this widget via AJAX partial re-renders as
    // often as full page loads (dragging it in fresh, live-editing a
    // control), and reliably (re)hydrating the canvas bootstrap script into
    // that DOM turned into more trouble than it was worth - a static image
    // is simpler and good enough here, and matches what
    // CGC_Elementor_Design_Picker::content_template() already previews in
    // the editor. The "select design" button/interactivity wiring is
    // otherwise identical, so choosing a design still works the same way.
    public function render_design_picker_markup_static( $starter_templates, $choose_label ) {
        $count = count( $starter_templates );

        if ( $count < 1 ) {
            return '';
        }

        if ( 1 === $count ) {
            return '<div class="cgc-design-single">' . $this->render_design_thumbnail( $starter_templates[0] ) . '</div>';
        }

        $items = '';

        foreach ( $starter_templates as $template ) {
            $card_id = (int) $template['card_id'];

            $items .= '<div class="cgc-design-item">'
                . $this->render_design_thumbnail( $template )
                . '<button type="button" class="cgc-design-select" '
                . wp_interactivity_data_wp_context( array( 'cardId' => $card_id ) ) . ' '
                . 'data-wp-on--click="actions.selectDesign" '
                . 'data-wp-class--is-selected="state.isDesignSelected" '
                . 'data-wp-text="state.designButtonLabel"></button>'
                . '</div>';
        }

        return '<div class="cgc-design-picker">'
            . '<label>' . esc_html( $choose_label ) . '</label>'
            . '<div class="cgc-design-carousel">' . $items . '</div>'
            . '</div>';
    }

    private function render_design_thumbnail( $template ) {
        if ( empty( $template['url'] ) ) {
            return '';
        }

        return '<img src="' . esc_url( $template['url'] ) . '" alt="' . esc_attr( $template['name'] ?? '' ) . '" style="max-width:100%;height:auto;display:block;">';
    }

    // 3-tier currency resolution, most to least specific: the field's own
    // currencySymbol override, then the GiftCard Studio-wide "Default Currency
    // Symbol" setting (class-cgc-admin-settings.php), then the WooCommerce
    // store's own configured currency.
    public function resolve_currency_symbol( $field ) {
        if ( ! empty( $field['currencySymbol'] ) ) {
            return $field['currencySymbol'];
        }

        $default_symbol = class_exists( 'CGC_Admin_Settings' ) ? CGC_Admin_Settings::get_default_currency_symbol() : '';
        if ( $default_symbol ) {
            return $default_symbol;
        }

        return function_exists( 'get_woocommerce_currency_symbol' ) ? get_woocommerce_currency_symbol() : '$';
    }

    private function render_amount_field_markup( $field ) {
        $pills = '';
        $currency_symbol = $this->resolve_currency_symbol( $field );

        foreach ( (array) ( $field['options'] ?? array() ) as $amount ) {
            $amount = (float) $amount;
            $pills .= '<button type="button" class="cgc-pill" '
                . wp_interactivity_data_wp_context( array( 'amount' => $amount ) ) . ' '
                . 'data-wp-on--click="actions.selectAmount" '
                . 'data-wp-class--is-selected="state.isAmountSelected">' . esc_html( $currency_symbol ) . esc_html( $amount ) . '</button>';
        }

        $other_html = '';

        if ( ! empty( $field['allow_other'] ) ) {
            $other_label = ! empty( $field['otherLabel'] ) ? $field['otherLabel'] : 'Other';
            $other_placeholder = ! empty( $field['otherPlaceholder'] ) ? $field['otherPlaceholder'] : 'Enter other amount';

            $pills .= '<button type="button" class="cgc-pill" '
                . 'data-wp-on--click="actions.selectOther" '
                . 'data-wp-class--is-selected="state.isOtherSelected">' . esc_html( $other_label ) . '</button>';

            $other_input_id = 'cgc-field-' . sanitize_html_class( $field['key'] ?? 'amount' ) . '-other';

            $other_html = '<div class="cgc-other-amount" data-wp-bind--hidden="!state.isOtherSelected">'
                . '<span>' . esc_html( $currency_symbol ) . '</span>'
                . '<input type="number" id="' . esc_attr( $other_input_id ) . '" name="' . esc_attr( $other_input_id ) . '" min="1" step="10" placeholder="' . esc_attr( $other_placeholder ) . '" '
                . 'data-wp-bind--value="state.amount" data-wp-on--input="actions.updateOtherAmount">'
                . '</div>';
        }

        return '<div class="cgc-field" style="' . esc_attr( $this->style_vars_attr( $field ) ) . '">'
            . '<h4>' . esc_html( $field['label'] ) . '</h4>'
            . '<div class="cgc-amount-pills">' . $pills . '</div>'
            . $other_html
            . '</div>';
    }

    private function render_products_field_markup( $field ) {
        $cards = '';

        foreach ( (array) ( $field['products'] ?? array() ) as $product ) {
            $product_id = (int) $product['id'];
            $is_variable = 'variable' === ( $product['type'] ?? 'simple' );
            $is_unavailable = $is_variable && empty( $product['variations'] );
            $action = $is_variable ? 'actions.selectVariableProduct' : 'actions.selectSimpleProduct';

            $attrs_html = '';

            if ( $is_variable && ! empty( $product['attributes'] ) ) {
                foreach ( $product['attributes'] as $attr_key => $attr_options ) {
                    /* translators: %s: product attribute name (eg. "Color", "Size"). */
                    $option_tags = '<option value="">' . esc_html( sprintf( __( 'Choose %s', 'gift-card-studio' ), $attr_key ) ) . '</option>';

                    foreach ( (array) $attr_options as $option ) {
                        $option_tags .= '<option value="' . esc_attr( $option ) . '">' . esc_html( $option ) . '</option>';
                    }

                    $attr_select_id = 'cgc-product-' . $product_id . '-' . sanitize_html_class( $attr_key );

                    // Carry productId explicitly here rather than relying on
                    // it being inherited from the ancestor .cgc-product-card's
                    // own data-wp-context - context merging across nested
                    // elements isn't something to assume without verifying.
                    $attrs_html .= '<select id="' . esc_attr( $attr_select_id ) . '" name="' . esc_attr( $attr_select_id ) . '" '
                        . wp_interactivity_data_wp_context( array( 'productId' => $product_id, 'attrKey' => $attr_key ) ) . ' '
                        . 'data-wp-on--change="actions.selectVariationAttribute">' . $option_tags . '</select>';
                }
            }

            $image_html = ! empty( $product['image'] )
                ? '<img src="' . esc_url( $product['image'] ) . '" alt="' . esc_attr( $product['name'] ) . '">'
                : '';

            $unavailable_note = $is_unavailable
                ? '<p class="cgc-unavailable-note">' . esc_html__( 'Currently unavailable', 'gift-card-studio' ) . '</p>'
                : '';

            $click_attr = $is_unavailable ? '' : 'data-wp-on--click="' . esc_attr( $action ) . '"';

            $addons_html = $this->render_product_addons_markup( $product_id, $product['addons'] ?? array() );

            $cards .= '<div class="cgc-product-card' . ( $is_unavailable ? ' is-unavailable' : '' ) . '" '
                . wp_interactivity_data_wp_context( array( 'productId' => $product_id ) ) . ' '
                . 'data-wp-class--is-selected="state.isProductSelected" ' . $click_attr . '>'
                . $image_html
                . '<p class="cgc-product-name">' . esc_html( $product['name'] ) . '</p>'
                . '<p class="cgc-product-price">' . wp_kses_post( $product['price_html'] ?? '' ) . '</p>'
                . $unavailable_note
                . '<div class="cgc-product-attributes" data-wp-bind--hidden="!state.isProductSelected">' . $attrs_html . '</div>'
                . $addons_html
                . '</div>';
        }

        return '<div class="cgc-field" style="' . esc_attr( $this->style_vars_attr( $field ) ) . '">'
            . '<h4>' . esc_html( $field['label'] ) . '</h4>'
            . '<div class="cgc-product-list">' . $cards . '</div>'
            . '</div>';
    }

    // Renders WooCommerce Product Add-Ons groups (see CGC_Fetch::get_product_addons())
    // attached to a "products" field's product. Every interactive element
    // gets its own explicit data-wp-context rather than relying on it being
    // inherited from the ancestor .cgc-product-card - matches the existing
    // per-attribute-select convention just above.
    private function render_product_addons_markup( $product_id, $addons ) {
        if ( ! $addons ) {
            return '';
        }

        $groups_html = '';

        foreach ( $addons as $addon ) {
            $group_index = (int) $addon['index'];
            $required_marker = $addon['required'] ? ' <span class="cgc-addon-required">*</span>' : '';
            $legend = '<legend>' . esc_html( $addon['name'] ) . $required_marker . '</legend>';

            if ( in_array( $addon['type'], array( 'checkbox', 'radio' ), true ) ) {
                $input_type = $addon['type'];
                $input_name = 'cgc-addon-' . $product_id . '-' . $group_index;
                $options_html = '';

                foreach ( $addon['options'] as $option_index => $option ) {
                    $context = wp_interactivity_data_wp_context( array(
                        'productId' => $product_id,
                        'groupIndex' => $group_index,
                        'optionIndex' => $option_index,
                    ) );
                    $action = 'checkbox' === $input_type ? 'actions.toggleAddonOption' : 'actions.selectAddonOption';

                    $options_html .= '<label class="cgc-addon-option">'
                        . '<input type="' . esc_attr( $input_type ) . '" name="' . esc_attr( $input_name ) . '" ' . $context . ' '
                        . 'data-wp-on--change="' . esc_attr( $action ) . '" data-wp-bind--checked="state.isAddonOptionSelected">'
                        . '<span>' . esc_html( $option['label'] ) . '</span>'
                        . $this->render_addon_price_delta( $option['price'], $option['price_type'] )
                        . '</label>';
                }

                $groups_html .= '<fieldset class="cgc-addon-group">' . $legend . $options_html . '</fieldset>';
            } elseif ( 'select' === $addon['type'] ) {
                $context = wp_interactivity_data_wp_context( array( 'productId' => $product_id, 'groupIndex' => $group_index ) );
                $option_tags = '<option value="">' . esc_html__( 'None', 'gift-card-studio' ) . '</option>';

                foreach ( $addon['options'] as $option_index => $option ) {
                    $price_delta = $this->render_addon_price_delta( $option['price'], $option['price_type'], false );
                    $option_tags .= '<option value="' . esc_attr( $option_index ) . '">' . esc_html( $option['label'] . $price_delta ) . '</option>';
                }

                $groups_html .= '<fieldset class="cgc-addon-group">' . $legend
                    . '<select ' . $context . ' data-wp-on--change="actions.selectAddonOption">' . $option_tags . '</select>'
                    . '</fieldset>';
            } else { // text / textarea
                $context = wp_interactivity_data_wp_context( array( 'productId' => $product_id, 'groupIndex' => $group_index ) );
                $price_delta = $this->render_addon_price_delta( $addon['price'], $addon['price_type'] );
                $field_html = 'textarea' === $addon['type']
                    ? '<textarea ' . $context . ' data-wp-bind--value="state.addonTextValue" data-wp-on--input="actions.updateAddonText"></textarea>'
                    : '<input type="text" ' . $context . ' data-wp-bind--value="state.addonTextValue" data-wp-on--input="actions.updateAddonText">';

                $groups_html .= '<fieldset class="cgc-addon-group">' . $legend . $field_html . $price_delta . '</fieldset>';
            }
        }

        return '<div class="cgc-product-addons" data-wp-bind--hidden="!state.isProductSelected">' . $groups_html . '</div>';
    }

    // $wrap_span controls whether the delta is wrapped in a
    // <span class="cgc-addon-price"> (for inline label use) or returned as
    // plain text (eg. for baking into a <select>'s option text, where markup
    // isn't rendered).
    private function render_addon_price_delta( $price, $price_type, $wrap_span = true ) {
        if ( ! $price ) {
            return '';
        }

        $text = 'percentage' === $price_type
            ? '+' . $price . '%'
            : '+' . wp_strip_all_tags( wc_price( $price ) );

        return $wrap_span ? ' <span class="cgc-addon-price">' . esc_html( $text ) . '</span>' : ' (' . $text . ')';
    }

    private function render_emails_field_markup( $field ) {
        $description = ! empty( $field['description'] ) ? $field['description'] : '';
        $placeholder = ! empty( $field['placeholder'] ) ? $field['placeholder'] : __( 'Enter email address', 'gift-card-studio' );
        $add_label = ! empty( $field['addLabel'] ) ? $field['addLabel'] : __( '+ Add email address', 'gift-card-studio' );
        $add_another_label = ! empty( $field['addAnotherLabel'] ) ? $field['addAnotherLabel'] : __( '+ Add another', 'gift-card-studio' );

        return '<div class="cgc-field" style="' . esc_attr( $this->style_vars_attr( $field ) ) . '">'
            . '<h4>' . esc_html( $field['label'] ) . '</h4>'
            . ( $description ? '<p>' . esc_html( $description ) . '</p>' : '' )
            . '<template data-wp-each="state.emails" data-wp-each-key="context.item.id">'
            . '<div class="cgc-repeater-item">'
            . '<input type="email" name="cgc-field-' . esc_attr( $field['key'] ) . '[]" placeholder="' . esc_attr( $placeholder ) . '" '
            . 'data-wp-bind--value="context.item.value" data-wp-on--input="actions.updateEmail">'
            . '<button type="button" class="cgc-repeater-remove" data-wp-on--click="actions.removeEmail" '
            . 'data-wp-bind--hidden="!state.canRemoveEmail">&times;</button>'
            . '</div>'
            . '</template>'
            // Two buttons, not one - "Add another" reads oddly before any
            // recipient row exists yet, so the empty-list state gets its own
            // (also site-owner-configurable) label instead.
            . '<button type="button" class="cgc-repeater-add" data-wp-on--click="actions.addEmail" '
            . 'data-wp-bind--hidden="!state.canAddFirstEmail">' . esc_html( $add_label ) . '</button>'
            . '<button type="button" class="cgc-repeater-add" data-wp-on--click="actions.addEmail" '
            . 'data-wp-bind--hidden="!state.canAddAnotherEmail">' . esc_html( $add_another_label ) . '</button>'
            . '</div>';
    }

    private function render_text_field_markup( $field ) {
        $max_length = (int) ( $field['max_length'] ?? 0 );
        $placeholder = esc_attr( $field['placeholder'] ?? '' );
        $field_id = 'cgc-field-' . sanitize_html_class( $field['key'] );
        $context = wp_interactivity_data_wp_context( array(
            'fieldKey' => $field['key'],
            'maxLength' => $max_length,
        ) );

        if ( 'textarea' === $field['type'] ) {
            $input = '<textarea id="' . esc_attr( $field_id ) . '" name="' . esc_attr( $field['key'] ) . '" rows="4" placeholder="' . $placeholder . '" ' . $context . ' '
                . 'data-wp-bind--value="state.fieldValue" data-wp-on--input="actions.updateField"></textarea>';
        } else {
            $type = 'email' === $field['type'] ? 'email' : 'text';
            $input = '<input type="' . $type . '" id="' . esc_attr( $field_id ) . '" name="' . esc_attr( $field['key'] ) . '" placeholder="' . $placeholder . '" ' . $context . ' '
                . 'data-wp-bind--value="state.fieldValue" data-wp-on--input="actions.updateField">';
        }

        return '<div class="cgc-field" style="' . esc_attr( $this->style_vars_attr( $field ) ) . '">'
            . '<label for="' . esc_attr( $field_id ) . '"><h4>' . esc_html( $field['label'] ) . '</h4></label>'
            . $input
            . '</div>';
    }

    private function render_image_field_markup( $field ) {
        $field_key = $field['key'];
        $button_label = ! empty( $field['placeholder'] ) ? $field['placeholder'] : __( 'Upload a photo', 'gift-card-studio' );
        $field_id = 'cgc-field-' . sanitize_html_class( $field_key );
        $context = wp_interactivity_data_wp_context( array( 'fieldKey' => $field_key ) );

        // Reuses the same state.fieldValue getter generic text fields use
        // (it just reads state.fields[context.fieldKey]) - no new getter
        // needed, since an uploaded image's value is just a URL string
        // living in that same map.
        return '<div class="cgc-field" style="' . esc_attr( $this->style_vars_attr( $field ) ) . '">'
            . '<label for="' . esc_attr( $field_id ) . '"><h4>' . esc_html( $field['label'] ) . '</h4></label>'
            . '<input type="file" id="' . esc_attr( $field_id ) . '" name="' . esc_attr( $field_key ) . '" accept="image/*" class="cgc-image-upload-input" ' . $context . ' '
            . 'data-wp-on--change="actions.uploadImage">'
            . '<label for="' . esc_attr( $field_id ) . '" class="cgc-image-upload-label">' . esc_html( $button_label ) . '</label>'
            . '<img class="cgc-image-preview" ' . $context . ' data-wp-bind--src="state.fieldValue" data-wp-bind--hidden="!state.fieldValue" alt="">'
            . '</div>';
    }

    // Public - also called directly by CGC_Elementor_Field::render() (see
    // render_design_picker_markup()'s comment above for why).
    public function render_field_markup( $field ) {
        switch ( $field['type'] ?? 'text' ) {
            case 'amount':
                return $this->render_amount_field_markup( $field );
            case 'products':
                return $this->render_products_field_markup( $field );
            case 'emails':
                return $this->render_emails_field_markup( $field );
            case 'image':
                return $this->render_image_field_markup( $field );
            default:
                return $this->render_text_field_markup( $field );
        }
    }

    // Server-rendered placeholder shown until the Interactivity module has
    // loaded and hydrated (state.hydrated flips via data-wp-init, see
    // app/src/gift-card-view.ts's initHydrated callback). Both this and the
    // real content below are toggled via data-wp-bind--hidden, which
    // WordPress's Interactivity API evaluates server-side against the
    // initial state too - so the correct one is already hidden/visible in
    // the very first HTML response, not just after client JS runs.
    // Public - also called directly by CGC_Elementor_Gift_Card_Form::render()
    // (see render_design_picker_markup()'s comment above).
    public function render_skeleton_markup( $form_fields ) {
        $rows = '<div class="cgc-skeleton-block cgc-skeleton-design"></div>';

        foreach ( $form_fields as $field ) {
            $rows .= '<div class="cgc-skeleton-block cgc-skeleton-field"></div>';
        }

        $rows .= '<div class="cgc-skeleton-block cgc-skeleton-button"></div>';

        return '<div class="cgc-skeleton" data-wp-bind--hidden="state.hydrated" aria-hidden="true">' . $rows . '</div>';
    }

    // Split into two public methods (was one) - the messages area is
    // parent-level system UI (printed once by whichever caller owns the
    // overall form wrapper), while the submit button itself is what
    // CGC_Elementor_Submit_Button::render() calls independently (see
    // render_design_picker_markup()'s comment above).
    public function render_messages_markup() {
        return '<div class="cgc-messages">'
            . '<p class="cgc-error" data-wp-bind--hidden="!state.hasError" data-wp-text="state.error"></p>'
            . '<p class="cgc-success" data-wp-bind--hidden="!state.hasSuccess" data-wp-text="state.success"></p>'
            . '</div>';
    }

    public function render_submit_button_markup( $submit_button_text ) {
        return '<button type="button" class="cgc-submit" data-wp-on--click="actions.submit" data-wp-bind--disabled="state.loading">'
            // Hidden by default server-side (state.loading starts false) so
            // there's no spinner flash before hydration - toggled reactively
            // once JS takes over.
            . '<span class="cgc-submit-spinner" aria-hidden="true" hidden data-wp-bind--hidden="!state.loading"></span>'
            . '<span data-wp-text="state.submitButtonLabel">' . esc_html( $submit_button_text ) . '</span>'
            . '</button>';
    }

    // Gutenberg-specific adapter: the form's real configuration (which designs
    // are offered, which fields appear and in what order, the submit button
    // text) lives on the InnerBlocks placed inside this block in the editor,
    // not on this block's own attributes - reconstruct the same shape by
    // walking them in saved order, then hand off to the shared renderer
    // (render_form()) that also serves the Elementor widget - see
    // includes/elementor/class-cgc-elementor-gift-card-form.php.
    public function render_gift_card_form( $attributes, $content, $block ) {
        $design_ids = array();
        $form_fields = array();
        $submit_button_text = 'Continue to Payment';
        $submit_loading_text = 'Processing…';
        $choose_label = 'Choose a Design';
        $select_label = 'Select Design';
        $selected_label = 'Selected';

        foreach ( $block->inner_blocks as $inner_block ) {
            switch ( $inner_block->name ) {
                case 'cgc/gift-card-design-picker':
                    $design_ids = $inner_block->attributes['designIds'] ?? array();
                    $choose_label = $inner_block->attributes['chooseLabel'] ?? $choose_label;
                    $select_label = $inner_block->attributes['selectLabel'] ?? $select_label;
                    $selected_label = $inner_block->attributes['selectedLabel'] ?? $selected_label;
                    break;

                case 'cgc/gift-card-field':
                    $form_fields[] = $inner_block->attributes;
                    break;

                case 'cgc/gift-card-submit-button':
                    $submit_button_text = $inner_block->attributes['text'] ?? $submit_button_text;
                    $submit_loading_text = $inner_block->attributes['loadingText'] ?? $submit_loading_text;
                    break;
            }
        }

        return $this->render_form( array(
            'design_ids' => $design_ids,
            'form_fields' => $form_fields,
            'submit_button_text' => $submit_button_text,
            'submit_loading_text' => $submit_loading_text,
            'choose_label' => $choose_label,
            'select_label' => $select_label,
            'selected_label' => $selected_label,
            'styles' => $attributes['styles'] ?? array(),
            'messages' => $attributes['messages'] ?? array(),
        ) );
    }

    // Resolves ONE raw field's attributes into what render_field_markup()
    // expects: Pro-gating downgrade, style overrides, resolved WooCommerce
    // product detail, and pre-substituted required-field messages. Public -
    // used both by setup_form_state()'s per-field loop below (Gutenberg /
    // the full render_form() path) AND independently by
    // CGC_Elementor_Field::render() (an Elementor nested-element child
    // needs its own independently callable render() - see
    // render_design_picker_markup()'s comment above).
    public function resolve_field_for_render( array $field, array $styles, array $messages, $is_pro ) {
        if ( ! $is_pro ) {
            // Pro-only field types silently downgrade to plain text -
            // protects both a tampered request and a license that lapsed
            // after content was already configured with these types. Runs
            // before anything below reads $field['type'] or
            // $field['styleOverrides'], so downgraded fields never get
            // products resolved, product-specific messages built, etc.
            if ( in_array( $field['type'] ?? '', array( 'products', 'emails', 'image' ), true ) ) {
                $field['type'] = 'text';
            }

            // Custom "other" amount and per-field style overrides are also
            // Pro-only.
            unset( $field['allow_other'] );
            unset( $field['styleOverrides'] );
        }

        $overrides = $field['styleOverrides'] ?? array();
        if ( ! empty( $overrides['accentColor'] ) || ! empty( $overrides['accentTextColor'] ) ) {
            $field['styleVars'] = array(
                '--accent' => $this->hex_to_hsl_triplet( $overrides['accentColor'] ?? $styles['accentColor'] ),
                '--accent-foreground' => $this->hex_to_hsl_triplet( $overrides['accentTextColor'] ?? $styles['accentTextColor'] ),
            );
        }

        // Resolve the site owner's chosen WooCommerce products into full
        // detail (including variation attributes/prices) here, server-side,
        // so the frontend never needs its own WC REST call.
        if ( 'products' === ( $field['type'] ?? '' ) && ! empty( $field['productIds'] ) ) {
            global $cgc_fetch;
            if ( $cgc_fetch ) {
                $field['products'] = $cgc_fetch->get_products_by_ids( $field['productIds'] );
            }
        }

        // Pre-substitute the field's label into the shared message
        // templates here (server-side), so the client never needs its own
        // sprintf-equivalent for these - it just assigns the already-
        // resolved string to state.error.
        $field['requiredMessage'] = sprintf( $messages['fieldRequired'], $field['label'] ?? '' );
        if ( 'products' === ( $field['type'] ?? '' ) ) {
            $field['productRequiredMessage'] = sprintf( $messages['productRequired'], $field['label'] ?? '' );
            $field['variationRequiredMessage'] = sprintf( $messages['variationRequired'], $field['label'] ?? '' );
            $field['addonRequiredMessage'] = sprintf( $messages['addonRequired'], $field['label'] ?? '' );
        }

        return $field;
    }

    // Registers/enqueues the form's style and script handles only - no
    // per-request data (that still happens in setup_form_state() below via
    // wp_localize_script/wp_interactivity_state/config, which is safe to
    // call after enqueuing regardless of when enqueuing happened). Split out
    // so this can also be called EARLY (before wp_head() prints), which
    // setup_form_state() alone can't guarantee: Elementor's editor preview
    // often renders this form's widgets via an AJAX partial-render (dragging
    // a widget in fresh, or live-editing a control) rather than a full page
    // load, and wp_enqueue_style()/_script() calls made at that point are too
    // late to ever reach the already-sent <head> - see
    // class-cgc-elementor.php's wp_enqueue_scripts hook, which calls this
    // unconditionally whenever Elementor is in edit/preview mode.
    public function enqueue_form_frontend_assets() {
        wp_register_script_module(
            'cgc/gift-card-form-view',
            CGC_PLUGIN_URL . 'build/gift-card-view/index.js',
            array( '@wordpress/interactivity' ),
            CGC_PLUGIN_VERSION
        );
        wp_enqueue_script_module( 'cgc/gift-card-form-view' );
        wp_enqueue_style( 'cgc-gift-card-view', CGC_PLUGIN_URL . 'assets/gift-card-view.css', array(), CGC_PLUGIN_VERSION );
        // Swaps in assets/gift-card-view-rtl.css on RTL locales (eg. Hebrew) -
        // same convention core uses for its own stylesheets, just done
        // manually here since this file isn't registered via block.json's
        // "style" key (that would only ever load in wp-admin, not here).
        wp_style_add_data( 'cgc-gift-card-view', 'rtl', 'replace' );

        $canvas_asset = include( CGC_PLUGIN_PATH . 'build/gift-card-form/index.asset.php' );

        if ( is_array( $canvas_asset ) ) {
            wp_enqueue_script( 'cgc-gift-card-canvas', CGC_PLUGIN_URL . 'build/gift-card-form/index.js', $canvas_asset['dependencies'], CGC_PLUGIN_VERSION, true );
            wp_set_script_translations( 'cgc-gift-card-canvas', 'gift-card-studio', CGC_PLUGIN_PATH . 'languages' );
            wp_enqueue_style( 'cgc-gift-card-canvas', CGC_PLUGIN_URL . 'build/gift-card-form.css', array(), CGC_PLUGIN_VERSION );
            wp_enqueue_style( 'cgc-fonts', CGC_PLUGIN_URL . 'assets/fonts.css', array(), CGC_PLUGIN_VERSION );

            // Unlike the two admin-only enqueue points for this same canvas
            // bundle, this one also runs on the public checkout page - so
            // this check (unlike those) is load-bearing, not just
            // belt-and-suspenders: it keeps the whole media library JS
            // framework off the page for every logged-out shopper, only
            // pulling it in for the admin-only "Page" tab that can actually
            // use it (see side-bar.tsx's is_admin gate on that tab).
            if ( current_user_can( 'administrator' ) ) {
                wp_enqueue_media();
            }
        }
    }

    // Takes an already-assembled, plain-array form configuration (design
    // ids, raw per-field attribute arrays, submit button/label text, style
    // and message overrides) and does everything except building the final
    // markup: resolving starter templates, resolving every field via
    // resolve_field_for_render(), wiring the Interactivity API state/config,
    // and enqueuing assets. Returns an array with an 'error' key (a
    // user-facing message, when there's nothing to render) or the full
    // resolved state otherwise. Public - called by both render_form()
    // (Gutenberg's complete single-pass render) and
    // CGC_Elementor_Gift_Card_Form::render() (which builds markup itself by
    // interleaving print_child() calls between children instead - see that
    // class for why).
    public function setup_form_state( array $config ) {
        global $cgc_fetch;
        if ( ! $cgc_fetch ) {
            return array( 'error' => '' );
        }

        $styles = wp_parse_args( $config['styles'] ?? array(), array(
            'primaryColor' => '#0f172a',
            'primaryTextColor' => '#f8fafc',
            'accentColor' => '#f1f5f9',
            'accentTextColor' => '#0f172a',
            'backgroundColor' => '#ffffff',
            'borderColor' => '#e2e8f0',
            'radius' => 8,
        ) );

        $messages = wp_parse_args( $config['messages'] ?? array(), array(
            'designRequired' => 'Please select a design for your gift card.',
            'invalidAmount' => 'Please enter a valid amount.',
            'fieldRequired' => 'Please enter a value for "%s".',
            'productRequired' => 'Please select an option for "%s".',
            'variationRequired' => 'Please choose all options for "%s".',
            'addonRequired' => 'Please complete all required options for "%s".',
            'emailsRequired' => 'Please enter at least one email address.',
            'invalidEmail' => 'Please enter a valid email address. This is not valid: %s',
            'successMessage' => 'All Good. Proceeding to Payment',
            'genericError' => 'An error occurred while adding to cart.',
        ) );

        $design_ids = $config['design_ids'] ?? array();
        $form_fields = $config['form_fields'] ?? array();
        $submit_button_text = $config['submit_button_text'] ?? 'Continue to Payment';
        $submit_loading_text = $config['submit_loading_text'] ?? 'Processing…';
        $choose_label = $config['choose_label'] ?? 'Choose a Design';
        $select_label = $config['select_label'] ?? 'Select Design';
        $selected_label = $config['selected_label'] ?? 'Selected';

        $starter_templates = $design_ids
            ? $cgc_fetch->get_designs_by_ids( $design_ids )
            : $cgc_fetch->get_starter_templates();

        if ( ! $starter_templates ) {
            return array( 'error' => esc_html__( 'No gift card designs are available right now. Please check back soon.', 'gift-card-studio' ) );
        }

        $has_amount_field = false;
        $default_amount = 0;
        $default_selected_amount = '';
        $default_field_values = array();
        $card_placeholders = array();
        $is_pro = cgc_is_pro_active();

        foreach ( $form_fields as &$field ) {
            $field = $this->resolve_field_for_render( $field, $styles, $messages, $is_pro );

            if ( 'amount' === ( $field['type'] ?? '' ) ) {
                $has_amount_field = true;
                if ( ! empty( $field['options'] ) ) {
                    $default_amount = (float) $field['options'][0];
                    $default_selected_amount = (string) $default_amount;
                }
            }

            if ( in_array( $field['type'] ?? 'text', array( 'text', 'textarea', 'email', 'image' ), true ) ) {
                if ( ! empty( $field['defaultValue'] ) ) {
                    $default_field_values[ $field['key'] ] = $field['defaultValue'];
                } elseif ( ! empty( $field['placeholder'] ) ) {
                    // Cosmetic only - a nicer stand-in than a raw "{key}"
                    // token on the card before the customer types anything.
                    // Kept separate from $default_field_values: this must
                    // NOT count as a real submitted value (required-field
                    // validation, checkout data) the way defaultValue does.
                    $card_placeholders[ $field['key'] ] = $field['placeholder'];
                }
            }
        }
        unset( $field );

        // Reserved tokens (see includes/class-cgc-woocommerce.php) that only
        // resolve to a real value after payment completes - shown as
        // cosmetic placeholder text during live fill-out instead of a raw
        // literal "{coupon_code}"/"{expire_date}" token.
        $card_placeholders['coupon_code'] = 'COUPON CODE';
        $card_placeholders['expire_date'] = 'Valid thru MM/DD/YYYY';

        // Initial mutable state - the customer's interactions update this
        // client-side from here on (see app/src/gift-card-view.ts). Seeding
        // "fields" from each field's defaultValue means the card already
        // shows them on first render, before the customer types anything -
        // wp_interactivity_state()'s serialization needs an actual object
        // (stdClass), not an empty PHP array, or it'd serialize as JSON `[]`
        // instead of `{}` and break state.fields[key] assignment client-side.
        wp_interactivity_state( 'cgc/gift-card-form', array(
            'amount' => $default_amount,
            'selectedAmount' => $default_selected_amount,
            'selectedProduct' => null,
            'selectedDesign' => 1 === count( $starter_templates ) ? (int) $starter_templates[0]['card_id'] : 0,
            'emails' => array(),
            'fields' => $default_field_values ?: new stdClass(),
            'loading' => false,
            'error' => '',
            'success' => '',
            // Flips true via data-wp-init once the client module hydrates -
            // starts false so the skeleton (not the real form) is what the
            // very first HTML response shows.
            'hydrated' => false,
        ) );

        // Read-only config - mirrors what wp_localize_script's "appData" used
        // to carry for the old React app.
        wp_interactivity_config( 'cgc/gift-card-form', array(
            'restUrl' => rest_url( 'cgc/v2' ),
            'nonce' => wp_create_nonce( 'wp_rest' ),
            'isAdmin' => current_user_can( 'administrator' ),
            'embedded' => false,
            'siteUrl' => site_url(),
            'formFields' => $form_fields,
            'starterTemplates' => $starter_templates,
            'submitButtonText' => $submit_button_text,
            'submitLoadingText' => $submit_loading_text,
            'hasAmountField' => $has_amount_field,
            'messages' => $messages,
            'selectLabel' => $select_label,
            'selectedLabel' => $selected_label,
        ) );

        $this->enqueue_form_frontend_assets();

        // Fabric.js canvas bootstrap - still a classic React bundle (canvas
        // drawing is inherently imperative JS, not directive-expressible),
        // but far smaller now that it no longer carries the whole form's
        // fields/state/Tailwind/shadcn machinery. Mounts one canvas per
        // starter template into the ".cgc-canvas-mount" points below.
        $canvas_asset = include( CGC_PLUGIN_PATH . 'build/gift-card-form/index.asset.php' );

        if ( is_array( $canvas_asset ) ) {
            wp_localize_script( 'cgc-gift-card-canvas', 'appData', array(
                'rest_url' => rest_url( 'cgc/v2' ),
                'nonce' => wp_create_nonce( 'wp_rest' ),
                'site_url' => site_url(),
                'plugin_url' => CGC_PLUGIN_URL,
                'is_admin' => current_user_can( 'administrator' ),
                'embedded' => false,
                'starterTemplates' => $starter_templates,
                // Seeds the canvas's initial field-value state directly (not
                // via the cgc-fields-updated bridge event) so defaults show
                // up on the very first render, with no dependency on script
                // load/execution order between this bundle and the
                // Interactivity store's own module.
                'defaultFieldValues' => $default_field_values,
                // Cosmetic stand-in text shown on the card for a bound
                // {fieldKey} token that has no real value yet - falls back
                // to placeholder text instead of the raw literal token.
                'cardPlaceholders' => $card_placeholders,
            ) );
        }

        return array(
            'error' => null,
            'styles' => $styles,
            'messages' => $messages,
            'form_fields' => $form_fields,
            'starter_templates' => $starter_templates,
            'choose_label' => $choose_label,
            'select_label' => $select_label,
            'selected_label' => $selected_label,
            'submit_button_text' => $submit_button_text,
            'submit_loading_text' => $submit_loading_text,
            'style_attr' => esc_attr( $this->build_style_vars( $styles ) ),
        );
    }

    // Shared renderer - builds the complete form markup in one pass. Used
    // by render_gift_card_form() (Gutenberg, whose InnerBlocks children have
    // no independent render of their own) - CGC_Elementor_Gift_Card_Form
    // uses setup_form_state() directly instead and builds markup by
    // interleaving print_child() calls, since Elementor's nested-element
    // children each need their own independently callable render() (see
    // that class for the full explanation).
    public function render_form( array $config ) {
        $state = $this->setup_form_state( $config );

        if ( ! empty( $state['error'] ) ) {
            return '<p>' . $state['error'] . '</p>';
        }

        if ( null === $state['error'] ) {
            $markup = '<div class="cgc-gift-card-form" data-wp-interactive="cgc/gift-card-form" data-wp-init="callbacks.initHydrated" style="' . $state['style_attr'] . '">';

            $markup .= $this->render_skeleton_markup( $state['form_fields'] );

            $markup .= '<div class="cgc-form-content" data-wp-bind--hidden="!state.hydrated">';
            $markup .= $this->render_design_picker_markup( $state['starter_templates'], $state['choose_label'] );

            foreach ( $state['form_fields'] as $field ) {
                $markup .= $this->render_field_markup( $field );
            }

            $markup .= $this->render_messages_markup();
            $markup .= $this->render_submit_button_markup( $state['submit_button_text'] );
            $markup .= '</div>';
            $markup .= '</div>';

            return $markup;
        }

        return '';
    }
}


global $cgc_blocks;
$cgc_blocks = new CGC_Blocks();
