<?php

/**
 * Cart behaviour: rendering, AJAX toggling, fees, and order meta.
 *
 * @package CartGiftWrapping
 */
defined( 'ABSPATH' ) || exit;
/**
 * Class CGWFW_Cart
 */
class CGWFW_Cart {
    /**
     * Settings helper.
     *
     * @var CGWFW_Settings
     */
    private $settings;

    /**
     * Constructor.
     *
     * @param CGWFW_Settings $settings Settings helper.
     */
    public function __construct( $settings ) {
        $this->settings = $settings;
        // Per-item button under each cart line item.
        add_action(
            'woocommerce_after_cart_item_name',
            array($this, 'render_item_button'),
            10,
            2
        );
        // Per-order option placement is decided on init, not here: it reads
        // options whose defaults carry translated strings, and this constructor
        // runs on plugins_loaded — translating there trips WP 6.7+'s
        // "translation loading triggered too early" notice. The template hooks
        // involved fire long after init, so nothing is missed.
        add_action( 'init', array($this, 'register_order_hooks') );
        // AJAX handlers (logged in + guests).
        add_action( 'wp_ajax_cgwfw_toggle', array($this, 'ajax_toggle') );
        add_action( 'wp_ajax_nopriv_cgwfw_toggle', array($this, 'ajax_toggle') );
        // Show wrap status + gift message under each item (cart AND checkout review).
        add_filter(
            'woocommerce_get_item_data',
            array($this, 'item_data'),
            10,
            2
        );
        // Add the wrapping fee.
        add_action( 'woocommerce_cart_calculate_fees', array($this, 'add_fee') );
        // Persist wrap state into the order. The classic checkout fires
        // woocommerce_checkout_create_order; the block checkout (Store API)
        // does NOT — it fires woocommerce_store_api_checkout_update_order_meta
        // instead, so both are hooked. Only one fires per checkout.
        add_action(
            'woocommerce_checkout_create_order',
            array($this, 'save_order_meta'),
            10,
            2
        );
        add_action( 'woocommerce_store_api_checkout_update_order_meta', array($this, 'save_order_meta_store_api') );
        // Per-item wrap meta is attached per order line, keyed by cart item key,
        // so it lands on the right line even when the same product appears in
        // several cart lines (variations, personalisation, wrapped + unwrapped).
        add_action(
            'woocommerce_checkout_create_order_line_item',
            array($this, 'save_line_item_meta'),
            10,
            4
        );
        // Hide our internal, non-translatable line meta from the admin order
        // screen. It is already auto-hidden everywhere that respects the leading
        // underscore (emails, front-end, PDF), but the admin order items box uses
        // woocommerce_hidden_order_itemmeta instead.
        add_filter( 'woocommerce_hidden_order_itemmeta', array($this, 'hide_internal_item_meta') );
        // Show gift messages on the order screen / emails. The email hook also
        // fires in the plain-text templates, so it passes the extra args that
        // let display_order_meta() switch to tag-free output.
        add_action( 'woocommerce_admin_order_data_after_billing_address', array($this, 'display_admin_order_meta') );
        add_action( 'woocommerce_order_details_after_order_table', array($this, 'display_order_meta') );
        add_action(
            'woocommerce_email_after_order_table',
            array($this, 'display_order_meta'),
            10,
            3
        );
        // Clean up wrapped keys when items leave the cart.
        add_action(
            'woocommerce_cart_item_removed',
            array($this, 'on_item_removed'),
            10,
            2
        );
        add_action( 'woocommerce_cart_emptied', array($this, 'on_cart_emptied') );
    }

    /**
     * Hook the per-order option into the cart/checkout, per the display style
     * and "show on" settings. Runs on init (see the constructor note).
     *
     * @return void
     */
    public function register_order_hooks() {
        $show_on = $this->settings->get( 'order_show_on' );
        $on_cart = in_array( $show_on, array('both', 'cart'), true );
        $on_checkout = in_array( $show_on, array('both', 'checkout'), true );
        // Checkbox sits inside the totals/review table; the card sits just above
        // the cart-totals box, and inside the checkout order-review column (before
        // the payment box) — a full-width block there, rather than wedged in the
        // gap between the checkout's floated columns, where it collapses to a
        // sliver (woocommerce_checkout_before_order_review fires outside #order_review).
        if ( 'card' === $this->settings->get( 'order_style' ) ) {
            if ( $on_cart ) {
                add_action( 'woocommerce_before_cart_totals', array($this, 'render_order_card') );
            }
            if ( $on_checkout ) {
                add_action( 'woocommerce_review_order_before_payment', array($this, 'render_order_card') );
            }
        } else {
            if ( $on_cart ) {
                add_action( 'woocommerce_cart_totals_before_order_total', array($this, 'render_order_option') );
            }
            if ( $on_checkout ) {
                add_action( 'woocommerce_review_order_before_order_total', array($this, 'render_order_option') );
            }
        }
    }

