<?php

if (!defined('ABSPATH')) {
    die('You are not allowed to call this page directly.');
}

class MeprCouponsCtrl extends MeprCptCtrl
{
    /**
     * Load hooks for the coupons management.
     *
     * @return void
     */
    public function load_hooks()
    {
        /*
         * Registration waits until every plugin loaded, so a brand add-on
         * can force the mepr_feature_coupons_active filter off from its own
         * main file and remove every coupon entry point at once. Activation
         * requests include this file after plugins_loaded already fired, so
         * fall through to direct registration there.
         */
        if (did_action('plugins_loaded')) {
            $this->register_feature_hooks();
            return;
        }

        add_action('plugins_loaded', [$this, 'register_feature_hooks'], 0);
    }

    /**
     * Register the module's hooks when the coupons feature is active.
     *
     * The mepr_feature_coupons_active kill switch is honored here in full:
     * with the feature forced off nothing below registers, matching a build
     * that stripped the module files.
     *
     * @return void
     */
    public function register_feature_hooks()
    {
        if (!MeprFeatures::active('coupons')) {
            return;
        }

        add_filter('bulk_actions-edit-memberpresscoupon', 'MeprCouponsCtrl::disable_bulk');
        add_filter('post_row_actions', 'MeprCouponsCtrl::disable_row', 10, 2);
        add_action('admin_enqueue_scripts', 'MeprCouponsCtrl::admin_enqueue_scripts');
        add_action('manage_posts_custom_column', 'MeprCouponsCtrl::custom_columns', 10, 2);
        add_filter('manage_edit-memberpresscoupon_columns', 'MeprCouponsCtrl::columns');
        add_action('admin_init', 'MeprCoupon::expire_old_coupons_and_cleanup_db');
        add_action('admin_init', 'MeprDbMigrations::maybe_run_deferred_coupon_migrations');
        add_action('save_post', 'MeprCouponsCtrl::save_postdata');
        add_action('save_post', 'MeprCouponsCtrl::save_group_postdata', 11);
        add_action('mepr_process_options', 'MeprCouponsCtrl::store_options');
        add_action('wp_insert_post_data', 'MeprCouponsCtrl::sanitize_and_validate_coupon', 99, 2);
        add_filter('default_title', 'MeprCouponsCtrl::get_page_title_code');
        add_action('mepr_txn_store', 'MeprCouponsCtrl::update_coupon_usage_count');
        add_action('mepr_subscr_store', 'MeprCouponsCtrl::update_coupon_usage_count');

        // Cleanup list view.
        add_filter('views_edit-' . MeprCoupon::$cpt, 'MeprAppCtrl::cleanup_list_view');

        // Ajax coupon validation.
        add_action('wp_ajax_mepr_validate_coupon', 'MeprCouponsCtrl::validate_coupon_ajax');
        add_action('wp_ajax_nopriv_mepr_validate_coupon', 'MeprCouponsCtrl::validate_coupon_ajax');

        // Ajax coupon search for the specific-coupons Tom Select dropdown.
        add_action('wp_ajax_mepr_search_coupons', 'MeprCouponsCtrl::search_coupons_ajax');

        // Admin notice for coupon validation errors.
        add_action('admin_notices', 'MeprCouponsCtrl::display_coupon_validation_notice');

        // Standalone route for the front-end coupon validation endpoint.
        add_action('mepr_standalone_route', 'MeprCouponsCtrl::standalone_route', 10, 2);

        // Pricing and code-resolution seams: core never touches coupon classes.
        add_filter('mepr_pre_adjusted_price', 'MeprCouponsCtrl::apply_code_discount', 10, 3);
        add_filter('mepr_product_valid_coupon_code', 'MeprCouponsCtrl::validate_product_coupon_code', 10, 2);
        add_filter('mepr_product_applicable_coupon_code', 'MeprCouponsCtrl::resolve_applicable_coupon_code', 10, 3);
        add_filter('mepr_coupon_from_code', 'MeprCouponsCtrl::coupon_from_code', 10, 3);
        add_filter('mepr_validate_signup', 'MeprCouponsCtrl::validate_signup_coupon');
        add_filter('mepr_price_shortcode_coupon', 'MeprCouponsCtrl::resolve_price_shortcode_coupon', 10, 3);

        /*
         * Invoice coupon rows run at priority 5 so brand listeners on the
         * same filters observe the recomputed amounts, exactly as they did
         * when core baked them in before filtering.
         */
        add_filter('mepr_invoice', 'MeprCouponsCtrl::add_invoice_coupon_row', 5, 3);
        add_filter('mepr_invoice_order_bumps', 'MeprCouponsCtrl::add_order_bumps_invoice_coupon_row', 5, 4);

        // Coupon entries on the dashboard NBA and post-setup checklist surfaces.
        add_filter('mepr_dashboard_nba_actions', 'MeprCouponsCtrl::add_nba_growth_action');
        add_filter('mepr_nba_cross_sell_actions', 'MeprCouponsCtrl::add_nba_cross_sell_actions', 10, 3);
        add_filter('mepr_post_setup_checklist_optional_steps', 'MeprCouponsCtrl::add_checklist_optional_step');
        add_filter('mepr_coupon_count', 'MeprCouponsCtrl::filter_coupon_count');

        // Render slots owned by the module on core admin and checkout surfaces.
        add_action('mepr_checkout_coupon_field', 'MeprCouponsCtrl::render_checkout_coupon_field', 10, 3);
        add_action('mepr_options_signup_fields', 'MeprCouponsCtrl::render_options_field');
        add_action('mepr_admin_group_form', 'MeprCouponsCtrl::render_group_form_section', 10, 2);
        add_action('mepr_admin_support_help_topics', 'MeprCouponsCtrl::render_support_help_topic');
        add_action('mepr_admin_transactions_list_header', 'MeprCouponsCtrl::render_transactions_list_header');
        add_filter('mepr_admin_bar_nodes', 'MeprCouponsCtrl::add_admin_bar_node');
        add_filter('mepr_admin_list_table_post_types', 'MeprCouponsCtrl::add_list_table_post_type');

        // Stripe webhook events owned by the module.
        add_action('mepr_stripe_event_coupon.deleted', 'MeprCouponsCtrl::handle_stripe_coupon_deleted', 10, 2);

        /*
         * Announce the module once every plugin and integration registered
         * its hooks, so cross-feature glue (e.g. Easy Affiliate coupons) can
         * attach without core or integrations checking for the feature.
         */
        add_action('init', 'MeprCouponsCtrl::announce_loaded', 4);
    }

    /**
     * Register the custom post type for coupons.
     *
     * @return void
     */
    public function register_post_type()
    {
        /*
         * The base MeprCptCtrl constructor hooks this on init independently
         * of load_hooks(), so the feature kill switch is re-checked here.
         */
        if (!MeprFeatures::active('coupons')) {
            return;
        }

        register_post_type(MeprCoupon::$cpt, [
            'labels'               => [
                'name'               => __('Coupons', 'memberpress'),
                'singular_name'      => __('Coupon', 'memberpress'),
                'add_new'            => __('Add New', 'memberpress'),
                'add_new_item'       => __('Add New Coupon', 'memberpress'),
                'edit_item'          => __('Edit Coupon', 'memberpress'),
                'new_item'           => __('New Coupon', 'memberpress'),
                'view_item'          => __('View Coupon', 'memberpress'),
                'search_items'       => __('Search Coupons', 'memberpress'),
                'not_found'          => __('No Coupons found', 'memberpress'),
                'not_found_in_trash' => __('No Coupons found in Trash', 'memberpress'),
                'parent_item_colon'  => __('Parent Coupon:', 'memberpress'),
            ],
            'public'               => false,
            'show_ui'              => true,
            'show_in_menu'         => 'memberpress',
            'capability_type'      => 'page',
            'hierarchical'         => false,
            'register_meta_box_cb' => 'MeprCouponsCtrl::add_meta_boxes',
            'rewrite'              => false,
            'supports'             => ['title'],
        ]);
    }

