<?php
/**
 * Webhook handler class for processing Stripe events.
 */

// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class STRP_SUB_Webhook_Handler {
    
    /**
     * Single instance
     */
    private static $instance = null;
    
    /**
     * Get instance
     */
    public static function get_instance() {
        if ( null === self::$instance ) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    
    /**
     * Constructor
     */
    private function __construct() {
        // Register REST API endpoint for webhooks
        add_action( 'rest_api_init', array( $this, 'register_webhook_endpoint' ) );

        // Register Action Scheduler handler for async emails
        add_action( 'strp_sub_send_async_email', array( $this, 'handle_async_email' ), 10, 3 );

        // Register daily cleanup of processed sessions table.
        add_action( 'strp_sub_cleanup_processed_sessions', array( $this, 'cleanup_processed_sessions' ) );
    }

    /**
     * Schedule the daily cleanup cron event. Called on plugin activation.
     */
    public static function schedule_cleanup_cron() {
        if ( ! wp_next_scheduled( 'strp_sub_cleanup_processed_sessions' ) ) {
            wp_schedule_event( time(), 'daily', 'strp_sub_cleanup_processed_sessions' );
        }
    }

    private function __clone() {}

    public function __wakeup() {
        throw new \RuntimeException( 'Cannot unserialize singleton.' );
    }

    // ── Idempotency (processed sessions) ──────────────────────────────────

    /**
     * Create the wp_strp_sub_processed_sessions table used for atomic deduplication.
     * Safe to call repeatedly — dbDelta handles the "already exists" case.
     * Called on plugin activation (see stripe-sub-manager.php::activate()).
     */
    public static function create_sessions_table() {
        global $wpdb;
        $table           = $wpdb->prefix . 'strp_sub_processed_sessions';
        $charset_collate = $wpdb->get_charset_collate();

        $sql = "CREATE TABLE {$table} (
            id           bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            session_id   varchar(255)        NOT NULL,
            processed_at datetime            NOT NULL DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            UNIQUE KEY session_id (session_id)
        ) {$charset_collate};";

        require_once ABSPATH . 'wp-admin/includes/upgrade.php';
        dbDelta( $sql );
    }

    /**
     * Attempt to mark a Stripe session as processed using a DB-level UNIQUE constraint.
     *
     * Because the INSERT fails atomically on a duplicate key at the database level,
     * this guard works correctly even when using Redis or Memcached object caches
     * (where PHP transients are NOT guaranteed to be atomic).
     *
     * @param  string $session_id Stripe checkout.session.completed ID.
     * @return bool               TRUE  if the lock was acquired (first processor wins).
     *                            FALSE if the session was already being / has been processed.
     */
    private function acquire_session_lock( $session_id ) {
        global $wpdb;

        // Fast pre-check with transient (avoids a DB write on obvious duplicates).
        $transient_key = 'strp_sub_ps_' . $session_id;
        if ( get_transient( $transient_key ) ) {
            return false;
        }

        // Atomic INSERT — will fail with a duplicate-key error if another process
        // already inserted the same session_id.
        $inserted = $wpdb->insert(
            $wpdb->prefix . 'strp_sub_processed_sessions',
            array(
                'session_id'   => $session_id,
                'processed_at' => current_time( 'mysql' ),
            ),
            array( '%s', '%s' )
        );

        if ( $inserted === false ) {
            // Duplicate key or other DB error — another process already claimed this session.
            return false;
        }

        // Belt-and-suspenders: also set a short transient to short-circuit rapid retries.
        set_transient( $transient_key, true, 300 );
        return true;
    }

    /**
     * Remove processed session records older than 7 days to prevent unbounded table growth.
     * Scheduled via WordPress cron (daily).
     */
    public function cleanup_processed_sessions() {
        global $wpdb;
        $table = $wpdb->prefix . 'strp_sub_processed_sessions';
        $wpdb->query( $wpdb->prepare(
            "DELETE FROM {$table} WHERE processed_at < DATE_SUB(UTC_TIMESTAMP(), INTERVAL %d DAY)",
            7
        ) );
    }

    // ── Email helpers ──────────────────────────────────────────────────────

    /**
     * Return the WooCommerce my-account URL, falling back to the site home URL.
     */
    private function myaccount_url() {
        return function_exists( 'wc_get_page_permalink' )
            ? wc_get_page_permalink( 'myaccount' )
            : home_url( '/' );
    }

    /**
     * Wrap an HTML email body in a minimal container and send via wp_mail().
     *
     * @param string $to      Recipient email address.
     * @param string $subject Email subject line.
     * @param string $body    HTML body content (partial — will be wrapped).
     * @return bool           wp_mail() return value.
     */
    private function send_email( $to, $subject, $body ) {
        $site_name = get_bloginfo( 'name' );

        if ( function_exists( 'wc_get_template_html' ) ) {
            $html = wc_get_template_html(
                'emails/email-wrapper.php',
                array(
                    'body'      => $body,
                    'site_name' => $site_name,
                ),
                'stripe-sub-manager/',
                STRP_SUB_PLUGIN_DIR . 'templates/'
            );
        } else {
            // Fallback rendering if WooCommerce core is totally disabled.
            // Ensure template variables are explicitly available in scope.
            $template_body      = $body;
            $template_site_name = $site_name;
            ob_start();
            include STRP_SUB_PLUGIN_DIR . 'templates/emails/email-wrapper.php';
            $html = ob_get_clean();
        }

        return wp_mail( $to, $subject, $html, array( 'Content-Type: text/html; charset=UTF-8' ) );
    }

    /**
     * Action Scheduler callback for sending async emails.
     * Prevents SMTP timeouts from blocking webhook processing.
     *
     * @internal Used as Action Scheduler callback only.
     */
    public function handle_async_email( $to, $subject, $body ) {
        $this->send_email( $to, $subject, $body );
    }

    /**
     * Return a styled call-to-action button for HTML emails.
     *
     * @param string $url   Button URL.
     * @param string $label Button label text.
     * @return string       HTML string.
     */
    private function email_button( $url, $label ) {
        return '<p style="text-align:center;margin:24px 0;">'
             . '<a href="' . esc_url( $url ) . '" '
             . 'style="background:#4f46e5;color:#ffffff;padding:12px 28px;'
             . 'border-radius:4px;text-decoration:none;display:inline-block;font-weight:600;">'
             . esc_html( $label ) . '</a></p>';
    }
    
    /**
     * Register webhook endpoint
     */
    public function register_webhook_endpoint() {
        register_rest_route( 'stripe-sub/v1', '/webhook', array(
            'methods' => 'POST',
            'callback' => array( $this, 'handle_webhook' ),
            'permission_callback' => '__return_true',
        ) );
    }
    
    /**
     * Main webhook handler
     */
    public function handle_webhook( $request ) {
        if ( ! function_exists( 'wc_get_order' ) ) {
            STRP_SUB_Logger::error( 'Webhook received but WooCommerce is inactive.' );
            return new WP_REST_Response( array( 'error' => 'WooCommerce is required' ), 400 );
        }

        $payload = $request->get_body();

        // Try multiple ways to get the Stripe signature header
        // Stripe sends "Stripe-Signature" but WordPress/REST API may convert it
        $sig_header = $request->get_header( 'stripe_signature' );
        if ( empty( $sig_header ) ) {
            $sig_header = $request->get_header( 'stripe-signature' );
        }
        if ( empty( $sig_header ) ) {
            // Fallback to raw $_SERVER headers
            $sig_header = isset( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_STRIPE_SIGNATURE'] ) ) : '';
        }

        if ( empty( $sig_header ) ) {
            STRP_SUB_Logger::error( 'Webhook signature header not found' );
            return new WP_REST_Response( array( 'error' => 'Invalid request' ), 400 );
        }

        $webhook_secret = STRP_SUB_Settings::get_webhook_secret();

        if ( empty( $webhook_secret ) ) {
            STRP_SUB_Logger::error( 'Webhook secret not configured' );
            return new WP_REST_Response( array( 'error' => 'Configuration error' ), 400 );
        }

        try {
            $event = \STRP_SUB_Vendor\Stripe\Webhook::constructEvent(
                $payload,
                $sig_header,
                $webhook_secret
            );
        } catch ( \UnexpectedValueException $e ) {
            // Invalid payload
            STRP_SUB_Logger::error( 'Invalid webhook payload: ' . $e->getMessage() );
            return new WP_REST_Response( array( 'error' => 'Invalid payload' ), 400 );
        } catch ( \STRP_SUB_Vendor\Stripe\Exception\SignatureVerificationException $e ) {
            // Invalid signature
            STRP_SUB_Logger::error( 'Invalid webhook signature: ' . $e->getMessage() );
            STRP_SUB_Logger::debug( 'Signature header present: ' . ( ! empty( $sig_header ) ? 'yes' : 'no' ) );
            STRP_SUB_Logger::debug( 'Webhook secret configured: ' . ( ! empty( $webhook_secret ) ? 'yes' : 'no' ) );
            return new WP_REST_Response( array( 'error' => 'Invalid signature' ), 400 );
        }
        
        // Handle the event with retry mechanism
        STRP_SUB_Logger::log( 'Received webhook event: ' . $event->type );

        try {
            switch ( $event->type ) {
                case 'checkout.session.completed':
                    $this->handle_checkout_session_completed( $event->data->object );
                    break;

                case 'invoice.created':
                    $this->handle_invoice_created( $event->data->object );
                    break;

                case 'invoice.paid':
                    $this->handle_invoice_paid( $event->data->object );
                    break;

                case 'invoice.payment_failed':
                    $this->handle_invoice_payment_failed( $event->data->object );
                    break;

                case 'customer.subscription.deleted':
                    $this->handle_subscription_deleted( $event->data->object, $event );
                    break;

                case 'customer.subscription.updated':
                    $this->handle_subscription_updated( $event->data->object, $event );
                    break;

                case 'charge.refunded':
                    $this->handle_charge_refunded( $event->data->object );
                    break;

                case 'charge.dispute.closed':
                    $this->handle_charge_dispute_closed( $event->data->object );
                    break;

                default:
                    STRP_SUB_Logger::log( 'Unhandled webhook event type: ' . $event->type );
                    break;
            }

            // Mark webhook as successfully processed (remove from retry queue if it was there)
            if ( class_exists( 'STRP_SUB_Webhook_Retry' ) ) {
                $retry_manager = STRP_SUB_Webhook_Retry::get_instance();
                $retry_manager->mark_success( $event->id );
            }

            // Log successful event to webhook monitor
            if ( class_exists( 'STRP_SUB_Webhook_Monitor' ) ) {
                STRP_SUB_Webhook_Monitor::get_instance()->log_event( $event->id, $event->type, 'success' );
            }

        } catch ( Exception $e ) {
            // Log the error
            $error_message = sprintf(
                'Webhook processing failed for %s: %s (File: %s, Line: %d)',
                $event->type,
                $e->getMessage(),
                $e->getFile(),
                $e->getLine()
            );
            STRP_SUB_Logger::error( $error_message );

            // Log failed event to webhook monitor
            if ( class_exists( 'STRP_SUB_Webhook_Monitor' ) ) {
                STRP_SUB_Webhook_Monitor::get_instance()->log_event( $event->id, $event->type, 'failed' );
            }

            // Queue for retry (Starter+)
            if ( class_exists( 'STRP_SUB_Webhook_Retry' ) ) {
                $retry_manager = STRP_SUB_Webhook_Retry::get_instance();
                $retry_manager->log_failure( $event, $error_message );
            }

            // Return 500 to Stripe to trigger exponential backoff retry mechanism correctly
            return new WP_REST_Response( array(
                'success' => false,
                'message' => 'Processing error'
            ), 500 );
        }

        return new WP_REST_Response( array( 'success' => true ), 200 );
    }
    
    public function handle_checkout_session_completed( $session ) {
        STRP_SUB_Logger::log( '=== WEBHOOK STARTED: checkout.session.completed ===' );
        STRP_SUB_Logger::log( 'Session ID: ' . $session->id );
        STRP_SUB_Logger::log( 'Customer: ' . $session->customer );
        STRP_SUB_Logger::log( 'Mode: ' . $session->mode );

        // RACE CONDITION PROTECTION: Atomic DB-level lock via UNIQUE INSERT.
        // This works correctly under Redis/Memcached unlike a bare transient.
        if ( ! $this->acquire_session_lock( $session->id ) ) {
            STRP_SUB_Logger::log( 'Session already processed or being processed (duplicate lock rejected): ' . $session->id );
            return;
        }
        STRP_SUB_Logger::log( 'Acquired processing lock for session: ' . $session->id );

        try {

            // Bail early if we already created an order for this session.
            $existing_order = $this->get_order_by_session_id( $session->id );
            if ( $existing_order ) {
                STRP_SUB_Logger::log( 'Order already exists for session: ' . $session->id );
                return;
            }

            // --- Step 1: Propagate metadata to related Stripe objects ----------------
            $metadata_array = $this->process_metadata_propagation( $session );
            $affiliate_ref  = $metadata_array['affiliate_ref'] ?? '';

            // --- Step 2: Resolve/create WP user --------------------------------------
            list( $user_id, $is_new_user_creation ) = $this->handle_guest_user_creation( $session );

            if ( $user_id === 0 ) {
                STRP_SUB_Logger::error( 'Guest user creation failed for session ' . $session->id . ' (email: ' . ( $session->customer_details->email ?? 'unknown' ) . '). Order will be created but access will NOT be granted — manual intervention required.' );
                wp_mail(
                    get_option( 'admin_email' ),
                    __( '[Action Required] Failed user creation for Stripe payment', 'subscription-manager-for-stripe' ),
                    sprintf(
                        __( "A customer completed checkout (Stripe session %s) but WordPress user creation failed.\n\nCustomer email: %s\n\nThe payment was collected and an order was created, but the customer has NOT been granted access.\n\nPlease create the user account and grant access manually.", 'subscription-manager-for-stripe' ),
                        $session->id,
                        $session->customer_details->email ?? __( 'unknown', 'subscription-manager-for-stripe' )
                    )
                );
            }

            // --- Step 3: Create WooCommerce order ------------------------------------
            $subscription_type = $session->metadata->subscription_type ?? '';
            $is_lifetime       = ( $subscription_type === 'lifetime' || $session->mode === 'payment' );

            $subscription = null;
            if ( ! $is_lifetime && ! empty( $session->subscription ) ) {
                $stripe_api   = STRP_SUB_Stripe_API::get_instance();
                $subscription = $stripe_api->get_subscription( $session->subscription );
                STRP_SUB_Logger::log( 'Retrieved subscription: ' . $session->subscription );
            }

            $order = STRP_SUB_Checkout_Handler::create_order_from_session( $session, $subscription );

            if ( ! $order ) {
                throw new \RuntimeException( 'Order creation failed for session: ' . $session->id );
            }

            STRP_SUB_Logger::log( 'Successfully created order #' . $order->get_id() );

            // Link order to the resolved WP user if necessary.
            if ( $user_id > 0 && $order->get_customer_id() == 0 ) {
                $order->set_customer_id( $user_id );
                $order->save();
                STRP_SUB_Logger::log( 'Linked order #' . $order->get_id() . ' to user #' . $user_id );
            }

            // Clear active checkout session tracking.
            if ( $user_id > 0 ) {
                delete_transient( 'strp_sub_active_checkout_' . $user_id );
            }

            // Fire hook so the affiliate plugin can record a commission.
            do_action( 'strp_sub_order_created_from_session', $order->get_id(), $session );

            // --- Step 4: Persist subscription state & send emails -------------------
            if ( $user_id > 0 ) {
                if ( $is_lifetime ) {
                    update_user_meta( $user_id, '_strp_sub_has_lifetime_access', 'yes' );
                    update_user_meta( $user_id, '_strp_sub_subscription_status', 'lifetime' );
                    STRP_SUB_Logger::log( 'Set lifetime access for user #' . $user_id );

                } elseif ( $subscription ) {
                    update_user_meta( $user_id, '_strp_sub_subscription_status', $subscription->status );
                    update_user_meta( $user_id, '_strp_sub_active_subscription_id', $subscription->id );
                    update_user_meta( $user_id, '_strp_sub_stripe_customer_id', $session->customer );

                    // Remove WP trial metadata that may have been set during a free trial grant.
                    delete_user_meta( $user_id, '_strp_sub_has_lifetime_access' );

                    // Step 4a: Handle trial-specific logic (fraud check, trial tracking — Starter+).
                    if ( $subscription->status === 'trialing' ) {
                        if ( function_exists( 'strp_sub_fs' ) && strp_sub_fs() && strp_sub_fs()->is__premium_only() ) {
                            $this->process_trial_fraud_check( $user_id, $subscription->id, $order );
                        }
                    }

                    // Step 4b: Send the appropriate welcome email.
                    $is_renewal = isset( $session->metadata->renewal ) && $session->metadata->renewal === 'true';
                    if ( $is_renewal ) {
                        $this->send_renewal_welcome_email( $user_id, $subscription );
                    } elseif ( ! $is_new_user_creation ) {
                        $this->send_new_subscription_welcome_email( $user_id, $subscription );
                        STRP_SUB_Logger::log( 'Sent new subscription welcome email to existing user #' . $user_id );
                    } else {
                        STRP_SUB_Logger::log( 'Skipped subscription welcome email for new user #' . $user_id . ' (already received account creation email)' );
                    }
                }

                // Step 5: Cancel old subscription on upgrades.
                if ( ! empty( $session->metadata->old_subscription_id ) ) {
                    $old_sub_id  = $session->metadata->old_subscription_id;
                    $stripe_api  = STRP_SUB_Stripe_API::get_instance();
                    $cancelled   = $stripe_api->cancel_subscription( $old_sub_id, false );
                    STRP_SUB_Logger::log( 'Upgrade: cancel old subscription ' . $old_sub_id . ' — ' . ( $cancelled ? 'OK' : 'FAILED' ) );

                    $note = $cancelled
                        ? sprintf( __( 'Previous subscription %s cancelled due to upgrade.', 'subscription-manager-for-stripe' ), $old_sub_id )
                        : sprintf( __( 'WARNING: Failed to automatically cancel old subscription %s. Please cancel manually in Stripe.', 'subscription-manager-for-stripe' ), $old_sub_id );
                    $order->add_order_note( $note );
                }
            }

        } catch ( Exception $e ) {
            STRP_SUB_Logger::error( 'checkout.session.completed processing failed for session ' . $session->id . ': ' . $e->getMessage() );
            throw $e; // Re-throw so the outer webhook handler returns 500 to Stripe.
        }

        STRP_SUB_Logger::log( '=== WEBHOOK ENDED: checkout.session.completed ===' );
    }

    /**
     * Propagate session metadata to the linked Stripe subscription, customer, and invoice.
     *
     * Returns an associative array of the metadata key→value pairs that were extracted,
     * including `affiliate_ref` when present.
     *
     * @param  object $session  Stripe Checkout\Session object.
     * @return array            Metadata key→value map (empty array when no metadata).
     */
    private function process_metadata_propagation( $session ) {
        $metadata_array = array();
        $affiliate_ref  = '';

        if ( ! isset( $session->metadata ) ) {
            STRP_SUB_Logger::log( 'No metadata found in session' );
            return $metadata_array;
        }

        foreach ( $session->metadata as $key => $value ) {
            $metadata_array[ $key ] = $value;
            if ( $key === 'affiliate_ref' ) {
                $affiliate_ref = sanitize_text_field( $value );
                STRP_SUB_Logger::log( 'Affiliate ref extracted: ' . $affiliate_ref );
            }
        }

        STRP_SUB_Logger::log( 'Session metadata: ' . wp_json_encode( $metadata_array ) );

        if ( empty( $metadata_array ) ) {
            return $metadata_array;
        }

        // Propagate to subscription.
        if ( ! empty( $session->subscription ) ) {
            try {
                \STRP_SUB_Vendor\Stripe\Subscription::update( $session->subscription, array( 'metadata' => $metadata_array ) );
                STRP_SUB_Logger::log( 'Metadata copied to subscription ' . $session->subscription );
            } catch ( Exception $e ) {
                STRP_SUB_Logger::error( 'Failed to copy metadata to subscription: ' . $e->getMessage() );
            }
        }

        // Propagate to customer.
        if ( ! empty( $session->customer ) ) {
            try {
                $customer        = \STRP_SUB_Vendor\Stripe\Customer::retrieve( $session->customer );
                $merged_metadata = array_merge( (array) $customer->metadata, $metadata_array );
                \STRP_SUB_Vendor\Stripe\Customer::update( $session->customer, array( 'metadata' => $merged_metadata ) );
                STRP_SUB_Logger::log( 'Metadata copied to customer ' . $session->customer );
            } catch ( Exception $e ) {
                STRP_SUB_Logger::error( 'Failed to copy metadata to customer: ' . $e->getMessage() );
            }
        }

        // Resolve invoice ID and propagate there too.
        $invoice_id = null;
        if ( ! empty( $session->invoice ) ) {
            $invoice_id = $session->invoice;
        } elseif ( ! empty( $session->subscription ) ) {
            try {
                $stripe_api   = STRP_SUB_Stripe_API::get_instance();
                $subscription = $stripe_api->get_subscription( $session->subscription );
                if ( $subscription && ! empty( $subscription->latest_invoice ) ) {
                    $invoice_id = $subscription->latest_invoice;
                }
            } catch ( Exception $e ) {
                STRP_SUB_Logger::error( 'Failed to retrieve subscription for invoice propagation: ' . $e->getMessage() );
            }
        }

        if ( $invoice_id ) {
            try {
                \STRP_SUB_Vendor\Stripe\Invoice::update( $invoice_id, array( 'metadata' => $metadata_array ) );
                STRP_SUB_Logger::log( 'Metadata copied to invoice ' . $invoice_id );
            } catch ( Exception $e ) {
                STRP_SUB_Logger::error( 'Failed to copy metadata to invoice: ' . $e->getMessage() );
            }
        }

        $metadata_array['affiliate_ref'] = $affiliate_ref; // ensure key always present
        return $metadata_array;
    }

    /**
     * Resolve (or create) a WordPress user for a guest checkout session.
     *
     * Uses a MySQL GET_LOCK() advisory lock so concurrent webhook deliveries
     * for the same email do not race to create duplicate accounts.
     *
     * @param  object $session  Stripe Checkout\Session object.
     * @return array            Two-element array: [ int $user_id, bool $is_new_user ].
     *                          $user_id is 0 when no guest processing is needed (logged-in checkout).
     */
    private function handle_guest_user_creation( $session ) {
        global $wpdb;

        // If wc_user_id was embedded in metadata the customer was already logged in.
        $user_id = isset( $session->metadata->wc_user_id ) ? intval( $session->metadata->wc_user_id ) : 0;
        if ( $user_id > 0 ) {
            if ( ! empty( $session->customer ) ) {
                update_user_meta( $user_id, '_strp_sub_stripe_customer_id', $session->customer );
            }
            return array( $user_id, false );
        }

        if ( empty( $session->customer_details->email ) ) {
            STRP_SUB_Logger::error( 'Session ' . $session->id . ' has no customer_details email — cannot create user.' );
            return array( 0, false );
        }

        $email       = $session->customer_details->email;
        $is_new_user = false;
        $lock_name   = 'strp_sub_user_' . md5( strtolower( $email ) );

        // Acquire a DB-level advisory lock (5-second timeout).
        // Unlike transients, GET_LOCK is truly atomic even under Redis/Memcached.
        $lock_acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 5)', $lock_name ) );

        try {
            $user = get_user_by( 'email', $email );

            if ( ! $user ) {
                $user_id     = $this->create_user_from_session( $session );
                $is_new_user = true;
                $masked_email = substr( $email, 0, 1 ) . '***@' . substr( strrchr( $email, '@' ), 1 );
                STRP_SUB_Logger::log( 'Created new user #' . $user_id . ' for ' . $masked_email );
            } else {
                $user_id = $user->ID;
                $masked_email = substr( $email, 0, 1 ) . '***@' . substr( strrchr( $email, '@' ), 1 );
                STRP_SUB_Logger::log( 'Using existing user #' . $user_id . ' for ' . $masked_email );
            }
        } finally {
            if ( $lock_acquired ) {
                $wpdb->get_var( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
            }
        }

        if ( $user_id > 0 && ! empty( $session->customer ) ) {
            update_user_meta( $user_id, '_strp_sub_stripe_customer_id', $session->customer );
        }

        return array( $user_id, $is_new_user );
    }

    /**
     * Check for duplicate-trial fraud using the card fingerprint, and mark trial usage.
     *
     * When the same card fingerprint has been used for a prior trial, the subscription
     * is cancelled immediately and the user is flagged. Otherwise the user is recorded
     * as having consumed their trial so they cannot get another one.
     *
     * @param int    $user_id         WordPress user ID.
     * @param string $subscription_id Stripe subscription ID.
     * @param \WC_Order|null $order   WooCommerce order object created from session.
     */
    private function process_trial_fraud_check( $user_id, $subscription_id, $order = null ) {
        $stripe_api       = STRP_SUB_Stripe_API::get_instance();
        $has_used_trial   = get_user_meta( $user_id, '_strp_sub_has_used_trial', true );
        $card_fingerprint = $stripe_api->get_card_fingerprint_from_subscription( $subscription_id );

        if ( ! $card_fingerprint ) {
            STRP_SUB_Logger::log( 'Could not retrieve card fingerprint for subscription ' . $subscription_id );
            if ( $has_used_trial !== 'yes' ) {
                update_user_meta( $user_id, '_strp_sub_has_used_trial', 'yes' );
                update_user_meta( $user_id, '_strp_sub_trial_start_date', time() );
            }
            return;
        }

        if ( $stripe_api->has_fingerprint_used_trial( $card_fingerprint ) ) {
            // Duplicate card — cancel and flag.
            STRP_SUB_Logger::error( 'FRAUD ALERT: Card fingerprint already used for trial. User #' . $user_id );

            update_user_meta( $user_id, '_strp_sub_has_used_trial', 'yes' );
            update_user_meta( $user_id, '_strp_sub_trial_start_date', time() );
            update_user_meta( $user_id, '_strp_sub_fraud_flag_duplicate_card', 'yes' );
            update_user_meta( $user_id, '_strp_sub_fraud_block_reason', 'duplicate_card_trial' );

            $cancelled = $stripe_api->cancel_subscription_for_fraud( $subscription_id );
            if ( $cancelled ) {
                update_user_meta( $user_id, '_strp_sub_subscription_status', 'cancelled' );
                $this->send_fraud_block_email( $user_id );
                if ( $order && is_a( $order, 'WC_Order' ) ) {
                    $order->update_status( 'cancelled', __( 'Fraud detected: Duplicate trial card.', 'subscription-manager-for-stripe' ) );
                }
                STRP_SUB_Logger::log( 'Cancelled subscription for duplicate-card fraud. User #' . $user_id );
            } else {
                STRP_SUB_Logger::error( 'Failed to cancel fraudulent subscription. Trial continues but user is flagged.' );
            }
        } else {
            // First use of this card for a trial.
            if ( $has_used_trial !== 'yes' ) {
                update_user_meta( $user_id, '_strp_sub_has_used_trial', 'yes' );
                update_user_meta( $user_id, '_strp_sub_trial_start_date', time() );
                STRP_SUB_Logger::log( 'Marked user #' . $user_id . ' as having used trial.' );
            } else {
                STRP_SUB_Logger::error( 'WARNING: User #' . $user_id . ' received a trial but already had one. This should not happen!' );
            }
            $stripe_api->store_trial_fingerprint( $card_fingerprint, $user_id );
        }
    }
    /**
     * Create user from Stripe session and send welcome email.
     *
     * @param object $session Stripe Checkout\Session object.
     * @return int User ID on success, 0 on failure.
     */
    private function create_user_from_session( $session ) {
        $email = $session->customer_details->email ?? '';
        $name  = $session->customer_details->name ?? '';
        
        // Generate username from email prefix, then make it unique.
        $username      = sanitize_user( current( explode( '@', $email ) ) );
        $base_username = $username;

        // Append a random numeric suffix if the base username exists.
        // Produces clean usernames like "john_1042" instead of "john_7xkP9".
        if ( username_exists( $username ) ) {
            $suffix   = wp_rand( 1000, 9999 );
            $username = $base_username . '_' . $suffix;

            // In the unlikely event of a collision, increment up to 10 times
            // then fall back to a unique ID to guarantee termination.
            $attempts = 0;
            while ( username_exists( $username ) ) {
                $attempts++;
                if ( $attempts >= 10 ) {
                    $username = $base_username . '_' . uniqid();
                    break;
                }
                $suffix++;
                $username = $base_username . '_' . $suffix;
            }
        }

        /**
         * Filter the generated username before the WordPress account is created.
         *
         * Allows agencies and developers to customise username generation —
         * for example, using first.last instead of the email prefix.
         *
         * @param string $username The sanitized, unique username derived from the email.
         * @param string $email    The customer's email address.
         * @param object $session  The Stripe Checkout\Session object.
         */
        $username = apply_filters( 'strp_sub_generated_username', $username, $email, $session );
        // Re-sanitize after filtering to catch bad output from third-party callbacks.
        $username = sanitize_user( $username, true );

        // Create user with random password (customer will set their own via email link).
        $password = wp_generate_password( 12, true, false );
        $user_id  = wp_create_user( $username, $password, $email );
        
        if ( is_wp_error( $user_id ) ) {
            STRP_SUB_Logger::error( 'Could not create user: ' . $user_id->get_error_message() );
            return 0;
        }
        
        // Build user data — split display_name into first/last if a full name is available.
        $user_data = array(
            'ID'           => $user_id,
            'display_name' => $name ?: $username,
            'first_name'   => '',
            'last_name'    => '',
        );
        if ( $name ) {
            $name_parts              = explode( ' ', $name, 2 );
            $user_data['first_name'] = $name_parts[0];
            if ( isset( $name_parts[1] ) ) {
                $user_data['last_name'] = $name_parts[1];
            }
        }

        /**
         * Filter the new WP user data array before the account is updated.
         *
         * Allows developers to set additional fields (e.g. locale, description)
         * without modifying core plugin files.
         *
         * @param array  $user_data Associative array of WP_User fields.
         * @param object $session   The Stripe Checkout\Session object.
         */
        $user_data = apply_filters( 'strp_sub_new_user_data', $user_data, $session );

        wp_update_user( $user_data );

        // Set user role — respect WooCommerce's configurable default, falling back
        // to the WordPress site default, then finally 'customer' as the last resort.
        $default_role = get_option( 'woocommerce_default_customer_role', get_option( 'default_role', 'customer' ) );

        /**
         * Filter the role assigned to a newly created customer.
         *
         * Useful for membership sites or enterprise stores that use a custom role.
         *
         * @param string $default_role The resolved role slug.
         * @param object $session      The Stripe Checkout\Session object.
         */
        $default_role = apply_filters( 'strp_sub_new_customer_role', $default_role, $session );

        $user = new WP_User( $user_id );
        $user->set_role( sanitize_key( $default_role ) );
        
        // Mark as new user needing first password
        update_user_meta( $user_id, '_strp_sub_new_user_needs_password', 'yes' );
        
        // Update billing details from Stripe.
        if ( ! empty( $session->customer_details->address ) ) {
            $address = $session->customer_details->address;
            update_user_meta( $user_id, 'billing_email', $email );
            if ( $name ) {
                update_user_meta( $user_id, 'billing_first_name', $user_data['first_name'] );
                update_user_meta( $user_id, 'billing_last_name', $user_data['last_name'] );
            }
            if ( ! empty( $address->city ) ) {
                update_user_meta( $user_id, 'billing_city', $address->city );
            }
            if ( ! empty( $address->country ) ) {
                update_user_meta( $user_id, 'billing_country', $address->country );
            }
            if ( ! empty( $address->line1 ) ) {
                update_user_meta( $user_id, 'billing_address_1', $address->line1 );
            }
            if ( ! empty( $address->line2 ) ) {
                update_user_meta( $user_id, 'billing_address_2', $address->line2 );
            }
            if ( ! empty( $address->postal_code ) ) {
                update_user_meta( $user_id, 'billing_postcode', $address->postal_code );
            }
            if ( ! empty( $address->state ) ) {
                update_user_meta( $user_id, 'billing_state', $address->state );
            }
        }

        // Send welcome email with password setup link.
        $user      = get_user_by( 'id', $user_id );
        $reset_key = get_password_reset_key( $user );
        if ( ! is_wp_error( $reset_key ) ) {
            // Use the standard WordPress password-reset URL — portable across all setups.
            $reset_url = network_site_url(
                'wp-login.php?action=rp&key=' . rawurlencode( $reset_key )
                . '&login=' . rawurlencode( $user->user_login ),
                'login'
            );

            $site_name = get_bloginfo( 'name' );
            $user_name = $user->first_name ?: $user->display_name;

            /* translators: %s: site name */
            $subject = sprintf( __( '[%s] Your account is ready — set your password', 'subscription-manager-for-stripe' ), $site_name );

            // Use WooCommerce templating system to render the email body,
            // allowing themes to override via `their-theme/stripe-sub-manager/emails/customer-welcome.php`.
            if ( function_exists( 'wc_get_template_html' ) ) {
                $body = wc_get_template_html(
                    'emails/customer-welcome.php',
                    array(
                        'user_name' => $user_name,
                        'site_name' => $site_name,
                        'reset_url' => $reset_url,
                    ),
                    'stripe-sub-manager/',
                    STRP_SUB_PLUGIN_DIR . 'templates/'
                );
            } else {
                // Fallback rendering if WooCommerce is inexplicably missing despite dependencies
                ob_start();
                include STRP_SUB_PLUGIN_DIR . 'templates/emails/customer-welcome.php';
                $body = ob_get_clean();
            }

            if ( function_exists( 'as_enqueue_async_action' ) ) {
                as_enqueue_async_action( 'strp_sub_send_async_email', array( $email, $subject, $body ), 'strp_sub' );
            } else {
                $this->send_email( $email, $subject, $body );
            }
            STRP_SUB_Logger::log( 'Queued/sent welcome email to user #' . $user_id );

            // Send admin notification about new signup.
            $admin_email = get_option( 'admin_email' );
            /* translators: %s: site name */
            $admin_subject = sprintf( __( '[%s] New customer signed up', 'subscription-manager-for-stripe' ), $site_name );

            $admin_body  = '<p>' . __( 'A new customer just signed up via Stripe checkout.', 'subscription-manager-for-stripe' ) . '</p>';
            $admin_body .= '<p><strong>' . __( 'Name:', 'subscription-manager-for-stripe' ) . '</strong> '
                         . esc_html( $name ?: __( 'Not provided', 'subscription-manager-for-stripe' ) ) . '</p>';
            $admin_body .= '<p><strong>' . __( 'Email:', 'subscription-manager-for-stripe' ) . '</strong> '
                         . esc_html( $email ) . '</p>';
            $admin_body .= '<p><strong>' . __( 'Username:', 'subscription-manager-for-stripe' ) . '</strong> '
                         . esc_html( $username ) . '</p>';
            $admin_body .= '<p><strong>' . __( 'User ID:', 'subscription-manager-for-stripe' ) . '</strong> '
                         . (int) $user_id . '</p>';

            if ( function_exists( 'as_enqueue_async_action' ) ) {
                as_enqueue_async_action( 'strp_sub_send_async_email', array( $admin_email, $admin_subject, $admin_body ), 'strp_sub' );
            } else {
                $this->send_email( $admin_email, $admin_subject, $admin_body );
            }
            STRP_SUB_Logger::log( 'Queued/sent admin notification email for new user #' . $user_id );
        }

        return $user_id;
    }
    
    /**
     * Handle invoice.created event
     * NOW HANDLES: Trial conversion email (sent immediately when trial ends, before SEPA payment clears)
     */
    public function handle_invoice_created( $invoice ) {
        STRP_SUB_Logger::log( '=== WEBHOOK STARTED: invoice.created ===' );
        STRP_SUB_Logger::log( 'Invoice ID: ' . $invoice->id );
        STRP_SUB_Logger::log( 'Billing reason: ' . ($invoice->billing_reason ?? 'unknown') );

        if ( ! isset( $invoice->subscription ) || empty( $invoice->subscription ) ) {
            STRP_SUB_Logger::log( 'No subscription found for invoice - skipping' );
            return;
        }

        try {
            $stripe_api = STRP_SUB_Stripe_API::get_instance();
            $subscription = $stripe_api->get_subscription( $invoice->subscription );

            if ( ! $subscription ) {
                STRP_SUB_Logger::error( 'Failed to retrieve subscription: ' . $invoice->subscription );
                return;
            }

            // Use toArray() to get clean metadata array
            $sub_metadata = $subscription->metadata->toArray();
            STRP_SUB_Logger::log( 'Subscription metadata: ' . wp_json_encode( $sub_metadata ) );

            if ( ! empty( $sub_metadata ) ) {
                // Update Invoice metadata
                \STRP_SUB_Vendor\Stripe\Invoice::update(
                    $invoice->id,
                    array( 'metadata' => $sub_metadata )
                );

                STRP_SUB_Logger::log( '[OK] Metadata copied from subscription to invoice' );

                if ( isset( $sub_metadata['affiliate_ref'] ) ) {
                    STRP_SUB_Logger::log( '[OK] Affiliate ref copied to invoice: ' . $sub_metadata['affiliate_ref'] );
                }

                // Propagate to Payment Intent if exists
                if ( isset( $invoice->payment_intent ) && ! empty( $invoice->payment_intent ) ) {
                    STRP_SUB_Logger::log( 'Propagating metadata to payment intent: ' . $invoice->payment_intent );

                    \STRP_SUB_Vendor\Stripe\PaymentIntent::update(
                        $invoice->payment_intent,
                        array( 'metadata' => $sub_metadata )
                    );

                    STRP_SUB_Logger::log( '[OK] Metadata copied to payment intent' );

                    if ( isset( $sub_metadata['affiliate_ref'] ) ) {
                        STRP_SUB_Logger::log( '[OK] Affiliate ref copied to payment intent: ' . $sub_metadata['affiliate_ref'] );
                    }
                } else {
                    STRP_SUB_Logger::log( 'No payment intent found on invoice yet' );
                }
            } else {
                STRP_SUB_Logger::log( 'No metadata on subscription to copy' );
            }

            // Handle trial conversion on invoice.created (Day 2) instead of invoice.paid (Day 7)
            // This ensures users get confirmation email when trial ends, not 5 days later when SEPA clears
            $subscription_id = $invoice->subscription;
            $order = $this->get_order_by_subscription_id( $subscription_id );

            if ( $order ) {
                global $wpdb;
                $lock_key = 'strp_sub_trial_conv_' . $order->get_id();
                $lock_acquired = $wpdb->get_var( $wpdb->prepare( 'SELECT GET_LOCK(%s, 2)', $lock_key ) );

                if ( '1' !== $lock_acquired ) {
                    STRP_SUB_Logger::warning( 'Could not acquire advisory lock for trial conversion: ' . $lock_key );
                    return;
                }

                try {
                    $affiliate_payment_status = $order->get_meta( '_affiliate_payment_status' );
                    $trial_email_sent = $order->get_meta( '_strp_sub_trial_email_sent' );

                    // "Trial conversion" refers to a Stripe subscription trial converting to a paid
                    // subscription (Stripe's native billing feature), not a plugin trial.
                    // Only send trial conversion email when:
                    // 1. Order has affiliate_payment_status = 'trial' (meaning it started as a Stripe trial)
                    // 2. This is NOT the initial subscription_create invoice (skip $0 invoices)
                    // 3. Email hasn't been sent yet (prevent duplicates)
                    $is_trial_conversion = (
                        $affiliate_payment_status === 'trial' &&
                        $invoice->billing_reason !== 'subscription_create' &&
                        $trial_email_sent !== 'yes'
                    );

                    if ( $is_trial_conversion ) {
                        // Check unified idempotency flag
                        $conversion_processed = $order->get_meta( '_strp_sub_trial_conversion_processed' );
                        if ( $conversion_processed === 'yes' ) {
                            STRP_SUB_Logger::log( '[OK] Trial conversion already processed for order #' . $order->get_id() . ' - skipping' );
                        } else {
                            STRP_SUB_Logger::log( '[TRIAL-CONVERSION] DETECTED on invoice.created: Order #' . $order->get_id() );
                            STRP_SUB_Logger::log( '   Billing reason: ' . $invoice->billing_reason );
                            STRP_SUB_Logger::log( '   Invoice amount: ' . $invoice->amount_due );

                            $user_id = $order->get_customer_id();

                            if ( $user_id > 0 ) {
                                // Set subscription status to active (trial has ended)
                                update_user_meta( $user_id, '_strp_sub_subscription_status', 'active' );
                                STRP_SUB_Logger::log( '[OK] Set subscription status to active' );

                                // Trigger affiliate commission conversion via hook (affiliate plugin listens).
                                STRP_SUB_Logger::log( '[TRIAL-CONVERSION] Triggering affiliate commission conversion' );
                                do_action( 'strp_sub_trial_converted_to_paid', $user_id );

                                // Send trial conversion email (SEPA payment is processing, not yet cleared)
                                STRP_SUB_Logger::log( '[EMAIL] Sending trial conversion email (payment processing)' );
                                $this->send_trial_conversion_email_from_invoice( $user_id, $invoice );

                                // Mark that we've sent the trial conversion email AND update payment status
                                $order->update_meta_data( '_strp_sub_trial_email_sent', 'yes' );
                                $order->update_meta_data( '_affiliate_payment_status', 'paid' );
                                $order->update_meta_data( '_strp_sub_trial_conversion_processed', 'yes' );
                                $order->add_order_note( __( 'Trial ended. Conversion email sent. Payment processing (SEPA 3-5 days).', 'subscription-manager-for-stripe' ) );
                                $order->save();

                                STRP_SUB_Logger::log( '[OK] Updated order payment status from trial to paid' );
                            }
                        }
                    } else {
                        // Log why trial conversion email was NOT sent
                        if ( $affiliate_payment_status === 'trial' ) {
                            if ( $invoice->billing_reason === 'subscription_create' ) {
                                STRP_SUB_Logger::log( '[SKIP] Skipping trial conversion email - initial subscription creation invoice (billing_reason = subscription_create)' );
                            } elseif ( $trial_email_sent === 'yes' ) {
                                STRP_SUB_Logger::log( '[SKIP] Skipping trial conversion email - already sent for order #' . $order->get_id() );
                            }
                        }
                    }
                } finally {
                    $wpdb->query( $wpdb->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_key ) );
                }
            }

        } catch ( Exception $e ) {
            STRP_SUB_Logger::error( 'Error handling invoice.created: ' . $e->getMessage() );
            // Re-throw so the outer handler queues this for retry — trial conversion failures are critical.
            throw $e;
        }

        STRP_SUB_Logger::log( '=== WEBHOOK ENDED: invoice.created ===' );
    }
    
    /**
     * Handle invoice.paid event (renewals)
     * ENHANCED: Now sends renewal confirmation email
     * FIX: Detects and handles trial-to-paid conversions
     */
    public function handle_invoice_paid( $invoice ) {
        STRP_SUB_Logger::log( 'Processing invoice.paid: ' . $invoice->id );

        // Skip if this is the first invoice (already handled by checkout.session.completed)
        if ( isset( $invoice->billing_reason ) && $invoice->billing_reason === 'subscription_create' ) {
            STRP_SUB_Logger::log( 'Skipping initial subscription invoice' );
            return;
        }

        // Get subscription
        if ( ! isset( $invoice->subscription ) || empty( $invoice->subscription ) ) {
            STRP_SUB_Logger::log( 'No subscription found in invoice' );
            return;
        }

        $subscription_id = $invoice->subscription;

        // Find order with this subscription
        $order = $this->get_order_by_subscription_id( $subscription_id );

        if ( ! $order ) {
            STRP_SUB_Logger::error( 'No order found for subscription: ' . $subscription_id );
            return;
        }

        // Check if this is the first payment after trial (trial conversion)
        $affiliate_payment_status = $order->get_meta( '_affiliate_payment_status' );
        $trial_email_sent = $order->get_meta( '_strp_sub_trial_email_sent' );
        $is_trial_conversion = ( $affiliate_payment_status === 'trial' );

        if ( $is_trial_conversion ) {
            STRP_SUB_Logger::log( '[TRIAL-CONVERSION] PAYMENT: First payment received for order #' . $order->get_id() );

            // Update order payment date to NOW (when payment actually cleared)
            $order->set_date_paid( time() );
            $order->add_order_note( __( 'Trial conversion payment received (SEPA cleared).', 'subscription-manager-for-stripe' ) );
            $order->save();
            STRP_SUB_Logger::log( '[OK] Updated order date_paid to reflect actual payment clearance' );

            // Check if trial conversion was already fully processed
            $conversion_processed = $order->get_meta( '_strp_sub_trial_conversion_processed' );
            if ( $conversion_processed === 'yes' ) {
                STRP_SUB_Logger::log( '[OK] Trial conversion already fully processed for order #' . $order->get_id() . ' - skipping' );
            } elseif ( $trial_email_sent === 'yes' ) {
                STRP_SUB_Logger::log( '[OK] Trial conversion email was already sent on invoice.created - skipping duplicate' );
                $order->update_meta_data( '_strp_sub_trial_conversion_processed', 'yes' );
                $order->save();
            } else {
                // Fallback: Send email now if it wasn't sent before (shouldn't happen with new flow)
                STRP_SUB_Logger::log( '[WARNING] Trial conversion email NOT sent yet - sending now (fallback)' );
                $user_id = $order->get_customer_id();
                if ( $user_id > 0 ) {
                    update_user_meta( $user_id, '_strp_sub_subscription_status', 'active' );
                    do_action( 'strp_sub_trial_converted_to_paid', $user_id );
                    $this->send_trial_conversion_email_from_invoice( $user_id, $invoice );
                    $order->update_meta_data( '_strp_sub_trial_conversion_processed', 'yes' );
                    $order->save();
                }
            }
        } else {
            // Regular renewal
            $order->add_order_note( sprintf(
                __( 'Subscription renewed. Invoice: %s', 'subscription-manager-for-stripe' ),
                $invoice->id
            ) );
        }

        // Get user ID for processing
        $user_id = $order->get_customer_id();

        // Update subscription metadata (optional - proceed even if this fails)
        $stripe_api = STRP_SUB_Stripe_API::get_instance();
        $subscription = $stripe_api->get_subscription( $subscription_id );

        if ( $subscription ) {
            $order->update_meta_data( '_strp_sub_current_period_end', $subscription->current_period_end );
            $order->update_meta_data( '_strp_sub_last_renewal_date', time() );
            $order->update_meta_data( '_strp_sub_last_invoice_id', $invoice->id );
            $order->save();

            // Update user subscription status for renewals (not trials - already handled above)
            if ( $user_id > 0 && ! $is_trial_conversion ) {
                update_user_meta( $user_id, '_strp_sub_subscription_status', $subscription->status );

                // Send renewal confirmation email
                $this->send_renewal_confirmation_email( $user_id, $subscription, $invoice );
            }
        } else {
            STRP_SUB_Logger::warning( 'Could not retrieve subscription object from Stripe API for subscription: ' . $subscription_id );
        }

        STRP_SUB_Logger::log( 'Successfully processed ' . ( $is_trial_conversion ? 'trial conversion' : 'renewal' ) . ' for order #' . $order->get_id() );
    }
    
    /**
     * Send renewal confirmation email (automatic renewals).
     */
    private function send_renewal_confirmation_email( $user_id, $subscription, $invoice ) {
        do_action( 'strp_sub_send_renewal_email', $user_id, $subscription, $invoice );
        STRP_SUB_Logger::log( 'Dispatched renewal email for user #' . $user_id );
    }

    /**
     * Send trial conversion email using only invoice data.
     * Sent on invoice.created so the user is notified immediately when the trial ends,
     * before the SEPA payment clears (which can take 3–5 business days).
     */
    private function send_trial_conversion_email_from_invoice( $user_id, $invoice ) {
        STRP_SUB_Logger::log( '[EMAIL] Dispatching trial conversion email for user #' . $user_id );
        // Pass null for subscription — STRP_SUB_Email_Trial_Converted falls back to invoice line data
        do_action( 'strp_sub_send_trial_converted_email', $user_id, null, $invoice );
    }

    /**
     * Send trial conversion email (trial to paid conversion).
     * Legacy version requiring subscription object — prefer send_trial_conversion_email_from_invoice().
     */
    private function send_trial_conversion_email( $user_id, $subscription, $invoice ) {
        STRP_SUB_Logger::log( '[EMAIL] Dispatching trial conversion email for user #' . $user_id );
        do_action( 'strp_sub_send_trial_converted_email', $user_id, $subscription, $invoice );
    }

    /**
     * Send welcome back email for manual renewals.
     */
    private function send_renewal_welcome_email( $user_id, $subscription ) {
        // Renewal-welcome and renewal-confirmation are both handled by STRP_SUB_Email_Renewal.
        // Re-use the renewal email with a null invoice (heading already conveys context).
        do_action( 'strp_sub_send_renewal_email', $user_id, $subscription, (object) array(
            'amount_paid' => 0,
            'currency'    => function_exists( 'get_woocommerce_currency' ) ? strtolower( get_woocommerce_currency() ) : 'usd',
        ) );
        STRP_SUB_Logger::log( 'Dispatched renewal welcome email for user #' . $user_id );
    }

    /**
     * Send welcome email for new subscriptions (including trial subscriptions).
     */
    private function send_new_subscription_welcome_email( $user_id, $subscription ) {
        do_action( 'strp_sub_send_subscription_welcome_email', $user_id, $subscription );
        STRP_SUB_Logger::log( 'Dispatched subscription welcome email for user #' . $user_id );
    }

    /**
     * Handle invoice.payment_failed event
     * UPDATED: Now revokes access by setting subscription status to 'past_due'
     */
    public function handle_invoice_payment_failed( $invoice ) {
        STRP_SUB_Logger::log( 'Processing invoice.payment_failed: ' . $invoice->id );

        if ( ! isset( $invoice->subscription ) || empty( $invoice->subscription )
        ) {
            return;
        }

        $subscription_id = $invoice->subscription;

        // Find order with this subscription
        $order = $this->get_order_by_subscription_id( $subscription_id );

        if ( ! $order ) {
            STRP_SUB_Logger::error( 'No order found for subscription: ' . $subscription_id );
            return;
        }

        // Update order status to on-hold
        $order->update_status( 'on-hold', sprintf(
            __( 'Subscription payment failed. Invoice: %s', 'subscription-manager-for-stripe' ),
            $invoice->id
        ) );

        // CRITICAL: Revoke access by updating subscription status to 'past_due'
        $user_id = $order->get_customer_id();
        if ( $user_id > 0 ) {
            update_user_meta( $user_id, '_strp_sub_subscription_status', 'past_due' );
            STRP_SUB_Logger::log( '[ACCESS-REVOKED] Revoked access for user #' . $user_id . ' - status set to past_due' );

            // Send payment failure email to customer
            $this->send_payment_failure_email( $user_id, $invoice, $order );
        }

        // Add order note
        $order->add_order_note( __( 'Payment failed. Access revoked. Customer has been notified.', 'subscription-manager-for-stripe' ), true );

        STRP_SUB_Logger::log( 'Marked order #' . $order->get_id() . ' as on-hold and revoked user access due to payment failure' );
    }

    /**
     * Send payment failure email to customer.
     */
    private function send_payment_failure_email( $user_id, $invoice, $order ) {
        do_action( 'strp_sub_send_payment_failed_email', $user_id, $invoice, $order );
        STRP_SUB_Logger::log( 'Dispatched payment failure email for user #' . $user_id );
    }

    /**
     * Send fraud block notification email.
     * Sent when a duplicate card fingerprint is detected and the subscription is cancelled.
     *
     * @param int $user_id WordPress user ID.
     */
    private function send_fraud_block_email( $user_id ) {
        do_action( 'strp_sub_send_fraud_block_email', $user_id );
        STRP_SUB_Logger::log( 'Dispatched fraud block email for user #' . $user_id );
    }

    /**
     * Handle customer.subscription.deleted event
     */
    public function handle_subscription_deleted( $subscription, $event = null ) {
        STRP_SUB_Logger::log( 'Processing customer.subscription.deleted: ' . $subscription->id );

        // Find order with this subscription
        $order = $this->get_order_by_subscription_id( $subscription->id );

        if ( ! $order ) {
            STRP_SUB_Logger::error( 'No order found for subscription: ' . $subscription->id );
            return;
        }

        // Update order metadata
        $order->update_meta_data( '_strp_sub_subscription_status', 'cancelled' );
        $order->add_order_note( __( 'Subscription cancelled.', 'subscription-manager-for-stripe' ) );
        $order->save();

        // Update user meta — but preserve lifetime access
        $user_id = $order->get_customer_id();
        if ( $user_id > 0 ) {
            $has_lifetime = get_user_meta( $user_id, '_strp_sub_has_lifetime_access', true );
            if ( $has_lifetime === 'yes' ) {
                STRP_SUB_Logger::log( 'Skipping status update for user #' . $user_id . ' — user has lifetime access.' );
            } else {
                update_user_meta( $user_id, '_strp_sub_subscription_status', 'cancelled' );
                delete_user_meta( $user_id, '_strp_sub_active_subscription_id' );
            }
        }

        STRP_SUB_Logger::log( 'Cancelled subscription for order #' . $order->get_id() );

        // Check for retention flow metadata
        $retention_action = $subscription->metadata->strp_sub_retention_action ?? '';
        if ( ! empty( $retention_action ) ) {
            STRP_SUB_Logger::log( 'Subscription event triggered by retention flow: ' . $retention_action );
            do_action( 'strp_sub_retention_webhook', $event, $retention_action );
        }
    }
    
    /**
     * Handle customer.subscription.updated event
     * FIX: Ensures trial conversions are processed even if invoice.paid webhook fires first
     */
    public function handle_subscription_updated( $subscription, $event = null ) {
        STRP_SUB_Logger::log( 'Processing customer.subscription.updated: ' . $subscription->id );

        // Find order with this subscription
        $order = $this->get_order_by_subscription_id( $subscription->id );

        if ( ! $order ) {
            STRP_SUB_Logger::error( 'No order found for subscription: ' . $subscription->id );
            return;
        }

        // Update order metadata
        $order->update_meta_data( '_strp_sub_subscription_status', $subscription->status );
        $order->update_meta_data( '_strp_sub_current_period_end', $subscription->current_period_end );

        if ( isset( $subscription->cancel_at_period_end ) && $subscription->cancel_at_period_end ) {
            $order->add_order_note( __( 'Subscription set to cancel at period end.', 'subscription-manager-for-stripe' ) );
        }

        $order->save();

        // Update user meta — the strp_sub_trial_converted_to_paid action fires here,
        // which handles affiliate payment status update AND commission crediting.
        $user_id = $order->get_customer_id();
        if ( $user_id > 0 ) {
            // Preserve lifetime access — do not overwrite with subscription status changes
            $has_lifetime = get_user_meta( $user_id, '_strp_sub_has_lifetime_access', true );
            if ( $has_lifetime === 'yes' ) {
                STRP_SUB_Logger::log( 'Skipping subscription status update for user #' . $user_id . ' — user has lifetime access.' );
                return;
            }

            // Get the CURRENT status from database BEFORE updating
            $current_status = get_user_meta( $user_id, '_strp_sub_subscription_status', true );
            $affiliate_payment_status = $order->get_meta( '_affiliate_payment_status' );
            // Stripe subscription trial-to-paid conversion (Stripe billing status), not a plugin trial.
            $is_trial_conversion = ( $affiliate_payment_status === 'trial' && $subscription->status === 'active' );

            // Update the status
            update_user_meta( $user_id, '_strp_sub_subscription_status', $subscription->status );

            // Manually trigger conversion if needed
            // This handles two race condition scenarios:
            // 1. If invoice.paid arrived first and already updated status to 'active'
            // 2. If this webhook fires but update_user_meta doesn't trigger the hook (status unchanged)
            if ( $is_trial_conversion ) {
                // Check unified idempotency flag to prevent triple-firing across webhook handlers
                $conversion_processed = $order->get_meta( '_strp_sub_trial_conversion_processed' );
                if ( $conversion_processed === 'yes' ) {
                    STRP_SUB_Logger::log( '[OK] Trial conversion already processed for order #' . $order->get_id() . ' - skipping' );
                } else {
                    $affiliate_ref = $order->get_meta( '_affiliate_ref' );
                    STRP_SUB_Logger::log( sprintf( '[TRIAL-CONVERSION] Trial->Active conversion for order #%d with affiliate %s', $order->get_id(), $affiliate_ref ) );

                    // Always call conversion function directly to ensure it runs.
                    // The function has its own locking mechanism to prevent duplicate processing.
                    do_action( 'strp_sub_trial_converted_to_paid', $user_id );

                    $order->update_meta_data( '_strp_sub_trial_conversion_processed', 'yes' );
                    $order->save();
                }
            }
        }

        STRP_SUB_Logger::log( 'Updated subscription for order #' . $order->get_id() );

        // Check for retention flow metadata
        $retention_action = $subscription->metadata->strp_sub_retention_action ?? '';
        if ( ! empty( $retention_action ) ) {
            STRP_SUB_Logger::log( 'Subscription event triggered by retention flow: ' . $retention_action );
            do_action( 'strp_sub_retention_webhook', $event, $retention_action );
        }
    }

    /**
     * Get order by Stripe session ID (O(1) Indexed Lookup)
     */
    private function get_order_by_session_id( $session_id ) {
        global $wpdb;
        $order_id = $wpdb->get_var( $wpdb->prepare(
            "SELECT order_id FROM {$wpdb->prefix}strp_sub_order_index WHERE session_id = %s LIMIT 1",
            $session_id
        ) );
        if ( $order_id ) {
            $order = wc_get_order( $order_id );
            if ( $order ) {
                return $order;
            }
        }
        // Fallback: WooCommerce meta query when index is empty or missing.
        $orders = wc_get_orders( array(
            'limit'      => 1,
            'meta_query' => array( array(
                'key'   => '_strp_sub_stripe_session_id',
                'value' => $session_id,
            ) ),
        ) );
        return ! empty( $orders ) ? $orders[0] : null;
    }

    /**
     * Get order by Stripe subscription ID (O(1) Indexed Lookup)
     */
    private function get_order_by_subscription_id( $subscription_id ) {
        global $wpdb;
        $order_id = $wpdb->get_var( $wpdb->prepare(
            "SELECT order_id FROM {$wpdb->prefix}strp_sub_order_index WHERE subscription_id = %s LIMIT 1",
            $subscription_id
        ) );
        if ( $order_id ) {
            $order = wc_get_order( $order_id );
            if ( $order ) {
                return $order;
            }
        }
        // Fallback: WooCommerce meta query when index is empty or missing.
        $orders = wc_get_orders( array(
            'limit'      => 1,
            'meta_query' => array( array(
                'key'   => '_strp_sub_stripe_subscription_id',
                'value' => $subscription_id,
            ) ),
        ) );
        return ! empty( $orders ) ? $orders[0] : null;
    }

    // ── Refund & Dispute Handling ─────────────────────────────────────

    /**
     * Handle charge.refunded webhook event.
     *
     * Finds the WooCommerce order by Stripe charge/payment intent,
     * updates the order status to "refunded", and reverses any
     * affiliate commission from the affiliate's pending balance.
     *
     * @param object $charge The Stripe Charge object.
     */
    public function handle_charge_refunded( $charge ) {
        STRP_SUB_Logger::log( '=== WEBHOOK: charge.refunded ===' );
        STRP_SUB_Logger::log( 'Charge ID: ' . $charge->id );

        $order = $this->find_order_by_charge( $charge );
        if ( ! $order ) {
            STRP_SUB_Logger::log( 'No WooCommerce order found for refunded charge ' . $charge->id );
            return;
        }

        STRP_SUB_Logger::log( 'Found order #' . $order->get_id() . ' for refunded charge.' );

        if ( $order->get_status() === 'refunded' ) {
            STRP_SUB_Logger::log( 'Order #' . $order->get_id() . ' already refunded, skipping.' );
            return;
        }

        $order->update_status( 'refunded', __( 'Order refunded via Stripe webhook (charge.refunded).', 'subscription-manager-for-stripe' ) );

        $this->reverse_affiliate_commission( $order );
    }

    /**
     * Handle charge.dispute.closed webhook event.
     *
     * Only acts on disputes the merchant lost (status = "lost").
     * Updates the WooCommerce order to "refunded" and reverses
     * any affiliate commission.
     *
     * @param object $dispute The Stripe Dispute object.
     */
    public function handle_charge_dispute_closed( $dispute ) {
        STRP_SUB_Logger::log( '=== WEBHOOK: charge.dispute.closed ===' );
        STRP_SUB_Logger::log( 'Dispute ID: ' . $dispute->id . ' | Status: ' . $dispute->status );

        if ( $dispute->status !== 'lost' ) {
            STRP_SUB_Logger::log( 'Dispute not lost (status: ' . $dispute->status . '). No action taken.' );
            return;
        }

        // The dispute object contains the charge ID.
        $charge_id = $dispute->charge;
        if ( empty( $charge_id ) ) {
            STRP_SUB_Logger::error( 'Dispute has no charge ID.' );
            return;
        }

        // Retrieve the charge to find the associated order.
        try {
            $charge = \STRP_SUB_Vendor\Stripe\Charge::retrieve( $charge_id );
        } catch ( Exception $e ) {
            STRP_SUB_Logger::error( 'Could not retrieve charge for dispute: ' . $e->getMessage() );
            return;
        }

        $order = $this->find_order_by_charge( $charge );
        if ( ! $order ) {
            STRP_SUB_Logger::log( 'No WooCommerce order found for disputed charge ' . $charge_id );
            return;
        }

        STRP_SUB_Logger::log( 'Found order #' . $order->get_id() . ' for lost dispute.' );

        if ( $order->get_status() === 'refunded' ) {
            STRP_SUB_Logger::log( 'Order #' . $order->get_id() . ' already refunded, skipping.' );
            return;
        }

        $order->update_status( 'refunded', sprintf(
            /* translators: %s: Stripe dispute ID */
            __( 'Order refunded — Stripe dispute %s was lost.', 'subscription-manager-for-stripe' ),
            $dispute->id
        ) );

        $this->reverse_affiliate_commission( $order );
    }

    /**
     * Find a WooCommerce order from a Stripe Charge object.
     *
     * Tries multiple strategies:
     *  1. payment_intent metadata → order index lookup
     *  2. invoice → subscription → order index lookup
     *  3. Direct wc_get_orders meta query on _strp_sub_stripe_charge_id
     *
     * @param object $charge Stripe Charge object.
     * @return \WC_Order|null
     */
    private function find_order_by_charge( $charge ) {
        // 1. Try payment_intent — many checkout sessions store this.
        if ( ! empty( $charge->payment_intent ) ) {
            $orders = wc_get_orders( array(
                'limit'      => 1,
                'meta_query' => array( array(
                    'key'   => '_stripe_payment_intent_id',
                    'value' => $charge->payment_intent,
                ) ),
            ) );
            if ( ! empty( $orders ) ) {
                return $orders[0];
            }
        }

        // 2. Try invoice → subscription → order index.
        if ( ! empty( $charge->invoice ) ) {
            try {
                $invoice = \STRP_SUB_Vendor\Stripe\Invoice::retrieve( $charge->invoice );
                if ( ! empty( $invoice->subscription ) ) {
                    $order = $this->get_order_by_subscription_id( $invoice->subscription );
                    if ( $order ) {
                        return $order;
                    }
                }
            } catch ( Exception $e ) {
                STRP_SUB_Logger::debug( 'Could not retrieve invoice for charge lookup: ' . $e->getMessage() );
            }
        }

        // 3. Fallback: direct meta query on charge ID.
        $orders = wc_get_orders( array(
            'limit'      => 1,
            'meta_query' => array( array(
                'key'   => '_strp_sub_stripe_charge_id',
                'value' => $charge->id,
            ) ),
        ) );

        return ! empty( $orders ) ? $orders[0] : null;
    }

    /**
     * Reverse affiliate commission for a refunded/disputed order.
     *
     * Subtracts the commission from the affiliate's pending balance
     * and updates the order's affiliate payment status to "refunded".
     *
     * @param \WC_Order $order
     */
    private function reverse_affiliate_commission( $order ) {
        $affiliate_user_id = intval( $order->get_meta( '_affiliate_user_id' ) );
        $commission        = floatval( $order->get_meta( '_affiliate_commission' ) );
        $payment_status    = $order->get_meta( '_affiliate_payment_status' );

        if ( ! $affiliate_user_id || ! $commission ) {
            STRP_SUB_Logger::log( 'No affiliate commission on order #' . $order->get_id() . ', nothing to reverse.' );
            return;
        }

        if ( $payment_status === 'refunded' ) {
            STRP_SUB_Logger::log( 'Affiliate commission already reversed for order #' . $order->get_id() );
            return;
        }

        // Only subtract from pending if the commission was in "paid" status
        // (i.e., it was added to the affiliate's pending balance).
        if ( $payment_status === 'paid' ) {
            if ( class_exists( 'STRP_SUB_Affiliate_Stats' ) ) {
                // Subtract from pending balance (negative increment).
                STRP_SUB_Affiliate_Stats::increment_pending( $affiliate_user_id, -$commission );
                // Decrement the referral count.
                STRP_SUB_Affiliate_Stats::increment_referrals( $affiliate_user_id, -1 );
            }

            // Update global running totals atomically.
            if ( function_exists( 'strp_sub_atomic_option_increment' ) ) {
                strp_sub_atomic_option_increment( 'strp_sub_global_pending_total', -$commission, 2 );
                strp_sub_atomic_option_increment( 'strp_sub_global_referral_total', -1, 0 );
            }
        } elseif ( $payment_status === 'trial' ) {
            // Trial commission — just decrement trial referral count.
            if ( class_exists( 'STRP_SUB_Affiliate_Stats' ) ) {
                STRP_SUB_Affiliate_Stats::adjust_trial_referrals( $affiliate_user_id, -1 );
            }
        }

        $order->update_meta_data( '_affiliate_payment_status', 'refunded' );
        $currency_symbol = function_exists( 'get_woocommerce_currency_symbol' ) ? html_entity_decode( get_woocommerce_currency_symbol() ) : '$';
        $order->add_order_note( sprintf(
            /* translators: 1: formatted commission amount with currency, 2: affiliate user ID */
            __( 'Affiliate commission of %1$s reversed (refund/dispute). Affiliate user #%2$d balance updated.', 'subscription-manager-for-stripe' ),
            $currency_symbol . number_format( $commission, 2 ),
            $affiliate_user_id
        ) );
        $order->save();

        STRP_SUB_Logger::log( sprintf(
            'Reversed affiliate commission %s%.2f for user #%d on order #%d',
            $currency_symbol,
            $commission,
            $affiliate_user_id,
            $order->get_id()
        ) );
    }
}