    /**
     * Get the wrapped-items map from session: [ cart_item_key => message ].
     *
     * @return array
     */
    private function get_wrapped() {
        if ( !WC()->session ) {
            return array();
        }
        $wrapped = WC()->session->get( 'cgwfw_wrapped', array() );
        if ( !is_array( $wrapped ) ) {
            return array();
        }
        // Normalise to [ key => array( 'option' => id, 'message' => str ) ].
        $out = array();
        foreach ( $wrapped as $key => $data ) {
            if ( is_array( $data ) ) {
                $out[$key] = array(
                    'option'  => ( isset( $data['option'] ) ? (string) $data['option'] : '' ),
                    'message' => ( isset( $data['message'] ) ? (string) $data['message'] : '' ),
                );
            } else {
                // Legacy value was just the message string.
                $out[$key] = array(
                    'option'  => '',
                    'message' => (string) $data,
                );
            }
        }
        return $out;
    }

    /**
     * Save the wrapped-items map to session.
     *
     * @param array $wrapped Map.
     * @return void
     */
    private function set_wrapped( $wrapped ) {
        if ( WC()->session ) {
            WC()->session->set( 'cgwfw_wrapped', $wrapped );
        }
    }

    /**
     * Per-order wrap state: [ 'enabled' => bool, 'message' => string ].
     *
     * @return array
     */
    private function get_order_wrap() {
        $defaults = array(
            'enabled' => false,
            'message' => '',
            'option'  => '',
        );
        if ( !WC()->session ) {
            return $defaults;
        }
        $data = WC()->session->get( 'cgwfw_order_wrap', $defaults );
        return ( is_array( $data ) ? wp_parse_args( $data, $defaults ) : $defaults );
    }

    /**
     * Save per-order wrap state.
     *
     * @param array $data State.
     * @return void
     */
    private function set_order_wrap( $data ) {
        if ( WC()->session ) {
            WC()->session->set( 'cgwfw_order_wrap', $data );
        }
    }

    /**
     * Public accessor for the per-order wrap state (used by the blocks
     * integration, which renders an order-level control in the block cart).
     *
     * @return array
     */
    public function get_order_state() {
        return $this->get_order_wrap();
    }

    /**
     * Public setter for the per-order wrap state, with sanitising + message
     * length capping. Used by the Store API update callback.
     *
     * @param bool   $enabled Whether wrapping is enabled.
     * @param string $message Gift message.
     * @return void
     */
    public function set_order_state( $enabled, $message = '', $option = '' ) {
        $enabled = (bool) $enabled;
        $message = sanitize_textarea_field( (string) $message );
        $option = sanitize_key( (string) $option );
        // Ignore any submitted message when gift messages are disabled.
        if ( 'yes' !== $this->settings->get( 'enable_message' ) ) {
            $message = '';
        }
        $limit = (int) $this->settings->get( 'message_limit' );
        if ( $limit > 0 && function_exists( 'mb_substr' ) ) {
            $message = mb_substr( $message, 0, $limit );
        }
        $this->set_order_wrap( array(
            'enabled' => $enabled,
            'message' => ( $enabled ? $message : '' ),
            'option'  => ( $enabled ? $option : '' ),
        ) );
        // The Store API recalculates totals after update callbacks, so no
        // explicit calculate_totals() call is needed here.
    }

    /**
     * Public accessor: does the cart hold at least one eligible item?
     *
     * @return bool
     */
    public function has_eligible_item() {
        return $this->cart_has_eligible_item();
    }

    /**
     * Record a per-item wrap against a cart item key. Used to bridge a choice
     * made on the product page into the same session map the cart UI, fee
     * calculation and order meta already read from.
     *
     * Sanitising mirrors ajax_toggle(): the message is trimmed to the limit and,
     * in design mode, a valid design must be supplied or the call is ignored.
     *
     * @param string $cart_item_key Cart item key.
     * @param string $option        Selected wrap design id (design mode).
     * @param string $message       Gift message.
     * @return void
     */
    public function set_item_wrap( $cart_item_key, $option = '', $message = '' ) {
        if ( !WC()->session ) {
            return;
        }
        $option = sanitize_key( (string) $option );
        $message = sanitize_textarea_field( (string) $message );
        $limit = (int) $this->settings->get( 'message_limit' );
        if ( $limit > 0 && function_exists( 'mb_substr' ) ) {
            $message = mb_substr( $message, 0, $limit );
        }
        $wrapped = $this->get_wrapped();
        $wrapped[$cart_item_key] = array(
            'option'  => $option,
            'message' => ( 'yes' === $this->settings->get( 'enable_message' ) ? $message : '' ),
        );
        $this->set_wrapped( $wrapped );
    }