    /**
     * Define the columns for the coupon list table.
     *
     * @param  array $columns The existing columns.
     * @return array
     */
    public static function columns($columns)
    {
        $columns = [
            'cb'                 => '<input type="checkbox" />',
            'title'              => __('Code', 'memberpress'),
            'coupon-description' => __('Description', 'memberpress'),
            'date'               => __('Created', 'memberpress'),
            'coupon-discount'    => __('Discount', 'memberpress'),
            'coupon-dm'          => __('Mode', 'memberpress'),
            'coupon-starts'      => __('Starts', 'memberpress'),
            'coupon-expires'     => __('Expires', 'memberpress'),
            'coupon-count'       => __('Usage Count', 'memberpress'),
            'coupon-products'    => __('Applies To', 'memberpress'),
        ];

        return $columns;
    }

    /**
     * Render custom columns in the coupon list table.
     *
     * @param  string  $column    The name of the column.
     * @param  integer $coupon_id The ID of the coupon.
     * @return void
     */
    public static function custom_columns($column, $coupon_id)
    {
        $mepr_options = MeprOptions::fetch();
        $coupon       = new MeprCoupon($coupon_id);

        if ($coupon->ID !== null) {
            switch ($column) {
                case 'coupon-description':
                    echo esc_html(wp_strip_all_tags($coupon->post_content));
                    break;
                case 'coupon-discount':
                    if ($coupon->is_membership_specific) {
                        echo esc_html__('Membership Specific', 'memberpress');
                        break;
                    }
                    if ($coupon->discount_mode === 'first-payment') {
                        echo esc_html($coupon->first_payment_discount_amount); // Update this to show proper currency symbol later.
                        echo esc_html(($coupon->first_payment_discount_type === 'percent') ? __('%', 'memberpress') : $mepr_options->currency_code);
                        echo ' → ';
                    }

                    echo esc_html($coupon->discount_amount); // Update this to show proper currency symbol later.
                    echo esc_html(($coupon->discount_type === 'percent') ? __('%', 'memberpress') : $mepr_options->currency_code);
                    break;
                case 'coupon-starts':
                    if ($coupon->post_status !== 'trash') {
                        if ($coupon->should_start) {
                                echo esc_html(MeprUtils::get_date_from_ts($coupon->starts_on));
                        } else {
                              esc_html_e('Immediately', 'memberpress');
                        }
                    } else {
                        esc_html_e('Expired', 'memberpress'); // They've moved this to trash so show it as expired.
                    }
                    break;
                case 'coupon-expires':
                    if ($coupon->post_status !== 'trash') {
                        if ($coupon->should_expire) {
                                echo esc_html(MeprUtils::get_date_from_ts($coupon->expires_on));
                        } else {
                              esc_html_e('Never', 'memberpress');
                        }
                    } else {
                        esc_html_e('Expired', 'memberpress'); // They've moved this to trash so show it as expired.
                    }
                    break;
                case 'coupon-count':
                    echo '<a href="' . esc_url(admin_url('admin.php?page=memberpress-trans&coupon_id=' . $coupon->ID)) . '">';
                    if ($coupon->usage_amount) {
                        echo esc_html((int)$coupon->usage_count . ' / ' . $coupon->usage_amount);
                    } else {
                        echo esc_html((int)$coupon->usage_count . ' / ' . __('Unlimited', 'memberpress'));
                    }
                    echo '</a>';
                    break;
                case 'coupon-dm':
                    if ($coupon->discount_mode === 'trial-override') {
                        echo esc_html(
                            sprintf(
                                // Translators: %1$s: trial days, %2$s: trial amount.
                                __('Trial: %1$s days for %2$s', 'memberpress'),
                                $coupon->trial_days,
                                MeprAppHelper::format_currency($coupon->trial_amount)
                            )
                        );
                    } elseif ($coupon->discount_mode === 'first-payment') {
                        echo esc_html_x('First Payment', 'ui', 'memberpress');
                    } elseif ($coupon->discount_mode === 'standard') {
                        echo esc_html_x('Standard', 'ui', 'memberpress');
                    } else {
                        echo esc_html_x('None', 'ui', 'memberpress');
                    }
                    break;
                case 'coupon-products':
                    echo esc_html(implode(', ', $coupon->get_formatted_products()));
            }
        }
    }

    /**
     * Add meta boxes for the coupon edit screen.
     *
     * @return void
     */
    public static function add_meta_boxes()
    {
        global $post_id;
        $c = new MeprCoupon($post_id);

        add_meta_box('memberpress-coupon-meta', __('Coupon Options', 'memberpress'), 'MeprCouponsCtrl::coupon_meta_box', MeprCoupon::$cpt, 'normal', 'high');
        add_meta_box('memberpress-coupon-description', __('Description', 'memberpress'), 'MeprCouponsCtrl::coupon_description_box', MeprCoupon::$cpt, 'normal', 'high');

        MeprHooks::do_action('mepr_coupon_meta_boxes', $c);
    }

    /**
     * Display the coupon meta box.
     *
     * @return void
     */
    public static function coupon_meta_box()
    {
        global $post_id;
        $mepr_options = MeprOptions::fetch();
        $c            = new MeprCoupon($post_id);

        MeprView::render('/admin/coupons/form', get_defined_vars());
    }

    /**
     * Display the coupon description box.
     *
     * @return void
     */
    public static function coupon_description_box()
    {
        global $post_id;
        $c = new MeprCoupon($post_id);

        ?>
    <textarea name="content" id="excerpt"><?php echo esc_textarea($c->post_content); ?></textarea>
        <?php
    }