    /**
     * The "Gift wrapped" value to display: the chosen design name in design
     * mode, otherwise "Yes".
     *
     * @param string $option_id Selected design id.
     * @return string
     */
    private function wrap_value_label( $option_id ) {
        return __( 'Yes', 'tinyplugs-gift-wrapping' );
    }

    /**
     * Build the info-tooltip markup (a focusable "?" icon whose text shows on
     * hover/focus via CSS). Returns an empty string when disabled or empty.
     * The output is fully escaped and safe to echo directly.
     *
     * @return string
     */
    public function tooltip_html() {
        if ( 'yes' !== $this->settings->get( 'enable_tooltip' ) ) {
            return '';
        }
        $text = trim( (string) $this->settings->get( 'tooltip_text' ) );
        if ( function_exists( 'mb_substr' ) ) {
            $text = mb_substr( $text, 0, 200 );
        }
        if ( '' === $text ) {
            return '';
        }
        return sprintf( '<span class="cgwfw-tip" tabindex="0" role="img" aria-label="%1$s" data-cgwfw-tip="%1$s">?</span>', esc_attr( $text ) );
    }

    /**
     * Echo the info tooltip, escaped through wp_kses at the point of output.
     *
     * @return void
     */
    public function render_tooltip() {
        echo wp_kses( $this->tooltip_html(), array(
            'span' => array(
                'class'          => true,
                'tabindex'       => true,
                'role'           => true,
                'aria-label'     => true,
                'data-cgwfw-tip' => true,
            ),
        ) );
    }

    /**
     * Render the optional gift-message field.
     *
     * @param bool   $visible Whether it starts visible.
     * @param string $message Current message.
     * @return void
     */
    private function render_message_field( $visible, $message ) {
        if ( 'yes' !== $this->settings->get( 'enable_message' ) ) {
            return;
        }
        $limit = (int) $this->settings->get( 'message_limit' );
        ?>
		<div class="cgwfw-message-wrap"<?php 
        echo ( $visible ? '' : ' style="display:none;"' );
        ?>>
			<label class="cgwfw-message-label">
				<?php 
        echo esc_html( $this->settings->get( 'message_label' ) );
        ?>
				<textarea class="cgwfw-message" rows="2" <?php 
        echo ( $limit > 0 ? 'maxlength="' . esc_attr( $limit ) . '"' : '' );
        ?>><?php 
        echo esc_textarea( $message );
        ?></textarea>
			</label>
		</div>
		<?php 
    }

    /**
     * Render the per-item gift wrap button beneath the cart item name.
     *
     * @param array  $cart_item     Cart item.
     * @param string $cart_item_key Cart item key.
     * @return void
     */
    public function render_item_button( $cart_item, $cart_item_key ) {
        if ( 'per_item' !== $this->settings->get( 'mode' ) ) {
            return;
        }
        if ( is_checkout() ) {
            return;
        }
        if ( !$this->cart_item_eligible( $cart_item ) ) {
            return;
        }
        $wrapped = $this->get_wrapped();
        $is_wrapped = isset( $wrapped[$cart_item_key] );
        $entry = ( $is_wrapped ? $wrapped[$cart_item_key] : array(
            'option'  => '',
            'message' => '',
        ) );
        $message = $entry['message'];
        $selected = $entry['option'];
        $enable_message = 'yes' === $this->settings->get( 'enable_message' );
        $limit = (int) $this->settings->get( 'message_limit' );
        // Pro: design mode renders the selectable design picker instead of a
        // button; the switch/icon display styles and the small button size are
        // Pro display options.
        $style = 'button';
        $size_class = '';
        $label = ( $is_wrapped ? $this->settings->get( 'button_label_added' ) : $this->settings->get( 'button_label' ) );
        $fee_display = wc_price( $this->settings->get_fee() );
        ?>
		<div class="cgwfw-wrap cgwfw-style-<?php 
        echo esc_attr( $style );
        echo ( $is_wrapped ? ' is-wrapped' : '' );
        ?>" data-key="<?php 
        echo esc_attr( $cart_item_key );
        ?>">
			<?php 
        $rendered = false;
        if ( !$rendered ) {
            ?>
				<button type="button"
					class="cgwfw-btn<?php 
            echo ( $is_wrapped ? ' is-wrapped' : '' );
            echo esc_attr( $size_class );
            ?>"
					data-label="<?php 
            echo esc_attr( $this->settings->get( 'button_label' ) );
            ?>"
					data-label-added="<?php 
            echo esc_attr( $this->settings->get( 'button_label_added' ) );
            ?>"
					aria-pressed="<?php 
            echo ( $is_wrapped ? 'true' : 'false' );
            ?>">
					<?php 
            ?>
					<span class="cgwfw-btn-label"><?php 
            echo esc_html( $label );
            ?></span>
					<span class="cgwfw-btn-fee">(<?php 
            echo wp_kses_post( $fee_display );
            ?>)</span>
				</button>
			<?php 
        }
        ?>
			<?php 
        $this->render_tooltip();
        ?>
			<?php 
        if ( $enable_message ) {
            ?>
				<div class="cgwfw-message-wrap"<?php 
            echo ( $is_wrapped ? '' : ' style="display:none;"' );
            ?>>
					<label class="cgwfw-message-label">
						<?php 
            echo esc_html( $this->settings->get( 'message_label' ) );
            ?>
						<textarea class="cgwfw-message" rows="2"
							<?php 
            echo ( $limit > 0 ? 'maxlength="' . esc_attr( $limit ) . '"' : '' );
            ?>
							placeholder=""><?php 
            echo esc_textarea( $message );
            ?></textarea>
					</label>
				</div>
			<?php 
        }
        ?>
		</div>
		<?php 
    }

    /**
     * Render the single per-order wrap option below the cart totals.
     *
     * @return void
     */
    public function render_order_option() {
        if ( 'per_order' !== $this->settings->get( 'mode' ) ) {
            return;
        }
        // Only offer if at least one cart item is eligible.
        if ( !$this->cart_has_eligible_item() ) {
            return;
        }
        $state = $this->get_order_wrap();
        $is_wrapped = !empty( $state['enabled'] );
        $message = $state['message'];
        $enable_message = 'yes' === $this->settings->get( 'enable_message' );
        $limit = (int) $this->settings->get( 'message_limit' );
        $fee_display = wc_price( $this->settings->get_fee() );
        ?>
		<tr class="cgwfw-order-row">
			<th colspan="2">
				<div class="cgwfw-wrap cgwfw-order" data-key="__order__">
					<label class="cgwfw-order-toggle">
						<input type="checkbox" class="cgwfw-order-check" <?php 
        checked( $is_wrapped );
        ?> />
						<?php 
        echo esc_html( $this->settings->get( 'order_label' ) );
        ?>
						<span class="cgwfw-btn-fee">(<?php 
        echo wp_kses_post( $fee_display );
        ?>)</span>
					</label>
					<?php 
        $this->render_tooltip();
        ?>
					<?php 
        if ( $enable_message ) {
            ?>
						<div class="cgwfw-message-wrap"<?php 
            echo ( $is_wrapped ? '' : ' style="display:none;"' );
            ?>>
							<label class="cgwfw-message-label">
								<?php 
            echo esc_html( $this->settings->get( 'message_label' ) );
            ?>
								<textarea class="cgwfw-message" rows="2"
									<?php 
            echo ( $limit > 0 ? 'maxlength="' . esc_attr( $limit ) . '"' : '' );
            ?>><?php 
            echo esc_textarea( $message );
            ?></textarea>
							</label>
						</div>
					<?php 
        }
        ?>
				</div>
			</th>
		</tr>
		<?php 
    }

    /**
     * Render the per-order option as a promo card (icon, title, subtitle,
     * price, add button). Shown above the cart totals box in per-order mode
     * when the "card" display style is selected.
     *
     * @return void
     */
    public function render_order_card() {
        if ( 'per_order' !== $this->settings->get( 'mode' ) ) {
            return;
        }
        if ( !$this->cart_has_eligible_item() ) {
            return;
        }
        $state = $this->get_order_wrap();
        $is_wrapped = !empty( $state['enabled'] );
        $message = $state['message'];
        $enable_message = 'yes' === $this->settings->get( 'enable_message' );
        $limit = (int) $this->settings->get( 'message_limit' );
        $fee_display = wc_price( $this->settings->get_fee() );
        $add_label = $this->settings->get( 'card_add_label' );
        $added_label = $this->settings->get( 'card_added_label' );
        $btn_label = ( $is_wrapped ? $added_label : $add_label );
        ?>
		<div class="cgwfw-wrap cgwfw-card cgwfw-order<?php 
        echo ( $is_wrapped ? ' is-wrapped' : '' );
        ?>" data-key="__order__">
			<div class="cgwfw-card-main">
				<span class="cgwfw-card-icon" aria-hidden="true">
					<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
						<polyline points="20 12 20 22 4 22 4 12"></polyline>
						<rect x="2" y="7" width="20" height="5"></rect>
						<line x1="12" y1="22" x2="12" y2="7"></line>
						<path d="M12 7H7.5a2.5 2.5 0 0 1 0-5C11 2 12 7 12 7z"></path>
						<path d="M12 7h4.5a2.5 2.5 0 0 0 0-5C13 2 12 7 12 7z"></path>
					</svg>
				</span>
				<div class="cgwfw-card-body">
					<div class="cgwfw-card-title"><?php 
        echo esc_html( $this->settings->get( 'order_label' ) );
        $this->render_tooltip();
        ?></div>
					<div class="cgwfw-card-sub"><?php 
        echo esc_html( $this->settings->get( 'order_subtitle' ) );
        ?></div>
					<div class="cgwfw-card-price"><?php 
        echo wp_kses_post( $fee_display );
        ?></div>
				</div>
				<button type="button"
					class="cgwfw-btn cgwfw-card-btn<?php 
        echo ( $is_wrapped ? ' is-wrapped' : '' );
        ?>"
					data-label="<?php 
        echo esc_attr( $add_label );
        ?>"
					data-label-added="<?php 
        echo esc_attr( $added_label );
        ?>"
					aria-pressed="<?php 
        echo ( $is_wrapped ? 'true' : 'false' );
        ?>">
					<span class="cgwfw-btn-label"><?php 
        echo esc_html( $btn_label );
        ?></span>
				</button>
			</div>
			<?php 
        if ( $enable_message ) {
            ?>
				<div class="cgwfw-message-wrap"<?php 
            echo ( $is_wrapped ? '' : ' style="display:none;"' );
            ?>>
					<label class="cgwfw-message-label">
						<?php 
            echo esc_html( $this->settings->get( 'message_label' ) );
            ?>
						<textarea class="cgwfw-message" rows="2"
							<?php 
            echo ( $limit > 0 ? 'maxlength="' . esc_attr( $limit ) . '"' : '' );
            ?>><?php 
            echo esc_textarea( $message );
            ?></textarea>
					</label>
				</div>
			<?php 
        }
        ?>
		</div>
		<?php 
    }