    /**
     * Save the coupon's metadata when the post is saved.
     *
     * @param  integer $post_id The ID of the post being saved.
     * @return mixed
     */
    public static function save_postdata($post_id)
    {
        $post = get_post($post_id);

        if (!wp_verify_nonce((isset($_POST[MeprCoupon::$nonce_str])) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$nonce_str])) : '', MeprCoupon::$nonce_str . wp_salt())) {
            return $post_id; // Nonce prevents meta data from being wiped on move to trash.
        }

        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return $post_id;
        }

        if (defined('DOING_AJAX')) {
            return;
        }

        if (!empty($post) && $post->post_type === MeprCoupon::$cpt) {
            $coupon = new MeprCoupon($post_id);
            self::prepare_dates_data_for_db($coupon);

            if (isset($_POST[MeprCoupon::$usage_amount_str]) and is_numeric($_POST[MeprCoupon::$usage_amount_str])) {
                $coupon->usage_amount = sanitize_text_field(wp_unslash($_POST[MeprCoupon::$usage_amount_str]));
            } else {
                $coupon->usage_amount = 0;
            }

            $coupon->is_membership_specific = isset($_POST[MeprCoupon::$is_membership_specific_str]);
            $discount_settings              = self::parse_discount_settings(
                $coupon->is_membership_specific ?
                [] : // If is membership specific, we want to reset the general coupon discount settings.
                $_POST
            );

            // Set the general coupon discount settings.
            foreach ($discount_settings as $key => $value) {
                $coupon->$key = $value;
            }

            // Reset membership_specific.
            $coupon->membership_specific = [];

            // If is membership specific, we want to parse the discount settings for each membership.
            if ($coupon->is_membership_specific) {
                $membership_specific = [];
                foreach ($_POST[MeprCoupon::$membership_specific_str] ?? [] as $membership_id => $settings) {
                    $membership_specific[$membership_id] = self::parse_discount_settings($settings);
                }
                $coupon->membership_specific = $membership_specific;
            }

            $use_on_upgrades_downgrades         = sanitize_text_field(
                wp_unslash($_POST[MeprCoupon::$use_on_upgrades_downgrades_str] ?? 'none')
            );
            $coupon->use_on_upgrades_downgrades = array_key_exists(
                $use_on_upgrades_downgrades,
                MeprCouponsHelper::get_usage_on_upgrades_downgrades_types()
            ) ?
                $use_on_upgrades_downgrades
                : 'none';

            $coupon->use_on_ud_if_already_used = (bool) (wp_unslash($_POST[MeprCoupon::$use_on_ud_if_already_used_str] ?? false));

            // Handle use on upgrades and downgrades dependencies.
            $use_if_other_coupon_used         = sanitize_text_field(
                wp_unslash($_POST[MeprCoupon::$use_if_other_coupon_used_str] ?? 'unset')
            );
            $coupon->use_if_other_coupon_used = array_key_exists(
                $use_if_other_coupon_used,
                MeprCouponsHelper::get_usage_if_other_coupon_used_types()
            ) ?
                $use_if_other_coupon_used
                : 'unset';

            $coupon->use_if_specific_coupons = [];
            if (in_array($coupon->use_if_other_coupon_used, ['anyof', 'noneof', 'noneoranyof'], true)) {
                $coupon->use_if_specific_coupons = isset($_POST[MeprCoupon::$use_if_specific_coupons_str]) ?
                    array_map('absint', wp_unslash($_POST[MeprCoupon::$use_if_specific_coupons_str])) :
                    [];
            }

            $coupon->valid_products = isset($_POST[MeprCoupon::$valid_products_str]) ? array_map('sanitize_text_field', wp_unslash($_POST[MeprCoupon::$valid_products_str])) : [];

            if (isset($_POST[MeprCoupon::$usage_per_user_count_str]) and is_numeric($_POST[MeprCoupon::$usage_per_user_count_str])) {
                $coupon->usage_per_user_count = $coupon->usage_amount <= 0 || $_POST[MeprCoupon::$usage_per_user_count_str] <= $coupon->usage_amount ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$usage_per_user_count_str])) : $coupon->usage_amount;
            } else {
                $coupon->usage_per_user_count = 0;
            }
            $coupon->usage_per_user_count_timeframe = isset($_POST[MeprCoupon::$usage_per_user_count_timeframe_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$usage_per_user_count_timeframe_str])) : 'lifetime';
            $coupon->store_meta();

            MeprHooks::do_action('mepr_coupon_save_meta', $coupon);
        }
    }

    /**
     * Saves the coupon fields of a group edit screen.
     *
     * Owned by the coupons module so brand builds that strip this controller
     * also lose the save-side processing: a build without the Group Coupons
     * UI can never interpret the missing fields as a wipe. Runs on save_post
     * priority 11, after MeprGroupsCtrl::save_postdata has stored the group.
     *
     * @param  integer $post_id The post ID.
     * @return void
     */
    public static function save_group_postdata($post_id)
    {
        $post = get_post($post_id);

        $nonce_value = (isset($_POST[MeprGroup::$nonce_str]))
            ? sanitize_text_field(wp_unslash($_POST[MeprGroup::$nonce_str]))
            : '';
        if (!wp_verify_nonce($nonce_value, MeprGroup::$nonce_str . wp_salt())) {
            return; // Nonce prevents meta data from being wiped on move to trash.
        }

        if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) {
            return;
        }

        if (defined('DOING_AJAX')) {
            return;
        }

        if (empty($post) || $post->post_type !== MeprGroup::$cpt) {
            return;
        }

        /*
         * Marker contract: only process coupon fields when the Group Coupons
         * section actually rendered in the submitted form.
         */
        if (!isset($_POST['mepr_present']['group_coupons'])) {
            return;
        }

        $group                       = new MeprGroup($post_id);
        $group->auto_apply_coupons   = isset($_POST[MeprGroup::$auto_apply_coupons_str]);
        $group->coupon_discount_note = isset($_POST[MeprGroup::$coupon_discount_note_str])
            ? sanitize_text_field(wp_unslash($_POST[MeprGroup::$coupon_discount_note_str]))
            : '';
        self::store_group_coupons($group);
    }

    /**
     * Store group coupons for the group.
     *
     * @param  MeprGroup $group The group.
     * @return void
     */
    private static function store_group_coupons($group)
    {
        $post_prefix = MeprGroup::$group_coupons_str;
        if (!isset($_POST[$post_prefix]) || !is_array($_POST[$post_prefix])) {
            // No coupon rows submitted: the user removed them all.
            $group->group_coupons = [];
            $group->store_meta();
            return;
        }

        $group_coupons = [];

        // Process each coupon entry in POST data.
        // Structure: $_POST[$post_prefix][$index] = ['coupon_id' => ..., 'should_apply' => ..., 'apply_month' => ..., 'apply_day' => ..., 'should_unapply' => ..., 'unapply_month' => ..., etc.].
        foreach ($_POST[$post_prefix] as $index => $coupon_data) {
            // Skip template entry (has TEMPLATE_INDEX as key or empty coupon_id).
            if (!is_numeric($index) || !isset($coupon_data[MeprGroup::$group_coupon_coupon_id_str])) {
                continue;
            }
            $coupon_id = intval($coupon_data[MeprGroup::$group_coupon_coupon_id_str] ?? 0);

            if ($coupon_id > 0) {
                $group_coupons[] = array_merge(
                    ['coupon_id' => $coupon_id],
                    self::prepare_group_coupon_dates_data_for_db($coupon_data)
                );
            }
        }

        $group->group_coupons = $group_coupons;
        $group->store_meta();
    }

    /**
     * Prepare group coupon dates data for database storage.
     *
     * @param  array $coupon_data The coupon data.
     * @return array The prepared coupon dates data with field map keys.
     */
    private static function prepare_group_coupon_dates_data_for_db(array $coupon_data): array
    {
        $field_map         = MeprCouponsHelper::get_group_coupon_field_map();
        $coupon_dates_data = MeprCouponsHelper::process_date_fields(
            $coupon_data,
            $field_map
        );

        return [
            $field_map['should_start']   => $coupon_dates_data['should_start'],
            $field_map['start_date_ts']  => $coupon_dates_data['start_date_ts'],
            $field_map['start_timezone'] => $coupon_dates_data['start_timezone'],
            $field_map['should_end']     => $coupon_dates_data['should_end'],
            $field_map['end_date_ts']    => $coupon_dates_data['end_date_ts'],
            $field_map['end_timezone']   => $coupon_dates_data['end_timezone'],
        ];
    }

    /**
     * Saves the coupon field toggle from the options form submission.
     *
     * Owned by the coupons module (mepr_process_options, matching the
     * MeprUpdateCtrl::store_options precedent) so stripped builds cannot
     * flip the stored flag when the toggle did not render.
     *
     * @param  array $params The posted options form parameters.
     * @return void
     */
    public static function store_options($params)
    {
        // Marker contract: only process the toggle when it actually rendered.
        if (!isset($params['mepr_present']['coupon_field'])) {
            return;
        }

        $mepr_options                       = MeprOptions::fetch();
        $mepr_options->coupon_field_enabled = isset($params[$mepr_options->coupon_field_enabled_str]);
        $mepr_options->store(false);
    }

    /**
     * Prepare dates data for database storage.
     *
     * @param  MeprCoupon $coupon            The coupon object.
     * @param  array      $coupon_dates_data Optional. The dates data array from process_date_fields. If empty, will be retrieved from $_POST.
     * @return void
     */
    private static function prepare_dates_data_for_db(MeprCoupon $coupon, array $coupon_dates_data = []): void
    {
        if (empty($coupon_dates_data)) {
            $coupon_dates_data = MeprCouponsHelper::process_date_fields($_POST);
        }

        $coupon->should_start    = $coupon_dates_data['should_start'];
        $coupon->starts_on       = $coupon_dates_data['start_date_ts'];
        $coupon->start_timezone  = $coupon_dates_data['start_timezone'];
        $coupon->should_expire   = $coupon_dates_data['should_end'];
        $coupon->expires_on      = $coupon_dates_data['end_date_ts'];
        $coupon->expire_timezone = $coupon_dates_data['end_timezone'];
    }

    /**
     * Parse discount settings.
     *
     * @param  array $data_source The data source.
     * @return array
     */
    private static function parse_discount_settings(array $data_source): array
    {
        // Default values.
        $to_return = [
            'discount_mode'                 => 'standard',
            'discount_type'                 => 'percent',
            'discount_amount'               => 0,
            'first_payment_discount_type'   => 'percent',
            'first_payment_discount_amount' => 0,
            'trial_days'                    => 0,
            'trial_amount'                  => 0.00,
        ];

        $to_return['discount_mode'] = isset($data_source[MeprCoupon::$discount_mode_str]) ?
            sanitize_text_field(wp_unslash($data_source[MeprCoupon::$discount_mode_str])) :
            $to_return['discount_mode'];

        $to_return['discount_type'] = isset($data_source[MeprCoupon::$discount_type_str]) ?
            sanitize_text_field(wp_unslash($data_source[MeprCoupon::$discount_type_str])) :
            $to_return['discount_type'];

        $to_return['discount_amount'] = isset($data_source[MeprCoupon::$discount_amount_str]) ?
            self::parse_amount_value(sanitize_text_field(wp_unslash($data_source[MeprCoupon::$discount_amount_str])), $to_return['discount_type']) : 0;

        if ($to_return['discount_type'] === 'percent' && $to_return['discount_amount'] > 100) {
            $to_return['discount_amount'] = 100; // Make sure percent is never > 100.
        }
        switch ($to_return['discount_mode']) {
            case 'first-payment':
                $to_return['first_payment_discount_type']   = isset($data_source[MeprCoupon::$first_payment_discount_type_str]) ?
                    sanitize_text_field(wp_unslash($data_source[MeprCoupon::$first_payment_discount_type_str])) :
                    $to_return['first_payment_discount_type'];
                $to_return['first_payment_discount_amount'] = isset($data_source[MeprCoupon::$first_payment_discount_amount_str]) ?
                    self::parse_amount_value(sanitize_text_field(wp_unslash($data_source[MeprCoupon::$first_payment_discount_amount_str])), $to_return['first_payment_discount_type']) :
                    $to_return['first_payment_discount_amount'];

                if (
                    $to_return['first_payment_discount_type'] === 'percent' &&
                    $to_return['first_payment_discount_amount'] > 100
                ) {
                    $to_return['first_payment_discount_amount'] = 100; // Make sure percent is never > 100.
                }

                break;

            case 'trial-override':
                $to_return['trial_days']   = isset($data_source[MeprCoupon::$trial_days_str]) ?
                    (int)sanitize_text_field(wp_unslash($data_source[MeprCoupon::$trial_days_str])) :
                    $to_return['trial_days'];
                $to_return['trial_amount'] = isset($data_source[MeprCoupon::$trial_amount_str]) ?
                    (float) MeprUtils::format_currency_us_float(sanitize_text_field(wp_unslash($data_source[MeprCoupon::$trial_amount_str]))) :
                    $to_return['trial_amount'];

                break;

            default:
                break;
        }

        return $to_return;
    }

    /**
     * Parse a user-entered discount amount.
     *
     * Percent values are never grouped with thousands separators, so any
     * separator is a decimal point. Currency amounts go through the
     * locale-aware parser.
     *
     * @param  string $amount The raw amount.
     * @param  string $type   The discount type ('percent' or 'dollar').
     * @return float
     */
    private static function parse_amount_value(string $amount, string $type): float
    {
        return $type === 'percent'
            ? MeprUtils::float_value($amount)
            : (float) MeprUtils::format_currency_us_float($amount);
    }

    /**
     * Sanitize and validate coupon data before saving.
     *
     * Handles title sanitization, duplicate title prevention,
     * and validates that at least one membership is selected.
     *
     * @param  array $data    The post data.
     * @param  array $postarr The post array.
     * @return array
     */
    public static function sanitize_and_validate_coupon($data, $postarr)
    {
        global $wpdb;

        if ($data['post_type'] === MeprCoupon::$cpt) {
            // Get rid of invalid chars.
            $data['post_title'] = preg_replace(['/ +/', '/[^A-Za-z0-9_-]/'], ['-', ''], $data['post_title']);

            // Duplicate titles handling.
            $q1    = "SELECT ID FROM {$wpdb->posts} WHERE post_title = %s AND post_type = %s AND ID <> %d LIMIT 1";
            $q2    = $wpdb->prepare($q1, $data['post_title'], MeprCoupon::$cpt, $postarr['ID']); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $count = 0;

            if (is_admin()) {
                // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery
                while ($wpdb->get_var($q2)) {
                    ++$count; // Want to increment before running the query, so when we exit the loop $data['post_title'] . "-{$count}" is stil valid.
                    $q2 = $wpdb->prepare($q1, $data['post_title'] . "-{$count}", MeprCoupon::$cpt, $postarr['ID']); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
                }
            }

            if ($count > 0) {
                $data['post_title'] .= "-{$count}";
            }

            // Validate that at least one membership is selected.
            // Only check when trying to publish and the nonce is present (actual form submission).
            $nonce_value = isset($_POST[MeprCoupon::$nonce_str]) ? sanitize_text_field(wp_unslash($_POST[MeprCoupon::$nonce_str])) : '';
            if (wp_verify_nonce($nonce_value, MeprCoupon::$nonce_str . wp_salt())) {
                $valid_products = isset($_POST[MeprCoupon::$valid_products_str]) ? array_filter($_POST[MeprCoupon::$valid_products_str]) : [];

                if (empty($valid_products) && in_array($data['post_status'], ['publish', 'pending', 'future'], true)) {
                    // Force the coupon to draft status if no memberships are selected.
                    $data['post_status'] = 'draft';

                    // Set a transient to display an admin notice.
                    set_transient('mepr_coupon_no_products_error_' . get_current_user_id(), true, 30);
                }
            }
        }

        return $data;
    }

    /**
     * Display admin notice when coupon is saved without memberships.
     *
     * @return void
     */
    public static function display_coupon_validation_notice()
    {
        $transient_key = 'mepr_coupon_no_products_error_' . get_current_user_id();

        if (get_transient($transient_key)) {
            delete_transient($transient_key);
            ?>
            <div class="notice notice-error is-dismissible">
                <p><strong><?php esc_html_e('ERROR', 'memberpress'); ?></strong>: <?php esc_html_e('Please select at least one Membership before publishing the coupon. The coupon has been saved as a draft.', 'memberpress'); ?></p>
            </div>
            <?php
        }
    }

    /**
     * Disable certain row actions for the coupon list table.
     *
     * @param  array   $actions The existing actions.
     * @param  WP_Post $post    The current post object.
     * @return array
     */
    public static function disable_row($actions, $post)
    {
        global $current_screen;

        if (!isset($current_screen->post_type) || $current_screen->post_type !== MeprCoupon::$cpt) {
            return $actions;
        }

        unset($actions['inline hide-if-no-js']); // Hides quick-edit.
        unset($actions['delete']); // Hides permanantely delete.

        return $actions;
    }

    /**
     * Disable certain bulk actions for the coupon list table.
     *
     * @param  array $actions The existing actions.
     * @return array
     */
    public static function disable_bulk($actions)
    {
        unset($actions['delete']); // Disables permanent delete bulk action.
        unset($actions['edit']); // Disables bulk edit.

        return $actions;
    }

    /**
     * Enqueue scripts and styles for the coupon admin page.
     *
     * @param  string $hook The current admin page hook.
     * @return void
     */
    public static function admin_enqueue_scripts($hook)
    {
        global $current_screen;

        /*
         * The coupon search field (Tom Select) renders on the coupon edit
         * screen and in the Group Coupons section of the group edit screen.
         * Both enqueues live here so the script strips together with its
         * wp_ajax_mepr_search_coupons endpoint.
         */
        if (in_array($current_screen->post_type, [MeprCoupon::$cpt, MeprGroup::$cpt], true)) {
            wp_enqueue_style('mepr-tom-select-css', MEPR_CSS_URL . '/vendor/tom-select.min.css', [], MEPR_VERSION);
            wp_enqueue_script('mepr-tom-select-js', MEPR_JS_URL . '/vendor/tom-select.complete.min.js', [], MEPR_VERSION);
            wp_enqueue_script('admin-coupon-search-js', MEPR_JS_URL . '/admin_coupon_search.js', ['jquery', 'mepr-tom-select-js'], MEPR_VERSION);
            wp_localize_script(
                'admin-coupon-search-js',
                'MeprCouponSearch',
                [
                    'ajax_url' => admin_url('admin-ajax.php'),
                    'nonce'    => wp_create_nonce('mepr_search_coupons'),
                    'l10n'     => [
                        'loading'        => __('Loading...', 'memberpress'),
                        'no_results'     => __('No coupons found.', 'memberpress'),
                        'type_to_search' => __('Type to search coupons...', 'memberpress'),
                    ],
                ]
            );
        }

        if ($current_screen->post_type === MeprCoupon::$cpt) {
            $coupon = new MeprCoupon(get_the_ID());
            if ($coupon && $coupon->is_membership_specific) {
                $membership_specific = $coupon->membership_specific;
            }

            wp_register_style('mepr-settings-table-css', MEPR_CSS_URL . '/settings_table.css', [], MEPR_VERSION);
            wp_enqueue_style('mepr-coupons-css', MEPR_CSS_URL . '/admin-coupons.css', ['mepr-settings-table-css', 'mepr-tom-select-css'], MEPR_VERSION);

            wp_register_script('mepr-settings-table-js', MEPR_JS_URL . '/settings_table.js', ['jquery'], MEPR_VERSION);
            wp_dequeue_script('autosave'); // Disable auto-saving.
            wp_enqueue_script('mepr-coupons-js', MEPR_JS_URL . '/admin_coupons.js', ['jquery', 'mepr-settings-table-js', 'admin-coupon-search-js'], MEPR_VERSION);
            wp_localize_script(
                'mepr-coupons-js',
                'MeprCoupon',
                [
                    'l10n'                => [
                        'mepr_no_products_message' => __('Please select at least one Membership before saving.', 'memberpress'),
                    ],
                    'membership_specific' => $membership_specific ?? [],
                    'coupon_id'           => (int) get_the_ID(),
                ]
            );

            MeprHooks::do_action('mepr_coupon_admin_enqueue_script', $hook);
        }
    }

    /**
     * Generate a random title code for the coupon page.
     *
     * @param  string $title The current title.
     * @return string
     */
    public static function get_page_title_code($title)
    {
        global $current_screen;

        if (empty($title) && isset($current_screen->post_type) && $current_screen->post_type === MeprCoupon::$cpt) {
            return MeprUtils::random_string(10, false, true);
        } else {
            return $title;
        }
    }

    /**
     * Search coupons via AJAX for the specific-coupons dropdown.
     *
     * Returns up to 100 results. When no search term is given, results are ordered
     * by ID DESC (most recent first) to avoid a filesort over large datasets.
     * When a search term is given, results are ordered alphabetically.
     *
     * @return void
     */
    public static function search_coupons_ajax()
    {
        check_ajax_referer('mepr_search_coupons', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error(null, 403);
        }

        global $wpdb;

        $search           = sanitize_text_field(wp_unslash($_POST['q'] ?? ''));
        $exclude          = array_values(array_filter(array_map('intval', (array) ($_POST['exclude'] ?? []))));
        $default_per_page = 100;
        $per_page         = (int) apply_filters('mepr_coupon_search_per_page', $default_per_page);
        $per_page         = $per_page > 0 ? $per_page : $default_per_page;

        $conditions = ['post_status = %s', 'post_type = %s'];
        $values     = ['publish', MeprCoupon::$cpt];

        if (!empty($search)) {
            $conditions[] = 'post_title LIKE %s';
            $values[]     = '%' . $wpdb->esc_like($search) . '%';
        }

        if (!empty($exclude)) {
            $placeholders = implode(',', array_fill(0, count($exclude), '%d'));
            $conditions[] = "ID NOT IN ($placeholders)"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            $values       = array_merge($values, $exclude);
        }

        $where    = implode(' AND ', $conditions);
        $order_by = empty($search) ? 'ID DESC' : 'post_title ASC';

        $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
            $wpdb->prepare(
                "SELECT ID, post_title FROM {$wpdb->posts} WHERE {$where} ORDER BY {$order_by} LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                ...array_merge($values, [$per_page])
            )
        );

        $results = [];
        foreach ((array) $rows as $row) {
            if (!isset($row->ID, $row->post_title)) {
                continue;
            }
            $results[] = [
                'id'   => (int) $row->ID,
                'text' => $row->post_title,
            ];
        }

        wp_send_json(['results' => $results]);
    }

    /**
     * Validate the coupon via AJAX.
     *
     * @param  string|null  $code       The coupon code.
     * @param  integer|null $product_id The product ID.
     * @return void
     */
    public static function validate_coupon_ajax($code = null, $product_id = null)
    {
        check_ajax_referer('mepr_coupons', 'coupon_nonce');

        if (empty($code) || empty($product_id)) {
            if (!isset($_POST['code']) || empty($_POST['code']) || !isset($_POST['prd_id']) || empty($_POST['prd_id'])) {
                echo 'false';
                die();
            } else {
                $code       = sanitize_text_field(wp_unslash($_POST['code']));
                $product_id = sanitize_text_field(wp_unslash($_POST['prd_id']));
            }
        }

        $is_valid = MeprCoupon::is_valid_coupon_code($code, $product_id);
        $output   = MeprHooks::apply_filters('mepr_validate_coupon', $is_valid, $code, $product_id);

        if ($output) {
            echo 'true';
        } else {
            echo 'false';
        }

        die();
    }

    /**
     * Update the usage count of a coupon.
     *
     * @param  object $object The object containing the coupon ID.
     * @return void
     */
    public static function update_coupon_usage_count($object)
    {
        if (!isset($object->coupon_id) || empty($object->coupon_id)) {
            return;
        }

        $coupon = new MeprCoupon($object->coupon_id);

        if ($coupon->ID) {
            $coupon->update_usage_count();
        }
    }

    /**
     * Handle the standalone route for front-end coupon validation.
     *
     * Runs on the mepr_standalone_route action dispatched by MeprAppCtrl.
     *
     * @param  string $controller The requested standalone controller.
     * @param  string $action     The requested action.
     * @return void
     */
    public static function standalone_route($controller, $action)
    {
        if (MeprFeatures::active('coupons') && $controller === 'coupons' && $action === 'validate') {
            self::validate_coupon_ajax(MeprAppCtrl::get_param('mepr_coupon_code'), MeprAppCtrl::get_param('mpid'));
        }
    }

    /**
     * Cached applicable coupon codes, keyed by product spl_object_id.
     *
     * Each entry pins a WeakReference to the product it was computed for.
     * PHP recycles object ids after destruction, so a bare id key could
     * serve a dead product's code to a new instance; a reference mismatch
     * discards the stale entry instead. This also caps growth at the number
     * of live product instances.
     *
     * @var array<int, array{ref: WeakReference, values: array<string, string>}>
     */
    private static $applicable_coupon_code_cache = [];

    /**
     * Apply a coupon discount to the product price before proration.
     *
     * Runs on the mepr_pre_adjusted_price filter. Deliberately does not
     * validate the code: expired or maxed-out coupons still discount at
     * display time (see the note in MeprProduct::adjusted_price()).
     *
     * @param  float       $price       The base product price.
     * @param  string|null $coupon_code The coupon code, if any.
     * @param  MeprProduct $product     The product.
     * @return float The price with the coupon discount applied.
     */
    public static function apply_code_discount($price, $coupon_code, $product)
    {
        if (!MeprFeatures::active('coupons') || empty($coupon_code)) {
            return $price;
        }

        $coupon = MeprCoupon::get_one_from_code($coupon_code);

        if ($coupon !== false) {
            $price = $coupon->apply_discount($price, $product->is_one_time_payment(), $product);
        }

        return $price;
    }

    /**
     * Null coupon codes that are not valid for the given product.
     *
     * Runs on the mepr_product_valid_coupon_code filter used by
     * MeprProduct::is_payment_required().
     *
     * @param  string|null $coupon_code The submitted coupon code.
     * @param  MeprProduct $product     The product.
     * @return string|null The code when valid, null otherwise.
     */
    public static function validate_product_coupon_code($coupon_code, $product)
    {
        if (!MeprFeatures::active('coupons')) {
            return null;
        }

        if ($coupon_code && !MeprCoupon::is_valid_coupon_code($coupon_code, $product->ID)) {
            return null;
        }

        return $coupon_code;
    }

    /**
     * Resolve a coupon code to a coupon object.
     *
     * Runs on the mepr_coupon_from_code filter, the single seam core uses
     * to interpret request-supplied codes. Supported args: ignore_status
     * (include non-publish coupons, for display of historical codes) and
     * product_id (require the code to be valid for that product).
     *
     * @param  false|MeprCoupon $coupon The incoming value (false by default).
     * @param  string           $code   The coupon code.
     * @param  array            $args   Optional resolution arguments.
     * @return false|MeprCoupon The resolved coupon or false.
     */
    public static function coupon_from_code($coupon, $code, $args = [])
    {
        if (!MeprFeatures::active('coupons') || empty($code)) {
            return $coupon;
        }

        if (!empty($args['product_id']) && !MeprCoupon::is_valid_coupon_code($code, (int) $args['product_id'])) {
            return false;
        }

        $found = MeprCoupon::get_one_from_code($code, !empty($args['ignore_status']));

        return $found !== false ? $found : $coupon;
    }

    /**
     * Resolve the applicable coupon code for a product.
     *
     * Runs on the mepr_product_applicable_coupon_code filter behind
     * MeprProduct::get_applicable_coupon_code(). Checks the request
     * parameter first, then group auto-apply schedules. Results are cached
     * per product instance and arguments, mirroring the per-instance cache
     * this logic had when it lived on MeprProduct: a fresh product object
     * always resolves fresh.
     *
     * @param  string      $code    The incoming code (empty by default).
     * @param  MeprProduct $product The product.
     * @param  array       $args    Resolution arguments: use_coupon_param,
     *                              coupon_param_source, user_id, use_cache.
     * @return string The applicable coupon code, or empty string.
     */
    public static function resolve_applicable_coupon_code($code, $product, $args)
    {
        if (!MeprFeatures::active('coupons')) {
            return $code;
        }

        $use_coupon_param    = $args['use_coupon_param'] ?? true;
        $coupon_param_source = $args['coupon_param_source'] ?? 'GET';
        $user_id             = (int) ($args['user_id'] ?? 0);
        $use_cache           = $args['use_cache'] ?? true;

        $user_id = 0 >= $user_id ? get_current_user_id() : $user_id;

        // Bind the cache entry to this exact product instance, keyed by the resolution parameters.
        $object_id = spl_object_id($product);
        $cache_key = md5(implode('|', [$use_coupon_param, $coupon_param_source, $user_id]));
        $entry     = self::$applicable_coupon_code_cache[$object_id] ?? null;

        if ($entry === null || $entry['ref']->get() !== $product) {
            self::$applicable_coupon_code_cache[$object_id] = [
                'ref'    => WeakReference::create($product),
                'values' => [],
            ];
        }

        // Return cached value if available and cache is enabled.
        if ($use_cache && isset(self::$applicable_coupon_code_cache[$object_id]['values'][$cache_key])) {
            return self::$applicable_coupon_code_cache[$object_id]['values'][$cache_key];
        }

        // First check for coupon in request parameter (if enabled).
        if ($use_coupon_param) {
            $coupon_param = MeprCouponsHelper::validate_coupon_param($product->ID, $coupon_param_source, $user_id);
            if (!empty($coupon_param)) {
                self::$applicable_coupon_code_cache[$object_id]['values'][$cache_key] = $coupon_param;
                return $coupon_param;
            }
        }

        // Get auto-apply coupons (filtered by time applicability).
        $coupons_by_product = MeprProductsHelper::get_auto_apply_coupons_for_products([$product->ID], true, $use_cache);
        $auto_apply_coupons = $coupons_by_product[$product->ID] ?? [];

        // Loop through auto-apply coupons and return the first valid one.
        foreach ($auto_apply_coupons as $coupon_data) {
            $coupon_id = (int) ($coupon_data[MeprGroup::$group_coupon_coupon_id_str] ?? 0);
            if ($coupon_id > 0) {
                $coupon = new MeprCoupon($coupon_id);
                if ($coupon->ID && $coupon->is_valid($product->ID, $user_id, false)) {
                    self::$applicable_coupon_code_cache[$object_id]['values'][$cache_key] = $coupon->post_title; // Coupon code is stored in post_title.
                    return $coupon->post_title;
                }
            }
        }

        self::$applicable_coupon_code_cache[$object_id]['values'][$cache_key] = '';
        return '';
    }

    /**
     * Add the invalid-coupon error to signup validation.
     *
     * Runs on the mepr_validate_signup filter applied at both checkout
     * call sites, replacing the block that lived in
     * MeprUser::validate_signup().
     *
     * @param  array $errors The validation errors.
     * @return array The errors including the coupon error when applicable.
     */
    public static function validate_signup_coupon($errors)
    {
        if (!MeprFeatures::active('coupons')) {
            return $errors;
        }

        $code = isset($_POST['mepr_coupon_code']) ? sanitize_text_field(wp_unslash($_POST['mepr_coupon_code'])) : '';

        if ($code !== '') {
            $product_id = isset($_POST['mepr_product_id']) ? (int) $_POST['mepr_product_id'] : 0;

            if (!MeprCoupon::is_valid_coupon_code($code, $product_id)) {
                $errors['mepr_coupon_code'] = __('Your coupon code is invalid.', 'memberpress');
            }
        }

        return $errors;
    }

    /**
     * Resolve the coupon for a price shortcode.
     *
     * Runs on the mepr_price_shortcode_coupon filter. Handles the three
     * shortcode shapes: no coupon attribute (auto-apply only), coupon
     * "param" (request parameter), and a direct code with optional
     * auto-apply fallback.
     *
     * @param  MeprCoupon|null $coupon     The incoming value (null by default).
     * @param  array           $atts       Shortcode attributes.
     * @param  MeprProduct     $membership The product.
     * @return MeprCoupon|null The resolved coupon, or null if none found.
     */
    public static function resolve_price_shortcode_coupon($coupon, array $atts, MeprProduct $membership)
    {
        if (!MeprFeatures::active('coupons')) {
            return $coupon;
        }

        $found = false;

        // With no coupon attribute, use auto-apply coupons only, unless with_auto_coupon is false.
        if (!isset($atts['coupon'])) {
            $coupon_code = ($atts['with_auto_coupon'] ?? true) !== 'false' ?
                $membership->get_applicable_coupon_code(false) : null;
        } else {
            // Extract and sanitize coupon value once.
            $coupon_value     = sanitize_text_field($atts['coupon']);
            $with_auto_coupon = ($atts['with_auto_coupon'] ?? true) !== 'false';
            $is_param_coupon  = $coupon_value === 'param';

            if ($is_param_coupon) {
                // Coupon from request parameter.
                $coupon_code = $with_auto_coupon
                    ? $membership->get_applicable_coupon_code(true, 'REQUEST')
                    : MeprCouponsHelper::validate_coupon_param($membership->ID, 'REQUEST');
            } else {
                // Direct coupon code provided.
                $coupon_code = $coupon_value;
                if ($with_auto_coupon) {
                    // Validate direct coupon and fall back to auto-apply if invalid.
                    $found = MeprCoupon::get_one_from_code($coupon_code);
                    if ($found === false || ! $found->is_valid($membership->ID)) {
                        $coupon_code = $membership->get_applicable_coupon_code(false);
                        $found       = false;
                    }
                }
            }
        }

        // Fetch coupon object once at the end.
        if (empty($coupon_code)) {
            return null;
        }

        $found = $found === false ? MeprCoupon::get_one_from_code($coupon_code) : $found;
        return $found !== false ? $found : null;
    }

    /**
     * Recompute the invoice line amount and coupon row for a couponed order.
     *
     * Runs on the mepr_invoice filter. Core builds the invoice with the
     * plain transaction amount and an empty coupon row; this restores the
     * pre-discount line amount and fills the coupon row, preserving the
     * exact tax-inclusive and trial semantics that used to live in
     * MeprTransactionsHelper.
     *
     * @param  array                 $invoice The invoice array.
     * @param  MeprTransaction       $txn     The transaction.
     * @param  MeprSubscription|null $sub     The subscription, if any.
     * @return array The invoice with coupon data applied.
     */
    public static function add_invoice_coupon_row($invoice, $txn, $sub = null)
    {
        return self::recompute_invoice_coupon($invoice, $txn, $sub);
    }

    /**
     * Recompute the order-bumps invoice coupon data.
     *
     * Runs on the mepr_invoice_order_bumps filter; see add_invoice_coupon_row().
     *
     * @param  array                 $invoice     The invoice array.
     * @param  MeprTransaction       $txn         The transaction.
     * @param  array                 $order_bumps The order bump tuples.
     * @param  MeprSubscription|null $sub         The subscription, if any.
     * @return array The invoice with coupon data applied.
     */
    public static function add_order_bumps_invoice_coupon_row($invoice, $txn, $order_bumps = [], $sub = null)
    {
        return self::recompute_invoice_coupon($invoice, $txn, $sub);
    }

    /**
     * Shared invoice coupon recomputation for both invoice filters.
     *
     * @param  array                 $invoice The invoice array.
     * @param  MeprTransaction       $txn     The transaction.
     * @param  MeprSubscription|null $sub     The subscription, if any.
     * @return array The invoice with the line amount and coupon row updated.
     */
    private static function recompute_invoice_coupon($invoice, $txn, $sub)
    {
        if (!MeprFeatures::active('coupons') || empty($invoice['items'])) {
            return $invoice;
        }

        $mepr_options                 = MeprOptions::fetch();
        $prd                          = $txn->product();
        $calculate_taxes              = (bool) get_option('mepr_calculate_taxes');
        $tax_inclusive                = $mepr_options->attr('tax_calc_type') === 'inclusive';
        $show_negative_tax_on_invoice = get_option('mepr_show_negative_tax_on_invoice');

        $coupon = $txn->coupon();
        if ($coupon) {
            $raw_amount = MeprUtils::maybe_round_to_minimum_amount($prd->price);
            $amount     = $raw_amount;

            if ($show_negative_tax_on_invoice && $txn->tax_reversal_amount > 0) {
                $cpn_amount = MeprUtils::format_float((float) $amount - (float) $txn->amount - (float) $txn->tax_reversal_amount);
            } else {
                $remove_tax = $calculate_taxes && $tax_inclusive && $txn->tax_rate > 0;
                if ($remove_tax) {
                    $amount = ($amount / (1 + ($txn->tax_rate / 100)));
                }
                if (!$remove_tax && $prd->trial && (float) $txn->amount === 0.00) {
                    $amount = 0;
                    if ($coupon->get_discount_type($prd) === 'percent' && (int) $coupon->get_discount_amount($prd) === 100) {
                        $amount = $raw_amount;
                    }
                }
                $cpn_amount = MeprUtils::format_float((float) $amount - (float) $txn->amount);
            }

            $cpn_id   = $coupon->ID;
            $cpn_desc = sprintf(
                // Translators: %s: coupon code.
                __("Coupon Code '%s'", 'memberpress'),
                $coupon->post_title
            );
        } elseif ($sub && ($coupon = $sub->coupon())) { // phpcs:ignore Generic.CodeAnalysis.AssignmentInCondition.Found
            if ($coupon->get_discount_mode($prd) === 'trial-override' && $sub->trial) {
                $amount = MeprUtils::maybe_round_to_minimum_amount($prd->trial_amount);
            } else {
                $amount = $sub->price;
            }

            $cpn_id     = $coupon->ID;
            $cpn_desc   = sprintf(
                // Translators: %s: coupon code.
                __("Coupon Code '%s'", 'memberpress'),
                $coupon->post_title
            );
            $cpn_amount = MeprUtils::format_float((float) $amount - (float) $txn->amount);
        } else {
            return $invoice;
        }

        $invoice['items'][0]['amount'] = $amount;
        $invoice['coupon']             = [
            'id'     => $cpn_id,
            'desc'   => $cpn_desc,
            'amount' => $cpn_amount,
        ];

        /*
         * Reduced-trial presentation rule: when the subscription is in
         * trial and either the trial-override amount exceeds the product
         * price or the paid trial amount undercuts it, the invoice hides
         * the coupon line and shows the charged amount on the line item.
         * The row resets to the empty scaffold rather than unsetting so
         * the post-filter subtotal math in core still finds an amount.
         */
        if ($sub instanceof MeprSubscription && $sub->trial) {
            $sub_prd = $sub->product();

            if (
                ($coupon->get_discount_mode($sub_prd) === 'trial-override' && $coupon->get_trial_amount($sub_prd) > $sub_prd->price)
                || ($sub->trial_amount > 0 && $sub->trial_amount < $sub_prd->price)
            ) {
                $invoice['items'][0]['amount'] = $txn->amount;
                $invoice['coupon']             = [
                    'id'     => 0,
                    'desc'   => '',
                    'amount' => 0.00,
                ];
            }
        }

        return $invoice;
    }

    /**
     * Get the count of published coupons.
     *
     * @return integer The number of published coupons.
     */
    public static function get_coupon_count()
    {
        $counts = wp_count_posts(MeprCoupon::$cpt);

        return (int) ($counts->publish ?? 0);
    }

    /**
     * Answer the mepr_coupon_count filter.
     *
     * @param  integer $count The incoming count (0 by default).
     * @return integer The number of published coupons.
     */
    public static function filter_coupon_count($count)
    {
        if (!MeprFeatures::active('coupons')) {
            return $count;
        }

        return self::get_coupon_count();
    }

    /**
     * Announce that the coupons module is loaded and active.
     *
     * Runs early on init so listeners registered during plugin boot (e.g.
     * the Easy Affiliate integration) can attach their coupon glue.
     *
     * @return void
     */
    public static function announce_loaded()
    {
        if (!MeprFeatures::active('coupons')) {
            return;
        }

        MeprHooks::do_action('mepr_coupons_loaded');
    }

    /**
     * Render the checkout coupon field or its hidden fallback input.
     *
     * Runs on the mepr_checkout_coupon_field action in every checkout form
     * variant. When the field is disabled (or the feature kill switch is
     * on) the hidden input still carries a pre-filled code through the
     * form, exactly as the inline markup used to.
     *
     * @param  MeprProduct $product          The product being purchased.
     * @param  string      $mepr_coupon_code The submitted or applicable code.
     * @param  array       $ctx              Render context: field_id and hidden_id.
     * @return void
     */
    public static function render_checkout_coupon_field($product, $mepr_coupon_code = '', $ctx = [])
    {
        $mepr_options = MeprOptions::fetch();
        $field_id     = $ctx['field_id'] ?? 'mepr_coupon_code-' . $product->ID;

        if (MeprFeatures::active('coupons') && $mepr_options->coupon_field_enabled) {
            MeprView::render('/checkout/coupon_field', [
                'product'          => $product,
                'mepr_coupon_code' => (string) $mepr_coupon_code,
                'field_id'         => $field_id,
            ]);
            return;
        }

        $hidden_id = $ctx['hidden_id'] ?? null;

        printf(
            '<input type="hidden"%s name="mepr_coupon_code" value="%s" />',
            $hidden_id ? ' id="' . esc_attr($hidden_id) . '"' : '',
            esc_attr(stripslashes((string) $mepr_coupon_code))
        );
    }

    /**
     * Render the coupon field toggle on the options registration section.
     *
     * Runs on the mepr_options_signup_fields action.
     *
     * @param  MeprOptions $mepr_options The options object.
     * @return void
     */
    public static function render_options_field($mepr_options)
    {
        if (!MeprFeatures::active('coupons')) {
            return;
        }

        MeprView::render('/admin/coupons/options_field', compact('mepr_options'));
    }

    /**
     * Render the group coupons section on the group edit form.
     *
     * Runs on the mepr_admin_group_form action.
     *
     * @param  MeprGroup   $group        The group being edited.
     * @param  MeprOptions $mepr_options The options object.
     * @return void
     */
    public static function render_group_form_section($group, $mepr_options)
    {
        if (!MeprFeatures::active('coupons')) {
            return;
        }

        MeprView::render('/admin/groups/parts/group-coupons', compact('group', 'mepr_options'));
    }

    /**
     * Render the coupons help topic on the support page.
     *
     * Runs on the mepr_admin_support_help_topics action.
     *
     * @return void
     */
    public static function render_support_help_topic()
    {
        if (!MeprFeatures::active('coupons')) {
            return;
        }
        ?>
    <div class="mepr-support-help-topic">
      <a target="_blank" href="<?php echo esc_url(MeprUtils::get_link_url('docs_coupons')); ?>">
        <h3><?php esc_html_e('Creating and Managing Coupons', 'memberpress'); ?></h3>
        <p><?php esc_html_e('Create and manage coupons for discounts and promotions.', 'memberpress'); ?></p>
      </a>
    </div>
        <?php
    }

    /**
     * Render the coupon-filter heading above the transactions list table.
     *
     * Runs on the mepr_admin_transactions_list_header action when the list
     * is filtered by a coupon_id query argument.
     *
     * @return void
     */
    public static function render_transactions_list_header()
    {
        if (!MeprFeatures::active('coupons') || empty($_GET['coupon_id'])) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            return;
        }

        $coupon = new MeprCoupon((int) $_GET['coupon_id']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended

        if ($coupon->ID > 0) :
            ?>
        <h3>
            <?php esc_html_e('All Transactions for Coupon', 'memberpress'); ?>:
            <span id="txn-coupon-title"><?php echo esc_html($coupon->post_title); ?></span>
        </h3>
            <?php
        endif;
    }

    /**
     * Add the Coupons node to the plugin's admin-bar menu.
     *
     * Runs on the mepr_admin_bar_nodes filter.
     *
     * @param  array $nodes The node definitions to add.
     * @return array The nodes including the Coupons entry.
     */
    public static function add_admin_bar_node($nodes)
    {
        if (!MeprFeatures::active('coupons')) {
            return $nodes;
        }

        $nodes[] = [
            'id'     => 'mepr_admin_bar_coupons',
            'parent' => 'mepr_admin_bar',
            'title'  => __('Coupons', 'memberpress'),
            'href'   => admin_url('edit.php?post_type=' . MeprCoupon::$cpt),
        ];

        return $nodes;
    }

    /**
     * Register the coupon post type as plugin-owned for admin list tables.
     *
     * Runs on the mepr_admin_list_table_post_types filter that backs the
     * month-dropdown cleanup on every admin list table.
     *
     * @param  string[] $post_types The plugin-owned post types.
     * @return string[] The post types including the coupon CPT.
     */
    public static function add_list_table_post_type($post_types)
    {
        $post_types[] = MeprCoupon::$cpt;

        return $post_types;
    }

    /**
     * Handle the Stripe coupon.deleted webhook event.
     *
     * Runs on the mepr_stripe_event_coupon.deleted action fired by the
     * gateway for events it does not handle itself.
     *
     * @param  object $obj     The Stripe event object.
     * @param  object $gateway The Stripe gateway instance.
     * @return void
     */
    public static function handle_stripe_coupon_deleted($obj, $gateway)
    {
        MeprCoupon::delete_stripe_coupon_id($gateway->get_meta_gateway_id(), $obj->id);
    }

    /**
     * Add the create-coupon growth action to the dashboard NBA recommendations.
     *
     * Runs on the mepr_dashboard_nba_actions filter so the action only exists
     * when the coupons feature ships with the build.
     *
     * @param  array $actions The available NBA actions.
     * @return array The actions including the create-coupon suggestion when applicable.
     */
    public static function add_nba_growth_action($actions)
    {
        if (!MeprFeatures::active('coupons')) {
            return $actions;
        }

        if (MeprDashboardHelper::is_launch_phase() || self::get_coupon_count() > 0) {
            return $actions;
        }

        if (MeprDashboardHelper::get_total_revenue() <= 0) {
            return $actions;
        }

        $actions[] = [
            'id'           => 'create_coupon',
            'title'        => __('Boost sales with a promotion', 'memberpress'),
            'description'  => __('Create a discount coupon to attract new members or reward existing ones.', 'memberpress'),
            'action_url'   => admin_url('post-new.php?post_type=' . MeprCoupon::$cpt),
            'action_text'  => __('Create Coupon', 'memberpress'),
            'effort'       => __('Takes ~2 minutes', 'memberpress'),
            'type'         => MeprNextBestActionHelper::TYPE_GROWTH,
            'priority'     => 6,
            'applicable'   => true,
            'reach'        => 60,
            'impact'       => 2,
            'confidence'   => 80,
            'effort_score' => 0.5,
        ];

        return $actions;
    }

    /**
     * Add coupon-driven cross-sell actions to the dashboard NBA recommendations.
     *
     * Suggests Pretty Links once a site manages several coupons. Runs on the
     * mepr_nba_cross_sell_actions filter so the suggestion disappears together
     * with the coupons feature.
     *
     * @param  array   $actions       The cross-sell actions.
     * @param  float   $total_revenue The total revenue.
     * @param  integer $coupon_count  The number of published coupons.
     * @return array The actions including the Pretty Links suggestion when applicable.
     */
    public static function add_nba_cross_sell_actions($actions, $total_revenue, $coupon_count)
    {
        if (!MeprFeatures::active('coupons')) {
            return $actions;
        }

        if ($coupon_count >= 3 && !is_plugin_active('pretty-link/pretty-link.php')) {
            $actions[] = [
                'id'           => 'install_pretty_links',
                'title'        => __('Managing lots of promo links?', 'memberpress'),
                'description'  => __('Pretty Links can organize and track your promotional links for better marketing insights.', 'memberpress'),
                'action_url'   => MeprNextBestActionHelper::get_utm_url('https://prettylinks.com/', 'pretty_links'),
                'action_text'  => __('Learn More', 'memberpress'),
                'effort'       => __('Takes ~1 minute', 'memberpress'),
                'type'         => MeprNextBestActionHelper::TYPE_CROSS_SELL,
                'priority'     => 10,
                'applicable'   => true,
                'reach'        => 40,
                'impact'       => 1,
                'confidence'   => 70,
                'effort_score' => 0.5,
            ];
        }

        return $actions;
    }

    /**
     * Add the create-coupon step to the post-setup checklist optional steps.
     *
     * Runs on the mepr_post_setup_checklist_optional_steps filter so the step
     * only exists when the coupons feature ships with the build.
     *
     * @param  array $steps The optional checklist steps.
     * @return array The steps with the create-coupon step prepended.
     */
    public static function add_checklist_optional_step($steps)
    {
        if (!MeprFeatures::active('coupons')) {
            return $steps;
        }

        array_unshift($steps, [
            'id'          => 'create_coupon',
            'title'       => __('Create a coupon', 'memberpress'),
            'description' => __('Create a discount coupon for promotions.', 'memberpress'),
            'completed'   => self::get_coupon_count() > 0,
            'action_url'  => admin_url('post-new.php?post_type=' . MeprCoupon::$cpt),
            'action_text' => __('Create Coupon', 'memberpress'),
        ]);

        return $steps;
    }
}