    /**
     * Display the wrap status and gift message under each cart item. This
     * surfaces it in the cart AND the checkout order-review table (which the
     * per-item button does not reach, since it is hidden on checkout).
     *
     * @param array $item_data Existing item data rows.
     * @param array $cart_item Cart item.
     * @return array
     */
    public function item_data( $item_data, $cart_item ) {
        $product_id = ( isset( $cart_item['product_id'] ) ? (int) $cart_item['product_id'] : 0 );
        $variation_id = ( isset( $cart_item['variation_id'] ) ? (int) $cart_item['variation_id'] : 0 );
        // "Gift wrapping included" note for pre-wrapped products. Shown
        // everywhere EXCEPT the classic cart page, where the styled badge
        // (render_included_badge) already shows it.
        if ( $product_id && $this->settings->product_includes_wrapping( $product_id, $variation_id ) ) {
            // On the classic cart page the styled badge (render_included_badge)
            // already shows this, so only add the note elsewhere: the block cart
            // (item data comes via the Store API, where is_cart() is false),
            // checkout and the mini-cart.
            if ( !is_cart() ) {
                $item_data[] = array(
                    'key'     => $this->settings->get( 'included_text' ),
                    'value'   => '',
                    'display' => '',
                );
            }
            return $item_data;
            // Pre-wrapped products have no paid wrap to show.
        }
        if ( 'per_item' !== $this->settings->get( 'mode' ) ) {
            return $item_data;
        }
        // On the cart page the editable button + textarea already show this,
        // so only add the read-only rows elsewhere (checkout, mini-cart).
        if ( is_cart() ) {
            return $item_data;
        }
        if ( empty( $cart_item['key'] ) ) {
            return $item_data;
        }
        $wrapped = $this->get_wrapped();
        if ( !isset( $wrapped[$cart_item['key']] ) ) {
            return $item_data;
        }
        $entry = $wrapped[$cart_item['key']];
        $item_data[] = array(
            'key'     => __( 'Gift wrapped', 'tinyplugs-gift-wrapping' ),
            'value'   => $this->wrap_value_label( $entry['option'] ),
            'display' => '',
        );
        $message = $entry['message'];
        if ( '' !== $message ) {
            $item_data[] = array(
                'key'     => __( 'Gift message', 'tinyplugs-gift-wrapping' ),
                'value'   => wp_strip_all_tags( $message ),
                'display' => '',
            );
        }
        return $item_data;
    }

    /**
     * AJAX: toggle wrap for an item or the order, optionally with a message.
     *
     * @return void
     */
    public function ajax_toggle() {
        check_ajax_referer( 'cgwfw_toggle', 'nonce' );
        // Everything below (state, eligibility, totals, fragment) needs the
        // cart; bail with a JSON error rather than fataling without one.
        if ( !WC()->cart ) {
            wp_send_json_error( array(
                'message' => __( 'Cart is unavailable.', 'tinyplugs-gift-wrapping' ),
            ) );
        }
        $key = ( isset( $_POST['key'] ) ? sanitize_text_field( wp_unslash( $_POST['key'] ) ) : '' );
        $wrap = isset( $_POST['wrap'] ) && 'true' === $_POST['wrap'];
        $message = ( isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '' );
        $option = ( isset( $_POST['option'] ) ? sanitize_key( wp_unslash( $_POST['option'] ) ) : '' );
        // Ignore any submitted message when gift messages are disabled.
        if ( 'yes' !== $this->settings->get( 'enable_message' ) ) {
            $message = '';
        }
        $limit = (int) $this->settings->get( 'message_limit' );
        if ( $limit > 0 && function_exists( 'mb_substr' ) ) {
            $message = mb_substr( $message, 0, $limit );
        }
        if ( '__order__' === $key ) {
            $this->set_order_wrap( array(
                'enabled' => $wrap,
                'message' => ( $wrap ? $message : '' ),
                'option'  => ( $wrap ? $option : '' ),
            ) );
        } else {
            $cart = WC()->cart->get_cart();
            if ( !isset( $cart[$key] ) ) {
                wp_send_json_error( array(
                    'message' => 'Invalid cart item.',
                ) );
            }
            // Don't let a direct admin-ajax POST mark an ineligible product as
            // wrapped (un-wrapping an already-ineligible item is still allowed).
            if ( $wrap && !$this->cart_item_eligible( $cart[$key] ) ) {
                wp_send_json_error( array(
                    'message' => __( 'This product is not eligible for gift wrapping.', 'tinyplugs-gift-wrapping' ),
                ) );
            }
            $wrapped = $this->get_wrapped();
            if ( $wrap ) {
                $wrapped[$key] = array(
                    'option'  => $option,
                    'message' => $message,
                );
            } else {
                unset($wrapped[$key]);
            }
            $this->set_wrapped( $wrapped );
        }
        // Recalculate so fees/totals refresh.
        WC()->cart->calculate_totals();
        ob_start();
        woocommerce_cart_totals();
        $totals_html = ob_get_clean();
        wp_send_json_success( array(
            'fragments' => array(
                '.cart_totals' => $totals_html,
            ),
        ) );
    }

    /**
     * Add the gift wrapping fee to the cart.
     *
     * @param WC_Cart $cart Cart object.
     * @return void
     */
    public function add_fee( $cart ) {
        if ( is_admin() && !defined( 'DOING_AJAX' ) ) {
            return;
        }
        $total_fee = $this->calculate_total_fee();
        if ( $total_fee <= 0 ) {
            return;
        }
        $taxable = 'yes' === $this->settings->get( 'taxable' );
        $label = $this->settings->get( 'fee_label' );
        $tax_class = ( $taxable ? (string) $this->settings->get( 'tax_class' ) : '' );
        // The configured fee is treated as inclusive of tax, to mirror product
        // prices when the store enters prices inclusive of tax. WooCommerce takes
        // fee amounts as tax-exclusive and adds tax on top, so back the tax out
        // here first — that way the amount actually charged equals the configured
        // fee (e.g. 4.90 stays 4.90 rather than becoming 4.90 + tax).
        if ( $taxable && wc_prices_include_tax() && class_exists( 'WC_Tax' ) ) {
            $tax_rates = WC_Tax::get_rates( $tax_class );
            $fee_taxes = WC_Tax::calc_inclusive_tax( $total_fee, $tax_rates );
            $total_fee = $total_fee - array_sum( $fee_taxes );
        }
        $cart->add_fee(
            $label,
            $total_fee,
            $taxable,
            $tax_class
        );
    }

    /**
     * The total wrapping fee to add to the cart.
     *
     * When wrap designs are defined the fee is the sum of the chosen designs'
     * prices; otherwise it is the flat fee multiplied by the wrapped count.
     * Covers order-level wrap (per-order mode and the block cart) plus per-item
     * wraps in per-item mode.
     *
     * @return float
     */
    private function calculate_total_fee() {
        $has_designs = $this->settings->has_wrap_options();
        $total = 0.0;
        // Order-level wrap (per-order mode only — ignore any leftover session
        // state if the store has since switched to per-item mode).
        if ( 'per_order' === $this->settings->get( 'mode' ) ) {
            $state = $this->get_order_wrap();
            if ( !empty( $state['enabled'] ) && $this->cart_has_eligible_item() ) {
                $total += ( $has_designs ? $this->settings->get_option_price( ( isset( $state['option'] ) ? $state['option'] : '' ) ) : $this->settings->get_fee() );
            }
        }
        // Per-item wraps.
        if ( 'per_item' === $this->settings->get( 'mode' ) && WC()->cart ) {
            $wrapped = $this->get_wrapped();
            $per_unit = 'unit' === $this->settings->get( 'fee_per' );
            $cart = WC()->cart->get_cart();
            foreach ( $wrapped as $key => $data ) {
                if ( !isset( $cart[$key] ) || !$this->cart_item_eligible( $cart[$key] ) ) {
                    continue;
                }
                $qty = ( $per_unit ? max( 1, (int) $cart[$key]['quantity'] ) : 1 );
                $price = ( $has_designs ? $this->settings->get_option_price( $data['option'] ) : $this->settings->get_fee() );
                $total += $price * $qty;
            }
        }
        return (float) $total;
    }

    /**
     * Eligibility for a specific cart line, honouring variation-level
     * exclusions / inclusions. Accepts any array carrying 'product_id' and
     * (optionally) 'variation_id' — i.e. a cart item, or the $values array
     * passed to the order line-item hook.
     *
     * @param array $cart_item Cart item / line values.
     * @return bool
     */
    private function cart_item_eligible( $cart_item ) {
        $product_id = ( isset( $cart_item['product_id'] ) ? (int) $cart_item['product_id'] : 0 );
        $variation_id = ( isset( $cart_item['variation_id'] ) ? (int) $cart_item['variation_id'] : 0 );
        return $this->settings->product_is_eligible( $product_id, $variation_id );
    }

    /**
     * Does the cart contain at least one eligible item?
     *
     * @return bool
     */
    private function cart_has_eligible_item() {
        if ( !WC()->cart ) {
            return false;
        }
        foreach ( WC()->cart->get_cart() as $item ) {
            if ( $this->cart_item_eligible( $item ) ) {
                return true;
            }
        }
        return false;
    }

    /**
     * Drop a wrapped key when its cart item is removed.
     *
     * @param string  $cart_item_key Removed key.
     * @param WC_Cart $cart          Cart.
     * @return void
     */
    public function on_item_removed( $cart_item_key, $cart ) {
        $wrapped = $this->get_wrapped();
        if ( isset( $wrapped[$cart_item_key] ) ) {
            unset($wrapped[$cart_item_key]);
            $this->set_wrapped( $wrapped );
        }
        // Forget the order-level wrap once no eligible item remains, so it does
        // not silently return when a product is added back to the cart.
        if ( !$this->cart_has_eligible_item() ) {
            $this->set_order_wrap( array(
                'enabled' => false,
                'message' => '',
                'option'  => '',
            ) );
        }
    }

    /**
     * Clear all wrap state when the cart is emptied.
     *
     * @return void
     */
    public function on_cart_emptied() {
        $this->set_wrapped( array() );
        $this->set_order_wrap( array(
            'enabled' => false,
            'message' => '',
            'option'  => '',
        ) );
    }

    /**
     * Persist wrap details onto the order at checkout.
     *
     * @param WC_Order $order Order object.
     * @param array    $data  Posted checkout data.
     * @return void
     */
    public function save_order_meta( $order, $data ) {
        // Order-level wrap (per-order mode only, matching the fee logic).
        if ( 'per_order' === $this->settings->get( 'mode' ) ) {
            $state = $this->get_order_wrap();
            if ( !empty( $state['enabled'] ) && $this->cart_has_eligible_item() ) {
                $order->update_meta_data( '_cgwfw_wrapped', 'order' );
                if ( '' !== $state['message'] ) {
                    $order->update_meta_data( '_cgwfw_order_message', $state['message'] );
                }
                if ( !empty( $state['option'] ) ) {
                    $design = $this->settings->get_wrap_option( $state['option'] );
                    if ( $design ) {
                        $order->update_meta_data( '_cgwfw_order_design', $design['name'] );
                    }
                }
            }
            return;
        }
        if ( 'per_item' !== $this->settings->get( 'mode' ) ) {
            return;
        }
        // Per-item wrap notes are written per order line in save_line_item_meta()
        // (hooked to woocommerce_checkout_create_order_line_item), which maps by
        // cart item key and so handles duplicate product lines correctly. Here we
        // only record the order-level marker, and only if at least one eligible
        // wrapped item is actually present.
        $wrapped = $this->get_wrapped();
        if ( empty( $wrapped ) ) {
            return;
        }
        $cart = WC()->cart->get_cart();
        foreach ( $wrapped as $cart_key => $entry ) {
            if ( isset( $cart[$cart_key] ) && $this->cart_item_eligible( $cart[$cart_key] ) ) {
                $order->update_meta_data( '_cgwfw_wrapped', 'items' );
                break;
            }
        }
    }

    /**
     * Store API (block checkout) variant of save_order_meta(). The Store API
     * checkout route never fires woocommerce_checkout_create_order, so without
     * this the order-level wrap meta (_cgwfw_wrapped, _cgwfw_order_message,
     * _cgwfw_order_design) would be lost on block-checkout orders.
     *
     * @param WC_Order $order Order being placed.
     * @return void
     */
    public function save_order_meta_store_api( $order ) {
        // Unlike the classic hook (fresh order, fires once), this fires every
        // time the draft order is created/updated AND at placement, so clear
        // any wrap meta from an earlier draft update first — the customer may
        // have removed the wrap in between. save_order_meta() re-adds it when
        // wrapping is (still) enabled.
        $order->delete_meta_data( '_cgwfw_wrapped' );
        $order->delete_meta_data( '_cgwfw_order_message' );
        $order->delete_meta_data( '_cgwfw_order_design' );
        $this->save_order_meta( $order, array() );
    }

    /**
     * Attach per-item wrap meta (design + message) to a single order line item.
     *
     * Hooked to woocommerce_checkout_create_order_line_item, which fires once per
     * line with its originating cart item key, so the meta always lands on the
     * correct line — even when the same product appears in several cart lines.
     *
     * @param WC_Order_Item_Product $item          Order line item.
     * @param string                $cart_item_key Originating cart item key.
     * @param array                 $values        Cart item data.
     * @param WC_Order              $order         Order being created.
     * @return void
     */
    public function save_line_item_meta(
        $item,
        $cart_item_key,
        $values,
        $order
    ) {
        if ( 'per_item' !== $this->settings->get( 'mode' ) ) {
            return;
        }
        $wrapped = $this->get_wrapped();
        if ( empty( $wrapped[$cart_item_key] ) ) {
            return;
        }
        // $values is the cart item, so variation-level exclusions are honoured.
        if ( !$this->cart_item_eligible( $values ) ) {
            return;
        }
        $entry = $wrapped[$cart_item_key];
        $item->add_meta_data( __( 'Gift wrapped', 'tinyplugs-gift-wrapping' ), $this->wrap_value_label( $entry['option'] ), true );
        if ( !empty( $entry['message'] ) ) {
            $item->add_meta_data( __( 'Gift message', 'tinyplugs-gift-wrapping' ), $entry['message'], true );
        }
        // Stable, non-translatable keys for programmatic use (PDF packing slips,
        // integrations). The human-readable keys above are localised, so their
        // name changes per store language; these never do. The leading underscore
        // also hides them from the default order-item meta display.
        $item->add_meta_data( '_cgwfw_wrapped', 'yes', true );
        if ( !empty( $entry['message'] ) ) {
            $item->add_meta_data( '_cgwfw_message', $entry['message'], true );
        }
    }

    /**
     * Hide our internal line-item meta keys from the admin order screen.
     *
     * @param string[] $hidden Meta keys WooCommerce hides from the order items box.
     * @return string[]
     */
    public function hide_internal_item_meta( $hidden ) {
        $hidden = ( is_array( $hidden ) ? $hidden : array() );
        $hidden[] = '_cgwfw_wrapped';
        $hidden[] = '_cgwfw_message';
        return $hidden;
    }

    /**
     * Show per-order gift message on the front-end order details & emails.
     *
     * On woocommerce_email_after_order_table the extra args are passed (see the
     * constructor); woocommerce_order_details_after_order_table passes only the
     * order, so the defaults keep that call on the HTML path.
     *
     * @param WC_Order $order         Order.
     * @param bool     $sent_to_admin Whether the email goes to the admin (emails only).
     * @param bool     $plain_text    Whether a plain-text email is being built.
     * @return void
     */
    public function display_order_meta( $order, $sent_to_admin = false, $plain_text = false ) {
        $design = $order->get_meta( '_cgwfw_order_design' );
        $message = $order->get_meta( '_cgwfw_order_message' );
        if ( $plain_text ) {
            if ( $design ) {
                echo esc_html( $this->settings->get( 'fee_label' ) . ': ' . $design ) . "\n";
            }
            if ( $message ) {
                echo esc_html( __( 'Gift message', 'tinyplugs-gift-wrapping' ) . ': ' . $message ) . "\n";
            }
            return;
        }
        if ( $design ) {
            echo '<p><strong>' . esc_html( $this->settings->get( 'fee_label' ) ) . ':</strong> ' . esc_html( $design ) . '</p>';
        }
        if ( $message ) {
            echo '<p><strong>' . esc_html__( 'Gift message', 'tinyplugs-gift-wrapping' ) . ':</strong> ' . esc_html( $message ) . '</p>';
        }
    }

    /**
     * Show per-order gift message in the admin order screen.
     *
     * @param WC_Order $order Order.
     * @return void
     */
    public function display_admin_order_meta( $order ) {
        $design = $order->get_meta( '_cgwfw_order_design' );
        if ( $design ) {
            echo '<p><strong>' . esc_html__( 'Gift wrapping', 'tinyplugs-gift-wrapping' ) . ':</strong> ' . esc_html( $design ) . '</p>';
        }
        $message = $order->get_meta( '_cgwfw_order_message' );
        if ( $message ) {
            echo '<p><strong>' . esc_html__( 'Gift message', 'tinyplugs-gift-wrapping' ) . ':</strong><br/>' . esc_html( $message ) . '</p>';
        }
    }

}
